Skip to content

Commit 8660b04

Browse files
feat: add settings appearance command (#119)
* feat: add settings appearance command - add appearance light|dark|toggle support to settings command - implement iOS simulator and Android night mode handlers - cover parser/handler/platform behavior with unit tests - update README, docs, and agent-device skill guidance * test: align settings usage assertions - expect split settings usage lines for wifi/location and appearance * fix: harden settings appearance parsing and dedupe usage contract --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent ea7d60a commit 8660b04

14 files changed

Lines changed: 338 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ agent-device scrollintoview @e42
147147
- `trace start`, `trace stop`
148148
- `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)
149149
- `settings wifi|airplane|location on|off`
150+
- `settings appearance light|dark|toggle`
150151
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
151152
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
152153
- `appstate`, `apps`, `devices`, `session list`
@@ -299,6 +300,7 @@ Settings helpers:
299300
- `settings wifi on|off`
300301
- `settings airplane on|off`
301302
- `settings location on|off` (iOS uses per-app permission for the current session app)
303+
- `settings appearance light|dark|toggle` (iOS simulator appearance + Android night mode)
302304
- `settings permission grant|deny|reset <camera|microphone|photos|contacts|notifications> [full|limited]` (session app required)
303305
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
304306
- iOS permission targets map to `simctl privacy`: `camera`, `microphone`, `photos` (`full` => `photos`, `limited` => `photos-add`), `contacts`, `notifications`.

skills/agent-device/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
107107
- Use refs for discovery, selectors for replay/assertions.
108108
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
109109
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
110-
- iOS settings helpers are simulator-only; use faceid `match|nonmatch|enroll|unenroll`.
110+
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
111111
- Permission settings are app-scoped and require an active session app:
112112
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
113113
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.

src/core/settings-contract.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const SETTINGS_WIFI_USAGE = '<wifi|airplane|location> <on|off>';
2+
export const SETTINGS_APPEARANCE_USAGE = 'appearance <light|dark|toggle>';
3+
export const SETTINGS_FACEID_USAGE = 'faceid <match|nonmatch|enroll|unenroll>';
4+
export const SETTINGS_PERMISSION_USAGE =
5+
'permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]';
6+
7+
export const SETTINGS_USAGE_OVERRIDE = [
8+
`settings ${SETTINGS_WIFI_USAGE}`,
9+
`settings ${SETTINGS_APPEARANCE_USAGE}`,
10+
`settings ${SETTINGS_FACEID_USAGE}`,
11+
`settings ${SETTINGS_PERMISSION_USAGE}`,
12+
].join(' | ');
13+
14+
export const SETTINGS_INVALID_ARGS_MESSAGE =
15+
`settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, or ${SETTINGS_PERMISSION_USAGE}`;

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ test('settings usage hint documents canonical faceid states', async () => {
110110
assert.equal(response?.ok, false);
111111
if (response && !response.ok) {
112112
assert.equal(response.error.code, 'INVALID_ARGS');
113+
assert.match(response.error.message, /appearance <light\|dark\|toggle>/);
113114
assert.match(response.error.message, /match\|nonmatch\|enroll\|unenroll/);
114115
assert.match(response.error.message, /grant\|deny\|reset/);
115116
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);

src/daemon/handlers/snapshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
22
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
3+
import { SETTINGS_INVALID_ARGS_MESSAGE } from '../../core/settings-contract.ts';
34
import { runIosRunnerCommand, stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
45
import { snapshotAndroid } from '../../platforms/android/index.ts';
56
import {
@@ -394,8 +395,7 @@ export async function handleSnapshotCommands(params: {
394395
ok: false,
395396
error: {
396397
code: 'INVALID_ARGS',
397-
message:
398-
'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]',
398+
message: SETTINGS_INVALID_ARGS_MESSAGE,
399399
},
400400
};
401401
}

src/platforms/android/__tests__/index.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,101 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
294294
);
295295
});
296296

297+
test('setAndroidSetting appearance dark uses cmd uimode night yes', async () => {
298+
await withMockedAdb(
299+
'agent-device-android-appearance-dark-',
300+
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
301+
async ({ argsLogPath, device }) => {
302+
await setAndroidSetting(device, 'appearance', 'dark');
303+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
304+
.trim()
305+
.split('\n')
306+
.filter(Boolean);
307+
const logged = lines.join(' ');
308+
assert.match(logged, /shell cmd uimode night yes/);
309+
},
310+
);
311+
});
312+
313+
test('setAndroidSetting appearance toggle flips current mode', async () => {
314+
await withMockedAdb(
315+
'agent-device-android-appearance-toggle-',
316+
[
317+
'#!/bin/sh',
318+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
319+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
320+
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
321+
' echo "Night mode: yes"',
322+
' exit 0',
323+
'fi',
324+
'exit 0',
325+
'',
326+
].join('\n'),
327+
async ({ argsLogPath, device }) => {
328+
await setAndroidSetting(device, 'appearance', 'toggle');
329+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
330+
.trim()
331+
.split('\n')
332+
.filter(Boolean);
333+
const logged = lines.join(' ');
334+
assert.match(logged, /shell cmd uimode night __CMD__/);
335+
assert.match(logged, /shell cmd uimode night no/);
336+
},
337+
);
338+
});
339+
340+
test('setAndroidSetting appearance toggle from auto sets dark mode', async () => {
341+
await withMockedAdb(
342+
'agent-device-android-appearance-toggle-auto-',
343+
[
344+
'#!/bin/sh',
345+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
346+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
347+
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
348+
' echo "Night mode: auto"',
349+
' exit 0',
350+
'fi',
351+
'exit 0',
352+
'',
353+
].join('\n'),
354+
async ({ argsLogPath, device }) => {
355+
await setAndroidSetting(device, 'appearance', 'toggle');
356+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
357+
.trim()
358+
.split('\n')
359+
.filter(Boolean);
360+
const logged = lines.join(' ');
361+
assert.match(logged, /shell cmd uimode night yes/);
362+
},
363+
);
364+
});
365+
366+
test('setAndroidSetting appearance toggle rejects unknown current mode output', async () => {
367+
await withMockedAdb(
368+
'agent-device-android-appearance-toggle-unknown-',
369+
[
370+
'#!/bin/sh',
371+
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
372+
' echo "mode unavailable"',
373+
' exit 0',
374+
'fi',
375+
'exit 0',
376+
'',
377+
].join('\n'),
378+
async ({ device }) => {
379+
await assert.rejects(
380+
() => setAndroidSetting(device, 'appearance', 'toggle'),
381+
(error: unknown) => {
382+
assert.equal(error instanceof AppError, true);
383+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
384+
assert.match((error as AppError).message, /Unable to determine current Android appearance/);
385+
return true;
386+
},
387+
);
388+
},
389+
);
390+
});
391+
297392
test('swipeAndroid invokes adb input swipe with duration', async () => {
298393
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
299394
const adbPath = path.join(tmpDir, 'adb');

src/platforms/android/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
parsePermissionTarget,
1313
type PermissionSettingOptions,
1414
} from '../permission-utils.ts';
15+
import { parseAppearanceAction } from '../appearance.ts';
1516

1617
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
1718
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
@@ -618,6 +619,11 @@ export async function setAndroidSetting(
618619
await runCmd('adb', adbArgs(device, ['shell', 'settings', 'put', 'secure', 'location_mode', mode]));
619620
return;
620621
}
622+
case 'appearance': {
623+
const target = await resolveAndroidAppearanceTarget(device, state);
624+
await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night', target === 'dark' ? 'yes' : 'no']));
625+
return;
626+
}
621627
case 'permission': {
622628
if (!appPackage) {
623629
throw new AppError(
@@ -745,6 +751,44 @@ function parseSettingState(state: string): boolean {
745751
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
746752
}
747753

754+
async function resolveAndroidAppearanceTarget(
755+
device: DeviceInfo,
756+
state: string,
757+
): Promise<'light' | 'dark'> {
758+
const action = parseAppearanceAction(state);
759+
if (action !== 'toggle') return action;
760+
761+
const currentResult = await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night']), {
762+
allowFailure: true,
763+
});
764+
if (currentResult.exitCode !== 0) {
765+
throw new AppError('COMMAND_FAILED', 'Failed to read current Android appearance', {
766+
stdout: currentResult.stdout,
767+
stderr: currentResult.stderr,
768+
exitCode: currentResult.exitCode,
769+
});
770+
}
771+
const current = parseAndroidAppearance(currentResult.stdout, currentResult.stderr);
772+
if (!current) {
773+
throw new AppError('COMMAND_FAILED', 'Unable to determine current Android appearance for toggle', {
774+
stdout: currentResult.stdout,
775+
stderr: currentResult.stderr,
776+
});
777+
}
778+
if (current === 'auto') return 'dark';
779+
return current === 'dark' ? 'light' : 'dark';
780+
}
781+
782+
function parseAndroidAppearance(stdout: string, stderr: string): 'light' | 'dark' | 'auto' | null {
783+
const match = /night mode:\s*(yes|no|auto)\b/i.exec(`${stdout}\n${stderr}`);
784+
if (!match) return null;
785+
const value = match[1].toLowerCase();
786+
if (value === 'yes') return 'dark';
787+
if (value === 'no') return 'light';
788+
if (value === 'auto') return 'auto';
789+
return null;
790+
}
791+
748792
function parseAndroidPermissionTarget(
749793
permissionTarget: string | undefined,
750794
permissionMode: string | undefined,

src/platforms/appearance.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { AppError } from '../utils/errors.ts';
2+
3+
export type AppearanceAction = 'light' | 'dark' | 'toggle';
4+
5+
export function parseAppearanceAction(state: string): AppearanceAction {
6+
const normalized = state.trim().toLowerCase();
7+
if (normalized === 'light') return 'light';
8+
if (normalized === 'dark') return 'dark';
9+
if (normalized === 'toggle') return 'toggle';
10+
throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`);
11+
}

src/platforms/ios/__tests__/index.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,122 @@ exit 1
571571
);
572572
});
573573

574+
test('setIosSetting appearance dark uses simctl ui appearance', async () => {
575+
await withMockedXcrun(
576+
'agent-device-ios-appearance-dark-test-',
577+
`#!/bin/sh
578+
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
579+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
580+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
581+
cat <<'JSON'
582+
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
583+
JSON
584+
exit 0
585+
fi
586+
if [ "$1" = "simctl" ] && [ "$2" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ "$5" = "dark" ]; then
587+
exit 0
588+
fi
589+
echo "unexpected xcrun args: $@" >&2
590+
exit 1
591+
`,
592+
async ({ argsLogPath }) => {
593+
const device: DeviceInfo = {
594+
platform: 'ios',
595+
id: 'sim-1',
596+
name: 'iPhone Sim',
597+
kind: 'simulator',
598+
booted: true,
599+
};
600+
await setIosSetting(device, 'appearance', 'dark');
601+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
602+
.trim()
603+
.split('\n')
604+
.filter(Boolean);
605+
const logged = lines.join(' ');
606+
assert.match(logged, /simctl ui sim-1 appearance dark/);
607+
},
608+
);
609+
});
610+
611+
test('setIosSetting appearance toggle flips current simulator appearance', async () => {
612+
await withMockedXcrun(
613+
'agent-device-ios-appearance-toggle-test-',
614+
`#!/bin/sh
615+
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
616+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
617+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
618+
cat <<'JSON'
619+
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
620+
JSON
621+
exit 0
622+
fi
623+
if [ "$1" = "simctl" ] && [ "$2" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ -z "$5" ]; then
624+
echo "dark"
625+
exit 0
626+
fi
627+
if [ "$1" = "simctl" ] && [ "$2" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ "$5" = "light" ]; then
628+
exit 0
629+
fi
630+
echo "unexpected xcrun args: $@" >&2
631+
exit 1
632+
`,
633+
async ({ argsLogPath }) => {
634+
const device: DeviceInfo = {
635+
platform: 'ios',
636+
id: 'sim-1',
637+
name: 'iPhone Sim',
638+
kind: 'simulator',
639+
booted: true,
640+
};
641+
await setIosSetting(device, 'appearance', 'toggle');
642+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
643+
.trim()
644+
.split('\n')
645+
.filter(Boolean);
646+
const logged = lines.join(' ');
647+
assert.match(logged, /simctl ui sim-1 appearance/);
648+
assert.match(logged, /simctl ui sim-1 appearance light/);
649+
},
650+
);
651+
});
652+
653+
test('setIosSetting appearance toggle rejects unsupported current appearance output', async () => {
654+
await withMockedXcrun(
655+
'agent-device-ios-appearance-toggle-unsupported-test-',
656+
`#!/bin/sh
657+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
658+
cat <<'JSON'
659+
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
660+
JSON
661+
exit 0
662+
fi
663+
if [ "$1" = "simctl" ] && [ "$2" = "ui" ] && [ "$3" = "sim-1" ] && [ "$4" = "appearance" ] && [ -z "$5" ]; then
664+
echo "unsupported"
665+
exit 0
666+
fi
667+
exit 0
668+
`,
669+
async () => {
670+
const device: DeviceInfo = {
671+
platform: 'ios',
672+
id: 'sim-1',
673+
name: 'iPhone Sim',
674+
kind: 'simulator',
675+
booted: true,
676+
};
677+
await assert.rejects(
678+
() => setIosSetting(device, 'appearance', 'toggle'),
679+
(error: unknown) => {
680+
assert.equal(error instanceof AppError, true);
681+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
682+
assert.match((error as AppError).message, /Unable to determine current iOS appearance/);
683+
return true;
684+
},
685+
);
686+
},
687+
);
688+
});
689+
574690
test('setIosSetting permission grant camera uses simctl privacy', async () => {
575691
await withMockedXcrun(
576692
'agent-device-ios-permission-camera-test-',

0 commit comments

Comments
 (0)