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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@executor-js/plugin-onepassword": "workspace:*",
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@types/node": "catalog:",
Expand Down
43 changes: 39 additions & 4 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,18 @@ import {
SidecarPortInUseError,
type SidecarConnection,
} from "./sidecar";
import { getServerSettings, regeneratePassword, updateServerSettings } from "./settings";
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
import {
getServerProfiles,
getServerSettings,
regeneratePassword,
setServerProfiles,
updateServerSettings,
} from "./settings";
import {
SERVER_SETTINGS_USERNAME,
type DesktopServerConnection,
type DesktopServerSettings,
} from "../shared/server-settings";

// Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
// Electron-side consumer (electron-store, electron-log, window-state) lands
Expand Down Expand Up @@ -215,7 +225,7 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> =>
}
};

const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: string }> => {
const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {
if (connection) {
await stopSidecar(connection.child);
connection = null;
Expand All @@ -228,10 +238,30 @@ const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: strin
connection = next;
installBasicAuthHeader(next.baseUrl, next.authPassword);
if (mainWindow) await mainWindow.loadURL(next.baseUrl);
return { port: next.port, baseUrl: next.baseUrl };
return toDesktopServerConnection(next);
};

const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnection => ({
kind: "desktop-sidecar",
key: "desktop-sidecar",
origin: conn.baseUrl,
apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,
displayName: "Desktop sidecar",
...(conn.authPassword
? {
auth: {
kind: "basic" as const,
username: SERVER_SETTINGS_USERNAME,
password: conn.authPassword,
},
}
: {}),
});

const registerIpcHandlers = () => {
ipcMain.handle("executor:server:connection", (): DesktopServerConnection | null =>
connection ? toDesktopServerConnection(connection) : null,
);
ipcMain.handle("executor:settings:get", (): DesktopServerSettings => getServerSettings());
ipcMain.handle(
"executor:settings:update",
Expand All @@ -242,6 +272,11 @@ const registerIpcHandlers = () => {
"executor:settings:regenerate-password",
(): DesktopServerSettings => regeneratePassword(),
);
ipcMain.handle("executor:server-profiles:get", (): string | null => getServerProfiles());
ipcMain.handle("executor:server-profiles:set", (_evt, value: unknown): void => {
if (typeof value !== "string") return;
setServerProfiles(value);
});
ipcMain.handle("executor:server:restart", () => restartSidecarAndReload());
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
if (typeof rawUrl !== "string") return;
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/s

interface PersistedShape {
readonly server: DesktopServerSettings;
readonly serverProfiles?: string;
}

const generatePassword = (): string => randomBytes(24).toString("base64url");
Expand Down Expand Up @@ -43,3 +44,9 @@ export const regeneratePassword = (): DesktopServerSettings => {
store.set("server", next);
return next;
};

export const getServerProfiles = (): string | null => store.get("serverProfiles") ?? null;

export const setServerProfiles = (value: string): void => {
store.set("serverProfiles", value);
};
229 changes: 208 additions & 21 deletions apps/desktop/src/main/sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@
*/

import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve, join } from "node:path";
import { app } from "electron";
import { Option, Schema } from "effect";
import {
normalizeExecutorServerConnection,
parseExecutorLocalServerManifest,
serializeExecutorLocalServerManifest,
} from "@executor-js/sdk/shared";
import { getServerSettings } from "./settings";
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";

Expand All @@ -41,6 +47,132 @@ interface StartOptions {
readonly hostname?: string;
}

const sidecarManifestPathByPid = new Map<number, string>();

const serverControlDir = (dataDir: string): string => join(dataDir, "server-control");
const localServerManifestPath = (dataDir: string): string =>
join(serverControlDir(dataDir), "server.json");
const localServerStartLockPath = (dataDir: string): string =>
join(serverControlDir(dataDir), "startup.lock");

const LocalServerStartLockFile = Schema.Struct({
pid: Schema.Number,
startedAt: Schema.String,
});
const decodeUnknownJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
const decodeLocalServerStartLockFile = Schema.decodeUnknownOption(LocalServerStartLockFile);

const isPidAlive = (pid: number): boolean => {
if (!Number.isInteger(pid) || pid <= 0) return false;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Node process probing API reports liveness by throwing
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};

const readManifest = (dataDir: string) => {
const path = localServerManifestPath(dataDir);
if (!existsSync(path)) return null;
return parseExecutorLocalServerManifest(readFileSync(path, "utf8"));
};

const removeManifestIfOwnedBy = (dataDir: string, pid: number) => {
const manifest = readManifest(dataDir);
if (manifest?.pid !== pid) return;
rmSync(localServerManifestPath(dataDir), { force: true });
};

const assertNoOtherLocalServerOwner = (dataDir: string) => {
const manifest = readManifest(dataDir);
if (!manifest) return;
if (!isPidAlive(manifest.pid)) {
removeManifestIfOwnedBy(dataDir, manifest.pid);
return;
}
// oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process
throw new Error(
[
`A local Executor ${manifest.kind} is already running at ${manifest.connection.origin} (pid ${manifest.pid}).`,
`It owns the current data directory: ${manifest.dataDir}`,
"Stop it before starting the desktop sidecar.",
].join("\n"),
);
};

const readLockPid = (dataDir: string): number | null => {
const path = localServerStartLockPath(dataDir);
if (!existsSync(path)) return null;
const json = decodeUnknownJsonOption(readFileSync(path, "utf8"));
if (Option.isNone(json)) return null;
const decoded = decodeLocalServerStartLockFile(json.value);
return Option.isSome(decoded) ? decoded.value.pid : null;
};

const acquireLocalServerStartLock = (dataDir: string): (() => void) => {
mkdirSync(serverControlDir(dataDir), { recursive: true });
const lockPath = localServerStartLockPath(dataDir);
const payload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: lock acquisition uses atomic Node fs flags and maps contention to startup failure
try {
writeFileSync(lockPath, payload, { flag: "wx" });
} catch {
const existingPid = readLockPid(dataDir);
if (existingPid !== null && !isPidAlive(existingPid)) {
rmSync(lockPath, { force: true });
writeFileSync(lockPath, payload, { flag: "wx" });
} else {
// oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process
throw new Error("Another local Executor server startup is already in progress.");
}
}
return () => rmSync(lockPath, { force: true });
};

const writeSidecarManifest = (input: {
readonly dataDir: string;
readonly scopeDir: string;
readonly baseUrl: string;
readonly authPassword: string | null;
readonly childPid: number;
}) => {
const connection = normalizeExecutorServerConnection({
kind: "desktop-sidecar",
key: "desktop-sidecar",
origin: input.baseUrl,
displayName: "Desktop sidecar",
...(input.authPassword
? {
auth: {
kind: "basic" as const,
username: SERVER_SETTINGS_USERNAME,
password: input.authPassword,
},
}
: {}),
});
writeFileSync(
localServerManifestPath(input.dataDir),
serializeExecutorLocalServerManifest({
version: 1,
kind: "desktop-sidecar",
pid: input.childPid,
startedAt: new Date().toISOString(),
dataDir: input.dataDir,
scopeDir: input.scopeDir,
connection,
owner: {
client: "desktop",
version: app.getVersion() || null,
executablePath: process.execPath || null,
},
}),
);
sidecarManifestPathByPid.set(input.childPid, input.dataDir);
};

const resolveSidecarCommand = (): { command: string; args: string[]; cwd: string } => {
if (app.isPackaged) {
const binaryName = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar";
Expand Down Expand Up @@ -69,6 +201,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC

if (!existsSync(clientDir)) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: misconfiguration is fatal
// oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process
throw new Error(
`Executor client bundle not found at ${clientDir}. Run \`bun run --filter @executor-js/local build\` before launching desktop.`,
);
Expand All @@ -83,27 +216,52 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
// electron-log, and window-state — those stay app-scoped to avoid colliding
// with anything else under HOME.
const scopeDir = join(homedir(), ".executor");
mkdirSync(scopeDir, { recursive: true });
const dataDir = scopeDir;
mkdirSync(dataDir, { recursive: true });

const effectivePassword = settings.requireAuth ? settings.password : null;
const releaseStartupLock = acquireLocalServerStartLock(dataDir);
let startupLockReleased = false;
const releaseLock = () => {
if (startupLockReleased) return;
startupLockReleased = true;
releaseStartupLock();
};

const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
EXECUTOR_PORT: String(settings.port),
EXECUTOR_HOST: hostname,
// Only export the password env var when auth is enabled — the sidecar
// treats an empty password as "no auth required". Matches the CLI's
// `executor web` default.
...(effectivePassword ? { EXECUTOR_AUTH_PASSWORD: effectivePassword } : {}),
EXECUTOR_CLIENT_DIR: clientDir,
EXECUTOR_SCOPE_DIR: scopeDir,
EXECUTOR_DATA_DIR: scopeDir,
EXECUTOR_CLIENT: "desktop",
},
});
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures
try {
assertNoOtherLocalServerOwner(dataDir);
} catch (error) {
releaseLock();
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock
throw error;
}

let child: ChildProcess;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: spawn can throw synchronously and the local startup lock must be released
try {
child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
EXECUTOR_PORT: String(settings.port),
EXECUTOR_HOST: hostname,
// Only export the password env var when auth is enabled — the sidecar
// treats an empty password as "no auth required". Matches the CLI's
// `executor web` default.
...(effectivePassword ? { EXECUTOR_AUTH_PASSWORD: effectivePassword } : {}),
EXECUTOR_CLIENT_DIR: clientDir,
EXECUTOR_SCOPE_DIR: scopeDir,
EXECUTOR_DATA_DIR: dataDir,
EXECUTOR_CLIENT: "desktop",
},
});
} catch (error) {
releaseLock();
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve spawn failure after releasing local startup lock
throw error;
}

