Skip to content
Open
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
48 changes: 48 additions & 0 deletions packages/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# @axiom-labs/arc-desktop

Minimal Electron shell for ARC. Spawns the local daemon, opens a window pointed
at the dashboard, and installs a tray icon with show/hide/quit.

## Quick start

```bash
pnpm install
pnpm --filter @axiom-labs/arc-desktop dev
```

That compiles `src/` to `dist/` and launches Electron. On first boot the shell
calls `startDaemon({ version })` from `@axiom-labs/arc-daemon` and polls
`http://127.0.0.1:7272/health` until it answers. Once healthy, the window loads
the dashboard served at `/`.

### Dashboard fallback

This scaffold was authored before the daemon serves dashboard assets at `/`
(that work lives in Unit 4). Until that lands, the daemon only responds to
`/health` and `/` returns 404 — so the shell falls back to the standalone
dev dashboard at `http://127.0.0.1:3700/` if it can reach it. Run
`pnpm dev:dashboard` in another terminal for the fallback to succeed.

## Commands

| Command | What it does |
| ----------------------------------------------- | --------------------------------------- |
| `pnpm --filter @axiom-labs/arc-desktop dev` | Build TS then launch Electron |
| `pnpm --filter @axiom-labs/arc-desktop build` | Compile TypeScript to `dist/` |
| `pnpm --filter @axiom-labs/arc-desktop pack` | Unsigned unpacked app in `dist-electron` |
| `pnpm --filter @axiom-labs/arc-desktop dist` | Unsigned installer in `dist-electron` |

Code signing, auto-update, and real installers are out of scope for the
scaffold.

## Tray icon

`assets/tray.png` is a placeholder 16x16 transparent PNG. Replace it with a
real icon before shipping. If the file is missing or empty the shell still
launches (it uses `nativeImage.createEmpty()`).

## Files

- `src/main.ts` — Electron main process (daemon bootstrap, window, tray).
- `src/preload.ts` — empty placeholder for a future context bridge.
- `electron-builder.yml` — minimal unsigned packaging config.
Binary file added packages/desktop/assets/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions packages/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
appId: sh.arc.desktop
productName: ARC
directories:
output: dist-electron
buildResources: assets
files:
- dist/**/*
- assets/**/*
- package.json
# Workspace deps need to be bundled in via `asar.smartUnpack: false` + node_modules.
# For a scaffold we leave this minimal; real packaging will rewire later.
asar: true
mac:
target:
- dir
category: public.app-category.developer-tools
linux:
target:
- dir
win:
target:
- dir
22 changes: 22 additions & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@axiom-labs/arc-desktop",
"version": "1.0.0-alpha.0",
"private": true,
"type": "module",
"description": "ARC desktop shell (Electron). Spawns the ARC daemon and loads the dashboard.",
"main": "./dist/main.js",
"scripts": {
"build": "tsup src/main.ts src/preload.ts --format esm --target node20 --out-dir dist --external electron --clean",
"dev": "pnpm run build && electron ./dist/main.js",
"pack": "pnpm run build && electron-builder --config electron-builder.yml --dir",
"dist": "pnpm run build && electron-builder --config electron-builder.yml",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@axiom-labs/arc-daemon": "workspace:*"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.1.8"
}
}
185 changes: 185 additions & 0 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import http from "node:http";
import { app, BrowserWindow, Menu, Tray, nativeImage } from "electron";
import { startDaemon, type DaemonHandle } from "@axiom-labs/arc-daemon";

let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let daemon: Pick<DaemonHandle, "config" | "stop"> | null = null;
let quitting = false;

// Hard-coded to avoid pulling in the CLI package (which would drag the whole
// TUI into the Electron bundle). Packaged builds can stamp ARC_VERSION.
const VERSION: string = process.env["ARC_VERSION"] ?? "1.0.0-alpha.0";

