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
2 changes: 2 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
55 changes: 53 additions & 2 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,7 @@ export function registerIpc({
resolveSyncService,
runWithIpcWindow,
getWindowSession,
getProjectContext,
setWindowProjectTabs,
bindRemoteProject,
localRuntimeConnectionPool,
Expand All @@ -1565,6 +1566,7 @@ export function registerIpc({
resolveSyncService?: () => Promise<ReturnType<typeof createSyncService> | null | undefined>;
runWithIpcWindow?: <T>(event: { sender: Electron.WebContents }, fn: () => T | Promise<T>) => T | Promise<T>;
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;
Expand Down Expand Up @@ -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<AppContext["iosSimulatorService"]> => {
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,
});
Comment thread
arul28 marked this conversation as resolved.
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.",
);
Comment thread
arul28 marked this conversation as resolved.
}
return service;
};

const ensureAppControl = (): NonNullable<AppContext["appControlService"]> => {
const service = getCtx().appControlService;
Expand Down Expand Up @@ -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"],
Expand Down
108 changes: 108 additions & 0 deletions apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>([
["/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({
Expand Down
77 changes: 77 additions & 0 deletions apps/desktop/src/preload/preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,18 @@ async function assertLocalProjectHostAction(action: string): Promise<void> {
throw new Error(`${action} is only available on the local project host.`);
}

async function requireLocalProjectHostBinding(action: string): Promise<Extract<
OpenProjectBinding,
{ kind: "local" }
>> {
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<T>(
domain: string,
action: string,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion docs/features/ios-simulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<video>`.
sources, passing the bound local project root so Electron-only window capture
uses the same project context as the runtime stream state, then attaches a
desktop-capture stream to a `<video>`.

5. **Window parking.** `prepareSimulatorWindowForCapture()` opens Simulator.app,
unhides/unminimizes its windows, sizes the simulator window, and places it
Expand Down