| 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 | */ |
| 15 | import { |
| 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"; |
| 26 | import * as Effect from "effect/Effect"; |
| 27 | import * as Schema from "effect/Schema"; |
| 28 | |
| 29 | import type { DeviceHostReady } from "./DeviceHost.ts"; |
| 30 | |
| 31 | type Runner = DeviceHostReady["run"]; |
| 32 | |
| 33 | const decodeAxStatus = Schema.decodeUnknownEffect( |
| 34 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), |
| 35 | ); |
| 36 | const encodePushPayload = Schema.encodeUnknownEffect( |
| 37 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), |
| 38 | ); |
| 39 | |
| 40 | const 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 | |
| 55 | const 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. */ |
| 69 | const IOS_TOGGLES = new Set([ |
| 70 | "reduceMotion", |
| 71 | "increaseContrast", |
| 72 | "reduceTransparency", |
| 73 | "showBorders", |
| 74 | "voiceOver", |
| 75 | ]); |
| 76 | const ANDROID_TOGGLES = new Set(["reduceMotion", "networkEnabled"]); |
| 77 | |
| 78 | export 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 | |
| 87 | const 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"). |
| 101 | const IOS_TEXT_SIZES: Record<DeviceTextSize, string> = { |
| 102 | small: "small", |
| 103 | default: "large", |
| 104 | large: "extra-extra-large", |
| 105 | "extra-large": "accessibility-large", |
| 106 | }; |
| 107 | const 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 | |
| 114 | const 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 | |
| 126 | const 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 | |
| 133 | const 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. |
| 142 | const 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 | |
| 154 | const 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. |
| 170 | const 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 | }; |
| 176 | const ANDROID_ROTATION: Record<DeviceOrientation, string> = { |
| 177 | portrait: "0", |
| 178 | landscape_left: "1", |
| 179 | portrait_upside_down: "2", |
| 180 | landscape_right: "3", |
| 181 | }; |
| 182 | |
| 183 | export 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 | |
| 199 | const 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 | |
| 204 | const 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 | |
| 219 | const 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 | |
| 316 | const 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 | |
| 342 | const adb = (run: Runner, serial: string, args: ReadonlyArray<string>, operation: string) => |
| 343 | run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); |
| 344 | |
| 345 | const 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. */ |
| 463 | export 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 | |
| 473 | const quiet = <A, E>(effect: Effect.Effect<A, E>) => |
| 474 | effect.pipe(Effect.orElseSucceed((): A | undefined => undefined)); |
| 475 | |
| 476 | const 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 | |
| 526 | const 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 | |
| 533 | const 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 | }); |