diff --git a/apps/desktop/after-pack.cjs b/apps/desktop/after-pack.cjs new file mode 100644 index 0000000..69e0143 --- /dev/null +++ b/apps/desktop/after-pack.cjs @@ -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." + ); + } +}; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9ca0d48..aa5f846 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -36,6 +36,7 @@ }, "files": [ "dist/**/*", + "preload.cjs", "package.json" ], "extraResources": [ @@ -63,8 +64,10 @@ "oneClick": true, "perMachine": false, "allowToChangeInstallationDirectory": false, - "deleteAppDataOnUninstall": false + "deleteAppDataOnUninstall": false, + "shortcutName": "GodMode" }, + "afterPack": "./after-pack.cjs", "mac": { "target": [ "dmg" diff --git a/apps/desktop/src/preload.ts b/apps/desktop/preload.cjs similarity index 77% rename from apps/desktop/src/preload.ts rename to apps/desktop/preload.cjs index ac5efda..93fdbb9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/preload.cjs @@ -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", { diff --git a/apps/desktop/resources/runtime/.gitkeep b/apps/desktop/resources/runtime/.gitkeep index ebe8c6a..8b13789 100644 --- a/apps/desktop/resources/runtime/.gitkeep +++ b/apps/desktop/resources/runtime/.gitkeep @@ -1,3 +1 @@ -# Staged by scripts/release/package-desktop.mjs — do not commit runtime contents. -* -!.gitkeep + diff --git a/apps/desktop/resources/update/.gitkeep b/apps/desktop/resources/update/.gitkeep index 5375402..8b13789 100644 --- a/apps/desktop/resources/update/.gitkeep +++ b/apps/desktop/resources/update/.gitkeep @@ -1,3 +1 @@ -# Staged by scripts/release/package-desktop.mjs — do not commit update scripts copies. -* -!.gitkeep + diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 832b144..f84b231 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { @@ -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 = ` +
+
GodMode
+
${message.replaceAll("<", "<")}
+
`; + 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 { 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(), @@ -81,26 +140,32 @@ async function boot(): Promise { 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; } @@ -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; @@ -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 } @@ -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(); }); diff --git a/apps/desktop/src/runtime.ts b/apps/desktop/src/runtime.ts index 2fa6e7f..6195498 100644 --- a/apps/desktop/src/runtime.ts +++ b/apps/desktop/src/runtime.ts @@ -1,6 +1,6 @@ import { createServer } from "node:net"; import { randomBytes } from "node:crypto"; -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; @@ -34,7 +34,6 @@ export function runtimeRoot(isPackaged: boolean, appPath: string): string { } const staged = path.join(appPath, "resources", "runtime"); if (fs.existsSync(path.join(staged, "bin", "host.mjs"))) return staged; - // Dev fallback: monorepo root when resources/runtime is not staged yet. return path.resolve(appPath, "..", ".."); } @@ -49,7 +48,64 @@ export function nodeBinary(runtime: string): string { const name = process.platform === "win32" ? "node.exe" : "node"; const bundled = path.join(runtime, "bin", name); if (fs.existsSync(bundled)) return bundled; - return process.execPath; + throw new Error( + `Bundled Node runtime missing at ${bundled}. Reinstall GodMode.` + ); +} + +export function ensureRuntimeDependencies(runtime: string): void { + const modules = path.join(runtime, "node_modules"); + const staged = path.join(runtime, "_node_modules"); + if (!fs.existsSync(modules) && fs.existsSync(staged)) { + try { + fs.renameSync(staged, modules); + } catch (error) { + // Windows Defender / indexer sometimes blocks rename; junction is enough. + const linked = spawnSync( + "cmd.exe", + ["/c", `mklink /J "${modules}" "${staged}"`], + { encoding: "utf8" } + ); + if (linked.status !== 0 || !fs.existsSync(modules)) { + throw new Error( + `Unable to expose runtime dependencies at ${modules}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + const cors = path.join(modules, "cors"); + if (!fs.existsSync(cors)) { + throw new Error( + `Desktop runtime is missing dependencies (cors). ` + + `Expected packages under ${modules}. Reinstall from a newer GitHub release.` + ); + } +} + +export function openLogFile(userData: string): string { + const dir = path.join(userData, "logs"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "desktop.log"); + fs.writeFileSync( + file, + `\n---- GodMode desktop ${new Date().toISOString()} ----\n`, + { flag: "a" } + ); + return file; +} + +export function appendLog(logFile: string | null, message: string): void { + if (!logFile) { + console.error(message); + return; + } + try { + fs.appendFileSync(logFile, `${message}\n`); + } catch { + console.error(message); + } } export async function waitForUrl( @@ -63,9 +119,11 @@ export async function waitForUrl( const response = await fetch(url, { signal: AbortSignal.timeout(2_000), }); - if (response.ok || response.status === 401 || response.status === 404) { + // Require a real success — 404 means static assets are missing. + if (response.ok || response.status === 401) { return; } + lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } @@ -78,37 +136,68 @@ export async function waitForUrl( ); } +function spawnLogged( + command: string, + args: string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + logFile: string | null; + label: string; + } +): ChildProcess { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const forward = (chunk: Buffer, stream: "stdout" | "stderr") => { + const text = chunk.toString("utf8").trimEnd(); + if (!text) return; + for (const line of text.split(/\r?\n/)) { + appendLog(options.logFile, `[${options.label}:${stream}] ${line}`); + } + }; + child.stdout?.on("data", (chunk: Buffer) => forward(chunk, "stdout")); + child.stderr?.on("data", (chunk: Buffer) => forward(chunk, "stderr")); + return child; +} + export function spawnHost(options: { runtime: string; env: NodeJS.ProcessEnv; + logFile: string | null; }): ChildProcess { const node = nodeBinary(options.runtime); const hostScript = path.join(options.runtime, "bin", "host.mjs"); if (!fs.existsSync(hostScript)) { throw new Error(`Missing host runtime at ${hostScript}`); } - return spawn(node, [hostScript], { + return spawnLogged(node, [hostScript], { cwd: options.runtime, env: options.env, - stdio: "inherit", - windowsHide: true, + logFile: options.logFile, + label: "host", }); } export function spawnSupervisor(options: { updateRoot: string; env: NodeJS.ProcessEnv; + logFile: string | null; }): ChildProcess { - const node = options.env.GODMODE_NODE_BIN || process.execPath; + const node = options.env.GODMODE_NODE_BIN; + if (!node) throw new Error("GODMODE_NODE_BIN is required for the supervisor"); const script = path.join(options.updateRoot, "supervisor.mjs"); if (!fs.existsSync(script)) { throw new Error(`Missing update supervisor at ${script}`); } - return spawn(node, [script], { + return spawnLogged(node, [script], { cwd: options.updateRoot, env: options.env, - stdio: "inherit", - windowsHide: true, + logFile: options.logFile, + label: "supervisor", }); } diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 9cde420..3c3dc5a 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -104,9 +104,13 @@ Non-technical users should install the signed desktop app from GitHub Releases: | Bare-metal Windows | `godmode-windows-bare-metal-.zip` | The Electron shell boots the same Bridge + web runtime as bare-metal, binds only -on loopback, stores SQLite under the OS app data directory, and starts a local -update supervisor so **Admin → Updates** can download, Sigstore-verify, and -apply the matching `installer` artifact. +on loopback, stores SQLite under the OS app data directory +(`%APPDATA%\\GodMode\\data` on Windows), and starts a local update supervisor so +**Admin → Updates** can download, Sigstore-verify, and apply the matching +`installer` artifact. + +If the desktop app fails to start, check `%APPDATA%\\GodMode\\logs\\desktop.log` +(macOS: `~/Library/Application Support/GodMode/logs/desktop.log`). Set these GitHub Actions secrets for **stable** signed desktop builds: diff --git a/scripts/release/__tests__/contract.test.mjs b/scripts/release/__tests__/contract.test.mjs index 8487b2d..96c54f7 100644 --- a/scripts/release/__tests__/contract.test.mjs +++ b/scripts/release/__tests__/contract.test.mjs @@ -161,14 +161,26 @@ test("bare-metal updater selects signed bundle artifacts", async () => { assert.match(updater, /artifact\.sha256/); }); -test("desktop updater selects signed installer artifacts", async () => { - const updater = await readFile("scripts/update/desktop-update.mjs", "utf8"); - assert.match(updater, /kind === "installer"/); - assert.match(updater, /AppImage/); - assert.match(updater, /NSIS|\/S/); - assert.match(updater, /\$\{manifestUrl\}\.bundle/); - assert.doesNotMatch(updater, /\$\{artifactUrl\}\.bundle/); - assert.match(updater, /artifact\.sha256/); +test("desktop packaging dodges gitignore stripping of node_modules", async () => { + const packager = await readFile("scripts/release/package-desktop.mjs", "utf8"); + const afterPack = await readFile("apps/desktop/after-pack.cjs", "utf8"); + const desktopPkg = await readFile("apps/desktop/package.json", "utf8"); + assert.match(packager, /_node_modules/); + assert.match(packager, /extraMetadata\.name=GodMode/); + assert.match(afterPack, /_node_modules/); + assert.match(afterPack, /cors/); + assert.match(desktopPkg, /after-pack\.cjs/); +}); + +test("desktop shell surfaces boot failures instead of quitting silently", async () => { + const main = await readFile("apps/desktop/src/main.ts", "utf8"); + const runtime = await readFile("apps/desktop/src/runtime.ts", "utf8"); + assert.match(main, /showSplash|Preparing GodMode/); + assert.match(main, /failStartup/); + assert.match(main, /desktop\.log|openLogFile/); + assert.match(runtime, /ensureRuntimeDependencies/); + assert.match(runtime, /stdio: \["ignore", "pipe", "pipe"\]/); + assert.doesNotMatch(runtime, /return process\.execPath/); }); test("supervisor routes electron surface to desktop-update", async () => { diff --git a/scripts/release/package-desktop.mjs b/scripts/release/package-desktop.mjs index 8148cac..5da7da9 100644 --- a/scripts/release/package-desktop.mjs +++ b/scripts/release/package-desktop.mjs @@ -1,4 +1,4 @@ -import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { cp, mkdir, readdir, rename, rm, access } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { @@ -7,6 +7,15 @@ import { } from "./artifact-names.mjs"; import { hostPlatformLabel, stageRuntime } from "./stage-runtime.mjs"; +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + const outputDirectory = process.argv[2] ?? "release-out"; const version = process.env.RELEASE_VERSION; const commit = process.env.RELEASE_COMMIT; @@ -22,7 +31,13 @@ const root = process.cwd(); const desktopRoot = path.join(root, "apps", "desktop"); const runtimeDest = path.join(desktopRoot, "resources", "runtime"); const updateDest = path.join(desktopRoot, "resources", "update"); -const stage = path.join(desktopRoot, ".stage-runtime"); +// Prefer a fresh temp stage so leftover locked files under apps/desktop/.stage-runtime +// (Windows AV / prior electron copies) cannot block packaging. +const stage = path.join( + root, + "release-out", + `.desktop-stage-${process.pid}-${Date.now()}` +); await stageRuntime({ platform, @@ -34,10 +49,20 @@ await stageRuntime({ }); await rm(runtimeDest, { recursive: true, force: true }); -await mkdir(runtimeDest, { recursive: true }); +await mkdir(path.dirname(runtimeDest), { recursive: true }); +// Copy stage directory onto runtimeDest path (replace), not into an existing folder. await cp(stage, runtimeDest, { recursive: true }); await rm(stage, { recursive: true, force: true }); +// electron-builder honors .gitignore and would omit node_modules from extraResources. +// Stage deps under _node_modules; after-pack.cjs renames them back inside the app. +const runtimeNodeModules = path.join(runtimeDest, "node_modules"); +const runtimeBundledModules = path.join(runtimeDest, "_node_modules"); +if (await exists(runtimeNodeModules)) { + await rm(runtimeBundledModules, { recursive: true, force: true }); + await rename(runtimeNodeModules, runtimeBundledModules); +} + await rm(updateDest, { recursive: true, force: true }); await mkdir(updateDest, { recursive: true }); for (const name of ["supervisor.mjs", "desktop-update.mjs", "bare-metal-update.mjs"]) { @@ -45,13 +70,16 @@ for (const name of ["supervisor.mjs", "desktop-update.mjs", "bare-metal-update.m } const electronVersion = version.replace(/^v/, ""); +const packDirOnly = process.env.DESKTOP_PACK_DIR === "1"; const builderArgs = [ "run", - "dist", + packDirOnly ? "pack" : "dist", "-w", "@godmode/desktop", "--", `-c.extraMetadata.version=${electronVersion}`, + // Avoid NSIS install dir "@godmodedesktop" from the scoped package name. + "-c.extraMetadata.name=GodMode", "--publish", "never", ]; diff --git a/scripts/release/stage-runtime.mjs b/scripts/release/stage-runtime.mjs index ef1c101..72124a5 100644 --- a/scripts/release/stage-runtime.mjs +++ b/scripts/release/stage-runtime.mjs @@ -41,13 +41,38 @@ export async function stageRuntime({ const copy = (source, destination) => cp(path.join(root, source), path.join(stageDir, destination), { recursive: true, + // Keep links/junctions as-is for speed; @godmode workspaces are replaced below. verbatimSymlinks: true, }); + const skipPackagingTooling = !includeServices; + const nodeModulesSource = path.join(root, "node_modules"); + const nodeModulesDest = path.join(stageDir, "node_modules"); + await mkdir(nodeModulesDest, { recursive: true }); + const nmEntries = await import("node:fs/promises").then(({ readdir }) => + readdir(nodeModulesSource) + ); + const skipNames = new Set([ + "electron", + "electron-builder", + "electron-publish", + "electron-winstaller", + "app-builder-bin", + "app-builder-lib", + "@electron", + ]); + await Promise.all([ copy("package.json", "package.json"), copy("package-lock.json", "package-lock.json"), - copy("node_modules", "node_modules"), + ...nmEntries + .filter((entry) => !(skipPackagingTooling && (skipNames.has(entry) || /^electron/i.test(entry)))) + .map((entry) => + cp(path.join(nodeModulesSource, entry), path.join(nodeModulesDest, entry), { + recursive: true, + verbatimSymlinks: true, + }) + ), copy("apps/bridge/package.json", "apps/bridge/package.json"), copy("apps/bridge/dist", "apps/bridge/dist"), copy("apps/web/dist", "apps/web/dist"),