diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa4df45..0958824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,36 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm lint - run: pnpm test - run: pnpm typecheck + # Prompts must grow with the scene, not the book (see scripts/scale-check.ts). + - run: pnpm scale-check --max-prompt 8000 - run: pnpm site:build - run: pnpm site:check + + e2e: + # The real UI in Chromium against the real server, with offline models. + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright-core install --with-deps chromium + - run: pnpm e2e + - run: pnpm tsx scripts/ui-refresh.ts + - run: pnpm tsx scripts/e2e-covers.ts + - name: Keep screenshots of a failed run + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-screenshots + path: docs/screenshots + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65fccab --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +name: Release + +# Pushing a tag like v0.2.0 builds the desktop app on macOS (Apple silicon), Windows (x64) and +# Linux (x64) and attaches the installers to a draft GitHub release for that tag. Review the +# draft, then publish it. Running the workflow by hand builds the same installers as workflow +# artifacts without creating a release. +# +# Signing is optional. Without these repository secrets the installers are unsigned (macOS builds +# are ad-hoc signed): MAC_CSC_LINK, MAC_CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, +# APPLE_TEAM_ID (sign and notarize for macOS); WIN_CSC_LINK, WIN_CSC_KEY_PASSWORD (Windows). + +on: + push: + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: build (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + if: runner.os == 'Linux' + # Builds the UI, bundles the server, rebuilds better-sqlite3 for Electron, then runs + # electron-builder for this platform. Output: apps/desktop/release/. + - run: pnpm dist + env: + QUILLGON_VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + CSC_LINK: ${{ runner.os == 'macOS' && secrets.MAC_CSC_LINK || '' }} + CSC_KEY_PASSWORD: ${{ runner.os == 'macOS' && secrets.MAC_CSC_KEY_PASSWORD || '' }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + - uses: actions/upload-artifact@v4 + with: + name: quillgon-${{ runner.os }} + if-no-files-found: error + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.exe + apps/desktop/release/*.AppImage + apps/desktop/release/*.deb + + release: + needs: build + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: installers + merge-multiple: true + - name: Attach the installers to a draft release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + ls -l installers + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" installers/* --clobber --repo "$GITHUB_REPOSITORY" + else + prerelease="" + case "$TAG" in *-*) prerelease="--prerelease" ;; esac + gh release create "$TAG" installers/* --draft $prerelease --verify-tag --generate-notes \ + --title "Quillgon $TAG" --repo "$GITHUB_REPOSITORY" + fi diff --git a/.gitignore b/.gitignore index 1d89ed5..b413d38 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ out/ coverage/ .vite/ .vercel/ +release/ +evals/runs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3dde64..f9e0d87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,16 +16,37 @@ Open and choose **Open the sample novella**. To explore For UI development, run `pnpm dev`. The local Vite server uses . +## Desktop app and packaging + +`pnpm desktop` runs the Electron shell against the source tree (plain Node and tsx; needs `node` on your `PATH`). Installers are built with: + +```sh +pnpm dist # UI + server bundle + electron-builder, for this platform +pnpm -C apps/desktop dist:dir # the same, but only the unpacked app (faster) +pnpm -C apps/desktop bundle # only stage apps/desktop/out/ (needs `pnpm build` first) +``` + +The pieces: + +- `apps/desktop/electron/main.cjs` and `preload.cjs`: the shell. It starts the server, waits up to 30 seconds for its URL (`QUILLGON_STARTUP_TIMEOUT_MS`), and keeps the window on the server's origin. +- `apps/desktop/electron/server-entry.mjs`: the server's entry point in a packaged app. +- `apps/desktop/scripts/prepare-package.mjs`: bundles that entry and the workspace packages into `out/server/server.mjs` with esbuild, copies the UI, the sample novella and the agent prompts next to it, and installs `better-sqlite3` rebuilt for Electron (a prebuilt binary if one exists, otherwise compiled, which needs a C/C++ toolchain, Python 3 and access to electronjs.org). +- `apps/desktop/electron-builder.config.cjs`: targets, and optional signing. +- `.github/workflows/release.yml`: on a `v*` tag, builds macOS, Windows and Linux and attaches the installers to a draft release. + +To run the bundled server without packaging, stage it and point the shell at it: `pnpm -C apps/desktop bundle`, then `QUILLGON_SERVER_BUNDLE="$PWD/apps/desktop/out/server/server.mjs" pnpm desktop` from the repository root. If you add a file that the server reads at runtime relative to its own source file, stage it in `prepare-package.mjs` too, or the installed app will not find it. Build output (`apps/desktop/out/`, `apps/desktop/release/`) is ignored by Git; do not commit it. + ## Before a pull request ```sh +pnpm lint pnpm test pnpm typecheck pnpm site:build pnpm site:check ``` -If you change the desktop UI, include a screenshot and describe the user flow you checked. If you change story data or an agent, describe the fixture or offline scenario you ran. Live-model tests require a provider key and are not part of the normal contribution gate. +If you change the desktop UI, include a screenshot and describe the user flow you checked. If you change the Electron shell or packaging, say which platforms you built and ran (`pnpm -C apps/desktop dist:dir` is enough for most changes). If you change story data or an agent, describe the fixture or offline scenario you ran. Live-model tests require a provider key and are not part of the normal contribution gate. Keep pull requests focused. Explain the user-facing problem, the change, and how you verified it. Do not commit API keys, private manuscripts, generated books, or `.engine` databases. The project stores books as local Markdown and YAML; sample data belongs in `fixtures/` only when it is safe to publish. diff --git a/README.md b/README.md index 4a1665a..7cba745 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,12 @@ pnpm serve # http://127.0.0.1:4317, then "Open the sample novella" > [!TIP] > **No API key? No problem.** Choose **Offline** models when you create a book (or set `QUILLGON_OFFLINE=1`). Offline output is placeholder text, but every screen and workflow works, so you can explore the whole app for free. -For the Electron shell, stop the server and run `pnpm desktop` (it also needs Node.js on your `PATH`). For development, `pnpm dev` runs the API and the Vite dev server with hot reload on http://localhost:5173. +For the Electron shell, stop the server and run `pnpm desktop` (it also needs Node.js on your `PATH`). To build an installer instead, see [Desktop app and installers](#desktop-app-and-installers). For development, `pnpm dev` runs the API and the Vite dev server with hot reload on http://localhost:5173. Books are stored in local Markdown and YAML files. Connected AI providers receive relevant story context when you use generation or analysis. > [!NOTE] -> **Quillgon is an early-stage source preview.** The sample novella and offline workflows are tested end to end. A complete 80,000-word novel with live models has not yet been validated, and there are no packaged installers yet. [Tell us what breaks](https://github.com/JeremySNR/quillgon/issues). +> **Quillgon is an early-stage source preview.** The sample novella and offline workflows are tested end to end. A complete 80,000-word novel with live models has not yet been validated. No installers have been published yet; you can build one with `pnpm dist` (see [Desktop app and installers](#desktop-app-and-installers)). [Tell us what breaks](https://github.com/JeremySNR/quillgon/issues). ### πŸ”Œ Connect a model @@ -195,6 +195,35 @@ Verification: a short synthetic request through the real Codex SDK and local Cha +## Desktop app and installers + +`pnpm desktop` runs the Electron shell from your checkout. To build an installer for the computer you are on: + +```bash +pnpm install +pnpm dist # installers in apps/desktop/release/ +``` + +| Platform | Output | +| --- | --- | +| macOS (Apple silicon) | `.dmg` and `.zip` | +| Windows (x64) | NSIS installer (`.exe`) | +| Linux (x64) | `.AppImage` and `.deb` | + +`pnpm dist` builds the UI, bundles the server into a single file with esbuild, rebuilds `better-sqlite3` for Electron, and runs electron-builder (see `apps/desktop/scripts/prepare-package.mjs` and `apps/desktop/electron-builder.config.cjs`). The installed app runs that bundled server with its own Electron runtime, so it needs neither Node.js nor the source tree. The `better-sqlite3` rebuild needs a C/C++ toolchain, Python 3 and network access to electronjs.org for the Electron headers. `pnpm -C apps/desktop dist:dir` skips the installer and leaves the unpacked app in `apps/desktop/release/`. + +Pushing a `v*` tag runs [`.github/workflows/release.yml`](.github/workflows/release.yml), which builds all three platforms and attaches the installers to a draft GitHub release. + +Before you install one: + +- **Installers are unsigned unless you configure signing.** macOS builds are only ad-hoc signed, so macOS reports an unidentified developer: Control-click the app and choose **Open** the first time. Windows SmartScreen warns about an unknown publisher. To sign, set electron-builder's variables (`CSC_LINK` and `CSC_KEY_PASSWORD`, plus `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD` and `APPLE_TEAM_ID` to notarize); the release workflow reads them from the repository secrets listed at its top. +- **Git must be installed.** Every book is a Git repository. The `.deb` depends on `git`. +- **API keys come from the environment.** An app started from the Dock, the Start menu or a launcher does not see variables set in your shell profile. Start it from a terminal where `OPENAI_API_KEY` is set (on macOS, `/Applications/Quillgon.app/Contents/MacOS/Quillgon`), or use Offline models. +- **Codex subscription in an installed app needs your own Codex binary.** The Codex SDK runs the binary from its own npm package, which the installer does not include. Install the Codex CLI and set `QUILLGON_CODEX_PATH` to its path (the output of `which codex`) in the environment that launches Quillgon. +- The installers use Electron's default icon. + +Verified so far: on Linux x64, electron-builder produces the AppImage, the `.deb` and the unpacked app, and the packaged app starts its bundled server and shows the UI. Not yet verified: opening a book in an installed build, which needs `better-sqlite3` rebuilt for Electron (the release workflow does this; it could not be done where the packaging was developed), and the macOS and Windows installers, which so far are built only by the release workflow. + ## πŸ› οΈ How it works ```mermaid @@ -260,11 +289,13 @@ quillgon read [--act act-1] [--outline] | check [--mechanical] | voice | estimat quillgon export [--format md|docx|epub] (docx/epub need Pandoc) quillgon branch [name | switch | merge | delete ] quillgon serve [--dir book] [--port 4317] +quillgon eval [--offline|--codex] [--limit N] [--only a,b] [--all-scenes] score the frozen premise set +quillgon eval compare judge run B's key scenes against run A's, both ways ``` -Pass `--yes` to accept the defaults at every gate (useful for scripting), or `--record ` / `--replay ` to record model responses as test fixtures or replay them. +Pass `--yes` to accept the defaults at every gate (useful for scripting). Without a terminal and without `--yes`, any choice that changes the book stops with an error rather than answering itself. Use `--record ` / `--replay ` to record model responses as test fixtures or replay them. ## πŸ—οΈ Architecture @@ -284,6 +315,7 @@ fixtures/novella/ "The Tidekeeper's Ledger": 12 scenes, 4 characters, 3 threa
+- **The local API is locked to the app.** The server binds to 127.0.0.1 and requires a per-launch token on every API request; the UI reads it from the page the server serves. Requests with a non-loopback `Host` (DNS rebinding) or a foreign `Origin` are refused, request bodies must be JSON, and git refs from the UI are validated before they reach git. Set `QUILLGON_TOKEN` to choose the token yourself, for example to script against `pnpm serve`. A book's `api_key_env` must name a variable ending in `_API_KEY`, so a shared book file cannot send another secret to a model endpoint. - **Only the graph store writes to the manuscript.** Agents return structured output, validated with Zod, and the store writes files, updates the index and commits. - **A book is a git repo of Markdown and YAML** (`book.yaml`, `intent.md`, `plot-points/`, `threads/`, `characters//{bio.md,voice.md,state.yaml}`, `scenes//{card.yaml,prose.md,facts.yaml}`, `obligations/`, `idea.yaml` (the idea board and its conversation), `structure.yaml`, `timeline.yaml`, `style/` (samples, banned patterns, `style.yaml`), `reports/`). You can edit any of it in another editor; the app reloads. - **The index is disposable.** `.engine/index.sqlite` holds entities, references, facts and scene embeddings. Delete it and it rebuilds. Embeddings are local and deterministic (hashed term vectors), so impact analysis needs no network. @@ -300,13 +332,29 @@ The UI design follows the research brief in [docs/design.md](docs/design.md); th ## πŸ§ͺ Tests and roadmap ```bash -pnpm test # 94 unit/integration tests, no model calls +pnpm test # unit, integration, CLI and UI tests, no model calls pnpm e2e # drives the real UI in Chromium (offline models) +pnpm scale-check # prompt sizes and write latency on a 96-scene book +pnpm quillgon eval --offline --limit 2 # the eval harness, offline (see below) pnpm screenshots # regenerates docs/screenshots pnpm typecheck pnpm test:live # live-model tests; needs QUILLGON_LIVE=1 and an API key ``` +### Evaluating prompt and model changes + +`quillgon eval` runs the frozen set in `evals/premises.yaml` (20 premises, each with a fixed intent) through every gate: plot points, structure, scene cards, then the opening, the turn and the ending (or every scene with `--all-scenes`), and the simulated reader. It scores each run with deterministic measures: + +| Measure | What it catches | +| --- | --- | +| Slop per 1,000 words | Banned patterns and stock machine phrasing | +| Style violations per 1,000 words | Breaks of the code-checked style sheet | +| Continuity errors, structural tells | Knowledge errors; single-track plots, sagging middles, tidy endings | +| Twists guessed early, engagement | The simulated reader's predictions and interest | +| Shared names, trigram overlap, plan diversity | Books in one run converging on the same story | + +Results and a report go to `evals/runs//`, with the generated books. To test a change, run the set before and after it, then `quillgon eval compare `: every key scene is judged against its counterpart in both orders, a split verdict counts as a tie, and the result is a win rate and the Elo difference it implies. A live run calls the model for every gate, so start with `--limit`. Pass `--record ` to keep the model responses and `--replay ` to re-score them without new calls. + | Phase | Status | | --- | --- | | 1. Schemas and store | βœ… Fixture validates; 12 kinds of corruption reported with exact file and field; index rebuild matches its snapshot | @@ -326,7 +374,7 @@ pnpm test:live # live-model tests; needs QUILLGON_LIVE=1 and an API key
- **OpenAI Responses API and Codex SDK.** Quillgon owns the story graph and review workflow; each writing agent requests a structured result through the official OpenAI SDK. A hosted Agents API execution environment is unnecessary for these calls. The local Codex SDK provides a separate path for users signing in with ChatGPT. -- **Electron runs the core as a plain Node process.** This lets `better-sqlite3` load without an Electron rebuild. It needs `node` on your `PATH` (or set `QUILLGON_NODE`). +- **Electron runs the core as a separate process.** From a checkout (`pnpm desktop`) it is a plain Node process running the CLI through tsx, so `better-sqlite3` needs no Electron rebuild; it needs `node` on your `PATH` (or set `QUILLGON_NODE`). An installed app runs the bundled server with Electron's own Node (`ELECTRON_RUN_AS_NODE`) and a `better-sqlite3` rebuilt for it. Either way the window only shows the server's own pages: other navigation is blocked, web and mail links open in your browser, permission requests are denied, and if the server stops the app offers to restart it. - **Reader prediction matching is mechanical** (embedding similarity, synonym folding, and the prediction must name the right person). A cheap model judge would be more accurate and is an easy swap. - **Two small additions to the data model:** `scenes//facts.yaml` holds extracted facts, so the index can rebuild from files, and an optional `locations.yaml` / `facts.yaml` glossary gives ids human-readable names. diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs new file mode 100644 index 0000000..455835b --- /dev/null +++ b/apps/desktop/electron-builder.config.cjs @@ -0,0 +1,57 @@ +// electron-builder configuration. `pnpm dist` (root) or `pnpm -C apps/desktop dist` builds the UI, +// stages out/app and out/server with scripts/prepare-package.mjs, then runs electron-builder for +// the current platform and architecture. Installers land in apps/desktop/release/. +// +// Code signing is optional. With no certificates configured the build is unsigned (macOS gets an +// ad-hoc signature so it runs on Apple silicon). To sign, set the usual electron-builder variables: +// macOS: CSC_LINK + CSC_KEY_PASSWORD (Developer ID Application .p12), and to notarize +// APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID (or APPLE_API_KEY*) +// Windows: CSC_LINK + CSC_KEY_PASSWORD (or WIN_CSC_LINK + WIN_CSC_KEY_PASSWORD) +const electronVersion = require('electron/package.json').version; +const hasMacCert = !!(process.env.CSC_LINK || process.env.CSC_NAME); + +/** @type {import('electron-builder').Configuration} */ +module.exports = { + appId: 'io.github.jeremysnr.quillgon', + productName: 'Quillgon', + copyright: 'Copyright Β© 2026 JeremySNR and Quillgon contributors', + electronVersion, + directories: { app: 'out/app', output: 'release', buildResources: 'build-resources' }, + // Only the shell. The UI is bundled by Vite and the server by esbuild, so no node_modules: without + // the exclusion electron-builder falls back to apps/desktop's dependencies and packs all of them. + files: ['package.json', 'electron/**', '!**/node_modules/**'], + asar: true, + // The app (main.cjs, preload.cjs) has no dependencies. The one native module, better-sqlite3, + // belongs to the server in resources/server and prepare-package.mjs has already rebuilt it for + // this Electron, so electron-builder has nothing to install or rebuild. + npmRebuild: false, + nodeGypRebuild: false, + // electron-builder always drops a copied directory's top-level node_modules, so the server's + // (just better-sqlite3 and its two helpers) is copied as an entry of its own. + extraResources: [ + { from: 'out/server', to: 'server' }, + { from: 'out/server/node_modules', to: 'server/node_modules' }, + ], + artifactName: '${productName}-${version}-${os}-${arch}.${ext}', + publish: null, + + mac: { + category: 'public.app-category.productivity', + target: ['dmg', 'zip'], + hardenedRuntime: true, + // electron-builder's default entitlements include disable-library-validation, which an + // ad-hoc signed app needs to load better_sqlite3.node. + identity: hasMacCert ? undefined : '-', + }, + dmg: { writeUpdateInfo: false }, + win: { target: ['nsis'] }, + nsis: { oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, differentialPackage: false }, + linux: { + target: ['AppImage', 'deb'], + category: 'Office', + synopsis: 'Write a whole novel with AI while staying in charge of the story', + maintainer: 'Quillgon contributors', + }, + // electron-builder's default Debian dependencies, plus git (every book is a git repository). + deb: { depends: ['libgtk-3-0', 'libnotify4', 'libnss3', 'libxss1', 'libxtst6', 'xdg-utils', 'libatspi2.0-0', 'libuuid1', 'libsecret-1-0', 'git'] }, +}; diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9abcbe4..7a16342 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1,34 +1,164 @@ -// Electron shell. Starts the local Quillgon server (plain Node, so native modules -// like better-sqlite3 load without an Electron rebuild) and opens the UI it serves. -const { app, BrowserWindow, dialog, ipcMain, shell, nativeTheme } = require('electron'); +// Electron shell. Starts the local Quillgon server and opens the UI it serves. +// +// - From a source checkout (`pnpm desktop`) the server is a plain Node process running the CLI +// through tsx, so better-sqlite3 loads without an Electron rebuild. That needs `node` on PATH +// (or QUILLGON_NODE). +// - In a packaged app the server is one bundled file, resources/server/server.mjs (built by +// scripts/prepare-package.mjs), run by Electron's own Node (ELECTRON_RUN_AS_NODE=1) against a +// better-sqlite3 rebuilt for Electron. Nothing outside the app is needed. +// +// The window may only ever show the server's origin: other navigations are blocked, new windows +// are refused (http, https and mailto links open in the default browser), and every permission +// request is denied. +const { app, BrowserWindow, dialog, ipcMain, session, shell, nativeTheme } = require('electron'); const { spawn } = require('node:child_process'); +const fs = require('node:fs'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const root = path.resolve(__dirname, '..', '..', '..'); -let server = null; +const STARTUP_TIMEOUT_MS = Number(process.env.QUILLGON_STARTUP_TIMEOUT_MS) || 30_000; +const EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']); + +let server = null; // the running server process +let appOrigin = null; // e.g. http://127.0.0.1:53817, the only origin the window may show let win = null; +let quitting = false; + +const tail = (text, lines = 15) => text.trimEnd().split('\n').slice(-lines).join('\n'); + +function sameOrigin(url) { + try { + return appOrigin !== null && new URL(url).origin === appOrigin; + } catch { + return false; + } +} + +function openExternal(url) { + let protocol; + try { + protocol = new URL(url).protocol; + } catch { + return; + } + if (EXTERNAL_PROTOCOLS.has(protocol)) shell.openExternal(url).catch(() => {}); +} + +/** How to run the server: the bundle with Electron's Node when packaged, else node + tsx. */ +function serverCommand() { + const dir = process.env.QUILLGON_PROJECT ? ['--dir', path.resolve(process.env.QUILLGON_PROJECT)] : []; + // QUILLGON_SERVER_BUNDLE=/abs/path/to/apps/desktop/out/server/server.mjs runs the bundled server from a + // source checkout too (after `pnpm -C apps/desktop bundle`), to test it without packaging. + if (app.isPackaged || process.env.QUILLGON_SERVER_BUNDLE) { + const bundle = process.env.QUILLGON_SERVER_BUNDLE ? path.resolve(process.env.QUILLGON_SERVER_BUNDLE) : path.join(process.resourcesPath, 'server', 'server.mjs'); + if (!fs.existsSync(bundle)) throw new Error(`The bundled server is missing (${bundle}). Reinstall Quillgon.`); + return { + command: process.execPath, + args: [bundle, '--port', '0', ...dir], + cwd: app.getPath('home'), + env: { ELECTRON_RUN_AS_NODE: '1', QUILLGON_PARENT_STDIN: '1', QUILLGON_FIXTURE_DIR: path.join(path.dirname(bundle), 'fixtures', 'novella') }, + notFound: 'Reinstall Quillgon.', + }; + } + let tsx; + try { + tsx = require.resolve('tsx', { paths: [root] }); + } catch { + throw new Error(`tsx is not installed in ${root}. Run \`pnpm install\` in the repository first.`); + } + const command = process.env.QUILLGON_NODE || 'node'; + return { + command, + args: ['--import', pathToFileURL(tsx).href, path.join(root, 'packages', 'cli', 'src', 'main.ts'), 'serve', '--port', '0', ...dir], + cwd: root, + env: {}, + notFound: process.env.QUILLGON_NODE + ? 'Set QUILLGON_NODE to the full path of a Node.js 22 (or newer) binary, or unset it to use `node` from your PATH.' + : 'Install Node.js 22 or newer and make sure `node` is on your PATH, or set QUILLGON_NODE to the full path of a node binary.', + }; +} +/** Starts the server and resolves with its URL once it prints one. */ function startServer() { return new Promise((resolve, reject) => { - const node = process.env.QUILLGON_NODE || 'node'; - const tsx = require.resolve('tsx', { paths: [root] }); - const cli = path.join(root, 'packages', 'cli', 'src', 'main.ts'); - const args = ['--import', require('node:url').pathToFileURL(tsx).href, cli, 'serve', '--port', '0']; - if (process.env.QUILLGON_PROJECT) args.push('--dir', process.env.QUILLGON_PROJECT); - server = spawn(node, args, { cwd: root, env: { ...process.env, FORCE_COLOR: '0' } }); - let out = ''; - server.stdout.on('data', (d) => { - out += d.toString(); - const m = out.match(/http:\/\/127\.0\.0\.1:(\d+)/); - if (m) resolve(`http://127.0.0.1:${m[1]}`); + const spec = serverCommand(); + const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: { ...process.env, FORCE_COLOR: '0', ...spec.env }, windowsHide: true }); + server = child; + let stdout = ''; + let stderr = ''; + let started = false; + let failed = false; + const fail = (message) => { + if (started || failed) return; + failed = true; + clearTimeout(timer); + if (child.exitCode === null) child.kill(); + if (server === child) server = null; + reject(new Error(stderr.trim() ? `${message}\n\nServer output:\n${tail(stderr)}` : message)); + }; + const timer = setTimeout(() => fail(`The Quillgon server did not start within ${STARTUP_TIMEOUT_MS / 1000} seconds.`), STARTUP_TIMEOUT_MS); + + child.stdout.on('data', (d) => { + if (started || failed) return; + stdout += d.toString(); + const m = stdout.match(/http:\/\/127\.0\.0\.1:(\d+)/); + if (m) { + started = true; + clearTimeout(timer); + resolve(`http://127.0.0.1:${m[1]}`); + } + }); + child.stderr.on('data', (d) => { + process.stderr.write(d); + stderr = (stderr + d.toString()).slice(-16_000); + }); + // Our end of the server's stdin: an EPIPE here must not crash the shell. + child.stdin.on('error', () => {}); + child.on('error', (err) => { + fail(err.code === 'ENOENT' ? `Could not run "${spec.command}": it was not found. ${spec.notFound}` : `Could not start the Quillgon server (${spec.command}): ${err.message}`); + }); + child.on('exit', (code, signal) => { + if (!started) return fail(`The Quillgon server stopped before it was ready (${signal ?? `exit code ${code}`}).`); + if (server === child) server = null; + if (!quitting) serverCrashed(code, signal, stderr); }); - server.stderr.on('data', (d) => process.stderr.write(d)); - server.on('exit', (code) => reject(new Error(`server exited with ${code}`))); }); } -async function createWindow() { - const url = process.env.VITE_DEV_SERVER_URL || (await startServer()); +function fatal(err) { + dialog.showErrorBox('Quillgon could not start', err instanceof Error ? err.message : String(err)); + quitting = true; + app.quit(); +} + +async function serverCrashed(code, signal, stderr) { + const options = { + type: 'error', + buttons: ['Restart', 'Quit'], + defaultId: 0, + cancelId: 1, + title: 'Quillgon', + message: 'The Quillgon server stopped unexpectedly.', + detail: `It ended with ${signal ?? `exit code ${code}`}. Changes you had not yet saved in the window may be lost.${stderr.trim() ? `\n\n${tail(stderr)}` : ''}`, + }; + const { response } = win && !win.isDestroyed() ? await dialog.showMessageBox(win, options) : await dialog.showMessageBox(options); + if (quitting) return; + if (response !== 0) return app.quit(); + try { + await show(await startServer()); + } catch (err) { + fatal(err); + } +} + +async function show(url) { + appOrigin = new URL(url).origin; + if (!win || win.isDestroyed()) createWindow(); + await win.loadURL(url); +} + +function createWindow() { win = new BrowserWindow({ width: 1440, height: 920, @@ -39,27 +169,58 @@ async function createWindow() { titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', trafficLightPosition: { x: 16, y: 14 }, show: false, - webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, sandbox: true }, + webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false, webviewTag: false }, }); win.once('ready-to-show', () => win.show()); - win.webContents.setWindowOpenHandler(({ url: u }) => (shell.openExternal(u), { action: 'deny' })); - await win.loadURL(url); + win.on('closed', () => (win = null)); +} + +async function start() { + // Deny every permission (camera, microphone, notifications, geolocation…); the UI needs none. + session.defaultSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); + session.defaultSession.setPermissionCheckHandler(() => false); + + await show(process.env.VITE_DEV_SERVER_URL || (await startServer())); + // QUILLGON_SCREENSHOT=file.png: capture the window once it has rendered, then quit (smoke test). if (process.env.QUILLGON_SCREENSHOT) { setTimeout(async () => { const img = await win.webContents.capturePage(); - require('node:fs').writeFileSync(process.env.QUILLGON_SCREENSHOT, img.toPNG()); + fs.writeFileSync(process.env.QUILLGON_SCREENSHOT, img.toPNG()); app.quit(); }, 2500); } } -ipcMain.handle('choose-folder', async () => { +// Every web contents (the window, and anything it might create) stays on the server's origin. +app.on('web-contents-created', (_e, contents) => { + contents.setWindowOpenHandler(({ url }) => { + openExternal(url); + return { action: 'deny' }; + }); + contents.on('will-navigate', (e, url = e.url) => { + if (sameOrigin(url)) return; + e.preventDefault(); + openExternal(url); + }); + contents.on('will-redirect', (e, url = e.url) => { + if (!sameOrigin(url)) e.preventDefault(); + }); + contents.on('will-attach-webview', (e) => e.preventDefault()); +}); + +// IPC is only answered for the app's own pages. +const fromApp = (e) => !!e.senderFrame && sameOrigin(e.senderFrame.url); +ipcMain.handle('choose-folder', async (e) => { + if (!fromApp(e)) return null; const r = await dialog.showOpenDialog(win, { properties: ['openDirectory', 'createDirectory'] }); return r.canceled ? null : r.filePaths[0]; }); -ipcMain.on('reveal', (_e, p) => shell.showItemInFolder(p)); +ipcMain.on('reveal', (e, p) => { + if (fromApp(e) && typeof p === 'string' && path.isAbsolute(p)) shell.showItemInFolder(p); +}); -app.whenReady().then(createWindow); +app.whenReady().then(start).catch(fatal); app.on('window-all-closed', () => app.quit()); +app.on('before-quit', () => (quitting = true)); app.on('quit', () => server && server.kill()); diff --git a/apps/desktop/electron/server-entry.mjs b/apps/desktop/electron/server-entry.mjs new file mode 100644 index 0000000..f5e70c6 --- /dev/null +++ b/apps/desktop/electron/server-entry.mjs @@ -0,0 +1,38 @@ +// Entry point of the server inside a packaged app. scripts/prepare-package.mjs bundles it +// (with @quillgon/server and every workspace and npm dependency except better-sqlite3) into +// out/server/server.mjs, which ships as resources/server/server.mjs. main.cjs runs it with +// Electron's own Node (ELECTRON_RUN_AS_NODE=1), so a packaged app needs neither Node nor tsx. +// +// Files it expects next to itself (all staged by prepare-package.mjs): +// ui/ the built UI (vite build) +// fixtures/novella/ the sample novella, copied by /api/open-sample +// shared.md, /prompt.md the agents' built-in prompts (read relative to this file) +// node_modules/better-sqlite3 rebuilt for Electron's ABI +// +// Usage: server.mjs [--port 0] [--dir ]. Prints "Quillgon on http://127.0.0.1:". +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { NovelServer } from '@quillgon/server'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const flag = (name) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 ? argv[i + 1] : undefined; +}; + +process.env.QUILLGON_FIXTURE_DIR ||= path.join(here, 'fixtures', 'novella'); + +const server = new NovelServer({ staticDir: process.env.QUILLGON_STATIC_DIR || path.join(here, 'ui') }); +const dir = flag('dir'); +if (dir) server.open(path.resolve(dir)); +const port = await server.listen(Number(flag('port') ?? 4317)); +console.log(`Quillgon on http://127.0.0.1:${port}${dir ? ` (${path.resolve(dir)})` : ''}`); + +// Started by the Electron shell: exit when the shell goes away, even if it was killed and +// never got to stop us (its end of our stdin closes then). +if (process.env.QUILLGON_PARENT_STDIN === '1') { + process.stdin.on('end', () => process.exit(0)); + process.stdin.on('error', () => process.exit(0)); + process.stdin.resume(); +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bc253c0..9c68ef6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,12 +5,15 @@ "type": "module", "main": "electron/main.cjs", "scripts": { - "dev": "concurrently -k -n api,ui -c gray,magenta \"node --import tsx ../../packages/cli/src/main.ts serve --port 4317\" \"vite\"", + "dev": "node scripts/dev.mjs", "ui": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc -p tsconfig.json --noEmit", - "electron": "vite build && electron ." + "electron": "vite build && electron .", + "bundle": "node scripts/prepare-package.mjs", + "dist": "vite build && node scripts/prepare-package.mjs && electron-builder --config electron-builder.config.cjs --publish never", + "dist:dir": "vite build && node scripts/prepare-package.mjs && electron-builder --config electron-builder.config.cjs --publish never --dir" }, "dependencies": { "@codemirror/commands": "^6.11.1", @@ -31,12 +34,17 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@electron/rebuild": "^4.2.1", "@quillgon/core": "workspace:*", + "@quillgon/server": "workspace:*", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.1.2", - "concurrently": "^10.0.6", "electron": "^44.7.0", + "electron-builder": "26.15.3", + "esbuild": "^0.28.2", "typescript": "^5.9.0", "vite": "^8.3.4" } diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs new file mode 100644 index 0000000..16fbae7 --- /dev/null +++ b/apps/desktop/scripts/dev.mjs @@ -0,0 +1,29 @@ +// `pnpm dev`: the API server and the Vite dev server, sharing one per-launch API token. +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const app = path.resolve(here, '..'); +const root = path.resolve(app, '..', '..'); +const require = createRequire(path.join(app, 'package.json')); +// Resolve everything before starting anything, so a failure cannot orphan a child. +const viteBin = path.join(path.dirname(require.resolve('vite/package.json')), 'bin', 'vite.js'); +const env = { ...process.env, QUILLGON_TOKEN: process.env.QUILLGON_TOKEN || randomBytes(24).toString('base64url') }; + +const children = [ + spawn(process.execPath, ['--import', 'tsx', path.join(root, 'packages', 'cli', 'src', 'main.ts'), 'serve', '--port', '4317'], { cwd: root, env, stdio: 'inherit' }), + spawn(process.execPath, [viteBin], { cwd: app, env, stdio: 'inherit' }), +]; + +let stopping = false; +const stop = (code) => { + if (stopping) return; + stopping = true; + for (const c of children) if (c.exitCode === null) c.kill(); + process.exitCode = code ?? 0; +}; +for (const c of children) c.on('exit', (code) => stop(code)); +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => stop(0)); diff --git a/apps/desktop/scripts/prepare-package.mjs b/apps/desktop/scripts/prepare-package.mjs new file mode 100644 index 0000000..bb6c781 --- /dev/null +++ b/apps/desktop/scripts/prepare-package.mjs @@ -0,0 +1,176 @@ +// Stages everything electron-builder packs (see electron-builder.config.cjs). Run after `vite build`. +// +// out/app/ the Electron shell: package.json, electron/main.cjs, electron/preload.cjs (β†’ app.asar) +// out/server/ the core server as one bundled file plus what it reads at runtime (β†’ resources/server) +// server.mjs electron/server-entry.mjs + @quillgon/* + npm deps, bundled by esbuild +// ui/ the built UI +// fixtures/novella/ the sample novella (/api/open-sample copies it) +// shared.md, /prompt.md the agents' built-in prompts (read relative to server.mjs) +// node_modules/better-sqlite3 the one native dependency, rebuilt for Electron's ABI +// +// The dev path (`pnpm desktop`: plain Node + tsx from the source tree) does not use any of this. +// Env: QUILLGON_VERSION=1.2.3 (or v1.2.3) sets the app version; default is apps/desktop/package.json. +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { rebuild } from '@electron/rebuild'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const appDir = path.resolve(here, '..'); +const root = path.resolve(appDir, '..', '..'); +const out = path.join(appDir, 'out'); +const outApp = path.join(out, 'app'); +const outServer = path.join(out, 'server'); +const requireApp = createRequire(path.join(appDir, 'package.json')); +const desktopPkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')); +const electronPkg = requireApp('electron/package.json'); +const version = (process.env.QUILLGON_VERSION || desktopPkg.version).replace(/^v/, ''); + +const step = (msg) => console.log(`\n● ${msg}`); +const copy = (from, to, filter) => fs.cpSync(from, to, { recursive: true, dereference: true, filter }); +const noEngine = (src) => !src.split(path.sep).includes('.engine'); + +// --- 0. reset the staging area (keep the rebuilt native module; it is slow to build) +fs.rmSync(outApp, { recursive: true, force: true }); +fs.mkdirSync(outServer, { recursive: true }); +for (const entry of fs.readdirSync(outServer)) if (entry !== 'node_modules') fs.rmSync(path.join(outServer, entry), { recursive: true, force: true }); + +// --- 1. the server bundle +step('Bundling the server with esbuild'); +const result = await build({ + entryPoints: [path.join(appDir, 'electron', 'server-entry.mjs')], + outfile: path.join(outServer, 'server.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + // Native addon, loaded through `bindings`: it ships as a real package next to server.mjs. + // (@openai/codex-sdk is plain JS and is bundled; the Codex binary it looks for is not shipped.) + external: ['better-sqlite3'], + // Bundled CommonJS dependencies call require() for Node built-ins; give the ESM bundle one. + banner: { js: "import { createRequire as __quillgonCreateRequire } from 'node:module';\nconst require = __quillgonCreateRequire(import.meta.url);" }, + legalComments: 'external', + logLevel: 'warning', + metafile: true, +}); +const bytes = Object.values(result.metafile.outputs).reduce((n, o) => n + o.bytes, 0); +console.log(` server.mjs and licences: ${(bytes / 1e6).toFixed(1)} MB`); + +// --- 2. files the server reads at runtime +step('Copying the UI, the sample novella and the agent prompts'); +const ui = path.join(appDir, 'dist'); +if (!fs.existsSync(path.join(ui, 'index.html'))) throw new Error(`No built UI at ${ui}. Run \`vite build\` first (pnpm -C apps/desktop build).`); +copy(ui, path.join(outServer, 'ui')); +copy(path.join(root, 'fixtures', 'novella'), path.join(outServer, 'fixtures', 'novella'), noEngine); +// packages/agents/src/agents/define.ts reads /shared.md and //prompt.md; +// in the bundle "its dir" is the directory of server.mjs. +const agentsDir = path.join(root, 'packages', 'agents', 'src', 'agents'); +copy(agentsDir, outServer, (src) => fs.statSync(src).isDirectory() || src.endsWith('.md')); + +// --- 3. better-sqlite3, rebuilt for Electron +const requireCore = createRequire(path.join(root, 'packages', 'core', 'package.json')); +const sqlitePkgPath = requireCore.resolve('better-sqlite3/package.json'); +const sqliteVersion = JSON.parse(fs.readFileSync(sqlitePkgPath, 'utf8')).version; +fs.writeFileSync( + path.join(outServer, 'package.json'), + JSON.stringify({ name: 'quillgon-server', version, private: true, type: 'module', dependencies: { 'better-sqlite3': sqliteVersion } }, null, 2) + '\n', +); +const modules = path.join(outServer, 'node_modules'); +const addon = path.join(modules, 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node'); +const stampFile = path.join(modules, '.quillgon-native.json'); +const stamp = JSON.stringify({ electron: electronPkg.version, platform: process.platform, arch: process.arch, betterSqlite3: sqliteVersion }); +const cached = fs.existsSync(addon) && fs.existsSync(stampFile) && fs.readFileSync(stampFile, 'utf8') === stamp; + +if (cached) { + step(`better-sqlite3 ${sqliteVersion} already built for Electron ${electronPkg.version} (${process.platform}-${process.arch})`); +} else { + step(`Rebuilding better-sqlite3 ${sqliteVersion} for Electron ${electronPkg.version} (${process.platform}-${process.arch})`); + fs.rmSync(modules, { recursive: true, force: true }); + // Copy the package and its runtime dependencies (bindings β†’ file-uri-to-path), not the + // install-time ones (prebuild-install is run from the workspace copy below). + const installOnly = new Set(['prebuild-install']); + const seen = new Set(); + const copyPackage = (pkgJsonPath) => { + const dir = path.dirname(pkgJsonPath); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + if (seen.has(pkg.name)) return; + seen.add(pkg.name); + const skip = new Set(['node_modules', 'build', 'prebuilds'].map((d) => path.join(dir, d))); + copy(dir, path.join(modules, pkg.name), (src) => !skip.has(src)); + const req = createRequire(pkgJsonPath); + for (const dep of Object.keys(pkg.dependencies ?? {})) if (!installOnly.has(dep)) copyPackage(req.resolve(`${dep}/package.json`)); + }; + copyPackage(sqlitePkgPath); + + // A prebuilt binary from the better-sqlite3 release, if one exists for this Electron ABI + // (what `npm install` would do); otherwise compile it, which needs a C/C++ toolchain, Python 3 + // and the Electron headers from electronjs.org. + const prebuild = spawnSync( + process.execPath, + [createRequire(sqlitePkgPath).resolve('prebuild-install/bin.js'), '--runtime', 'electron', '--target', electronPkg.version, '--arch', process.arch, '--platform', process.platform], + { cwd: path.join(modules, 'better-sqlite3'), stdio: 'inherit' }, + ); + if (prebuild.status === 0 && fs.existsSync(addon)) { + console.log(' using the prebuilt binary'); + } else { + console.log(' no prebuilt binary for this Electron; building from source'); + try { + await rebuild({ buildPath: outServer, electronVersion: electronPkg.version, arch: process.arch, onlyModules: ['better-sqlite3'], force: true, buildFromSource: true }); + } catch (err) { + throw new Error(`Could not build better-sqlite3 for Electron ${electronPkg.version}: ${err.message}\nThis needs a C/C++ toolchain, Python 3 and network access to https://www.electronjs.org/headers.`); + } + } + if (!fs.existsSync(addon)) throw new Error(`Rebuild finished but ${addon} is missing`); + + // Keep only what loads at runtime: lib/ and the compiled addon (drops ~10 MB of SQLite sources). + const pkgDir = path.join(modules, 'better-sqlite3'); + for (const d of ['deps', 'src', 'binding.gyp']) fs.rmSync(path.join(pkgDir, d), { recursive: true, force: true }); + const keep = path.join(pkgDir, 'better_sqlite3.node.tmp'); + fs.renameSync(addon, keep); + fs.rmSync(path.join(pkgDir, 'build'), { recursive: true, force: true }); + fs.mkdirSync(path.dirname(addon), { recursive: true }); + fs.renameSync(keep, addon); + fs.writeFileSync(stampFile, stamp); +} + +// Prove the addon loads in Electron's Node before anything is packed. +const electronBin = requireApp('electron'); +const check = spawnSync( + electronBin, + ['-e', "const D = require('better-sqlite3'); const db = new D(':memory:'); console.log('sqlite', db.prepare('select sqlite_version() v').get().v); db.close();"], + { cwd: outServer, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, encoding: 'utf8' }, +); +if (check.status !== 0) { + fs.rmSync(stampFile, { force: true }); // rebuild next time + throw new Error(`better-sqlite3 does not load in Electron ${electronPkg.version}:\n${check.stderr || check.error}`); +} +console.log(` loads in Electron ${electronPkg.version}: ${check.stdout.trim()}`); + +// --- 4. the Electron shell +step('Staging the Electron shell'); +fs.mkdirSync(path.join(outApp, 'electron'), { recursive: true }); +for (const f of ['main.cjs', 'preload.cjs']) fs.copyFileSync(path.join(appDir, 'electron', f), path.join(outApp, 'electron', f)); +const rootPkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +fs.writeFileSync( + path.join(outApp, 'package.json'), + JSON.stringify( + { + name: 'quillgon', + productName: 'Quillgon', + // Linux: the .desktop file name, and Electron's app_id / WM_CLASS, so docks match the window. + desktopName: 'quillgon.desktop', + version, + description: rootPkg.description, + license: 'MIT', + author: { name: 'Quillgon contributors' }, + homepage: 'https://github.com/JeremySNR/quillgon', + main: 'electron/main.cjs', + }, + null, + 2, + ) + '\n', +); +console.log(`\nStaged Quillgon ${version} in ${path.relative(root, out)}/`); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a526638..a575759 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { AppProvider, useApp } from './lib/app'; +import { DialogsProvider } from './components/confirm'; import { CommandPalette, Sidebar, StatusBar, Toasts, TopBar, useRail, useShortcuts } from './components/shell'; import { Welcome } from './views/welcome'; import { Dashboard } from './views/dashboard'; @@ -80,8 +81,10 @@ function Shell() { export function App() { return ( - - - + + + + + ); } diff --git a/apps/desktop/src/components/confirm.test.tsx b/apps/desktop/src/components/confirm.test.tsx new file mode 100644 index 0000000..5f56aee --- /dev/null +++ b/apps/desktop/src/components/confirm.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DialogsProvider, useConfirm, usePrompt } from './confirm'; + +afterEach(cleanup); + +function Harness({ onApi }: { onApi: (api: { confirm: ReturnType; prompt: ReturnType }) => void }) { + onApi({ confirm: useConfirm(), prompt: usePrompt() }); + return ; +} + +function setup() { + let api!: { confirm: ReturnType; prompt: ReturnType }; + render( + + (api = a)} /> + , + ); + return api; +} + +describe('confirm and prompt dialogs', () => { + it('confirm resolves true on the confirm button', async () => { + const { confirm } = setup(); + let answer: Promise; + act(() => { + answer = confirm({ title: 'Delete s04?', message: 'Gone for good.', confirmLabel: 'Delete scene', danger: true }); + }); + expect(screen.getByRole('dialog', { name: 'Delete s04?' })).toBeTruthy(); + // The safe choice has focus first. + expect(document.activeElement?.textContent).toBe('Cancel'); + act(() => void fireEvent.click(screen.getByText('Delete scene'))); + await expect(answer!).resolves.toBe(true); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('confirm resolves false on Escape or Cancel', async () => { + const { confirm } = setup(); + let first: Promise; + act(() => { + first = confirm({ title: 'Revert?' }); + }); + act(() => void fireEvent.keyDown(document.activeElement!, { key: 'Escape' })); + await expect(first!).resolves.toBe(false); + let second: Promise; + act(() => { + second = confirm({ title: 'Revert?' }); + }); + act(() => void fireEvent.click(screen.getByText('Cancel'))); + await expect(second!).resolves.toBe(false); + }); + + it('prompt returns the trimmed value, validates, and returns null on cancel', async () => { + const { prompt } = setup(); + let name: Promise; + act(() => { + name = prompt({ title: 'New what-if', label: 'Name', initial: 'what-if-', validate: (v) => (v === 'main' ? 'Taken' : null) }); + }); + const input = screen.getByLabelText('Name') as HTMLInputElement; + expect(document.activeElement).toBe(input); + expect(input.value).toBe('what-if-'); + fireEvent.change(input, { target: { value: 'main' } }); + act(() => void fireEvent.submit(input.closest('form')!)); + expect(screen.getByText('Taken')).toBeTruthy(); + fireEvent.change(input, { target: { value: ' storm ' } }); + act(() => void fireEvent.submit(input.closest('form')!)); + await expect(name!).resolves.toBe('storm'); + + let cancelled: Promise; + act(() => { + cancelled = prompt({ title: 'Again', label: 'Name' }); + }); + act(() => void fireEvent.click(screen.getByText('Cancel'))); + await expect(cancelled!).resolves.toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/confirm.tsx b/apps/desktop/src/components/confirm.tsx new file mode 100644 index 0000000..813e3c6 --- /dev/null +++ b/apps/desktop/src/components/confirm.tsx @@ -0,0 +1,127 @@ +import { createContext, useCallback, useContext, useId, useMemo, useState, type ReactNode } from 'react'; +import { Button, Dialog, Field } from './ui'; + +export interface ConfirmOptions { + title: ReactNode; + message?: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + /** Style the confirm button as destructive. */ + danger?: boolean; +} + +export interface PromptOptions { + title: ReactNode; + message?: ReactNode; + label: string; + initial?: string; + placeholder?: string; + confirmLabel?: string; + /** Return an error message to keep the dialog open. */ + validate?: (value: string) => string | null; +} + +type Request = + | { kind: 'confirm'; opts: ConfirmOptions; resolve: (ok: boolean) => void } + | { kind: 'prompt'; opts: PromptOptions; resolve: (value: string | null) => void }; + +interface Dialogs { + confirm: (opts: ConfirmOptions) => Promise; + prompt: (opts: PromptOptions) => Promise; +} + +const Ctx = createContext(null); + +/** The app's replacement for window.confirm/prompt: promise-based, rendered with Dialog, one at a time. */ +export function DialogsProvider({ children }: { children: ReactNode }) { + const [queue, setQueue] = useState([]); + const confirm = useCallback((opts: ConfirmOptions) => new Promise((resolve) => setQueue((q) => [...q, { kind: 'confirm', opts, resolve }])), []); + const prompt = useCallback((opts: PromptOptions) => new Promise((resolve) => setQueue((q) => [...q, { kind: 'prompt', opts, resolve }])), []); + const value = useMemo(() => ({ confirm, prompt }), [confirm, prompt]); + const top = queue[0]; + const settle = (v: boolean | string | null) => { + if (!top) return; + setQueue((q) => q.slice(1)); + (top.resolve as (x: typeof v) => void)(v); + }; + return ( + + {children} + {top?.kind === 'confirm' && } + {top?.kind === 'prompt' && } + + ); +} + +function useDialogs(): Dialogs { + const ctx = useContext(Ctx); + if (!ctx) throw new Error('DialogsProvider is missing'); + return ctx; +} + +export const useConfirm = () => useDialogs().confirm; +export const usePrompt = () => useDialogs().prompt; + +function ConfirmDialog({ opts, onDone }: { opts: ConfirmOptions; onDone: (ok: boolean) => void }) { + return ( + onDone(false)} + footer={ + <> + + + + } + > + {opts.message &&

