Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ packages/trueforge/src/catalog/skillCatalog.gen.ts
packages/trueforge/src/catalog/sandboxCatalog.gen.ts
packages/trueforge/src/sandbox/local/sandboxScripts.gen.ts
data/
desktop/.stage/
desktop/release/
# Helm subchart deps are fetched via `helm dependency build` (pinned by the
# committed Chart.lock); the downloaded .tgz/dirs are not committed.
charts/*/charts/
Expand Down
41 changes: 41 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# TrueForge desktop (local development)

This Electron shell runs the built standalone TrueForge server and opens its bundled UI in a desktop window. The local
topology matches `npx @truefoundry/trueforge`: one Node process serves the API and frontend and persists data in SQLite.

From the repository root:

```bash
pnpm desktop
```

The command builds the workspace first, starts TrueForge on `127.0.0.1:8790`, waits for `/healthz`, and opens Electron.
Closing the window stops the server process started by Electron.

Set `PORT` before running the command to use another port. If a healthy TrueForge server is already listening there,
the desktop shell reuses it and leaves it running when the window closes.

The `pnpm desktop` workflow is for local development and does not package the app.

## Build an unsigned DMG

On macOS, run:

```bash
pnpm desktop:pack
```

This builds TrueForge and stages a production-only harness plus the current Node executable, then `desktop/pack.mjs`
builds the app bundle in `desktop/release/mac-<arch>/`, copies the staged runtime into it, and writes an unsigned DMG to
`desktop/release/`. The DMG targets the architecture of the Mac that runs the command.

`pnpm desktop:pack:dmg` repeats everything after staging, which is the loop to use when only packaging changes.

Two packaging details are deliberate. The staged harness is copied into `Contents/Resources` by the script rather than
by electron-builder's `extraResources`, which drops `node_modules` and leaves the harness unable to resolve its
dependencies at runtime. The DMG is created with `hdiutil` rather than electron-builder's DMG target, because macOS
fails that target's image conversion intermittently with `hdiutil: convert failed - Resource temporarily unavailable`;
the script retries the call instead of failing the build.

Because the app is unsigned, macOS may block its first launch. Right-click the installed app and choose **Open** to
confirm that you trust it.
24 changes: 24 additions & 0 deletions desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
appId: dev.trueforge.desktop
productName: TrueForge
asar: true
npmRebuild: false

directories:
output: desktop/release

files:
- desktop/main.mjs
- package.json

extraMetadata:
name: trueforge-desktop
main: desktop/main.mjs

mac:
category: public.app-category.developer-tools
identity: null
# The DMG is written by desktop/dmg.mjs; electron-builder only produces the app bundle.
target:
- dir

artifactName: ${productName}-${version}-${arch}.${ext}
172 changes: 172 additions & 0 deletions desktop/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Starts the built standalone TrueForge server and opens its bundled UI in Electron.
* This is the same single-process topology used by `npx @truefoundry/trueforge`:
* Hono API + static frontend + SQLite, all on localhost.
*/
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { app, BrowserWindow, dialog } from 'electron';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const serverDirectory = app.isPackaged
? path.join(process.resourcesPath, 'harness')
: path.join(repoRoot, 'packages/trueforge');
const serverEntry = path.join(serverDirectory, 'dist/main.js');
const envFile = app.isPackaged ? undefined : path.join(serverDirectory, '.env');
const bundledNodeExecutable = path.join(process.resourcesPath, 'node/bin/node');
const defaultPort = 8790;
const healthTimeoutMs = 60_000;

let serverProcess;
let ownsServerProcess = false;
let quitting = false;

function resolvePort() {
const rawPort = process.env['PORT'];
if (rawPort === undefined || rawPort.trim() === '') {
return defaultPort;
}

const port = Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(`PORT must be an integer between 1 and 65535, got "${rawPort}"`);
}
return port;
}

function serverOrigin(port) {
return `http://127.0.0.1:${String(port)}`;
}

async function isHealthy(port) {
try {
const response = await fetch(`${serverOrigin(port)}/healthz`);
return response.ok;
} catch {
return false;
}
}

function delay(milliseconds) {
return new Promise(resolve => {
setTimeout(resolve, milliseconds);
});
}

async function waitForServer(port) {
const deadline = Date.now() + healthTimeoutMs;
while (Date.now() < deadline) {
if (serverProcess?.exitCode !== null && serverProcess?.exitCode !== undefined) {
throw new Error(`TrueForge server exited with code ${String(serverProcess.exitCode)} before becoming ready`);
}
if (await isHealthy(port)) {
return;
}
await delay(200);
}

throw new Error(
`TrueForge did not become ready at ${serverOrigin(port)}/healthz within ${String(healthTimeoutMs / 1000)} seconds`,
);
}

function startServer(port) {
if (!existsSync(serverEntry)) {
throw new Error(
app.isPackaged
? `The app is missing its bundled TrueForge server at ${serverEntry}`
: `Missing ${serverEntry}. Run \`pnpm desktop:build\` first.`,
);
}

const nodeExecutable = app.isPackaged ? bundledNodeExecutable : (process.env['npm_node_execpath'] ?? 'node');
if (!existsSync(nodeExecutable) && app.isPackaged) {
throw new Error(`Missing bundled Node executable at ${nodeExecutable}`);
}
const nodeArgs = envFile !== undefined && existsSync(envFile) ? ['--env-file=.env', serverEntry] : [serverEntry];
const child = spawn(nodeExecutable, nodeArgs, {
cwd: serverDirectory,
env: {
...process.env,
HOST: '127.0.0.1',
NODE_ENV: 'production',
PORT: String(port),
STANDALONE: 'true',
},
stdio: 'inherit',
});

child.on('error', error => {
console.error('Failed to start the TrueForge server', error);
});
child.on('exit', (code, signal) => {
if (quitting) {
return;
}
console.error(
signal === null
? `TrueForge server exited with code ${String(code)}`
: `TrueForge server exited after signal ${signal}`,
);
});

return child;
}

