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/); + }); +});