Skip to content

Commit 544211d

Browse files
committed
fix: harden settings appearance parsing and dedupe usage contract
1 parent bb9ac02 commit 544211d

8 files changed

Lines changed: 135 additions & 34 deletions

File tree

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/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>, appearance <light|dark|toggle>, 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,58 @@ test('setAndroidSetting appearance toggle flips current mode', async () => {
336336
);
337337
});
338338

339+
test('setAndroidSetting appearance toggle from auto sets dark mode', async () => {
340+
await withMockedAdb(
341+
'agent-device-android-appearance-toggle-auto-',
342+
[
343+
'#!/bin/sh',
344+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
345+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
346+
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
347+
' echo "Night mode: auto"',
348+
' exit 0',
349+
'fi',
350+
'exit 0',
351+
'',
352+
].join('\n'),
353+
async ({ argsLogPath, device }) => {
354+
await setAndroidSetting(device, 'appearance', 'toggle');
355+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
356+
.trim()
357+
.split('\n')
358+
.filter(Boolean);
359+
const logged = lines.join(' ');
360+
assert.match(logged, /shell cmd uimode night yes/);
361+
},
362+
);
363+
});
364+
365+
test('setAndroidSetting appearance toggle rejects unknown current mode output', async () => {
366+
await withMockedAdb(
367+
'agent-device-android-appearance-toggle-unknown-',
368+
[
369+
'#!/bin/sh',
370+
'if [ "$1" = "-s" ] && [ "$4" = "cmd" ] && [ "$5" = "uimode" ] && [ "$6" = "night" ] && [ -z "$7" ]; then',
371+
' echo "mode unavailable"',
372+
' exit 0',
373+
'fi',
374+
'exit 0',
375+
'',
376+
].join('\n'),
377+
async ({ device }) => {
378+
await assert.rejects(
379+
() => setAndroidSetting(device, 'appearance', 'toggle'),
380+
(error: unknown) => {
381+
assert.equal(error instanceof AppError, true);
382+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
383+
assert.match((error as AppError).message, /Unable to determine current Android appearance/);
384+
return true;
385+
},
386+
);
387+
},
388+
);
389+
});
390+
339391
test('swipeAndroid invokes adb input swipe with duration', async () => {
340392
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
341393
const adbPath = path.join(tmpDir, 'adb');

src/platforms/android/index.ts

Lines changed: 9 additions & 18 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' },
@@ -733,16 +734,6 @@ function parseSettingState(state: string): boolean {
733734
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
734735
}
735736

736-
type AppearanceAction = 'light' | 'dark' | 'toggle';
737-
738-
function parseAppearanceAction(state: string): AppearanceAction {
739-
const normalized = state.trim().toLowerCase();
740-
if (normalized === 'light') return 'light';
741-
if (normalized === 'dark') return 'dark';
742-
if (normalized === 'toggle') return 'toggle';
743-
throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`);
744-
}
745-
746737
async function resolveAndroidAppearanceTarget(
747738
device: DeviceInfo,
748739
state: string,
@@ -767,17 +758,17 @@ async function resolveAndroidAppearanceTarget(
767758
stderr: currentResult.stderr,
768759
});
769760
}
761+
if (current === 'auto') return 'dark';
770762
return current === 'dark' ? 'light' : 'dark';
771763
}
772764

773-
function parseAndroidAppearance(stdout: string, stderr: string): 'light' | 'dark' | null {
774-
const output = `${stdout}\n${stderr}`.toLowerCase();
775-
if (output.includes('night mode: yes')) return 'dark';
776-
if (output.includes('night mode: no')) return 'light';
777-
if (/\bdark\b/.test(output)) return 'dark';
778-
if (/\blight\b/.test(output)) return 'light';
779-
if (/\byes\b/.test(output)) return 'dark';
780-
if (/\bno\b/.test(output)) return 'light';
765+
function parseAndroidAppearance(stdout: string, stderr: string): 'light' | 'dark' | 'auto' | null {
766+
const match = /night mode:\s*(yes|no|auto)\b/i.exec(`${stdout}\n${stderr}`);
767+
if (!match) return null;
768+
const value = match[1].toLowerCase();
769+
if (value === 'yes') return 'dark';
770+
if (value === 'no') return 'light';
771+
if (value === 'auto') return 'auto';
781772
return null;
782773
}
783774

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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,43 @@ exit 1
650650
);
651651
});
652652

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+
653690
test('setIosSetting permission grant camera uses simctl privacy', async () => {
654691
await withMockedXcrun(
655692
'agent-device-ios-permission-camera-test-',

src/platforms/ios/apps.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
parsePermissionTarget,
99
type PermissionSettingOptions,
1010
} from '../permission-utils.ts';
11+
import { parseAppearanceAction } from '../appearance.ts';
1112

1213
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
1314
import {
@@ -360,16 +361,6 @@ function parseSettingState(state: string): boolean {
360361
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
361362
}
362363

363-
type AppearanceAction = 'light' | 'dark' | 'toggle';
364-
365-
function parseAppearanceAction(state: string): AppearanceAction {
366-
const normalized = state.trim().toLowerCase();
367-
if (normalized === 'light') return 'light';
368-
if (normalized === 'dark') return 'dark';
369-
if (normalized === 'toggle') return 'toggle';
370-
throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`);
371-
}
372-
373364
async function resolveIosAppearanceTarget(deviceId: string, state: string): Promise<'light' | 'dark'> {
374365
const action = parseAppearanceAction(state);
375366
if (action !== 'toggle') return action;
@@ -395,9 +386,12 @@ async function resolveIosAppearanceTarget(deviceId: string, state: string): Prom
395386
}
396387

397388
function parseIosAppearance(stdout: string, stderr: string): 'light' | 'dark' | null {
398-
const match = /\b(light|dark)\b/i.exec(`${stdout}\n${stderr}`);
389+
const match = /\b(light|dark|unsupported|unknown)\b/i.exec(`${stdout}\n${stderr}`);
399390
if (!match) return null;
400-
return match[1].toLowerCase() === 'dark' ? 'dark' : 'light';
391+
const value = match[1].toLowerCase();
392+
if (value === 'dark') return 'dark';
393+
if (value === 'light') return 'light';
394+
return null;
401395
}
402396

403397
type FaceIdAction = 'match' | 'nonmatch' | 'enroll' | 'unenroll';

src/utils/command-schema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts';
2+
13
export type CliFlags = {
24
json: boolean;
35
platform?: 'ios' | 'android';
@@ -557,8 +559,7 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
557559
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS],
558560
},
559561
settings: {
560-
usageOverride:
561-
'settings <wifi|airplane|location> <on|off> | settings appearance <light|dark|toggle> | settings faceid <match|nonmatch|enroll|unenroll> | settings permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]',
562+
usageOverride: SETTINGS_USAGE_OVERRIDE,
562563
description: 'Toggle OS settings, appearance, and app permissions (session app scope for permission actions)',
563564
positionalArgs: ['setting', 'state', 'target?', 'mode?'],
564565
allowedFlags: [],

0 commit comments

Comments
 (0)