diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..604ffc3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,67 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NEXT_TELEMETRY_DISABLED: "1"
+
+jobs:
+ quality:
+ name: Typecheck, tests, lint
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ cache: npm
+ - run: npm ci
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run lint
+
+ standalone:
+ name: Standalone (${{ matrix.platform }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Standard macos-15 runners are Apple Silicon (not macos-15-intel).
+ - os: macos-15
+ platform: mac-arm64
+ - os: windows-2025
+ platform: windows-x64
+ - os: ubuntu-24.04
+ platform: linux-x64
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 25
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ cache: npm
+ - run: npm ci
+ - name: Native renderer tests on this OS
+ run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
+ env:
+ NODE_ENV: test
+ ELECTRON_RUN_AS_NODE: "1"
+ - run: npm run build
+ - run: node scripts/build-server.mjs
+ - run: node scripts/smoke-server.mjs --electron
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d145c65
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,176 @@
+name: Desktop release
+
+on:
+ push:
+ tags: ["v*"]
+ # Manual runs are artifact-only, unsigned, and NEVER create a release.
+ # Use this for the first Windows/Linux dress rehearsal without secrets.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# Serialize separate runs for the same ref; never cancel an upload in flight.
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+env:
+ NEXT_TELEMETRY_DISABLED: "1"
+
+jobs:
+ quality:
+ name: Release gates
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ cache: npm
+ - run: npm ci
+ - name: Tag must match package.json
+ if: github.event_name == 'push'
+ run: |
+ node --input-type=module -e '
+ import fs from "node:fs";
+ const {version} = JSON.parse(fs.readFileSync("package.json", "utf8"));
+ if (process.env.GITHUB_REF_NAME !== `v${version}`) {
+ console.error("Release tag must equal v" + version);
+ process.exit(1);
+ }
+ '
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run lint
+
+ draft:
+ name: Prepare one draft
+ needs: quality
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: write
+ steps:
+ # This job succeeds without creating anything on manual rehearsals.
+ # Preparing once avoids the publisher's list-miss/create race between OS jobs.
+ - name: Create or reuse the tag's draft
+ if: github.event_name == 'push'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const {owner, repo} = context.repo;
+ const tag = context.ref.replace(/^refs\/tags\//, "");
+ const version = tag.slice(1);
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ owner, repo, per_page: 100,
+ });
+ const matches = releases.filter(release =>
+ release.tag_name === tag || release.tag_name === version);
+ if (matches.length > 1) {
+ throw new Error(`Multiple releases match ${tag}; resolve them before uploading.`);
+ }
+ if (matches.length === 1) {
+ if (!matches[0].draft) {
+ throw new Error(`${tag} is already published; refusing to modify it.`);
+ }
+ core.info(`Reusing draft ${matches[0].id} for ${tag}`);
+ return;
+ }
+ const {data} = await github.rest.repos.createRelease({
+ owner, repo, tag_name: tag, name: version, draft: true,
+ });
+ core.info(`Created draft ${data.id} for ${tag}`);
+
+ package:
+ name: Package (${{ matrix.platform }})
+ needs: [quality, draft]
+ permissions:
+ contents: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-15
+ platform: mac-arm64
+ builder_args: --mac --arm64
+ - os: windows-2025
+ platform: windows-x64
+ builder_args: --win --x64
+ - os: ubuntu-24.04
+ platform: linux-x64
+ builder_args: --linux --x64
+ runs-on: ${{ matrix.os }}
+ # Apple's notarization queue has taken ~2 hours for this team.
+ timeout-minutes: 180
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ cache: npm
+ - run: npm ci
+ - name: Native renderer tests on this OS
+ run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
+ env:
+ NODE_ENV: test
+ ELECTRON_RUN_AS_NODE: "1"
+ - run: npm run build
+ - run: node scripts/build-server.mjs
+ - run: node scripts/smoke-server.mjs --electron
+ - name: Validate macOS release secrets
+ if: github.event_name == 'push' && runner.os == 'macOS'
+ env:
+ CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ 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 }}
+ run: |
+ for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [[ -z "${!name}" ]]; then
+ echo "::error::Missing signing/notarization secret: $name"
+ exit 1
+ fi
+ done
+ - name: Signed and notarized macOS release → draft
+ if: github.event_name == 'push' && runner.os == 'macOS'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ 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 }}
+ run: npx electron-builder ${{ matrix.builder_args }} --publish always -c.forceCodeSigning=true
+ - name: Windows/Linux release → draft
+ if: github.event_name == 'push' && runner.os != 'macOS'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npx electron-builder ${{ matrix.builder_args }} --publish always
+ - name: Unsigned macOS rehearsal (no publish, no notarization)
+ if: github.event_name == 'workflow_dispatch' && runner.os == 'macOS'
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ run: npx electron-builder ${{ matrix.builder_args }} --publish never -c.mac.notarize=false
+ - name: Windows/Linux rehearsal (no publish)
+ if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
+ run: npx electron-builder ${{ matrix.builder_args }} --publish never
+ - name: Save installers and update metadata
+ uses: actions/upload-artifact@v4
+ with:
+ name: learnharness-${{ matrix.platform }}
+ retention-days: 7
+ if-no-files-found: error
+ path: |
+ dist/*.dmg
+ dist/*.zip
+ dist/*.exe
+ dist/*.AppImage
+ dist/*.deb
+ dist/*.blockmap
+ dist/latest*.yml
diff --git a/README.md b/README.md
index bd09a47..fdd4c03 100644
--- a/README.md
+++ b/README.md
@@ -24,11 +24,17 @@ A system whose premise is that it can be trusted is built so that it can be chec
## Download
-**[Releases](https://github.com/amosblomqvist/learnharness/releases)** — macOS on Apple Silicon, signed and notarized. Windows and Linux are planned.
+**[Releases](https://github.com/amosblomqvist/learnharness/releases)**
+
+- **macOS (Apple Silicon):** signed and notarized DMG.
+- **Windows (x64):** installer. Currently unsigned; Windows may show an “unknown publisher” / SmartScreen warning.
+- **Linux (x64):** AppImage (recommended) or Debian/Ubuntu `.deb`.
+
+All three platforms support in-app updates. AppImage updates need no administrator permission; `.deb` updates may request it.
## Quick start
-1. Download the DMG and drag the app to Applications.
+1. Download your platform’s package: drag the macOS app to Applications, run the Windows installer, or make the Linux AppImage executable and open it (alternatively, install the `.deb`).
2. Open Settings, paste your OpenRouter API key, and pick your models.
3. Start a session and say what you want to understand.
@@ -42,6 +48,8 @@ npm test # deterministic, no LLM, no network
The architecture spec and full development guide live in [`docs/`](docs/) — start with [`docs/architecture.md`](docs/architecture.md) and [`docs/development.md`](docs/development.md).
+Questions or bugs: [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues).
+
## License
[AGPL-3.0](LICENSE)
diff --git a/app/globals.css b/app/globals.css
index c902320..bc538d7 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -486,9 +486,9 @@ body {
/* ─────────────────────────────────────────────────────────────────────────
Electron merged chrome (owner, 2026-09-17). The system title bar is
- hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's
- own 60px header bands are the drag surface. Everything here is inert
- in the plain browser — it keys off , which
+ hidden (macOS hiddenInset; Windows/Linux hidden + native controls
+ overlay in electron/main.cjs); the app's 60px bands are the drag surface.
+ Everything here is inert in the plain browser — it keys off , which
SidebarProvider sets after mount when it detects the Electron shell.
- .lh-drag window-drag region — on a dedicated mask div in
@@ -532,13 +532,30 @@ body {
.lh-electron {
--lh-zoom: 1;
}
-.lh-electron .lh-lights-gutter {
+.lh-electron[data-lh-platform="darwin"] .lh-lights-gutter {
padding-left: calc(92px / var(--lh-zoom));
}
-.lh-electron .lh-closed-toggle {
+.lh-electron[data-lh-platform="darwin"] .lh-closed-toggle {
margin-left: calc(92px / var(--lh-zoom));
}
+/* Windows/Linux Window Controls Overlay: Chromium publishes the usable
+ titlebar rectangle in CSS coordinates (including resize/zoom changes).
+ Reserve the native buttons' actual right-side extent, no fixed width
+ and no second zoom division. The env fallbacks make fullscreen/absent
+ overlay a zero gutter. Keep this independent of .lh-drag: rename mode
+ still needs to clear the native buttons when dragging is disabled. */
+.lh-electron:is([data-lh-platform="win32"], [data-lh-platform="linux"]) {
+ --lh-wco-right: max(0px, calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw)));
+}
+.lh-electron .lh-wco-gutter {
+ padding-right: calc(0.75rem + var(--lh-wco-right, 0px));
+}
+.lh-electron .lh-wco-floating {
+ right: calc(0.5rem + var(--lh-wco-right, 0px));
+ max-width: max(0px, calc(100% - 5.5rem - var(--lh-wco-right, 0px)));
+}
+
/* --font-title deliberately NOT @theme inline (see comment above): the
session-name-serif toggle overrides this var at runtime, so the
.font-title utility must read it through var(), not carry a baked copy. */
diff --git a/components/graph-view.tsx b/components/graph-view.tsx
index aa67798..b4357f5 100644
--- a/components/graph-view.tsx
+++ b/components/graph-view.tsx
@@ -1590,7 +1590,7 @@ export function GraphView({
against the full-bleed parent) — the row NEVER wraps (owner
rejected the two-row fold); in narrow windows the search is
the flexible element and shrinks instead. */}
-
+
{/* z-20 (2026-09-20): the row paints above the cost number below
it — without this, the later-in-DOM cost control layered over
the settings/coverage dropdowns (owner report), still hoverable
diff --git a/components/minimal-home.tsx b/components/minimal-home.tsx
index b115185..47b14b8 100644
--- a/components/minimal-home.tsx
+++ b/components/minimal-home.tsx
@@ -180,7 +180,7 @@ export function MinimalHome({
window instead. Node search and the render-settings gear are
canvas things and stay gone; Import ALWAYS renders (the
entry law). */}
-
+
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index b552a4a..1f21140 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -56,6 +56,8 @@ declare global {
interface Window {
/** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */
lh?: {
+ /** Read-only host OS; determines the native-control gutter. */
+ platform?: string;
expandWindow: () => void;
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
@@ -91,6 +93,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
// and in the plain browser the class never appears.
if (IS_ELECTRON) {
document.documentElement.classList.add("lh-electron");
+ document.documentElement.dataset.lhPlatform = window.lh?.platform ?? "";
}
// THE ZOOM TRUTH (owner round 7, 2026-10-05): ⌘±/⌘0 zoom the UI
diff --git a/components/top-bar.tsx b/components/top-bar.tsx
index ec4a18b..6969116 100644
--- a/components/top-bar.tsx
+++ b/components/top-bar.tsx
@@ -53,7 +53,7 @@ export function TopBar({
return (
{children}
diff --git a/docs/desktop-release.md b/docs/desktop-release.md
new file mode 100644
index 0000000..3ac8301
--- /dev/null
+++ b/docs/desktop-release.md
@@ -0,0 +1,77 @@
+# Desktop builds and releases
+
+## Platforms
+
+| Platform | Build runner | Packages | In-app update |
+|---|---|---|---|
+| macOS, Apple Silicon | `macos-15` (arm64) | signed/notarized DMG + ZIP | ZIP via Squirrel.Mac |
+| Windows, x64 | `windows-2025` | unsigned per-user NSIS installer | NSIS |
+| Linux, x64 | `ubuntu-24.04` | AppImage + `.deb` | AppImage replacement; `.deb` through dpkg/apt with elevation if needed |
+
+Windows signing is deliberately disabled for this first release. SmartScreen may warn about an unknown publisher on initial installation. AppImage is the primary Linux download because it is self-contained and updates without administrator permission. The installed `electron-updater` 6.8.9 also supports `.deb` updates; an update may request an administrator password. Both use the same download/restart banner.
+
+The custom app bar stays on all three platforms. macOS keeps its left-side traffic lights; Windows/Linux use Electron's native right-side Window Controls Overlay, with CSS space reserved from the overlay's actual geometry. Windows/Linux hide the native menu until Alt is pressed. macOS keeps its system menu.
+
+The existing `build/icon.icns` is the source for all platforms: electron-builder converts it to ICO/Linux PNGs. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues).
+
+## Build pipeline
+
+Each OS installs its own dependencies and builds its own Next standalone tree:
+
+1. `npm ci`
+2. Native PDF/SVG renderer tests for that OS, running in Electron's bundled Node (no display needed).
+3. `npm run build` (`output: "standalone"`).
+4. `node scripts/build-server.mjs` — copy static assets, refuse leaked data/keys, remove only standalone-root `dist/` and `node_modules/electron/` (old installers and the development runtime), check prompt assets. Runtime dependencies' own `dist/` directories and native renderer binaries stay intact.
+5. `node scripts/smoke-server.mjs --electron` — boot the standalone server in Electron's bundled Node, using empty temporary data/auth, then check the home route and GET-only APIs.
+6. `npx electron-builder --publish `.
+
+The shell/updater live in `app.asar`; the standalone server and native renderer binaries ship unpacked as `extraResources`. **Do not cross-build the server payload.** Next traces the build machine's native binaries, so a macOS-built standalone tree is not a Windows/Linux payload.
+
+`npm run desktop:dist` remains the local macOS signing path, using the `LH_NOTARY` keychain profile. For a local Windows/Linux build, run steps 1–5 above, then:
+
+```sh
+# On Windows:
+npx electron-builder --win --x64 --publish never
+
+# On Linux:
+npx electron-builder --linux --x64 --publish never
+```
+
+## CI vs release
+
+`.github/workflows/ci.yml` runs on main pushes and pull requests:
+
+- Typecheck, full deterministic test suite, and lint on Ubuntu.
+- Native renderer tests plus standalone build/boot-smoke on macOS, Windows, and Linux.
+
+`.github/workflows/release.yml` has two deliberately separate modes:
+
+- **Manual / Run workflow:** unsigned rehearsal installers saved as workflow artifacts for 7 days. No signing secrets needed, no release created, and nothing published. macOS notarization is skipped. Use the Windows/Linux artifacts for the first human smoke pass. These are not production macOS installers.
+- **Push `v`:** gates first, then one job creates or reuses a **single draft GitHub Release** before the three OS jobs package/upload their artifacts and update metadata in parallel. This avoids competing draft creation. A published or ambiguous existing release fails the preparation step instead of being modified. Separate runs for the same ref are serialized, never cancelled mid-upload. The tag must match the package version. macOS must sign and notarize successfully; missing secrets fail loudly. The workflow never publishes the draft.
+
+The three jobs upload distinct updater feeds: `latest-mac.yml`, `latest.yml`, `latest-linux.yml`, with the matching ZIP/NSIS/AppImage/deb assets and blockmaps. Do not publish a partially populated draft. The app uses the baked GitHub feed without a token: **the repository must be public before end users can download updates.** Making the repository public and publishing the release remain the owner's actions.
+
+## macOS CI secrets
+
+Add these under repository **Settings → Secrets and variables → Actions**:
+
+| Secret | Value |
+|---|---|
+| `MAC_CSC_LINK` | Base64-encoded Developer ID Application `.p12` backup, including its private key |
+| `MAC_CSC_KEY_PASSWORD` | Password protecting that `.p12` |
+| `APPLE_ID` | Apple ID used for notarization |
+| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for that Apple ID |
+| `APPLE_TEAM_ID` | `JS6XN22HY6` |
+
+The workflow maps the certificate secrets to electron-builder's `CSC_LINK` / `CSC_KEY_PASSWORD`. GitHub's built-in `GITHUB_TOKEN` supplies `GH_TOKEN` with job-scoped `contents: write`; no separate GitHub token secret is required. Signing credentials are present only for the packaging step, not the Next build. Never commit certificates, passwords, or `.env.local`.
+
+## First release runbook
+
+1. Push the implementation; run the manual Desktop release workflow.
+2. Human smoke on Windows/Linux: install/open, window buttons and dragging, sidebar at narrow widths, zoom, setup/keys, session/resume, native PDF/SVG verification and demo helper. macOS remains signed/notarized through the production path.
+3. Add macOS signing/notarization secrets.
+4. Bump `package.json` and the lockfile to the agreed release version; run local gates and push a matching `v...` tag.
+5. Wait for **all three** packaging jobs. Verify all installer assets and updater feeds are in the draft.
+6. Owner makes the repository public, publishes the complete draft, then checks the real GitHub update path using a prior installed version.
+
+A full human pass is required before the first three-platform release. Subsequent content-only updates rely on the deterministic suite plus the per-OS build/boot-smoke matrix. Changes to `electron/`, native modules, packaging/updater configuration, or Electron itself call for another platform smoke pass.
diff --git a/docs/development.md b/docs/development.md
index a6c5026..edd42d0 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -24,7 +24,7 @@ npm run lint # eslint — includes the import law (architecture §2)
npm run typecheck # tsc --noEmit
npm run desktop:dev # next dev + Electron shell pointed at it
-npm run desktop:dist # build + package the desktop app (electron-builder)
+npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
# one-shot data scripts (run from the master tree only — data never crosses trees)
npx tsx --env-file=.env.local scripts/import-notes.ts # bulk-import notes → graph
@@ -33,6 +33,8 @@ npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
```
+For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
+
## Environment (`.env.local`)
Secrets and machine-specific paths only. Model choice and thinking budgets are the user's, not the environment's — BYOK model slots live in **settings** (`models` section: strong/cheap/vision, provider + model id + optional thinking level; per-role and per-chat overrides ride on top). The extraction batch threshold is a settings knob too (`graph.extractionChunkTokens`, default 4096, floor 500).
@@ -42,9 +44,9 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
| `OPENROUTER_API_KEY` | required — all LLM calls go through OpenRouter |
| `GOOGLE_SEARCH_API_KEY` + `GOOGLE_CSE_ID` | researcher's `web_search` (Google Custom Search) |
| `DEEPGRAM_API_KEY` | optional — dictation (STT token minting; audio streams browser→Deepgram, never through the server) |
-| `CHROME_PATH` | optional explicit Chrome path for headless verification (puppeteer-core uses system Chrome; nothing is downloaded) |
+| `CHROME_PATH` | optional system Chrome fallback for demo verification; the app's own browser helper is primary (nothing is downloaded) |
-The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, injected into the server child as env only).
+The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
## Architecture
@@ -117,7 +119,7 @@ src/
subagents/ researcher · demo-builder · visual-maker
· pdf-reader,
extraction pipeline (extractor/auditor + merger handoff),
- runner (async protocol), headless Chrome helper,
+ runner (async protocol), native renderers + browser-service helper,
abort registry
server/chat-handler.ts THE turn composition root (liveness · steers · persist · stream)
server/turn-service.ts turn seams: persist · extraction trigger
@@ -154,7 +156,7 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
-**The async sub-agent protocol (ack-and-steer).** `research` / `demo` / `visual` are server-executed tools: the call resolves immediately mid-turn with a fire-and-forget ack and the teacher keeps teaching; a background runner works in parallel (the client polls `/api/activity` and `/api/jobs`). On completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client — and the **wake pipeline** delivers it: a per-chat serialized queue waits for the turn to end and no question to be pending, then fires a server-side teacher turn that weaves the result into the lesson. Delivery is exactly-once (deterministic steer ids, delivered-flag checks at every claim point); stalled jobs are recovered on read; aborted jobs deliver an abort steer. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate) and renders steers as artifacts — never a bubble. Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (vision-capable model — the `vision` slot in settings).
+**The async sub-agent protocol (ack-and-steer).** `research` / `demo` / `visual` are server-executed tools: the call resolves immediately mid-turn with a fire-and-forget ack and the teacher keeps teaching; a background runner works in parallel (the client polls `/api/activity` and `/api/jobs`). On completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client — and the **wake pipeline** delivers it: a per-chat serialized queue waits for the turn to end and no question to be pending, then fires a server-side teacher turn that weaves the result into the lesson. Delivery is exactly-once (deterministic steer ids, delivered-flag checks at every claim point); stalled jobs are recovered on read; aborted jobs deliver an abort steer. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate) and renders steers as artifacts — never a bubble. Sub-agents **verify, never one-shot**: the demo-builder executes its HTML in the app's own Chromium browser-service (system Chrome fallback) before delivery; the visual-maker rasterizes each SVG natively and looks at it (vision-capable model — the `vision` slot in settings).
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees the source — the session's teacher prose or a notes chunk — plus its own source's digest (**THE GRAPH SO FAR**: propositions + chains already extracted from this source, ids verbatim — digest-aware redesign, 2026-09-30 evening: the extractor IS the assembler, referencing existing ids instead of restating, leaning on established conclusions as premises); fully trusted; its deliverable is core ideas + reasoning chains: premises that jointly entail a conclusion, submitted as chains and stored as chains — `chains.jsonl` is the truth, edges a derived view; owner redesign + chains-primary, 2026-09-30) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools and stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes chains — identical arguments merge under one `arg_` id; then the chain-simplification pass strips provably redundant derivations — prefer-fewest-premises + ancestry-cover — checkpoints coverage) → layout settle. A source's batches run strictly in order (each extractor continues the digest of every earlier merge). No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 8192, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read (`deriveView` rebuilds the lossy image: each chain an implication node `⟹` with premise→implication→conclusion arrows, so two arguments for one conclusion stay two diamonds); the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
diff --git a/electron-builder.yml b/electron-builder.yml
index ebbe9f7..ba15b18 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -1,4 +1,4 @@
-# electron-builder config — LearnHarness desktop (macOS arm64 first).
+# electron-builder config — LearnHarness desktop (macOS arm64; Windows/Linux x64).
#
# Shape (fact-checked 2026-09-17):
# - the app's real server (.next/standalone, curated by
@@ -114,6 +114,33 @@ mac:
notarize: true
dmg:
title: "${productName} ${version}"
+win:
+ target:
+ - target: nsis
+ arch:
+ - x64
+ artifactName: "${productName}-${version}-${arch}-setup.${ext}"
+ # Unsigned Windows release (owner ruling): preserve icon/version metadata,
+ # skip signing, and do not require a publisher signature for updates.
+ signExecutable: false
+ verifyUpdateCodeSignature: false
+nsis:
+ oneClick: true
+ perMachine: false
+linux:
+ target:
+ - target: AppImage
+ arch:
+ - x64
+ - target: deb
+ arch:
+ - x64
+ artifactName: "${productName}-${version}-${arch}.${ext}"
+ category: Education
+ # Match Electron's app_id/WM_CLASS to the installed desktop launcher.
+ syncDesktopName: true
+ # Both targets update via electron-updater 6.8.9. AppImage replaces its
+ # own file; deb uses dpkg/apt and may request administrator permission.
publish:
# Update feed = GitHub Releases (decided 2026-10-08): latest-mac.yml +
# zip/blockmap live on releases; electron-updater reads them anonymously.
@@ -123,4 +150,6 @@ publish:
# upload; publishing happens at release time (GH_TOKEN + --publish).
- provider: github
owner: amosblomqvist
- repo: learnharness
\ No newline at end of file
+ repo: learnharness
+ # All three CI jobs contribute assets; only the owner publishes it.
+ releaseType: draft
diff --git a/electron/main.cjs b/electron/main.cjs
index c634f10..def71c5 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -258,8 +258,16 @@ function createWindow(url) {
// (band 0–59.5, pill box 19–39.5) — y 22.5 lands the circles at
// 29.25, exactly on the pill. Wordmark gutter: components/sidebar.tsx.
// Dragging comes from the renderer's .lh-drag bands (app/globals.css).
- titleBarStyle: "hiddenInset",
- trafficLightPosition: { x: 16, y: 22.5 },
+ ...(process.platform === "darwin"
+ ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 22.5 } }
+ : {
+ titleBarStyle: "hidden",
+ // Native min/max/close on the right; the renderer reserves their
+ // actual width via env(titlebar-area-*), not a guessed gutter.
+ titleBarOverlay: { color: "#141414", symbolColor: "#e5e5e5", height: 60 },
+ // Keep the custom bar alone; Alt reveals the native menu.
+ autoHideMenuBar: true,
+ }),
webPreferences: {
contextIsolation: true,
sandbox: true,
@@ -361,7 +369,9 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
* Feed: GitHub Releases (electron-builder.yml publish → app-update.yml,
* baked + signed into the bundle). macOS needs the app signed (it is —
* Developer ID) and updates ride the zip target (Squirrel.Mac); blockmaps
- * give differential downloads for free.
+ * give differential downloads for free. Windows uses NSIS; Linux uses
+ * AppImage or DebUpdater (6.8.9 supports deb; installation can request
+ * administrator permission via the package manager — owner 2026-10-11).
*
* Hard rules:
* - DEV NEVER UPDATES: !app.isPackaged skips the updater entirely — the
diff --git a/electron/preload.cjs b/electron/preload.cjs
index 421763f..1751ae0 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -4,6 +4,9 @@
* on (electron/main.cjs), so this file is the renderer's entire access to
* Electron — keep it a minimal, explicit API surface.
*
+ * window.lh.platform — read-only OS identity for native window-control
+ * gutters (darwin: left traffic lights; win32/linux: right overlay).
+ *
* window.lh.expandWindow() — widen the window to the sidebar auto-close
* threshold (928px) when the sidebar is opened below it. The main
* process clamps: no-op if the window is already wide enough.
@@ -25,6 +28,7 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
+ platform: process.platform,
expandWindow: () => ipcRenderer.send("lh:expand-window"),
onZoom: (cb) => {
const listener = (_event, factor) => cb(factor);
diff --git a/next.config.ts b/next.config.ts
index e9e1390..8c6c6b2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -28,11 +28,13 @@ const nextConfig: NextConfig = {
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
// Anchored "./"-prefixed forms behave the same. Only the three entries
// below are verified safe (complete externals trace + clean payload).
- // The 7.4 GB recursive-DMG payload (repo dist/ swept into the bundle,
- // shipping the previous build inside the next) is instead prevented by
- // the `rm -rf dist .next/standalone` clean step in desktop:dist —
- // verified sufficient, since the sweep can only copy what exists at
- // trace time. scripts/build-server.mjs guards the rest.
+ // The recursive-DMG payload (repo dist/ swept into the bundle, shipping
+ // the previous build inside the next) is instead prevented by the
+ // desktop:dist clean step AND targeted post-trace curation in
+ // scripts/build-server.mjs (2026-10-11): remove standalone-root dist/
+ // and node_modules/electron/ (the demo helper's DEV runtime; packaged
+ // helpers use the app's own binary). Runtime dependencies' dist/ dirs
+ // stay intact, and the script guards against leaked data/keys.
outputFileTracingExcludes: {
"*": [
"data/**/*",
diff --git a/package.json b/package.json
index 3d58ebd..dcbb9fe 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,12 @@
"name": "learnharness",
"version": "0.2.0",
"license": "AGPL-3.0-only",
+ "author": {
+ "name": "Amos Blomqvist",
+ "email": "255718537+amosblomqvist@users.noreply.github.com"
+ },
+ "homepage": "https://github.com/amosblomqvist/learnharness",
+ "desktopName": "learnharness.desktop",
"description": "A standalone learning harness: a teacher agent (AI SDK) with quiz/ask interactions, session artifacts, and a derived knowledge graph of what the learner knows.",
"type": "module",
"private": true,
diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs
index cae13ec..bbb1480 100644
--- a/scripts/build-server.mjs
+++ b/scripts/build-server.mjs
@@ -6,7 +6,9 @@
* outside that the docs require us to copy: public/ and .next/static/.
* This script finishes the job and sanity-checks the result — including
* the exclusion that keeps the repo's real data/ OUT of the bundle
- * (see next.config.ts outputFileTracingExcludes).
+ * (see next.config.ts outputFileTracingExcludes). It also removes two
+ * exact build-only paths the tracer sweeps in: old installers and the
+ * development Electron runtime. Broad trace exclusions break externals.
*/
import * as fs from "node:fs";
import * as path from "node:path";
@@ -58,6 +60,22 @@ for (const secret of ["auth.json", ".env", ".env.local"]) {
}
}
+// 2c) Payload curation (2026-10-11): the trace sweep can copy repo-root
+// dist/ (previous installers, recursively) and the development Electron
+// runtime (the demo helper's dev-only require). Neither belongs in the
+// server: packaged helpers use process.execPath, the app's own binary.
+// Remove ONLY these standalone-root paths AFTER tracing — excluding a
+// generic dist/** earlier broke pi-ai / pi-agent-core's runtime dist/.
+// Never touch ROOT/dist or ROOT/node_modules/electron (the running app
+// and the build runtime); native canvas/resvg packages stay untouched.
+for (const extra of ["dist", path.join("node_modules", "electron")]) {
+ const target = path.join(STANDALONE, extra);
+ if (exists(target)) {
+ fs.rmSync(target, { recursive: true, force: true });
+ console.log(`[build-server] removed build-only payload: ${extra}`);
+ }
+}
+
// 3) Sanity: the runtime-read prompt assets must be present (the teacher
// agent reads them from disk at request time).
for (const asset of [
diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs
index 023873f..5721d01 100644
--- a/scripts/smoke-server.mjs
+++ b/scripts/smoke-server.mjs
@@ -12,7 +12,9 @@
* in seconds, not after a full packaging pass.
*
* What it does: spawns .next/standalone/server.js exactly the way the
- * Electron shell does (electron/main.cjs) — 127.0.0.1 on a kernel-assigned
+ * Electron shell does (electron/main.cjs) — with --electron it uses
+ * Electron's bundled Node via ELECTRON_RUN_AS_NODE, otherwise host Node —
+ * 127.0.0.1 on a kernel-assigned
* free port, NODE_ENV=production, a THROWAWAY LH_DATA_DIR and an EMPTY
* LH_AUTH_FILE under os.tmpdir() (no real keys or data ever touch it) —
* polls GET / until 200, then probes the GET-only API surface (graph /
@@ -23,6 +25,7 @@
* way, same day).
*/
import { spawn } from "node:child_process";
+import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
import * as net from "node:net";
@@ -32,6 +35,8 @@ import * as path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const STANDALONE = path.join(ROOT, ".next", "standalone");
const SERVER = path.join(STANDALONE, "server.js");
+const ELECTRON_MODE = process.argv.includes("--electron");
+const RUNTIME = ELECTRON_MODE ? createRequire(import.meta.url)("electron") : process.execPath;
const POLL_MS = 500;
const BOOT_TIMEOUT_MS = 30_000;
@@ -95,10 +100,12 @@ async function main() {
for (const line of tail.slice(-40)) if (line.trim()) console.error(` ${line}`);
};
- const child = spawn(process.execPath, [SERVER], {
+ console.log(`[smoke-server] runtime: ${ELECTRON_MODE ? "Electron bundled Node" : "host Node"}`);
+ const child = spawn(RUNTIME, [SERVER], {
cwd: STANDALONE,
env: {
...process.env,
+ ...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "127.0.0.1",
diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts
index 3c66d13..296b01a 100644
--- a/src/agent/subagents/headless.ts
+++ b/src/agent/subagents/headless.ts
@@ -5,16 +5,14 @@ import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/**
- * Shared headless-browser helper for the verifying sub-agents (W6):
- * the demo-builder executes demos and captures errors; the visual-maker
- * rasterizes SVGs so it can look at them. Both use the SAME system Chrome
- * via puppeteer-core — NO bundled chromium is downloaded.
+ * Verification engines for sub-agents: demos use the app's own Electron
+ * browser-service via puppeteer-core, with system Chrome as fallback.
+ * SVGs use resvg (browser fallback); PDFs use pdfjs + Skia (no browser).
+ * No extra Chromium is shipped or downloaded.
*
- * Chrome resolution: process.env.CHROME_PATH first, then the common
- * macOS/Linux install paths. If none exists we fall back to puppeteer's
- * `channel: "chrome"` at launch time; `available()` is cheap and only knows
- * about the explicit candidates, so a channel-only setup reports unavailable
- * and the builders refuse to run (the house rule: never skip testing).
+ * Chrome fallback resolution: CHROME_PATH first, then common macOS,
+ * Windows and Linux install paths. `available()` is a cheap executable
+ * check; the helper must still prove it can launch/connect at job time.
*
* Test seam: `headlessOverride.current` replaces the whole API (tests inject
* a fake; nothing here touches a real browser). Builders call `getHeadless()`.
@@ -58,16 +56,15 @@ export interface RenderedPdfPage {
}
export interface HeadlessApi {
- /** True when a Chrome executable is resolvable (cheap, no launch). */
+ /** True when a browser helper or Chrome is resolvable (cheap, no launch). */
available(): boolean;
/** Why not — for error messages. */
unavailableReason(): string | null;
- /** Load the HTML in headless Chrome, capture errors, optionally probe. */
+ /** Execute HTML in a browser, capture errors, optionally probe. */
runDemo(html: string, opts?: RunDemoOptions): Promise;
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
renderSvg(svg: string, opts?: RenderSvgOptions): Promise;
- /** Render PDF pages to PNG buffers via Chrome's pdfium viewer (for the
- * pdf-reader sub-agent's look-at-the-page loop). */
+ /** Render PDF pages to PNGs natively (pdfjs + Skia) for the pdf-reader. */
renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise;
}
@@ -81,6 +78,11 @@ export function getHeadless(): HeadlessApi {
// ─── Chrome resolution ───────────────────────────────────────────────────────
const CHROME_CANDIDATES = [
+ ...(process.platform === "win32" ? [
+ path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
+ path.join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"),
+ ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")] : []),
+ ] : []),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
diff --git a/tests/desktop-payload.test.ts b/tests/desktop-payload.test.ts
new file mode 100644
index 0000000..cbd3069
--- /dev/null
+++ b/tests/desktop-payload.test.ts
@@ -0,0 +1,96 @@
+/** Run the real payload-curation script in throwaway repository fixtures. */
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { describe, it } from "node:test";
+
+const source = fs.readFileSync(new URL("../scripts/build-server.mjs", import.meta.url), "utf8");
+const STANDALONE = path.join(".next", "standalone");
+const PROMPT = path.join("src", "agent", "system-prompt.md");
+
+function withPayload(test: (fixture: {
+ root: string;
+ write: (relative: string, content?: string) => void;
+ run: () => ReturnType;
+}) => void) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-payload-test-"));
+ const write = (relative: string, content = "fixture") => {
+ const target = path.join(root, relative);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, content);
+ };
+ try {
+ write(path.join("scripts", "build-server.mjs"), source);
+ write(path.join(STANDALONE, "server.js"));
+ write(path.join(".next", "static", "runtime.js"));
+ write(path.join("public", "asset.txt"));
+ write(path.join(STANDALONE, PROMPT));
+ write(path.join(STANDALONE, "src", "agent", "prompts", "teacher", "20-teaching.md"));
+ test({ root, write, run: () => spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], {
+ cwd: root, encoding: "utf8", timeout: 10_000,
+ }) });
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+}
+
+describe("desktop standalone payload", () => {
+ it("removes only traced installers/dev Electron, preserving runtime dist/ and native binaries", () => {
+ withPayload(({ root, write, run }) => {
+ for (const extra of [path.join("dist", "old.app", "nested.dmg"), path.join("node_modules", "electron", "dist", "electron.exe")]) {
+ // BOTH copies exist: curation must not touch the real repo's build/runtime.
+ write(extra);
+ write(path.join(STANDALONE, extra));
+ }
+ const runtime = [
+ "node_modules/@earendil-works/pi-ai/dist/index.js",
+ "node_modules/@earendil-works/pi-agent-core/dist/index.js",
+ "node_modules/@napi-rs/canvas-darwin-arm64/skia.darwin-arm64.node",
+ "node_modules/@resvg/resvg-js-win32-x64-msvc/resvgjs.win32-x64-msvc.node",
+ "node_modules/electron-updater/out/main.js",
+ ];
+ for (const file of runtime) write(path.join(STANDALONE, file));
+ const result = run();
+ assert.equal(result.status, 0, String(result.stderr));
+ assert.equal(fs.existsSync(path.join(root, STANDALONE, "dist")), false);
+ assert.equal(fs.existsSync(path.join(root, STANDALONE, "node_modules", "electron")), false);
+ assert.ok(fs.existsSync(path.join(root, "dist", "old.app", "nested.dmg")));
+ assert.ok(fs.existsSync(path.join(root, "node_modules", "electron", "dist", "electron.exe")));
+ for (const file of runtime) assert.ok(fs.existsSync(path.join(root, STANDALONE, file)), file);
+ assert.ok(fs.existsSync(path.join(root, STANDALONE, ".next", "static", "runtime.js")));
+ assert.ok(fs.existsSync(path.join(root, STANDALONE, "public", "asset.txt")));
+ });
+ });
+
+ it("accepts a clean payload and is idempotent", () => {
+ withPayload(({ run }) => {
+ for (let i = 0; i < 2; i++) {
+ const result = run();
+ assert.equal(result.status, 0, String(result.stderr));
+ assert.match(String(result.stdout), /server payload ready/);
+ }
+ });
+ });
+
+ it("still refuses leaked developer data or keys", () => {
+ for (const leaked of ["data/chat.json", "auth.json", ".env", ".env.local"]) {
+ withPayload(({ write, run }) => {
+ write(path.join(STANDALONE, leaked));
+ const result = run();
+ assert.equal(result.status, 1, leaked);
+ assert.match(String(result.stderr), /REFUSING/);
+ });
+ }
+ });
+
+ it("still refuses a missing runtime prompt asset", () => {
+ withPayload(({ root, run }) => {
+ fs.rmSync(path.join(root, STANDALONE, PROMPT));
+ const result = run();
+ assert.equal(result.status, 1);
+ assert.match(String(result.stderr), /prompt asset missing/);
+ });
+ });
+});
diff --git a/tests/desktop-platform.test.ts b/tests/desktop-platform.test.ts
new file mode 100644
index 0000000..dec4e0b
--- /dev/null
+++ b/tests/desktop-platform.test.ts
@@ -0,0 +1,200 @@
+/** Platform window chrome + packaging policy, without launching Electron. */
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { runInNewContext } from "node:vm";
+import { describe, it } from "node:test";
+
+const require = createRequire(import.meta.url);
+const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
+const mainSource = readFileSync(mainFile, "utf8");
+
+/** Decline the single-instance lock so normal boot never spawns a server.
+ * Then call the REAL createWindow with a stub Electron API and inspect its
+ * constructor options. No rewritten copy of the production logic. */
+function windowOptions(platform: string) {
+ class FakeWindow {
+ constructor(readonly options: unknown) {}
+ webContents = { on() {}, setWindowOpenHandler() {} };
+ loadURL() {}
+ once() {}
+ }
+ const electron = {
+ app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
+ BrowserWindow: FakeWindow,
+ session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
+ };
+ const context = {
+ require: (id: string) => id === "electron" ? electron : require(id),
+ process: { platform, argv: [], env: {}, on() {} },
+ __dirname: dirname(mainFile),
+ };
+ runInNewContext(mainSource, context, { filename: mainFile });
+ const win = runInNewContext('createWindow("http://127.0.0.1:9000")', context) as FakeWindow;
+ // The options originate in a VM realm; normalize prototypes for assertions.
+ return JSON.parse(JSON.stringify(win.options));
+}
+
+describe("desktop window chrome", () => {
+ it("macOS keeps the proven traffic lights and does not enable WCO", () => {
+ const options = windowOptions("darwin");
+ assert.equal(options.titleBarStyle, "hiddenInset");
+ assert.deepEqual(options.trafficLightPosition, { x: 16, y: 22.5 });
+ assert.equal(options.titleBarOverlay, undefined);
+ assert.equal(options.autoHideMenuBar, undefined);
+ });
+
+ for (const platform of ["win32", "linux"]) {
+ it(`${platform} uses native right-side controls with an Alt-accessible menu`, () => {
+ const options = windowOptions(platform);
+ assert.equal(options.titleBarStyle, "hidden");
+ assert.deepEqual(options.titleBarOverlay, { color: "#141414", symbolColor: "#e5e5e5", height: 60 });
+ assert.equal(options.trafficLightPosition, undefined);
+ assert.equal(options.autoHideMenuBar, true);
+ assert.equal(options.webPreferences.contextIsolation, true);
+ assert.equal(options.webPreferences.sandbox, true);
+ assert.equal(options.webPreferences.nodeIntegration, false);
+ });
+ }
+
+ it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
+ let bridge: Record = {};
+ const context = {
+ process: { platform: "win32" },
+ require: () => ({
+ contextBridge: {
+ exposeInMainWorld(name: string, api: Record) {
+ assert.equal(name, "lh");
+ bridge = api;
+ },
+ },
+ ipcRenderer: {},
+ }),
+ };
+ runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
+ assert.equal(bridge.platform, "win32");
+ assert.deepEqual(Object.keys(bridge).sort(), [
+ "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
+ ]);
+ });
+});
+
+describe("desktop packaging policy", () => {
+ const yaml = require("js-yaml") as { load(source: string): Record };
+ const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8"));
+
+ it("configuration is accepted by the INSTALLED electron-builder schema", async () => {
+ const { validateConfiguration } = require("app-builder-lib/out/util/config/config.js");
+ await validateConfiguration(config, { isEnabled: false });
+ });
+
+ it("ships Mac arm64, Windows NSIS x64, and both Linux x64 channels", () => {
+ assert.deepEqual(config.mac.target, [
+ { target: "dmg", arch: ["arm64"] }, { target: "zip", arch: ["arm64"] },
+ ]);
+ assert.deepEqual(config.win.target, [{ target: "nsis", arch: ["x64"] }]);
+ assert.equal(config.win.signExecutable, false);
+ assert.equal(config.win.verifyUpdateCodeSignature, false);
+ assert.deepEqual(config.linux.target, [
+ { target: "AppImage", arch: ["x64"] }, { target: "deb", arch: ["x64"] },
+ ]);
+ assert.equal(config.linux.syncDesktopName, true);
+ assert.equal(config.publish[0].releaseType, "draft");
+ });
+
+ it("deb has public maintainer metadata and a matching desktop identity", () => {
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+ assert.equal(pkg.author.name, "Amos Blomqvist");
+ assert.equal(pkg.author.email, "255718537+amosblomqvist@users.noreply.github.com");
+ assert.equal(pkg.homepage, "https://github.com/amosblomqvist/learnharness");
+ assert.equal(pkg.desktopName, "learnharness.desktop");
+ });
+});
+
+describe("desktop release workflow", () => {
+ type Step = { if?: string; run?: string; env?: Record; with?: { script: string } };
+ type Workflow = {
+ concurrency: { group: string; "cancel-in-progress": boolean };
+ jobs: Record;
+ };
+ const yaml = require("js-yaml") as { load(source: string): Workflow };
+ const workflow = yaml.load(readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"));
+ const draft = workflow.jobs.draft;
+ const packaging = workflow.jobs.package;
+ assert.ok(draft && packaging);
+ const prepare = draft.steps[0];
+ assert.ok(prepare?.with);
+ const script = prepare.with.script;
+ type Release = { id: number; tag_name: string; draft: boolean };
+
+ /** Execute the REAL action script with an in-memory GitHub API. */
+ async function prepareDraft(releases: Release[]) {
+ const created: Record[] = [];
+ const listReleases = () => {};
+ await runInNewContext(`(async () => { ${script} })()`, {
+ context: { repo: { owner: "test-owner", repo: "test-repo" }, ref: "refs/tags/v0.3.0" },
+ core: { info() {} },
+ github: {
+ paginate: async (method: unknown, options: unknown) => {
+ assert.equal(method, listReleases);
+ assert.deepEqual(JSON.parse(JSON.stringify(options)), {
+ owner: "test-owner", repo: "test-repo", per_page: 100,
+ });
+ return releases;
+ },
+ rest: { repos: {
+ listReleases,
+ createRelease: async (options: Record) => {
+ created.push(JSON.parse(JSON.stringify(options)));
+ return { data: { id: 123 } };
+ },
+ } },
+ },
+ });
+ return created;
+ }
+
+ it("prepares once after gates, never on rehearsals, before parallel packaging", () => {
+ assert.equal(draft.needs, "quality");
+ assert.equal(prepare.if, "github.event_name == 'push'");
+ assert.deepEqual(packaging.needs, ["quality", "draft"]);
+ assert.equal(workflow.concurrency.group, "release-${{ github.ref }}");
+ assert.equal(workflow.concurrency["cancel-in-progress"], false);
+ for (const filename of ["ci.yml", "release.yml"]) {
+ const config = yaml.load(readFileSync(new URL(`../.github/workflows/${filename}`, import.meta.url), "utf8"));
+ const job = config.jobs[filename === "ci.yml" ? "standalone" : "package"];
+ assert.ok(job);
+ const steps = job.steps;
+ const native = steps.find(step => step.run?.includes("pdf-native-render.test.ts"));
+ assert.ok(native);
+ assert.ok(native.run?.startsWith("npx electron --import tsx --test "));
+ assert.equal(native.env?.ELECTRON_RUN_AS_NODE, "1");
+ assert.ok(steps.some(step => step.run === "node scripts/smoke-server.mjs --electron"));
+ }
+ });
+
+ it("creates exactly one unpublished draft when none matches", async () => {
+ assert.deepEqual(await prepareDraft([{ id: 9, tag_name: "v0.2.0", draft: false }]), [{
+ owner: "test-owner", repo: "test-repo", tag_name: "v0.3.0", name: "0.3.0", draft: true,
+ }]);
+ });
+
+ it("reuses an existing draft without creating another", async () => {
+ assert.deepEqual(await prepareDraft([{ id: 10, tag_name: "v0.3.0", draft: true }]), []);
+ // Match the publisher's support for legacy unprefixed tags too.
+ assert.deepEqual(await prepareDraft([{ id: 10, tag_name: "0.3.0", draft: true }]), []);
+ });
+
+ it("refuses to modify an already-published release", async () => {
+ await assert.rejects(prepareDraft([{ id: 10, tag_name: "v0.3.0", draft: false }]), /already published/);
+ });
+
+ it("refuses an ambiguous set of drafts instead of splitting assets", async () => {
+ await assert.rejects(prepareDraft([
+ { id: 10, tag_name: "v0.3.0", draft: true },
+ { id: 11, tag_name: "v0.3.0", draft: true },
+ ]), /Multiple releases match/);
+ });
+});