diff --git a/apps/desktop/src/main/doc-tools/__tests__/docsOutputWriterUtilityProcess.test.ts b/apps/desktop/src/main/doc-tools/__tests__/docsOutputWriterUtilityProcess.test.ts index 7ff571dc3c0..472778bbbac 100644 --- a/apps/desktop/src/main/doc-tools/__tests__/docsOutputWriterUtilityProcess.test.ts +++ b/apps/desktop/src/main/doc-tools/__tests__/docsOutputWriterUtilityProcess.test.ts @@ -241,7 +241,8 @@ describe('docs output cwd-bound writer', () => { ` const fs = (await import('node:fs')).default; const path = (await import('node:path')).default; -const { runDocsOutputWrite } = await import(process.env.CINDY_WRITER_MODULE); +const writerModule = await import(process.env.CINDY_WRITER_MODULE); +const { runDocsOutputWrite } = writerModule.default ?? writerModule; const root = process.env.CINDY_WRITER_ROOT; const safe = process.env.CINDY_WRITER_SAFE; const moved = process.env.CINDY_WRITER_MOVED; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts index cb1bfce95c8..5b3b7f5a364 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts @@ -639,6 +639,18 @@ describe('iOS Simulator IPC handlers', () => { }), ).resolves.toMatchObject({ ok: true }); expect(callTool).toHaveBeenCalledWith('attach_device', {}, 'session-a'); + await expect( + harness.invokeFrom(17, MAKER_INVOKE.IOS_SIMULATOR_CALL, { + sessionId: 'session-a', + name: 'delete_instance', + args: { instanceId: 'instance-a', generation: 2, leaseId: 'lease-a' }, + }), + ).resolves.toMatchObject({ ok: true }); + expect(callTool).toHaveBeenCalledWith( + 'delete_instance', + { instanceId: 'instance-a', generation: 2, leaseId: 'lease-a' }, + 'session-a', + ); for (const name of ['build_app', 'open_url', 'push_notification', 'delete_everything']) { await expect( harness.invokeFrom(17, MAKER_INVOKE.IOS_SIMULATOR_CALL, { @@ -648,7 +660,7 @@ describe('iOS Simulator IPC handlers', () => { }), ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); } - expect(callTool).toHaveBeenCalledTimes(1); + expect(callTool).toHaveBeenCalledTimes(2); }); it('folds tool-call internals behind the same safe Main-to-Renderer boundary', async () => { diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts index f5ac0716bec..cac05e1ab65 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts @@ -2314,6 +2314,406 @@ describe('iOS Simulator host', () => { await host.dispose(); }); + it('deletes a stopped Cindy-created simulator only after its Host runtime is released', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Shutdown' as const }; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(), + createExact: vi.fn(), + deleteExact: vi.fn(async () => undefined), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'cindy', + bootProvenance: 'user-booted', + device, + }); + const stopDriver = vi.fn(async () => undefined); + const cleanupOrphaned = vi.fn(async () => undefined); + const discardInstance = vi.fn(async () => undefined); + const resourceScheduler = testResourceScheduler(); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + resourceScheduler, + driverManager: { + get: vi.fn(() => null), + start: vi.fn(), + stop: stopDriver, + cleanupOrphaned, + }, + mediaCapture: { + discardSession: vi.fn(async () => undefined), + discardInstance, + } as unknown as IOSSimulatorMediaCaptureAdapter, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + const route = { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }; + + await expect( + host.callTool('delete_instance', route, { + sessionId: 'delete-session', + origin: 'user', + }), + ).resolves.toMatchObject({ ok: true }); + + expect(discardInstance).toHaveBeenCalledWith(instance.instanceId); + expect(stopDriver).toHaveBeenCalledWith(instance.instanceId); + expect(cleanupOrphaned).toHaveBeenCalledWith(instance.instanceId, device.udid); + expect(lifecycle.deleteExact).toHaveBeenCalledWith(device.udid, expect.any(AbortSignal)); + expect(actor.list('delete-session')).toEqual([]); + expect(resourceScheduler.runningCount()).toBe(0); + await host.dispose(); + }); + + it('cleans up, stops, and deletes a running Cindy-created simulator in order', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Booted' as const }; + const events: string[] = []; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(async () => { + events.push('shutdown'); + }), + createExact: vi.fn(), + deleteExact: vi.fn(async () => { + events.push('delete'); + }), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'cindy', + bootProvenance: 'agent-booted', + device, + }); + const resourceScheduler = testResourceScheduler(); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + resourceScheduler, + driverManager: { + get: vi.fn(() => null), + start: vi.fn(), + stop: vi.fn(async () => { + events.push('driver'); + }), + cleanupOrphaned: vi.fn(async () => { + events.push('orphaned'); + }), + }, + mediaCapture: { + discardSession: vi.fn(async () => undefined), + discardInstance: vi.fn(async () => { + events.push('media'); + }), + } as unknown as IOSSimulatorMediaCaptureAdapter, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + events.length = 0; + + await expect( + host.callTool( + 'delete_instance', + { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }, + { sessionId: 'delete-session', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: true }); + expect(events).toEqual(['media', 'driver', 'orphaned', 'shutdown', 'delete']); + expect(actor.list('delete-session')).toEqual([]); + expect(resourceScheduler.runningCount()).toBe(0); + await host.dispose(); + }); + + it('does not start queued viewer recovery after the simulator is deleted', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Booted' as const }; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(async () => undefined), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'cindy', + bootProvenance: 'agent-booted', + device, + }); + const startDriver = vi.fn(); + const resourceScheduler = testResourceScheduler(); + const runStart = resourceScheduler.runStart.bind(resourceScheduler); + let signalViewerRecoveryQueued!: () => void; + const viewerRecoveryQueued = new Promise((resolve) => { + signalViewerRecoveryQueued = resolve; + }); + let releaseViewerRecovery!: () => void; + const viewerRecoveryGate = new Promise((resolve) => { + releaseViewerRecovery = resolve; + }); + vi.spyOn(resourceScheduler, 'runStart').mockImplementation(async (instanceId, task) => { + signalViewerRecoveryQueued(); + await viewerRecoveryGate; + return runStart(instanceId, task); + }); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + resourceScheduler, + driverManager: { + get: vi.fn(() => null), + start: startDriver, + stop: vi.fn(async () => undefined), + }, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + deviceLivenessIntervalMs: 0, + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + const route = { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }; + + const viewer = host.setViewerVisibility( + 'delete-session', + route, + true, + 'jpeg', + undefined, + 17, + 'delete-viewer', + ); + await viewerRecoveryQueued; + await expect( + host.callTool('delete_instance', route, { + sessionId: 'delete-session', + origin: 'user', + }), + ).resolves.toMatchObject({ ok: true }); + + releaseViewerRecovery(); + await expect(viewer).resolves.toMatchObject({ + ok: false, + errorCode: 'MUTATION_CANCELLED', + }); + expect(startDriver).not.toHaveBeenCalled(); + expect(actor.list('delete-session')).toEqual([]); + await host.dispose(); + }); + + it('retains ownership and does not delete when automatic shutdown fails', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Booted' as const }; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(async () => { + throw new IOSSimulatorInstanceError('SIMULATOR_SHUTDOWN_FAILED', 'shutdown failed', true); + }), + createExact: vi.fn(), + deleteExact: vi.fn(), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'cindy', + bootProvenance: 'agent-booted', + device, + }); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + driverManager: { + get: vi.fn(() => null), + start: vi.fn(), + stop: vi.fn(async () => undefined), + }, + mediaCapture: { + discardSession: vi.fn(async () => undefined), + discardInstance: vi.fn(async () => undefined), + } as unknown as IOSSimulatorMediaCaptureAdapter, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + + await expect( + host.callTool( + 'delete_instance', + { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }, + { sessionId: 'delete-session', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'SIMULATOR_SHUTDOWN_FAILED' }); + expect(lifecycle.deleteExact).not.toHaveBeenCalled(); + expect(actor.list('delete-session')).toHaveLength(1); + expect(actor.getOwned('delete-session', instance.instanceId)).toMatchObject({ + lifecycleState: 'error', + }); + await host.dispose(); + }); + + it('rejects deleting an externally created simulator before releasing any runtime', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Shutdown' as const }; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(), + createExact: vi.fn(), + deleteExact: vi.fn(), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'external', + bootProvenance: 'preexisting', + device, + }); + const stopDriver = vi.fn(async () => undefined); + const discardInstance = vi.fn(async () => undefined); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + driverManager: { + get: vi.fn(() => null), + start: vi.fn(), + stop: stopDriver, + }, + mediaCapture: { + discardSession: vi.fn(async () => undefined), + discardInstance, + } as unknown as IOSSimulatorMediaCaptureAdapter, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + discardInstance.mockClear(); + stopDriver.mockClear(); + + await expect( + host.callTool( + 'delete_instance', + { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }, + { sessionId: 'delete-session', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'SIMULATOR_DELETE_FORBIDDEN' }); + expect(discardInstance).not.toHaveBeenCalled(); + expect(stopDriver).not.toHaveBeenCalled(); + expect(lifecycle.deleteExact).not.toHaveBeenCalled(); + expect(actor.list('delete-session')).toHaveLength(1); + await host.dispose(); + }); + + it('retains ownership when runtime cleanup fails before simulator deletion', async () => { + const device = { ...READY_REPORT.devices[0]!, state: 'Shutdown' as const }; + const lifecycle: IOSSimulatorSimctlLifecycle = { + findExact: vi.fn(async () => device), + bootExact: vi.fn(), + shutdownExact: vi.fn(), + createExact: vi.fn(), + deleteExact: vi.fn(), + }; + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle, + }); + const instance = actor.attach({ + sessionId: 'delete-session', + worktreeRoot: '/tmp/delete-session', + sourceFingerprint: 'fingerprint-a', + creationProvenance: 'cindy', + bootProvenance: 'user-booted', + device, + }); + const host = createIOSSimulatorHost({ + actor, + lifecycle, + driverManager: { + get: vi.fn(() => null), + start: vi.fn(), + stop: vi.fn(async () => undefined), + }, + mediaCapture: { + discardSession: vi.fn(async () => undefined), + discardInstance: vi.fn(async () => { + throw new Error('recording cleanup failed'); + }), + } as unknown as IOSSimulatorMediaCaptureAdapter, + runtime: { inspect: vi.fn(async () => ({ ...READY_REPORT, devices: [device] })) }, + getSession: vi.fn(async (id) => localSession(id)), + }); + await host.reconcileOwnership(); + const current = actor.getOwned('delete-session', instance.instanceId); + + await expect( + host.callTool( + 'delete_instance', + { + instanceId: current.instanceId, + generation: current.generation, + leaseId: current.lease.id, + }, + { sessionId: 'delete-session', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'DEVICE_BUSY' }); + expect(lifecycle.deleteExact).not.toHaveBeenCalled(); + expect(actor.list('delete-session')).toHaveLength(1); + await host.dispose(); + }); + it.each([ { label: 'external preexisting', diff --git a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts index 47a31b19def..07e726d9d4e 100644 --- a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts +++ b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts @@ -81,6 +81,7 @@ import type { IOSSimulatorPublicRouteReasonCode, IOSSimulatorPublicRouteState, IOSSimulatorPublicRouteStatus, + IOSSimulatorRendererToolName, IOSSimulatorSessionStatus, } from '../../shared/iosSimulatorIpc.js'; import type { @@ -118,6 +119,8 @@ const MAX_WDA_VIEWER_FRAMES_PER_SECOND = 20; const MAX_NATIVE_H264_VIEWER_FRAMES_PER_SECOND = 60; const MAX_INSTANCES_PER_SESSION = 4; +type IOSSimulatorHostToolName = IOSSimulatorMcpToolName | IOSSimulatorRendererToolName; + interface IOSSimulatorSessionSnapshot { id: string; workDir: string; @@ -257,7 +260,7 @@ export interface IOSSimulatorHost { /** Synchronously retire media/input owned by one exact revoked renderer grant. */ revokeRendererViewer(sessionId: string, viewerWebContentsId: number): number; callTool( - name: IOSSimulatorMcpToolName, + name: IOSSimulatorHostToolName, args: Record, context?: IOSSimulatorMcpCallContext, ): Promise; @@ -1306,6 +1309,7 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I pendingTeardowns: current.pendingTeardowns + 1, }); blockedBuildInstances.add(instanceId); + viewerVisibilityIntents.delete(instanceId); releaseViewerTouches(instanceId); } function finishInstanceTeardown(instanceId: string): void { @@ -1985,30 +1989,34 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I } } - async function releaseInstanceRuntime(instance: IOSSimulatorInstance): Promise { - beginInstanceTeardown(instance.instanceId); - try { - await cancelBuild(instance.instanceId); - clearInstanceRuntimeProjection(instance.instanceId); - let cleanupSucceeded = true; - await mediaCapture.discardInstance(instance.instanceId).catch((error) => { + async function cleanupInstanceRuntimeResources(instance: IOSSimulatorInstance): Promise { + await cancelBuild(instance.instanceId); + clearInstanceRuntimeProjection(instance.instanceId); + let cleanupSucceeded = true; + await mediaCapture.discardInstance(instance.instanceId).catch((error) => { + cleanupSucceeded = false; + logger.warn('iOS Simulator ownership cleanup could not discard recording', { + instanceId: instance.instanceId, + error: error instanceof Error ? error.message : String(error), + }); + }); + if (driverManager) { + await driverManager.stop(instance.instanceId).catch((error) => { cleanupSucceeded = false; - logger.warn('iOS Simulator ownership cleanup could not discard recording', { + logger.warn('iOS Simulator ownership cleanup could not stop driver runtime', { instanceId: instance.instanceId, error: error instanceof Error ? error.message : String(error), }); }); - if (driverManager) { - await driverManager.stop(instance.instanceId).catch((error) => { - cleanupSucceeded = false; - logger.warn('iOS Simulator ownership cleanup could not stop driver runtime', { - instanceId: instance.instanceId, - error: error instanceof Error ? error.message : String(error), - }); - }); - if (!(await cleanupOrphanedDriverRuntime(instance))) cleanupSucceeded = false; - } - return cleanupSucceeded; + if (!(await cleanupOrphanedDriverRuntime(instance))) cleanupSucceeded = false; + } + return cleanupSucceeded; + } + + async function releaseInstanceRuntime(instance: IOSSimulatorInstance): Promise { + beginInstanceTeardown(instance.instanceId); + try { + return await cleanupInstanceRuntimeResources(instance); } finally { finishInstanceTeardown(instance.instanceId); } @@ -3980,9 +3988,18 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I return viewerRouteRefreshResult(instance); } try { + const viewerRecoveryAdmission = captureInstanceOperationAdmission( + instance.instanceId, + 'viewer recovery', + ); running = await resourceScheduler.runStart( instance.instanceId, async (commitRunning) => { + assertInstanceOperationAdmission( + instance.instanceId, + viewerRecoveryAdmission, + 'viewer recovery', + ); assertCurrentViewerVisibilityIntent( instance.instanceId, viewerWebContentsId, @@ -3993,6 +4010,11 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I commitRunning(); actor.markHealth(resolved.sessionId, instance.instanceId, 'recovering', null); const started = await ensureDriver(instance, environment); + assertInstanceOperationAdmission( + instance.instanceId, + viewerRecoveryAdmission, + 'viewer recovery', + ); assertSessionRemovalAdmission(resolved.sessionId, removalBarrierOperation); return started; }, @@ -4694,6 +4716,47 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I finishInstanceTeardown(route.instanceId); } } + if (name === 'delete_instance') { + const route = readMutationRoute(sessionId, args); + const instance = actor.getOwned(sessionId, route.instanceId); + requireControlGrant(instance, context); + actor.assertRoute(route); + if (instance.creationProvenance !== 'cindy') { + throw new IOSSimulatorInstanceError( + 'SIMULATOR_DELETE_FORBIDDEN', + 'Only simulators created by Cindy can be deleted.', + ); + } + // Keep activation blocked from the first resource cleanup through + // exact shutdown, CoreSimulator deletion, and ownership release. + beginInstanceTeardown(route.instanceId); + try { + const cleanupSucceeded = await cleanupInstanceRuntimeResources(instance); + if (!cleanupSucceeded) { + throw new IOSSimulatorInstanceError( + 'DEVICE_BUSY', + 'The simulator runtime could not be fully released. Try deleting it again.', + true, + ); + } + const stopped = await actor.stop(route); + resourceScheduler.markStopped(route.instanceId); + publishRouteStatusForInstance(stopped, null); + const deleted = await actor.delete({ + sessionId: stopped.sessionId, + instanceId: stopped.instanceId, + generation: stopped.generation, + leaseId: stopped.lease.id, + }); + clearRemovedInstanceProjection(route.instanceId); + return { + ok: true, + data: instanceData(deleted), + }; + } finally { + finishInstanceTeardown(route.instanceId); + } + } if (name === 'detach_device') { const route = readMutationRoute(sessionId, args); requireControlGrant(actor.getOwned(sessionId, route.instanceId), context); @@ -6689,7 +6752,7 @@ export function getIOSSimulatorPluginStatus( } export function callIOSSimulatorHostTool( - name: IOSSimulatorMcpToolName, + name: IOSSimulatorRendererToolName, args: Record, sessionId: string, ): Promise { diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx index fd3cc802839..9001472f9e5 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx @@ -23,10 +23,12 @@ import { ShieldOff, Smartphone, Square, + Trash2, UnlockKeyhole, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { cn } from '@/lib/utils'; import { extractIpcError } from '@/utils/ipcError'; import type { @@ -57,7 +59,7 @@ interface IOSSimulatorTabBodyProps { shellVisible?: boolean; } -type Operation = 'attach' | 'start' | 'stop' | 'detach' | 'grant' | 'control' | null; +type Operation = 'attach' | 'start' | 'stop' | 'delete' | 'detach' | 'grant' | 'control' | null; type StandardStreamProfileName = 'low' | 'balanced' | 'high'; type StreamProfileName = StandardStreamProfileName | 'experimental60'; type StreamProfile = { framesPerSecond: number; jpegQuality: number; scalingPercent: number }; @@ -329,6 +331,7 @@ export function IOSSimulatorTabBody({ const [actionError, setActionError] = useState(null); const [refreshing, setRefreshing] = useState(false); const [operation, setOperation] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [unavailableDevicesExpanded, setUnavailableDevicesExpanded] = useState(false); const [frameUrl, setFrameUrl] = useState(null); const [framePresentation, setFramePresentation] = useState<'jpeg' | 'h264' | null>(null); @@ -1069,8 +1072,9 @@ export function IOSSimulatorTabBody({ return; } const instance = resultInstance(result); - if (name === 'detach_device') ctx.patchState({ instanceId: null }); - else if (instance) ctx.patchState({ instanceId: instance.instanceId }); + if (name === 'detach_device' || name === 'delete_instance') { + ctx.patchState({ instanceId: null }); + } else if (instance) ctx.patchState({ instanceId: instance.instanceId }); await refresh(); } catch { setActionError(t('rightSidebar.iosSimulator.operationErrorWithRecovery')); @@ -2204,6 +2208,14 @@ export function IOSSimulatorTabBody({ disabled={busy} onClick={() => void call('detach', 'detach_device', routeFor(attachedInstance))} /> + {attachedInstance.creationProvenance === 'cindy' && ( + setDeleteTarget(attachedInstance)} + /> + )} {attachedInstance.lifecycleState === 'ready' && ( @@ -2450,6 +2462,28 @@ export function IOSSimulatorTabBody({ )} + { + if (!open) setDeleteTarget(null); + }} + title={t('rightSidebar.iosSimulator.deleteDeviceConfirmTitle', { + device: deleteTarget?.simulatorName ?? '', + })} + description={t( + deleteTarget?.lifecycleState === 'ready' + ? 'rightSidebar.iosSimulator.deleteRunningDeviceConfirmDescription' + : 'rightSidebar.iosSimulator.deleteDeviceConfirmDescription', + )} + confirmText={t('rightSidebar.iosSimulator.deleteDevice')} + confirmVariant="destructive" + autoFocusConfirm + onConfirm={() => { + const target = deleteTarget; + setDeleteTarget(null); + if (target) void call('delete', 'delete_instance', routeFor(target)); + }} + /> ); } diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/__tests__/IOSSimulatorTabBody.test.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/__tests__/IOSSimulatorTabBody.test.tsx index 24dfd0f659b..7b7d8356108 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/__tests__/IOSSimulatorTabBody.test.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/__tests__/IOSSimulatorTabBody.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IOSSimulatorMutationState } from '@cindy/ios-simulator-runtime'; @@ -2262,6 +2262,9 @@ describe('IOSSimulatorTabBody', () => { expect( screen.getByRole('button', { name: 'rightSidebar.iosSimulator.detachDevice' }), ).toBeTruthy(); + expect( + screen.queryByRole('button', { name: 'rightSidebar.iosSimulator.deleteDevice' }), + ).toBeNull(); expect( screen.queryByRole('button', { name: 'rightSidebar.iosSimulator.pressHome' }), ).toBeNull(); @@ -2271,6 +2274,88 @@ describe('IOSSimulatorTabBody', () => { expect(screen.queryByText('rightSidebar.iosSimulator.agentControlTitle')).toBeNull(); }); + it('confirms deletion for a stopped Cindy-created simulator', async () => { + const instance: IOSSimulatorPublicInstance = { + ...readyInstance(), + creationProvenance: 'cindy', + lifecycleState: 'stopped', + stoppedAt: '2026-08-04T09:00:00.000Z', + }; + const api = installStatus(readyStatus(instance)); + vi.mocked(ctx.patchState).mockClear(); + + render(); + + const deleteButton = await screen.findByRole('button', { + name: 'rightSidebar.iosSimulator.deleteDevice', + }); + fireEvent.click(deleteButton); + expect(screen.getByText('rightSidebar.iosSimulator.deleteDeviceConfirmTitle')).toBeTruthy(); + expect( + screen.getByText('rightSidebar.iosSimulator.deleteDeviceConfirmDescription'), + ).toBeTruthy(); + expect(api.call).not.toHaveBeenCalled(); + const confirmButton = within(screen.getByRole('alertdialog')).getByRole('button', { + name: 'rightSidebar.iosSimulator.deleteDevice', + }); + await waitFor(() => expect(document.activeElement).toBe(confirmButton)); + + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(api.call).toHaveBeenCalledWith({ + sessionId: 'session-a', + name: 'delete_instance', + args: { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }, + }); + expect(ctx.patchState).toHaveBeenCalledWith({ instanceId: null }); + }); + }); + + it('offers deletion while a Cindy-created simulator is running and explains automatic shutdown', async () => { + const instance: IOSSimulatorPublicInstance = { + ...readyInstance(), + creationProvenance: 'cindy', + }; + const api = installStatus(readyStatus(instance)); + vi.mocked(ctx.patchState).mockClear(); + + render(); + + fireEvent.click( + await screen.findByRole('button', { + name: 'rightSidebar.iosSimulator.deleteDevice', + }), + ); + expect( + screen.getByText('rightSidebar.iosSimulator.deleteRunningDeviceConfirmDescription'), + ).toBeTruthy(); + expect(api.call).not.toHaveBeenCalled(); + + fireEvent.click( + within(screen.getByRole('alertdialog')).getByRole('button', { + name: 'rightSidebar.iosSimulator.deleteDevice', + }), + ); + + await waitFor(() => { + expect(api.call).toHaveBeenCalledWith({ + sessionId: 'session-a', + name: 'delete_instance', + args: { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }, + }); + expect(ctx.patchState).toHaveBeenCalledWith({ instanceId: null }); + }); + }); + it('maps host error codes to stable localized setup steps', () => { expect(setupStepKeys('XCODE_NOT_FOUND')).toEqual([ 'rightSidebar.iosSimulator.setup.installXcode', diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 77cf33e2591..77801636ebf 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -5960,6 +5960,10 @@ "detachDevice": "Detach Device", "startDevice": "Start Device", "stopDevice": "Stop Device", + "deleteDevice": "Delete Device", + "deleteDeviceConfirmTitle": "Delete {{device}}?", + "deleteDeviceConfirmDescription": "This permanently deletes the simulator and all apps, accounts, and data stored on it. This cannot be undone.", + "deleteRunningDeviceConfirmDescription": "This simulator is running. Cindy will clean up its resources, stop it, and then permanently delete all apps, accounts, and data stored on it. This cannot be undone.", "attachedDescription": "This device is bound to the current Cindy session.", "agentControlTitle": "Agent Control", "agentControlDescription": "Agents can start, stop, or interact with this device only when allowed.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index ce74e82390b..f10dd223182 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -5952,6 +5952,10 @@ "detachDevice": "デバイスを切断", "startDevice": "デバイスを起動", "stopDevice": "デバイスを停止", + "deleteDevice": "デバイスを削除", + "deleteDeviceConfirmTitle": "「{{device}}」を削除しますか?", + "deleteDeviceConfirmDescription": "このシミュレータと、その中に保存されているすべてのアプリ、アカウント、データを完全に削除します。この操作は取り消せません。", + "deleteRunningDeviceConfirmDescription": "このシミュレータは実行中です。確認すると、Cindy が関連リソースをクリーンアップしてデバイスを停止した後、保存されているすべてのアプリ、アカウント、データを完全に削除します。この操作は取り消せません。", "attachedDescription": "このデバイスは現在の Cindy セッションに割り当てられています。", "agentControlTitle": "Agent の操作権限", "agentControlDescription": "許可した場合のみ、Agent がこのデバイスを起動、停止、操作できます。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 84e5ecccf1d..a442c7d58cb 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -5952,6 +5952,10 @@ "detachDevice": "기기 연결 해제", "startDevice": "기기 시작", "stopDevice": "기기 중지", + "deleteDevice": "기기 삭제", + "deleteDeviceConfirmTitle": "{{device}} 기기를 삭제할까요?", + "deleteDeviceConfirmDescription": "이 시뮬레이터와 그 안에 저장된 모든 앱, 계정 및 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", + "deleteRunningDeviceConfirmDescription": "이 시뮬레이터는 실행 중입니다. 확인하면 Cindy가 관련 리소스를 정리하고 기기를 중지한 다음 저장된 모든 앱, 계정 및 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", "attachedDescription": "이 기기는 현재 Cindy 세션에 연결되어 있습니다.", "agentControlTitle": "Agent 제어", "agentControlDescription": "허용한 경우에만 Agent가 이 기기를 시작, 중지 또는 조작할 수 있습니다.", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index cdfe392d58c..71f1b3abdea 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -5944,6 +5944,10 @@ "detachDevice": "断开设备", "startDevice": "启动设备", "stopDevice": "停止设备", + "deleteDevice": "删除设备", + "deleteDeviceConfirmTitle": "删除“{{device}}”?", + "deleteDeviceConfirmDescription": "此操作会永久删除这台模拟器,以及其中存储的所有 App、账号和数据。此操作无法撤销。", + "deleteRunningDeviceConfirmDescription": "这台模拟器正在运行。确认后,Cindy 会先清理相关资源并停止设备,然后永久删除其中的所有 App、账号和数据。此操作无法撤销。", "attachedDescription": "此设备已绑定到当前 Cindy 任务。", "agentControlTitle": "Agent 控制", "agentControlDescription": "允许后,Agent 才能启动、停止或操作这台设备。", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 40e2658bf50..35b148f1622 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -5944,6 +5944,10 @@ "detachDevice": "中斷連線裝置", "startDevice": "啟動裝置", "stopDevice": "停止裝置", + "deleteDevice": "刪除裝置", + "deleteDeviceConfirmTitle": "刪除「{{device}}」?", + "deleteDeviceConfirmDescription": "此操作會永久刪除這台模擬器,以及其中儲存的所有 App、帳號與資料。此操作無法復原。", + "deleteRunningDeviceConfirmDescription": "這台模擬器正在執行。確認後,Cindy 會先清理相關資源並停止裝置,然後永久刪除其中的所有 App、帳號與資料。此操作無法復原。", "attachedDescription": "此裝置已繫結到當前 Cindy 任務。", "agentControlTitle": "Agent 控制", "agentControlDescription": "允許後,Agent 才能啟動、停止或操作這臺裝置。", diff --git a/apps/desktop/src/shared/iosSimulatorIpc.ts b/apps/desktop/src/shared/iosSimulatorIpc.ts index 54377f8e72a..ecd1c9c1571 100644 --- a/apps/desktop/src/shared/iosSimulatorIpc.ts +++ b/apps/desktop/src/shared/iosSimulatorIpc.ts @@ -133,7 +133,7 @@ export interface IOSSimulatorAccessRequestResult { * Renderer-owned simulator actions. Agent-only build, install, URL, push, media, * and diagnostic tools must stay behind the MCP approval/control boundary. */ -export const IOS_SIMULATOR_RENDERER_TOOL_NAMES = [ +const IOS_SIMULATOR_RENDERER_MCP_TOOL_NAMES = [ 'attach_device', 'detach_device', 'start_instance', @@ -147,6 +147,13 @@ export const IOS_SIMULATOR_RENDERER_TOOL_NAMES = [ 'unlock_screen', ] as const satisfies readonly IOSSimulatorMcpToolName[]; +export const IOS_SIMULATOR_RENDERER_TOOL_NAMES = [ + ...IOS_SIMULATOR_RENDERER_MCP_TOOL_NAMES, + // Host-only destructive lifecycle action. Keep it out of the MCP registry: + // the trusted Renderer confirmation flow is its sole caller. + 'delete_instance', +] as const; + export type IOSSimulatorRendererToolName = (typeof IOS_SIMULATOR_RENDERER_TOOL_NAMES)[number]; export interface IOSSimulatorToolRequest { diff --git a/packages/ios-simulator-runtime/src/instance-actor.test.ts b/packages/ios-simulator-runtime/src/instance-actor.test.ts index bf6ab6fa82c..3b22b9de7a1 100644 --- a/packages/ios-simulator-runtime/src/instance-actor.test.ts +++ b/packages/ios-simulator-runtime/src/instance-actor.test.ts @@ -6,6 +6,7 @@ import { IOSSimulatorCreateCleanupRequiredError, type IOSSimulatorSimctlLifecycle, } from "./simctl-lifecycle.js"; +import type { IOSSimulatorInstance } from "./instance-types.js"; import type { IOSSimulatorDevice } from "./types.js"; const UDID = "1A9D41E0-E031-4AD0-A8B5-847480802E8E"; @@ -27,6 +28,7 @@ function createHarness( booted?: boolean; cindy?: boolean; onDetachCleanupError?: (error: unknown) => void; + onStoreChange?: (instances: IOSSimulatorInstance[]) => void; } = {}, ) { let now = 1_000; @@ -35,10 +37,11 @@ function createHarness( clock: { now: () => now }, createId: () => `id-${++id}`, leaseDurationMs: 1_000_000, + onChange: options.onStoreChange, }); const scheduled: Array<() => void | Promise> = []; const lifecycle: IOSSimulatorSimctlLifecycle = { - findExact: vi.fn(), + findExact: vi.fn(async () => DEVICE), bootExact: vi.fn(async () => ({ ...DEVICE, state: "Booted" })), shutdownExact: vi.fn(async () => undefined), createExact: vi.fn(), @@ -941,6 +944,37 @@ describe("IOSSimulatorInstanceActor", () => { ); }); + it("releases ownership on retry when the Cindy simulator was already physically deleted", async () => { + let failOwnershipWrite = false; + let physicalDeviceExists = true; + const harness = createHarness({ + cindy: true, + onStoreChange: () => { + if (failOwnershipWrite) throw new Error("registry write failed"); + }, + }); + vi.mocked(harness.lifecycle.findExact).mockImplementation(async () => + physicalDeviceExists ? DEVICE : null, + ); + vi.mocked(harness.lifecycle.deleteExact).mockImplementation(async () => { + physicalDeviceExists = false; + }); + + failOwnershipWrite = true; + await expect(harness.actor.delete(harness.route())).rejects.toThrow( + "registry write failed", + ); + expect(harness.lifecycle.deleteExact).toHaveBeenCalledTimes(1); + expect(harness.store.get(harness.instance.instanceId)).not.toBeNull(); + + failOwnershipWrite = false; + await harness.actor.delete(harness.route()); + + expect(harness.lifecycle.findExact).toHaveBeenCalledTimes(2); + expect(harness.lifecycle.deleteExact).toHaveBeenCalledTimes(1); + expect(harness.store.get(harness.instance.instanceId)).toBeNull(); + }); + it("creates a Cindy-owned simulator from an exact installed template", async () => { const harness = createHarness(); const createdUdid = "2A9D41E0-E031-4AD0-A8B5-847480802E8E"; diff --git a/packages/ios-simulator-runtime/src/instance-actor.ts b/packages/ios-simulator-runtime/src/instance-actor.ts index e0c2eabd541..2a8bd6b2736 100644 --- a/packages/ios-simulator-runtime/src/instance-actor.ts +++ b/packages/ios-simulator-runtime/src/instance-actor.ts @@ -1408,18 +1408,27 @@ export class IOSSimulatorInstanceActor { ); } this.#assertMutationAllowed?.(); - if (instance.lifecycleState === "ready") { - await this.#lifecycle.shutdownExact( + const device = await this.#lifecycle.findExact( + instance.simulatorUdid, + this.#lifecycleExitController.signal, + ); + this.#throwIfLifecycleExitCancelled(); + // Physical deletion may have succeeded before ownership persistence + // failed. A retry must still release that exact Cindy-owned binding. + if (device !== null) { + if (instance.lifecycleState === "ready") { + await this.#lifecycle.shutdownExact( + instance.simulatorUdid, + this.#lifecycleExitController.signal, + ); + this.#throwIfLifecycleExitCancelled(); + } + await this.#lifecycle.deleteExact( instance.simulatorUdid, this.#lifecycleExitController.signal, ); this.#throwIfLifecycleExitCancelled(); } - await this.#lifecycle.deleteExact( - instance.simulatorUdid, - this.#lifecycleExitController.signal, - ); - this.#throwIfLifecycleExitCancelled(); this.#cancelDetachGrace(instance.instanceId); return this.#store.release(instance.instanceId, instance.sessionId); });