From f9faa7c94659071f76b6f36097cb96ac224cd92f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 01:56:10 +0000 Subject: [PATCH] fix(server): preserve Android device action URL arguments Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/device/DeviceActions.test.ts | 38 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 6 +++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 41e1de4304692..1a8cab7f29837 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -43,6 +47,40 @@ const makeReady = ( const udid = "SIM-1"; describe("runDeviceAction", () => { + it.live.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"]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // adb joins arguments verbatim before the Android shell parses them. + const stdout = yield* spawner.string( + ChildProcess.make("/bin/sh", [ + "-c", + `am() { printf '%s\\0' "$@"; }\n${args.slice(3).join(" ")}`, + ]), + ); + expect(stdout.split("\0").slice(0, -1)).toEqual([ + "start", + "-a", + "android.intent.action.VIEW", + "-d", + url, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"], ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"], diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 002d8cb3e7d28..905a23d5cc726 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -342,13 +342,17 @@ 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 arguments without escaping before the device's shell parses them. +const androidShellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; + 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); + adb(run, serial, ["shell", ...args.map(androidShellWord)], operation); switch (input.type) { case "setAppearance": yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance");