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
32 changes: 32 additions & 0 deletions apps/desktop/after-pack.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* electron-builder applies the repo .gitignore to extraResources, which would
* drop `node_modules/`. We stage deps as `_node_modules` and rename here after
* the app directory is assembled (before NSIS/DMG/AppImage wrapping).
*/
const fs = require("node:fs");
const path = require("node:path");

exports.default = async function afterPack(context) {
const runtime = path.join(context.appOutDir, "resources", "runtime");
const staged = path.join(runtime, "_node_modules");
const target = path.join(runtime, "node_modules");

if (fs.existsSync(staged)) {
if (fs.existsSync(target)) {
fs.rmSync(target, { recursive: true, force: true });
}
fs.renameSync(staged, target);
}

const cors = path.join(target, "cors");
const bridge = path.join(runtime, "apps", "bridge", "dist", "index.js");
if (!fs.existsSync(bridge)) {
throw new Error(`Desktop afterPack: missing Bridge at ${bridge}`);
}
if (!fs.existsSync(cors)) {
throw new Error(
`Desktop afterPack: runtime node_modules incomplete (missing cors at ${cors}). ` +
"electron-builder likely stripped dependencies."
);
}
};
5 changes: 4 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"files": [
"dist/**/*",
"preload.cjs",
"package.json"
],
"extraResources": [
Expand Down Expand Up @@ -63,8 +64,10 @@
"oneClick": true,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"deleteAppDataOnUninstall": false
"deleteAppDataOnUninstall": false,
"shortcutName": "GodMode"
},
"afterPack": "./after-pack.cjs",
"mac": {
"target": [
"dmg"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload.ts → apps/desktop/preload.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { contextBridge } from "electron";
const { contextBridge } = require("electron");

/** Intentional empty preload — UI talks to Bridge over localhost HTTP only. */
contextBridge.exposeInMainWorld("godmodeDesktop", {
Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/resources/runtime/.gitkeep
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
# Staged by scripts/release/package-desktop.mjs — do not commit runtime contents.
*
!.gitkeep

4 changes: 1 addition & 3 deletions apps/desktop/resources/update/.gitkeep
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
# Staged by scripts/release/package-desktop.mjs — do not commit update scripts copies.
*
!.gitkeep

157 changes: 128 additions & 29 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import {
app,
BrowserWindow,
dialog,
} from "electron";
import { app, BrowserWindow, dialog } from "electron";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import type { ChildProcess } from "node:child_process";
import {
appendLog,
ensureRuntimeDependencies,
freePort,
nodeBinary,
openLogFile,
runtimeRoot,
spawnHost,
spawnSupervisor,
supervisorToken,
updateScriptsRoot,
waitForUrl,
nodeBinary,
} from "./runtime.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");

let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;
let hostProcess: ChildProcess | null = null;
let supervisorProcess: ChildProcess | null = null;
let shuttingDown = false;
let logFile: string | null = null;
let bootFailed = false;
let booting = true;

function readReleaseMeta(runtime: string): { version?: string; commit?: string } {
try {
Expand All @@ -36,9 +39,65 @@ function readReleaseMeta(runtime: string): { version?: string; commit?: string }
}
}

function showSplash(message: string): void {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.webContents
.executeJavaScript(
`document.getElementById("msg").textContent = ${JSON.stringify(message)}`
)
.catch(() => undefined);
return;
}
splashWindow = new BrowserWindow({
width: 420,
height: 180,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
autoHideMenuBar: true,
title: "GodMode",
show: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
const html = `<!doctype html><html><body style="margin:0;font:15px/1.4 system-ui,Segoe UI,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh">
<div style="text-align:center;padding:24px">
<div style="font-size:20px;font-weight:600;margin-bottom:12px">GodMode</div>
<div id="msg">${message.replaceAll("<", "&lt;")}</div>
</div></body></html>`;
void splashWindow.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent(html)}`
);
splashWindow.on("closed", () => {
splashWindow = null;
});
}

function closeSplash(): void {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close();
}
splashWindow = null;
}

function failStartup(title: string, detail: string): void {
bootFailed = true;
appendLog(logFile, `${title}: ${detail}`);
const logHint = logFile ? `\n\nDetails were written to:\n${logFile}` : "";
dialog.showErrorBox(title, `${detail}${logHint}`);
stopChildren();
closeSplash();
app.quit();
}

async function boot(): Promise<string> {
const runtime = runtimeRoot(app.isPackaged, appRoot);
const updateRoot = updateScriptsRoot(app.isPackaged, appRoot);
ensureRuntimeDependencies(runtime);
const release = readReleaseMeta(runtime);
const [publicPort, bridgePort, supervisorPort] = await Promise.all([
freePort(),
Expand Down Expand Up @@ -81,26 +140,32 @@ async function boot(): Promise<string> {
if (release.version) env.GODMODE_VERSION = String(release.version);
if (release.commit) env.GODMODE_COMMIT = String(release.commit);

hostProcess = spawnHost({ runtime, env });
appendLog(
logFile,
`Starting host runtime=${runtime} public=${publicUrl} data=${dataDir}`
);
showSplash("Starting local services…");

hostProcess = spawnHost({ runtime, env, logFile });
hostProcess.on("exit", (code, signal) => {
if (shuttingDown) return;
console.error(`GodMode host exited (code=${code} signal=${signal})`);
app.quit();
if (shuttingDown || bootFailed) return;
failStartup(
"GodMode failed to start",
`The local host process exited unexpectedly (code=${code ?? "?"} signal=${signal ?? "none"}).`
);
});

supervisorProcess = spawnSupervisor({ updateRoot, env });
supervisorProcess = spawnSupervisor({ updateRoot, env, logFile });
supervisorProcess.on("exit", (code, signal) => {
if (shuttingDown) return;
console.warn(
appendLog(
logFile,
`Update supervisor exited (code=${code} signal=${signal}); one-click apply unavailable`
);
});

await waitForUrl(`${publicUrl}/api/health`).catch(async () => {
// health may be unauthenticated or named differently — fall back to root
await waitForUrl(publicUrl);
});

showSplash("Waiting for GodMode to become ready…");
await waitForUrl(`${publicUrl}/api/health`);
return publicUrl;
}

Expand All @@ -113,13 +178,26 @@ function createWindow(url: string): void {
show: false,
title: "GodMode",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
preload: path.join(appRoot, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
mainWindow.once("ready-to-show", () => mainWindow?.show());
const showMain = () => {
booting = false;
closeSplash();
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show();
};
mainWindow.once("ready-to-show", showMain);
// Fallback if ready-to-show never fires (some Windows GPU drivers).
setTimeout(showMain, 8_000);
mainWindow.webContents.on("did-fail-load", (_e, code, desc, validatedURL) => {
failStartup(
"GodMode failed to load",
`Could not load ${validatedURL} (code ${code}): ${desc}`
);
});
void mainWindow.loadURL(url);
mainWindow.on("closed", () => {
mainWindow = null;
Expand All @@ -131,11 +209,7 @@ function stopChildren(): void {
for (const child of [supervisorProcess, hostProcess]) {
if (!child || child.killed) continue;
try {
if (process.platform === "win32") {
child.kill();
} else {
child.kill("SIGTERM");
}
child.kill();
} catch {
// ignore
}
Expand All @@ -152,25 +226,50 @@ if (!gotLock) {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
return;
}
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.focus();
}
});

app.whenReady().then(async () => {
logFile = openLogFile(app.getPath("userData"));
process.on("uncaughtException", (error) => {
failStartup(
"GodMode crashed",
error instanceof Error ? error.stack ?? error.message : String(error)
);
});
process.on("unhandledRejection", (reason) => {
failStartup(
"GodMode crashed",
reason instanceof Error ? reason.stack ?? reason.message : String(reason)
);
});

try {
const devUrl = process.env.GODMODE_DEV_URL?.trim();
const url = devUrl || (await boot());
if (devUrl) {
createWindow(devUrl);
return;
}
showSplash("Preparing GodMode…");
const url = await boot();
if (bootFailed) return;
createWindow(url);
} catch (error) {
dialog.showErrorBox(
if (bootFailed) return;
failStartup(
"GodMode failed to start",
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.stack ?? error.message : String(error)
);
stopChildren();
app.quit();
}
});

app.on("window-all-closed", () => {
// Splash closes before the main window is shown; do not quit mid-boot.
if (booting || bootFailed) return;
stopChildren();
app.quit();
});
Expand Down
Loading
Loading