diff --git a/.gitea/workflows/deploy-lean.yml b/.gitea/workflows/deploy-lean.yml new file mode 100644 index 0000000..349d405 --- /dev/null +++ b/.gitea/workflows/deploy-lean.yml @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8c9a2ed..2d66e74 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 - diff --git a/AGENTS.md b/AGENTS.md index e5d63b9..bacab8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=` 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 ``/`` 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. diff --git a/docker-compose.yml b/docker-compose.yml index ab9427c..f842218 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.gitignore b/frontend/.gitignore index 4a7f73a..f8cf470 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -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 diff --git a/frontend/app/composables/useAuth.ts b/frontend/app/composables/useAuth.ts index 69b0b95..8af9d06 100644 --- a/frontend/app/composables/useAuth.ts +++ b/frontend/app/composables/useAuth.ts @@ -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('auth:user', () => null) const loggedIn = computed(() => user.value !== null) diff --git a/frontend/app/plugins/desktop.ts b/frontend/app/plugins/desktop.ts new file mode 100644 index 0000000..babfb7e --- /dev/null +++ b/frontend/app/plugins/desktop.ts @@ -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)) +}) diff --git a/frontend/app/utils/desktop.ts b/frontend/app/utils/desktop.ts new file mode 100644 index 0000000..b1ab23b --- /dev/null +++ b/frontend/app/utils/desktop.ts @@ -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 { + 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, 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' }) +} diff --git a/frontend/desktop/build.mjs b/frontend/desktop/build.mjs new file mode 100644 index 0000000..85032f3 --- /dev/null +++ b/frontend/desktop/build.mjs @@ -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) diff --git a/frontend/desktop/icon-source.png b/frontend/desktop/icon-source.png new file mode 100644 index 0000000..64251df Binary files /dev/null and b/frontend/desktop/icon-source.png differ diff --git a/frontend/desktop/make-icon.py b/frontend/desktop/make-icon.py new file mode 100644 index 0000000..417b3c3 --- /dev/null +++ b/frontend/desktop/make-icon.py @@ -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() diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 1646146..8a6324a 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -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){}})();", + }, + ], }, }, }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 30e721e..e4fd348 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 86c5b3d..a108c7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/public/preboot.js b/frontend/public/preboot.js new file mode 100644 index 0000000..c840777 --- /dev/null +++ b/frontend/public/preboot.js @@ -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 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