Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ agent-device scrollintoview @e42
- `trace start`, `trace stop`
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
- `settings wifi|airplane|location on|off`
- `settings appearance light|dark|toggle`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
- `appstate`, `apps`, `devices`, `session list`
Expand Down Expand Up @@ -292,6 +293,7 @@ Settings helpers:
- `settings wifi on|off`
- `settings airplane on|off`
- `settings location on|off` (iOS uses per-app permission for the current session app)
- `settings appearance light|dark|toggle` (iOS simulator appearance + Android night mode)
- `settings permission grant|deny|reset <camera|microphone|photos|contacts|notifications> [full|limited]` (session app required)
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
- iOS permission targets map to `simctl privacy`: `camera`, `microphone`, `photos` (`full` => `photos`, `limited` => `photos-add`), `contacts`, `notifications`.
Expand Down
2 changes: 1 addition & 1 deletion skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
- Use refs for discovery, selectors for replay/assertions.
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
- iOS settings helpers are simulator-only; use faceid `match|nonmatch|enroll|unenroll`.
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
- Permission settings are app-scoped and require an active session app:
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.
Expand Down
15 changes: 15 additions & 0 deletions src/core/settings-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const SETTINGS_WIFI_USAGE = '<wifi|airplane|location> <on|off>';
export const SETTINGS_APPEARANCE_USAGE = 'appearance <light|dark|toggle>';
export const SETTINGS_FACEID_USAGE = 'faceid <match|nonmatch|enroll|unenroll>';
export const SETTINGS_PERMISSION_USAGE =
'permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]';

export const SETTINGS_USAGE_OVERRIDE = [
`settings ${SETTINGS_WIFI_USAGE}`,
`settings ${SETTINGS_APPEARANCE_USAGE}`,
`settings ${SETTINGS_FACEID_USAGE}`,
`settings ${SETTINGS_PERMISSION_USAGE}`,
].join(' | ');

export const SETTINGS_INVALID_ARGS_MESSAGE =
`settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, or ${SETTINGS_PERMISSION_USAGE}`;
1 change: 1 addition & 0 deletions src/daemon/handlers/__tests__/snapshot-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ test('settings usage hint documents canonical faceid states', async () => {
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /appearance <light\|dark\|toggle>/);
assert.match(response.error.message, /match\|nonmatch\|enroll\|unenroll/);
assert.match(response.error.message, /grant\|deny\|reset/);
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { SETTINGS_INVALID_ARGS_MESSAGE } from '../../core/settings-contract.ts';
import { runIosRunnerCommand, stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
import { snapshotAndroid } from '../../platforms/android/index.ts';
import {
Expand Down Expand Up @@ -394,8 +395,7 @@ export async function handleSnapshotCommands(params: {
ok: false,
error: {
code: 'INVALID_ARGS',
message:
'settings requires <wifi|airplane|location> <on|off>, faceid <match|nonmatch|enroll|unenroll>, or permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]',
message: SETTINGS_INVALID_ARGS_MESSAGE,
},
};
}
Expand Down
95 changes: 95 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,101 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
);
});

test('setAndroidSetting appearance dark uses cmd uimode night yes', async () => {
await withMockedAdb(
'agent-device-android-appearance-dark-',
'#!/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, 'appearance', 'dark');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /shell cmd uimode night yes/);
},
);
});

test('setAndroidSetting appearance toggle flips current mode', async () => {
await withMockedAdb(
'agent-device-android-appearance-toggle-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
' echo "Night mode: yes"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'appearance', 'toggle');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /shell cmd uimode night __CMD__/);
assert.match(logged, /shell cmd uimode night no/);
},
);
});

test('setAndroidSetting appearance toggle from auto sets dark mode', async () => {
await withMockedAdb(
'agent-device-android-appearance-toggle-auto-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
' echo "Night mode: auto"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'appearance', 'toggle');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /shell cmd uimode night yes/);
},
);
});

test('setAndroidSetting appearance toggle rejects unknown current mode output', async () => {
await withMockedAdb(
'agent-device-android-appearance-toggle-unknown-',
[
'#!/bin/sh',
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
' echo "mode unavailable"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
async ({ device }) => {
await assert.rejects(
() => setAndroidSetting(device, 'appearance', 'toggle'),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'COMMAND_FAILED');
assert.match((error as AppError).message, /Unable to determine current Android appearance/);
return true;
},
);
},
);
});

