desktop: tri-platform window chrome, packaging and draft-release CI

This commit is contained in:
amosblomqvist
2026-10-11 10:12:13 +03:00
parent 8ebaf0da6e
commit e266c3c028
20 changed files with 765 additions and 41 deletions
+67
View File
@@ -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
+176
View File
@@ -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
+10 -2
View File
@@ -24,11 +24,17 @@ A system whose premise is that it can be trusted is built so that it can be chec
## Download ## 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 ## 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. 2. Open Settings, paste your OpenRouter API key, and pick your models.
3. Start a session and say what you want to understand. 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). 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 ## License
[AGPL-3.0](LICENSE) [AGPL-3.0](LICENSE)
+22 -5
View File
@@ -486,9 +486,9 @@ body {
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
Electron merged chrome (owner, 2026-09-17). The system title bar is Electron merged chrome (owner, 2026-09-17). The system title bar is
hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's hidden (macOS hiddenInset; Windows/Linux hidden + native controls
own 60px header bands are the drag surface. Everything here is inert overlay in electron/main.cjs); the app's 60px bands are the drag surface.
in the plain browser — it keys off <html class="lh-electron">, which Everything here is inert in the plain browser — it keys off <html class="lh-electron">, which
SidebarProvider sets after mount when it detects the Electron shell. SidebarProvider sets after mount when it detects the Electron shell.
- .lh-drag window-drag region — on a dedicated mask div in - .lh-drag window-drag region — on a dedicated mask div in
@@ -532,13 +532,30 @@ body {
.lh-electron { .lh-electron {
--lh-zoom: 1; --lh-zoom: 1;
} }
.lh-electron .lh-lights-gutter { .lh-electron[data-lh-platform="darwin"] .lh-lights-gutter {
padding-left: calc(92px / var(--lh-zoom)); 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)); 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 /* --font-title deliberately NOT @theme inline (see comment above): the
session-name-serif toggle overrides this var at runtime, so 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. */ .font-title utility must read it through var(), not carry a baked copy. */
+1 -1
View File
@@ -1590,7 +1590,7 @@ export function GraphView({
against the full-bleed parent) — the row NEVER wraps (owner against the full-bleed parent) — the row NEVER wraps (owner
rejected the two-row fold); in narrow windows the search is rejected the two-row fold); in narrow windows the search is
the flexible element and shrinks instead. */} the flexible element and shrinks instead. */}
<div className="lh-no-drag absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2"> <div className="lh-no-drag lh-wco-floating absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
{/* z-20 (2026-09-20): the row paints above the cost number below {/* z-20 (2026-09-20): the row paints above the cost number below
it — without this, the later-in-DOM cost control layered over it — without this, the later-in-DOM cost control layered over
the settings/coverage dropdowns (owner report), still hoverable the settings/coverage dropdowns (owner report), still hoverable
+1 -1
View File
@@ -180,7 +180,7 @@ export function MinimalHome({
window instead. Node search and the render-settings gear are window instead. Node search and the render-settings gear are
canvas things and stay gone; Import ALWAYS renders (the canvas things and stay gone; Import ALWAYS renders (the
entry law). */} entry law). */}
<div className="lh-no-drag absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2"> <div className="lh-no-drag lh-wco-floating absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur"> <div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain" side="bottom"> <Hint label="Every reasoning chain" side="bottom">
+3
View File
@@ -56,6 +56,8 @@ declare global {
interface Window { interface Window {
/** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */ /** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */
lh?: { lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
expandWindow: () => void; expandWindow: () => void;
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void; 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. // and in the plain browser the class never appears.
if (IS_ELECTRON) { if (IS_ELECTRON) {
document.documentElement.classList.add("lh-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 // THE ZOOM TRUTH (owner round 7, 2026-10-05): ⌘±/⌘0 zoom the UI
+1 -1
View File
@@ -53,7 +53,7 @@ export function TopBar({
return ( return (
<header <header
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
className={`${drag ? "lh-drag " : ""}flex h-15 select-none items-center gap-3 border-b border-border px-3`} className={`${drag ? "lh-drag " : ""}lh-wco-gutter flex h-15 select-none items-center gap-3 border-b border-border px-3`}
> >
<SidebarToggle /> <SidebarToggle />
{children} {children}
+77
View File
@@ -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 <platform flags> --publish <policy>`.
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<package.json version>`:** 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.
+7 -5
View File
@@ -24,7 +24,7 @@ npm run lint # eslint — includes the import law (architecture §2)
npm run typecheck # tsc --noEmit npm run typecheck # tsc --noEmit
npm run desktop:dev # next dev + Electron shell pointed at it 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) # one-shot data scripts (run from the master tree only — data never crosses trees)
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # 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 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`) ## 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). 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 | | `OPENROUTER_API_KEY` | required — all LLM calls go through OpenRouter |
| `GOOGLE_SEARCH_API_KEY` + `GOOGLE_CSE_ID` | researcher's `web_search` (Google Custom Search) | | `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) | | `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 ## Architecture
@@ -117,7 +119,7 @@ src/
subagents/ researcher · demo-builder · visual-maker subagents/ researcher · demo-builder · visual-maker
· pdf-reader, · pdf-reader,
extraction pipeline (extractor/auditor + merger handoff), extraction pipeline (extractor/auditor + merger handoff),
runner (async protocol), headless Chrome helper, runner (async protocol), native renderers + browser-service helper,
abort registry abort registry
server/chat-handler.ts THE turn composition root (liveness · steers · persist · stream) server/chat-handler.ts THE turn composition root (liveness · steers · persist · stream)
server/turn-service.ts turn seams: persist · extraction trigger 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 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. **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.
+31 -2
View File
@@ -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): # Shape (fact-checked 2026-09-17):
# - the app's real server (.next/standalone, curated by # - the app's real server (.next/standalone, curated by
@@ -114,6 +114,33 @@ mac:
notarize: true notarize: true
dmg: dmg:
title: "${productName} ${version}" 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: publish:
# Update feed = GitHub Releases (decided 2026-10-08): latest-mac.yml + # Update feed = GitHub Releases (decided 2026-10-08): latest-mac.yml +
# zip/blockmap live on releases; electron-updater reads them anonymously. # zip/blockmap live on releases; electron-updater reads them anonymously.
@@ -123,4 +150,6 @@ publish:
# upload; publishing happens at release time (GH_TOKEN + --publish). # upload; publishing happens at release time (GH_TOKEN + --publish).
- provider: github - provider: github
owner: amosblomqvist owner: amosblomqvist
repo: learnharness repo: learnharness
# All three CI jobs contribute assets; only the owner publishes it.
releaseType: draft
+13 -3
View File
@@ -258,8 +258,16 @@ function createWindow(url) {
// (band 0–59.5, pill box 19–39.5) — y 22.5 lands the circles at // (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. // 29.25, exactly on the pill. Wordmark gutter: components/sidebar.tsx.
// Dragging comes from the renderer's .lh-drag bands (app/globals.css). // Dragging comes from the renderer's .lh-drag bands (app/globals.css).
titleBarStyle: "hiddenInset", ...(process.platform === "darwin"
trafficLightPosition: { x: 16, y: 22.5 }, ? { 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: { webPreferences: {
contextIsolation: true, contextIsolation: true,
sandbox: 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, * Feed: GitHub Releases (electron-builder.yml publish → app-update.yml,
* baked + signed into the bundle). macOS needs the app signed (it is — * baked + signed into the bundle). macOS needs the app signed (it is —
* Developer ID) and updates ride the zip target (Squirrel.Mac); blockmaps * 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: * Hard rules:
* - DEV NEVER UPDATES: !app.isPackaged skips the updater entirely — the * - DEV NEVER UPDATES: !app.isPackaged skips the updater entirely — the
+4
View File
@@ -4,6 +4,9 @@
* on (electron/main.cjs), so this file is the renderer's entire access to * on (electron/main.cjs), so this file is the renderer's entire access to
* Electron — keep it a minimal, explicit API surface. * 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 * window.lh.expandWindow() — widen the window to the sidebar auto-close
* threshold (928px) when the sidebar is opened below it. The main * threshold (928px) when the sidebar is opened below it. The main
* process clamps: no-op if the window is already wide enough. * process clamps: no-op if the window is already wide enough.
@@ -25,6 +28,7 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", { contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
expandWindow: () => ipcRenderer.send("lh:expand-window"), expandWindow: () => ipcRenderer.send("lh:expand-window"),
onZoom: (cb) => { onZoom: (cb) => {
const listener = (_event, factor) => cb(factor); const listener = (_event, factor) => cb(factor);
+7 -5
View File
@@ -28,11 +28,13 @@ const nextConfig: NextConfig = {
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely). // @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
// Anchored "./"-prefixed forms behave the same. Only the three entries // Anchored "./"-prefixed forms behave the same. Only the three entries
// below are verified safe (complete externals trace + clean payload). // below are verified safe (complete externals trace + clean payload).
// The 7.4 GB recursive-DMG payload (repo dist/ swept into the bundle, // The recursive-DMG payload (repo dist/ swept into the bundle, shipping
// shipping the previous build inside the next) is instead prevented by // the previous build inside the next) is instead prevented by the
// the `rm -rf dist .next/standalone` clean step in desktop:dist — // desktop:dist clean step AND targeted post-trace curation in
// verified sufficient, since the sweep can only copy what exists at // scripts/build-server.mjs (2026-10-11): remove standalone-root dist/
// trace time. scripts/build-server.mjs guards the rest. // 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: { outputFileTracingExcludes: {
"*": [ "*": [
"data/**/*", "data/**/*",
+6
View File
@@ -2,6 +2,12 @@
"name": "learnharness", "name": "learnharness",
"version": "0.2.0", "version": "0.2.0",
"license": "AGPL-3.0-only", "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.", "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", "type": "module",
"private": true, "private": true,
+19 -1
View File
@@ -6,7 +6,9 @@
* outside that the docs require us to copy: public/ and .next/static/. * outside that the docs require us to copy: public/ and .next/static/.
* This script finishes the job and sanity-checks the result — including * This script finishes the job and sanity-checks the result — including
* the exclusion that keeps the repo's real data/ OUT of the bundle * 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 fs from "node:fs";
import * as path from "node:path"; 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 // 3) Sanity: the runtime-read prompt assets must be present (the teacher
// agent reads them from disk at request time). // agent reads them from disk at request time).
for (const asset of [ for (const asset of [
+9 -2
View File
@@ -12,7 +12,9 @@
* in seconds, not after a full packaging pass. * in seconds, not after a full packaging pass.
* *
* What it does: spawns .next/standalone/server.js exactly the way the * 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 * 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) — * 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 / * polls GET / until 200, then probes the GET-only API surface (graph /
@@ -23,6 +25,7 @@
* way, same day). * way, same day).
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as http from "node:http"; import * as http from "node:http";
import * as net from "node:net"; import * as net from "node:net";
@@ -32,6 +35,8 @@ import * as path from "node:path";
const ROOT = path.resolve(import.meta.dirname, ".."); const ROOT = path.resolve(import.meta.dirname, "..");
const STANDALONE = path.join(ROOT, ".next", "standalone"); const STANDALONE = path.join(ROOT, ".next", "standalone");
const SERVER = path.join(STANDALONE, "server.js"); 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 POLL_MS = 500;
const BOOT_TIMEOUT_MS = 30_000; 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}`); 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, cwd: STANDALONE,
env: { env: {
...process.env, ...process.env,
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", HOSTNAME: "127.0.0.1",
+15 -13
View File
@@ -5,16 +5,14 @@ import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
/** /**
* Shared headless-browser helper for the verifying sub-agents (W6): * Verification engines for sub-agents: demos use the app's own Electron
* the demo-builder executes demos and captures errors; the visual-maker * browser-service via puppeteer-core, with system Chrome as fallback.
* rasterizes SVGs so it can look at them. Both use the SAME system Chrome * SVGs use resvg (browser fallback); PDFs use pdfjs + Skia (no browser).
* via puppeteer-core — NO bundled chromium is downloaded. * No extra Chromium is shipped or downloaded.
* *
* Chrome resolution: process.env.CHROME_PATH first, then the common * Chrome fallback resolution: CHROME_PATH first, then common macOS,
* macOS/Linux install paths. If none exists we fall back to puppeteer's * Windows and Linux install paths. `available()` is a cheap executable
* `channel: "chrome"` at launch time; `available()` is cheap and only knows * check; the helper must still prove it can launch/connect at job time.
* about the explicit candidates, so a channel-only setup reports unavailable
* and the builders refuse to run (the house rule: never skip testing).
* *
* Test seam: `headlessOverride.current` replaces the whole API (tests inject * Test seam: `headlessOverride.current` replaces the whole API (tests inject
* a fake; nothing here touches a real browser). Builders call `getHeadless()`. * a fake; nothing here touches a real browser). Builders call `getHeadless()`.
@@ -58,16 +56,15 @@ export interface RenderedPdfPage {
} }
export interface HeadlessApi { 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; available(): boolean;
/** Why not — for error messages. */ /** Why not — for error messages. */
unavailableReason(): string | null; 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<DemoRunResult>; runDemo(html: string, opts?: RunDemoOptions): Promise<DemoRunResult>;
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */ /** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>; renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>;
/** Render PDF pages to PNG buffers via Chrome's pdfium viewer (for the /** Render PDF pages to PNGs natively (pdfjs + Skia) for the pdf-reader. */
* pdf-reader sub-agent's look-at-the-page loop). */
renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise<RenderedPdfPage[]>; renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise<RenderedPdfPage[]>;
} }
@@ -81,6 +78,11 @@ export function getHeadless(): HeadlessApi {
// ─── Chrome resolution ─────────────────────────────────────────────────────── // ─── Chrome resolution ───────────────────────────────────────────────────────
const CHROME_CANDIDATES = [ 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/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+96
View File
@@ -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<typeof spawnSync>;
}) => 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/);
});
});
});
+200
View File
@@ -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<string, unknown> = {};
const context = {
process: { platform: "win32" },
require: () => ({
contextBridge: {
exposeInMainWorld(name: string, api: Record<string, unknown>) {
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<string, any> };
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<string, string>; with?: { script: string } };
type Workflow = {
concurrency: { group: string; "cancel-in-progress": boolean };
jobs: Record<string, { needs?: string | string[]; steps: Step[] }>;
};
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<string, unknown>[] = [];
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<string, unknown>) => {
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/);
});
});