1// serve-sim's preview socket accepts shell commands; use typed actions to avoid arbitrary host execution.
2import {
3 type DeviceActionInput,
4 type DeviceActionType,
5 type DeviceForegroundApp,
6 DeviceOperationError,
7 DeviceActionUnavailableError,
8 type DeviceOrientation,
9 type DevicePlatform,
10 type DeviceSettings,
11 type DeviceTextSize,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Result from "effect/Result";
15import * as Schema from "effect/Schema";
16
17import type { DeviceHostReady } from "./DeviceHost.ts";
18
19type Runner = DeviceHostReady["run"];
20
21const decodeAxStatus = Schema.decodeUnknownEffect(
22 Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)),
23);
24const encodePushPayload = Schema.encodeUnknownEffect(
25 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
26);
27
28const IOS_ACTIONS: ReadonlySet<DeviceActionType> = new Set([
29 "setAppearance",
30 "setTextSize",
31 "setToggle",
32 "setLiquidGlass",
33 "setColorFilter",
34 "setLocation",
35 "clearLocation",
36 "setPermission",
37 "openUrl",
38 "launchApp",
39 "terminateApp",
40 "sendPush",
41]);
42
43const ANDROID_ACTIONS: ReadonlySet<DeviceActionType> = new Set([
44 "setAppearance",
45 "setTextSize",
46 "setToggle",
47 "setOrientation",
48 "setLocation",
49 "clearLocation",
50 "setPermission",
51 "openUrl",
52 "launchApp",
53 "terminateApp",
54]);
55
56const IOS_TOGGLES = new Set([
57 "reduceMotion",
58 "increaseContrast",
59 "reduceTransparency",
60 "showBorders",
61 "voiceOver",
62]);
63const ANDROID_TOGGLES = new Set(["reduceMotion", "networkEnabled"]);
64
65const supportsAction = (platform: DevicePlatform, input: DeviceActionInput): boolean => {
66 const actions = platform === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS;
67 if (!actions.has(input.type)) return false;
68 if (input.type === "setToggle") {
69 return (platform === "ios" ? IOS_TOGGLES : ANDROID_TOGGLES).has(input.setting);
70 }
71 return true;
72};
73
74const ok = (operation: string) => (result: { code: number; stderr: string; stdout: string }) =>
75 result.code === 0
76 ? Effect.succeed(result.stdout)
77 : Effect.fail(
78 new DeviceOperationError({
79 operation,
80 reason: "command_failed",
81 exitCode: result.code,
82 cause: result,
83 }),
84 );
85
86const IOS_TEXT_SIZES: Record<DeviceTextSize, string> = {
87 small: "small",
88 default: "large",
89 large: "extra-extra-large",
90 "extra-large": "accessibility-large",
91};
92const ANDROID_TEXT_SIZES: Record<DeviceTextSize, string> = {
93 small: "0.85",
94 default: "1.0",
95 large: "1.15",
96 "extra-large": "1.3",
97};
98
99const textSizeFromIos = (category: string): DeviceTextSize | undefined => {
100 const entry = (Object.entries(IOS_TEXT_SIZES) as Array<[DeviceTextSize, string]>).find(
101 ([, value]) => value === category,
102 );
103 if (entry) return entry[0];
104 if (category.startsWith("accessibility")) return "extra-large";
105 if (category === "extra-small" || category === "small" || category === "medium") return "small";
106 return category.includes("extra") ? "large" : "default";
107};
108
109const textSizeFromAndroid = (scale: number): DeviceTextSize => {
110 if (scale <= 0.9) return "small";
111 if (scale >= 1.25) return "extra-large";
112 if (scale >= 1.1) return "large";
113 return "default";
114};
115
116const IOS_TOGGLE_OPTIONS: Record<string, string> = {
117 reduceMotion: "reduce-motion",
118 increaseContrast: "increase-contrast",
119 reduceTransparency: "reduce-transparency",
120 showBorders: "show-borders",
121 voiceOver: "voiceover",
122};
123
124const IOS_TCC_SERVICES: Record<string, string> = {
125 camera: "camera",
126 microphone: "microphone",
127 photos: "photos",
128 contacts: "contacts",
129 calendar: "calendar",
130 reminders: "reminders",
131 motion: "motion",
132 "media-library": "media-library",
133 faceid: "faceid",
134};
135
136const ANDROID_PERMISSIONS: Record<string, ReadonlyArray<string>> = {
137 camera: ["android.permission.CAMERA"],
138 microphone: ["android.permission.RECORD_AUDIO"],
139 photos: ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
140 contacts: ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"],
141 calendar: ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"],
142 location: [
143 "android.permission.ACCESS_FINE_LOCATION",
144 "android.permission.ACCESS_COARSE_LOCATION",
145 ],
146 notifications: ["android.permission.POST_NOTIFICATIONS"],
147 motion: ["android.permission.ACTIVITY_RECOGNITION"],
148};
149
150// Android numbers rotations from the display's natural orientation and the emulator's accelerometer
151// reports gravity in that same frame, so both tables are keyed by the orientation a portrait-native
152// device shows at each rotation.
153const ANDROID_GRAVITY: Record<DeviceOrientation, string> = {
154 portrait: "0:9.81:0",
155 landscape_left: "9.81:0:0",
156 portrait_upside_down: "0:-9.81:0",
157 landscape_right: "-9.81:0:0",
158};
159const ANDROID_ROTATION: Record<DeviceOrientation, string> = {
160 portrait: "0",
161 landscape_left: "1",
162 portrait_upside_down: "2",
163 landscape_right: "3",
164};
165// A landscape-native tablet sits a quarter turn further along that sequence: its portrait is
166// rotation 3 and its landscape is rotation 0.
167const ANDROID_LANDSCAPE_NATIVE_ROTATION: Record<DeviceOrientation, DeviceOrientation> = {
168 portrait: "landscape_right",
169 landscape_left: "portrait",
170 portrait_upside_down: "landscape_left",
171 landscape_right: "portrait_upside_down",
172};
173
174// `wm size` reports the natural-orientation size; an `Override size:` line is what the window
175// manager uses and is printed after `Physical size:`.
176const isLandscapeNative = (wmSize: string | undefined) => {
177 const size = wmSize ? [...wmSize.matchAll(/size: (\d+)x(\d+)/g)].at(-1) : undefined;
178 return size !== undefined && Number(size[1]) > Number(size[2]);
179};
180
181export const runDeviceAction = Effect.fn("DeviceActions.run")(function* (
182 ready: DeviceHostReady,
183 platform: DevicePlatform,
184 input: DeviceActionInput,
185) {
186 if (!supportsAction(platform, input)) {
187 return yield* new DeviceActionUnavailableError({
188 operation: input.type,
189 platform,
190 reason: "unsupported",
191 });
192 }
193 if (platform === "ios") return yield* runIos(ready, input);
194 return yield* runAndroid(ready.run, input);
195});
196
197const simctl = (run: Runner, udid: string, args: ReadonlyArray<string>, operation: string) =>
198 run("xcrun", ["simctl", ...args.slice(0, 1), udid, ...args.slice(1)]).pipe(
199 Effect.flatMap(ok(operation)),
200 );
201
202const axSettings = Effect.fn("DeviceActions.axSettings")(function* (
203 ready: DeviceHostReady,
204 udid: string,
205 args: ReadonlyArray<string>,
206) {
207 const helper = ready.helpers.serveSimAxSettings;
208 if (!helper) {
209 return yield* new DeviceActionUnavailableError({
210 operation: "accessibility",
211 platform: "ios",
212 reason: "helper_missing",
213 });
214 }
215 return yield* ready
216 .run("xcrun", ["simctl", "spawn", udid, helper, ...args])
217 .pipe(Effect.flatMap(ok("accessibility")));
218});
219
220const runIos = Effect.fn("DeviceActions.runIos")(function* (
221 ready: DeviceHostReady,
222 input: DeviceActionInput,
223) {
224 const udid = input.deviceId;
225 const { run } = ready;
226 switch (input.type) {
227 case "setAppearance":
228 yield* simctl(run, udid, ["ui", "appearance", input.value], "appearance");
229 return;
230 case "setTextSize":
231 yield* simctl(run, udid, ["ui", "content_size", IOS_TEXT_SIZES[input.value]], "text size");
232 return;
233 case "setToggle":
234 if (input.setting === "increaseContrast") {
235 yield* simctl(
236 run,
237 udid,
238 ["ui", "increase_contrast", input.value ? "enabled" : "disabled"],
239 "increase contrast",
240 );
241 return;
242 }
243 yield* axSettings(ready, udid, [
244 "set",
245 IOS_TOGGLE_OPTIONS[input.setting]!,
246 input.value ? "on" : "off",
247 ]);
248 return;
249 case "setLiquidGlass":
250 yield* axSettings(ready, udid, ["set", "liquid-glass", input.value]);
251 return;
252 case "setColorFilter":
253 yield* axSettings(ready, udid, ["set", "color-filter", input.value]);
254 return;
255 case "setLocation":
256 yield* simctl(
257 run,
258 udid,
259 ["location", "set", `${input.latitude},${input.longitude}`],
260 "location",
261 );
262 return;
263 case "clearLocation":
264 yield* simctl(run, udid, ["location", "clear"], "location");
265 return;
266 case "setPermission": {
267 if (input.permission === "notifications") {
268 // simctl lacks notification permissions; serve-sim edits the BulletinBoard plist.
269 yield* serveSimPermissions(ready, udid, input);
270 return;
271 }
272 const service =
273 input.permission === "location" ? "location" : IOS_TCC_SERVICES[input.permission];
274 if (!service)
275 return yield* new DeviceActionUnavailableError({
276 operation: "permission",
277 platform: "ios",
278 reason: "unsupported",
279 });
280 yield* simctl(run, udid, ["privacy", input.decision, service, input.appId], "permission");
281 return;
282 }
283 case "openUrl":
284 yield* simctl(run, udid, ["openurl", input.url], "open url");
285 return;
286 case "launchApp":
287 yield* simctl(run, udid, ["launch", input.appId], "launch");
288 return;
289 case "terminateApp":
290 yield* simctl(run, udid, ["terminate", input.appId], "terminate");
291 return;
292 case "sendPush": {
293 const payload =
294 typeof input.payload === "string" ? { aps: { alert: input.payload } } : input.payload;
295 const encoded = yield* encodePushPayload(payload).pipe(
296 Effect.mapError(
297 (cause) =>
298 new DeviceOperationError({ operation: "push", reason: "invalid_payload", cause }),
299 ),
300 );
301 yield* run("xcrun", ["simctl", "push", udid, input.appId, "-"], { stdin: encoded }).pipe(
302 Effect.flatMap(ok("push")),
303 );
304 return;
305 }
306 case "shake":
307 case "setOrientation":
308 return yield* new DeviceActionUnavailableError({
309 operation: input.type,
310 platform: "ios",
311 reason: "unsupported",
312 });
313 }
314});
315
316const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(function* (
317 ready: DeviceHostReady,
318 udid: string,
319 input: Extract<DeviceActionInput, { type: "setPermission" }>,
320) {
321 const cli = ready.helpers.serveSimCli;
322 if (!cli)
323 return yield* new DeviceActionUnavailableError({
324 operation: "permission",
325 platform: "ios",
326 reason: "helper_missing",
327 });
328 yield* ready
329 .run(ready.nodePath, [
330 cli,
331 "permissions",
332 input.decision,
333 input.permission,
334 input.appId,
335 "-d",
336 udid,
337 ])
338 .pipe(Effect.flatMap(ok("permission")));
339});
340
341const adb = (run: Runner, serial: string, args: ReadonlyArray<string>, operation: string) =>
342 run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation)));
343
344// adb shell joins arguments without escaping before the device's shell parses them.
345const androidShellWord = (value: string) =>
346 /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
347
348const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* (
349 run: Runner,
350 input: DeviceActionInput,
351) {
352 const serial = input.deviceId;
353 const shell = (args: ReadonlyArray<string>, operation: string) =>
354 adb(run, serial, ["shell", ...args.map(androidShellWord)], operation);
355 switch (input.type) {
356 case "setAppearance":
357 yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance");
358 return;
359 case "setTextSize":
360 yield* shell(
361 ["settings", "put", "system", "font_scale", ANDROID_TEXT_SIZES[input.value]],
362 "text size",
363 );
364 return;
365 case "setToggle":
366 if (input.setting === "networkEnabled") {
367 const state = input.value ? "enable" : "disable";
368 yield* shell(["svc", "wifi", state], "network");
369 yield* shell(["svc", "data", state], "network");
370 return;
371 }
372 if (input.setting === "reduceMotion") {
373 const scale = input.value ? "0" : "1";
374 for (const key of [
375 "animator_duration_scale",
376 "transition_animation_scale",
377 "window_animation_scale",
378 ]) {
379 yield* shell(["settings", "put", "global", key, scale], "reduce motion");
380 }
381 return;
382 }
383 return yield* new DeviceActionUnavailableError({
384 operation: input.type,
385 platform: "android",
386 reason: "unsupported",
387 });
388 case "setOrientation": {
389 const wmSize = yield* quiet(shell(["wm", "size"], "orientation"));
390 const rotation = isLandscapeNative(wmSize)
391 ? ANDROID_LANDSCAPE_NATIVE_ROTATION[input.value]
392 : input.value;
393 // `user-rotation lock` only rotates window content on recent images; the display the
394 // encoder captures stays put. Tilting the emulator's accelerometer rotates it for real, so
395 // that is used whenever the target is an emulator.
396 if (serial.startsWith("emulator-")) {
397 yield* shell(["settings", "put", "system", "accelerometer_rotation", "1"], "orientation");
398 yield* shell(["cmd", "window", "user-rotation", "free"], "orientation");
399 yield* adb(
400 run,
401 serial,
402 ["emu", "sensor", "set", "acceleration", ANDROID_GRAVITY[rotation]],
403 "orientation",
404 );
405 return;
406 }
407 yield* shell(
408 ["cmd", "window", "user-rotation", "lock", ANDROID_ROTATION[rotation]],
409 "orientation",
410 );
411 return;
412 }
413 case "setLocation":
414 yield* adb(
415 run,
416 serial,
417 ["emu", "geo", "fix", String(input.longitude), String(input.latitude)],
418 "location",
419 );
420 return;
421 case "clearLocation":
422 // The emulator cannot clear location; keep the fix and refresh the reading.
423 return;
424 case "setPermission": {
425 const permissions = ANDROID_PERMISSIONS[input.permission];
426 if (!permissions) {
427 return yield* new DeviceActionUnavailableError({
428 operation: "permission",
429 platform: "android",
430 reason: "unsupported",
431 });
432 }
433 const verb = input.decision === "grant" ? "grant" : "revoke";
434 // `pm` rejects a permission the app or system image does not declare, and Photos lists both
435 // the Android 13 and the older storage permission, so fail only when none of them applied.
436 const results = yield* Effect.forEach(permissions, (permission) =>
437 shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.result),
438 );
439 const failure = results.find(Result.isFailure);
440 if (failure && !results.some(Result.isSuccess)) return yield* failure.failure;
441 return;
442 }
443 case "openUrl":
444 yield* shell(
445 ["am", "start", "-a", "android.intent.action.VIEW", "-d", input.url],
446 "open url",
447 );
448 return;
449 case "launchApp":
450 yield* shell(
451 ["monkey", "-p", input.appId, "-c", "android.intent.category.LAUNCHER", "1"],
452 "launch",
453 );
454 return;
455 case "terminateApp":
456 yield* shell(["am", "force-stop", input.appId], "terminate");
457 return;
458 case "setLiquidGlass":
459 case "setColorFilter":
460 case "shake":
461 case "sendPush":
462 return yield* new DeviceActionUnavailableError({
463 operation: input.type,
464 platform: "android",
465 reason: "unsupported",
466 });
467 }
468});
469
470export const readDeviceDetail = Effect.fn("DeviceActions.readDetail")(function* (
471 ready: DeviceHostReady,
472 platform: DevicePlatform,
473 deviceId: string,
474): Effect.fn.Return<{ settings: DeviceSettings; foregroundApp: DeviceForegroundApp | null }> {
475 return platform === "ios"
476 ? yield* readIos(ready, deviceId)
477 : yield* readAndroid(ready.run, deviceId);
478});
479
480const quiet = <A, E>(effect: Effect.Effect<A, E>) =>
481 effect.pipe(Effect.orElseSucceed((): A | undefined => undefined));
482
483const readIos = Effect.fn("DeviceActions.readIos")(function* (
484 ready: DeviceHostReady,
485 udid: string,
486) {
487 const { run } = ready;
488 const uiValue = (option: string) =>
489 quiet(
490 simctl(run, udid, ["ui", option], option).pipe(Effect.map((out) => out.trim().toLowerCase())),
491 );
492 const [appearance, contentSize, contrast, axStatus] = yield* Effect.all(
493 [
494 uiValue("appearance"),
495 uiValue("content_size"),
496 uiValue("increase_contrast"),
497 quiet(axSettings(ready, udid, ["status"]).pipe(Effect.flatMap(decodeAxStatus))),
498 ],
499 { concurrency: 4 },
500 );
501 const onOff = (value: string | undefined) =>
502 value === "on" ? true : value === "off" ? false : undefined;
503 const settings: DeviceSettings = {
504 ...(appearance === "light" || appearance === "dark" ? { appearance } : {}),
505 ...(contentSize ? { textSize: textSizeFromIos(contentSize) } : {}),
506 ...(contrast ? { increaseContrast: contrast === "enabled" } : {}),
507 ...(axStatus
508 ? {
509 ...(onOff(axStatus["reduce-motion"]) === undefined
510 ? {}
511 : { reduceMotion: onOff(axStatus["reduce-motion"]) }),
512 ...(onOff(axStatus["reduce-transparency"]) === undefined
513 ? {}
514 : { reduceTransparency: onOff(axStatus["reduce-transparency"]) }),
515 ...(onOff(axStatus["show-borders"]) === undefined
516 ? {}
517 : { showBorders: onOff(axStatus["show-borders"]) }),
518 ...(onOff(axStatus.voiceover) === undefined
519 ? {}
520 : { voiceOver: onOff(axStatus.voiceover) }),
521 ...(axStatus["liquid-glass"] === "clear" || axStatus["liquid-glass"] === "tinted"
522 ? { liquidGlass: axStatus["liquid-glass"] }
523 : {}),
524 ...(isColorFilter(axStatus["color-filter"])
525 ? { colorFilter: axStatus["color-filter"] }
526 : {}),
527 }
528 : {}),
529 };
530 return { settings, foregroundApp: null };
531});
532
533const isColorFilter = (value: unknown): value is DeviceSettings["colorFilter"] & string =>
534 value === "none" ||
535 value === "grayscale" ||
536 value === "red-green" ||
537 value === "green-red" ||
538 value === "blue-yellow";
539
540const readAndroid = Effect.fn("DeviceActions.readAndroid")(function* (run: Runner, serial: string) {
541 const shell = (args: ReadonlyArray<string>) =>
542 quiet(
543 adb(run, serial, ["shell", ...args], args[0] ?? "shell").pipe(Effect.map((s) => s.trim())),
544 );
545 const [night, fontScale, animator, wifi, focus] = yield* Effect.all(
546 [
547 shell(["cmd", "uimode", "night"]),
548 shell(["settings", "get", "system", "font_scale"]),
549 shell(["settings", "get", "global", "animator_duration_scale"]),
550 shell(["settings", "get", "global", "wifi_on"]),
551 // API 36 dropped focus from `dumpsys window windows`; use the unfiltered dump.
552 shell(["dumpsys", "window"]),
553 ],
554 { concurrency: 5 },
555 );
556 const scale = fontScale && fontScale !== "null" ? Number(fontScale) : Number.NaN;
557 const focused = focus?.match(/m(?:CurrentFocus|FocusedApp)=\w+\{[^ ]+ u\d+ ([^/ ]+)\//);
558 const settings: DeviceSettings = {
559 ...(night?.includes("yes")
560 ? { appearance: "dark" }
561 : night?.includes("no")
562 ? { appearance: "light" }
563 : {}),
564 ...(Number.isFinite(scale) ? { textSize: textSizeFromAndroid(scale) } : {}),
565 ...(animator !== undefined && animator !== "null"
566 ? { reduceMotion: Number(animator) === 0 }
567 : {}),
568 ...(wifi === "1" || wifi === "0" ? { networkEnabled: wifi === "1" } : {}),
569 };
570 return {
571 settings,
572 foregroundApp: focused ? { id: focused[1]! } : null,
573 };
574});