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 e33ab562b71..5275b77b7ba 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/iosSimulatorHandlers.test.ts @@ -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, @@ -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(); diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index 7df09fed6b4..04920e7fe7a 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -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', diff --git a/apps/desktop/src/main/maker-ipc/iosSimulatorHandlers.ts b/apps/desktop/src/main/maker-ipc/iosSimulatorHandlers.ts index 59a625c9943..11e4c240dda 100644 --- a/apps/desktop/src/main/maker-ipc/iosSimulatorHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/iosSimulatorHandlers.ts @@ -4,6 +4,7 @@ import { clipboard, nativeImage } from 'electron'; import type { IOSSimulatorNativeH264StreamProfileRequest, + IOSSimulatorPreferences, IOSSimulatorRendererToolName, IOSSimulatorSessionStatus, IOSSimulatorToolResponse, @@ -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, @@ -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' @@ -56,6 +63,8 @@ type IOSSimulatorIpcOperation = | 'live-touch'; const IOS_SIMULATOR_SAFE_IPC_MESSAGES: Record = { + '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.', @@ -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; getSessionAccess( target: IOSSimulatorRendererWebContents, ): IOSSimulatorRendererAccessSnapshot | null; @@ -175,6 +186,8 @@ const defaultDeps: IOSSimulatorHandlerDeps = { getSessionContext: async () => null, getOwnerScopeKey: activeOwnerScopeKey, isOwnerBoundaryPending: isAppSessionBoundaryPending, + getPreferences: readIOSSimulatorPreferences, + setAutoOpenEmbeddedPanel: writeIOSSimulatorAutoOpenEmbeddedPanel, getSessionAccess: getIOSSimulatorRendererSessionAccess, getViewerAccess: getIOSSimulatorRendererViewerAccess, hasViewerAccess: hasIOSSimulatorRendererViewerAccess, @@ -340,6 +353,31 @@ function readSenderWebContents(event: unknown): IOSSimulatorRendererWebContents return sender as IOSSimulatorRendererWebContents; } +async function callIOSSimulatorPreferences( + deps: IOSSimulatorHandlerDeps, + operation: 'get-preferences' | 'set-preferences', + call: () => T | Promise, +): Promise { + 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 = {}, @@ -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); diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator-preferences.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator-preferences.test.ts new file mode 100644 index 00000000000..921e3f7b130 --- /dev/null +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator-preferences.test.ts @@ -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 }); + }); +}); 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 9e9e0f97b97..25c36513222 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 @@ -8943,6 +8943,7 @@ describe('iOS Simulator host', () => { openUrlExact: vi.fn(async () => undefined), }; const requestViewerFocus = vi.fn(); + let autoOpenViewer = true; const host = createIOSSimulatorHost({ actor, driverManager, @@ -8950,6 +8951,7 @@ describe('iOS Simulator host', () => { 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), @@ -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'); @@ -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, @@ -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) => diff --git a/apps/desktop/src/main/mcp-integrations/ios-simulator-preferences.ts b/apps/desktop/src/main/mcp-integrations/ios-simulator-preferences.ts new file mode 100644 index 00000000000..93ab90a1fa9 --- /dev/null +++ b/apps/desktop/src/main/mcp-integrations/ios-simulator-preferences.ts @@ -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).autoOpenEmbeddedPanel; + return { + autoOpenEmbeddedPanel: typeof value === 'boolean' ? value : DEFAULTS.autoOpenEmbeddedPanel, + }; +} + +export interface IOSSimulatorPreferencesStore { + read(): IOSSimulatorPreferences; + writeAutoOpenEmbeddedPanel(enabled: boolean): Promise; +} + +export function createIOSSimulatorPreferencesStore(options: { + filePath: () => string; + scopeKey?: () => string; +}): IOSSimulatorPreferencesStore { + const store = createOverrideSettingsFile({ + 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 { + return ownerStore.writeAutoOpenEmbeddedPanel(enabled); +} + +export const __testing = { normalize, DEFAULTS }; diff --git a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts index a71db40d26c..60978e44124 100644 --- a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts +++ b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts @@ -113,6 +113,7 @@ import { import { resolveIOSSimulatorDesktopAdmissionPolicy } from './ios-simulator-admission.js'; import { registerIOSSimulatorExitAbortHandler } from './ios-simulator-exit.js'; import { compareIOSSimulatorPngBuffers, IOSSimulatorMediaCapture } from './ios-simulator-media.js'; +import { readIOSSimulatorPreferences } from './ios-simulator-preferences.js'; import { clearIOSSimulatorRendererAccess, configureIOSSimulatorRendererAccessRevocationObserver, @@ -555,6 +556,8 @@ export interface IOSSimulatorHostOptions { withSessionLock?: (sessionId: string, task: () => Promise) => Promise; resolveWorktreeRoot?: (workDir: string) => Promise; requestViewerFocus?: (sessionId: string, instanceId: string) => void; + /** Owner preference gate for automatic presentation only; explicit focus bypasses it. */ + shouldAutoOpenViewer?: () => boolean; /** Main → renderer route diagnostics seam; injected in tests, broadcast by default. */ pushRouteStatus?: (status: IOSSimulatorPublicRouteStatus) => void; /** Main → exact owning viewer frame seam; injected in tests, fail-closed by default. */ @@ -2285,6 +2288,11 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I }); } }); + const shouldAutoOpenViewer = options.shouldAutoOpenViewer ?? (() => true); + const requestAutomaticViewerFocus = (sessionId: string, instanceId: string): void => { + if (!shouldAutoOpenViewer()) return; + requestViewerFocus(sessionId, instanceId); + }; async function resolveSession( sessionId: string, @@ -5213,7 +5221,7 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I viewerSessions.delete(instance.instanceId); viewerVisibilityIntents.delete(instance.instanceId); clearViewportState(instance.instanceId); - requestViewerFocus(sessionId, instance.instanceId); + requestAutomaticViewerFocus(sessionId, instance.instanceId); publishRouteStatusForInstance(instance, getDriverManager().get(instance.instanceId)); return { ok: true, @@ -6647,7 +6655,7 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I } finally { unpinArtifact(artifactId); } - requestViewerFocus(sessionId, route.instanceId); + requestAutomaticViewerFocus(sessionId, route.instanceId); return { ok: true, data: { artifactId, launched: true } }; } if (name === 'terminate_app') { @@ -7388,6 +7396,7 @@ function installDefaultIOSSimulatorHost( canReconcilePendingCreates: persistedActor.canReconcilePendingCreates, pendingCreateEvidence, driverManager: createDefaultDriverManager(), + shouldAutoOpenViewer: () => readIOSSimulatorPreferences().autoOpenEmbeddedPanel, }); configureIOSSimulatorRendererAccessRevocationObserver((grants) => { for (const grant of grants) { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 18c1eee7a8a..3bec8c997de 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -221,6 +221,7 @@ import type { IOSSimulatorAccessRequestResult, IOSSimulatorCopyScreenshotRequest, IOSSimulatorCopyScreenshotResult, + IOSSimulatorPreferences, IOSSimulatorSessionStatus, IOSSimulatorAgentControlRequest, IOSSimulatorFocusRequest, @@ -7135,6 +7136,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('maker:android:prepare-adb'), }, iosSimulator: { + getPreferences: (): Promise => + ipcRenderer.invoke('maker:ios-simulator:get-preferences'), + setAutoOpenEmbeddedPanel: (enabled: boolean): Promise => + ipcRenderer.invoke('maker:ios-simulator:set-auto-open-embedded-panel', { enabled }), requestAccess: ( request: IOSSimulatorAccessRequest, ): Promise => diff --git a/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx b/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx index c876bf71e73..37ca2b88457 100644 --- a/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx +++ b/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx @@ -2,8 +2,8 @@ * Plugin detail presentation for configuration, Tools, permissions, and factual metadata. * * Inputs: the renderer-safe Plugin detail model plus the installed Ghost when available. - * Outputs: accessible detail interactions, a single-row responsive action hero, and the sticky - * top bar that carries the back affordance plus this page's macOS window-drag region. + * Outputs: accessible detail interactions, Host-owned configuration rows, a single-row responsive + * action hero, and the sticky top bar carrying the back affordance and macOS window-drag region. * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -70,6 +70,7 @@ import { } from '../../../shared/ghost'; import { type GhostPluginDetail } from './lib/ghostPluginViewModel'; import { GhostPluginIcon } from './GhostPluginIcon'; +import { IOSSimulatorPreferences } from './IOSSimulatorPreferences'; import { ghostPluginSummary } from './lib/ghostPluginDetailModel'; import { ghostPrimaryAction } from './lib/ghostPluginViewModel'; import { PluginDetailTopBar, usePluginDetailScrolled } from './PluginDetailTopBar'; @@ -191,7 +192,11 @@ export function GhostPluginDetailView({ (primaryAction === 'command' && detail.canUse)); const cindyCapabilities = detail.cindyCapabilities; const hasConfiguration = - detail.hasMainView || detail.hasSettingsUi || cindyCapabilities.length > 0 || detail.hasErrand; + detail.hasMainView || + detail.hasSettingsUi || + detail.hostCapability === 'ios-simulator' || + cindyCapabilities.length > 0 || + detail.hasErrand; const summary = ghostPluginSummary(detail.description, detail.id); /** * 「从 .cindy 文件更新」是否可用。官方保留前缀(cindy- / filo- / xd-)在**非 dev @@ -499,6 +504,7 @@ export function GhostPluginDetailView({ title={t('settings.ghosts.detail.configurationTitle')} />
+ {detail.hostCapability === 'ios-simulator' ? : null} {detail.hasMainView ? (
{ + let cancelled = false; + void window.electronAPI.maker.iosSimulator + .getPreferences() + .then((preferences) => { + if (cancelled) return; + setEnabled(preferences.autoOpenEmbeddedPanel); + setReady(true); + }) + .catch((error) => { + if (cancelled) return; + log.warn('Failed to load iOS Simulator preferences', error); + toast.error(genericError); + }); + return () => { + cancelled = true; + }; + }, [genericError]); + + const handleToggle = useCallback( + async (next: boolean) => { + const previous = enabled; + setEnabled(next); + setPending(true); + try { + const preferences = + await window.electronAPI.maker.iosSimulator.setAutoOpenEmbeddedPanel(next); + setEnabled(preferences.autoOpenEmbeddedPanel); + } catch (error) { + log.warn('Failed to update iOS Simulator preferences', error); + setEnabled(previous); + toast.error(genericError); + } finally { + setPending(false); + } + }, + [enabled, genericError], + ); + + return ( +
+
+ ); +} diff --git a/apps/desktop/src/renderer/features/plugin/__tests__/GhostPluginDetailSections.test.tsx b/apps/desktop/src/renderer/features/plugin/__tests__/GhostPluginDetailSections.test.tsx index de3d726f62a..638c999013d 100644 --- a/apps/desktop/src/renderer/features/plugin/__tests__/GhostPluginDetailSections.test.tsx +++ b/apps/desktop/src/renderer/features/plugin/__tests__/GhostPluginDetailSections.test.tsx @@ -5,7 +5,7 @@ */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); @@ -54,6 +54,10 @@ vi.mock('react-i18next', () => ({ 'settings.defaults.restore': 'Restore default', 'settings.ghosts.detail.oauthScopeStale': 'This authorization does not include newly added permissions. Reconnect to enable them.', + 'settings.ghosts.detail.iosSimulatorAutoOpenTitle': + 'Open the embedded Simulator panel automatically', + 'settings.ghosts.detail.iosSimulatorAutoOpenDescription': + 'Automatically reveal the right-side Simulator panel.', }; return labels[key] ?? key; }, @@ -138,6 +142,22 @@ const detail: GhostPluginDetail = { }, }; +beforeEach(() => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + maker: { + iosSimulator: { + getPreferences: vi.fn(async () => ({ autoOpenEmbeddedPanel: true })), + setAutoOpenEmbeddedPanel: vi.fn(async (enabled: boolean) => ({ + autoOpenEmbeddedPanel: enabled, + })), + }, + }, + }, + }); +}); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -328,7 +348,7 @@ describe('Ghost plugin detail sections', () => { expect(detailActions?.className).toContain('flex-nowrap'); }); - it('renders an enabled Host capability as a conversation action', () => { + it('renders an enabled Host capability as a conversation action', async () => { vi.stubGlobal( 'ResizeObserver', class { @@ -355,6 +375,63 @@ describe('Ghost plugin detail sections', () => { fireEvent.click(screen.getByRole('button', { name: 'settings.ghosts.detail.chatAction' })); expect(onUse).toHaveBeenCalledTimes(1); + await waitFor(() => { + const preference = screen.getByRole('switch', { + name: 'Open the embedded Simulator panel automatically', + }); + expect((preference as HTMLButtonElement).disabled).toBe(false); + }); + }); + + it('shows and updates the Host-owned Simulator auto-open preference', async () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const getPreferences = vi.fn(async () => ({ autoOpenEmbeddedPanel: false })); + const setAutoOpenEmbeddedPanel = vi.fn(async (enabled: boolean) => ({ + autoOpenEmbeddedPanel: enabled, + })); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + maker: { + iosSimulator: { getPreferences, setAutoOpenEmbeddedPanel }, + }, + }, + }); + + render( + , + ); + + const toggle = await screen.findByRole('switch', { + name: 'Open the embedded Simulator panel automatically', + }); + await waitFor(() => { + expect(toggle.getAttribute('data-state')).toBe('unchecked'); + expect((toggle as HTMLButtonElement).disabled).toBe(false); + }); + + fireEvent.click(toggle); + await waitFor(() => expect(setAutoOpenEmbeddedPanel).toHaveBeenCalledWith(true)); + await waitFor(() => expect(toggle.getAttribute('data-state')).toBe('checked')); + expect(screen.getByText('Automatically reveal the right-side Simulator panel.')).toBeTruthy(); }); it('routes a projected detail icon failure to market recovery', () => { diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index f74d073c91a..a5d6a11b2f3 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -4951,6 +4951,8 @@ "sidebarEntryTitle": "Sidebar entry", "showInSidebar": "Show in sidebar", "sidebarEntryDescription": "When turned on, “{{title}}” appears in the sidebar. Hiding it does not disable the plugin.", + "iosSimulatorAutoOpenTitle": "Open the embedded Simulator panel automatically", + "iosSimulatorAutoOpenDescription": "Show the Simulator in the right sidebar after starting it or launching an app. When off, you can still open it manually; all other features are unchanged.", "openTool": "View details for Tool {{name}}", "noToolDescription": "No description is available for this Tool.", "viewAllTools": "See All", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index e36972f39c5..50ac3e21f91 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -4950,6 +4950,8 @@ "sidebarEntryTitle": "サイドバー項目", "showInSidebar": "サイドバーに表示", "sidebarEntryDescription": "オンにすると、サイドバーに「{{title}}」が表示されます。非表示にしてもプラグインは無効になりません。", + "iosSimulatorAutoOpenTitle": "内蔵シミュレータパネルを自動的に開く", + "iosSimulatorAutoOpenDescription": "シミュレータの起動時または App の起動後に、右側へシミュレータ画面を自動表示します。オフでも手動で開くことができ、ほかの機能には影響しません。", "openTool": "Tool {{name}} の詳細を表示", "noToolDescription": "この Tool には説明がありません。", "viewAllTools": "さらに表示", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 625ab095307..a4fa9a4ac80 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -4950,6 +4950,8 @@ "sidebarEntryTitle": "사이드바 항목", "showInSidebar": "사이드바에 표시", "sidebarEntryDescription": "켜면 사이드바에 “{{title}}” 항목이 표시됩니다. 숨겨도 플러그인이 비활성화되지는 않습니다.", + "iosSimulatorAutoOpenTitle": "내장 시뮬레이터 패널 자동 열기", + "iosSimulatorAutoOpenDescription": "시뮬레이터를 시작하거나 App을 실행한 뒤 오른쪽에 시뮬레이터 화면을 자동으로 표시합니다. 꺼도 수동으로 열 수 있으며 다른 기능에는 영향을 주지 않습니다.", "openTool": "Tool {{name}} 세부 정보 보기", "noToolDescription": "이 Tool에는 설명이 없습니다.", "viewAllTools": "더 보기", 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 b181b6e298f..6af2712348f 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -4950,6 +4950,8 @@ "sidebarEntryTitle": "侧边栏入口", "showInSidebar": "显示在侧边栏", "sidebarEntryDescription": "开启后,侧边栏会显示“{{title}}”。隐藏入口不会停用插件。", + "iosSimulatorAutoOpenTitle": "自动打开内置模拟器面板", + "iosSimulatorAutoOpenDescription": "启动模拟器或打开 App 后,自动在右侧显示模拟器画面。关闭后仍可手动打开,其他功能不受影响。", "openTool": "查看 Tool {{name}} 的详情", "noToolDescription": "该 Tool 暂无描述。", "viewAllTools": "查看更多", 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 ed9960cfbad..84a82d5e742 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -4950,6 +4950,8 @@ "sidebarEntryTitle": "側邊欄入口", "showInSidebar": "顯示在側邊欄", "sidebarEntryDescription": "開啟後,側邊欄會顯示「{{title}}」。隱藏入口不會停用插件。", + "iosSimulatorAutoOpenTitle": "自動開啟內建模擬器面板", + "iosSimulatorAutoOpenDescription": "啟動模擬器或開啟 App 後,自動在右側顯示模擬器畫面。關閉後仍可手動開啟,其他功能不受影響。", "openTool": "檢視 Tool {{name}} 的詳情", "noToolDescription": "該 Tool 暫無描述。", "viewAllTools": "檢視更多", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 6fdebc86087..c4dd6698e72 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -38,6 +38,7 @@ type IOSSimulatorCopyScreenshotRequest = import('../shared/iosSimulatorIpc').IOSSimulatorCopyScreenshotRequest; type IOSSimulatorCopyScreenshotResult = import('../shared/iosSimulatorIpc').IOSSimulatorCopyScreenshotResult; +type IOSSimulatorPreferences = import('../shared/iosSimulatorIpc').IOSSimulatorPreferences; type IOSSimulatorStatusRequest = import('../shared/iosSimulatorIpc').IOSSimulatorStatusRequest; type IOSSimulatorToolRequest = import('../shared/iosSimulatorIpc').IOSSimulatorToolRequest; type IOSSimulatorToolResponse = import('../shared/iosSimulatorIpc').IOSSimulatorToolResponse; @@ -6392,6 +6393,8 @@ interface ElectronAPI { prepareAdb: () => Promise; }; iosSimulator: { + getPreferences: () => Promise; + setAutoOpenEmbeddedPanel: (enabled: boolean) => Promise; requestAccess: ( request: IOSSimulatorAccessRequest, ) => Promise; diff --git a/apps/desktop/src/shared/iosSimulatorIpc.ts b/apps/desktop/src/shared/iosSimulatorIpc.ts index 782c0f38378..f116862de7a 100644 --- a/apps/desktop/src/shared/iosSimulatorIpc.ts +++ b/apps/desktop/src/shared/iosSimulatorIpc.ts @@ -129,6 +129,11 @@ export interface IOSSimulatorAccessRequestResult { granted: boolean; } +/** Owner-scoped presentation preference. Simulator lifecycle and tool access are unaffected. */ +export interface IOSSimulatorPreferences { + autoOpenEmbeddedPanel: boolean; +} + /** * Renderer-owned simulator actions. Agent-only build, install, URL, push, media, * and diagnostic tools must stay behind the MCP approval/control boundary.