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