Skip to content

feat(limrun): change iOS settings on Limrun instances through simctl - #3209

Merged
thymikee merged 7 commits into
callstack:mainfrom
jbroma:feat/limrun-ios-settings
Oct 5, 2026
Merged

thymikee merged 7 commits into
callstack:mainfrom
jbroma:feat/limrun-ios-settings

Conversation

@jbroma

@jbroma jbroma commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Limrun iOS sessions refused every settings change. This serves appearance, permission, location, and clear-app-state on both attached and created instances. Each sends the local simulator's simctl argv through the instance's simctl call, with booted for the UDID, and a service the runtime refuses reports UNSUPPORTED_OPERATION as it does locally. clear-app-state uses Limrun's softReset(bundleId, { strategy: 'data' }), then terminates the app, because the local version edits the data container on the host. Limrun's reset relaunches the app once before that stop.

Limrun only allows some simctl commands. keychain isn't one of them, so reset-keychain stays unsupported with wifi, airplane, faceid, touchid, and text-size. Reading settings back stays unavailable.

agent-device settings appearance dark
agent-device settings permission grant camera

The simctl settings parsing moved to platform-apple/simctl-settings.ts, and the composition root hands it to the Limrun runtime (ADR 0019). 17 files, mostly test fakes gaining the new dependency.

Validation

Tested commit 4f43457:

  • pnpm check:affected --run: 2043 tests pass. The new tests assert literal argv, call order, exact missing-app messages, and typed failures. Each fails under its mutation.
  • Live on Limrun iOS through tester-army/e2e: app.clearState, device.setPermission, device.setAppearance, and device.setLocation pass. On main, all four fail with "settings is not supported".
  • Live through the client: appearance dark/toggle (read back), permission grant/deny/reset, all, photos limited/full, location set/on/off, and clear-app-state pass; notifications and reset-keychain refuse as unsupported.

@jbroma
jbroma requested a review from thymikee October 4, 2026 14:09
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

Thanks for the Limrun iOS settings work. The settings flow looks right, but 4f43457 fails the Coverage eager-closure gate, and two more changes are needed before merge.

