diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 2b4688a15..253656cf7 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -20,6 +20,7 @@ type DaemonInfo = { token: string; pid: number; version?: string; + codeSignature?: string; processStartTime?: string; }; @@ -83,9 +84,24 @@ export async function sendToDaemon(req: Omit): Promise { const existing = readDaemonInfo(); const localVersion = readVersion(); + const localCodeSignature = resolveLocalDaemonCodeSignature(); const existingReachable = existing ? await canConnect(existing) : false; - if (existing && existing.version === localVersion && existingReachable) return existing; - if (existing && (existing.version !== localVersion || !existingReachable)) { + if ( + existing + && existing.version === localVersion + && existing.codeSignature === localCodeSignature + && existingReachable + ) { + return existing; + } + if ( + existing + && ( + existing.version !== localVersion + || existing.codeSignature !== localCodeSignature + || !existingReachable + ) + ) { await stopDaemonProcessForTakeover(existing); removeDaemonInfo(); } @@ -224,6 +240,22 @@ async function canConnect(info: DaemonInfo): Promise { } async function startDaemon(): Promise { + const launchSpec = resolveDaemonLaunchSpec(); + const args = launchSpec.useSrc + ? ['--experimental-strip-types', launchSpec.srcPath] + : [launchSpec.distPath]; + + runCmdDetached(process.execPath, args); +} + +type DaemonLaunchSpec = { + root: string; + distPath: string; + srcPath: string; + useSrc: boolean; +}; + +function resolveDaemonLaunchSpec(): DaemonLaunchSpec { const root = findProjectRoot(); const distPath = path.join(root, 'dist', 'src', 'daemon.js'); const srcPath = path.join(root, 'src', 'daemon.ts'); @@ -235,9 +267,23 @@ async function startDaemon(): Promise { } const runningFromSource = process.execArgv.includes('--experimental-strip-types'); const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc; - const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath]; + return { root, distPath, srcPath, useSrc }; +} - runCmdDetached(process.execPath, args); +function resolveLocalDaemonCodeSignature(): string { + const launchSpec = resolveDaemonLaunchSpec(); + const entryPath = launchSpec.useSrc ? launchSpec.srcPath : launchSpec.distPath; + return computeDaemonCodeSignature(entryPath, launchSpec.root); +} + +export function computeDaemonCodeSignature(entryPath: string, root: string = findProjectRoot()): string { + try { + const stat = fs.statSync(entryPath); + const relativePath = path.relative(root, entryPath) || entryPath; + return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; + } catch { + return 'unknown'; + } } async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise { diff --git a/src/daemon.ts b/src/daemon.ts index 48c2a020a..60a8b227f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -6,7 +6,7 @@ import crypto from 'node:crypto'; import { dispatchCommand, type CommandFlags } from './core/dispatch.ts'; import { isCommandSupportedOnDevice } from './core/capabilities.ts'; import { asAppError, AppError, normalizeError } from './utils/errors.ts'; -import { readVersion } from './utils/version.ts'; +import { findProjectRoot, readVersion } from './utils/version.ts'; import { abortAllIosRunnerSessions, stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts'; import type { DaemonRequest, DaemonResponse } from './daemon/types.ts'; import { SessionStore } from './daemon/session-store.ts'; @@ -47,6 +47,7 @@ type DaemonLockInfo = { }; const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; +const daemonCodeSignature = resolveDaemonCodeSignature(); function contextFromFlags( flags: CommandFlags | undefined, @@ -222,13 +223,37 @@ function writeInfo(port: number): void { fs.writeFileSync(logPath, ''); fs.writeFileSync( infoPath, - JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2), + JSON.stringify( + { + port, + token, + pid: process.pid, + version, + codeSignature: daemonCodeSignature, + processStartTime: daemonProcessStartTime, + }, + null, + 2, + ), { mode: 0o600, }, ); } +function resolveDaemonCodeSignature(): string { + const entryPath = process.argv[1]; + if (!entryPath) return 'unknown'; + try { + const stat = fs.statSync(entryPath); + const root = findProjectRoot(); + const relativePath = path.relative(root, entryPath) || entryPath; + return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; + } catch { + return 'unknown'; + } +} + function removeInfo(): void { if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath); } diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index b16699656..fd2c15354 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -841,6 +841,99 @@ test('open app on existing iOS session resolves and stores bundle id', async () assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences'); }); +test('open app on existing Android session resolves and stores package id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'Old App', + }, + ); + + let dispatchedContext: Record | undefined; + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['RNCLI83'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + dispatch: async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }, + ensureReady: async () => {}, + resolveAndroidPackageForOpen: async () => 'org.reactjs.native.example.RNCLI83', + }); + + assert.ok(response); + assert.equal(response?.ok, true); + const updated = sessionStore.get(sessionName); + assert.equal(updated?.appBundleId, 'org.reactjs.native.example.RNCLI83'); + assert.equal(updated?.appName, 'RNCLI83'); + assert.equal(dispatchedContext?.appBundleId, 'org.reactjs.native.example.RNCLI83'); +}); + +test('open intent target on existing Android session clears stale package context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }, + ); + + let dispatchedContext: Record | undefined; + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + dispatch: async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }, + ensureReady: async () => {}, + resolveAndroidPackageForOpen: async () => undefined, + }); + + assert.ok(response); + assert.equal(response?.ok, true); + const updated = sessionStore.get(sessionName); + assert.equal(updated?.appBundleId, undefined); + assert.equal(updated?.appName, 'settings'); + assert.equal(dispatchedContext?.appBundleId, undefined); +}); + test('open --relaunch closes and reopens active session app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-session'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 65959ccf4..423b3b7f7 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -146,6 +146,27 @@ async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): } } +async function resolveAndroidPackageForOpen( + device: DeviceInfo, + openTarget: string | undefined, +): Promise { + if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) return undefined; + try { + const { resolveAndroidApp } = await import('../../platforms/android/index.ts'); + const resolved = await resolveAndroidApp(device, openTarget); + return resolved.type === 'package' ? resolved.value : undefined; + } catch { + return undefined; + } +} + +function shouldPreserveAndroidPackageContext( + device: DeviceInfo, + openTarget: string | undefined, +): boolean { + return device.platform === 'android' && Boolean(openTarget && isDeepLinkTarget(openTarget)); +} + async function handleAppStateCommand(params: { req: DaemonRequest; sessionName: string; @@ -247,6 +268,10 @@ export async function handleSessionCommands(params: { start: typeof startAppLog; stop: typeof stopAppLog; }; + resolveAndroidPackageForOpen?: ( + device: DeviceInfo, + openTarget: string | undefined, + ) => Promise; }): Promise { const { req, @@ -263,6 +288,7 @@ export async function handleSessionCommands(params: { start: startAppLog, stop: stopAppLog, }, + resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen, } = params; const dispatch = dispatchOverride ?? dispatchCommand; const ensureReady = ensureReadyOverride ?? ensureDeviceReady; @@ -471,7 +497,10 @@ export async function handleSessionCommands(params: { }; } await ensureReady(session.device); - const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId); + const appBundleId = + (await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId)) + ?? (await resolveAndroidPackageForOpenOverride(session.device, openTarget)) + ?? (shouldPreserveAndroidPackageContext(session.device, openTarget) ? session.appBundleId : undefined); const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget]; if (shouldRelaunch) { const closeTarget = appBundleId ?? openTarget; @@ -530,7 +559,9 @@ export async function handleSessionCommands(params: { }; } await ensureReady(device); - const appBundleId = await resolveIosBundleIdForOpen(device, openTarget); + const appBundleId = + (await resolveIosBundleIdForOpen(device, openTarget)) + ?? (await resolveAndroidPackageForOpenOverride(device, openTarget)); if (shouldRelaunch && openTarget) { const closeTarget = appBundleId ?? openTarget; await dispatch(device, 'close', [closeTarget], req.flags?.out, { diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index c1bf83c71..adf628b0c 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -395,7 +395,7 @@ export async function handleSnapshotCommands(params: { error: { code: 'INVALID_ARGS', message: - 'settings requires , faceid , or permission [full|limited]', + 'settings requires , faceid , or permission [full|limited]', }, }; } diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index eadb41927..cb56e3a85 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -331,7 +331,7 @@ test('setAndroidSetting permission grant camera uses pm grant', async () => { ); }); -test('setAndroidSetting permission deny notifications uses appops', async () => { +test('setAndroidSetting permission deny notifications revokes runtime permission and appops', async () => { await withMockedAdb( 'agent-device-android-permission-notifications-', '#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', @@ -340,11 +340,35 @@ test('setAndroidSetting permission deny notifications uses appops', async () => permissionTarget: 'notifications', }); const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS/); assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndeny/); }, ); }); +test('setAndroidSetting permission reset notifications clears permission flags for reprompt', async () => { + await withMockedAdb( + 'agent-device-android-permission-notifications-reset-', + '#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', + async ({ argsLogPath, device }) => { + await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', { + permissionTarget: 'notifications', + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS/); + assert.match( + logged, + /shell\npm\nclear-permission-flags\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS\nuser-set/, + ); + assert.match( + logged, + /shell\npm\nclear-permission-flags\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS\nuser-fixed/, + ); + assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndefault/); + }, + ); +}); + test('setAndroidSetting permission reset camera maps to pm revoke', async () => { await withMockedAdb( 'agent-device-android-permission-reset-', @@ -382,6 +406,28 @@ test('setAndroidSetting permission rejects mode argument', async () => { ); }); +test('setAndroidSetting permission rejects iOS-only targets with Android-specific guidance', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + await assert.rejects( + () => + setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'calendar', + }), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'INVALID_ARGS'); + assert.match((error as AppError).message, /Unsupported permission target on Android/i); + return true; + }, + ); +}); + test('setAndroidSetting permission grant photos falls back to legacy permission on older SDK', async () => { await withMockedAdb( 'agent-device-android-permission-photos-fallback-', diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts index 8a4aaef93..07a914d73 100644 --- a/src/platforms/android/index.ts +++ b/src/platforms/android/index.ts @@ -21,7 +21,7 @@ function adbArgs(device: DeviceInfo, args: string[]): string[] { return ['-s', device.id, ...args]; } -async function resolveAndroidApp( +export async function resolveAndroidApp( device: DeviceInfo, app: string, ): Promise<{ type: 'intent' | 'package'; value: string }> { @@ -589,9 +589,8 @@ export async function setAndroidSetting( } const action = parsePermissionAction(state); const target = parseAndroidPermissionTarget(options?.permissionTarget, options?.permissionMode); - if (target.kind === 'appops') { - const appOpsMode = action === 'grant' ? 'allow' : action === 'deny' ? 'deny' : 'default'; - await runCmd('adb', adbArgs(device, ['shell', 'appops', 'set', appPackage, target.value, appOpsMode])); + if (target.kind === 'notifications') { + await setAndroidNotificationPermission(device, appPackage, action, target); return; } const pmAction = action === 'grant' ? 'grant' : 'revoke'; @@ -713,7 +712,7 @@ function parseAndroidPermissionTarget( permissionMode: string | undefined, ): | { kind: 'pm'; value: string; type: 'camera' | 'microphone' | 'photos' | 'contacts' } - | { kind: 'appops'; value: string } { + | { kind: 'notifications'; appOps: string; permission: string } { const normalized = parsePermissionTarget(permissionTarget); if (permissionMode?.trim()) { throw new AppError( @@ -731,10 +730,16 @@ function parseAndroidPermissionTarget( if (normalized === 'contacts') { return { kind: 'pm', value: 'android.permission.READ_CONTACTS', type: 'contacts' }; } - if (normalized === 'notifications') return { kind: 'appops', value: 'POST_NOTIFICATION' }; + if (normalized === 'notifications') { + return { + kind: 'notifications', + appOps: 'POST_NOTIFICATION', + permission: 'android.permission.POST_NOTIFICATIONS', + }; + } throw new AppError( 'INVALID_ARGS', - `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, + `Unsupported permission target on Android: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, ); } @@ -767,6 +772,41 @@ async function setAndroidPhotoPermission( }); } +async function setAndroidNotificationPermission( + device: DeviceInfo, + appPackage: string, + action: 'grant' | 'deny' | 'reset', + target: { appOps: string; permission: string }, +): Promise { + const appOpsMode = action === 'grant' ? 'allow' : action === 'deny' ? 'deny' : 'default'; + if (action === 'grant') { + await runCmd( + 'adb', + adbArgs(device, ['shell', 'pm', 'grant', appPackage, target.permission]), + { allowFailure: true }, + ); + } else { + await runCmd( + 'adb', + adbArgs(device, ['shell', 'pm', 'revoke', appPackage, target.permission]), + { allowFailure: true }, + ); + if (action === 'reset') { + await runCmd( + 'adb', + adbArgs(device, ['shell', 'pm', 'clear-permission-flags', appPackage, target.permission, 'user-set']), + { allowFailure: true }, + ); + await runCmd( + 'adb', + adbArgs(device, ['shell', 'pm', 'clear-permission-flags', appPackage, target.permission, 'user-fixed']), + { allowFailure: true }, + ); + } + } + await runCmd('adb', adbArgs(device, ['shell', 'appops', 'set', appPackage, target.appOps, appOpsMode])); +} + async function getAndroidSdkInt(device: DeviceInfo): Promise { const result = await runCmd('adb', adbArgs(device, ['shell', 'getprop', 'ro.build.version.sdk']), { allowFailure: true, diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index e51730e57..fef656ec9 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -30,7 +30,8 @@ async function withMockedXcrun( const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); const xcrunPath = path.join(tmpDir, 'xcrun'); const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile(xcrunPath, script, 'utf8'); + const scriptWithPrivacyHelp = injectDefaultPrivacyHelp(script); + await fs.writeFile(xcrunPath, scriptWithPrivacyHelp, 'utf8'); await fs.chmod(xcrunPath, 0o755); const previousPath = process.env.PATH; @@ -51,6 +52,38 @@ async function withMockedXcrun( } } +function injectDefaultPrivacyHelp(script: string): string { + if (script.includes('AGENT_DEVICE_CUSTOM_PRIVACY_HELP')) return script; + const helpBlock = `if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "help" ]; then + cat <<'HELP' +Usage: simctl privacy [] + + service + The service: + all - Apply the action to all services. + calendar - Allow access to calendar. + contacts-limited - Allow access to basic contact info. + contacts - Allow access to full contact details. + location - Allow access to location services when app is in use. + location-always - Allow access to location services at all times. + photos-add - Allow adding photos to the photo library. + photos - Allow full access to the photo library. + media-library - Allow access to the media library. + microphone - Allow access to audio input. + motion - Allow access to motion and fitness data. + reminders - Allow access to reminders. + siri - Allow use of the app with Siri. + camera - Allow access to camera. + notifications - Allow access to notifications. +HELP + exit 0 +fi +`; + const shebang = '#!/bin/sh\n'; + if (!script.startsWith(shebang)) return `${shebang}${helpBlock}${script}`; + return `${shebang}${helpBlock}${script.slice(shebang.length)}`; +} + test('openIosApp custom scheme deep links on iOS devices require app bundle context', async () => { const device: DeviceInfo = { platform: 'ios', @@ -573,6 +606,41 @@ exit 1 ); }); +test('setIosSetting permission grant calendar uses simctl privacy calendar target', async () => { + await withMockedXcrun( + 'agent-device-ios-permission-calendar-test-', + `#!/bin/sh +printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then + cat <<'JSON' +{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}} +JSON + exit 0 +fi +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "sim-1" ] && [ "$4" = "grant" ] && [ "$5" = "calendar" ] && [ "$6" = "com.example.app" ]; then + exit 0 +fi +echo "unexpected xcrun args: $@" >&2 +exit 1 +`, + async ({ argsLogPath }) => { + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Sim', + kind: 'simulator', + booted: true, + }; + await setIosSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'calendar', + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /simctl\nprivacy\nsim-1\ngrant\ncalendar\ncom\.example\.app/); + }, + ); +}); + test('setIosSetting permission grant photos limited maps to photos-add', async () => { await withMockedXcrun( 'agent-device-ios-permission-photos-test-', @@ -646,3 +714,156 @@ exit 1 }, ); }); + +test('setIosSetting permission reset notifications falls back to reset all when direct reset is blocked', async () => { + await withMockedXcrun( + 'agent-device-ios-permission-notifications-reset-fallback-', + `#!/bin/sh +printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then + cat <<'JSON' +{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}} +JSON + exit 0 +fi +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "sim-1" ] && [ "$4" = "reset" ] && [ "$5" = "notifications" ] && [ "$6" = "com.example.app" ]; then + echo "Failed to reset access" >&2 + echo "Operation not permitted" >&2 + exit 1 +fi +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "sim-1" ] && [ "$4" = "reset" ] && [ "$5" = "all" ] && [ "$6" = "com.example.app" ]; then + exit 0 +fi +echo "unexpected xcrun args: $@" >&2 +exit 1 +`, + async ({ argsLogPath }) => { + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Sim', + kind: 'simulator', + booted: true, + }; + await setIosSetting(device, 'permission', 'reset', 'com.example.app', { + permissionTarget: 'notifications', + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /simctl\nprivacy\nsim-1\nreset\nnotifications\ncom\.example\.app/); + assert.match(logged, /simctl\nprivacy\nsim-1\nreset\nall\ncom\.example\.app/); + }, + ); +}); + +test('setIosSetting permission deny notifications returns unsupported on runtimes that block it', async () => { + await withMockedXcrun( + 'agent-device-ios-permission-notifications-deny-unsupported-', + `#!/bin/sh +# AGENT_DEVICE_CUSTOM_PRIVACY_HELP +printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "help" ]; then + cat <<'HELP' +Usage: simctl privacy [] + + service + The service: + notifications - Allow access to notifications. + camera - Allow access to camera. +HELP + exit 0 +fi +if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then + cat <<'JSON' +{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}} +JSON + exit 0 +fi +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "sim-1" ] && [ "$4" = "revoke" ] && [ "$5" = "notifications" ] && [ "$6" = "com.example.app" ]; then + echo "Failed to revoke access" >&2 + echo "Operation not permitted" >&2 + exit 1 +fi +echo "unexpected xcrun args: $@" >&2 +exit 1 +`, + async ({ argsLogPath }) => { + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Sim', + kind: 'simulator', + booted: true, + }; + await assert.rejects( + () => + setIosSetting(device, 'permission', 'deny', 'com.example.app', { + permissionTarget: 'notifications', + }), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); + assert.match((error as AppError).message, /does not support setting notifications permission/i); + return true; + }, + ); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /simctl\nprivacy\nsim-1\nrevoke\nnotifications\ncom\.example\.app/); + }, + ); +}); + +test('setIosSetting permission rejects service missing from simctl privacy help', async () => { + await withMockedXcrun( + 'agent-device-ios-permission-service-unsupported-', + `#!/bin/sh +# AGENT_DEVICE_CUSTOM_PRIVACY_HELP +printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "help" ]; then + cat <<'HELP' +Usage: simctl privacy [] + + service + The service: + camera - Allow access to camera. + microphone - Allow access to audio input. +HELP + exit 0 +fi +if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then + cat <<'JSON' +{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}} +JSON + exit 0 +fi +echo "unexpected xcrun args: $@" >&2 +exit 1 +`, + async ({ argsLogPath }) => { + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Sim', + kind: 'simulator', + booted: true, + }; + await assert.rejects( + () => + setIosSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'calendar', + }), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); + assert.match((error as AppError).message, /does not support service "calendar"/i); + return true; + }, + ); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /simctl\nprivacy\nhelp/); + assert.doesNotMatch(logged, /simctl\nprivacy\nsim-1\ngrant\ncalendar/); + }, + ); +}); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index a4396ed8f..91f1f3f49 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -22,6 +22,8 @@ import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './sim const ALIASES: Record = { settings: 'com.apple.Preferences', }; +let cachedSimctlPrivacyServices: Set | null = null; +let cachedSimctlPrivacyServicesPath: string | undefined; function isMissingAppErrorOutput(output: string): boolean { return output.includes('not installed') || output.includes('not found') || output.includes('no such file'); @@ -288,7 +290,7 @@ export async function setIosSetting( } const action = mapIosPermissionAction(parsePermissionAction(state)); const target = parseIosPermissionTarget(options?.permissionTarget, options?.permissionMode); - await runCmd('xcrun', ['simctl', 'privacy', device.id, action, target, appBundleId]); + await runIosPrivacyCommand(device.id, action, target, appBundleId); return; } default: @@ -360,6 +362,123 @@ function mapIosPermissionAction(action: 'grant' | 'deny' | 'reset'): 'grant' | ' return action; } +async function runIosPrivacyCommand( + deviceId: string, + action: 'grant' | 'revoke' | 'reset', + target: string, + appBundleId: string, +): Promise { + const supportedServices = await getSimctlPrivacyServices(); + if (!supportedServices.has(target)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `iOS simctl privacy does not support service "${target}" on this runtime.`, + { + deviceId, + appBundleId, + hint: `Supported services: ${Array.from(supportedServices).sort().join(', ')}`, + }, + ); + } + + const args = ['simctl', 'privacy', deviceId, action, target, appBundleId]; + const isNotificationsTarget = target === 'notifications'; + if (!(action === 'reset' && isNotificationsTarget)) { + try { + await runCmd('xcrun', args); + return; + } catch (error) { + if (!(isNotificationsTarget && isNotificationsOperationNotPermitted(error))) { + throw error; + } + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.', + { + deviceId, + appBundleId, + hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.', + }, + ); + } + } + + try { + await runCmd('xcrun', args); + return; + } catch (error) { + if (!isNotificationsOperationNotPermitted(error)) { + throw error; + } + } + + try { + await runCmd('xcrun', ['simctl', 'privacy', deviceId, 'reset', 'all', appBundleId]); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'iOS simulator blocked direct notifications reset. Fallback reset-all also failed.', + { + deviceId, + appBundleId, + hint: 'Use reinstall to force a fresh notifications prompt, or reset simulator content and settings.', + }, + error instanceof Error ? error : undefined, + ); + } +} + +function isNotificationsOperationNotPermitted(error: unknown): boolean { + if (!(error instanceof AppError) || error.code !== 'COMMAND_FAILED') return false; + const stderr = String(error.details?.stderr ?? '').toLowerCase(); + return ( + (stderr.includes('failed to grant access') || + stderr.includes('failed to revoke access') || + stderr.includes('failed to reset access')) && + stderr.includes('operation not permitted') + ); +} + +async function getSimctlPrivacyServices(): Promise> { + const currentPath = process.env.PATH; + if (cachedSimctlPrivacyServices && cachedSimctlPrivacyServicesPath === currentPath) { + return cachedSimctlPrivacyServices; + } + const result = await runCmd('xcrun', ['simctl', 'privacy', 'help'], { allowFailure: true }); + const services = parseSimctlPrivacyServices(`${result.stdout}\n${result.stderr}`); + if (services.size === 0) { + throw new AppError('COMMAND_FAILED', 'Unable to determine supported simctl privacy services', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + hint: 'Run `xcrun simctl privacy help` manually to verify available services for this runtime.', + }); + } + cachedSimctlPrivacyServices = services; + cachedSimctlPrivacyServicesPath = currentPath; + return services; +} + +function parseSimctlPrivacyServices(helpText: string): Set { + const services = new Set(); + let inServiceSection = false; + for (const line of helpText.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === 'service') { + inServiceSection = true; + continue; + } + if (!inServiceSection) continue; + if (trimmed.startsWith('bundle identifier')) break; + const match = /^([a-z-]+)\s+-\s+/.exec(trimmed); + if (match) { + services.add(match[1]); + } + } + return services; +} + function parseIosPermissionTarget(permissionTarget: string | undefined, permissionMode: string | undefined): string { const normalized = parsePermissionTarget(permissionTarget); if (normalized !== 'photos' && permissionMode?.trim()) { @@ -371,7 +490,15 @@ function parseIosPermissionTarget(permissionTarget: string | undefined, permissi if (normalized === 'camera') return 'camera'; if (normalized === 'microphone') return 'microphone'; if (normalized === 'contacts') return 'contacts'; + if (normalized === 'contacts-limited') return 'contacts-limited'; if (normalized === 'notifications') return 'notifications'; + if (normalized === 'calendar') return 'calendar'; + if (normalized === 'location') return 'location'; + if (normalized === 'location-always') return 'location-always'; + if (normalized === 'media-library') return 'media-library'; + if (normalized === 'motion') return 'motion'; + if (normalized === 'reminders') return 'reminders'; + if (normalized === 'siri') return 'siri'; if (normalized === 'photos') { const mode = permissionMode?.trim().toLowerCase(); if (!mode || mode === 'full') return 'photos'; @@ -380,7 +507,7 @@ function parseIosPermissionTarget(permissionTarget: string | undefined, permissi } throw new AppError( 'INVALID_ARGS', - `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, + `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri.`, ); } diff --git a/src/platforms/permission-utils.ts b/src/platforms/permission-utils.ts index b83966e03..e77413f7c 100644 --- a/src/platforms/permission-utils.ts +++ b/src/platforms/permission-utils.ts @@ -1,7 +1,20 @@ import { AppError } from '../utils/errors.ts'; export type PermissionAction = 'grant' | 'deny' | 'reset'; -export type PermissionTarget = 'camera' | 'microphone' | 'photos' | 'contacts' | 'notifications'; +export type PermissionTarget = + | 'camera' + | 'microphone' + | 'photos' + | 'contacts' + | 'contacts-limited' + | 'notifications' + | 'calendar' + | 'location' + | 'location-always' + | 'media-library' + | 'motion' + | 'reminders' + | 'siri'; export type PermissionSettingOptions = { permissionTarget?: string; permissionMode?: string; @@ -11,7 +24,15 @@ export const PERMISSION_TARGETS: readonly PermissionTarget[] = [ 'microphone', 'photos', 'contacts', + 'contacts-limited', 'notifications', + 'calendar', + 'location', + 'location-always', + 'media-library', + 'motion', + 'reminders', + 'siri', ]; export function parsePermissionAction(action: string): PermissionAction { @@ -29,7 +50,15 @@ export function parsePermissionTarget(value: string | undefined): PermissionTarg normalized === 'microphone' || normalized === 'photos' || normalized === 'contacts' || - normalized === 'notifications' + normalized === 'contacts-limited' || + normalized === 'notifications' || + normalized === 'calendar' || + normalized === 'location' || + normalized === 'location-always' || + normalized === 'media-library' || + normalized === 'motion' || + normalized === 'reminders' || + normalized === 'siri' ) { return normalized; } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 6a3ff5311..8df4d232f 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -353,6 +353,6 @@ test('settings usage documents canonical faceid states', () => { const help = usageForCommand('settings'); if (help === null) throw new Error('Expected command help text'); assert.match(help, /match\|nonmatch\|enroll\|unenroll/); - assert.match(help, /camera\|microphone\|photos\|contacts\|notifications/); + assert.match(help, /camera\|microphone\|photos\|contacts\|contacts-limited\|notifications\|calendar\|location\|location-always\|media-library\|motion\|reminders\|siri/); assert.doesNotMatch(help, /validate\|unvalidate/); }); diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index e820d44f1..16b0a3a26 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { resolveDaemonRequestTimeoutMs, resolveDaemonStartupHint } from '../../daemon-client.ts'; +import { computeDaemonCodeSignature, resolveDaemonRequestTimeoutMs, resolveDaemonStartupHint } from '../../daemon-client.ts'; import { isProcessAlive, readProcessCommand, @@ -39,6 +39,19 @@ test('resolveDaemonStartupHint falls back to daemon.json guidance', () => { assert.match(hint, /daemon\.json/i); }); +test('computeDaemonCodeSignature includes relative path, size, and mtime', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-')); + try { + const daemonEntryPath = path.join(root, 'dist', 'src', 'daemon.js'); + fs.mkdirSync(path.dirname(daemonEntryPath), { recursive: true }); + fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); + const signature = computeDaemonCodeSignature(daemonEntryPath, root); + assert.match(signature, /^dist\/src\/daemon\.js:\d+:\d+$/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('stopDaemonProcessForTakeover terminates a matching daemon process', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-test-')); const daemonDir = path.join(root, 'agent-device', 'dist', 'src'); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 8c1d80f12..badb70654 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -558,7 +558,7 @@ const COMMAND_SCHEMAS: Record = { }, settings: { usageOverride: - 'settings | settings faceid | settings permission [full|limited]', + 'settings | settings faceid | settings permission [full|limited]', description: 'Toggle OS settings and app permissions (session app scope for permission actions)', positionalArgs: ['setting', 'state', 'target?', 'mode?'], allowedFlags: [],