Vibecoded CI/CD
Some checks failed
Build and Deploy Lean / build (push) Failing after 16m44s
Build and Deploy Nuxt / build (push) Failing after 48s

This commit is contained in:
2026-10-06 00:23:17 +02:00
parent a57770619e
commit e2c2c66d7f
79 changed files with 6044 additions and 47 deletions

View File

@@ -0,0 +1,53 @@
name: Build and Deploy Lean
on:
push:
branches: [master]
# This pipeline owns the lean editor image: heavy (it builds lean4web
# with Mathlib), so it runs only when lean/** actually changes
paths:
- "lean/**"
workflow_dispatch:
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SSH client
run: |
apt-get update -y && apt-get install -y openssh-client
- name: Setup SSH inside container
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Add the container host to known_hosts
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Log in to registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# Multi-stage: compilers/dev deps stay in the builder, the pushed image
# is the slim runtime (toolchain + client + Mathlib cache). Still a
# heavy build and a multi-GB push — expect it to take a while.
# Pin upstream with: docker build --build-arg LEAN4WEB_REF=v0.2.2 ...
- name: Build lean editor server
run: |
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest ./lean
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest
- name: Deploy
run: |
ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
cd /var/www/app/
docker compose pull lean
docker compose up -d lean
EOF

View File

@@ -3,6 +3,10 @@ name: Build and Deploy Nuxt
on:
push:
branches: [master]
# The Lean editor carries Mathlib and moves on its own schedule — its
# image is built and pushed by deploy-lean.yml, only when lean/** changes
paths-ignore:
- "lean/**"
jobs:
build:
@@ -39,16 +43,13 @@ jobs:
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest ./backend
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest
- name: Build lean backend
run: |
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest ./lean
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest
- name: Copy files
run: |
scp docker-compose.yml deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/
scp nginx.conf deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/nginx.conf
# `compose pull` also pulls the lean image from the registry —
# deploy-lean.yml is what keeps that tag current
- name: Deploy
run: |
ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
@@ -57,4 +58,3 @@ jobs:
docker compose pull
docker compose up -d --remove-orphans
EOF

View File

