Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/server/src/device/DeviceActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
// @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";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";

import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
Expand Down Expand Up @@ -43,6 +47,42 @@ const makeReady = (
const udid = "SIM-1";

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.each([
["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"],
["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"],
Expand Down
14 changes: 10 additions & 4 deletions apps/server/src/device/DeviceActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,13 +349,21 @@ const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(funct
const adb = (run: Runner, serial: string, args: ReadonlyArray<string>, 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<string>, 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<string>, 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");
Expand Down Expand Up @@ -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<string>) =>
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"]),
Expand Down
Loading