The settings vocabulary now loads eagerly. simctl-facade.ts re-exports core/simctl-settings.ts (https://github.com/callstack/agent-device/blob/4f43457/packages/platform-apple/src/simctl-facade.ts#L7), which pulls in contracts/src/settings.ts and grows that closure from 17 to 19 modules. provider-limrun/src/ios.ts also imports contracts/settings and kernel/location-coordinates statically, which grows the provider closure from 29 to 31. Every daemon or SDK path that imports the facade or the Limrun provider now loads settings code even when no settings command runs. The rule: settings code loads only when a settings command runs. Please move the Limrun setSetting body into an ios-settings.ts module and load it with await import from LimrunIosInteractor.setSetting. Expose the Apple vocabulary through a dedicated subpath or a lazy loader in limrun-runtime-dependencies.ts, as resolveAppAlias already does. Please do not re-export it from the simctl facade.

The simctl settings plan now has two owners. setAppearance, toggledAppearance, setLocation and setPermission in ios.ts (https://github.com/callstack/agent-device/blob/4f43457/packages/provider-limrun/src/ios.ts#L398) repeat the app-settings.ts branches almost line for line. That includes the argv shapes, the missing-app messages and the "Unable to determine current iOS appearance for toggle" error. Only the device id (booted) and the executor differ. A later change to the local argv or the toggle rule will drift from Limrun without any test failing. The rule: the Apple package owns the setting-to-simctl plan for every simulator executor. Could simctl-settings export one function that takes an exec callback, udid, setting, state, bundle id and options, and map refusals itself? app-settings.ts would pass runSimctlForDevice and device.id. Limrun would pass client.simctl and booted.

Would that simplification also delete the five-member LimrunIosSettingsAdapter, the duplicated branches and the facade re-exports, and fix the closure growth in one step? As far as I can see, nothing has to change first, because platform-apple already exposes lazily imported subpaths to the composition root. Only clear-app-state (softReset plus terminate) would stay Limrun-local.

Not blocking, and you can take or leave these. ios-interactor-settings.test.ts uses hand-written fakes for the parsers and the refusal matcher, so the real Limrun simctl wrapper never meets the real EPERM matcher. Building the session from createLimrunRuntimeDependencies().ios.settings and feeding real stderr like "Failed to grant access ... Operation not permitted" would cover that route. The five settings: {} as ... casts in platform-runtime-gateway.fixtures.ts hide an empty adapter that throws if a test reaches it, so one real adapter in limrunTestDependencies would be safer.

The PR body mentions live Limrun iOS runs, but I could not find artifacts for them. After the restructure, please run these through the daemon CLI on a Limrun iOS instance and attach the output:

  • settings appearance toggle, with the read-back shown.
  • settings permission grant camera.
  • settings permission grant photos --mode limited.
  • settings location set 37.77 -122.42.
  • settings clear-app-state.
  • A refused service such as notifications, which should return UNSUPPORTED_OPERATION with the Apple message.

Please also run one local-simulator settings permission grant camera to show the extracted Apple path is unchanged.

Coverage fails because of this PR: the gate names the simctl-facade.ts to settings.ts edge and the Limrun index to ios.ts edge, and this diff adds both. Smoke Tests looks unrelated. It failed when the local simulator open of the visible-depth fixture timed out in simctl openurl, and this diff does not touch that route. I could not read the @limrun/api 0.49 typings, so I did not check softReset({strategy:'data'}) or whether the remote simctl allowlist rejects keychain. I also did not run the mutation checks. Before merge, the eager-closure growth has to go, ideally through the single Apple-owned function above.

…ded on demand

platform-apple's simctl-settings now owns the appearance, permission and
location plan for any simctl runner, including the refusal mapping. The
local simulator passes runSimctlForDevice and the device id; Limrun passes
its instance simctl and 'booted'. The Limrun settings body moves to
ios-settings.ts behind an await import, and the composition root loads the
Apple plan from the new platform-apple/simctl-settings subpath on first use,
so neither the simctl facade nor the Limrun provider entry grows.
@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member

Finished the review items at fa74c1d, as four commits on top of yours. Nothing was rewritten.

  • One owner for the plan: platform-apple's core/simctl-settings.ts exports applySimctlSetting({ runSimctl, udid, setting, state, appBundleId, options }). It owns the argv shapes, the toggle read and its error, the missing-app refusals, photos and limited, and the EPERM → UNSUPPORTED_OPERATION mapping. app-settings.ts passes runSimctlForDevice and device.id; Limrun passes client.simctl(...).wait() and booted. LimrunIosSettingsAdapter, the duplicated ios.ts branches and the facade re-exports are gone. Only clear-app-state (softReset with strategy data, then terminate) stays in Limrun.
  • Lazy loading: simctl-facade.ts matches main again. The Limrun body lives in ios-settings.ts and is loaded with await import from setSetting. The plan reaches Limrun through a lazy loader in limrun-runtime-dependencies.ts (like resolveAppAlias), which imports a new subpath, @agent-device/platform-apple/simctl-settings; no existing subpath could carry it without growing its own closure. The eager-closure gate passes with no budget raised.
  • Tests: a new provider-scenario test sends the real Limrun wrapper's output, with the exact stderr the live instance returned, through the real Apple plan and EPERM matcher, and checks that a failure that isn't a refusal stays COMMAND_FAILED. The settings: {} as fixture casts are gone (there were six): one real adapter is in limrunTestDependencies, and the two package tests that can't import platform-apple use a typed vi.fn. New tests fail under their mutations.
  • Gate: pnpm check:affected --run passes. The branch also typechecks and passes the settings tests when merged with today's main, including feat(settings): let permission and iOS location settings target an explicit app #3205: its explicit --app reaches Limrun as appBundleId. The earlier Smoke Tests failure was the unrelated simctl openurl timeout.
Live run on Limrun iOS and a local simulator
Setup: I created the Limrun iOS instance myself with the org key from <main>/.env (loaded with `node --env-file`, because the sandbox refused `set -a && . .env`; the key was never printed or copied). The create call was `client.iosInstances.create({ wait: true, metadata: { displayName: 'jbroma-pr-3209-validation', labels: { purpose: 'jbroma-pr-validation' } }, spec: { hardTimeout: '20m', inactivityTimeout: '10m' } })`, and it returned {"id":"ios_euna_01m45pck58erxtj4d6am5nmxkm","state":"ready","region":"eu-north1"}. The CLI attached to that instance through LIM_IOS_INSTANCE_URL/LIM_IOS_INSTANCE_TOKEN (written to a mode-600 scratch env file and deleted afterwards) with an isolated state dir. `ad` below means: node --env-file=$SCRATCH/lim-ios.env bin/agent-device.mjs <args> --state-dir $SCRATCH/st3209, run from this worktree's build. The appearance read-back runs `simctl ui booted appearance` on the instance through @limrun/api/ios-client, because Limrun iOS settings cannot be read back through agent-device.

$ ad connect limrun --platform ios
Connected successfully with Limrun.
Verified: Existing iOS instance access verified. A daemon started with these variables drives it without creating or deleting it.
$ ad open com.apple.Preferences --platform ios
Opened: com.apple.Preferences
$ ad apps --platform ios
Expo (host.exp.Exponent)

# appearance toggle with read-back
$ simctl ui booted appearance (read-back)
{"code":0,"stdout":"light","stderr":""}
$ ad settings appearance toggle --json
{"success": true, "data": {"setting": "appearance", "state": "toggle", "message": "Updated setting: appearance"}}
$ simctl ui booted appearance (read-back)
{"code":0,"stdout":"dark","stderr":""}
$ ad settings appearance toggle
Updated setting: appearance
$ simctl ui booted appearance (read-back)
{"code":0,"stdout":"light","stderr":""}

# permission uses the session app, so I opened the user app first
$ ad open host.exp.Exponent
Opened: host.exp.Exponent
$ ad settings permission grant camera --json
{"success": true, "data": {"setting": "permission", "state": "grant", "message": "Updated setting: permission"}}
# photos mode is a positional word in this CLI (help: settings permission <grant|deny|reset> <...|photos|...> [full|limited]), not --mode
$ ad settings permission grant photos limited --json
{"success": true, "data": {"setting": "permission", "state": "grant", "message": "Updated setting: permission"}}
$ ad settings location set 37.77 -122.42 --json
{"success": true, "data": {"setting": "location", "state": "set", "latitude": 37.77, "longitude": -122.42, "message": "Updated setting: location"}}
$ ad settings clear-app-state host.exp.Exponent --json
{"success": true, "data": {"setting": "clear-app-state", "state": "clear", "bundleId": "host.exp.Exponent", "cleared": true, "message": "Cleared user data for host.exp.Exponent"}}
$ ad settings permission grant notifications --json
{"success": false, "error": {"code": "UNSUPPORTED_OPERATION", "message": "iOS simulator does not support setting notifications permission via simctl privacy on this runtime.", "hint": "Privacy support varies by Xcode runtime: run `xcrun simctl privacy help` for its documented services, or use the `all` target, which applies the action to every service this runtime can change.", "details": {"deviceId": "limrun:ios:82baa5704ab23530c11b89c2535772c8", "appBundleId": "host.exp.Exponent", "dispatched": "unknown"}}}
# the raw stderr behind that refusal, run directly on the instance (now used as the integration-test fixture):
$ simctl privacy booted grant notifications host.exp.Exponent
{"code":1,"stdout":"","stderr":"An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=1):\nSimulator device failed to complete the requested operation.\nOperation not permitted\nUnderlying error (domain=NSPOSIXErrorDomain, code=1):\n\tFailed to set access\n\tOperation not permitted"}
$ ad settings wifi off --json
"success": false, "code": "UNSUPPORTED_OPERATION", "message": "Limrun iOS direct sessions support appearance, permission, location, and clear-app-state settings, not wifi."
$ ad close ; ad disconnect
Disconnected remote session "adc-8d0c4dde5e5ae10df64fded06fb487bf".

# Local simulator, extracted Apple path. I created a dedicated simulator so I could not touch a device another session holds.
$ xcrun simctl create jbroma-3209-validation com.apple.CoreSimulator.SimDeviceType.iPhone-17 com.apple.CoreSimulator.SimRuntime.iOS-26-2
24C53C01-FA2D-417E-B9F6-2EE2F150B9DC
$ node bin/agent-device.mjs open com.apple.Preferences --platform ios --udid 24C53C01-FA2D-417E-B9F6-2EE2F150B9DC --state-dir $SCRATCH/st3209-local
Waiting for the Simulator to finish booting...
Opened: com.apple.Preferences
$ node bin/agent-device.mjs settings permission grant camera --platform ios --udid 24C53C01-... --json --state-dir $SCRATCH/st3209-local
{"success": true, "data": {"setting": "permission", "state": "grant", "message": "Updated setting: permission"}}
$ sqlite3 .../Devices/24C53C01-.../data/Library/TCC/TCC.db "select service, client, auth_value from access where client='com.apple.Preferences';"
kTCCServiceCamera|com.apple.Preferences|2
$ node bin/agent-device.mjs settings permission grant notifications --platform ios --udid 24C53C01-... --json --state-dir $SCRATCH/st3209-local
"success": false, "code": "UNSUPPORTED_OPERATION", "message": "iOS simulator does not support setting notifications permission via simctl privacy on this runtime."
$ node bin/agent-device.mjs close ...
Closed: default

Minor points the independent review left open, for you to decide:

  • LimrunIosSimctlSettingRequest copies platform-apple's request type, because provider-limrun can't import it.
  • On Limrun, the provider-level refusal has deviceId: 'booted'. The daemon response carries the real limrun:ios:… id.
  • A failed local appearance read now reports the generic xcrun COMMAND_FAILED instead of "Failed to read current iOS appearance". The code and details are unchanged.
  • About 12 tests for the plan still live in app-settings.test.ts, so the plan's own test file doesn't cover it on its own.
  • The lazy-load test counts calls to the loader rather than module evaluation; the eager-closure gate is what enforces laziness.

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member

Reviewed at fa74c1d: the three findings from the 4f43457 review are fixed. There is now one owner for the settings plan, the eager-closure growth is gone (the Coverage gate passes), and the posted Limrun log covers the live path. All checks pass and there are no conflicts.

Not blocking: LimrunIosSimctlSettingRequest in packages/provider-limrun/src/runtime-dependencies.ts copies SimctlSettingRequest because the provider cannot import platform-apple. TypeScript checks the match at the composition root, so moving the type into contracts can wait for a later change.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 5, 2026
@thymikee
thymikee marked this pull request as ready for review October 5, 2026 11:02
@thymikee
thymikee merged commit 2b0737e into callstack:main Oct 5, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants