-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(simulator): extract errors and device catalog (#708 1/4) #738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
dd5f3a4
refactor(simulator): extract errors and device catalog modules (#708 …
shaun0927 d935982
refactor(simulator): harden simctl JSON parsing and fuzzy resolver
shaun0927 7f09fe5
refactor(simulator): extract lifecycle module (#708 2/4)
shaun0927 45ef297
refactor(simulator): keep shutdown best-effort after nuclear erase
shaun0927 3009414
refactor(simulator): extract app manager module (#708 3/4)
shaun0927 755cfc5
fix(simulator): tighten bundleId regex and centralize not-installed d…
shaun0927 94ade8b
fix(simulator): strip all trailing bracket groups in launchctl labels…
shaun0927 69567a6
refactor(simulator): extract UI controller and finalize facade (#708 …
shaun0927 269c0c3
fix(simulator): hoist promisify and route simctl rotate through deps.…
shaun0927 8d9fee7
refactor(simulator): extract UI controller and finalize facade (#708 …
shaun0927 f8b495f
refactor(simulator): extract lifecycle module (#708 2/4) (#740)
shaun0927 006a125
refactor(simulator): extract app manager module (#708 3/4) (#742)
shaun0927 88c62cc
Merge remote-tracking branch 'origin/refactor/708b-lifecycle' into he…
hermes-agent 77bfa68
Merge develop into refactor/708a-errors-catalog
shaun0927 86a1920
fix(tests, audit): align with develop's HTTP transport hardening
shaun0927 f346491
fix(test): assert flutter-evaluate audit on redacted len=N format
shaun0927 f66aa3f
Preserve audit secrecy while landing simulator decomposition
shaun0927 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| /** | ||
| * App lifecycle operations — install, launch, terminate, uninstall, | ||
| * activate, list running apps, and reset app state. | ||
| * | ||
| * Extracted from SimulatorManager as part of #708 (step 4). | ||
| * All functions take injected dependencies for testability. | ||
| * | ||
| * Behavior is strictly preserved from the original manager: | ||
| * - launchApp: maps simctl launch output to pid; surfaces AppNotInstalledError | ||
| * for "domain not found" / "not installed"; wraps other errors in AppLaunchError. | ||
| * - terminateApp: returns terminated:false (not error) when app is not running; | ||
| * surfaces AppNotInstalledError for "domain not found". | ||
| * - activateApp: uses simctl launch to bring running app to foreground, or | ||
| * starts it if not running. | ||
| * - listRunningApps: parses launchctl list UIKitApplication entries. | ||
| * - resetApp: terminate → privacy reset → uninstall (caller reinstalls). | ||
| */ | ||
|
|
||
| import { SimctlError } from './simctl'; | ||
| import { DeviceNotBootedError, AppNotInstalledError, AppLaunchError } from './errors'; | ||
| import { SimulatorDevice } from './types'; | ||
|
|
||
| /** | ||
| * Returns true when the simctl error message indicates the bundle is not installed. | ||
| * Centralises detection of "domain not found" / "not installed" patterns used by | ||
| * launchApp, terminateApp, activateApp, and resetApp. | ||
| */ | ||
| function isAppNotInstalledError(err: SimctlError): boolean { | ||
| return err.message.includes('domain not found') || err.message.includes('not installed'); | ||
| } | ||
|
|
||
| /** Minimal simctl interface the app-manager functions depend on. */ | ||
| export interface AppManagerSimctl { | ||
| exec(args: string[], options?: { timeout?: number; env?: Record<string, string> }): Promise<string>; | ||
| } | ||
|
|
||
| /** Minimal device-lookup interface the app-manager functions depend on. */ | ||
| export interface AppManagerDeviceLookup { | ||
| getDevice(deviceId: string): Promise<SimulatorDevice | null>; | ||
| } | ||
|
|
||
| /** | ||
| * Launch an app on a booted simulator. | ||
| * Returns the pid parsed from simctl output. | ||
| * Throws DeviceNotBootedError if the device is not booted. | ||
| * Throws AppNotInstalledError if the bundle is not installed. | ||
| * Throws AppLaunchError for any other launch failure. | ||
| */ | ||
| export async function launchApp( | ||
| deviceId: string, | ||
| bundleId: string, | ||
| options: { args?: string[]; env?: Record<string, string> } | undefined, | ||
| deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, | ||
| ): Promise<{ pid: number; bundleId: string; deviceId: string }> { | ||
| const device = await deps.lookup.getDevice(deviceId); | ||
| if (!device || device.state !== 'Booted') { | ||
| throw new DeviceNotBootedError(deviceId); | ||
| } | ||
|
|
||
| const cmdArgs = ['launch', deviceId, bundleId]; | ||
| if (options?.args) { | ||
| cmdArgs.push(...options.args); | ||
| } | ||
|
|
||
| // simctl passes SIMCTL_CHILD_* env vars to the launched app | ||
| const childEnv: Record<string, string> = {}; | ||
| if (options?.env) { | ||
| for (const [key, value] of Object.entries(options.env)) { | ||
| childEnv[`SIMCTL_CHILD_${key}`] = value; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const output = await deps.simctl.exec(cmdArgs, { env: childEnv }); | ||
| const pidMatch = output.match(/:\s*(\d+)/); | ||
| const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; | ||
| return { pid, bundleId, deviceId }; | ||
| } catch (err) { | ||
| if (err instanceof SimctlError) { | ||
| if (isAppNotInstalledError(err)) { | ||
| throw new AppNotInstalledError(bundleId, deviceId); | ||
| } | ||
| } | ||
| throw new AppLaunchError(bundleId, deviceId, err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Terminate a running app on a booted simulator. | ||
| * Returns terminated:false (not an error) when the app is not running. | ||
| * Throws DeviceNotBootedError if the device is not booted. | ||
| * Throws AppNotInstalledError if the bundle is not installed. | ||
| */ | ||
| export async function terminateApp( | ||
| deviceId: string, | ||
| bundleId: string, | ||
| deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, | ||
| ): Promise<{ terminated: boolean; bundleId: string; deviceId: string }> { | ||
| const device = await deps.lookup.getDevice(deviceId); | ||
| if (!device || device.state !== 'Booted') { | ||
| throw new DeviceNotBootedError(deviceId); | ||
| } | ||
|
|
||
| try { | ||
| await deps.simctl.exec(['terminate', deviceId, bundleId]); | ||
| return { terminated: true, bundleId, deviceId }; | ||
| } catch (err) { | ||
| if (err instanceof SimctlError) { | ||
| if (err.message.includes('not running') || err.message.includes('Failed to terminate')) { | ||
| return { terminated: false, bundleId, deviceId }; | ||
| } | ||
| if (isAppNotInstalledError(err)) { | ||
| throw new AppNotInstalledError(bundleId, deviceId); | ||
| } | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Activate (bring to foreground) an app on a booted simulator. | ||
| * simctl launch brings an already-running app to the foreground; | ||
| * if the app is not running it starts it. | ||
| * Throws DeviceNotBootedError if the device is not booted. | ||
| * Throws AppNotInstalledError if the bundle is not installed. | ||
| */ | ||
| export async function activateApp( | ||
| deviceId: string, | ||
| bundleId: string, | ||
| deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, | ||
| ): Promise<{ activated: boolean; bundleId: string; deviceId: string; pid: number }> { | ||
| const device = await deps.lookup.getDevice(deviceId); | ||
| if (!device || device.state !== 'Booted') { | ||
| throw new DeviceNotBootedError(deviceId); | ||
| } | ||
|
|
||
| try { | ||
| const output = await deps.simctl.exec(['launch', deviceId, bundleId]); | ||
| const pidMatch = output.match(/:\s*(\d+)/); | ||
| const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1; | ||
| return { activated: true, bundleId, deviceId, pid }; | ||
| } catch (err) { | ||
| if (err instanceof SimctlError) { | ||
| if (isAppNotInstalledError(err)) { | ||
| throw new AppNotInstalledError(bundleId, deviceId); | ||
| } | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * List running foreground apps by parsing launchctl list UIKitApplication entries. | ||
| * Throws DeviceNotBootedError if the device is not booted. | ||
| */ | ||
| export async function listRunningApps( | ||
| deviceId: string, | ||
| deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, | ||
| ): Promise<Array<{ label: string; pid: number }>> { | ||
| const device = await deps.lookup.getDevice(deviceId); | ||
| if (!device || device.state !== 'Booted') { | ||
| throw new DeviceNotBootedError(deviceId); | ||
| } | ||
|
|
||
| const output = await deps.simctl.exec(['spawn', deviceId, 'launchctl', 'list']); | ||
| const lines = output.split('\n'); | ||
| const apps: Array<{ label: string; pid: number }> = []; | ||
|
|
||
| for (const line of lines) { | ||
| const parts = line.split('\t'); | ||
| if (parts.length < 3) continue; | ||
| const pid = parseInt(parts[0], 10); | ||
| const label = parts[2]; | ||
| // Filter for UIKitApplication entries (running foreground apps) | ||
| if (!isNaN(pid) && pid > 0 && label.startsWith('UIKitApplication:')) { | ||
| const bundleId = label.replace('UIKitApplication:', '').replace(/(\[[^\]]*\])+$/, ''); | ||
| apps.push({ label: bundleId, pid }); | ||
| } | ||
| } | ||
|
|
||
| return apps; | ||
| } | ||
|
|
||
| /** | ||
| * Reset app state on a simulator. | ||
| * Strategy: terminate app, reset privacy permissions, clear app data container. | ||
| * Note: simctl has no "clear data" command; the documented strategy is | ||
| * uninstall + reinstall. We uninstall here; the caller can reinstall. | ||
| * Throws DeviceNotBootedError if the device is not booted. | ||
| * Throws AppNotInstalledError if uninstall confirms the bundle is not installed. | ||
| */ | ||
| export async function resetApp( | ||
| deviceId: string, | ||
| bundleId: string, | ||
| deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup }, | ||
| ): Promise<{ reset: boolean; bundleId: string; deviceId: string; steps: string[] }> { | ||
| const device = await deps.lookup.getDevice(deviceId); | ||
| if (!device || device.state !== 'Booted') { | ||
| throw new DeviceNotBootedError(deviceId); | ||
| } | ||
|
|
||
| const steps: string[] = []; | ||
|
|
||
| // Step 1: Terminate the app if running | ||
| try { | ||
| await deps.simctl.exec(['terminate', deviceId, bundleId]); | ||
| steps.push('terminated'); | ||
| } catch { | ||
| steps.push('terminate_skipped'); | ||
| } | ||
|
|
||
| // Step 2: Reset privacy permissions | ||
| try { | ||
| await deps.simctl.exec(['privacy', deviceId, 'reset', 'all', bundleId]); | ||
| steps.push('privacy_reset'); | ||
| } catch { | ||
| steps.push('privacy_reset_skipped'); | ||
| } | ||
|
|
||
| // Step 3: Uninstall and note (cannot clear data container directly) | ||
| try { | ||
| await deps.simctl.exec(['uninstall', deviceId, bundleId]); | ||
| steps.push('uninstalled'); | ||
| } catch (err) { | ||
| if (err instanceof SimctlError) { | ||
| if (isAppNotInstalledError(err)) { | ||
| throw new AppNotInstalledError(bundleId, deviceId); | ||
| } | ||
| } | ||
| steps.push('uninstall_failed'); | ||
| } | ||
|
|
||
| return { reset: true, bundleId, deviceId, steps }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { SimctlExecutor } from './simctl'; | ||
| import { SimulatorDevice, SimulatorRuntime } from './types'; | ||
| import { DEVICE_PRESETS } from './presets'; | ||
| import { DeviceNotFoundError } from './errors'; | ||
|
|
||
| interface SimctlListResult { | ||
| // `simctl list devices -j` returns only `devices`; `simctl list runtimes -j` | ||
| // returns only `runtimes`. Treat both as optional so a missing key is not a crash. | ||
| devices?: Record<string, Array<{ | ||
| udid: string; | ||
| name: string; | ||
| state: string; | ||
| isAvailable: boolean; | ||
| }>>; | ||
| runtimes?: Array<{ | ||
| identifier: string; | ||
| version: string; | ||
| isAvailable: boolean; | ||
| platform: string; | ||
| }>; | ||
| } | ||
|
|
||
| export async function listDevices(simctl: SimctlExecutor): Promise<SimulatorDevice[]> { | ||
| const result = await simctl.execJson<SimctlListResult>(['list', 'devices']); | ||
| const devices: SimulatorDevice[] = []; | ||
|
|
||
| for (const [runtimeId, deviceList] of Object.entries(result.devices ?? {})) { | ||
| const version = runtimeId.match(/iOS-(\d+)-(\d+)/); | ||
| const runtimeVersion = version ? `${version[1]}.${version[2]}` : 'unknown'; | ||
|
|
||
| for (const device of deviceList) { | ||
| if (device.isAvailable) { | ||
| devices.push({ | ||
| udid: device.udid, | ||
| name: device.name, | ||
| state: device.state as SimulatorDevice['state'], | ||
| isAvailable: device.isAvailable, | ||
| runtime: runtimeId, | ||
| runtimeVersion, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return devices; | ||
| } | ||
|
|
||
| export async function listRuntimes(simctl: SimctlExecutor): Promise<SimulatorRuntime[]> { | ||
| const result = await simctl.execJson<SimctlListResult>(['list', 'runtimes']); | ||
| return (result.runtimes ?? []).filter(r => r.isAvailable); | ||
| } | ||
|
|
||
| export async function getDevice(simctl: SimctlExecutor, deviceId: string): Promise<SimulatorDevice | null> { | ||
| const devices = await listDevices(simctl); | ||
| return devices.find(d => d.udid === deviceId) ?? null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a preset key or device name to an actual device. | ||
| * Tries: exact UDID match → preset name match → fuzzy name match | ||
| */ | ||
| export async function resolveDevice(simctl: SimctlExecutor, presetKey: string): Promise<SimulatorDevice> { | ||
| const devices = await listDevices(simctl); | ||
|
|
||
| // 1. Exact UDID match | ||
| const byUdid = devices.find(d => d.udid === presetKey); | ||
| if (byUdid) return byUdid; | ||
|
|
||
| // 2. Preset name match | ||
| const preset = DEVICE_PRESETS[presetKey]; | ||
| if (preset) { | ||
| const exact = devices.find(d => d.name === preset.name); | ||
| if (exact) return exact; | ||
| } | ||
|
|
||
| // 3. Case-insensitive substring match | ||
| const lower = presetKey.toLowerCase(); | ||
| const substring = devices.find(d => d.name.toLowerCase().includes(lower)); | ||
| if (substring) return substring; | ||
|
|
||
| // 4. Fuzzy: split words, match all keywords. Filter empty tokens so that | ||
| // whitespace/separator-only input doesn't reduce to `every(kw => …includes(""))`, | ||
| // which would silently match the first available device. | ||
| const keywords = lower.split(/[\s-]+/).filter(Boolean); | ||
| if (keywords.length > 0) { | ||
| const fuzzy = devices.find(d => { | ||
| const dLower = d.name.toLowerCase(); | ||
| return keywords.every(kw => dLower.includes(kw)); | ||
| }); | ||
| if (fuzzy) return fuzzy; | ||
| } | ||
|
|
||
| throw new DeviceNotFoundError(presetKey, devices.map(d => d.name)); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep
textandvalueinSENSITIVE_KEYS; removing them causes raw user-entered form content to be written toargs_summaryin audit logs. MCP tool payloads commonly carry credentials/OTPs under keys liketype.textandselect_option.value, so this change reintroduces a concrete secret-leak path even when other credential-named fields are redacted.Useful? React with 👍 / 👎.