@@ -63,10 +63,17 @@ its theme (live, same page), and the Goals/Messages pane.
`lean/Dockerfile` still builds lean4web (upstream's real Lean + Mathlib per session;
`--build-arg LEAN4WEB_REF=<tag|branch>` pins it) and runs its websocket server on port 8080;
the compose `lean` service runs it. Upstream asks for a dedicated machine, and the image is
heavy (Mathlib). It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker
itself is the sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and
projects: `lean/README.md`.
the compose `lean` service runs it (image `git.aranroig.com/syndria98/mathew-lean:latest` —
CI builds and pushes it, and deploys lean, only when `lean/**` changes: `deploy-lean.yml`;
the regular deploy just pulls the tag). Upstream asks for a dedicated machine, so the build
is **multi-stage**: dev tooling stays in the builder, and the pushed image is a slim runtime
of node + elan toolchain + built client + Mathlib's compiled cache (~11GB — the cache is
the bulk; upstream's second demo project `Stable` is dropped, its extra ~3GB toolchain was
unused). `--build-arg BAKE_MATHLIB=false` ships Mathlib *out* of the image (~4GB) into a
`.lake` volume, filled once at first start by `lean/entrypoint.sh` — see lean/README.md.
It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker itself is the
sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and projects:
`lean/README.md`.
- `frontend/app/utils/lean/lsp.ts` — a minimal LSP JSON-RPC client over a websocket
(request/response pairing, notification dispatch, and auto-ack of the server→client
@@ -206,6 +213,24 @@ carry `<code>`/`<span>` markup are drawn with `v-html` through `th()`
(`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog
copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
- **The same bundle also ships as a desktop app** (Tauri 2, `src-tauri/`):
`npm run desktop:dev` / `desktop:build` / `desktop:generate` / `desktop:icons`
in `frontend/` (needs Rust and the WebKitGTK stack; the shell, the scripts and
the build prerequisites are `src-tauri/README.md`). `nuxt.config.ts` changes
shape on one flag — `MATHEW_DESKTOP=1`, implied too by the `TAURI_ENV_*` the
Tauri CLI passes to its hooks: `ssr: false`, no `/api` proxy route, the
pre-paint script from `public/preboot.js` instead of inline — and writes the
files to `dist-desktop/public` (never over the web `.output/`), which is what
`frontendDist` reads. Nothing in `app/` is desktop code: `utils/desktop.ts`
asks at run time whether it is in a window (`__TAURI_INTERNALS__`) and only
then does the window's thing — outward links leave through the `opener`
plugin (`plugins/desktop.ts`), and the session token and interface language
mirror into localStorage (`keepTokenRemembered`, since a webview is not owed
a cookie jar for its own `tauri://localhost`). A desktop build bakes the API
base to `https://mathew.aranroig.com` (`NUXT_PUBLIC_API_BASE` overrides, e.g.
`http://localhost:5000` to test against this machine) and leaves `leanUrl`
empty — a window on a random laptop has no `localhost:8080` to find.
- `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an
article — the address every version has, English included — drawn as a page of
the open book), `/wiki/[slug]` (an address naming no language: the middleware
@@ -425,5 +450,6 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
A tool page like this is wired in step: `ToolPage`/`TOOL_PAGE_PATHS`
(`utils/routes.ts`), the page file, `TOOL_LABEL_KEYS`, the `ToolPane` branch and spine
glyph, the sidebar link, and the `PAGE_TOOLS` entry.
- Frontend dev server may fall back to port 3001 if 3000 is taken.
- Frontend dev server may fall back to port 3001 if 3000 is taken — the desktop
shell's `devUrl` is fixed to 3000, so free the port before `npm run desktop:dev`.
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.

View File

@@ -23,12 +23,22 @@ services:
# The Lean editor (lean4web) — its own server, since it runs a real Lean
# process per session and asks for a dedicated machine (see lean/).
# CI builds and pushes this image only when lean/** changes.
lean:
build: ./lean
image: mathew-lean:latest
build:
context: ./lean
# To ship a ~4GB image that keeps Mathlib's cache in the volume below
# instead of inside itself (filled once at first start — see the
# "Keeping Mathlib out of the image" section of lean/README.md):
# args:
# BAKE_MATHLIB: "false"
image: git.aranroig.com/syndria98/mathew-lean:latest
ports:
- "8080:8080"
restart: always
# Only with BAKE_MATHLIB: "false" above:
# volumes:
# - lean-mathlib:/opt/lean4web/Projects/MathlibDemo/.lake
backend:
image: git.aranroig.com/syndria98/mathew-backend:latest
@@ -44,3 +54,8 @@ services:
# anyone could then forge sessions against the public site.
JWT_SECRET: ${JWT_SECRET:-}
restart: always
# Only with the lean volume mode (uncomment together with the lean build arg
# and the lean service's volumes entry):
# volumes:
# lean-mathlib:

3
frontend/.gitignore vendored
View File

@@ -6,6 +6,9 @@
.cache
dist
# The desktop bundle (nuxt generate in its desktop shape, read by src-tauri/)
dist-desktop
# Node dependencies
node_modules

View File

@@ -3,7 +3,10 @@
A session is a bearer token the API handed us, kept in a cookie rather than
localStorage so the server can see it too: the sidebar then knows who is
signed in on the very first draw, before any script runs. The token itself is
never decoded here — the API is asked who it belongs to (see restoreSession). */
never decoded here — the API is asked who it belongs to (see restoreSession).
In the desktop build the token is kept twice — see keepTokenRemembered, at
the foot of this file. */
export interface AuthUser {
id: string
@@ -34,6 +37,11 @@ export function useAuth() {
maxAge: WEEK_IN_SECONDS,
sameSite: 'lax',
})
/* A window is not owed a cookie jar: its own address is one a webview is not
everywhere obliged to store cookies against, and a session that could not
outlive the window would ask the person to sign in every time they opened
the app. A no-op anywhere else. */
keepTokenRemembered(token, TOKEN_COOKIE)
const user = useState<AuthUser | null>('auth:user', () => null)
const loggedIn = computed(() => user.value !== null)

View File

@@ -0,0 +1,65 @@
/* The window's own habits.
These are the two things the wiki does differently once it is a desktop app
rather than a page of the web — see utils/desktop.ts for the asking, and
src-tauri/ for the shell. The web build takes the first line and returns:
nothing here changes how a browser sees the wiki.
Runs after the auth plugin (plugins come in name order), which matters: by
now a signed-in person's account has already had its say about the interface
language, and an account outranks this device. */
import { isDesktop, leadsOutward, openExternal, storeGet, storeSet } from '~/utils/desktop'
/** Where a window keeps the interface language it was last shown. Also the
* name of the cookie the web build writes it to — the same choice, remembered
* the way this kind of host can be sure of remembering it. */
const LOCALE_KEY = 'mathew-locale'
export default defineNuxtPlugin(() => {
if (!import.meta.client || !isDesktop()) return
/* Links. The wiki's own addresses are relative and the router turns them as
it always does; an absolute one is about something outside this window —
and a window has one page, no tab strip, no back button a reader would
trust after the wiki had been replaced by some other site's document. So
those go out to the system's browser instead, and the window keeps
showing the wiki it was asked to show.
Captured on the way in, so it also catches the links drawn from article
Markdown — the rendered text belongs to the article, which has no reason
to know it is not in a browser. */
document.addEventListener(
'click',
(event) => {
if (event.defaultPrevented || event.button !== 0) return
// a modified click is the reader asking for the browser's own gesture
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
const target = event.target as Element | null
const href = target?.closest?.('a')?.getAttribute('href')
if (!href || !leadsOutward(href)) return
event.preventDefault()
void openExternal(href)
},
true,
)
/* The interface language, kept where a window can be certain of keeping it.
On the web the choice rides on the `mathew-locale` cookie, which the server
reads so the first draw already speaks it. A window draws nothing on a
server, and its own address — `tauri://localhost` — is one a webview is
not everywhere obliged to keep cookies for, so the choice is mirrored into
localStorage: a lost cookie costs a language switch at the next start, and
that is all.
An account's own choice outranks the mirror, as it outranks the cookie —
hence the guard, the auth plugin having run first. Whatever the UI ends up
speaking is then written back, so the mirror always says what this window
last showed. */
const { locale, choose } = useAppLocale()
const { user } = useAuth()
const remembered = storeGet(LOCALE_KEY)
if (remembered && !user.value?.locale) void choose(remembered)
watch(locale, (code) => storeSet(LOCALE_KEY, code))
})

View File

@@ -0,0 +1,116 @@
/* Running as a desktop app.
One bundle answers both a browser and an app window (Tauri — see
src-tauri/), so nothing in `app/` is desktop code: it is the wiki asking at
run time whether it happens to be sitting in a window. The answer comes from
the shell, which marks the page it loads and marks no other — so the same
dist-desktop/ files opened in an ordinary browser behave as a web page, and
a web page that somehow met Tauri would only gain the shell's habits.
What a window does differently, then, is small and all about being a window
with one page and no browser chrome: a link leads out to the system's own
browser rather than replacing the wiki in the only view there is, and the
few bytes the app remembers of a person live where a window can be certain
of keeping them. */
import type { Ref } from 'vue'
import { watch } from 'vue'
/** The handle every Tauri webview injects into its page — present before the
* npm API is imported, and without it. Nothing else sets this name. */
export function isDesktop(): boolean {
if (!import.meta.client) return false
return (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ != null
}
/* A window is expected to have localStorage. A private browser may throw on
being asked for it at all, and a setting that cannot be remembered is worth
far less than a page that still draws — so the three doors below are the
only place any of it is touched, and none of them lets a throw through. */
export function storeGet(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
export function storeSet(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
/* nothing remembered; nothing else depends on it */
}
}
export function storeClear(key: string): void {
try {
localStorage.removeItem(key)
} catch {
/* as good as cleared, for as long as this page lives */
}
}
/** Whether an address leads outside this app — to a document on some other
* site, or to a mail client, or to a phone dialler. The app's own pages are
* the relative `/wiki/…` the router already knows; an absolute address that
* happens to name the wiki's public site counts as outward too, and opens
* there in the browser, which is the honest reading of a link written out in
* full. */
export function leadsOutward(href: string): boolean {
if (!import.meta.client || !href) return false
let url: URL
try {
url = new URL(href, window.location.href)
} catch {
return false
}
if (url.protocol === 'mailto:' || url.protocol === 'tel:') return true
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== window.location.origin
}
/** Hand a document to whatever machine-level application owns it: the browser
* for a page, the mail client for `mailto:`. In a browser a link already does
* this, so the fallback is the browser's own new tab; in a window there is no
* other tab, and following the link in place would leave the wiki's page
* gone from the only view there is. */
export async function openExternal(url: string): Promise<void> {
if (!import.meta.client || !url) return
if (isDesktop()) {
try {
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(url)
return
} catch {
/* no shell to hand it to, or this window is not allowed this one — the
least worse thing left is the browser's way out */
}
}
window.open(url, '_blank', 'noopener')
}
/** Keep a session token where a window cannot lose it.
On the web a cookie is the whole of it: the server reads it, and the browser
keeps it as long as the API said the token lasts. A window's own address
(`tauri://localhost`) is one a webview is not everywhere obliged to keep
cookies for, and a session dying with the window would mean signing in again
every time the app was opened — for a token the API hands out for a week. So
here it is mirrored into localStorage and taken back from there whenever the
cookie comes up empty, whichever of the two this webview chose to keep.
The cookie still leads: it is the one already at hand, the one a web build
would have read, and the one the sign-out line clears. A browser asked this
question of the same files ignores all of it — there is nothing to mirror
where cookies work — so the wiki's behavior there is untouched. */
export function keepTokenRemembered(token: Ref<string | null>, key: string): void {
if (!import.meta.client || !isDesktop()) return
if (!token.value) {
const kept = storeGet(key)
if (kept) token.value = kept
}
// sync, so signing out clears the mirror in the same breath, not on a tick
watch(token, (value) => (value ? storeSet(key, value) : storeClear(key)), { flush: 'sync' })
}

View File

@@ -0,0 +1,36 @@
/* The bundle the desktop app loads.
nuxt.config.ts reads a MATHEW_DESKTOP flag and, in that shape, writes the
wiki as plain files — no server, no `/api` proxy, since a window has neither
— into dist-desktop/public, which is the `frontendDist` src-tauri/tauri.conf.json
names. `tauri build` gets here through `npm run desktop:generate`, which is
that config's `beforeBuildCommand`.
The reason this file exists rather than a script line: `MATHEW_DESKTOP=1 nuxt
generate` is a bashism, and desktop builds are something Windows is asked for
too. Setting the variable here works from any shell.
The wiki the app ships pointed at is overridable like any other build
setting — set it in the environment before `npm run desktop:build`:
NUXT_PUBLIC_API_BASE=http://localhost:5000 a wiki on this machine
NUXT_PUBLIC_LEAN_URL=https://lean.example a Lean server the app can reach
(Lean's default is empty, which leaves /lean saying there is no server here
rather than reaching for a localhost that is not on the machine). */
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const run = spawnSync(npm, ['exec', '--', 'nuxt', 'generate'], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
env: { ...process.env, MATHEW_DESKTOP: '1' },
stdio: 'inherit',
})
if (run.error) {
console.error(`desktop: could not run \`nuxt generate\` — ${run.error.message}`)
process.exit(1)
}
process.exit(run.status ?? 1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Draw desktop/icon-source.png — the 1024² master the icon set is cut from.
The wiki's own mark is the ∑ in the sidebar (AppHeader.vue's .logo-mark) over
the dark reading surface, so the app icon is that: the sigma, in the accent the
wiki wears by default, on the dark navy of its dark theme.
python3 desktop/make-icon.py # writes desktop/icon-source.png
npm run desktop:icons # cuts src-tauri/icons/ from it
Requires Pillow (pip install pillow). Nothing else in the project uses it —
this is build tooling, not app code.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
SIZE = 1024
PAD = 64 # the margin macOS wants round an app icon; harmless elsewhere
RADIUS = 200
TOP = (16, 21, 44) # the navy a touch above --bg, so the square reads as an object
BOTTOM = (6, 8, 14) # ... down to just under it
ACCENT = (124, 140, 255) # --accent, dark theme (#7c8cff)
MARK = "\u2211" # ∑ — the wiki's mark
FONTS = [
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
]
OUT = Path(__file__).resolve().parent / "icon-source.png"
def load_font(size: int) -> ImageFont.FreeTypeFont:
"""The first system font that actually carries a sigma."""
for path in FONTS:
try:
font = ImageFont.truetype(path, size)
except OSError:
continue
if font.getbbox(MARK)[2] > 0:
return font
raise SystemExit(f"no font with a {MARK} found — look for one of: {', '.join(FONTS)}")
def main() -> None:
# vertical gradient, then clipped to a rounded square
gradient = Image.new("RGB", (1, SIZE))
for y in range(SIZE):
t = y / (SIZE - 1)
gradient.putpixel((0, y), tuple(round(a + (b - a) * t) for a, b in zip(TOP, BOTTOM)))
gradient = gradient.resize((SIZE, SIZE))
mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[PAD, PAD, SIZE - PAD, SIZE - PAD], radius=RADIUS, fill=255
)
icon = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
icon.paste(gradient, (0, 0), mask)
draw = ImageDraw.Draw(icon)
# a hairline of accent light along the edge, so the dark square does not
# vanish into a dark taskbar
draw.rounded_rectangle(
[PAD, PAD, SIZE - PAD, SIZE - PAD], radius=RADIUS, outline=(124, 140, 255, 56), width=3
)
font = load_font(600)
left, top, right, bottom = draw.textbbox((0, 0), MARK, font=font)
w, h = right - left, bottom - top
# centred on the box the glyph is drawn in, not on its typographic cell:
# an ascent-based centre would sit the sigma high
draw.text(
((SIZE - w) / 2 - left, (SIZE - h) / 2 - top),
MARK,
font=font,
fill=ACCENT + (255,),
)
icon.save(OUT)
print(f"wrote {OUT}")
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,24 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
/* The desktop build — the same wiki in an app window (Tauri, see src-tauri/).
A window has no Nuxt server and no nginx behind it, so the wiki is written
out as plain files for the system webview to read off disk and every address
it calls has to be absolute. `desktop/build.mjs` sets the flag; the Tauri CLI
carries TAURI_ENV_* into its own build hooks, so `tauri build` reaches this
branch without anyone remembering to export anything. The web build takes
none of it: same config file, different shape of output. */
const desktop = process.env.MATHEW_DESKTOP === '1' || !!process.env.TAURI_ENV_PLATFORM
/* Where a desktop build takes its wiki from. Baked in at build — the app has
no origin of its own to route `/api` through — and overridable for anyone
building against another wiki: NUXT_PUBLIC_API_BASE (a staging wiki, this
machine), and NUXT_PUBLIC_LEAN_URL for a Lean server the app can actually
reach. Lean's default stays empty on purpose: a window on a random laptop
has no localhost:8080 to find, and /lean says so plainly rather than
reaching for a server that is not there. */
const apiDefault = desktop ? 'https://mathew.aranroig.com' : 'http://localhost:5000'
const leanDefault = desktop ? '' : 'http://localhost:8080'
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
@@ -37,26 +57,43 @@ export default defineNuxtConfig({
},
},
/* A page of the web is drawn on the server; a window on the machine cannot
be — the webview draws these same pages itself, from the bundle. The book,
the middleware and the popups all run client-side either way, so nothing
but where HTML first comes from changes. */
ssr: !desktop,
runtimeConfig: {
public: {
// Where the UI calls the API. The Docker image sets this to "" so the
// browser calls /api on its own origin and nginx routes it to the
// backend; dev keeps the absolute localhost address.
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:5000',
// backend; dev keeps the absolute localhost address, and the desktop
// build the public wiki it ships pointed at.
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? apiDefault,
// Where the Lean editor server (lean4web — see lean/) lives: /lean
// frames this address. It is the browser's own way in, not the
// server's — the iframe talks to its own origin from there on.
leanUrl: process.env.NUXT_PUBLIC_LEAN_URL ?? 'http://localhost:8080',
leanUrl: process.env.NUXT_PUBLIC_LEAN_URL ?? leanDefault,
},
},
/* The desktop bundle: files under dist-desktop/ and nothing to run. Written
there rather than into .output/ so a desktop build never overwrites the
server bundle a web build left, and `frontendDist` in tauri.conf.json has
a directory of its own to read. */
...(desktop
? { nitro: { preset: 'static', output: { dir: 'dist-desktop' } } }
: {}),
// The Nuxt server's own API calls (SSR) are relative once apiBase is "",
// and land on this Nitro route, which forwards them to the backend. The
// Docker image bakes NUXT_API_PROXY_TARGET=https://mathew.aranroig.com in
// at build; nginx routes that address on to the backend.
routeRules: {
'/api/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET || 'http://localhost:5000'}/api/**` },
},
// at build; nginx routes that address on to the backend. A desktop build has
// no Nuxt server to answer this, and no reason for one: the app calls the
// API by its own absolute address.
routeRules: desktop
? {}
: { '/api/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET || 'http://localhost:5000'}/api/**` } },
app: {
head: {
@@ -68,14 +105,25 @@ export default defineNuxtConfig({
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#f7f8fb' },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0a0c12' },
],
script: [
{
// Apply saved theme & accent before first paint to avoid a flash
tagPosition: 'head',
innerHTML:
"(function(){try{var d=document.documentElement;var s=localStorage.getItem('mathew-theme');var t=s||(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');d.setAttribute('data-theme',t);var a=localStorage.getItem('mathew-accent');if(a&&a!=='violet')d.setAttribute('data-accent',a);}catch(e){}})();",
},
],
script: desktop
? [
/* The same pre-paint, from a file: a window is a host one may
sensibly hold to a `script-src 'self'` Content Security Policy,
and code written into the HTML would not survive one.
public/preboot.js carries this exact code — keep the pair in
step. (src-tauri/ sets no policy yet; keeping this one in a
file is what makes turning it on a config line, not a page
rewrite.) */
{ tagPosition: 'head', src: '/preboot.js' },
]
: [
{
// Apply saved theme & accent before first paint to avoid a flash
tagPosition: 'head',
innerHTML:
"(function(){try{var d=document.documentElement;var s=localStorage.getItem('mathew-theme');var t=s||(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');d.setAttribute('data-theme',t);var a=localStorage.getItem('mathew-accent');if(a&&a!=='violet')d.setAttribute('data-accent',a);}catch(e){}})();",
},
],
},
},
})