{opts.message}

} +
+ ); +} + +function PromptDialog({ opts, onDone }: { opts: PromptOptions; onDone: (value: string | null) => void }) { + const [value, setValue] = useState(opts.initial ?? ''); + const [error, setError] = useState(null); + const formId = useId(); + const submit = () => { + const v = value.trim(); + if (!v) return setError('Enter a name first.'); + const problem = opts.validate?.(v) ?? null; + if (problem) return setError(problem); + onDone(v); + }; + return ( + onDone(null)} + footer={ + <> + + + + } + > +
{ + e.preventDefault(); + submit(); + }} + > + {opts.message &&

{opts.message}

} + + e.target.setSelectionRange(e.target.value.length, e.target.value.length)} onChange={(e) => (setValue(e.target.value), setError(null))} /> + +
+
+ ); +} diff --git a/apps/desktop/src/components/shell.tsx b/apps/desktop/src/components/shell.tsx index ab75177..0715640 100644 --- a/apps/desktop/src/components/shell.tsx +++ b/apps/desktop/src/components/shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Command } from 'cmdk'; import { AlertTriangle, @@ -39,6 +39,8 @@ import { useApp, type View } from '../lib/app'; import { get, post } from '../lib/api'; import { ago, cx, isMac, keys, loadPref, mod, plural, savePref } from '../lib/util'; import { Button, Kbd, stageName } from './ui'; +import { usePrompt } from './confirm'; +import { menuKeyDown, useEscape } from '../lib/layers'; const NAV: { view: View; label: string; icon: typeof LayoutDashboard; shortcut: string }[] = [ { view: 'dashboard', label: 'Writing desk', icon: LayoutDashboard, shortcut: '1' }, @@ -129,21 +131,41 @@ const TITLES: Record = { }; export function TopBar() { - const { project, route, go, setPalette, toast, refresh } = useApp(); + const { project, route, go, setPalette, toast, refresh, fail } = useApp(); + const prompt = usePrompt(); const [menu, setMenu] = useState(false); const ref = useRef(null); + const trigger = useRef(null); + const menuRef = useRef(null); useEffect(() => { if (!menu) return; const h = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setMenu(false); window.addEventListener('mousedown', h); return () => window.removeEventListener('mousedown', h); }, [menu]); + // Opening the menu moves focus to its first item; Escape closes it and returns to the button. + useEffect(() => { + if (menu) menuRef.current?.querySelector('[role="menuitem"]')?.focus(); + }, [menu]); + const closeMenu = useCallback((refocus = true) => { + setMenu(false); + if (refocus) trigger.current?.focus(); + }, []); + useEscape(closeMenu, menu); if (!project) return null; const scene = route.view === 'scene' ? project.scenes.find((s) => s.id === route.id) : null; const branch = project.branches.current; const newWhatIf = async () => { - const name = prompt('Name this what-if (a git branch):', 'what-if-'); + closeMenu(); + const name = await prompt({ + title: 'New what-if', + message: 'A what-if is a git branch: changes made there stay there until you merge them into main.', + label: 'Name', + initial: 'what-if-', + confirmLabel: 'Create what-if', + validate: (v) => (project.branches.all.includes(v.replace(/\s+/g, '-')) ? 'A what-if with that name already exists.' : null), + }); if (!name) return; try { await post('/branches', { name: name.trim().replace(/\s+/g, '-') }); @@ -154,16 +176,24 @@ export function TopBar() { } }; const switchTo = async (b: string) => { - setMenu(false); - await post('/branches/switch', { name: b }); - toast({ kind: 'info', message: `Switched to ${b}` }); + closeMenu(); + try { + await post('/branches/switch', { name: b }); + toast({ kind: 'info', message: `Switched to ${b}` }); + } catch (e) { + fail(`Could not switch to ${b}`)(e); + } refresh(); }; const merge = async () => { - setMenu(false); - await post('/branches/switch', { name: 'main' }); - const r = await post<{ ok: boolean; conflicts: string[] }>('/branches/merge', { from: branch }); - toast(r.ok ? { kind: 'success', message: `Merged β€œ${branch}” into main` } : { kind: 'error', message: 'Merge conflicts', detail: r.conflicts.join(', ') }); + closeMenu(); + try { + await post('/branches/switch', { name: 'main' }); + const r = await post<{ ok: boolean; conflicts: string[] }>('/branches/merge', { from: branch }); + toast(r.ok ? { kind: 'success', message: `Merged β€œ${branch}” into main` } : { kind: 'error', message: 'Merge conflicts', detail: r.conflicts.join(', ') }); + } catch (e) { + fail(`Could not merge β€œ${branch}”`)(e); + } refresh(); }; @@ -187,27 +217,50 @@ export function TopBar() { )}
- {menu && ( -
-
What-ifs
+