function stopServer() {
if (!ownsServerProcess || serverProcess === undefined || serverProcess.killed) {
return;
}
serverProcess.kill('SIGTERM');
}

function createWindow(port) {
const window = new BrowserWindow({
width: 1280,
height: 840,
minWidth: 900,
minHeight: 600,
title: 'TrueForge',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});

void window.loadURL(serverOrigin(port));
}

async function boot() {
const port = resolvePort();
if (await isHealthy(port)) {
console.log(`Using the TrueForge server already running at ${serverOrigin(port)}`);
} else {
serverProcess = startServer(port);
ownsServerProcess = true;
await waitForServer(port);
}
createWindow(port);
}

app.on('window-all-closed', () => {
app.quit();
});

app.on('before-quit', () => {
quitting = true;
stopServer();
});

function handleStartupError(error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
dialog.showErrorBox('TrueForge failed to start', message);
stopServer();
app.exit(1);
}

void app.whenReady().then(boot).catch(handleStartupError);
115 changes: 115 additions & 0 deletions desktop/pack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Turns the staged runtime in desktop/.stage into an unsigned DMG.
*
* electron-builder only builds the app bundle here: its resource copy drops
* `node_modules`, so the harness would ship without its dependencies, and its DMG
* target shells out to dmgbuild, which cannot retry the `hdiutil` image conversion
* that macOS fails intermittently with EAGAIN.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const stageDirectory = path.join(repoRoot, 'desktop/.stage');
const releaseDirectory = path.join(repoRoot, 'desktop/release');
const productName = 'TrueForge';

function run(options) {
const result = spawnSync(options.command, options.args, {
cwd: repoRoot,
env: options.env ?? process.env,
stdio: 'inherit',
});
if (result.error !== undefined) {
throw new Error(`Failed to run ${options.command}`, { cause: result.error });
}
return result.status;
}

function runOrThrow(options) {
const status = run(options);
if (status !== 0) {
throw new Error(`${options.command} exited with code ${String(status)}`);
}
}

/** macOS fails hdiutil image conversion intermittently, so one failure is not a verdict. */
function createDiskImage(options) {
const attemptLimit = 4;
for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
const status = run({
command: 'hdiutil',
args: [
'create',
'-volname',
productName,
'-srcfolder',
options.payloadDirectory,
'-fs',
'HFS+',
'-format',
'UDZO',
'-ov',
options.dmgPath,
],
});
if (status === 0) {
return;
}
if (attempt === attemptLimit) {
throw new Error(`hdiutil create failed ${String(attemptLimit)} times with exit code ${String(status)}`);
}
console.log(`hdiutil create failed (attempt ${String(attempt)}/${String(attemptLimit)}); retrying…`);
}
}

if (process.platform !== 'darwin') {
throw new Error('The desktop DMG can only be built on macOS.');
}

const stagedHarness = path.join(stageDirectory, 'harness');
const stagedNode = path.join(stageDirectory, 'node');
if (!existsSync(path.join(stagedHarness, 'dist/main.js')) || !existsSync(path.join(stagedNode, 'bin/node'))) {
throw new Error(`Missing staged runtime in ${stageDirectory}. Run \`pnpm desktop:stage\` first.`);
}

// Electron reads ELECTRON_RUN_AS_NODE when set by the surrounding terminal, which breaks packaging.
const builderEnv = { ...process.env };
delete builderEnv['ELECTRON_RUN_AS_NODE'];
runOrThrow({
command: 'electron-builder',
args: ['--config', 'desktop/electron-builder.yml', '--mac', 'dir'],
env: builderEnv,
});

const architecture = process.arch === 'arm64' ? 'arm64' : 'x64';
const appBundle = path.join(releaseDirectory, `mac-${architecture}`, `${productName}.app`);
if (!existsSync(appBundle)) {
throw new Error(`electron-builder did not write ${appBundle}`);
}

const bundleResources = path.join(appBundle, 'Contents/Resources');
// ditto keeps symlinks and extended attributes intact, which pnpm's node_modules layout needs.
runOrThrow({ command: 'ditto', args: [stagedHarness, path.join(bundleResources, 'harness')] });
runOrThrow({ command: 'ditto', args: [stagedNode, path.join(bundleResources, 'node')] });

const { version } = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const dmgPath = path.join(releaseDirectory, `${productName}-${version}-${architecture}.dmg`);
const payloadRoot = mkdtempSync(path.join(tmpdir(), 'trueforge-dmg-'));
const payloadDirectory = path.join(payloadRoot, 'payload');

try {
mkdirSync(payloadDirectory);
runOrThrow({ command: 'ditto', args: [appBundle, path.join(payloadDirectory, `${productName}.app`)] });
symlinkSync('/Applications', path.join(payloadDirectory, 'Applications'));

rmSync(dmgPath, { force: true });
createDiskImage({ payloadDirectory, dmgPath });
} finally {
rmSync(payloadRoot, { recursive: true, force: true });
}

console.log(`Built ${dmgPath}`);
Loading
Loading