return new Promise<SidecarConnection>((resolveStart, rejectStart) => {
let stderrBuffer = "";
Expand All @@ -113,6 +271,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
const reject = (err: Error) => {
if (resolved || rejected) return;
rejected = true;
releaseLock();
// oxlint-disable-next-line executor/no-promise-reject -- boundary: sidecar startup surfaces as a rejected promise
rejectStart(err);
};
Expand All @@ -122,10 +281,26 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
process.stdout.write(`[executor-sidecar] ${text}`);
const match = text.match(/EXECUTOR_READY:(\d+)/);
if (match && !resolved) {
if (!child.pid) {
reject(
// oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar startup failure surfaces here as a rejected start promise
new Error("Sidecar became ready before Electron reported a child pid."),
);
return;
}
resolved = true;
const port = parseInt(match[1], 10);
const baseUrl = `http://${hostname}:${port}`;
writeSidecarManifest({
dataDir,
scopeDir,
baseUrl,
authPassword: effectivePassword,
childPid: child.pid,
});
releaseLock();
resolveStart({
baseUrl: `http://${hostname}:${port}`,
baseUrl,
hostname,
port,
username: SERVER_SETTINGS_USERNAME,
Expand Down Expand Up @@ -161,14 +336,26 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
}

export async function stopSidecar(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.killed) return;
const cleanupManifest = () => {
if (!child.pid) return;
const dataDir = sidecarManifestPathByPid.get(child.pid);
if (!dataDir) return;
removeManifestIfOwnedBy(dataDir, child.pid);
sidecarManifestPathByPid.delete(child.pid);
};
if (child.exitCode !== null || child.killed) {
cleanupManifest();
return;
}
return new Promise<void>((resolveStop) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
cleanupManifest();
resolveStop();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
cleanupManifest();
resolveStop();
});
child.kill("SIGTERM");
Expand Down
Loading
Loading