diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 9160ce0..8dcb62b 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -93,6 +93,31 @@ Point the user to the Tangled CI run and, once it succeeds, the registry page (https://atcr.io/r/graham.systems/quantum) to confirm the new version's image and OCI labels. +## 9. Desktop artifacts (optional, local) + +``` +deno task desktop:package # all three platforms +deno task desktop:package --targets=windows # subset: windows, macos, linux +``` + +Cross-compiles the desktop app from this one host (prebuilt denort + CEF +download per target) and versions every artifact from the same `package.json` +ChronVer source as the image — run it after step 5 so the version stamped is +the released one. Produces in `dist/`: + +- `quantum-desktop--windows-x86_64.zip` (bundle dir with quantum.exe) +- `quantum-desktop--macos-aarch64.tar.gz` (quantum.app; tar.gz — NOT + zip — so unix exec bits and the framework symlinks survive packaging on NTFS) +- `quantum-desktop--linux-x86_64.AppImage` (single file) + +Caveats: the macOS and Linux artifacts are cross-compiled and have not yet been +launched on real hardware; nothing is signed (macOS users right-click → Open +the unsigned app); `.dmg` would need a real Mac (`hdiutil`). CI does not build +these — no artifact host/signing story yet (tracked in the desktop change's +open questions). Distribute manually. The script hard-fails if runtime state +(`.quantum/`, the local database) would leak into an archive, or if a bundle's +entrypoint is missing. + --- ## Notes @@ -104,5 +129,6 @@ and OCI labels. - **Pieces**: `scripts/version.ts` (ChronVer derivation), `release.ts` (bump + changelog), `changelog.ts` (section generation), `tag.ts` (tagging), `build-image.ts` (`deno task - image`), `.tangled/workflows/build-image.yml` - (CI). + image`), `build-desktop.ts` (`deno task + desktop:package`), + `.tangled/workflows/build-image.yml` (CI). diff --git a/.gitignore b/.gitignore index 99a4938..e1ba8a9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ Thumbs.db # deno desktop build output /.deno-deploy /quantum/ +/dist/ # Vite vite.config.js.timestamp-* diff --git a/deno.json b/deno.json index 363b72d..b48c6d2 100644 --- a/deno.json +++ b/deno.json @@ -15,6 +15,7 @@ "desktop:patch": "deno run --allow-read --allow-write scripts/patch-route-config.ts", "desktop": "deno task desktop:patch && deno desktop --backend cef --env-file=.env.desktop --unstable-cron --no-check -A .", "desktop:webview": "deno task desktop:patch && deno desktop --env-file=.env.desktop --unstable-cron --no-check -A .", + "desktop:package": "deno run --allow-read --allow-write --allow-run=deno,tar scripts/build-desktop.ts", "check": "deno run -A npm:@sveltejs/kit/svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json", "test": "deno test -A src", "fmt": "deno fmt", diff --git a/deno.lock b/deno.lock index a540cb9..82194f9 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,9 @@ { "version": "5", "specifiers": { + "jsr:@std/streams@^1.0.17": "1.1.1", + "jsr:@std/tar@*": "0.1.10", + "jsr:@std/tar@0.1": "0.1.10", "npm:@atproto-labs/handle-resolver@~0.4.5": "0.4.5", "npm:@atproto/jwk-jose@~0.2.4": "0.2.4", "npm:@atproto/oauth-client-node@~0.4.8": "0.4.8", @@ -27,6 +30,15 @@ "jsr": { "@std/streams@1.0.17": { "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" + }, + "@std/streams@1.1.1": { + "integrity": "92556d350e537e9dce527a6d08f6f15be3ff65e656079dea69d15252187c7613" + }, + "@std/tar@0.1.10": { + "integrity": "6bf907f3a4bc8bfef42973ba132d946756a6161ef6b914a9e1c06debe664db17", + "dependencies": [ + "jsr:@std/streams" + ] } }, "npm": { diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md index 9639a48..dd3c0a3 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -74,10 +74,28 @@ beside the database; `QUANTUM_DESKTOP=1` (baked into the desktop env) makes `loadConfig` carry a `desktop` flag so the hook reads the app-config at runtime rather than a baked mode. `dev-desktop` launch config added. -- [ ] 2.3 Extend the release/build scripts to produce and version the desktop +- [x] 2.3 Extend the release/build scripts to produce and version the desktop artifact alongside the container image, reusing the existing ChronVer version source. (Coordinate with the `release` skill's flow; do not fork - versioning.) + versioning.) — `scripts/build-desktop.ts` (`deno task desktop:package`) + cross-compiles all three platforms from one host (deno desktop + `--target`; prebuilt denort + CEF per target) into fresh + `dist/build//` dirs and archives + `dist/quantum-desktop--{windows-x86_64.zip, + macos-aarch64.tar.gz, linux-x86_64.AppImage}`, reading the version from + `currentVersion()` (package.json, same source as the image; no fork). + Hard lessons encoded in the script: the macOS artifact must be a + tar.gz written by @std/tar with modes stamped 0755 and the framework + symlinks preserved as symlink entries — a bsdtar zip from NTFS records + every binary 0644 (unlaunchable .app) and the first attempt shipped + exactly that; the bundle's real entrypoint is + `Contents/MacOS/laufey`, which the per-target payload check now + asserts. Every archive is verified to contain no `.quantum/` runtime + state (the artifact is deleted on violation). The `release` skill's + step 9 documents the three artifacts and their caveats: macOS/Linux + builds not yet launched on real hardware, nothing signed, .dmg needs a + Mac. CI stays container-only for now — no artifact host/signing story + yet (already an open question). `dist/` gitignored. ## 3. Config: server vs. local diff --git a/scripts/build-desktop.ts b/scripts/build-desktop.ts new file mode 100644 index 0000000..966b9d6 --- /dev/null +++ b/scripts/build-desktop.ts @@ -0,0 +1,236 @@ +// Package the Quantum desktop artifacts, versioned from ChronVer exactly like +// the container image (package.json is the single source of truth; see +// scripts/version.ts and the release skill). +// +// deno task desktop:package # all targets: windows, macos, linux +// deno task desktop:package --targets=windows # subset (comma-separated keys below) +// deno task desktop:package --skip-build # reuse the existing web build (.deno-deploy) +// +// `deno desktop` cross-compiles: prebuilt denort + CEF archives download per +// target, so a single (Windows) host produces all three artifacts. Only .dmg +// and code signing need a real macOS host — the .app bundle and the Linux +// .AppImage build anywhere (per the deno desktop distribution docs). +// +// Each target compiles into its own fresh dist/build// directory, so +// no runtime state from launches of a previously built app can be swept in. +// The archive step still verifies that no `.quantum/` state or database +// entered an archive, and deletes the artifact if it did. + +import { TarStream, type TarStreamInput } from "jsr:@std/tar@^0.1"; +import { currentVersion } from "./version.ts"; + +const STATE_DIR = ".quantum"; // deno desktop's runtime state dir — never ships + +interface Target { + triple: string; + /** Output name deno desktop compiles to (extension selects the format). */ + out: string; + /** Final artifact extension. */ + ext: "zip" | "tar.gz" | "AppImage"; + /** Entry that must exist in the archive (the app's actual entrypoint). */ + payload?: string; +} + +// CEF backend for every target: it is the only rendering path validated on +// this app (the OS-webview backend failed on Windows), and one engine across +// platforms keeps behavior uniform. Cost: ~big artifacts. macOS is arm64-only +// (Apple silicon); add x86_64-apple-darwin here if an Intel Mac ever needs it. +const TARGETS: Record = { + windows: { + triple: "x86_64-pc-windows-msvc", + out: "quantum", + ext: "zip", + payload: "quantum.exe", + }, + // macOS ships a tar.gz written by @std/tar, NOT a bsdtar zip: this script + // runs on NTFS, which has no unix modes, so bsdtar records every bundle + // binary as 0644 and the extracted .app cannot launch. @std/tar lets us + // stamp 0755 explicitly (harmless on resources, required on executables); + // macOS Archive Utility extracts tar.gz natively and preserves modes. + macos: { + triple: "aarch64-apple-darwin", + out: "quantum.app", + ext: "tar.gz", + // The bundle's CFBundleExecutable is the laufey backend runtime. + payload: "quantum.app/Contents/MacOS/laufey", + }, + linux: { + triple: "x86_64-unknown-linux-gnu", + out: "quantum.AppImage", + ext: "AppImage", + }, +}; + +/** + * Write `srcDir` as a gzipped tar with every entry stamped 0755. Used for the + * macOS bundle: the executables' real modes were lost on NTFS, and a blanket + * exec bit is harmless on resources while required on the binaries. + */ +async function writeTarGz(srcDir: string, artifact: string): Promise { + async function* walk( + dir: string, + prefix: string, + ): AsyncGenerator { + for (const entry of Deno.readDirSync(dir)) { + const fsPath = `${dir}/${entry.name}`; + const tarPath = `${prefix}${entry.name}`; + if (entry.isSymlink) { + // The .app's framework layout is symlinks (Versions/Current and + // friends); deno desktop writes them as real Windows symlinks. + // Preserve them — following them would break the bundle shape. + const linkname = Deno.readLinkSync(fsPath).replaceAll("\\", "/"); + yield { type: "symlink", path: tarPath, linkname }; + } else if (entry.isDirectory) { + yield { type: "directory", path: tarPath, options: { mode: 0o755 } }; + yield* walk(fsPath, `${tarPath}/`); + } else { + const { size } = Deno.statSync(fsPath); + yield { + type: "file", + path: tarPath, + size, + readable: (await Deno.open(fsPath)).readable, + options: { mode: 0o755 }, + }; + } + } + } + const out = await Deno.create(artifact); + await ReadableStream.from(walk(srcDir, "")) + .pipeThrough(new TarStream()) + .pipeThrough(new CompressionStream("gzip")) + .pipeTo(out.writable); +} + +async function run(cmd: string, args: string[]): Promise { + const { code } = await new Deno.Command(cmd, { + args, + stdout: "inherit", + stderr: "inherit", + }).output(); + if (code !== 0) { + console.error(`${cmd} ${args.join(" ")} failed (exit ${code})`); + Deno.exit(code); + } +} + +const version = await currentVersion(); + +const targetsArg = Deno.args.find((a) => a.startsWith("--targets=")); +const wanted = targetsArg + ? targetsArg.slice("--targets=".length).split(",") + : Object.keys(TARGETS); +for (const key of wanted) { + if (!(key in TARGETS)) { + console.error( + `Unknown target "${key}" — known: ${Object.keys(TARGETS).join(", ")}`, + ); + Deno.exit(1); + } +} + +// The web build (vite → .deno-deploy) is JS, shared by every target; the +// route-config patch fixes the adapter's Windows static-serving bug in the +// bundled output, so both run once, before any target compiles. +if (!Deno.args.includes("--skip-build")) { + console.log(`Building web bundle (version ${version})`); + await run("deno", ["task", "desktop:build"]); +} +await run("deno", ["task", "desktop:patch"]); + +const built: string[] = []; + +for (const key of wanted) { + const target = TARGETS[key]; + const platform = `${key}-${target.triple.split("-")[0]}`; + const buildDir = `dist/build/${platform}`; + const artifact = `dist/quantum-desktop-${version}-${platform}.${target.ext}`; + + try { + Deno.removeSync(buildDir, { recursive: true }); + } catch { + // fine: first build for this target + } + Deno.mkdirSync(buildDir, { recursive: true }); + + console.log(`\nCompiling ${key} (${target.triple})`); + await run("deno", [ + "desktop", + "--backend", + "cef", + "--env-file=.env.desktop", + "--unstable-cron", + "--no-check", + "-A", + "--target", + target.triple, + "-o", + `${buildDir}/${target.out}`, + ".", + ]); + + try { + Deno.removeSync(artifact); + } catch { + // fine: no previous artifact for this version + } + + if (target.ext === "AppImage") { + // Already a single self-contained file: version the name and move on. + Deno.copyFileSync(`${buildDir}/${target.out}`, artifact); + } else { + console.log(`Archiving ${artifact}`); + if (target.ext === "tar.gz") { + await writeTarGz(buildDir, artifact); + } else { + // bsdtar (Windows 10+/macOS; `-a` picks the format from the + // extension). fnmatch here is not path-segmented, so one pattern + // covers the state dir and everything under it. + await run("tar", [ + "-a", + "-cf", + artifact, + `--exclude=*${STATE_DIR}*`, + "-C", + buildDir, + ".", + ]); + } + + // Belt-and-suspenders: prove no runtime state (database, mode choice) + // shipped, and that the payload is actually present. tar -tf reads + // both the zip and the tar.gz, independently of how they were written. + const { code, stdout } = await new Deno.Command("tar", { + args: ["-tf", artifact], + stdout: "piped", + stderr: "inherit", + }).output(); + if (code !== 0) Deno.exit(code); + const entries = new TextDecoder().decode(stdout).split("\n") + .filter(Boolean); + const leaked = entries.filter((e) => e.includes(STATE_DIR)); + if (leaked.length > 0) { + console.error( + `ARTIFACT CONTAINS RUNTIME STATE (${leaked.length} entries) — deleting it:`, + ); + for (const e of leaked.slice(0, 5)) console.error(` ${e}`); + Deno.removeSync(artifact); + Deno.exit(1); + } + if ( + target.payload && + !entries.some((e) => e.endsWith(target.payload as string)) + ) { + console.error(`Artifact is missing ${target.payload} — deleting it.`); + Deno.removeSync(artifact); + Deno.exit(1); + } + } + + const size = (Deno.statSync(artifact).size / 1024 / 1024).toFixed(1); + console.log(`${artifact} (${size} MB)`); + built.push(artifact); +} + +console.log(`\n${built.length} artifact(s):`); +for (const a of built) console.log(` ${a}`);