const DAEMON_HOST = "127.0.0.1";
const DAEMON_PORT = 7272;
const DAEMON_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/`;
const DAEMON_HEALTH_URL = `${DAEMON_URL}health`;
const DEV_DASHBOARD_FALLBACK = "http://127.0.0.1:3700/";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PRELOAD_PATH = path.join(__dirname, "preload.js");
const TRAY_ICON_PATH = path.join(__dirname, "..", "assets", "tray.png");

async function ensureDaemon(): Promise<void> {
try {
daemon = await startDaemon({ version: VERSION });
} catch (err) {
// If another ARC process already owns the daemon that's fine — we just
// won't be the one to stop it on quit.
if (/already running/i.test((err as Error).message ?? "")) {
daemon = null;
return;
}
throw err;
}
}

/** HEAD-ish GET probe: resolves true when the URL answers <500 within timeoutMs. */
function probeUrl(url: string, timeoutMs = 500): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(url, { timeout: timeoutMs }, (res) => {
res.resume();
resolve((res.statusCode ?? 500) < 500);
});
req.on("error", () => resolve(false));
req.on("timeout", () => {
req.destroy();
resolve(false);
});
});
}

async function waitForHealth(timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await probeUrl(DAEMON_HEALTH_URL)) return true;
await sleep(100);
}
return false;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1280,
height: 840,
show: false,
backgroundColor: "#0a0a0a",
webPreferences: {
preload: PRELOAD_PATH,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});

win.once("ready-to-show", () => win.show());
// Hide instead of destroying so the tray can re-open this window.
win.on("close", (event) => {
if (!quitting) {
event.preventDefault();
win.hide();
}
});

return win;
}

async function loadDashboard(win: BrowserWindow): Promise<void> {
// TODO(Unit 4): once the daemon serves dashboard assets at `/`, drop the
// :3700 fallback. Until then `/` returns 404 and we prefer the dev server
// when it's reachable.
await waitForHealth();
const fallbackReachable = await probeUrl(DEV_DASHBOARD_FALLBACK);
await win.loadURL(fallbackReachable ? DEV_DASHBOARD_FALLBACK : DAEMON_URL);
}

function showOrCreateWindow(): void {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
return;
}
mainWindow = createWindow();
void loadDashboard(mainWindow);
}

function setupTray(): void {
// nativeImage tolerates a missing/empty icon — falls back to a blank tray.
const image = nativeImage.createFromPath(TRAY_ICON_PATH);
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image);
tray.setToolTip("ARC");
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Show ARC", click: showOrCreateWindow },
{ label: "Hide ARC", click: () => mainWindow?.hide() },
{ type: "separator" },
{
label: "Quit",
click: () => {
quitting = true;
app.quit();
},
},
]),
);
tray.on("click", () => {
if (mainWindow?.isVisible()) mainWindow.hide();
else showOrCreateWindow();
});
}

const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});

app.whenReady().then(async () => {
try {
await ensureDaemon();
} catch (err) {
// Non-fatal: the window still loads so the user sees a useful error
// message from the dashboard (or the daemon's 404).
console.error("[arc-desktop] failed to start daemon:", err);
}

mainWindow = createWindow();
await loadDashboard(mainWindow);
setupTray();

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) showOrCreateWindow();
});
});

app.on("before-quit", async (event) => {
if (!daemon) return;
event.preventDefault();
const handle = daemon;
daemon = null;
try {
await handle.stop();
} catch (err) {
console.error("[arc-desktop] daemon stop failed:", err);
} finally {
quitting = true;
app.quit();
}
});

// Intentionally empty: we keep the tray alive after the last window closes.
app.on("window-all-closed", () => {});
}
4 changes: 4 additions & 0 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Placeholder preload script. Intentionally empty — we run with
// contextIsolation + sandbox enabled and currently expose no bridge. Future
// bridges (profile switching, daemon RPCs, etc.) should live here.
export {};
13 changes: 13 additions & 0 deletions packages/desktop/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"jsx": "preserve",
"noEmit": true
},
"include": ["src/**/*.ts"]
}



Loading
Loading