Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/release-install.md
Original file line number Diff line number Diff line change
@@ -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`.

118 changes: 107 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
63 changes: 42 additions & 21 deletions apps/desktop/e2e-packaged/packaged.spec.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,83 @@
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",
"THIRD_PARTY_NOTICES.md",
"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 }) => [
a.isPackaged,
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");
Expand All @@ -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);
Expand Down Expand Up @@ -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<void>((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"));
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop/e2e-packaged/target.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const system = process.env["SystemRoot"] ?? "C:\\Windows";
const path = process.platform === "win32" ? `${system}\\System32;${system}` : "/usr/bin:/bin";
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
return { ...env, LUCID_HOME: home, PATH: path };
}
23 changes: 21 additions & 2 deletions apps/desktop/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,28 @@ export async function autoSaveDialog(app: ElectronApplication): Promise<string>
}

/** Clicks Download PDF, waits for the file, and returns its text (when pdftotext is installed). */
export async function downloadPdf(page: Page, path: string): Promise<string | undefined> {
export async function downloadPdf(
page: Page,
path: string,
timeoutMs = 90_000,
): Promise<string | undefined> {
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)
Expand Down
Loading
Loading