Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion apps/desktop/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,7 @@ async function runLaunchdColdStart(): Promise<void> {
buildSource:
process.env.NEXU_DESKTOP_BUILD_SOURCE ??
(app.isPackaged ? "packaged" : "local-dev"),
runtimeIdentityPath: app.isPackaged ? process.resourcesPath : undefined,
});

// Wire launchd-managed units into the orchestrator so the control plane
Expand Down Expand Up @@ -1031,6 +1032,7 @@ function focusMainWindow(): void {
mainWindow.restore();
}

app.focus({ steal: true });
mainWindow.focus();
}

Expand Down Expand Up @@ -1860,7 +1862,7 @@ app.whenReady().then(async () => {
return;
}

focusMainWindow();
showMainWindowFromResidentEntry();
});

app.once("before-quit", () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/main/lifecycle/launchd-recovery-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface LaunchdRecoveryEnvIdentity {
openclawStateDir?: string;
userDataPath?: string;
buildSource?: string;
runtimeIdentityPath?: string;
}

export interface LaunchdRecoveredPorts {
Expand Down Expand Up @@ -94,6 +95,7 @@ export function decideLaunchdRecovery(args: {
[recovered.openclawStateDir, env.openclawStateDir],
[recovered.userDataPath, env.userDataPath],
[recovered.buildSource, env.buildSource],
[recovered.runtimeIdentityPath, env.runtimeIdentityPath],
] as const
).some(
([recoveredVal, envVal]) =>
Expand All @@ -105,7 +107,7 @@ export function decideLaunchdRecovery(args: {
action: "teardown-stale-services",
reason: versionMismatch
? `App version changed (${recovered.appVersion} -> ${env.appVersion})`
: "Build identity mismatch (openclawStateDir, userDataPath, or buildSource differ)",
: "Build identity mismatch (openclawStateDir, userDataPath, buildSource, or runtimeIdentityPath differ)",
deleteSession: true,
};
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/main/lifecycle/launchd-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface LaunchdRuntimeSessionMetadata {
openclawStateDir?: string;
userDataPath?: string;
buildSource?: string;
runtimeIdentityPath?: string;
}

export function getLaunchdRuntimeSessionPath(plistDir: string): string {
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/main/services/launchd-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface LaunchdBootstrapEnv {
userDataPath?: string;
/** Build source identifier (e.g. "stable", "beta") — persisted for cross-build attach validation */
buildSource?: string;
/** Packaged runtime identity path (usually process.resourcesPath) for test bundle isolation */
runtimeIdentityPath?: string;

// --- Controller env vars (must match manifests.ts) ---
/** Web UI URL for CORS/redirects */
Expand Down Expand Up @@ -147,6 +149,8 @@ interface RuntimePortsMetadata {
userDataPath?: string;
/** Build source identifier (e.g. "stable", "beta", "dev") — used to prevent cross-attach. */
buildSource?: string;
/** Runtime identity path (usually process.resourcesPath) — used to prevent same-version test bundles cross-attaching. */
runtimeIdentityPath?: string;
}

/**
Expand Down Expand Up @@ -757,6 +761,11 @@ export async function bootstrapWithLaunchd(
],
["userDataPath", recovered.userDataPath, env.userDataPath],
["buildSource", recovered.buildSource, env.buildSource],
[
"runtimeIdentityPath",
recovered.runtimeIdentityPath,
env.runtimeIdentityPath,
Comment thread
anthhub marked this conversation as resolved.
],
] as const
).some(
([, recoveredVal, envVal]) =>
Expand All @@ -766,7 +775,7 @@ export async function bootstrapWithLaunchd(
if (versionMismatch || identityMismatch) {
const reason = versionMismatch
? `App version changed (${recovered.appVersion} → ${env.appVersion})`
: "Build identity mismatch (openclawStateDir, userDataPath, or buildSource differ)";
: "Build identity mismatch (openclawStateDir, userDataPath, buildSource, or runtimeIdentityPath differ)";
console.log(
`[bootstrap] teardown: ${reason} (controller=${controllerRunning ? "running" : "stopped"} openclaw=${openclawRunning ? "running" : "stopped"})`,
);
Expand Down Expand Up @@ -1206,6 +1215,7 @@ export async function bootstrapWithLaunchd(
openclawStateDir: env.openclawStateDir,
userDataPath: env.userDataPath,
buildSource: env.buildSource,
runtimeIdentityPath: env.runtimeIdentityPath,
});

return {
Expand Down
20 changes: 20 additions & 0 deletions tests/desktop/dev-toolchain-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,26 @@ describe("Shutdown safety", () => {
expect(secondInstanceBlock).toContain("focusMainWindow()");
});

it("index.ts restores the main window on activate", () => {
const indexTs = readFile("apps/desktop/main/index.ts");
const activateStart = indexTs.indexOf('app.on("activate"');
const activateBlock = indexTs.slice(activateStart, activateStart + 240);

expect(activateBlock).toContain(
"BrowserWindow.getAllWindows().length === 0",
);
expect(activateBlock).toContain("showMainWindowFromResidentEntry()");
});

it("focusMainWindow explicitly focuses the macOS app", () => {
const indexTs = readFile("apps/desktop/main/index.ts");
const focusStart = indexTs.indexOf("function focusMainWindow(): void {");
const focusBlock = indexTs.slice(focusStart, focusStart + 260);

expect(focusBlock).toContain("app.focus({ steal: true })");
expect(focusBlock).toContain("mainWindow.focus()");
});

// -----------------------------------------------------------------------
// 19. dev-launchd.sh stop sends SIGTERM before SIGKILL
// -----------------------------------------------------------------------
Expand Down
101 changes: 101 additions & 0 deletions tests/desktop/launchd-recovery-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";

import {
decideLaunchdRecovery,
detectStaleLaunchdSession,
} from "../../apps/desktop/main/lifecycle/launchd-recovery-policy";

describe("launchd recovery policy", () => {
it("treats old Electron metadata as stale after the threshold", () => {
const writtenAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();

const result = detectStaleLaunchdSession({
metadata: {
writtenAt,
electronPid: 123,
controllerPort: 50800,
openclawPort: 18790,
webPort: 50810,
nexuHome: "/tmp/nexu-home",
isDev: false,
},
isElectronAlive: false,
});

expect(result.stale).toBe(true);
expect(result.reason).toContain("Stale session detected");
});

it("tears down packaged services when runtime identity path changes", () => {
const result = decideLaunchdRecovery({
recovered: {
writtenAt: new Date().toISOString(),
electronPid: 123,
controllerPort: 50800,
openclawPort: 18790,
webPort: 50810,
nexuHome: "/Users/test/.nexu",
isDev: false,
appVersion: "0.1.11",
openclawStateDir:
"/Users/test/Library/Application Support/@nexu/desktop/runtime/openclaw/state",
userDataPath: "/Users/test/Library/Application Support/@nexu/desktop",
buildSource: "packaged",
runtimeIdentityPath: "/tmp/build-A/Nexu.app/Contents/Resources",
},
env: {
isDev: false,
appVersion: "0.1.11",
nexuHome: "/Users/test/.nexu",
openclawStateDir:
"/Users/test/Library/Application Support/@nexu/desktop/runtime/openclaw/state",
userDataPath: "/Users/test/Library/Application Support/@nexu/desktop",
buildSource: "packaged",
runtimeIdentityPath: "/tmp/build-B/Nexu.app/Contents/Resources",
},
anyRunning: true,
runningNexuHome: "/Users/test/.nexu",
defaultWebPort: 50810,
previousElectronAlive: true,
});

expect(result.action).toBe("teardown-stale-services");
expect(result.reason).toContain("runtimeIdentityPath");
});

it("reuses controller and openclaw ports when only Electron died", () => {
const result = decideLaunchdRecovery({
recovered: {
writtenAt: new Date().toISOString(),
electronPid: 123,
controllerPort: 50800,
openclawPort: 18790,
webPort: 50810,
nexuHome: "/Users/test/.nexu",
isDev: false,
appVersion: "0.1.11",
buildSource: "packaged",
runtimeIdentityPath: "/tmp/build-A/Nexu.app/Contents/Resources",
},
env: {
isDev: false,
appVersion: "0.1.11",
nexuHome: "/Users/test/.nexu",
buildSource: "packaged",
runtimeIdentityPath: "/tmp/build-A/Nexu.app/Contents/Resources",
},
anyRunning: true,
runningNexuHome: "/Users/test/.nexu",
defaultWebPort: 50999,
previousElectronAlive: false,
});

expect(result.action).toBe("reuse-ports");
if (result.action !== "reuse-ports") {
throw new Error("expected reuse-ports");
}
expect(result.effectivePorts.controllerPort).toBe(50800);
expect(result.effectivePorts.openclawPort).toBe(18790);
expect(result.effectivePorts.webPort).toBe(50999);
});
});
Loading