View File

@@ -8,6 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/i18n": "^10.6.0",
"@tauri-apps/plugin-opener": "^2.7.0",
"highlight.js": "^11.12.0",
"isomorphic-dompurify": "^4.4.0",
"katex": "^0.18.9",
@@ -19,6 +20,7 @@
"vue-router": "^5.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.1",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.2.0",
"sass": "^1.105.0"
@@ -4756,6 +4758,257 @@
"integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
"license": "CC0-1.0"
},
"node_modules/@tauri-apps/api": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.1.tgz",
"integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz",
"integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.12.1",
"@tauri-apps/cli-darwin-x64": "2.12.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.12.1",
"@tauri-apps/cli-linux-arm64-musl": "2.12.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.1",
"@tauri-apps/cli-linux-x64-gnu": "2.12.1",
"@tauri-apps/cli-linux-x64-musl": "2.12.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.12.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.12.1",
"@tauri-apps/cli-win32-x64-msvc": "2.12.1"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz",
"integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz",
"integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz",
"integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz",
"integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz",
"integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz",
"integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz",
"integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz",
"integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz",
"integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz",
"integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz",
"integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.0.tgz",
"integrity": "sha512-mBorYfVKh9Lt7s4ZFtKbMUTxBw+Y/sVmSlKCtkkoBgMTC/DIbt/kaPm67L/2djAUqDI1wbixy5OHHVmipjuGMw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",

