diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 7faf9ef35..7e4ae2d53 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6081,6 +6081,8 @@ app.whenReady().then(async () => { runWithIpcWindow: (event, fn) => ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), getWindowSession, + getProjectContext: (projectRoot) => + projectContexts.get(normalizeProjectRoot(projectRoot)) ?? null, setWindowProjectTabs: rememberWindowProjectTabs, bindRemoteProject: bindWindowToRemoteProject, localRuntimeConnectionPool: shouldUseInProcessProjectRuntime() diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index ae983f8e1..4da0dae82 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1548,6 +1548,7 @@ export function registerIpc({ resolveSyncService, runWithIpcWindow, getWindowSession, + getProjectContext, setWindowProjectTabs, bindRemoteProject, localRuntimeConnectionPool, @@ -1565,6 +1566,7 @@ export function registerIpc({ resolveSyncService?: () => Promise | null | undefined>; runWithIpcWindow?: (event: { sender: Electron.WebContents }, fn: () => T | Promise) => T | Promise; getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; openProjectTabs?: ProjectInfo[]; pendingLocalProjectRoots?: string[] }; + getProjectContext?: (projectRoot: string) => AppContext | null | undefined; setWindowProjectTabs?: (windowId: number | null, rootPaths: string[]) => ProjectInfo[]; bindRemoteProject?: (windowId: number | null, binding: OpenProjectBinding & { kind: "remote" }) => void; localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; @@ -1973,6 +1975,55 @@ export function registerIpc({ } return service; }; + const readProjectRootArg = (arg: unknown): string | null => { + if (!arg || typeof arg !== "object" || Array.isArray(arg)) return null; + const value = (arg as { projectRoot?: unknown }).projectRoot; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + const getIosSimulatorContextForEvent = (event: IpcMainInvokeEvent, arg?: unknown): AppContext | null => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession?.(windowId) ?? null; + const boundLocalRoot = session?.binding?.kind === "local" + ? session.binding.rootPath + : null; + const explicitRoot = readProjectRootArg(arg); + const resolveProjectContext = (projectRoot: string) => + getProjectContext ? getProjectContext(projectRoot) ?? null : getCtx(); + if (explicitRoot) { + if (!boundLocalRoot || explicitRoot !== boundLocalRoot) { + throw new Error("iOS Simulator access is only allowed for the window's bound local project."); + } + return resolveProjectContext(explicitRoot); + } + const sessionRoot = boundLocalRoot ?? session?.project?.rootPath ?? null; + if (sessionRoot) return resolveProjectContext(sessionRoot); + return getCtx(); + }; + const ensureIosSimulatorForEvent = ( + event: IpcMainInvokeEvent, + arg?: unknown, + channel = IPC.iosSimulatorListWindowSources, + ): NonNullable => { + const ctx = getIosSimulatorContextForEvent(event, arg); + const service = ctx?.iosSimulatorService; + if (!service) { + const requestedProjectRoot = readProjectRootArg(arg); + const projectRoot = requestedProjectRoot ?? ctx?.project?.rootPath ?? null; + const logger = ctx?.logger ?? getCtx().logger; + logger.warn("ios_simulator.service_unavailable", { + channel, + requestedProjectRoot, + contextProjectRoot: ctx?.project?.rootPath ?? null, + hasUserSelectedProject: ctx?.hasUserSelectedProject ?? false, + }); + throw new Error( + projectRoot + ? `iOS Simulator service is not available for ${projectRoot}.` + : "iOS Simulator service is not available because no local project is bound to this window.", + ); + } + return service; + }; const ensureAppControl = (): NonNullable => { const service = getCtx().appControlService; @@ -7012,8 +7063,8 @@ export function registerIpc({ ipcMain.handle(IPC.iosSimulatorGetWindowState, async () => getSimulatorWindowState()); - ipcMain.handle(IPC.iosSimulatorListWindowSources, async (event) => { - const status = await ensureIosSimulator().getStatus(); + ipcMain.handle(IPC.iosSimulatorListWindowSources, async (event, arg = {}) => { + const status = await ensureIosSimulatorForEvent(event, arg, IPC.iosSimulatorListWindowSources).getStatus(); if (!status.supported) return []; const readSources = async () => desktopCapturer.getSources({ types: ["window"], diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index e500ca7c1..ba1c96410 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -838,6 +838,114 @@ describe("registerIpc sync bridge", () => { vi.useRealTimers(); }); + it("uses the sender window's bound local project for iOS Simulator window sources", async () => { + const repoGetStatus = vi.fn(async () => ({ supported: false })); + const otherGetStatus = vi.fn(async () => ({ supported: false })); + const contexts = new Map([ + ["/repo", { + project: { rootPath: "/repo" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + iosSimulatorService: { getStatus: repoGetStatus }, + }], + ["/other", { + project: { rootPath: "/other" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + iosSimulatorService: { getStatus: otherGetStatus }, + }], + ]); + const getProjectContext = vi.fn((root: string) => contexts.get(root) ?? null); + registerIpc({ + getCtx: () => ({ + project: { rootPath: "/fallback" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + iosSimulatorService: { getStatus: vi.fn(async () => ({ supported: false })) }, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + getProjectContext, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.iosSimulatorListWindowSources)?.( + eventForSender(), + { projectRoot: "/repo" }, + ), + ).resolves.toEqual([]); + + expect(getProjectContext).toHaveBeenCalledWith("/repo"); + expect(repoGetStatus).toHaveBeenCalledTimes(1); + expect(otherGetStatus).not.toHaveBeenCalled(); + }); + + it("rejects iOS Simulator window-source requests for an unbound project root", async () => { + const getProjectContext = vi.fn(() => ({ + project: { rootPath: "/other" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + iosSimulatorService: { getStatus: vi.fn(async () => ({ supported: false })) }, + }) as any); + registerIpc({ + getCtx: () => ({ + project: { rootPath: "/fallback" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + getProjectContext, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.iosSimulatorListWindowSources)?.( + eventForSender(), + { projectRoot: "/other" }, + ), + ).rejects.toThrow("bound local project"); + + expect(getProjectContext).not.toHaveBeenCalled(); + }); + + it("falls back to the active context for matching iOS Simulator roots when no project context lookup is registered", async () => { + const getStatus = vi.fn(async () => ({ supported: false })); + registerIpc({ + getCtx: () => ({ + project: { rootPath: "/repo" }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + iosSimulatorService: { getStatus }, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.iosSimulatorListWindowSources)?.( + eventForSender(), + { projectRoot: "/repo" }, + ), + ).resolves.toEqual([]); + + expect(getStatus).toHaveBeenCalledTimes(1); + }); + it("surfaces missing sync service for active lane presence when no runtime pool is bound", async () => { const resolveSyncService = vi.fn(async () => null); registerIpc({ diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index fbda862ab..1f834a47c 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -419,6 +419,83 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.iosSimulatorListWindowSources); }); + it("rejects iOS Simulator window sources when no local project is bound", async () => { + const invoke = vi.fn(async (channel: string, _payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding: null }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.iosSimulator.listSimulatorWindowSources()).rejects.toThrow(/open local project/i); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).not.toHaveBeenCalledWith(IPC.iosSimulatorListWindowSources, expect.anything()); + }); + + it("passes the bound local project root when reading iOS Simulator window sources", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const sources = [{ id: "window:1", name: "Simulator", thumbnailDataUrl: null }]; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.iosSimulatorListWindowSources) { + expect(payload).toEqual({ projectRoot: "/repo" }); + return sources; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(payload)}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.iosSimulator.listSimulatorWindowSources()).resolves.toEqual(sources); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).toHaveBeenCalledWith(IPC.iosSimulatorListWindowSources, { projectRoot: "/repo" }); + }); + it("routes local macOS VM deletion through direct IPC when a local project runtime is bound", async () => { const binding = { kind: "local", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 995515402..02e973806 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1099,6 +1099,18 @@ async function assertLocalProjectHostAction(action: string): Promise { throw new Error(`${action} is only available on the local project host.`); } +async function requireLocalProjectHostBinding(action: string): Promise> { + const binding = await getProjectRuntimeBinding({ fresh: true }); + if (binding?.kind === "local") return binding; + if (binding?.kind === "remote") { + throw new Error(`${action} is only available on the local project host.`); + } + throw new Error(`${action} requires an open local project.`); +} + async function callRemoteProjectActionIfBound( domain: string, action: string, @@ -5445,8 +5457,10 @@ contextBridge.exposeInMainWorld("ade", { listSimulatorWindowSources: async (): Promise< IosSimulatorWindowSource[] > => { - await assertLocalProjectHostAction("iOS Simulator window sources"); - return ipcRenderer.invoke(IPC.iosSimulatorListWindowSources); + const binding = await requireLocalProjectHostBinding("iOS Simulator window sources"); + return ipcRenderer.invoke(IPC.iosSimulatorListWindowSources, { + projectRoot: binding.rootPath, + }); }, tap: async (args: { deviceUdid?: string | null; diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index 08680f821..5de3d152e 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -56,7 +56,9 @@ force shutdown is requested. `simulator-window-capture`; there is no separate ADE-managed streaming backend. The service records running status and opens Simulator.app with `open -g -a Simulator`. The renderer asks IPC for capturable Simulator window - sources and attaches a desktop-capture stream to a `