From 48fc2ad53ec1e6bfadba6bd617bc9202b778b74d Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 20 Oct 2026 04:56:14 +0000 Subject: [PATCH 1/2] fix(server): preserve URL arguments in Android device actions --- apps/server/src/device/DeviceActions.test.ts | 39 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 14 +++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index a91236579caae..121a09bc8bd8c 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,5 +1,8 @@ +import * as NodeChildProcess from "node:child_process"; + import { describe, expect, it } from "@effect/vitest"; import type { DeviceActionInput } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts"; @@ -59,6 +62,42 @@ describe("supportsAction", () => { }); describe("runDeviceAction", () => { + it.effect.each([ + "https://example.com/callback?code=1&state=2", + "https://example.com/it's?a=1&b=2", + "https://example.com/a b?key=value", + "https://example.com/plain", + ])("opens the complete Android URL %s", (url) => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "android", { + type: "openUrl", + deviceId: "emulator-5554", + url, + }); + const args = calls[0]!.args; + expect(args.slice(0, 3)).toEqual(["-s", "emulator-5554", "shell"]); + // adb joins its shell arguments without escaping before the device runs sh -c. + const result = NodeChildProcess.spawnSync( + "/bin/sh", + [ + "-c", + `am() { printf '%s\\0' "$@"; } +${args.slice(3).join(" ")}`, + ], + { encoding: "utf8" }, + ); + expect(result.status).toBe(0); + expect(result.stdout.split("\0").slice(0, -1)).toEqual([ + "start", + "-a", + "android.intent.action.VIEW", + "-d", + url, + ]); + }), + ); it.effect("maps shared text sizes onto simctl content-size categories", () => Effect.gen(function* () { const { ready, calls } = makeReady(); diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 5d2ddca0f740b..482e850208cd6 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -349,13 +349,21 @@ const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(funct const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); +// `adb shell` joins its arguments with spaces and hands the result to the device's `sh -c` +// without escaping, so URLs and app ids must be quoted to arrive as single words. +const androidShellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; + +const adbShell = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => + adb(run, serial, ["shell", ...args.map(androidShellWord)], operation); + const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( run: Runner, input: DeviceActionInput, ) { const serial = input.deviceId; const shell = (args: ReadonlyArray, operation: string) => - adb(run, serial, ["shell", ...args], operation); + adbShell(run, serial, args, operation); switch (input.type) { case "setAppearance": yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance"); @@ -540,9 +548,7 @@ const isColorFilter = (value: unknown): value is DeviceSettings["colorFilter"] & const readAndroid = Effect.fn("DeviceActions.readAndroid")(function* (run: Runner, serial: string) { const shell = (args: ReadonlyArray) => - quiet( - adb(run, serial, ["shell", ...args], args[0] ?? "shell").pipe(Effect.map((s) => s.trim())), - ); + quiet(adbShell(run, serial, args, args[0] ?? "shell").pipe(Effect.map((s) => s.trim()))); const [night, fontScale, animator, wifi, focus] = yield* Effect.all( [ shell(["cmd", "uimode", "night"]), From b83d48733a0cbb5701bb803501fc195cc2c84a65 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 20 Oct 2026 05:27:30 +0000 Subject: [PATCH 2/2] test(server): document native shell regression boundary --- apps/server/src/device/DeviceActions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 121a09bc8bd8c..000b688b2ab98 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Runs a real shell to reproduce adb joining arguments before Android executes them. import * as NodeChildProcess from "node:child_process"; import { describe, expect, it } from "@effect/vitest";