Something went wrong. Try again.
A self-hosted household ledger.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237// 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/<platform>/ 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<string, Target> = { 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<void> { async function* walk( dir: string, prefix: string, ): AsyncGenerator<TarStreamInput> { 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<void> { 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}`);