View File

@@ -7,10 +7,15 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"desktop:generate": "node desktop/build.mjs",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:icons": "tauri icon desktop/icon-source.png"
},
"dependencies": {
"@nuxtjs/i18n": "^10.6.0",
"@tauri-apps/plugin-opener": "^2.7.0",
"highlight.js": "^11.12.0",
"isomorphic-dompurify": "^4.4.0",
"katex": "^0.18.9",
@@ -22,6 +27,7 @@
"vue-router": "^5.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.1",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.2.0",
"sass": "^1.105.0"

View File

@@ -0,0 +1,17 @@
/* Apply saved theme & accent before first paint to avoid a flash.
*
* This is the desktop build's copy of the inline snippet nuxt.config.ts puts
* in <head> for the web (keep the two in step). Out here as a file because a
* window may be held to a Content Security Policy — see src-tauri/README.md —
* and `script-src 'self'` would have no room for a script written inline in
* the HTML.
* A classic <script src> in <head> still runs before the page paints, so the
* surfaces arrive already the right way round. */
try {
var d = document.documentElement
var s = localStorage.getItem('mathew-theme')
var t = s || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
d.setAttribute('data-theme', t)
var a = localStorage.getItem('mathew-accent')
if (a && a !== 'violet') d.setAttribute('data-accent', a)
} catch (e) {}

6
frontend/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Rust's build output — hundreds of MB, never belongs in the wiki.
target/
# Written by tauri-build on every build: the JSON schemas for the permissions
# listed under capabilities/. Generated, so regenerable.
gen/schemas

4912
frontend/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
[package]
name = "mathew"
version = "0.1.0"
description = "Mathew — a wiki for mathematics, in a window of its own"
authors = ["Aran Roig"]
edition = "2021"
# The app is a library with a thin main: Tauri's own entry points want it that
# way, so the same `run()` also serves a mobile build should one ever be asked
# for (the window itself is desktop-shaped — see src/lib.rs).
[lib]
name = "mathew_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# A link inside the window hands its document to the system's browser rather
# than replacing the wiki in the only view there is. The wiki asks for this
# itself (app/utils/desktop.ts); this is the door it goes out by.
tauri-plugin-opener = "2"
# Only a desktop app can be launched twice onto one window.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"

View File

@@ -0,0 +1,78 @@
# src-tauri/ — the app window
Mathew as a **desktop app** (Tauri 2, Linux / Windows / macOS): the wiki in a
window of its own, no browser around it. The shell is deliberately thin — the
app *is* the same Nuxt bundle the website serves, written out as plain files
and embedded in the binary. Nothing in `frontend/app/` is desktop code; the
bundle asks at run time whether it happens to be sitting in a window
(`app/utils/desktop.ts`) and only then takes the window's habits: a link leads
out to the system's browser instead of replacing the wiki in the only view
there is, and the session token and interface language are mirrored into
`localStorage` because a webview is not everywhere obliged to keep cookies for
its own `tauri://localhost` address.
The wiki needs a wiki to talk to: the window holds no server and no database.
It calls the **public Mathew** over HTTPS by default — the API base is baked in
at build, see the table below.
## Build & run
From `frontend/` — these need [Rust](https://rustup.rs) (stable) and the
[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS;
on Linux that is the WebKitGTK stack:
`libwebkit2gtk-4.1-dev libgtk-3-dev libssl-dev libxdo-dev librsvg2-dev build-essential`.
| Script | What it does |
| --- | --- |
| `npm run desktop:dev` | `tauri dev` — opens the **Nuxt dev server** (`localhost:3000`, started for you) in the window, hot-reload included; `isDesktop()` is already true inside it |
| `npm run desktop:build` | `tauri build` — runs `desktop:generate` (below), compiles the shell, embeds the files, and leaves installers under `src-tauri/target/release/bundle/` (deb/rpm/AppImage on Linux, NSIS/MSI on Windows, .app/.dmg on macOS) |
| `npm run desktop:generate` | the bundle the window loads: `MATHEW_DESKTOP=1 nuxt generate` → `dist-desktop/public`, the `frontendDist` this config names (a desktop build never overwrites the web `.output/`) |
| `npm run desktop:icons` | re-cuts `src-tauri/icons/` from `desktop/icon-source.png` (redraw that one with `python3 desktop/make-icon.py`) |
The desktop shape lives in `nuxt.config.ts` under one flag
(`MATHEW_DESKTOP=1`, also implied by the `TAURI_ENV_*` the Tauri CLI passes to
its build hooks): `ssr: false`, no `/api` proxy route, and the pre-paint theme
script served from `public/preboot.js` instead of inline. The web build takes
none of it. Deep routes (`/wiki/en/bayes-theorem`, …) are not files in the
bundle — Tauri's own asset serving answers unknown paths with `index.html`, so
the client router takes over exactly as it does on a static host.
**Dev-server port**: `devUrl` is fixed to `3000`, and Nuxt falls back to `3001`
when `3000` is taken (see the main gotchas) — free the port before
`desktop:dev`, or the window loads whatever else answers there.
## Where the app reaches
| Build variable | Default (desktop) | Meaning |
| --- | --- | --- |
| `NUXT_PUBLIC_API_BASE` | `https://mathew.aranroig.com` | the wiki the app reads and writes; point it at `http://localhost:5000` to test against this machine |
| `NUXT_PUBLIC_LEAN_URL` | empty | the Lean server `/lean` talks to; empty leaves that page saying there is no server here, which is the honest answer on a random laptop. The API's default CORS (`*`) carries the window's `tauri://localhost` origin fine — it sends a bearer header, not cookies |
Set them in the environment before `npm run desktop:build`.
## What the shell owns
- **one window per launch** — `tauri-plugin-single-instance`: pressing the
icon again brings the open window forward instead of stacking a second wiki
behind it (`src/lib.rs`).
- **the way out** — `tauri-plugin-opener` is the door `app/utils/desktop.ts`
hands outward links to the system's browser; `capabilities/default.json` is
what grants it. That and `core:default` are the whole permission list:
reading and writing the wiki is the webview's own business.
- **no CSP** (`csp: null` in `tauri.conf.json`): the window loads no foreign
script and talks to one API over HTTPS, and `/lean`'s Monaco wants
`eval`-like tricks a strict `script-src` would have to excuse. The one
script the page writes itself (the pre-paint theme, `public/preboot.js`)
already lives in a **file**, so turning a policy on later is a config line —
keep it that way if one is ever set.
Nothing here is signed: bundles come out unsigned, and updating is
reinstalling. An updater, signing, and a per-machine "which wiki?" setting are
the obvious next steps if this ever ships for real.
## Versioning
The app's version lives in `tauri.conf.json` (`version`); it names the
installers and the platform bundle metadata. Commit `Cargo.lock` once a build
has produced one — for an application binary, a locked dependency set is what
makes a rebuild reproducible.

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "What the Mathew window may ask of the machine. Reading and writing the wiki is the webview's own business — plain HTTP out to the API and, if one is configured, a websocket to the Lean server — and needs no permission here. The one thing it cannot do by itself is hand a link to the system's browser, which is what `opener:default` lets app/utils/desktop.ts ask for.",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,38 @@
/* Mathew as a window.
The shell is thin on purpose. Everything the app *is* — the open book, the
editor, the five interface languages — is the Nuxt bundle it loads, written
out by `npm run desktop:generate` and read from disk by the system webview
(see src-tauri/README.md). The wiki reaches its own API by itself: no Rust
command stands between the two, so an article is fetched the same way in the
window as in a browser.
Two things are the shell's own:
- `opener`, the door a link goes out by. The wiki asks for it by name
(app/utils/desktop.ts); `capabilities/default.json` is what answers.
- one window per launch. Pressing the icon again brings the window that is
already open to the front instead of stacking a second copy of the wiki
behind it. */
#[cfg(desktop)]
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}));
}
builder
.run(tauri::generate_context!())
.expect("error while running Mathew");
}

