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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ describe('iOS Simulator IPC handlers', () => {
});

it.each([
MAKER_INVOKE.IOS_SIMULATOR_GET_PREFERENCES,
MAKER_INVOKE.IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL,
MAKER_INVOKE.IOS_SIMULATOR_REQUEST_ACCESS,
MAKER_INVOKE.IOS_SIMULATOR_STATUS,
MAKER_INVOKE.IOS_SIMULATOR_CALL,
Expand All @@ -60,6 +62,76 @@ describe('iOS Simulator IPC handlers', () => {
expect(getStatus).not.toHaveBeenCalled();
});

it('reads and updates the owner-scoped presentation preference without a task grant', async () => {
const harness = new IpcHarness();
const getPluginAccess = vi.fn(() => ({ allowed: true as const }));
const getPreferences = vi.fn(() => ({ autoOpenEmbeddedPanel: true }));
const setAutoOpenEmbeddedPanel = vi.fn(async (enabled: boolean) => ({
autoOpenEmbeddedPanel: enabled,
}));
registerTrusted(harness, {
getPluginAccess,
getSessionAccess: () => null,
getViewerAccess: () => null,
hasViewerAccess: () => false,
getPreferences,
setAutoOpenEmbeddedPanel,
});

await expect(
harness.invokeFrom(17, MAKER_INVOKE.IOS_SIMULATOR_GET_PREFERENCES),
).resolves.toEqual({ autoOpenEmbeddedPanel: true });
await expect(
harness.invokeFrom(17, MAKER_INVOKE.IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL, {
enabled: false,
}),
).resolves.toEqual({ autoOpenEmbeddedPanel: false });

expect(getPreferences).toHaveBeenCalledOnce();
expect(setAutoOpenEmbeddedPanel).toHaveBeenCalledWith(false);
expect(getPluginAccess).not.toHaveBeenCalled();
});

it('rejects malformed preference writes before reaching persistence', async () => {
const harness = new IpcHarness();
const setAutoOpenEmbeddedPanel = vi.fn();
registerTrusted(harness, { setAutoOpenEmbeddedPanel });

await expect(
harness.invokeFrom(17, MAKER_INVOKE.IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL, {
enabled: 'false',
}),
).rejects.toMatchObject({ code: 'INVALID_PARAMS' });
expect(setAutoOpenEmbeddedPanel).not.toHaveBeenCalled();
});

it('drops a preference write when the owner changes while persistence is pending', async () => {
const harness = new IpcHarness();
let ownerScopeKey = 'local:owner-a:1';
let releaseWrite: (() => void) | undefined;
const setAutoOpenEmbeddedPanel = vi.fn(
() =>
new Promise<{ autoOpenEmbeddedPanel: boolean }>((resolve) => {
releaseWrite = () => resolve({ autoOpenEmbeddedPanel: false });
}),
);
registerTrusted(harness, {
getOwnerScopeKey: () => ownerScopeKey,
setAutoOpenEmbeddedPanel,
});

const pending = harness.invokeFrom(
17,
MAKER_INVOKE.IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL,
{ enabled: false },
);
await vi.waitFor(() => expect(releaseWrite).toBeDefined());
ownerScopeKey = 'cloud:owner-b:2';
releaseWrite?.();

await expect(pending).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
});

it('rejects every Renderer entry before reaching the Host when the plugin is unavailable', async () => {
const harness = new IpcHarness();
const getStatus = vi.fn();
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/maker-ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,9 @@ export const MAKER_INVOKE = {
ANDROID_SET_DEFAULT_DEVICE: 'maker:android:set-default-device',
ANDROID_SET_ADB_PATH: 'maker:android:set-adb-path',
ANDROID_PREPARE_ADB: 'maker:android:prepare-adb',
// iOS Simulator presentation preference. Owner-scoped and independent from task grants.
IOS_SIMULATOR_GET_PREFERENCES: 'maker:ios-simulator:get-preferences',
IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL: 'maker:ios-simulator:set-auto-open-embedded-panel',
// iOS Simulator pane and Agent discovery. Session id is required and checked in main.
IOS_SIMULATOR_REQUEST_ACCESS: 'maker:ios-simulator:request-access',
IOS_SIMULATOR_STATUS: 'maker:ios-simulator:status',
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/main/maker-ipc/iosSimulatorHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { clipboard, nativeImage } from 'electron';

import type {
IOSSimulatorNativeH264StreamProfileRequest,
IOSSimulatorPreferences,
IOSSimulatorRendererToolName,
IOSSimulatorSessionStatus,
IOSSimulatorToolResponse,
Expand All @@ -22,6 +23,10 @@ import {
setIOSSimulatorViewerStreamProfile,
updateIOSSimulatorViewerTouch,
} from '../mcp-integrations/ios-simulator.js';
import {
readIOSSimulatorPreferences,
writeIOSSimulatorAutoOpenEmbeddedPanel,
} from '../mcp-integrations/ios-simulator-preferences.js';
import {
getIOSSimulatorRendererSessionAccess,
getIOSSimulatorRendererViewerAccess,
Expand All @@ -43,6 +48,8 @@ import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js';
const log = createLogger('maker-ipc:ios-simulator');

type IOSSimulatorIpcOperation =
| 'get-preferences'
| 'set-preferences'
| 'request-access'
| 'status'
| 'call-tool'
Expand All @@ -56,6 +63,8 @@ type IOSSimulatorIpcOperation =
| 'live-touch';

const IOS_SIMULATOR_SAFE_IPC_MESSAGES: Record<IOSSimulatorIpcOperation, string> = {
'get-preferences': 'iOS Simulator preferences are temporarily unavailable.',
'set-preferences': 'iOS Simulator preferences could not be updated.',
'request-access': 'iOS Simulator access could not be requested.',
status: 'iOS Simulator status is temporarily unavailable.',
'call-tool': 'iOS Simulator operation failed.',
Expand All @@ -75,6 +84,8 @@ export interface IOSSimulatorHandlerDeps {
getSessionContext(sessionId: string): Promise<{ workingDir: string | null } | null>;
getOwnerScopeKey(): string;
isOwnerBoundaryPending(): boolean;
getPreferences(): IOSSimulatorPreferences;
setAutoOpenEmbeddedPanel(enabled: boolean): Promise<IOSSimulatorPreferences>;
getSessionAccess(
target: IOSSimulatorRendererWebContents,
): IOSSimulatorRendererAccessSnapshot | null;
Expand Down Expand Up @@ -175,6 +186,8 @@ const defaultDeps: IOSSimulatorHandlerDeps = {
getSessionContext: async () => null,
getOwnerScopeKey: activeOwnerScopeKey,
isOwnerBoundaryPending: isAppSessionBoundaryPending,
getPreferences: readIOSSimulatorPreferences,
setAutoOpenEmbeddedPanel: writeIOSSimulatorAutoOpenEmbeddedPanel,
getSessionAccess: getIOSSimulatorRendererSessionAccess,
getViewerAccess: getIOSSimulatorRendererViewerAccess,
hasViewerAccess: hasIOSSimulatorRendererViewerAccess,
Expand Down Expand Up @@ -340,6 +353,31 @@ function readSenderWebContents(event: unknown): IOSSimulatorRendererWebContents
return sender as IOSSimulatorRendererWebContents;
}

async function callIOSSimulatorPreferences<T>(
deps: IOSSimulatorHandlerDeps,
operation: 'get-preferences' | 'set-preferences',
call: () => T | Promise<T>,
): Promise<T> {
const ownerScopeKey = deps.getOwnerScopeKey();
const assertOwnerScopeCurrent = (): void => {
if (deps.isOwnerBoundaryPending() || deps.getOwnerScopeKey() !== ownerScopeKey) {
throwIpcError(
'PRECONDITION_FAILED',
'iOS Simulator preferences changed owner while handling the request. Retry the operation.',
);
}
};
assertOwnerScopeCurrent();
try {
const result = await call();
assertOwnerScopeCurrent();
return result;
} catch (error) {
assertOwnerScopeCurrent();
throwIOSSimulatorIpcError(deps, operation, error);
}
}

export function registerIOSSimulatorHandlers(
registry: IpcHandlerRegistry,
deps: Partial<IOSSimulatorHandlerDeps> = {},
Expand Down Expand Up @@ -431,6 +469,18 @@ export function registerIOSSimulatorHandlers(
}
});
};
handle(MAKER_INVOKE.IOS_SIMULATOR_GET_PREFERENCES, () =>
callIOSSimulatorPreferences(resolved, 'get-preferences', () => resolved.getPreferences()),
);
handle(MAKER_INVOKE.IOS_SIMULATOR_SET_AUTO_OPEN_EMBEDDED_PANEL, (_event, payload) => {
const record = readRecord(payload);
if (typeof record.enabled !== 'boolean') {
throwIpcError('INVALID_PARAMS', 'enabled (boolean) required');
}
return callIOSSimulatorPreferences(resolved, 'set-preferences', () =>
resolved.setAutoOpenEmbeddedPanel(record.enabled as boolean),
);
});
handle(MAKER_INVOKE.IOS_SIMULATOR_REQUEST_ACCESS, async (event, payload) => {
const sessionId = readSessionId(payload);
const sender = readSenderWebContents(event);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';

import { createIOSSimulatorPreferencesStore } from '../ios-simulator-preferences.js';

const cleanupPaths: string[] = [];

afterEach(async () => {
await Promise.all(cleanupPaths.splice(0).map((target) => rm(target, { recursive: true })));
});

async function createStore() {
const root = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-preferences-'));
cleanupPaths.push(root);
const file = path.join(root, 'ios-simulator-preferences.json');
return {
file,
store: createIOSSimulatorPreferencesStore({ filePath: () => file }),
};
}

describe('iOS Simulator preferences', () => {
it('defaults to automatically opening the embedded panel', async () => {
const { store } = await createStore();

expect(store.read()).toEqual({ autoOpenEmbeddedPanel: true });
});

it('persists an opt-out and reloads it from disk', async () => {
const { file, store } = await createStore();

await expect(store.writeAutoOpenEmbeddedPanel(false)).resolves.toEqual({
autoOpenEmbeddedPanel: false,
});
expect(JSON.parse(await readFile(file, 'utf8'))).toEqual({
autoOpenEmbeddedPanel: false,
});

const reloaded = createIOSSimulatorPreferencesStore({ filePath: () => file });
expect(reloaded.read()).toEqual({ autoOpenEmbeddedPanel: false });
});

it('removes the override after restoring the default', async () => {
const { file, store } = await createStore();
await store.writeAutoOpenEmbeddedPanel(false);

await expect(store.writeAutoOpenEmbeddedPanel(true)).resolves.toEqual({
autoOpenEmbeddedPanel: true,
});
await expect(access(file)).rejects.toMatchObject({ code: 'ENOENT' });
});

it('falls back to the default for an invalid persisted value', async () => {
const { file, store } = await createStore();
await writeFile(file, JSON.stringify({ autoOpenEmbeddedPanel: 'false' }), 'utf8');

expect(store.read()).toEqual({ autoOpenEmbeddedPanel: true });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8943,13 +8943,15 @@ describe('iOS Simulator host', () => {
openUrlExact: vi.fn(async () => undefined),
};
const requestViewerFocus = vi.fn();
let autoOpenViewer = true;
const host = createIOSSimulatorHost({
actor,
driverManager,
projectBuilder,
appLifecycle,
resourceScheduler: testResourceScheduler(),
requestViewerFocus,
shouldAutoOpenViewer: () => autoOpenViewer,
runtime: { inspect: vi.fn(async () => READY_REPORT) },
getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })),
resolveWorktreeRoot: vi.fn(async (workDir) => workDir),
Expand Down Expand Up @@ -9043,6 +9045,9 @@ describe('iOS Simulator host', () => {
{ sessionId: 'session-a', origin: 'user' },
),
).resolves.toMatchObject({ ok: true });
expect(requestViewerFocus).toHaveBeenCalledWith('session-a', instance.instanceId);
requestViewerFocus.mockClear();
autoOpenViewer = false;

const mobileArtifact = { ...artifact, artifactId: 'mobile-artifact' };
const mobileAppPath = path.join(worktree, 'apps', 'mobile', 'ios', 'build', 'Cindy.app');
Expand All @@ -9068,6 +9073,7 @@ describe('iOS Simulator host', () => {
{ sessionId: 'session-a', origin: 'user' },
),
).resolves.toMatchObject({ ok: true });
expect(requestViewerFocus).not.toHaveBeenCalled();
expect(validateLaunch).toHaveBeenCalledWith(
worktree,
READY_REPORT.devices[0]!.udid,
Expand Down Expand Up @@ -9117,8 +9123,6 @@ describe('iOS Simulator host', () => {
'demo://home',
expect.any(AbortSignal),
);
expect(requestViewerFocus).toHaveBeenCalledWith('session-a', instance.instanceId);

let installSignal: AbortSignal | undefined;
installExact.mockImplementationOnce(
async (_simulatorUdid, _artifact, signal) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Owner-scoped iOS Simulator presentation preferences.
*
* This store only controls whether successful Host actions automatically ask
* the Renderer to reveal the embedded panel. It must never gate simulator
* lifecycle, ownership, build, launch, input, or explicit panel-open actions.
*/

import type { IOSSimulatorPreferences } from '../../shared/iosSimulatorIpc.js';
import { activeOwnerScopeKey, ownerScopedUserDataPath } from '../appSessionState.js';
import { desktopMakerLogger } from '../maker-host/logger-adapter.js';
import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js';

const log = desktopMakerLogger.child('ios-simulator-preferences');

const DEFAULTS: IOSSimulatorPreferences = {
autoOpenEmbeddedPanel: true,
};

function normalize(raw: unknown): IOSSimulatorPreferences {
if (!raw || typeof raw !== 'object') return { ...DEFAULTS };
const value = (raw as Record<string, unknown>).autoOpenEmbeddedPanel;
return {
autoOpenEmbeddedPanel: typeof value === 'boolean' ? value : DEFAULTS.autoOpenEmbeddedPanel,
};
}

export interface IOSSimulatorPreferencesStore {
read(): IOSSimulatorPreferences;
writeAutoOpenEmbeddedPanel(enabled: boolean): Promise<IOSSimulatorPreferences>;
}

export function createIOSSimulatorPreferencesStore(options: {
filePath: () => string;
scopeKey?: () => string;
}): IOSSimulatorPreferencesStore {
const store = createOverrideSettingsFile<IOSSimulatorPreferences>({
filePath: options.filePath,
defaults: DEFAULTS,
normalize,
scopeKey: options.scopeKey,
log,
label: 'iOS Simulator preferences',
maxBytes: 4 * 1024,
});

return {
read() {
store.invalidateIfChanged();
return store.read();
},
async writeAutoOpenEmbeddedPanel(enabled) {
store.invalidateIfChanged();
await store.writePatchAtomic({ autoOpenEmbeddedPanel: enabled });
log.info('iOS Simulator auto-open preference written', { enabled });
return store.read();
},
};
}

const ownerStore = createIOSSimulatorPreferencesStore({
filePath: () => ownerScopedUserDataPath('ios-simulator-preferences.json'),
scopeKey: activeOwnerScopeKey,
});

export function readIOSSimulatorPreferences(): IOSSimulatorPreferences {
return ownerStore.read();
}

export function writeIOSSimulatorAutoOpenEmbeddedPanel(
enabled: boolean,
): Promise<IOSSimulatorPreferences> {
return ownerStore.writeAutoOpenEmbeddedPanel(enabled);
}

export const __testing = { normalize, DEFAULTS };
Loading