diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index cf9732eba..991a7a12f 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -970,6 +970,7 @@ async function runLaunchdColdStart(): Promise { 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 @@ -1031,6 +1032,7 @@ function focusMainWindow(): void { mainWindow.restore(); } + app.focus({ steal: true }); mainWindow.focus(); } @@ -1860,7 +1862,7 @@ app.whenReady().then(async () => { return; } - focusMainWindow(); + showMainWindowFromResidentEntry(); }); app.once("before-quit", () => { diff --git a/apps/desktop/main/lifecycle/launchd-recovery-policy.ts b/apps/desktop/main/lifecycle/launchd-recovery-policy.ts index 36cbb42ba..99b1ea68e 100644 --- a/apps/desktop/main/lifecycle/launchd-recovery-policy.ts +++ b/apps/desktop/main/lifecycle/launchd-recovery-policy.ts @@ -7,6 +7,7 @@ export interface LaunchdRecoveryEnvIdentity { openclawStateDir?: string; userDataPath?: string; buildSource?: string; + runtimeIdentityPath?: string; } export interface LaunchdRecoveredPorts { @@ -87,6 +88,11 @@ 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 && ( @@ -94,18 +100,21 @@ 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]) => 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, }; } diff --git a/apps/desktop/main/lifecycle/launchd-session-store.ts b/apps/desktop/main/lifecycle/launchd-session-store.ts index 39e4605e4..c14a06d69 100644 --- a/apps/desktop/main/lifecycle/launchd-session-store.ts +++ b/apps/desktop/main/lifecycle/launchd-session-store.ts @@ -17,6 +17,7 @@ export interface LaunchdRuntimeSessionMetadata { openclawStateDir?: string; userDataPath?: string; buildSource?: string; + runtimeIdentityPath?: string; } export function getLaunchdRuntimeSessionPath(plistDir: string): string { diff --git a/apps/desktop/main/services/launchd-bootstrap.ts b/apps/desktop/main/services/launchd-bootstrap.ts index af4846e31..f18dca57d 100644 --- a/apps/desktop/main/services/launchd-bootstrap.ts +++ b/apps/desktop/main/services/launchd-bootstrap.ts @@ -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 */ @@ -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; } /** @@ -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 @@ -757,16 +766,23 @@ export async function bootstrapWithLaunchd( ], ["userDataPath", recovered.userDataPath, env.userDataPath], ["buildSource", recovered.buildSource, env.buildSource], + [ + "runtimeIdentityPath", + recovered.runtimeIdentityPath, + env.runtimeIdentityPath, + ], ] 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"})`, ); @@ -1206,6 +1222,7 @@ export async function bootstrapWithLaunchd( openclawStateDir: env.openclawStateDir, userDataPath: env.userDataPath, buildSource: env.buildSource, + runtimeIdentityPath: env.runtimeIdentityPath, }); return { diff --git a/tests/desktop/dev-toolchain-invariants.test.ts b/tests/desktop/dev-toolchain-invariants.test.ts index f31e46d28..161d8d3bc 100644 --- a/tests/desktop/dev-toolchain-invariants.test.ts +++ b/tests/desktop/dev-toolchain-invariants.test.ts @@ -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 // ----------------------------------------------------------------------- diff --git a/tests/desktop/launchd-recovery-policy.test.ts b/tests/desktop/launchd-recovery-policy.test.ts new file mode 100644 index 000000000..9087feaea --- /dev/null +++ b/tests/desktop/launchd-recovery-policy.test.ts @@ -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); + }); +}); diff --git a/tests/desktop/launchd-startup-scenarios.test.ts b/tests/desktop/launchd-startup-scenarios.test.ts index be9ba2290..5c8aef983 100644 --- a/tests/desktop/launchd-startup-scenarios.test.ts +++ b/tests/desktop/launchd-startup-scenarios.test.ts @@ -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; + const { runtimeIdentityPath: _runtimeIdentityPath, ...legacyPorts } = + runtimePorts; + const ports = JSON.stringify(legacyPorts); + (fsMock.readFile as ReturnType) + .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 // -----------------------------------------------------------------------