From e2c2c66d7fd9d82ee42730221bcb3341ee5f71c5 Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Tue, 6 Oct 2026 00:23:17 +0200 Subject: [PATCH] Vibecoded CI/CD --- .gitea/workflows/deploy-lean.yml | 53 + .gitea/workflows/deploy.yml | 12 +- AGENTS.md | 36 +- docker-compose.yml | 19 +- frontend/.gitignore | 3 + frontend/app/composables/useAuth.ts | 10 +- frontend/app/plugins/desktop.ts | 65 + frontend/app/utils/desktop.ts | 116 + frontend/desktop/build.mjs | 36 + frontend/desktop/icon-source.png | Bin 0 -> 26410 bytes frontend/desktop/make-icon.py | 88 + frontend/nuxt.config.ts | 78 +- frontend/package-lock.json | 253 + frontend/package.json | 8 +- frontend/public/preboot.js | 17 + frontend/src-tauri/.gitignore | 6 + frontend/src-tauri/Cargo.lock | 4912 +++++++++++++++++ frontend/src-tauri/Cargo.toml | 27 + frontend/src-tauri/README.md | 78 + frontend/src-tauri/build.rs | 3 + frontend/src-tauri/capabilities/default.json | 7 + frontend/src-tauri/icons/128x128.png | Bin 0 -> 6275 bytes frontend/src-tauri/icons/128x128@2x.png | Bin 0 -> 12367 bytes frontend/src-tauri/icons/32x32.png | Bin 0 -> 1597 bytes frontend/src-tauri/icons/64x64.png | Bin 0 -> 3137 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 5344 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 7049 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 7529 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 13726 bytes frontend/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1548 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 15377 bytes frontend/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 2266 bytes frontend/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 3578 bytes frontend/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 4428 bytes frontend/src-tauri/icons/StoreLogo.png | Bin 0 -> 2515 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3712 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7934 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3259 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2425 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 5311 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2076 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4840 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 10399 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4006 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7249 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 15593 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 5597 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9639 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 22563 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 7540 bytes .../android/values/ic_launcher_background.xml | 4 + frontend/src-tauri/icons/icon.icns | Bin 0 -> 117767 bytes frontend/src-tauri/icons/icon.ico | Bin 0 -> 22844 bytes frontend/src-tauri/icons/icon.png | Bin 0 -> 27300 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 951 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 1957 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 1957 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 2886 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 1413 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 2887 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 2887 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 4214 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 1957 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 3849 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 3849 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 5694 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 23367 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 5694 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 8331 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 3729 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 7202 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 7856 bytes frontend/src-tauri/src/lib.rs | 38 + frontend/src-tauri/src/main.rs | 7 + frontend/src-tauri/tauri.conf.json | 47 + lean/Dockerfile | 81 +- lean/README.md | 55 +- lean/entrypoint.sh | 27 + 79 files changed, 6044 insertions(+), 47 deletions(-) create mode 100644 .gitea/workflows/deploy-lean.yml create mode 100644 frontend/app/plugins/desktop.ts create mode 100644 frontend/app/utils/desktop.ts create mode 100644 frontend/desktop/build.mjs create mode 100644 frontend/desktop/icon-source.png create mode 100644 frontend/desktop/make-icon.py create mode 100644 frontend/public/preboot.js create mode 100644 frontend/src-tauri/.gitignore create mode 100644 frontend/src-tauri/Cargo.lock create mode 100644 frontend/src-tauri/Cargo.toml create mode 100644 frontend/src-tauri/README.md create mode 100644 frontend/src-tauri/build.rs create mode 100644 frontend/src-tauri/capabilities/default.json create mode 100644 frontend/src-tauri/icons/128x128.png create mode 100644 frontend/src-tauri/icons/128x128@2x.png create mode 100644 frontend/src-tauri/icons/32x32.png create mode 100644 frontend/src-tauri/icons/64x64.png create mode 100644 frontend/src-tauri/icons/Square107x107Logo.png create mode 100644 frontend/src-tauri/icons/Square142x142Logo.png create mode 100644 frontend/src-tauri/icons/Square150x150Logo.png create mode 100644 frontend/src-tauri/icons/Square284x284Logo.png create mode 100644 frontend/src-tauri/icons/Square30x30Logo.png create mode 100644 frontend/src-tauri/icons/Square310x310Logo.png create mode 100644 frontend/src-tauri/icons/Square44x44Logo.png create mode 100644 frontend/src-tauri/icons/Square71x71Logo.png create mode 100644 frontend/src-tauri/icons/Square89x89Logo.png create mode 100644 frontend/src-tauri/icons/StoreLogo.png create mode 100644 frontend/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 frontend/src-tauri/icons/icon.icns create mode 100644 frontend/src-tauri/icons/icon.ico create mode 100644 frontend/src-tauri/icons/icon.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 frontend/src-tauri/src/lib.rs create mode 100644 frontend/src-tauri/src/main.rs create mode 100644 frontend/src-tauri/tauri.conf.json create mode 100644 lean/entrypoint.sh 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 0000000000000000000000000000000000000000..64251df20f0ba83b7e6176f648c2e9c4bc5222f2 GIT binary patch literal 26410 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 LEj%5v=7WC&R8Ekg literal 0 HcmV?d00001 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