feat(gui): make desktop setup flow real and testable

Add a GUI-first setup gate and runtime state API so desktop onboarding is safe, iterative, and works with isolated fresh-mode installs. Scaffold and wire the desktop shell/runtime pieces so this branch runs end-to-end without disturbing existing user installs.
This commit is contained in:
Brooklyn Nicholson
2026-04-25 19:48:02 -05:00
parent edc78e258c
commit 648da6a8d1
38 changed files with 14234 additions and 528 deletions

View File

@@ -0,0 +1,156 @@
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { dirname, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../../..");
const python = process.env.HERMES_PYTHON || "python";
let port = process.env.HERMES_GUI_PORT || "9120";
let url = `http://127.0.0.1:${port}`;
let dashboard = null;
function stop() {
if (dashboard && !dashboard.killed) dashboard.kill();
}
process.on("SIGINT", () => {
stop();
process.exit(130);
});
process.on("SIGTERM", () => {
stop();
process.exit(143);
});
process.on("exit", stop);
async function waitForHealth() {
for (let i = 0; i < 120; i += 1) {
if (await isHealthy()) return true;
await delay(500);
}
return false;
}
async function isHealthy() {
try {
const res = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000),
});
const data = await res.json();
return res.ok && data.status === "ok";
} catch {
return false;
}
}
function canBind(candidate) {
return new Promise((resolveBind) => {
const server = createServer();
server.once("error", () => resolveBind(false));
server.listen(Number(candidate), "127.0.0.1", () => {
server.close(() => resolveBind(true));
});
});
}
async function choosePort() {
if (process.env.HERMES_GUI_PORT) return;
let candidate = Number(port);
for (let i = 0; i < 20; i += 1) {
if (await canBind(candidate)) {
port = String(candidate);
url = `http://127.0.0.1:${port}`;
return;
}
candidate += 1;
}
}
function startDashboard() {
dashboard = spawn(
python,
[
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{
cwd: repoRoot,
env: {
...process.env,
HERMES_GUI: "1",
},
stdio: "inherit",
},
);
dashboard.on("exit", (code) => {
process.exit(code ?? 0);
});
}
function run(command, args) {
return (
spawnSync(command, args, {
shell: process.platform === "win32",
stdio: "ignore",
}).status === 0
);
}
function openGuiWindow() {
if (process.platform === "win32") {
return (
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", url])
);
}
if (process.env.WSL_DISTRO_NAME) {
return (
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", url])
);
}
if (process.platform === "darwin") {
return (
run("open", ["-na", "Google Chrome", "--args", `--app=${url}`]) ||
run("open", [url])
);
}
return (
run("google-chrome", [`--app=${url}`]) ||
run("chromium", [`--app=${url}`]) ||
run("xdg-open", [url])
);
}
if (await isHealthy()) {
console.log(`Hermes GUI already running -> ${url}`);
openGuiWindow();
process.exit(0);
}
await choosePort();
startDashboard();
if (await waitForHealth()) {
console.log(`Hermes GUI -> ${url}`);
openGuiWindow();
} else {
console.error(`Hermes GUI did not become healthy at ${url}`);
}

View File

@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../../..");
const python = process.env.HERMES_PYTHON || "python";
const port = process.env.HERMES_GUI_PORT || "9120";
const url = `http://127.0.0.1:${port}`;
async function isHealthy() {
try {
const res = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000),
});
const data = await res.json();
return res.ok && data.status === "ok";
} catch {
return false;
}
}
function wslRepoRoot() {
const normalized = repoRoot.replaceAll("\\", "/");
const parts = normalized.split("/");
const host = parts[2]?.toLowerCase();
if (process.platform !== "win32") return null;
if (host !== "wsl$" && host !== "wsl.localhost") return null;
const distro = parts[3];
const path = `/${parts.slice(4).join("/")}`;
return distro && path !== "/" ? { distro, path } : null;
}
function spawnDashboard() {
const wsl = wslRepoRoot();
if (wsl) {
return spawn(
"wsl.exe",
[
"-d",
wsl.distro,
"--cd",
wsl.path,
"env",
"HERMES_GUI=1",
process.env.HERMES_WSL_PYTHON || "python",
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{ stdio: "inherit" },
);
}
return spawn(
python,
[
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{
cwd: repoRoot,
env: {
...process.env,
HERMES_GUI: "1",
},
stdio: "inherit",
},
);
}
if (await isHealthy()) {
console.log(`Hermes GUI already running -> ${url}`);
process.exit(0);
}
const child = spawnDashboard();
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});

View File

@@ -0,0 +1,90 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const bin = process.platform === "win32" ? "tauri.cmd" : "tauri";
const localTauri = resolve(appRoot, "node_modules", ".bin", bin);
const args = process.argv.slice(2);
function isWsl() {
return process.platform === "linux" && !!process.env.WSL_DISTRO_NAME;
}
function quotePs(value) {
return `'${value.replaceAll("'", "''")}'`;
}
function dispatchToWindows() {
const pathResult = spawnSync("wslpath", ["-w", appRoot], {
encoding: "utf8",
});
const windowsPath = pathResult.stdout.trim();
if (!windowsPath) return false;
const command = [
"$ErrorActionPreference = 'Stop'",
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
"if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {",
' Write-Error "Windows npm was not found. Install Windows Node.js first: winget install OpenJS.NodeJS.LTS"',
"}",
"if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {",
' Write-Error "Windows Rust was not found. Install Rust first: winget install Rustlang.Rustup"',
"}",
`Set-Location -LiteralPath ${quotePs(windowsPath)}`,
"& npm run dev:tauri",
].join("; ");
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);
}
function run(command, commandArgs, { exit = true } = {}) {
if (process.platform === "win32") {
const psCommand = [
"$ErrorActionPreference = 'Stop'",
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
`Set-Location -LiteralPath ${quotePs(appRoot)}`,
`& ${quotePs(command)} ${commandArgs.map(quotePs).join(" ")}`,
].join("; ");
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psCommand],
{ stdio: "inherit" },
);
if (result.error && result.error.code === "ENOENT") return false;
if (exit) process.exit(result.status ?? 1);
return result.status === 0;
}
const result = spawnSync(command, commandArgs, {
cwd: appRoot,
env: process.env,
stdio: "inherit",
});
if (result.error && result.error.code === "ENOENT") return false;
if (exit) process.exit(result.status ?? 1);
return result.status === 0;
}
if (isWsl() && process.env.HERMES_GUI_TAURI_WSL !== "1") {
console.log("Launching native Windows Tauri from WSL...");
dispatchToWindows();
console.error(
"Could not hand off to Windows PowerShell. Run this from Windows PowerShell instead:",
);
console.error(" cd \\\\wsl$\\Ubuntu\\home\\bb\\hermes-agent\\apps\\gui");
console.error(" npm run dev:tauri");
process.exit(1);
}
if (existsSync(localTauri)) run(localTauri, args);
if (run("tauri", args, { exit: false })) process.exit(0);
if (run("cargo", ["tauri", ...args], { exit: false })) process.exit(0);
run("npx", ["--yes", "@tauri-apps/cli@latest", ...args]);