test('swipeAndroid invokes adb input swipe with duration', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
const adbPath = path.join(tmpDir, 'adb');
Expand Down
44 changes: 44 additions & 0 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parsePermissionTarget,
type PermissionSettingOptions,
} from '../permission-utils.ts';
import { parseAppearanceAction } from '../appearance.ts';

const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
Expand Down Expand Up @@ -601,6 +602,11 @@ export async function setAndroidSetting(
await runCmd('adb', adbArgs(device, ['shell', 'settings', 'put', 'secure', 'location_mode', mode]));
return;
}
case 'appearance': {
const target = await resolveAndroidAppearanceTarget(device, state);
await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night', target === 'dark' ? 'yes' : 'no']));
return;
}
case 'permission': {
if (!appPackage) {
throw new AppError(
Expand Down Expand Up @@ -728,6 +734,44 @@ function parseSettingState(state: string): boolean {
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
}

async function resolveAndroidAppearanceTarget(
device: DeviceInfo,
state: string,
): Promise<'light' | 'dark'> {
const action = parseAppearanceAction(state);
if (action !== 'toggle') return action;

const currentResult = await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night']), {
allowFailure: true,
});
if (currentResult.exitCode !== 0) {
throw new AppError('COMMAND_FAILED', 'Failed to read current Android appearance', {
stdout: currentResult.stdout,
stderr: currentResult.stderr,
exitCode: currentResult.exitCode,
});
}
const current = parseAndroidAppearance(currentResult.stdout, currentResult.stderr);
if (!current) {
throw new AppError('COMMAND_FAILED', 'Unable to determine current Android appearance for toggle', {
stdout: currentResult.stdout,
stderr: currentResult.stderr,
});
}
if (current === 'auto') return 'dark';
return current === 'dark' ? 'light' : 'dark';
}

function parseAndroidAppearance(stdout: string, stderr: string): 'light' | 'dark' | 'auto' | null {
const match = /night mode:\s*(yes|no|auto)\b/i.exec(`${stdout}\n${stderr}`);
if (!match) return null;
const value = match[1].toLowerCase();
if (value === 'yes') return 'dark';
if (value === 'no') return 'light';
if (value === 'auto') return 'auto';
return null;
}

function parseAndroidPermissionTarget(
permissionTarget: string | undefined,
permissionMode: string | undefined,
Expand Down
11 changes: 11 additions & 0 deletions src/platforms/appearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { AppError } from '../utils/errors.ts';

export type AppearanceAction = 'light' | 'dark' | 'toggle';

export function parseAppearanceAction(state: string): AppearanceAction {
const normalized = state.trim().toLowerCase();
if (normalized === 'light') return 'light';
if (normalized === 'dark') return 'dark';
if (normalized === 'toggle') return 'toggle';
throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`);
}
116 changes: 116 additions & 0 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,122 @@ exit 1
);
});

test('setIosSetting appearance dark uses simctl ui appearance', async () => {
await withMockedXcrun(
'agent-device-ios-appearance-dark-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" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ "$5" = "dark" ]; 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, 'appearance', 'dark');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /simctl ui sim-1 appearance dark/);
},
);
});

test('setIosSetting appearance toggle flips current simulator appearance', async () => {
await withMockedXcrun(
'agent-device-ios-appearance-toggle-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" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ -z "$5" ]; then
echo "dark"
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ "$5" = "light" ]; 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, 'appearance', 'toggle');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /simctl ui sim-1 appearance/);
assert.match(logged, /simctl ui sim-1 appearance light/);
},
);
});

test('setIosSetting appearance toggle rejects unsupported current appearance output', async () => {
await withMockedXcrun(
'agent-device-ios-appearance-toggle-unsupported-test-',
`#!/bin/sh
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" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ -z "$5" ]; then
echo "unsupported"
exit 0
fi
exit 0
`,
async () => {
const device: DeviceInfo = {
platform: 'ios',
id: 'sim-1',
name: 'iPhone Sim',
kind: 'simulator',
booted: true,
};
await assert.rejects(
() => setIosSetting(device, 'appearance', 'toggle'),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'COMMAND_FAILED');
assert.match((error as AppError).message, /Unable to determine current iOS appearance/);
return true;
},
);
},
);
});

test('setIosSetting permission grant camera uses simctl privacy', async () => {
await withMockedXcrun(
'agent-device-ios-permission-camera-test-',
Expand Down
Loading
Loading