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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ jobs:
npm install
npm test
npm run build
- name: Desktop unit tests
run: node --test desktop/backend.test.mjs desktop/urls.test.mjs
87 changes: 87 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Desktop builds

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: desktop-build-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: ${{ matrix.label }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
label: Linux
electron_args: --linux AppImage deb
- os: windows-latest
label: Windows
electron_args: --win nsis
- os: macos-latest
label: macOS
electron_args: --mac dmg zip
defaults:
run:
shell: bash
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: |
ui/package-lock.json
desktop/package-lock.json

- name: Build UI
working-directory: ui
run: |
npm ci
npm run build

- name: Install Python package and PyInstaller
run: |
python -m pip install --upgrade pip
python -m pip install -e .
python -m pip install pyinstaller

- name: Bundle backend sidecar
run: python -m PyInstaller packaging/loadpath.spec --noconfirm --clean --distpath desktop/backend-dist

- name: Smoke-test sidecar
run: python packaging/smoke_backend.py

- name: Install desktop dependencies
working-directory: desktop
run: npm ci

- name: Build Electron app
working-directory: desktop
run: npx electron-builder --publish never ${{ matrix.electron_args }}

- name: Upload installers
uses: actions/upload-artifact@v4
with:
name: loadpath-${{ matrix.label }}
if-no-files-found: error
path: |
desktop/dist/*.AppImage
desktop/dist/*.deb
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
desktop/dist/*.yml
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ ui/dist/
.playwright-mcp/
ui/playwright-report/
ui/test-results/
desktop/backend-dist/
desktop/dist/
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ cd ui && npm install && npm run build && cd ..
loadpath --help
```

## Desktop app (Windows, macOS, Linux)

Electron wraps the same local app: it starts the Loadpath backend and opens it in a native window. Tokens still live in `~/.loadpath/settings.json`.

**From source**

```bash
pip install -e .
cd ui && npm install && npm run build && cd ..
cd desktop && npm install && npm start
```

Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or set `LOADPATH_PYTHON`.

**Installers**

GitHub → Actions → **Desktop builds** → **Run workflow**. That manual job builds:

| OS | Artifact |
| --- | --- |
| Linux | AppImage and `.deb` |
| Windows | NSIS `.exe` |
| macOS | `.dmg` and `.zip` (unsigned) |

macOS Gatekeeper will block the unsigned app until you open it from Finder with right-click → Open. The workflow smokes `/api/health` on the bundled Python sidecar before packaging.

## CLI

```bash
Expand Down Expand Up @@ -205,6 +231,7 @@ Line coverage on changed files is the wrong metric. Loadpath scores the **impact
```bash
pytest
cd ui && npm test
node --test desktop/*.test.mjs
```

| Suite | What it covers |
Expand All @@ -217,6 +244,7 @@ cd ui && npm test
| `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph |
| `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas |
| `tests/e2e/test_ui_screenshots.py` | Playwright: Architecture, Review, Impact graph, Pull requests, Settings → `docs/screenshots/` |
| `desktop/*.test.mjs` | Electron sidecar command, health-wait, and external-URL allowlist |

CI installs Chromium and runs the full suite.

Expand Down
73 changes: 73 additions & 0 deletions desktop/backend.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import net from "node:net";
import path from "node:path";

export function backendBinaryName(platform) {
return platform === "win32" ? "loadpath.exe" : "loadpath";
}

export function backendCommand({
packaged,
platform,
port,
resourcesPath,
python,
repoRoot,
}) {
const serveArgs = ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-open"];
if (packaged) {
return {
command: path.join(resourcesPath, "loadpath", backendBinaryName(platform)),
args: serveArgs,
cwd: undefined,
env: {},
};
}
const command = python || (platform === "win32" ? "python" : "python3");
return {
command,
args: ["-m", "loadpath", ...serveArgs],
cwd: repoRoot,
env: repoRoot ? { PYTHONPATH: path.join(repoRoot, "src") } : {},
};
}

export function pickFreePort(host = "127.0.0.1") {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((err) => (err ? reject(err) : resolve(port)));
});
});
}

export async function waitForHealth(baseUrl, options = {}) {
const {
fetchImpl = globalThis.fetch,
timeoutMs = 45_000,
intervalMs = 200,
isAborted = () => false,
} = options;
const url = `${String(baseUrl).replace(/\/$/, "")}/api/health`;
const start = Date.now();
let lastError = "backend did not become ready";
while (Date.now() - start < timeoutMs) {
if (isAborted()) {
throw new Error("backend exited before it became ready");
}
try {
const res = await fetchImpl(url, { signal: AbortSignal.timeout(1500) });
if (res.ok) {
return res.json().catch(() => ({}));
}
lastError = `health check HTTP ${res.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Loadpath backend failed to start: ${lastError}`);
}
111 changes: 111 additions & 0 deletions desktop/backend.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import path from "node:path";
import { describe, it } from "node:test";

import { backendBinaryName, backendCommand, waitForHealth } from "./backend.mjs";

describe("backendBinaryName", () => {
it("uses .exe on Windows", () => {
assert.equal(backendBinaryName("win32"), "loadpath.exe");
});

it("uses a bare name on macOS and Linux", () => {
assert.equal(backendBinaryName("darwin"), "loadpath");
assert.equal(backendBinaryName("linux"), "loadpath");
});
});

describe("backendCommand", () => {
it("runs python -m loadpath serve in development", () => {
const result = backendCommand({
packaged: false,
platform: "linux",
port: 7345,
python: "python3",
repoRoot: "/repo",
});
assert.equal(result.command, "python3");
assert.deepEqual(result.args, [
"-m",
"loadpath",
"serve",
"--host",
"127.0.0.1",
"--port",
"7345",
"--no-open",
]);
assert.equal(result.cwd, "/repo");
assert.equal(result.env.PYTHONPATH, path.join("/repo", "src"));
});

it("defaults to python.exe-style interpreter name on Windows", () => {
const result = backendCommand({
packaged: false,
platform: "win32",
port: 8000,
repoRoot: "C:\\\\src",
});
assert.equal(result.command, "python");
assert.equal(result.args[6], "8000");
});

it("points at the bundled sidecar when packaged", () => {
const result = backendCommand({
packaged: true,
platform: "darwin",
port: 9000,
resourcesPath: "/App/Contents/Resources",
});
assert.equal(result.command, path.join("/App/Contents/Resources", "loadpath", "loadpath"));
assert.deepEqual(result.args, ["serve", "--host", "127.0.0.1", "--port", "9000", "--no-open"]);
assert.equal(result.cwd, undefined);
});

it("uses loadpath.exe under extraResources on Windows", () => {
const result = backendCommand({
packaged: true,
platform: "win32",
port: 7345,
resourcesPath: "C:\\\\app\\\\resources",
});
assert.equal(result.command, path.join("C:\\\\app\\\\resources", "loadpath", "loadpath.exe"));
});
});

describe("waitForHealth", () => {
it("returns once /api/health is ok", async () => {
let calls = 0;
const body = await waitForHealth("http://127.0.0.1:9", {
timeoutMs: 1000,
intervalMs: 1,
fetchImpl: async (url) => {
calls += 1;
assert.equal(url, "http://127.0.0.1:9/api/health");
if (calls < 3) throw new Error("connection refused");
return {
ok: true,
status: 200,
json: async () => ({ status: "ok", version: "0.1.0" }),
};
},
});
assert.equal(calls, 3);
assert.equal(body.status, "ok");
});

it("fails when the backend process already exited", async () => {
await assert.rejects(
() =>
waitForHealth("http://127.0.0.1:9", {
timeoutMs: 500,
intervalMs: 1,
isAborted: () => true,
fetchImpl: async () => {
throw new Error("should not fetch");
},
}),
/exited before it became ready/,
);
});
});
44 changes: 44 additions & 0 deletions desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
appId: app.loadpath.desktop
productName: Loadpath
copyright: Copyright © Loadpath
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
directories:
output: dist
buildResources: resources
files:
- main.mjs
- backend.mjs
- urls.mjs
- package.json
extraResources:
- from: backend-dist/loadpath
to: loadpath
filter:
- "**/*"
asar: true
compression: normal
linux:
target:
- AppImage
- deb
category: Development
maintainer: Loadpath <loadpath@users.noreply.github.com>
synopsis: Architecture-typed impact graphs for Django + React pull requests.
syncDesktopName: true
win:
target:
- nsis
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
identity: null
hardenedRuntime: false
gatekeeperAssess: false
dmg:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
Loading
Loading