Skip to content
Open
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
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
13 changes: 11 additions & 2 deletions 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 @@ -87,25 +88,33 @@ export function decideLaunchdRecovery(args: {

const versionMismatch =
env.appVersion != null && recovered.appVersion !== env.appVersion;
const missingRuntimeIdentityMismatch =
!versionMismatch &&
!env.isDev &&
env.runtimeIdentityPath != null &&
recovered.runtimeIdentityPath == null;
const identityMismatch =
!versionMismatch &&
(
[
[recovered.openclawStateDir, env.openclawStateDir],
[recovered.userDataPath, env.userDataPath],
[recovered.buildSource, env.buildSource],
[recovered.runtimeIdentityPath, env.runtimeIdentityPath],
] as const
).some(
([recoveredVal, envVal]) =>
recoveredVal != null && envVal != null && recoveredVal !== envVal,
);

if (versionMismatch || identityMismatch) {
if (versionMismatch || missingRuntimeIdentityMismatch || identityMismatch) {
return {
action: "teardown-stale-services",
reason: versionMismatch
? `App version changed (${recovered.appVersion} -> ${env.appVersion})`
: "Build identity mismatch (openclawStateDir, userDataPath, or buildSource differ)",
: missingRuntimeIdentityMismatch
? "Build identity mismatch (runtimeIdentityPath missing from recovered packaged session metadata)"
: "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
21 changes: 19 additions & 2 deletions 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 @@ -741,6 +745,11 @@ export async function bootstrapWithLaunchd(
// (conservative: forces fresh start on first upgrade to version-aware code).
const versionMismatch =
env.appVersion != null && recovered.appVersion !== env.appVersion;
const missingRuntimeIdentityMismatch =
!versionMismatch &&
!env.isDev &&
env.runtimeIdentityPath != null &&
recovered.runtimeIdentityPath == null;
// Check identity fields beyond version: if any of openclawStateDir,
// userDataPath, or buildSource are present in both recovered metadata
// and current env, they must match. A mismatch means two different
Expand All @@ -757,16 +766,23 @@ 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]) =>
recoveredVal != null && envVal != null && recoveredVal !== envVal,
);

if (versionMismatch || identityMismatch) {
if (versionMismatch || missingRuntimeIdentityMismatch || identityMismatch) {
const reason = versionMismatch
? `App version changed (${recovered.appVersion} → ${env.appVersion})`
: "Build identity mismatch (openclawStateDir, userDataPath, or buildSource differ)";
: missingRuntimeIdentityMismatch
? "Build identity mismatch (runtimeIdentityPath missing from recovered packaged session metadata)"
: "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 +1222,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
137 changes: 137 additions & 0 deletions tests/desktop/launchd-recovery-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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("tears down packaged services when recovered metadata is missing runtime identity path", () => {
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",
},
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 missing");
});

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);
});
});
46 changes: 46 additions & 0 deletions tests/desktop/launchd-startup-scenarios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,52 @@ describe("Launchd Startup Scenarios", () => {
expect(mockLaunchdManager.installService).toHaveBeenCalledTimes(2);
}, 15000);

it("Scenario 19b: missing runtimeIdentityPath in old packaged metadata refuses cross-attach", async () => {
const fsMock = await import("node:fs/promises");
const runtimePorts = JSON.parse(
makeRuntimePorts({
appVersion: "1.0.0",
openclawStateDir: "/tmp/state",
userDataPath: "/tmp/user-data",
buildSource: "packaged",
}),
) as Record<string, unknown>;
const { runtimeIdentityPath: _runtimeIdentityPath, ...legacyPorts } =
runtimePorts;
const ports = JSON.stringify(legacyPorts);
(fsMock.readFile as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error("ENOENT"))
.mockRejectedValueOnce(new Error("ENOENT"))
.mockResolvedValueOnce(ports)
.mockResolvedValueOnce(ports);

mockLaunchdManager.getServiceStatus.mockResolvedValue(
mockRunningService({ NEXU_HOME: "/tmp/nexu-home", PORT: "50800" }),
);

const { bootstrapWithLaunchd } = await import(
"../../apps/desktop/main/services/launchd-bootstrap"
);

const result = await bootstrapWithLaunchd(
makeBootstrapEnv({
isDev: false,
appVersion: "1.0.0",
openclawStateDir: "/tmp/state",
userDataPath: "/tmp/user-data",
buildSource: "packaged",
runtimeIdentityPath: "/Applications/Nexu-B.app/Contents/Resources",
}) as never,
);

expect(result.isAttach).toBe(false);
expect(
mockLaunchdManager.bootoutAndWaitForExit.mock.calls.length +
mockLaunchdManager.bootoutService.mock.calls.length,
).toBeGreaterThan(0);
expect(mockLaunchdManager.installService).toHaveBeenCalledTimes(2);
}, 15000);

// -----------------------------------------------------------------------
// Scenario 20: Partial attach — only controller running
// -----------------------------------------------------------------------
Expand Down
Loading