desktop: tri-platform window chrome, packaging and draft-release CI
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -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.
|
||||||
@@ -124,3 +151,5 @@ 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
@@ -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,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
@@ -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/**/*",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user