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..e8ec89f 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,98 @@ 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 + $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' + 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 + + - 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 }} + 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" diff --git a/apps/desktop/e2e-packaged/packaged.spec.ts b/apps/desktop/e2e-packaged/packaged.spec.ts index 3032e98..74afe07 100644 --- a/apps/desktop/e2e-packaged/packaged.spec.ts +++ b/apps/desktop/e2e-packaged/packaged.spec.ts @@ -1,39 +1,56 @@ 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"); +// 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; -const bare = (home: string) => ({ - ...process.env, - LUCID_HOME: home, - PATH: "/usr/bin:/bin", -}); +/** 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, env: bare(home) }); + const app = await electron.launch({ + executablePath: BINARY, + args: EXTRA_ARGS, + 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("the app is there, is signed, and carries what it needs", () => { +test.beforeEach(() => { + output.length = 0; +}); +// 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")}`, + ); +}); + +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 +58,18 @@ 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 () => { + test.setTimeout(720_000); const { app, page } = await launch(); try { const identity = await app.evaluate(({ app: a }) => [ @@ -57,7 +77,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"); @@ -74,7 +94,8 @@ test("it opens as Lucid 0.1.0, and a PDF with a diagram is made with nothing ins 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); @@ -123,7 +144,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 }; +} diff --git a/apps/desktop/e2e/helpers.ts b/apps/desktop/e2e/helpers.ts index 0cd0724..00bdcee 100644 --- a/apps/desktop/e2e/helpers.ts +++ b/apps/desktop/e2e/helpers.ts @@ -16,9 +16,28 @@ 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(); - await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: 90_000 }); + try { + 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(); + 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); return existsSync(PDFTOTEXT) 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/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/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" }); }; 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; 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}`);