From 4260b0eb76496623cdd8aeb088b4133cb1af32e6 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:27:39 +0530 Subject: [PATCH 01/11] build: Tectonic for every system the app is built for The fetch script knows the release files for macOS (Apple Silicon and Intel), Linux (x64 and arm64, static builds) and Windows (x64), each with its SHA-256, and fetches the one for the system it runs on. Co-Authored-By: Claude Sonnet 5.5 --- scripts/fetch-tectonic.mjs | 100 ++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/scripts/fetch-tectonic.mjs b/scripts/fetch-tectonic.mjs index 5f9e036..e18e31b 100644 --- a/scripts/fetch-tectonic.mjs +++ b/scripts/fetch-tectonic.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node -// Downloads the official Tectonic release for macOS (Apple Silicon) into apps/desktop/resources/bin/tectonic, where the app -// finds it in development and electron-builder copies it into the packaged app. The download is checked against a pinned -// SHA-256, so a changed or damaged file is refused. -// node scripts/fetch-tectonic.mjs [--force] +// Downloads the official Tectonic release for a system into apps/desktop/resources/bin/, where the app finds it in +// development and electron-builder copies it into the packaged app. Every download is checked against a pinned SHA-256 +// (the digests GitHub lists for the release files), so a changed or damaged file is refused. +// node scripts/fetch-tectonic.mjs [--platform darwin|linux|win32] [--arch arm64|x64] [--force] +// Without options it fetches the one for the system it runs on. import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; @@ -11,46 +12,89 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const VERSION = "0.17.0"; -const ARCHIVE = `tectonic-${VERSION}-aarch64-apple-darwin.tar.gz`; -const URL = `https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${VERSION}/${ARCHIVE}`; -const SHA256 = "a3f1cac7c5678f01661a92212f58480ae3b0634115d880dbc59e2953ded45667"; +const BASE = `https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${VERSION}`; +/** Platform and architecture (Node's names) → the release file, its digest and the program inside it. */ +const TARGETS = { + "darwin-arm64": { + file: `tectonic-${VERSION}-aarch64-apple-darwin.tar.gz`, + sha256: "a3f1cac7c5678f01661a92212f58480ae3b0634115d880dbc59e2953ded45667", + program: "tectonic", + }, + "darwin-x64": { + file: `tectonic-${VERSION}-x86_64-apple-darwin.tar.gz`, + sha256: "7c90ef5b6ddb1eb1937e4337add5237b79338e4b9676459fa91187d24d6cdf80", + program: "tectonic", + }, + // The musl builds are static: they need nothing installed on the system. + "linux-x64": { + file: `tectonic-${VERSION}-x86_64-unknown-linux-musl.tar.gz`, + sha256: "8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7", + program: "tectonic", + }, + "linux-arm64": { + file: `tectonic-${VERSION}-aarch64-unknown-linux-musl.tar.gz`, + sha256: "b10954a95404f3ab2328d2fa59a5ebab8e657f893fab096f98be8db7c0c979b8", + program: "tectonic", + }, + "win32-x64": { + file: `tectonic-${VERSION}-x86_64-pc-windows-msvc.zip`, + sha256: "f61ce51f0b0ade1015b7de7ef368541c5424e9756ecbd0d7af97d6d48030845f", + program: "tectonic.exe", + }, +}; -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const target = join(root, "apps", "desktop", "resources", "bin", "tectonic"); -const stamp = `${target}.version`; - -if (process.platform !== "darwin" || process.arch !== "arm64") { - console.log("Lucid is packaged for macOS on Apple Silicon: nothing to fetch on this machine."); +const arg = (name) => { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +}; +const platform = arg("platform") ?? process.platform; +const arch = arg("arch") ?? process.arch; +const target = TARGETS[`${platform}-${arch}`]; +if (!target) { + console.log(`There is no Tectonic build for ${platform} ${arch} here: nothing to fetch.`); process.exit(0); } + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const out = join(root, "apps", "desktop", "resources", "bin", target.program); +const stamp = `${out}.version`; +const wanted = `${VERSION} ${target.sha256}`; + if ( !process.argv.includes("--force") && - existsSync(target) && + existsSync(out) && existsSync(stamp) && - readFileSync(stamp, "utf8").trim() === VERSION + readFileSync(stamp, "utf8").trim() === wanted ) { - console.log(`Tectonic ${VERSION} is already in ${target}`); + console.log(`Tectonic ${VERSION} is already in ${out}`); process.exit(0); } -console.log(`Downloading Tectonic ${VERSION} ...`); -const response = await fetch(URL, { redirect: "follow" }); -if (!response.ok) throw new Error(`Could not download ${URL}: ${String(response.status)}`); +const url = `${BASE}/${target.file}`; +console.log(`Downloading Tectonic ${VERSION} for ${platform} ${arch} ...`); +const response = await fetch(url, { redirect: "follow" }); +if (!response.ok) throw new Error(`Could not download ${url}: ${String(response.status)}`); const bytes = Buffer.from(await response.arrayBuffer()); const sum = createHash("sha256").update(bytes).digest("hex"); -if (sum !== SHA256) - throw new Error(`The download is not the expected file (SHA-256 ${sum}, wanted ${SHA256}).`); +if (sum !== target.sha256) + throw new Error( + `The download is not the expected file (SHA-256 ${sum}, wanted ${target.sha256}).`, + ); const work = join(tmpdir(), `lucid-tectonic-${String(process.pid)}`); mkdirSync(work, { recursive: true }); try { - writeFileSync(join(work, ARCHIVE), bytes); - execFileSync("tar", ["-xzf", join(work, ARCHIVE), "-C", work, "tectonic"]); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, readFileSync(join(work, "tectonic"))); - chmodSync(target, 0o755); - writeFileSync(stamp, `${VERSION}\n`); + const archive = join(work, target.file); + writeFileSync(archive, bytes); + // `tar` reads .tar.gz everywhere, and .zip on macOS and Windows (bsdtar); on Linux a .zip would need `unzip`. + if (target.file.endsWith(".zip") && process.platform === "linux") + execFileSync("unzip", ["-o", archive, target.program, "-d", work]); + else execFileSync("tar", ["-xf", archive, "-C", work, target.program]); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, readFileSync(join(work, target.program))); + if (platform !== "win32") chmodSync(out, 0o755); + writeFileSync(stamp, `${wanted}\n`); } finally { rmSync(work, { recursive: true, force: true }); } -console.log(`Tectonic ${VERSION} is in ${target}`); +console.log(`Tectonic ${VERSION} is in ${out}`); From d96d6408a68ba902a204edead75266ab44bfdc94 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:27:39 +0530 Subject: [PATCH 02/11] build(desktop): installers for Windows and Linux next to the Mac ones Windows gets an installer (per user, no administrator needed) and a portable .zip; Linux an AppImage and a .deb; the Mac files keep their names with the system added (Lucid--mac-arm64.dmg). pnpm dist builds the ones for the system it runs on, and the build still refuses an app that lacks a file it must carry, on every system. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/electron-builder.yml | 54 ++++++++++++++++++++++++++---- apps/desktop/package.json | 4 +-- apps/desktop/scripts/afterPack.cjs | 36 +++++++++++--------- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 2efa8a8..3f7d48d 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,4 +1,7 @@ -# Packaging for macOS on Apple Silicon: `pnpm dist` makes release/Lucid--arm64.dmg and .zip. +# Packaging: `pnpm dist` builds the installers for the system it runs on, into release/: +# macOS (Apple Silicon) Lucid--mac-arm64.dmg and .zip +# Windows (x64) Lucid--win-x64.exe (installer) and .zip +# Linux (x64) Lucid--linux-x64.AppImage and Lucid--linux-amd64.deb appId: io.github.triggeredcode.lucid productName: Lucid copyright: Copyright © 2026 Omkar Tripathi @@ -14,9 +17,6 @@ files: - package.json - "!node_modules" extraResources: - # The PDF engine, so nothing has to be installed for PDFs (fetched by scripts/fetch-tectonic.mjs). - - from: resources/bin/tectonic - to: bin/tectonic # For agents: `lucid-mcp.mjs` connects Claude Code or LM Studio to the open app (docs/mcp.md). - from: ../../packages/mcp/dist/lucid-mcp.mjs to: lucid-mcp.mjs @@ -30,6 +30,9 @@ extraResources: # Chromium's licence notices (part of Electron, the app's runtime). - from: node_modules/electron/dist/LICENSES.chromium.html to: LICENSES.chromium.html +afterPack: scripts/afterPack.cjs +artifactName: Lucid-${version}-${os}-${arch}.${ext} + mac: category: public.app-category.productivity icon: resources/icon.png @@ -46,11 +49,50 @@ mac: identity: null hardenedRuntime: false gatekeeperAssess: false -afterPack: scripts/afterPack.cjs + extraResources: + # The PDF engine, so nothing has to be installed for PDFs (fetched by scripts/fetch-tectonic.mjs). + - from: resources/bin/tectonic + to: bin/tectonic dmg: title: Lucid ${version} iconSize: 96 window: width: 540 height: 380 -artifactName: Lucid-${version}-${arch}.${ext} + +win: + icon: resources/icon.png + target: + - target: nsis + arch: [x64] + - target: zip + arch: [x64] + # Not signed (there is no certificate yet): Windows SmartScreen asks once. + signAndEditExecutable: false + extraResources: + - from: resources/bin/tectonic.exe + to: bin/tectonic.exe +nsis: + # Installs for the person who runs it (no administrator needed), in one step, and starts Lucid. + oneClick: true + perMachine: false + deleteAppDataOnUninstall: false + shortcutName: Lucid + +linux: + icon: resources/icon.png + category: Office + executableName: lucid + synopsis: Structured notes, built while you think + description: >- + Lucid turns what you say or type into a structured document of headings, lists, tables and diagrams, with a + language model that runs on your own computer, and typesets it as a PDF. + maintainer: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> + target: + - target: AppImage + arch: [x64] + - target: deb + arch: [x64] + extraResources: + - from: resources/bin/tectonic + to: bin/tectonic diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 29b7db6..8a46e0b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,9 +12,9 @@ "e2e": "electron-vite build && playwright test", "e2e:real": "electron-vite build && playwright test -c playwright.real.config.ts", "prepack": "node ../../scripts/fetch-tectonic.mjs && node node_modules/electron/install.js && pnpm --filter @lucid/mcp bundle && electron-vite build", - "dist": "pnpm prepack && electron-builder --mac --arm64 --publish never", + "dist": "pnpm prepack && electron-builder --publish never", "e2e:packaged": "playwright test -c playwright.packaged.config.ts", - "dist:dir": "pnpm prepack && electron-builder --mac --arm64 --dir" + "dist:dir": "pnpm prepack && electron-builder --dir" }, "license": "MIT", "author": { diff --git a/apps/desktop/scripts/afterPack.cjs b/apps/desktop/scripts/afterPack.cjs index 33b15dd..5a21c19 100644 --- a/apps/desktop/scripts/afterPack.cjs +++ b/apps/desktop/scripts/afterPack.cjs @@ -1,27 +1,31 @@ -// Apple Silicon only runs signed code. Without a developer certificate the app is signed "ad hoc" (no identity), which is enough -// to run it once the person has allowed it (see README: Install). Runs after electron-builder has put the app together. +// Runs after electron-builder has put the app together, on every system. +// - It refuses an app that lacks a file it must carry (for example Electron was never downloaded, so its licence notices were +// not there to copy): a broken build must stop here instead of shipping. +// - On a Mac it signs the app "ad hoc" (no identity): Apple Silicon only runs signed code, and without a developer certificate +// this is what lets it run once the person has allowed it (see README: Install). const { execFileSync } = require("node:child_process"); const { existsSync } = require("node:fs"); const path = require("node:path"); -// What the app must carry. A missing file is a broken build (for example Electron was never downloaded, so its licence notices -// were not there to copy), and it must stop the build instead of shipping. -const REQUIRED_RESOURCES = [ - "bin/tectonic", - "lucid-mcp.mjs", - "guide.html", - "LICENSE", - "THIRD_PARTY_NOTICES.md", - "LICENSES.chromium.html", -]; - exports.default = async function afterPack(context) { - if (context.electronPlatformName !== "darwin") return; + const system = context.electronPlatformName; // "darwin", "win32" or "linux" const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`); - const resources = path.join(app, "Contents", "Resources"); - for (const file of REQUIRED_RESOURCES) { + const resources = + system === "darwin" + ? path.join(app, "Contents", "Resources") + : path.join(context.appOutDir, "resources"); + const required = [ + system === "win32" ? "bin/tectonic.exe" : "bin/tectonic", + "lucid-mcp.mjs", + "guide.html", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "LICENSES.chromium.html", + ]; + for (const file of required) { if (!existsSync(path.join(resources, file))) throw new Error(`The app is missing ${file}.`); } + if (system !== "darwin") return; execFileSync("codesign", ["--force", "--deep", "--sign", "-", app], { stdio: "inherit" }); execFileSync("codesign", ["--verify", "--deep", "--strict", app], { stdio: "inherit" }); }; From 5c395d39039d02a05c99fbbe709d81bdc7efc6f2 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:27:39 +0530 Subject: [PATCH 03/11] test(desktop): the packaged-app tests run on macOS, Windows and Linux They find the app by system (or by LUCID_APP, for an installed copy), use a minimal PATH of each system's own folders, and on a Linux machine without a desktop take flags from LUCID_E2E_ARGS. The version is read from package.json instead of being written in the test. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/e2e-packaged/packaged.spec.ts | 41 +++++++------- apps/desktop/e2e-packaged/target.ts | 64 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/e2e-packaged/target.ts diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index 3032e98..4948cf5 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -1,39 +1,36 @@ import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, realpathSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registry } from "@lucid/kit"; import { _electron as electron, expect, test } from "@playwright/test"; import { autoSaveDialog, downloadPdf, maximizeWindow } from "../e2e/helpers"; +import { APP, BINARY, EXTRA_ARGS, RESOURCES, TECTONIC, bareEnv } from "./target"; /** * The packaged app, as a person who installed it would run it: no Homebrew, nothing on the PATH, nothing but the app. A new * folder is the library; LM Studio is not needed for any of this. */ -// LUCID_APP points the tests at another copy (for example the one taken out of the .dmg). -const APP = realpathSync( - process.env["LUCID_APP"] ?? join(__dirname, "../release/mac-arm64/Lucid.app"), -); -const BINARY = join(APP, "Contents/MacOS/Lucid"); -const RESOURCES = join(APP, "Contents/Resources"); - -const bare = (home: string) => ({ - ...process.env, - LUCID_HOME: home, - PATH: "/usr/bin:/bin", -}); +// Where the app is (and the other copy LUCID_APP can point to) is in target.ts. The tests run on macOS, Windows and Linux. +const VERSION = ( + JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { version: string } +).version; +const bare = bareEnv; async function launch(home = mkdtempSync(join(tmpdir(), "lucid-home-"))) { - const app = await electron.launch({ executablePath: BINARY, env: bare(home) }); + const app = await electron.launch({ + executablePath: BINARY, + args: EXTRA_ARGS, + env: bare(home), + }); const page = await app.firstWindow(); await maximizeWindow(app, page); return { app, page, home }; } -test("the app is there, is signed, and carries what it needs", () => { +test("the app is there, carries what it needs, and is signed on a Mac", () => { expect(existsSync(BINARY), "run `pnpm dist:dir` first").toBe(true); for (const file of [ - "bin/tectonic", "guide.html", "lucid-mcp.mjs", "LICENSE", @@ -41,15 +38,17 @@ test("the app is there, is signed, and carries what it needs", () => { "LICENSES.chromium.html", ]) expect(existsSync(join(RESOURCES, file)), file).toBe(true); - expect(statSync(join(RESOURCES, "bin/tectonic")).mode & 0o111).not.toBe(0); + expect(existsSync(TECTONIC), TECTONIC).toBe(true); + if (process.platform !== "win32") expect(statSync(TECTONIC).mode & 0o111).not.toBe(0); // Apple Silicon only runs signed code: ad hoc at least, and the signature must be intact. - execFileSync("codesign", ["--verify", "--deep", "--strict", APP]); + if (process.platform === "darwin") + execFileSync("codesign", ["--verify", "--deep", "--strict", APP]); // Everything is bundled into one small archive: no node_modules travel with it (they would make it ten times bigger). expect(statSync(join(RESOURCES, "app.asar")).size).toBeLessThan(25_000_000); expect(existsSync(join(RESOURCES, "app"))).toBe(false); }); -test("it opens as Lucid 0.1.0, and a PDF with a diagram is made with nothing installed", async () => { +test("it opens as Lucid with its own version, and a PDF with a diagram is made with nothing installed", async () => { const { app, page } = await launch(); try { const identity = await app.evaluate(({ app: a }) => [ @@ -57,7 +56,7 @@ test("it opens as Lucid 0.1.0, and a PDF with a diagram is made with nothing ins a.getVersion(), a.getName(), ]); - expect(identity).toEqual([true, "0.1.0", "Lucid"]); + expect(identity).toEqual([true, VERSION, "Lucid"]); const diagram = registry.get("diagram").fixtures[0]; if (!diagram) throw new Error("no diagram fixture"); @@ -123,7 +122,7 @@ test("opening Lucid a second time brings the first to the front instead of start const started = Date.now(); // The second one finds the first and ends by itself. await new Promise((resolve, reject) => { - const second = spawn(BINARY, [], { env: bare(home), stdio: "ignore" }); + const second = spawn(BINARY, EXTRA_ARGS, { env: bare(home), stdio: "ignore" }); const timer = setTimeout(() => { second.kill("SIGKILL"); reject(new Error("the second Lucid did not end by itself")); diff --git a/apps/desktop/e2e-packaged/target.ts b/apps/desktop/e2e-packaged/target.ts new file mode 100644 index 0000000..60549cb --- /dev/null +++ b/apps/desktop/e2e-packaged/target.ts @@ -0,0 +1,64 @@ +import { existsSync, realpathSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Where the packaged app is, on this system: the folder electron-builder leaves in release/ (the same files the installers + * carry), or another copy named by LUCID_APP (for example one taken out of the .dmg, or installed from the Windows installer). + */ +const release = join(__dirname, "../release"); + +function defaults(): { + app: string; + binary: (app: string) => string; + resources: (app: string) => string; +} { + switch (process.platform) { + case "darwin": + return { + app: join(release, process.arch === "arm64" ? "mac-arm64" : "mac", "Lucid.app"), + binary: (a) => join(a, "Contents/MacOS/Lucid"), + resources: (a) => join(a, "Contents/Resources"), + }; + case "win32": + return { + app: join(release, "win-unpacked"), + binary: (a) => join(a, "Lucid.exe"), + resources: (a) => join(a, "resources"), + }; + default: + return { + app: join(release, "linux-unpacked"), + binary: (a) => join(a, "lucid"), + resources: (a) => join(a, "resources"), + }; + } +} + +const d = defaults(); +const given = process.env["LUCID_APP"]; +export const APP = existsSync(given ?? d.app) ? realpathSync(given ?? d.app) : (given ?? d.app); +export const BINARY = d.binary(APP); +export const RESOURCES = d.resources(APP); +export const TECTONIC = join( + RESOURCES, + "bin", + process.platform === "win32" ? "tectonic.exe" : "tectonic", +); + +/** + * What the program is started with. On a Linux build machine without a desktop session Chromium's sandbox cannot start; + * LUCID_E2E_ARGS (for example "--no-sandbox --disable-gpu") adds the flags a test run there needs. A person's own copy is + * never started with them. + */ +export const EXTRA_ARGS: string[] = (process.env["LUCID_E2E_ARGS"] ?? "") + .split(/\s+/) + .filter(Boolean); + +/** A minimal environment, so the test shows the app needs nothing installed: nothing but the system's own folders on the PATH. */ +export function bareEnv(home: string): Record { + const system = process.env["SystemRoot"] ?? "C:\\Windows"; + const path = process.platform === "win32" ? `${system}\\System32;${system}` : "/usr/bin:/bin"; + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v; + return { ...env, LUCID_HOME: home, PATH: path }; +} From d33f4653195089254b44dae6143158513bfd4655 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:27:39 +0530 Subject: [PATCH 04/11] ci: the release builds, tests and publishes Lucid for macOS, Windows and Linux Each system builds on its own runner and tests the unpacked app and then what a person installs (the disk image, the Windows installer run silently, the .deb installed with apt). One job gathers the files, writes SHA256SUMS and creates the release. Run from a branch by hand, it builds and tests without publishing. Co-Authored-By: Claude Sonnet 5.5 --- .github/release-install.md | 10 +++- .github/workflows/release.yml | 99 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/.github/release-install.md b/.github/release-install.md index ea72eb3..e18cb99 100644 --- a/.github/release-install.md +++ b/.github/release-install.md @@ -1,4 +1,10 @@ -**Install:** download the `.dmg` below, open it and drag **Lucid** to **Applications** (Apple Silicon, macOS 13 or newer). Lucid is not notarized by Apple, so macOS asks once: on macOS 15 open **System Settings → Privacy & Security** and click **Open Anyway**; on macOS 13 and 14 right-click the app and choose **Open**. Then start [LM Studio](https://lmstudio.ai) with the model `qwen/qwen3.8-27b`. Details are in the [README](https://github.com/triggeredcode/lucid#install). +**Install:** download the file for your system below. -Check a download with `shasum -a 256 --ignore-missing -c SHA256SUMS`. +- **macOS** (Apple Silicon, macOS 13 or newer): `Lucid-…-mac-arm64.dmg`. Open it, drag Lucid to Applications. Lucid is not notarized by Apple, so macOS asks once: on macOS 15 open **System Settings → Privacy & Security** and click **Open Anyway**; on macOS 13 and 14 right-click the app and choose **Open**. +- **Windows** (Windows 10 or newer, 64-bit): `Lucid-…-win-x64.exe`. Run it; it installs for you and starts Lucid. Lucid is not signed, so Windows SmartScreen may say "Windows protected your PC": click **More info**, then **Run anyway**. A portable `.zip` is also there. +- **Linux** (64-bit): `Lucid-…-linux-amd64.deb` for Debian and Ubuntu (`sudo apt install ./Lucid-…-linux-amd64.deb`), or `Lucid-…-linux-x64.AppImage` for other distributions (make it executable, then run it). + +Then start [LM Studio](https://lmstudio.ai) with the model `qwen/qwen3.8-27b`. Details are in the [README](https://github.com/triggeredcode/lucid#install). + +Check a download with `shasum -a 256 --ignore-missing -c SHA256SUMS` (macOS, Linux) or `Get-FileHash` (Windows) against the file `SHA256SUMS`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22ad737..eacd05e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,37 @@ name: Release -# Pushing a tag like v0.1.0 builds the macOS app from that tag, tests it, and attaches it to a GitHub release. +# Pushing a tag like v0.2.0 builds Lucid for macOS, Windows and Linux from that tag, tests each build the way a person would run +# it (the unpacked app, and the installer or package that was made), and attaches everything to one GitHub release with checksums. on: push: tags: ["v*"] + # A pull request that changes how the app is packaged builds and tests it on every system (nothing is published). + pull_request: + paths: + - "apps/desktop/electron-builder.yml" + - "apps/desktop/package.json" + - "apps/desktop/scripts/**" + - "apps/desktop/e2e-packaged/**" + - "scripts/fetch-tectonic.mjs" + - ".github/workflows/release.yml" + # Run by hand from a branch to try the same without a pull request. + workflow_dispatch: permissions: contents: write jobs: - release: - name: Build and publish - runs-on: macos-14 + build: + name: Build and test (${{ matrix.name }}) + runs-on: ${{ matrix.os }} timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - { os: macos-14, name: macOS } + - { os: windows-latest, name: Windows } + - { os: ubuntu-latest, name: Linux } steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -21,21 +40,79 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Check - run: pnpm run ci + - name: Unit tests + run: pnpm test - name: Build the app run: pnpm dist - - name: Test the built app + + # ---- the built app, as it comes out of the build + - name: Test the built app (macOS, Windows) + if: runner.os != 'Linux' run: pnpm e2e:packaged + - name: Test the built app (Linux) + if: runner.os == 'Linux' + # No desktop session on the runner: a virtual screen, and Chromium without its sandbox (which needs a configured system). + run: xvfb-run -a pnpm e2e:packaged + env: + LUCID_E2E_ARGS: "--no-sandbox --disable-gpu" + + # ---- what a person installs + - name: Test the Windows installer + if: runner.os == 'Windows' + shell: pwsh + run: | + $setup = Get-ChildItem apps/desktop/release/*.exe | Select-Object -First 1 + Start-Process -FilePath $setup.FullName -ArgumentList "/S" -Wait + $installed = Join-Path $env:LOCALAPPDATA "Programs\Lucid" + if (-not (Test-Path (Join-Path $installed "Lucid.exe"))) { throw "The installer did not install Lucid in $installed" } + $env:LUCID_APP = $installed + pnpm e2e:packaged + - name: Test the Linux package + if: runner.os == 'Linux' + run: | + sudo apt-get install -y ./apps/desktop/release/*.deb + test -x /opt/Lucid/lucid + LUCID_APP=/opt/Lucid xvfb-run -a pnpm e2e:packaged + env: + LUCID_E2E_ARGS: "--no-sandbox --disable-gpu" + - name: Test the Mac disk image + if: runner.os == 'macOS' + run: | + hdiutil attach -nobrowse -readonly -mountpoint /tmp/lucid-dmg apps/desktop/release/*.dmg + cp -R /tmp/lucid-dmg/Lucid.app /tmp/Lucid.app + hdiutil detach /tmp/lucid-dmg + LUCID_APP=/tmp/Lucid.app pnpm e2e:packaged + + - uses: actions/upload-artifact@v7 + with: + name: lucid-${{ matrix.name }} + if-no-files-found: error + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.exe + apps/desktop/release/*.AppImage + apps/desktop/release/*.deb + + publish: + name: Publish the release + needs: build + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + path: assets + merge-multiple: true - name: Checksums - working-directory: apps/desktop/release - run: shasum -a 256 *.dmg *.zip > SHA256SUMS + working-directory: assets + run: sha256sum -- * > SHA256SUMS && cat SHA256SUMS - name: Publish env: GH_TOKEN: ${{ github.token }} run: | # Release notes keep every line break, so lines wrapped in the changelog are joined first. notes="$(cat .github/release-install.md; awk -v v="${GITHUB_REF_NAME#v}" '$0 ~ "^## \\["v"\\]" {on=1; next} /^## \[/ {on=0} on' CHANGELOG.md | awk '/^ / {sub(/^ +/, " "); buf = buf $0; next} {if (NR > 1) print buf; buf = $0} END {print buf}')" - gh release create "$GITHUB_REF_NAME" \ - apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/SHA256SUMS \ + gh release create "$GITHUB_REF_NAME" assets/* \ --title "Lucid ${GITHUB_REF_NAME#v}" --notes "$notes" From 92351f688f8527e8ebee38ed98062df0a8f550fa Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:34:36 +0530 Subject: [PATCH 05/11] test: a PDF that was not saved says what the window said, and failed runs keep their test results Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 8 ++++++++ apps/desktop/e2e/helpers.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eacd05e..7ed0b45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,14 @@ jobs: hdiutil detach /tmp/lucid-dmg LUCID_APP=/tmp/Lucid.app pnpm e2e:packaged + - name: Keep the test results when a test failed + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-results-${{ matrix.name }} + if-no-files-found: ignore + path: apps/desktop/test-results + - uses: actions/upload-artifact@v7 with: name: lucid-${{ matrix.name }} diff --git a/apps/desktop/e2e/helpers.ts b/apps/desktop/e2e/helpers.ts index 0cd0724..8d72f3b 100644 --- a/apps/desktop/e2e/helpers.ts +++ b/apps/desktop/e2e/helpers.ts @@ -18,7 +18,15 @@ export async function autoSaveDialog(app: ElectronApplication): Promise /** Clicks Download PDF, waits for the file, and returns its text (when pdftotext is installed). */ export async function downloadPdf(page: Page, path: string): Promise { await page.getByRole("button", { name: "Download PDF" }).click(); - await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: 90_000 }); + try { + await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: 90_000 }); + } catch (error) { + // Say what the window said instead (the engine's own message, if it failed), so a failure on another machine can be read. + const said = await page.locator(".export-note").allTextContents(); + throw new Error(`The PDF was not saved. The window says: ${JSON.stringify(said)}`, { + cause: error, + }); + } expect(readFileSync(path).subarray(0, 5).toString()).toBe("%PDF-"); expect(statSync(path).size).toBeGreaterThan(3_000); return existsSync(PDFTOTEXT) From 3adf65be75711a472203562dce1945fb20fccf58 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:35:49 +0530 Subject: [PATCH 06/11] test: the first PDF of a packaged app gets ten minutes, the time it takes to fetch the typesetting files On a Mac it took 78 s of a 90 s allowance; the Windows and Linux runners need longer. A packaged app that never made a PDF is exactly this case. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/e2e-packaged/packaged.spec.ts | 4 +++- apps/desktop/e2e/helpers.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index 4948cf5..e1c4faa 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -49,6 +49,7 @@ test("the app is there, carries what it needs, and is signed on a Mac", () => { }); test("it opens as Lucid with its own version, and a PDF with a diagram is made with nothing installed", async () => { + test.setTimeout(720_000); const { app, page } = await launch(); try { const identity = await app.evaluate(({ app: a }) => [ @@ -73,7 +74,8 @@ test("it opens as Lucid with its own version, and a PDF with a diagram is made w const path = await autoSaveDialog(app); // The first PDF may wait for the engine to prepare itself (it fetches typesetting files once). - const text = await downloadPdf(page, path); + // On a machine that has never made a PDF this downloads Tectonic's typesetting files, which takes minutes. + const text = await downloadPdf(page, path, 600_000); await expect(page.locator(".export-note")).not.toContainText("could not be drawn"); if (text !== undefined) for (const word of ["Web Server", "Redis"]) expect(text, word).toContain(word); diff --git a/apps/desktop/e2e/helpers.ts b/apps/desktop/e2e/helpers.ts index 8d72f3b..76e68e8 100644 --- a/apps/desktop/e2e/helpers.ts +++ b/apps/desktop/e2e/helpers.ts @@ -16,10 +16,14 @@ export async function autoSaveDialog(app: ElectronApplication): Promise } /** Clicks Download PDF, waits for the file, and returns its text (when pdftotext is installed). */ -export async function downloadPdf(page: Page, path: string): Promise { +export async function downloadPdf( + page: Page, + path: string, + timeoutMs = 90_000, +): Promise { await page.getByRole("button", { name: "Download PDF" }).click(); try { - await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: timeoutMs }); } catch (error) { // Say what the window said instead (the engine's own message, if it failed), so a failure on another machine can be read. const said = await page.locator(".export-note").allTextContents(); From 3afab6ac3834cf76488447a708fe8fdc3273465b Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:54:51 +0530 Subject: [PATCH 07/11] test: failed packaged tests print what the app and its window printed, and a PDF that was not saved leaves a screenshot A temporary workflow runs only the Windows PDF test, to find why it does not finish there. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/debug-windows.yml | 33 ++++++++++++++++++++++ apps/desktop/e2e-packaged/packaged.spec.ts | 20 ++++++++++++- apps/desktop/e2e/helpers.ts | 13 +++++++-- 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/debug-windows.yml diff --git a/.github/workflows/debug-windows.yml b/.github/workflows/debug-windows.yml new file mode 100644 index 0000000..d0535e0 --- /dev/null +++ b/.github/workflows/debug-windows.yml @@ -0,0 +1,33 @@ +name: Debug Windows PDF + +# Temporary: builds the unpacked Windows app and runs only the PDF test of the packaged-app tests, with the app's logs. +on: + push: + branches: [packaging] + paths: [".github/workflows/debug-windows.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @lucid/desktop dist:dir + - name: The PDF test, with the app's logs + run: pnpm --filter @lucid/desktop exec playwright test -c playwright.packaged.config.ts -g "PDF with a diagram" + continue-on-error: true + - uses: actions/upload-artifact@v7 + with: + name: debug-windows + if-no-files-found: ignore + path: apps/desktop/test-results diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index e1c4faa..54a0264 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -17,17 +17,35 @@ const VERSION = ( ).version; const bare = bareEnv; +/** What the app and its window printed, kept so that a failure on a machine nobody can look at can be read afterwards. */ +const output: string[] = []; + async function launch(home = mkdtempSync(join(tmpdir(), "lucid-home-"))) { const app = await electron.launch({ executablePath: BINARY, args: EXTRA_ARGS, - env: bare(home), + env: { ...bare(home), ELECTRON_ENABLE_LOGGING: "1" }, }); + const child = app.process(); + child.stdout?.on("data", (d: Buffer) => output.push(`[app out] ${d.toString().trimEnd()}`)); + child.stderr?.on("data", (d: Buffer) => output.push(`[app err] ${d.toString().trimEnd()}`)); const page = await app.firstWindow(); + page.on("console", (m) => output.push(`[window ${m.type()}] ${m.text()}`)); + page.on("pageerror", (e) => output.push(`[window error] ${e.message}`)); await maximizeWindow(app, page); return { app, page, home }; } +test.beforeEach(() => { + output.length = 0; +}); +test.afterEach(async ({}, testInfo) => { + if (testInfo.status === testInfo.expectedStatus) return; + console.log( + `---- what the app and its window printed (${testInfo.title}) ----\n${output.slice(-200).join("\n")}`, + ); +}); + test("the app is there, carries what it needs, and is signed on a Mac", () => { expect(existsSync(BINARY), "run `pnpm dist:dir` first").toBe(true); for (const file of [ diff --git a/apps/desktop/e2e/helpers.ts b/apps/desktop/e2e/helpers.ts index 76e68e8..00bdcee 100644 --- a/apps/desktop/e2e/helpers.ts +++ b/apps/desktop/e2e/helpers.ts @@ -27,9 +27,16 @@ export async function downloadPdf( } catch (error) { // Say what the window said instead (the engine's own message, if it failed), so a failure on another machine can be read. const said = await page.locator(".export-note").allTextContents(); - throw new Error(`The PDF was not saved. The window says: ${JSON.stringify(said)}`, { - cause: error, - }); + const buttons = await page.getByRole("button").allTextContents(); + await page + .screenshot({ path: `test-results/pdf-not-saved-${String(Date.now())}.png` }) + .catch(() => undefined); + throw new Error( + `The PDF was not saved. The window says: ${JSON.stringify(said)}; its buttons: ${JSON.stringify(buttons)}`, + { + cause: error, + }, + ); } expect(readFileSync(path).subarray(0, 5).toString()).toBe("%PDF-"); expect(statSync(path).size).toBeGreaterThan(3_000); From ed449796a1db56af7de5264e5caeaaa332695ff0 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:55:25 +0530 Subject: [PATCH 08/11] test: lint Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/e2e-packaged/packaged.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index 54a0264..aae1c1e 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -39,7 +39,7 @@ async function launch(home = mkdtempSync(join(tmpdir(), "lucid-home-"))) { test.beforeEach(() => { output.length = 0; }); -test.afterEach(async ({}, testInfo) => { +test.afterEach((_fixtures, testInfo) => { if (testInfo.status === testInfo.expectedStatus) return; console.log( `---- what the app and its window printed (${testInfo.title}) ----\n${output.slice(-200).join("\n")}`, From ec2221a4bb8dec2f6e973b282a977f8ca7832c5a Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:09:05 +0530 Subject: [PATCH 09/11] fix(desktop): the note after saving a file names the file on Windows too, and the missing-engine hint fits the system The notes 'Saved page.pdf' cut the name out of the path by splitting at '/', which Windows paths do not use, so there they showed the whole path (the PDF was saved; a packaged-app test on Windows could not find the note). The name is now taken at either separator. The hint for a missing PDF engine named Homebrew, which is Mac-only; elsewhere it points to Tectonic's page. The temporary debugging workflow is removed. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/debug-windows.yml | 33 ------------------------- apps/desktop/electron/pdf.test.ts | 23 ++++++++++++++++- apps/desktop/electron/pdf.ts | 16 +++++++++--- apps/desktop/src/pages/ExportButton.tsx | 8 +++--- apps/desktop/src/platform.test.ts | 11 ++++++++- apps/desktop/src/platform.ts | 3 +++ 6 files changed, 51 insertions(+), 43 deletions(-) delete mode 100644 .github/workflows/debug-windows.yml diff --git a/.github/workflows/debug-windows.yml b/.github/workflows/debug-windows.yml deleted file mode 100644 index d0535e0..0000000 --- a/.github/workflows/debug-windows.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Debug Windows PDF - -# Temporary: builds the unpacked Windows app and runs only the PDF test of the packaged-app tests, with the app's logs. -on: - push: - branches: [packaging] - paths: [".github/workflows/debug-windows.yml"] - workflow_dispatch: - -permissions: - contents: read - -jobs: - windows: - runs-on: windows-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @lucid/desktop dist:dir - - name: The PDF test, with the app's logs - run: pnpm --filter @lucid/desktop exec playwright test -c playwright.packaged.config.ts -g "PDF with a diagram" - continue-on-error: true - - uses: actions/upload-artifact@v7 - with: - name: debug-windows - if-no-files-found: ignore - path: apps/desktop/test-results diff --git a/apps/desktop/electron/pdf.test.ts b/apps/desktop/electron/pdf.test.ts index 6eaaaba..df4a575 100644 --- a/apps/desktop/electron/pdf.test.ts +++ b/apps/desktop/electron/pdf.test.ts @@ -107,6 +107,25 @@ describe("explainLatexError", () => { }); }); +describe("explaining a missing PDF engine", () => { + const log = + "could not start /opt/Lucid/resources/bin/tectonic: spawn /opt/Lucid/resources/bin/tectonic ENOENT"; + it("suggests Homebrew on a Mac only", () => { + expect(explainLatexError(log, "darwin")).toContain("brew install tectonic"); + for (const platform of ["linux", "win32"] as const) { + const said = explainLatexError(log, platform); + expect(said).not.toContain("brew"); + expect(said).toContain("tectonic-typesetting.github.io"); + } + }); + + it("names the engine whichever way the path is written", () => { + const windows = + "could not start C:\\Lucid\\resources\\bin\\tectonic.exe: spawn C:\\Lucid\\resources\\bin\\tectonic.exe ENOENT"; + expect(explainLatexError(windows, "win32")).toMatch(/^Tectonic, the PDF engine, was not found/); + }); +}); + describe("explaining what went wrong on a first PDF", () => { it("says the first PDF needs the internet, when Tectonic could not fetch its files", () => { const log = @@ -496,7 +515,9 @@ shellScripts("tools that are not installed", () => { expect(r.ok).toBe(false); const message = !r.ok && !r.cancelled ? r.message : ""; expect(message).toContain("Tectonic, the PDF engine, was not found"); - expect(message).toContain("brew install tectonic"); + expect(message).toContain( + process.platform === "darwin" ? "brew install tectonic" : "tectonic-typesetting.github.io", + ); expect(message).toContain("Markdown export still works"); expect( explainLatexError("error: could not start /opt/x/other: spawn /opt/x/other ENOENT"), diff --git a/apps/desktop/electron/pdf.ts b/apps/desktop/electron/pdf.ts index 7f06193..1b8036b 100644 --- a/apps/desktop/electron/pdf.ts +++ b/apps/desktop/electron/pdf.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { LUCID_STY, registry, renderDocument, type Format } from "@lucid/kit"; import type { LucidDoc } from "@lucid/core"; import type { PdfProgress } from "./ipc/types"; @@ -83,13 +83,21 @@ export function svgWidthsInPoints( } /** The first LaTeX error in words a person can act on, without file paths or engine jargon. */ -export function explainLatexError(log: string): string { +export function explainLatexError( + log: string, + platform: NodeJS.Platform = process.platform, +): string { // A tool that is not installed, before anything about LaTeX. const missing = /could not start (\S+): spawn \S+ ENOENT/.exec(log); if (missing?.[1]) { - const name = missing[1].split("/").pop() ?? missing[1]; + const name = basename(missing[1]); + // Homebrew is a Mac thing: elsewhere the way to get Tectonic is its own page. + const install = + platform === "darwin" + ? "brew install tectonic" + : "see https://tectonic-typesetting.github.io"; return name.includes("tectonic") - ? `Tectonic, the PDF engine, was not found (${missing[1]}). Reinstall Lucid, or install it with: brew install tectonic. The Markdown export still works.` + ? `Tectonic, the PDF engine, was not found (${missing[1]}). Reinstall Lucid, or install it yourself (${install}). The Markdown export still works.` : `${missing[1]} was not found.`; } // The first PDF needs the internet once: the typesetting files are downloaded, then kept. diff --git a/apps/desktop/src/pages/ExportButton.tsx b/apps/desktop/src/pages/ExportButton.tsx index bbcf97e..d5c2692 100644 --- a/apps/desktop/src/pages/ExportButton.tsx +++ b/apps/desktop/src/pages/ExportButton.tsx @@ -1,5 +1,5 @@ import { Icon } from "../ui/Icon"; -import { keys } from "../platform"; +import { baseName, keys } from "../platform"; import { useEffect, useRef, useState } from "react"; import { makePdf, pdfFileName } from "../preview/exportPdf"; import { docToMarkdown } from "@lucid/kit"; @@ -90,7 +90,7 @@ export function ExportButton({ const path = await window.lucid.pdf.save(made.pdf, pdfFileName(name)); if (path) { const warning = made.warnings.length > 0 ? ` ${made.warnings.join(" ")}` : ""; - setNote({ kind: "saved", text: `Saved ${path.split("/").pop() ?? path}.${warning}`, path }); + setNote({ kind: "saved", text: `Saved ${baseName(path)}.${warning}`, path }); } } finally { setBusy(false); @@ -104,14 +104,14 @@ export function ExportButton({ docToMarkdown(current, { title: name }), pdfFileName(name).replace(/\.pdf$/, ".md"), ); - if (path) setNote({ kind: "saved", text: `Saved ${path.split("/").pop() ?? path}` }); + if (path) setNote({ kind: "saved", text: `Saved ${baseName(path)}` }); }; const runNote = async (history: boolean) => { setNoteOpen(false); setNote(undefined); try { const path = await window.lucid.note.export(getNote(), { history }); - if (path) setNote({ kind: "saved", text: `Saved ${path.split("/").pop() ?? path}.` }); + if (path) setNote({ kind: "saved", text: `Saved ${baseName(path)}.` }); } catch (error) { setNote({ kind: "error", text: `The note couldn’t be exported. ${plain(error)}` }); } diff --git a/apps/desktop/src/platform.test.ts b/apps/desktop/src/platform.test.ts index 0315f1f..d88ba28 100644 --- a/apps/desktop/src/platform.test.ts +++ b/apps/desktop/src/platform.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { keys } from "./platform"; +import { baseName, keys } from "./platform"; describe("keys", () => { it("leaves the Mac's symbols alone on a Mac", () => { @@ -21,3 +21,12 @@ describe("keys", () => { ); }); }); + +describe("baseName", () => { + it("is the name of the file, on a Mac, Linux or Windows path", () => { + expect(baseName("/Users/a/Documents/page.pdf")).toBe("page.pdf"); + expect(baseName("C:\\Users\\a\\Documents\\page.pdf")).toBe("page.pdf"); + expect(baseName("D:/exports/page.pdf")).toBe("page.pdf"); + expect(baseName("page.pdf")).toBe("page.pdf"); + }); +}); diff --git a/apps/desktop/src/platform.ts b/apps/desktop/src/platform.ts index 628f0f3..85975fe 100644 --- a/apps/desktop/src/platform.ts +++ b/apps/desktop/src/platform.ts @@ -5,6 +5,9 @@ export const IS_MAC: boolean = typeof navigator !== "undefined" && /mac/i.test(navigator.platform || navigator.userAgent); +/** The name of a file from its path, whatever the system writes it with: `/Users/a/page.pdf` or `C:\\Users\\a\\page.pdf`. */ +export const baseName = (path: string): string => path.split(/[\\/]/).pop() ?? path; + /** `⇧⌘E` as the person's keyboard calls it: `⇧⌘E` on a Mac, `Ctrl+Shift+E` elsewhere. */ export function keys(text: string, mac: boolean = IS_MAC): string { if (mac) return text; From 4723c1d74f7a48b1f8b6925c498689fcb29c13c9 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:15:37 +0530 Subject: [PATCH 10/11] test: Playwright needs a destructuring pattern as the first argument of a hook Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/e2e-packaged/packaged.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index aae1c1e..74afe07 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -39,7 +39,9 @@ async function launch(home = mkdtempSync(join(tmpdir(), "lucid-home-"))) { test.beforeEach(() => { output.length = 0; }); -test.afterEach((_fixtures, testInfo) => { +// Playwright requires the first argument of a hook to be a destructuring pattern, even when it uses no fixture. +// eslint-disable-next-line no-empty-pattern +test.afterEach(({}, testInfo) => { if (testInfo.status === testInfo.expectedStatus) return; console.log( `---- what the app and its window printed (${testInfo.title}) ----\n${output.slice(-200).join("\n")}`, From 22dcaa9fb3530cb3d50197e1222df5bbe9a2d811 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:23:08 +0530 Subject: [PATCH 11/11] ci: the Windows installer test waits for the install and finds the app where it went Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ed0b45..e8ec89f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,10 +62,21 @@ jobs: shell: pwsh run: | $setup = Get-ChildItem apps/desktop/release/*.exe | Select-Object -First 1 - Start-Process -FilePath $setup.FullName -ArgumentList "/S" -Wait - $installed = Join-Path $env:LOCALAPPDATA "Programs\Lucid" - if (-not (Test-Path (Join-Path $installed "Lucid.exe"))) { throw "The installer did not install Lucid in $installed" } - $env:LUCID_APP = $installed + $run = Start-Process -FilePath $setup.FullName -ArgumentList "/S" -Wait -PassThru + "The installer ended with exit code $($run.ExitCode)" + # The installer may leave work to a process it started: wait until the program is there, then find it wherever it went. + $exe = $null + for ($i = 0; $i -lt 60 -and -not $exe; $i++) { + $exe = Get-ChildItem -Path $env:LOCALAPPDATA, $env:ProgramFiles -Filter Lucid.exe -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch 'node_modules|\\resources\\' } | Select-Object -First 1 + if (-not $exe) { Start-Sleep -Seconds 2 } + } + if (-not $exe) { + Get-ChildItem "$env:LOCALAPPDATA\Programs" -ErrorAction SilentlyContinue | Out-String + throw "The installer did not install Lucid anywhere" + } + "Installed in $($exe.DirectoryName)" + $env:LUCID_APP = $exe.DirectoryName pnpm e2e:packaged - name: Test the Linux package if: runner.os == 'Linux'