View File

@@ -0,0 +1,7 @@
// An app window has no terminal to answer from: keep one from opening behind
// it on Windows, where a release build would otherwise start a console.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mathew_desktop_lib::run()
}

View File

@@ -0,0 +1,47 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Mathew",
"version": "0.1.0",
"identifier": "com.aranroig.mathew",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:3000",
"beforeBuildCommand": "npm run desktop:generate",
"frontendDist": "../dist-desktop/public"
},
"app": {
"windows": [
{
"label": "main",
"title": "Mathew",
"width": 1440,
"height": 900,
"minWidth": 940,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"publisher": "Aran Roig",
"homepage": "https://mathew.aranroig.com",
"category": "Education",
"shortDescription": "A collaborative wiki of mathematics",
"longDescription": "Mathew is a wiki of mathematical concepts: Markdown with LaTeX, an open shelf of articles in several languages, a Markdown editor, a calculator, a graph plotter and a Lean 4 editor. This is the same wiki in an app window of its own; it reads and writes a Mathew wiki over HTTP, so it needs one to reach.",
"copyright": "© Aran Roig",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -11,10 +11,19 @@
# docker build -t mathew-lean ./lean
# docker run -p 8080:8080 mathew-lean # → http://localhost:8080
#
# The build is heavy by nature (Mathlib is fetched and its precompiled
# cache unpacked); expect a long build and an image of several GB.
# The build is heavy by nature (Mathlib is fetched and its precompiled cache
# unpacked), but multi-stage keeps the junk out of the pushed image: the
# compilers, npm dev dependencies and caches stay in the builder, and the
# final image is a slim runtime with exactly what a session needs — the node
# server, the elan toolchain, the built client, and the project's Lean cache.
#
# To ship an image without Mathlib itself (~4GB instead of ~11GB), pass
# `--build-arg BAKE_MATHLIB=false` and hold the cache in a volume instead:
# the entrypoint fills a fresh volume once at first start. See
# "Keeping Mathlib out of the image" in lean/README.md.
FROM node:24-bookworm
# ---------- build ----------
FROM node:24-bookworm AS build
# lake fetches dependencies over git/curl, drives clang to link, and elan
# (Lean's toolchain manager) installs the toolchains each project pins
@@ -28,19 +37,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -sSfL https://elan.lean-lang.org/elan-init.sh | bash -s -- -y --default-toolchain none
ENV PATH="/root/.elan/bin:${PATH}"
# cypress (a root dev dependency) pulls an ~800MB browser bundle on install —
# nothing in this build runs browser tests, so skip it
ENV CYPRESS_INSTALL_BINARY=0
# The lean4web source. Pass a tag or branch to pin a build:
# docker build --build-arg LEAN4WEB_REF=v0.2.2 ./lean
ARG LEAN4WEB_REF=main
RUN git clone --depth 1 --branch "${LEAN4WEB_REF}" \
https://github.com/leanprover-community/lean4web.git /opt/lean4web
# Ship only the MathlibDemo project: the wiki opens the default project, and
# upstream's second demo ("Stable") pins its own toolchain — elan would carry
# a second ~3GB one that no session ever uses.
RUN rm -rf /opt/lean4web/Projects/Stable
WORKDIR /opt/lean4web
RUN npm install
# Build the Lean projects under Projects/ (MathlibDemo pulls Mathlib and
# unpacks its precompiled cache — the slow part of the image) and then the
# editor client, which the server serves alongside its websocket
RUN npm run build:projects
# Build the Lean projects under Projects/. For MathlibDemo this fetches
# Mathlib and unpacks its precompiled cache — the slow part of the image.
# With BAKE_MATHLIB=false that step is skipped and only the pinned toolchain
# is installed: the project ships unbuilt, and the entrypoint fills it on a
# first start (the intended pairing is a volume over the project's .lake).
ARG BAKE_MATHLIB=true
RUN if [ "${BAKE_MATHLIB}" = "true" ]; then \
npm run build:projects; \
else \
(cd Projects/MathlibDemo && lake --version > /dev/null); \
fi
# Mathew's theme layer: the wiki's font, stylesheet, and theme handshake
# join the client's public/ so they ship in the built bundle, and a sed
@@ -56,9 +81,48 @@ RUN sed -i \
RUN npm run build:client
# ---------- runtime ----------
# Only what serving sessions needs. The lean/lake binaries carry their own
# shared libraries — nothing else from the builder has to come along.
FROM node:24-bookworm-slim
# bash for scripts, curl for the healthcheck and Mathlib's cache fetch,
# git for lake's dependency operations
RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# elan and the toolchain(s) the projects resolved at build time
COPY --from=build /root/.elan /root/.elan
ENV PATH="/root/.elan/bin:${PATH}"
WORKDIR /opt/lean4web
# The server, the built (wiki-skinned) client, and the manifests. Note the
# whole prod dependency tree: the server imports vscode-ws-jsonrpc out of the
# client workspace (npm hoists it), so a server-only `npm ci -w` would miss it.
COPY --from=build /opt/lean4web/package.json /opt/lean4web/package-lock.json ./
COPY --from=build /opt/lean4web/client/package.json ./client/
COPY --from=build /opt/lean4web/server ./server
COPY --from=build /opt/lean4web/client/dist ./client/dist
# The Lean project: toolchain pin, lakefile, and — unless BAKE_MATHLIB=false
# — Mathlib and its unpacked cache, the bulk of the image.
COPY --from=build /opt/lean4web/Projects ./Projects
RUN npm ci --omit=dev \
&& rm -rf /root/.npm
# Normally a no-op: the image already carries the built Mathlib. With
# BAKE_MATHLIB=false and a volume over the project's .lake, this fills a
# fresh volume once before the server starts (see lean/README.md).
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Bubblewrap cannot sandbox inside a container (no nested user namespaces by
# default) — Docker itself is the isolation here, so the server runs Lean
# directly. PORT 8080 is lean4web's production default: editor + websocket.
# `npm run prod` would only add nodemon (a dev dependency) around this same
# `node index.mjs`, so the final image runs the server directly.
ENV NODE_ENV=production \
NO_BWRAP=true \
PORT=8080
@@ -67,4 +131,5 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://localhost:8080/health || exit 1
CMD ["npm", "run", "prod"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server/index.mjs"]

View File

@@ -25,6 +25,14 @@ docker build -t mathew-lean ./lean
docker run --rm -p 8080:8080 mathew-lean
```
The build is **multi-stage**: compilers, dev dependencies and build caches stay
in the builder, and the pushed image is a slim runtime carrying only what
sessions need — the node server, the elan toolchain, the built client, and
Mathlib's precompiled cache (the bulk of it, ~7GB of olean files; total image
~11GB). The upstream "Stable" demo project is dropped: it pinned a second
~3GB toolchain, and the wiki only ever opens the default project
(`MathlibDemo`).
`GET http://localhost:8080/health` answers when the server is up — the image
carries a `HEALTHCHECK` for it. The frontend finds the server at
`NUXT_PUBLIC_LEAN_URL` (default `http://localhost:8080`, dev included), so out
@@ -38,6 +46,29 @@ docker build --build-arg LEAN4WEB_REF=v0.2.2 -t mathew-lean ./lean
(any branch or tag; the clone is shallow, so commit SHAs are not offered.)
## Keeping Mathlib out of the image
Mathlib's compiled cache is what makes this image (and pushing it) heavy. If
the registry or the CI runner chokes on it, build without Mathlib and let a
volume hold it instead:
```bash
docker build --build-arg BAKE_MATHLIB=false -t mathew-lean ./lean # ~4GB
docker run --rm -p 8080:8080 -v lean-mathlib:/opt/lean4web/Projects/MathlibDemo/.lake mathew-lean
```
The `BAKE_MATHLIB=false` image still ships the pinned elan toolchain, but not
the project's `.lake`: the entrypoint notices a fresh volume and fills it once
at first start — `lake update` fetches the sources pinned in
`lake-manifest.json`, Mathlib's post-update hook downloads the matching
precompiled cache, `lake build` writes the project's own module. Expect ~10
minutes and a few hundred MB of download; restarts after that are instant,
and **image rebuilds never touch Mathlib again** (the volume stays). The
trade: the cache must survive on the host, first start needs network, and it
redoes once if you recreate with a clean volume — hence the default staying
`BAKE_MATHLIB=true`. The commented blocks in `docker-compose.yml` wire this
up for the production stack.
## The protocol the wiki's client speaks
The websocket endpoint is `/websocket/<project>` (project names come from
@@ -117,21 +148,27 @@ The image clones lean4web at build time, so a rebuild is an update:
docker compose build --pull lean && docker compose up -d lean
```
Rebuilding also re-runs `Projects/*/leanweb-build.sh` — the demo projects are
rebuilt and Mathlib's cache re-fetched, so treat this like the heavy build it
is (several GB of image; run it on the machine that hosts the editor).
Rebuilding also re-runs the project build — Mathlib's pin moves with upstream
and its cache re-downloads, so treat this like the heavy build it is (run it
on the machine that hosts the editor; pin `LEAN4WEB_REF` for a build that
stands still).
CI follows the same split: `.gitea/workflows/deploy-lean.yml` rebuilds, pushes
and redeploys the lean image **only when `lean/**` changes**; the regular
deploy pipeline ignores `lean/**` entirely and just pulls whatever
`mathew-lean:latest` the registry holds. (In volume mode the pushed image is
~4GB and a lean rebuild doesn't fetch Mathlib at all.)
## Adding Lean projects
Anything under `Projects/` of the *image source* becomes a selectable project
(`#project=<name>`). To ship one with the image, build a custom image that
copies it in before the project build step:
Anything under `Projects/` of the *running image* becomes a selectable project
(`#project=<name>`). To ship one with the image, add it in the Dockerfile's
builder stage, before the project build step:
```dockerfile
FROM mathew-lean AS base # or edit lean/Dockerfile directly
# in lean/Dockerfile, build stage, before the BAKE_MATHLIB step
COPY MyProject /opt/lean4web/Projects/MyProject
WORKDIR /opt/lean4web/Projects/MyProject
RUN lake build
RUN cd Projects/MyProject && lake build
```
See upstream's [Adding Projects](https://github.com/leanprover-community/lean4web/blob/main/doc/Projects.md)

27
lean/entrypoint.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
# Mathew — lean server entrypoint.
#
# In the usual image this does nothing: the built Mathlib already sits under
# the project and the server starts straight away.
#
# With the image built as `--build-arg BAKE_MATHLIB=false` (see README's
# "Keeping Mathlib out of the image"), the project's .lake is meant to be a
# volume: a fresh one gets filled once, at first start, so Mathlib's ~7GB of
# compiled cache never travels through the registry.
set -eu
PROJECT=/opt/lean4web/Projects/MathlibDemo
if [ ! -f "$PROJECT/.lake/build/lib/lean/MathlibDemo.olean" ]; then
echo "[mathew-lean] $PROJECT/.lake holds no built Mathlib — fetching it once (expect ~10 minutes)"
cd "$PROJECT"
# lake update pulls the sources pinned in lake-manifest.json, and Mathlib's
# post-update hook fetches the matching precompiled cache; lake build then
# only writes the project's own module. The image's pinned toolchain
# (elan) stays as it is — no lean-toolchain re-curl, no surprise downloads.
lake update -R
lake build
echo "[mathew-lean] Mathlib ready"
fi
exec "$@"