From d3019ce73876f49a8cada7d349ec8356eb7f751c Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Wed, 2 Apr 2025 15:20:05 +0800 Subject: [PATCH] feat(adb): rewrite process spawner API (#739) --- apps/cli/src/index.ts | 21 +- libraries/adb-scrcpy/src/1_15/options.ts | 11 +- libraries/adb-scrcpy/src/1_15_1.ts | 11 +- libraries/adb-scrcpy/src/1_16.ts | 11 +- libraries/adb-scrcpy/src/1_17.ts | 11 +- libraries/adb-scrcpy/src/1_18.ts | 11 +- libraries/adb-scrcpy/src/1_19.ts | 11 +- libraries/adb-scrcpy/src/1_20.ts | 11 +- libraries/adb-scrcpy/src/1_21.ts | 11 +- libraries/adb-scrcpy/src/1_22/options.ts | 11 +- libraries/adb-scrcpy/src/1_23.ts | 11 +- libraries/adb-scrcpy/src/1_24.ts | 11 +- libraries/adb-scrcpy/src/1_25.ts | 11 +- libraries/adb-scrcpy/src/2_0/options.ts | 11 +- libraries/adb-scrcpy/src/2_1/options.ts | 11 +- libraries/adb-scrcpy/src/2_1_1.ts | 11 +- libraries/adb-scrcpy/src/2_2.ts | 11 +- libraries/adb-scrcpy/src/2_3.ts | 11 +- libraries/adb-scrcpy/src/2_3_1.ts | 11 +- libraries/adb-scrcpy/src/2_4.ts | 11 +- libraries/adb-scrcpy/src/2_5.ts | 11 +- libraries/adb-scrcpy/src/2_6.ts | 11 +- libraries/adb-scrcpy/src/2_7.ts | 11 +- libraries/adb-scrcpy/src/3_0.ts | 11 +- libraries/adb-scrcpy/src/3_0_1.ts | 11 +- libraries/adb-scrcpy/src/3_0_2.ts | 11 +- libraries/adb-scrcpy/src/3_1.ts | 11 +- libraries/adb-scrcpy/src/client-options.ts | 6 + libraries/adb-scrcpy/src/client.ts | 62 +++-- libraries/adb-scrcpy/src/index.ts | 2 + libraries/adb-scrcpy/src/latest.ts | 8 +- libraries/adb-scrcpy/src/types.ts | 18 +- libraries/adb-server-node-tcp/src/index.ts | 2 +- libraries/adb/src/adb.ts | 24 +- libraries/adb/src/commands/base.ts | 9 +- libraries/adb/src/commands/power.ts | 12 +- .../adb/src/commands/subprocess/command.ts | 144 ----------- .../adb/src/commands/subprocess/index.ts | 5 +- .../adb/src/commands/subprocess/none/index.ts | 4 + .../none.spec.ts => none/process.spec.ts} | 101 +++----- .../src/commands/subprocess/none/process.ts | 56 +++++ .../adb/src/commands/subprocess/none/pty.ts | 45 ++++ .../src/commands/subprocess/none/service.ts | 42 ++++ .../src/commands/subprocess/none/spawner.ts | 68 +++++ .../commands/subprocess/protocols/index.ts | 3 - .../src/commands/subprocess/protocols/none.ts | 90 ------- .../commands/subprocess/protocols/shell.ts | 177 ------------- .../commands/subprocess/protocols/types.ts | 72 ------ libraries/adb/src/commands/subprocess/pty.ts | 15 ++ .../adb/src/commands/subprocess/service.ts | 32 +++ .../src/commands/subprocess/shell/index.ts | 5 + .../shell.spec.ts => shell/process.spec.ts} | 55 ++--- .../src/commands/subprocess/shell/process.ts | 127 ++++++++++ .../adb/src/commands/subprocess/shell/pty.ts | 112 +++++++++ .../src/commands/subprocess/shell/service.ts | 58 +++++ .../commands/subprocess/shell/shared.spec.ts | 16 ++ .../src/commands/subprocess/shell/shared.ts | 25 ++ .../src/commands/subprocess/shell/spawner.ts | 90 +++++++ .../adb/src/commands/subprocess/utils.ts | 41 +++ .../adb/src/commands/sync/socket.spec.ts | 2 +- libraries/adb/src/commands/sync/sync.ts | 2 +- libraries/adb/src/commands/tcpip.ts | 4 +- libraries/adb/src/daemon/socket.ts | 6 +- libraries/adb/src/server/client.ts | 7 +- libraries/android-bin/src/am.ts | 35 ++- libraries/android-bin/src/bu.ts | 20 +- libraries/android-bin/src/bug-report.ts | 32 +-- libraries/android-bin/src/cmd.ts | 233 +++++++++++------- libraries/android-bin/src/demo-mode.ts | 6 +- libraries/android-bin/src/dumpsys.ts | 12 +- libraries/android-bin/src/logcat.ts | 17 +- libraries/android-bin/src/overlay-display.ts | 4 +- libraries/android-bin/src/pm.ts | 192 +++++++-------- libraries/android-bin/src/settings.ts | 35 +-- libraries/scrcpy/src/utils/wrapper.ts | 4 +- 75 files changed, 1422 insertions(+), 1022 deletions(-) create mode 100644 libraries/adb-scrcpy/src/client-options.ts delete mode 100644 libraries/adb/src/commands/subprocess/command.ts create mode 100644 libraries/adb/src/commands/subprocess/none/index.ts rename libraries/adb/src/commands/subprocess/{protocols/none.spec.ts => none/process.spec.ts} (50%) create mode 100644 libraries/adb/src/commands/subprocess/none/process.ts create mode 100644 libraries/adb/src/commands/subprocess/none/pty.ts create mode 100644 libraries/adb/src/commands/subprocess/none/service.ts create mode 100644 libraries/adb/src/commands/subprocess/none/spawner.ts delete mode 100644 libraries/adb/src/commands/subprocess/protocols/index.ts delete mode 100644 libraries/adb/src/commands/subprocess/protocols/none.ts delete mode 100644 libraries/adb/src/commands/subprocess/protocols/shell.ts delete mode 100644 libraries/adb/src/commands/subprocess/protocols/types.ts create mode 100644 libraries/adb/src/commands/subprocess/pty.ts create mode 100644 libraries/adb/src/commands/subprocess/service.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/index.ts rename libraries/adb/src/commands/subprocess/{protocols/shell.spec.ts => shell/process.spec.ts} (82%) create mode 100644 libraries/adb/src/commands/subprocess/shell/process.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/pty.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/service.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/shared.spec.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/shared.ts create mode 100644 libraries/adb/src/commands/subprocess/shell/spawner.ts diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3fa18eca..4762ae3e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -135,19 +135,19 @@ createDeviceCommand("shell [args...]") const ref = new Ref(); const adb = await createAdb(options); - const shell = await adb.subprocess.shell(args); + const shell = await adb.subprocess.noneProtocol.pty(args); - const stdinWriter = shell.stdin.getWriter(); + const inputWriter = shell.input.getWriter(); process.stdin.setRawMode(true); process.stdin.on("data", (data: Uint8Array) => { - stdinWriter.write(data).catch((e) => { + inputWriter.write(data).catch((e) => { console.error(e); process.exit(1); }); }); - shell.stdout + shell.output .pipeTo( new WritableStream({ write(chunk) { @@ -160,11 +160,11 @@ createDeviceCommand("shell [args...]") process.exit(1); }); - shell.exit.then( - (code) => { + shell.exited.then( + () => { // `process.stdin.on("data")` will keep the process alive, // so call `process.exit` explicitly. - process.exit(code); + process.exit(0); }, (e) => { console.error(e); @@ -181,12 +181,15 @@ createDeviceCommand("logcat [args...]") .configureHelp({ showGlobalOptions: true }) .action(async (args: string[], options: DeviceCommandOptions) => { const adb = await createAdb(options); - const logcat = await adb.subprocess.spawn(`logcat ${args.join(" ")}`); + const logcat = await adb.subprocess.noneProtocol.spawn([ + "logcat", + ...args, + ]); // eslint-disable-next-line @typescript-eslint/no-misused-promises process.on("SIGINT", async () => { await logcat.kill(); }); - await logcat.stdout.pipeTo( + await logcat.output.pipeTo( new WritableStream({ write: (chunk) => { process.stdout.write(chunk); diff --git a/libraries/adb-scrcpy/src/1_15/options.ts b/libraries/adb-scrcpy/src/1_15/options.ts index c46284ae..5e9b028b 100644 --- a/libraries/adb-scrcpy/src/1_15/options.ts +++ b/libraries/adb-scrcpy/src/1_15/options.ts @@ -2,14 +2,21 @@ import type { Adb } from "@yume-chan/adb"; import type { ScrcpyDisplay, ScrcpyEncoder } from "@yume-chan/scrcpy"; import { ScrcpyOptions1_15 } from "@yume-chan/scrcpy"; +import type { AdbScrcpyClientOptions } from "../client-options.js"; import type { AdbScrcpyConnection } from "../connection.js"; import { AdbScrcpyOptions } from "../types.js"; import { createConnection, getDisplays, getEncoders } from "./impl/index.js"; export class AdbScrcpyOptions1_15 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_15.Init, version?: string) { - super(new ScrcpyOptions1_15(init, version)); + constructor( + init: ScrcpyOptions1_15.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_15(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_15_1.ts b/libraries/adb-scrcpy/src/1_15_1.ts index e3024483..74aff6b8 100644 --- a/libraries/adb-scrcpy/src/1_15_1.ts +++ b/libraries/adb-scrcpy/src/1_15_1.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_15_1 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_15_1.Init, version?: string) { - super(new ScrcpyOptions1_15_1(init, version)); + constructor( + init: ScrcpyOptions1_15_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_15_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_16.ts b/libraries/adb-scrcpy/src/1_16.ts index fc3b6e16..d8ade447 100644 --- a/libraries/adb-scrcpy/src/1_16.ts +++ b/libraries/adb-scrcpy/src/1_16.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_16 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_16.Init, version?: string) { - super(new ScrcpyOptions1_16(init, version)); + constructor( + init: ScrcpyOptions1_16.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_16(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_17.ts b/libraries/adb-scrcpy/src/1_17.ts index dc7d4d8f..37fdebc7 100644 --- a/libraries/adb-scrcpy/src/1_17.ts +++ b/libraries/adb-scrcpy/src/1_17.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_17 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_17.Init, version?: string) { - super(new ScrcpyOptions1_17(init, version)); + constructor( + init: ScrcpyOptions1_17.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_17(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_18.ts b/libraries/adb-scrcpy/src/1_18.ts index 6505cf26..84a97101 100644 --- a/libraries/adb-scrcpy/src/1_18.ts +++ b/libraries/adb-scrcpy/src/1_18.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_18 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_18.Init, version?: string) { - super(new ScrcpyOptions1_18(init, version)); + constructor( + init: ScrcpyOptions1_18.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_18(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_19.ts b/libraries/adb-scrcpy/src/1_19.ts index ef681e39..7b5ecb71 100644 --- a/libraries/adb-scrcpy/src/1_19.ts +++ b/libraries/adb-scrcpy/src/1_19.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_19 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_19.Init, version?: string) { - super(new ScrcpyOptions1_19(init, version)); + constructor( + init: ScrcpyOptions1_19.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_19(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_20.ts b/libraries/adb-scrcpy/src/1_20.ts index 8294dc64..bec64d6d 100644 --- a/libraries/adb-scrcpy/src/1_20.ts +++ b/libraries/adb-scrcpy/src/1_20.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_20 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_20.Init, version?: string) { - super(new ScrcpyOptions1_20(init, version)); + constructor( + init: ScrcpyOptions1_20.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_20(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_21.ts b/libraries/adb-scrcpy/src/1_21.ts index 1885a485..b16ab365 100644 --- a/libraries/adb-scrcpy/src/1_21.ts +++ b/libraries/adb-scrcpy/src/1_21.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_21 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_21.Init, version?: string) { - super(new ScrcpyOptions1_21(init, version)); + constructor( + init: ScrcpyOptions1_21.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_21(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_22/options.ts b/libraries/adb-scrcpy/src/1_22/options.ts index 65cfa56f..bcde19ad 100644 --- a/libraries/adb-scrcpy/src/1_22/options.ts +++ b/libraries/adb-scrcpy/src/1_22/options.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "../1_15/impl/index.js"; +import type { AdbScrcpyClientOptions } from "../client-options.js"; import type { AdbScrcpyConnection } from "../connection.js"; import { AdbScrcpyOptions } from "../types.js"; export class AdbScrcpyOptions1_22 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_22.Init, version?: string) { - super(new ScrcpyOptions1_22(init, version)); + constructor( + init: ScrcpyOptions1_22.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_22(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_23.ts b/libraries/adb-scrcpy/src/1_23.ts index 64494f8f..4ea28e62 100644 --- a/libraries/adb-scrcpy/src/1_23.ts +++ b/libraries/adb-scrcpy/src/1_23.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_22/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_23 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_23.Init, version?: string) { - super(new ScrcpyOptions1_23(init, version)); + constructor( + init: ScrcpyOptions1_23.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_23(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_24.ts b/libraries/adb-scrcpy/src/1_24.ts index 4c613f2b..8b4839a5 100644 --- a/libraries/adb-scrcpy/src/1_24.ts +++ b/libraries/adb-scrcpy/src/1_24.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_22/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_24 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_24.Init, version?: string) { - super(new ScrcpyOptions1_24(init, version)); + constructor( + init: ScrcpyOptions1_24.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_24(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/1_25.ts b/libraries/adb-scrcpy/src/1_25.ts index 3aea1e7e..ac57bab2 100644 --- a/libraries/adb-scrcpy/src/1_25.ts +++ b/libraries/adb-scrcpy/src/1_25.ts @@ -7,12 +7,19 @@ import { getDisplays, getEncoders, } from "./1_22/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions1_25 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions1_25.Init, version?: string) { - super(new ScrcpyOptions1_25(init, version)); + constructor( + init: ScrcpyOptions1_25.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions1_25(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_0/options.ts b/libraries/adb-scrcpy/src/2_0/options.ts index 18a0d13d..d192304f 100644 --- a/libraries/adb-scrcpy/src/2_0/options.ts +++ b/libraries/adb-scrcpy/src/2_0/options.ts @@ -2,14 +2,21 @@ import type { Adb } from "@yume-chan/adb"; import type { ScrcpyDisplay, ScrcpyEncoder } from "@yume-chan/scrcpy"; import { ScrcpyOptions2_0 } from "@yume-chan/scrcpy"; +import type { AdbScrcpyClientOptions } from "../client-options.js"; import type { AdbScrcpyConnection } from "../connection.js"; import { AdbScrcpyOptions } from "../types.js"; import { createConnection, getDisplays, getEncoders } from "./impl/index.js"; export class AdbScrcpyOptions2_0 extends AdbScrcpyOptions { - constructor(init: ScrcpyOptions2_0.Init, version?: string) { - super(new ScrcpyOptions2_0(init, version)); + constructor( + init: ScrcpyOptions2_0.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_0(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_1/options.ts b/libraries/adb-scrcpy/src/2_1/options.ts index 8887cbd7..ceec7612 100644 --- a/libraries/adb-scrcpy/src/2_1/options.ts +++ b/libraries/adb-scrcpy/src/2_1/options.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "../2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "../client-options.js"; import type { AdbScrcpyConnection } from "../connection.js"; import { AdbScrcpyOptions } from "../types.js"; export class AdbScrcpyOptions2_1< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_1.Init, version?: string) { - super(new ScrcpyOptions2_1(init, version)); + constructor( + init: ScrcpyOptions2_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_1_1.ts b/libraries/adb-scrcpy/src/2_1_1.ts index 95abafc4..412d911c 100644 --- a/libraries/adb-scrcpy/src/2_1_1.ts +++ b/libraries/adb-scrcpy/src/2_1_1.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_1_1< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_1_1.Init, version?: string) { - super(new ScrcpyOptions2_1_1(init, version)); + constructor( + init: ScrcpyOptions2_1_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_1_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_2.ts b/libraries/adb-scrcpy/src/2_2.ts index da7440f3..e3313f89 100644 --- a/libraries/adb-scrcpy/src/2_2.ts +++ b/libraries/adb-scrcpy/src/2_2.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_2< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_2.Init, version?: string) { - super(new ScrcpyOptions2_2(init, version)); + constructor( + init: ScrcpyOptions2_2.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_2(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_3.ts b/libraries/adb-scrcpy/src/2_3.ts index 24e4b8ea..037f4021 100644 --- a/libraries/adb-scrcpy/src/2_3.ts +++ b/libraries/adb-scrcpy/src/2_3.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_3< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_3.Init, version?: string) { - super(new ScrcpyOptions2_3(init, version)); + constructor( + init: ScrcpyOptions2_3.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_3(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_3_1.ts b/libraries/adb-scrcpy/src/2_3_1.ts index 90e1a838..073f8ae4 100644 --- a/libraries/adb-scrcpy/src/2_3_1.ts +++ b/libraries/adb-scrcpy/src/2_3_1.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_3_1< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_3_1.Init, version?: string) { - super(new ScrcpyOptions2_3_1(init, version)); + constructor( + init: ScrcpyOptions2_3_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_3_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_4.ts b/libraries/adb-scrcpy/src/2_4.ts index b3f1fdc1..4428e622 100644 --- a/libraries/adb-scrcpy/src/2_4.ts +++ b/libraries/adb-scrcpy/src/2_4.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_4< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_4.Init, version?: string) { - super(new ScrcpyOptions2_4(init, version)); + constructor( + init: ScrcpyOptions2_4.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_4(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_5.ts b/libraries/adb-scrcpy/src/2_5.ts index 454cbc1b..fd7bd0a2 100644 --- a/libraries/adb-scrcpy/src/2_5.ts +++ b/libraries/adb-scrcpy/src/2_5.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_5< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_5.Init, version?: string) { - super(new ScrcpyOptions2_5(init, version)); + constructor( + init: ScrcpyOptions2_5.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_5(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_6.ts b/libraries/adb-scrcpy/src/2_6.ts index d5ae445c..79b4ae01 100644 --- a/libraries/adb-scrcpy/src/2_6.ts +++ b/libraries/adb-scrcpy/src/2_6.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_6< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_6.Init, version?: string) { - super(new ScrcpyOptions2_6(init, version)); + constructor( + init: ScrcpyOptions2_6.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_6(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/2_7.ts b/libraries/adb-scrcpy/src/2_7.ts index 820b2241..5cfb32b6 100644 --- a/libraries/adb-scrcpy/src/2_7.ts +++ b/libraries/adb-scrcpy/src/2_7.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions2_7< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions2_7.Init, version?: string) { - super(new ScrcpyOptions2_7(init, version)); + constructor( + init: ScrcpyOptions2_7.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions2_7(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/3_0.ts b/libraries/adb-scrcpy/src/3_0.ts index 7e9d2578..a9ae9344 100644 --- a/libraries/adb-scrcpy/src/3_0.ts +++ b/libraries/adb-scrcpy/src/3_0.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions3_0< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions3_0.Init, version?: string) { - super(new ScrcpyOptions3_0(init, version)); + constructor( + init: ScrcpyOptions3_0.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions3_0(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/3_0_1.ts b/libraries/adb-scrcpy/src/3_0_1.ts index ed7528d3..89ec0e51 100644 --- a/libraries/adb-scrcpy/src/3_0_1.ts +++ b/libraries/adb-scrcpy/src/3_0_1.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions3_0_1< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions3_0_1.Init, version?: string) { - super(new ScrcpyOptions3_0_1(init, version)); + constructor( + init: ScrcpyOptions3_0_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions3_0_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/3_0_2.ts b/libraries/adb-scrcpy/src/3_0_2.ts index fbe70542..3a68789d 100644 --- a/libraries/adb-scrcpy/src/3_0_2.ts +++ b/libraries/adb-scrcpy/src/3_0_2.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions3_0_2< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions3_0_2.Init, version?: string) { - super(new ScrcpyOptions3_0_2(init, version)); + constructor( + init: ScrcpyOptions3_0_2.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions3_0_2(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/3_1.ts b/libraries/adb-scrcpy/src/3_1.ts index f81dc10f..160fac6a 100644 --- a/libraries/adb-scrcpy/src/3_1.ts +++ b/libraries/adb-scrcpy/src/3_1.ts @@ -7,14 +7,21 @@ import { getDisplays, getEncoders, } from "./2_1/impl/index.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; import type { AdbScrcpyConnection } from "./connection.js"; import { AdbScrcpyOptions } from "./types.js"; export class AdbScrcpyOptions3_1< TVideo extends boolean, > extends AdbScrcpyOptions> { - constructor(init: ScrcpyOptions3_1.Init, version?: string) { - super(new ScrcpyOptions3_1(init, version)); + constructor( + init: ScrcpyOptions3_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super( + new ScrcpyOptions3_1(init, clientOptions?.version), + clientOptions?.spawner, + ); } override getEncoders(adb: Adb, path: string): Promise { diff --git a/libraries/adb-scrcpy/src/client-options.ts b/libraries/adb-scrcpy/src/client-options.ts new file mode 100644 index 00000000..8391d758 --- /dev/null +++ b/libraries/adb-scrcpy/src/client-options.ts @@ -0,0 +1,6 @@ +import type { AdbNoneProtocolSpawner } from "@yume-chan/adb"; + +export interface AdbScrcpyClientOptions { + version?: string; + spawner?: AdbNoneProtocolSpawner | undefined; +} diff --git a/libraries/adb-scrcpy/src/client.ts b/libraries/adb-scrcpy/src/client.ts index 2d0a94df..9fa84389 100644 --- a/libraries/adb-scrcpy/src/client.ts +++ b/libraries/adb-scrcpy/src/client.ts @@ -1,8 +1,5 @@ -import type { Adb, AdbSubprocessProtocol } from "@yume-chan/adb"; -import { - AdbReverseNotSupportedError, - AdbSubprocessNoneProtocol, -} from "@yume-chan/adb"; +import type { Adb, AdbNoneProtocolProcess } from "@yume-chan/adb"; +import { AdbReverseNotSupportedError } from "@yume-chan/adb"; import type { ScrcpyAudioStreamDisabledMetadata, ScrcpyAudioStreamErroredMetadata, @@ -70,7 +67,7 @@ export class AdbScrcpyExitedError extends Error { interface AdbScrcpyClientInit> { options: TOptions; - process: AdbSubprocessProtocol; + process: AdbNoneProtocolProcess; stdout: ReadableStream; videoStream: ReadableStream | undefined; @@ -117,7 +114,7 @@ export class AdbScrcpyClient> { options: TOptions, ): Promise> { let connection: AdbScrcpyConnection | undefined; - let process: AdbSubprocessProtocol | undefined; + let process: AdbNoneProtocolProcess | undefined; try { try { @@ -135,35 +132,34 @@ export class AdbScrcpyClient> { } } - process = await adb.subprocess.spawn( - [ - // cspell: disable-next-line - `CLASSPATH=${path}`, - "app_process", - /* unused */ "/", - "com.genymobile.scrcpy.Server", - options.version, - ...options.serialize(), - ], - { - // Scrcpy server doesn't use stderr, - // so disable Shell Protocol to simplify processing - protocols: [AdbSubprocessNoneProtocol], - }, - ); + const args = [ + "app_process", + "-cp", + path, + /* unused */ "/", + "com.genymobile.scrcpy.Server", + options.version, + ...options.serialize(), + ]; - const stdout = process.stdout + if (options.spawner) { + process = await options.spawner.spawn(args); + } else { + process = await adb.subprocess.noneProtocol.spawn(args); + } + + const output = process.output .pipeThrough(new TextDecoderStream()) .pipeThrough(new SplitStringStream("\n")); // Must read all streams, otherwise the whole connection will be blocked. - const output: string[] = []; + const lines: string[] = []; const abortController = new AbortController(); - const pipe = stdout + const pipe = output .pipeTo( new WritableStream({ write(chunk) { - output.push(chunk); + lines.push(chunk); }, }), { @@ -180,8 +176,8 @@ export class AdbScrcpyClient> { }); const streams = await Promise.race([ - process.exit.then(() => { - throw new AdbScrcpyExitedError(output); + process.exited.then(() => { + throw new AdbScrcpyExitedError(lines); }), connection.getStreams(), ]); @@ -192,7 +188,7 @@ export class AdbScrcpyClient> { return new AdbScrcpyClient({ options, process, - stdout: concatStreams(arrayToStream(output), stdout), + stdout: concatStreams(arrayToStream(lines), output), videoStream: streams.video, audioStream: streams.audio, controlStream: streams.control, @@ -232,15 +228,15 @@ export class AdbScrcpyClient> { } #options: TOptions; - #process: AdbSubprocessProtocol; + #process: AdbNoneProtocolProcess; #stdout: ReadableStream; get stdout() { return this.#stdout; } - get exit() { - return this.#process.exit; + get exited() { + return this.#process.exited; } #videoStream: Promise | undefined; diff --git a/libraries/adb-scrcpy/src/index.ts b/libraries/adb-scrcpy/src/index.ts index e8902c84..3d327712 100644 --- a/libraries/adb-scrcpy/src/index.ts +++ b/libraries/adb-scrcpy/src/index.ts @@ -22,6 +22,8 @@ export * from "./3_0.js"; export * from "./3_0_1.js"; export * from "./3_0_2.js"; export * from "./3_1.js"; +export * from "./client-options.js"; export * from "./client.js"; export * from "./connection.js"; export * from "./latest.js"; +export * from "./types.js"; diff --git a/libraries/adb-scrcpy/src/latest.ts b/libraries/adb-scrcpy/src/latest.ts index 0df62861..dc8fb383 100644 --- a/libraries/adb-scrcpy/src/latest.ts +++ b/libraries/adb-scrcpy/src/latest.ts @@ -1,10 +1,14 @@ import { AdbScrcpyOptions3_1 } from "./3_1.js"; +import type { AdbScrcpyClientOptions } from "./client-options.js"; export class AdbScrcpyOptionsLatest< TVideo extends boolean, > extends AdbScrcpyOptions3_1 { - constructor(init: AdbScrcpyOptions3_1.Init, version: string) { - super(init, version); + constructor( + init: AdbScrcpyOptions3_1.Init, + clientOptions?: AdbScrcpyClientOptions, + ) { + super(init, clientOptions); } } diff --git a/libraries/adb-scrcpy/src/types.ts b/libraries/adb-scrcpy/src/types.ts index 96438030..b9f4ea47 100644 --- a/libraries/adb-scrcpy/src/types.ts +++ b/libraries/adb-scrcpy/src/types.ts @@ -1,5 +1,9 @@ -import type { Adb } from "@yume-chan/adb"; -import type { ScrcpyDisplay, ScrcpyEncoder } from "@yume-chan/scrcpy"; +import type { Adb, AdbNoneProtocolSpawner } from "@yume-chan/adb"; +import type { + ScrcpyDisplay, + ScrcpyEncoder, + ScrcpyOptions, +} from "@yume-chan/scrcpy"; import { ScrcpyOptionsWrapper } from "@yume-chan/scrcpy"; import type { AdbScrcpyConnection } from "./connection.js"; @@ -7,6 +11,16 @@ import type { AdbScrcpyConnection } from "./connection.js"; export abstract class AdbScrcpyOptions< T extends object, > extends ScrcpyOptionsWrapper { + #spawner: AdbNoneProtocolSpawner | undefined; + get spawner() { + return this.#spawner; + } + + constructor(base: ScrcpyOptions, spawner?: AdbNoneProtocolSpawner) { + super(base); + this.#spawner = spawner; + } + abstract getEncoders(adb: Adb, path: string): Promise; abstract getDisplays(adb: Adb, path: string): Promise; diff --git a/libraries/adb-server-node-tcp/src/index.ts b/libraries/adb-server-node-tcp/src/index.ts index 7d15d9b2..7e69fe9a 100644 --- a/libraries/adb-server-node-tcp/src/index.ts +++ b/libraries/adb-server-node-tcp/src/index.ts @@ -14,7 +14,7 @@ function nodeSocketToConnection( ): AdbServerClient.ServerConnection { socket.setNoDelay(true); - const closed = new Promise((resolve) => { + const closed = new Promise((resolve) => { socket.on("close", resolve); }); diff --git a/libraries/adb/src/adb.ts b/libraries/adb/src/adb.ts index f38842fd..e58619e7 100644 --- a/libraries/adb/src/adb.ts +++ b/libraries/adb/src/adb.ts @@ -10,7 +10,7 @@ import type { AdbFrameBuffer } from "./commands/index.js"; import { AdbPower, AdbReverseCommand, - AdbSubprocess, + AdbSubprocessService, AdbSync, AdbTcpIpCommand, escapeArg, @@ -30,7 +30,7 @@ export interface AdbSocket Closeable { get service(): string; - get closed(): Promise; + get closed(): Promise; } export type AdbIncomingSocketHandler = ( @@ -87,7 +87,7 @@ export class Adb implements Closeable { return this.banner.features; } - readonly subprocess: AdbSubprocess; + readonly subprocess: AdbSubprocessService; readonly power: AdbPower; readonly reverse: AdbReverseCommand; readonly tcpip: AdbTcpIpCommand; @@ -95,7 +95,7 @@ export class Adb implements Closeable { constructor(transport: AdbTransport) { this.transport = transport; - this.subprocess = new AdbSubprocess(this); + this.subprocess = new AdbSubprocessService(this); this.power = new AdbPower(this); this.reverse = new AdbReverseCommand(this); this.tcpip = new AdbTcpIpCommand(this); @@ -122,15 +122,13 @@ export class Adb implements Closeable { .pipeThrough(new ConcatStringStream()); } - async getProp(key: string): Promise { - const stdout = await this.subprocess.spawnAndWaitLegacy([ - "getprop", - key, - ]); - return stdout.trim(); + getProp(key: string): Promise { + return this.subprocess.noneProtocol + .spawnWaitText(["getprop", key]) + .then((output) => output.trim()); } - async rm( + rm( filenames: string | string[], options?: { recursive?: boolean; force?: boolean }, ): Promise { @@ -150,8 +148,8 @@ export class Adb implements Closeable { } // https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984 args.push(" { diff --git a/libraries/adb/src/commands/base.ts b/libraries/adb/src/commands/base.ts index b1112271..617ddc00 100644 --- a/libraries/adb/src/commands/base.ts +++ b/libraries/adb/src/commands/base.ts @@ -2,11 +2,14 @@ import { AutoDisposable } from "@yume-chan/event"; import type { Adb } from "../adb.js"; -export class AdbCommandBase extends AutoDisposable { - protected adb: Adb; +export class AdbServiceBase extends AutoDisposable { + #adb: Adb; + get adb() { + return this.#adb; + } constructor(adb: Adb) { super(); - this.adb = adb; + this.#adb = adb; } } diff --git a/libraries/adb/src/commands/power.ts b/libraries/adb/src/commands/power.ts index 70e4fe57..11877012 100644 --- a/libraries/adb/src/commands/power.ts +++ b/libraries/adb/src/commands/power.ts @@ -3,9 +3,9 @@ // cspell: ignore keyevent // cspell: ignore longpress -import { AdbCommandBase } from "./base.js"; +import { AdbServiceBase } from "./base.js"; -export class AdbPower extends AdbCommandBase { +export class AdbPower extends AdbServiceBase { reboot(mode = "") { return this.adb.createSocketAndWait(`reboot:${mode}`); } @@ -35,18 +35,18 @@ export class AdbPower extends AdbCommandBase { return this.reboot("edl"); } - powerOff() { - return this.adb.subprocess.spawnAndWaitLegacy(["reboot", "-p"]); + powerOff(): Promise { + return this.adb.subprocess.noneProtocol.spawnWaitText(["reboot", "-p"]); } - powerButton(longPress = false) { + powerButton(longPress = false): Promise { const args = ["input", "keyevent"]; if (longPress) { args.push("--longpress"); } args.push("POWER"); - return this.adb.subprocess.spawnAndWaitLegacy(args); + return this.adb.subprocess.noneProtocol.spawnWaitText(args); } /** diff --git a/libraries/adb/src/commands/subprocess/command.ts b/libraries/adb/src/commands/subprocess/command.ts deleted file mode 100644 index 22d9c536..00000000 --- a/libraries/adb/src/commands/subprocess/command.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { AbortSignal } from "@yume-chan/stream-extra"; -import { ConcatStringStream, TextDecoderStream } from "@yume-chan/stream-extra"; - -import { AdbCommandBase } from "../base.js"; - -import type { - AdbSubprocessProtocol, - AdbSubprocessProtocolConstructor, -} from "./protocols/index.js"; -import { - AdbSubprocessNoneProtocol, - AdbSubprocessShellProtocol, -} from "./protocols/index.js"; - -export interface AdbSubprocessOptions { - /** - * A list of `AdbSubprocessProtocolConstructor`s to be used. - * - * Different `AdbSubprocessProtocol` has different capabilities, thus requires specific adaptations. - * Check their documentations for details. - * - * The first protocol whose `isSupported` returns `true` will be used. - * If no `AdbSubprocessProtocol` is supported, an error will be thrown. - * - * @default [AdbSubprocessShellProtocol, AdbSubprocessNoneProtocol] - */ - protocols: AdbSubprocessProtocolConstructor[]; - - signal?: AbortSignal; -} - -const DEFAULT_OPTIONS = { - protocols: [AdbSubprocessShellProtocol, AdbSubprocessNoneProtocol], -} satisfies AdbSubprocessOptions; - -export interface AdbSubprocessWaitResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export class AdbSubprocess extends AdbCommandBase { - async #createProtocol( - mode: "pty" | "raw", - command?: string | string[], - options?: Partial, - ): Promise { - const { protocols, signal } = { ...DEFAULT_OPTIONS, ...options }; - - let Constructor: AdbSubprocessProtocolConstructor | undefined; - for (const item of protocols) { - // It's async so can't use `Array#find` - if (await item.isSupported(this.adb)) { - Constructor = item; - break; - } - } - - if (!Constructor) { - throw new Error("No specified protocol is supported by the device"); - } - - if (Array.isArray(command)) { - command = command.join(" "); - } else if (command === undefined) { - // spawn the default shell - command = ""; - } - return await Constructor[mode](this.adb, command, signal); - } - - /** - * Spawns an executable in PTY mode. - * - * Redirection mode is enough for most simple commands, but PTY mode is required for - * commands that manipulate the terminal, such as `vi` and `less`. - * @param command The command to run. If omitted, the default shell will be spawned. - * @param options The options for creating the `AdbSubprocessProtocol` - * @returns A new `AdbSubprocessProtocol` instance connecting to the spawned process. - */ - shell( - command?: string | string[], - options?: Partial, - ): Promise { - return this.#createProtocol("pty", command, options); - } - - /** - * Spawns an executable and redirect the standard input/output stream. - * - * Redirection mode is enough for most simple commands, but PTY mode is required for - * commands that manipulate the terminal, such as `vi` and `less`. - * @param command The command to run, or an array of strings containing both command and args. - * @param options The options for creating the `AdbSubprocessProtocol` - * @returns A new `AdbSubprocessProtocol` instance connecting to the spawned process. - */ - spawn( - command: string | string[], - options?: Partial, - ): Promise { - return this.#createProtocol("raw", command, options); - } - - /** - * Spawns a new process, waits until it exits, and returns the entire output. - * @param command The command to run - * @param options The options for creating the `AdbSubprocessProtocol` - * @returns The entire output of the command - */ - async spawnAndWait( - command: string | string[], - options?: Partial, - ): Promise { - const process = await this.spawn(command, options); - - const [stdout, stderr, exitCode] = await Promise.all([ - process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.stderr - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.exit, - ]); - - return { - stdout, - stderr, - exitCode, - }; - } - - /** - * Spawns a new process, waits until it exits, and returns the entire output. - * @param command The command to run - * @returns The entire output of the command - */ - async spawnAndWaitLegacy(command: string | string[]): Promise { - const { stdout } = await this.spawnAndWait(command, { - protocols: [AdbSubprocessNoneProtocol], - }); - return stdout; - } -} diff --git a/libraries/adb/src/commands/subprocess/index.ts b/libraries/adb/src/commands/subprocess/index.ts index c6c04153..483ec9ab 100644 --- a/libraries/adb/src/commands/subprocess/index.ts +++ b/libraries/adb/src/commands/subprocess/index.ts @@ -1,3 +1,4 @@ -export * from "./command.js"; -export * from "./protocols/index.js"; +export * from "./none/index.js"; +export * from "./service.js"; +export * from "./shell/index.js"; export * from "./utils.js"; diff --git a/libraries/adb/src/commands/subprocess/none/index.ts b/libraries/adb/src/commands/subprocess/none/index.ts new file mode 100644 index 00000000..0babc655 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/none/index.ts @@ -0,0 +1,4 @@ +export * from "./process.js"; +export * from "./pty.js"; +export * from "./service.js"; +export * from "./spawner.js"; diff --git a/libraries/adb/src/commands/subprocess/protocols/none.spec.ts b/libraries/adb/src/commands/subprocess/none/process.spec.ts similarity index 50% rename from libraries/adb/src/commands/subprocess/protocols/none.spec.ts rename to libraries/adb/src/commands/subprocess/none/process.spec.ts index 4a4a5a28..b2a83fb5 100644 --- a/libraries/adb/src/commands/subprocess/protocols/none.spec.ts +++ b/libraries/adb/src/commands/subprocess/none/process.spec.ts @@ -6,15 +6,15 @@ import { ReadableStream, WritableStream } from "@yume-chan/stream-extra"; import type { AdbSocket } from "../../../adb.js"; -import { AdbSubprocessNoneProtocol } from "./none.js"; +import { AdbNoneProtocolProcessImpl } from "./process.js"; -describe("AdbSubprocessNoneProtocol", () => { - describe("stdout", () => { +describe("AdbNoneProtocolProcessImpl", () => { + describe("output", () => { it("should pipe data from `socket`", async () => { - const closed = new PromiseResolver(); - const socket: AdbSocket = { + const closed = new PromiseResolver(); + const socket = { service: "", - close: mock.fn(() => {}), + close: () => {}, closed: closed.promise, readable: new ReadableStream({ async start(controller) { @@ -25,10 +25,10 @@ describe("AdbSubprocessNoneProtocol", () => { }, }), writable: new WritableStream(), - }; + } satisfies AdbSocket; - const process = new AdbSubprocessNoneProtocol(socket); - const reader = process.stdout.getReader(); + const process = new AdbNoneProtocolProcessImpl(socket); + const reader = process.output.getReader(); assert.deepStrictEqual(await reader.read(), { done: false, @@ -41,8 +41,8 @@ describe("AdbSubprocessNoneProtocol", () => { }); it("should close when `socket` is closed", async () => { - const closed = new PromiseResolver(); - const socket: AdbSocket = { + const closed = new PromiseResolver(); + const socket = { service: "", close: mock.fn(() => {}), closed: closed.promise, @@ -55,10 +55,10 @@ describe("AdbSubprocessNoneProtocol", () => { }, }), writable: new WritableStream(), - }; + } satisfies AdbSocket; - const process = new AdbSubprocessNoneProtocol(socket); - const reader = process.stdout.getReader(); + const process = new AdbNoneProtocolProcessImpl(socket); + const reader = process.output.getReader(); assert.deepStrictEqual(await reader.read(), { done: false, @@ -69,37 +69,7 @@ describe("AdbSubprocessNoneProtocol", () => { value: new Uint8Array([4, 5, 6]), }); - closed.resolve(); - - assert.deepStrictEqual(await reader.read(), { - done: true, - value: undefined, - }); - }); - }); - - describe("stderr", () => { - it("should be empty", async () => { - const closed = new PromiseResolver(); - const socket: AdbSocket = { - service: "", - close: mock.fn(() => {}), - closed: closed.promise, - readable: new ReadableStream({ - async start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])); - controller.enqueue(new Uint8Array([4, 5, 6])); - await closed.promise; - controller.close(); - }, - }), - writable: new WritableStream(), - }; - - const process = new AdbSubprocessNoneProtocol(socket); - const reader = process.stderr.getReader(); - - closed.resolve(); + closed.resolve(undefined); assert.deepStrictEqual(await reader.read(), { done: true, @@ -110,48 +80,35 @@ describe("AdbSubprocessNoneProtocol", () => { describe("exit", () => { it("should resolve when `socket` closes", async () => { - const closed = new PromiseResolver(); - const socket: AdbSocket = { + const closed = new PromiseResolver(); + const socket = { service: "", - close: mock.fn(() => {}), + close: () => {}, closed: closed.promise, readable: new ReadableStream(), writable: new WritableStream(), - }; + } satisfies AdbSocket; - const process = new AdbSubprocessNoneProtocol(socket); + const process = new AdbNoneProtocolProcessImpl(socket); - closed.resolve(); + closed.resolve(undefined); - assert.strictEqual(await process.exit, 0); + assert.strictEqual(await process.exited, undefined); }); }); - it("`resize` shouldn't throw any error", () => { - const socket: AdbSocket = { + it("`kill` should close `socket`", async () => { + const socket = { service: "", close: mock.fn(() => {}), - closed: new PromiseResolver().promise, + closed: new PromiseResolver().promise, readable: new ReadableStream(), writable: new WritableStream(), - }; + } satisfies AdbSocket; - const process = new AdbSubprocessNoneProtocol(socket); - assert.doesNotThrow(() => process.resize()); - }); - - it("`kill` should close `socket`", async () => { - const close = mock.fn(() => {}); - const socket: AdbSocket = { - service: "", - close, - closed: new PromiseResolver().promise, - readable: new ReadableStream(), - writable: new WritableStream(), - }; - - const process = new AdbSubprocessNoneProtocol(socket); + const process = new AdbNoneProtocolProcessImpl(socket); await process.kill(); - assert.deepEqual(close.mock.callCount(), 1); + + assert.deepEqual(socket.close.mock.callCount(), 1); }); }); diff --git a/libraries/adb/src/commands/subprocess/none/process.ts b/libraries/adb/src/commands/subprocess/none/process.ts new file mode 100644 index 00000000..849100b8 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/none/process.ts @@ -0,0 +1,56 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import { PromiseResolver } from "@yume-chan/async"; +import type { + AbortSignal, + MaybeConsumable, + ReadableStream, + WritableStream, +} from "@yume-chan/stream-extra"; + +import type { AdbSocket } from "../../../adb.js"; + +import type { AdbNoneProtocolProcess } from "./spawner.js"; + +export class AdbNoneProtocolProcessImpl implements AdbNoneProtocolProcess { + readonly #socket: AdbSocket; + + get stdin(): WritableStream> { + return this.#socket.writable; + } + + get output(): ReadableStream { + return this.#socket.readable; + } + + readonly #exited: Promise; + get exited(): Promise { + return this.#exited; + } + + constructor(socket: AdbSocket, signal?: AbortSignal) { + this.#socket = socket; + + if (signal) { + // `signal` won't affect `this.output` + // So remaining data can still be read + // (call `controller.error` will discard all pending data) + + const exited = new PromiseResolver(); + this.#socket.closed.then( + () => exited.resolve(undefined), + (e) => exited.reject(e), + ); + signal.addEventListener("abort", () => { + exited.reject(signal.reason); + this.#socket.close(); + }); + this.#exited = exited.promise; + } else { + this.#exited = this.#socket.closed; + } + } + + kill(): MaybePromiseLike { + return this.#socket.close(); + } +} diff --git a/libraries/adb/src/commands/subprocess/none/pty.ts b/libraries/adb/src/commands/subprocess/none/pty.ts new file mode 100644 index 00000000..2aac4e7c --- /dev/null +++ b/libraries/adb/src/commands/subprocess/none/pty.ts @@ -0,0 +1,45 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import type { + ReadableStream, + WritableStream, + WritableStreamDefaultWriter, +} from "@yume-chan/stream-extra"; +import { MaybeConsumable } from "@yume-chan/stream-extra"; + +import type { AdbSocket } from "../../../adb.js"; +import type { AdbPtyProcess } from "../pty.js"; + +export class AdbNoneProtocolPtyProcess implements AdbPtyProcess { + readonly #socket: AdbSocket; + readonly #writer: WritableStreamDefaultWriter>; + + readonly #input: MaybeConsumable.WritableStream; + get input(): WritableStream> { + return this.#input; + } + + get output(): ReadableStream { + return this.#socket.readable; + } + + get exited(): Promise { + return this.#socket.closed; + } + + constructor(socket: AdbSocket) { + this.#socket = socket; + + this.#writer = this.#socket.writable.getWriter(); + this.#input = new MaybeConsumable.WritableStream({ + write: (chunk) => this.#writer.write(chunk), + }); + } + + sigint() { + return this.#writer.write(new Uint8Array([0x03])); + } + + kill(): MaybePromiseLike { + return this.#socket.close(); + } +} diff --git a/libraries/adb/src/commands/subprocess/none/service.ts b/libraries/adb/src/commands/subprocess/none/service.ts new file mode 100644 index 00000000..9925d4dc --- /dev/null +++ b/libraries/adb/src/commands/subprocess/none/service.ts @@ -0,0 +1,42 @@ +import type { Adb } from "../../../adb.js"; + +import { AdbNoneProtocolProcessImpl } from "./process.js"; +import { AdbNoneProtocolPtyProcess } from "./pty.js"; +import { AdbNoneProtocolSpawner } from "./spawner.js"; + +export class AdbNoneProtocolSubprocessService extends AdbNoneProtocolSpawner { + readonly #adb: Adb; + get adb(): Adb { + return this.#adb; + } + + constructor(adb: Adb) { + super(async (command, signal) => { + // `shell,raw:${command}` also triggers raw mode, + // But is not supported on Android version <7. + const socket = await this.#adb.createSocket( + `exec:${command.join(" ")}`, + ); + + if (signal?.aborted) { + await socket.close(); + throw signal.reason; + } + + return new AdbNoneProtocolProcessImpl(socket, signal); + }); + this.#adb = adb; + } + + async pty(command?: string | string[]): Promise { + if (command === undefined) { + command = ""; + } else if (Array.isArray(command)) { + command = command.join(" "); + } + + return new AdbNoneProtocolPtyProcess( + await this.#adb.createSocket(`shell:${command}`), + ); + } +} diff --git a/libraries/adb/src/commands/subprocess/none/spawner.ts b/libraries/adb/src/commands/subprocess/none/spawner.ts new file mode 100644 index 00000000..f4b0f9b8 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/none/spawner.ts @@ -0,0 +1,68 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import type { + AbortSignal, + MaybeConsumable, + ReadableStream, + WritableStream, +} from "@yume-chan/stream-extra"; +import { + ConcatBufferStream, + ConcatStringStream, + TextDecoderStream, +} from "@yume-chan/stream-extra"; + +import { splitCommand } from "../utils.js"; + +export interface AdbNoneProtocolProcess { + get stdin(): WritableStream>; + + /** + * Mix of stdout and stderr + */ + get output(): ReadableStream; + + get exited(): Promise; + + kill(): MaybePromiseLike; +} + +export class AdbNoneProtocolSpawner { + readonly #spawn: ( + command: string[], + signal: AbortSignal | undefined, + ) => Promise; + + constructor( + spawn: ( + command: string[], + signal: AbortSignal | undefined, + ) => Promise, + ) { + this.#spawn = spawn; + } + + spawn( + command: string | string[], + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + + if (typeof command === "string") { + command = splitCommand(command); + } + + return this.#spawn(command, signal); + } + + async spawnWait(command: string | string[]): Promise { + const process = await this.spawn(command); + return await process.output.pipeThrough(new ConcatBufferStream()); + } + + async spawnWaitText(command: string | string[]): Promise { + const process = await this.spawn(command); + return await process.output + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new ConcatStringStream()); + } +} diff --git a/libraries/adb/src/commands/subprocess/protocols/index.ts b/libraries/adb/src/commands/subprocess/protocols/index.ts deleted file mode 100644 index bc8faeb1..00000000 --- a/libraries/adb/src/commands/subprocess/protocols/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./none.js"; -export * from "./shell.js"; -export * from "./types.js"; diff --git a/libraries/adb/src/commands/subprocess/protocols/none.ts b/libraries/adb/src/commands/subprocess/protocols/none.ts deleted file mode 100644 index 6f169447..00000000 --- a/libraries/adb/src/commands/subprocess/protocols/none.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { - AbortSignal, - MaybeConsumable, - WritableStream, -} from "@yume-chan/stream-extra"; -import { ReadableStream } from "@yume-chan/stream-extra"; - -import type { Adb, AdbSocket } from "../../../adb.js"; - -import type { AdbSubprocessProtocol } from "./types.js"; - -/** - * The legacy shell - * - * Features: - * * `stderr`: No - * * `exit` exit code: No - * * `resize`: No - */ -export class AdbSubprocessNoneProtocol implements AdbSubprocessProtocol { - static isSupported() { - return true; - } - - static async pty(adb: Adb, command: string, signal?: AbortSignal) { - return new AdbSubprocessNoneProtocol( - await adb.createSocket(`shell:${command}`), - signal, - ); - } - - static async raw(adb: Adb, command: string, signal?: AbortSignal) { - // `shell,raw:${command}` also triggers raw mode, - // But is not supported on Android version <7. - return new AdbSubprocessNoneProtocol( - await adb.createSocket(`exec:${command}`), - signal, - ); - } - - readonly #socket: AdbSocket; - - // Legacy shell forwards all data to stdin. - get stdin(): WritableStream> { - return this.#socket.writable; - } - - /** - * Legacy shell mixes stdout and stderr. - */ - get stdout(): ReadableStream { - return this.#socket.readable; - } - - #stderr: ReadableStream; - /** - * `stderr` will always be empty. - */ - get stderr(): ReadableStream { - return this.#stderr; - } - - #exit: Promise; - get exit() { - return this.#exit; - } - - constructor(socket: AdbSocket, signal?: AbortSignal) { - signal?.throwIfAborted(); - - this.#socket = socket; - signal?.addEventListener("abort", () => void this.kill()); - - this.#stderr = new ReadableStream({ - start: async (controller) => { - await this.#socket.closed; - controller.close(); - }, - }); - this.#exit = socket.closed.then(() => 0); - } - - resize() { - // Not supported, but don't throw. - } - - async kill() { - await this.#socket.close(); - } -} diff --git a/libraries/adb/src/commands/subprocess/protocols/shell.ts b/libraries/adb/src/commands/subprocess/protocols/shell.ts deleted file mode 100644 index 89102962..00000000 --- a/libraries/adb/src/commands/subprocess/protocols/shell.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { PromiseResolver } from "@yume-chan/async"; -import type { - AbortSignal, - PushReadableStreamController, - ReadableStream, - WritableStreamDefaultWriter, -} from "@yume-chan/stream-extra"; -import { - MaybeConsumable, - PushReadableStream, - StructDeserializeStream, - WritableStream, -} from "@yume-chan/stream-extra"; -import type { StructValue } from "@yume-chan/struct"; -import { buffer, struct, u32, u8 } from "@yume-chan/struct"; - -import type { Adb, AdbSocket } from "../../../adb.js"; -import { AdbFeature } from "../../../features.js"; -import { encodeUtf8 } from "../../../utils/index.js"; - -import type { AdbSubprocessProtocol } from "./types.js"; - -export const AdbShellProtocolId = { - Stdin: 0, - Stdout: 1, - Stderr: 2, - Exit: 3, - CloseStdin: 4, - WindowSizeChange: 5, -} as const; - -export type AdbShellProtocolId = - (typeof AdbShellProtocolId)[keyof typeof AdbShellProtocolId]; - -// This packet format is used in both directions. -export const AdbShellProtocolPacket = struct( - { - id: u8(), - data: buffer(u32), - }, - { littleEndian: true }, -); - -type AdbShellProtocolPacket = StructValue; - -/** - * Shell v2 a.k.a Shell Protocol - * - * Features: - * * `stderr`: Yes - * * `exit` exit code: Yes - * * `resize`: Yes - */ -export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol { - static isSupported(adb: Adb) { - return adb.canUseFeature(AdbFeature.ShellV2); - } - - static async pty(adb: Adb, command: string, signal?: AbortSignal) { - // TODO: AdbShellSubprocessProtocol: Support setting `XTERM` environment variable - return new AdbSubprocessShellProtocol( - await adb.createSocket(`shell,v2,pty:${command}`), - signal, - ); - } - - static async raw(adb: Adb, command: string, signal?: AbortSignal) { - return new AdbSubprocessShellProtocol( - await adb.createSocket(`shell,v2,raw:${command}`), - signal, - ); - } - - readonly #socket: AdbSocket; - #writer: WritableStreamDefaultWriter>; - - #stdin: WritableStream>; - get stdin() { - return this.#stdin; - } - - #stdout: ReadableStream; - get stdout() { - return this.#stdout; - } - - #stderr: ReadableStream; - get stderr() { - return this.#stderr; - } - - readonly #exit = new PromiseResolver(); - get exit() { - return this.#exit.promise; - } - - constructor(socket: AdbSocket, signal?: AbortSignal) { - signal?.throwIfAborted(); - - this.#socket = socket; - signal?.addEventListener("abort", () => void this.kill()); - - let stdoutController!: PushReadableStreamController; - let stderrController!: PushReadableStreamController; - this.#stdout = new PushReadableStream((controller) => { - stdoutController = controller; - }); - this.#stderr = new PushReadableStream((controller) => { - stderrController = controller; - }); - - socket.readable - .pipeThrough(new StructDeserializeStream(AdbShellProtocolPacket)) - .pipeTo( - new WritableStream({ - write: async (chunk) => { - switch (chunk.id) { - case AdbShellProtocolId.Exit: - this.#exit.resolve(chunk.data[0]!); - break; - case AdbShellProtocolId.Stdout: - await stdoutController.enqueue(chunk.data); - break; - case AdbShellProtocolId.Stderr: - await stderrController.enqueue(chunk.data); - break; - } - }, - }), - ) - .then( - () => { - stdoutController.close(); - stderrController.close(); - // If `#exit` has already resolved, this will be a no-op - this.#exit.reject( - new Error("Socket ended without exit message"), - ); - }, - (e) => { - stdoutController.error(e); - stderrController.error(e); - // If `#exit` has already resolved, this will be a no-op - this.#exit.reject(e); - }, - ); - - this.#writer = this.#socket.writable.getWriter(); - - this.#stdin = new MaybeConsumable.WritableStream({ - write: async (chunk) => { - await this.#writer.write( - AdbShellProtocolPacket.serialize({ - id: AdbShellProtocolId.Stdin, - data: chunk, - }), - ); - }, - }); - } - - async resize(rows: number, cols: number) { - await this.#writer.write( - AdbShellProtocolPacket.serialize({ - id: AdbShellProtocolId.WindowSizeChange, - // The "correct" format is `${rows}x${cols},${x_pixels}x${y_pixels}` - // However, according to https://linux.die.net/man/4/tty_ioctl - // `x_pixels` and `y_pixels` are unused, so always sending `0` should be fine. - data: encodeUtf8(`${rows}x${cols},0x0\0`), - }), - ); - } - - kill() { - return this.#socket.close(); - } -} diff --git a/libraries/adb/src/commands/subprocess/protocols/types.ts b/libraries/adb/src/commands/subprocess/protocols/types.ts deleted file mode 100644 index d4cee51f..00000000 --- a/libraries/adb/src/commands/subprocess/protocols/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { MaybePromiseLike } from "@yume-chan/async"; -import type { - AbortSignal, - MaybeConsumable, - ReadableStream, - WritableStream, -} from "@yume-chan/stream-extra"; - -import type { Adb, AdbSocket } from "../../../adb.js"; - -export interface AdbSubprocessProtocol { - /** - * A WritableStream that writes to the `stdin` stream. - */ - readonly stdin: WritableStream>; - - /** - * The `stdout` stream of the process. - */ - readonly stdout: ReadableStream; - - /** - * The `stderr` stream of the process. - * - * Note: Some `AdbSubprocessProtocol` doesn't separate `stdout` and `stderr`, - * All output will be sent to `stdout`. - */ - readonly stderr: ReadableStream; - - /** - * A `Promise` that resolves to the exit code of the process. - * - * Note: Some `AdbSubprocessProtocol` doesn't support exit code, - * They will always resolve it with `0`. - */ - readonly exit: Promise; - - /** - * Resizes the current shell. - * - * Some `AdbSubprocessProtocol`s may not support resizing - * and will ignore calls to this method. - */ - resize(rows: number, cols: number): MaybePromiseLike; - - /** - * Kills the current process. - */ - kill(): MaybePromiseLike; -} - -export interface AdbSubprocessProtocolConstructor { - /** Returns `true` if the `adb` instance supports this shell */ - isSupported(adb: Adb): MaybePromiseLike; - - /** Spawns an executable in PTY (interactive) mode. */ - pty: ( - adb: Adb, - command: string, - signal?: AbortSignal, - ) => MaybePromiseLike; - - /** Spawns an executable and pipe the output. */ - raw( - adb: Adb, - command: string, - signal?: AbortSignal, - ): MaybePromiseLike; - - /** Creates a new `AdbShell` by attaching to an exist `AdbSocket` */ - new (socket: AdbSocket): AdbSubprocessProtocol; -} diff --git a/libraries/adb/src/commands/subprocess/pty.ts b/libraries/adb/src/commands/subprocess/pty.ts new file mode 100644 index 00000000..c297f467 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/pty.ts @@ -0,0 +1,15 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import type { + MaybeConsumable, + ReadableStream, + WritableStream, +} from "@yume-chan/stream-extra"; + +export interface AdbPtyProcess { + get input(): WritableStream>; + get output(): ReadableStream; + get exited(): Promise; + + sigint(): Promise; + kill(): MaybePromiseLike; +} diff --git a/libraries/adb/src/commands/subprocess/service.ts b/libraries/adb/src/commands/subprocess/service.ts new file mode 100644 index 00000000..dabd101e --- /dev/null +++ b/libraries/adb/src/commands/subprocess/service.ts @@ -0,0 +1,32 @@ +import type { Adb } from "../../adb.js"; +import { AdbFeature } from "../../features.js"; + +import { AdbNoneProtocolSubprocessService } from "./none/index.js"; +import { AdbShellProtocolSubprocessService } from "./shell/index.js"; + +export class AdbSubprocessService { + #adb: Adb; + get adb() { + return this.#adb; + } + + #noneProtocol: AdbNoneProtocolSubprocessService; + get noneProtocol(): AdbNoneProtocolSubprocessService { + return this.#noneProtocol; + } + + #shellProtocol?: AdbShellProtocolSubprocessService; + get shellProtocol(): AdbShellProtocolSubprocessService | undefined { + return this.#shellProtocol; + } + + constructor(adb: Adb) { + this.#adb = adb; + + this.#noneProtocol = new AdbNoneProtocolSubprocessService(adb); + + if (adb.canUseFeature(AdbFeature.ShellV2)) { + this.#shellProtocol = new AdbShellProtocolSubprocessService(adb); + } + } +} diff --git a/libraries/adb/src/commands/subprocess/shell/index.ts b/libraries/adb/src/commands/subprocess/shell/index.ts new file mode 100644 index 00000000..20aa75f4 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/index.ts @@ -0,0 +1,5 @@ +export * from "./process.js"; +export * from "./pty.js"; +export * from "./service.js"; +export * from "./shared.js"; +export * from "./spawner.js"; diff --git a/libraries/adb/src/commands/subprocess/protocols/shell.spec.ts b/libraries/adb/src/commands/subprocess/shell/process.spec.ts similarity index 82% rename from libraries/adb/src/commands/subprocess/protocols/shell.spec.ts rename to libraries/adb/src/commands/subprocess/shell/process.spec.ts index 0ba939fc..0b943e5d 100644 --- a/libraries/adb/src/commands/subprocess/protocols/shell.spec.ts +++ b/libraries/adb/src/commands/subprocess/shell/process.spec.ts @@ -7,16 +7,13 @@ import { ReadableStream, WritableStream } from "@yume-chan/stream-extra"; import type { AdbSocket } from "../../../adb.js"; -import { - AdbShellProtocolId, - AdbShellProtocolPacket, - AdbSubprocessShellProtocol, -} from "./shell.js"; +import { AdbShellProtocolProcessImpl } from "./process.js"; +import { AdbShellProtocolId, AdbShellProtocolPacket } from "./shared.js"; function createMockSocket( readable: (controller: ReadableStreamDefaultController) => void, -): [AdbSocket, PromiseResolver] { - const closed = new PromiseResolver(); +): [AdbSocket, PromiseResolver] { + const closed = new PromiseResolver(); const socket: AdbSocket = { service: "", close() {}, @@ -51,24 +48,12 @@ async function assertResolves(promise: Promise, expected: T) { return assert.deepStrictEqual(await promise, expected); } -describe("AdbShellProtocolPacket", () => { - it("should serialize", () => { - assert.deepStrictEqual( - AdbShellProtocolPacket.serialize({ - id: AdbShellProtocolId.Stdout, - data: new Uint8Array([1, 2, 3, 4]), - }), - new Uint8Array([1, 4, 0, 0, 0, 1, 2, 3, 4]), - ); - }); -}); - -describe("AdbSubprocessShellProtocol", () => { +describe("AdbShellProtocolProcessImpl", () => { describe("`stdout` and `stderr`", () => { it("should parse data from `socket", () => { const [socket] = createMockSocket(() => {}); - const process = new AdbSubprocessShellProtocol(socket); + const process = new AdbShellProtocolProcessImpl(socket); const stdoutReader = process.stdout.getReader(); const stderrReader = process.stderr.getReader(); @@ -98,12 +83,12 @@ describe("AdbSubprocessShellProtocol", () => { ); }); - const process = new AdbSubprocessShellProtocol(socket); + const process = new AdbShellProtocolProcessImpl(socket); const stdoutReader = process.stdout.getReader(); const stderrReader = process.stderr.getReader(); await stdoutReader.cancel(); - closed.resolve(); + closed.resolve(undefined); assertResolves(stderrReader.read(), { done: false, @@ -118,7 +103,7 @@ describe("AdbSubprocessShellProtocol", () => { describe("`socket` close", () => { describe("with `exit` message", () => { - it("should close `stdout`, `stderr` and resolve `exit`", async () => { + it("should close `stdout`, `stderr` and resolve `exited`", async () => { const [socket, closed] = createMockSocket((controller) => { controller.enqueue( AdbShellProtocolPacket.serialize({ @@ -129,7 +114,7 @@ describe("AdbSubprocessShellProtocol", () => { controller.close(); }); - const process = new AdbSubprocessShellProtocol(socket); + const process = new AdbShellProtocolProcessImpl(socket); const stdoutReader = process.stdout.getReader(); const stderrReader = process.stderr.getReader(); @@ -142,7 +127,7 @@ describe("AdbSubprocessShellProtocol", () => { value: new Uint8Array([4, 5, 6]), }); - closed.resolve(); + closed.resolve(undefined); assertResolves(stdoutReader.read(), { done: true, @@ -152,17 +137,17 @@ describe("AdbSubprocessShellProtocol", () => { done: true, value: undefined, }); - assert.strictEqual(await process.exit, 42); + assert.strictEqual(await process.exited, 42); }); }); describe("with no `exit` message", () => { - it("should close `stdout`, `stderr` and reject `exit`", async () => { + it("should close `stdout`, `stderr` and reject `exited`", async () => { const [socket, closed] = createMockSocket((controller) => { controller.close(); }); - const process = new AdbSubprocessShellProtocol(socket); + const process = new AdbShellProtocolProcessImpl(socket); const stdoutReader = process.stdout.getReader(); const stderrReader = process.stderr.getReader(); @@ -175,7 +160,7 @@ describe("AdbSubprocessShellProtocol", () => { value: new Uint8Array([4, 5, 6]), }); - closed.resolve(); + closed.resolve(undefined); await Promise.all([ assertResolves(stdoutReader.read(), { @@ -186,20 +171,20 @@ describe("AdbSubprocessShellProtocol", () => { done: true, value: undefined, }), - assert.rejects(process.exit), + assert.rejects(process.exited), ]); }); }); }); describe("`socket.readable` invalid data", () => { - it("should error `stdout`, `stderr` and reject `exit`", async () => { + it("should error `stdout`, `stderr` and reject `exited`", async () => { const [socket, closed] = createMockSocket((controller) => { controller.enqueue(new Uint8Array([7, 8, 9])); controller.close(); }); - const process = new AdbSubprocessShellProtocol(socket); + const process = new AdbShellProtocolProcessImpl(socket); const stdoutReader = process.stdout.getReader(); const stderrReader = process.stderr.getReader(); @@ -212,12 +197,12 @@ describe("AdbSubprocessShellProtocol", () => { value: new Uint8Array([4, 5, 6]), }); - closed.resolve(); + closed.resolve(undefined); await Promise.all([ assert.rejects(stdoutReader.read()), assert.rejects(stderrReader.read()), - assert.rejects(process.exit), + assert.rejects(process.exited), ]); }); }); diff --git a/libraries/adb/src/commands/subprocess/shell/process.ts b/libraries/adb/src/commands/subprocess/shell/process.ts new file mode 100644 index 00000000..2d73c5c4 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/process.ts @@ -0,0 +1,127 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import { PromiseResolver } from "@yume-chan/async"; +import type { + AbortSignal, + PushReadableStreamController, + ReadableStream, + WritableStreamDefaultWriter, +} from "@yume-chan/stream-extra"; +import { + MaybeConsumable, + PushReadableStream, + StructDeserializeStream, + WritableStream, +} from "@yume-chan/stream-extra"; + +import type { AdbSocket } from "../../../adb.js"; + +import { AdbShellProtocolId, AdbShellProtocolPacket } from "./shared.js"; +import type { AdbShellProtocolProcess } from "./spawner.js"; + +export class AdbShellProtocolProcessImpl implements AdbShellProtocolProcess { + readonly #socket: AdbSocket; + readonly #writer: WritableStreamDefaultWriter>; + + readonly #stdin: WritableStream>; + get stdin() { + return this.#stdin; + } + + readonly #stdout: ReadableStream; + get stdout() { + return this.#stdout; + } + + readonly #stderr: ReadableStream; + get stderr() { + return this.#stderr; + } + + readonly #exited: Promise; + get exited() { + return this.#exited; + } + + constructor(socket: AdbSocket, signal?: AbortSignal) { + this.#socket = socket; + + let stdoutController!: PushReadableStreamController; + let stderrController!: PushReadableStreamController; + this.#stdout = new PushReadableStream((controller) => { + stdoutController = controller; + }); + this.#stderr = new PushReadableStream((controller) => { + stderrController = controller; + }); + + const exited = new PromiseResolver(); + this.#exited = exited.promise; + + socket.readable + .pipeThrough(new StructDeserializeStream(AdbShellProtocolPacket)) + .pipeTo( + new WritableStream({ + write: async (chunk) => { + switch (chunk.id) { + case AdbShellProtocolId.Exit: + exited.resolve(chunk.data[0]!); + break; + case AdbShellProtocolId.Stdout: + await stdoutController.enqueue(chunk.data); + break; + case AdbShellProtocolId.Stderr: + await stderrController.enqueue(chunk.data); + break; + default: + // Ignore unknown messages like Google ADB does + // https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/daemon/shell_service.cpp;l=684;drc=61197364367c9e404c7da6900658f1b16c42d0da + break; + } + }, + }), + ) + .then( + () => { + stdoutController.close(); + stderrController.close(); + // If `exited` has already settled, this will be a no-op + exited.reject( + new Error("Socket ended without exit message"), + ); + }, + (e) => { + stdoutController.error(e); + stderrController.error(e); + // If `exited` has already settled, this will be a no-op + exited.reject(e); + }, + ); + + if (signal) { + // `signal` won't affect `this.stdout` and `this.stderr` + // So remaining data can still be read + // (call `controller.error` will discard all pending data) + + signal.addEventListener("abort", () => { + exited.reject(signal.reason); + this.#socket.close(); + }); + } + + this.#writer = this.#socket.writable.getWriter(); + this.#stdin = new MaybeConsumable.WritableStream({ + write: async (chunk) => { + await this.#writer.write( + AdbShellProtocolPacket.serialize({ + id: AdbShellProtocolId.Stdin, + data: chunk, + }), + ); + }, + }); + } + + kill(): MaybePromiseLike { + return this.#socket.close(); + } +} diff --git a/libraries/adb/src/commands/subprocess/shell/pty.ts b/libraries/adb/src/commands/subprocess/shell/pty.ts new file mode 100644 index 00000000..43ddaed3 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/pty.ts @@ -0,0 +1,112 @@ +import { PromiseResolver } from "@yume-chan/async"; +import type { + PushReadableStreamController, + ReadableStream, + WritableStreamDefaultWriter, +} from "@yume-chan/stream-extra"; +import { + MaybeConsumable, + PushReadableStream, + StructDeserializeStream, + WritableStream, +} from "@yume-chan/stream-extra"; +import { encodeUtf8 } from "@yume-chan/struct"; + +import type { AdbSocket } from "../../../adb.js"; +import type { AdbPtyProcess } from "../pty.js"; + +import { AdbShellProtocolId, AdbShellProtocolPacket } from "./shared.js"; + +export class AdbShellProtocolPtyProcess implements AdbPtyProcess { + readonly #socket: AdbSocket; + readonly #writer: WritableStreamDefaultWriter>; + + readonly #input: WritableStream>; + get input() { + return this.#input; + } + + readonly #stdout: ReadableStream; + get output() { + return this.#stdout; + } + + readonly #exited = new PromiseResolver(); + get exited() { + return this.#exited.promise; + } + + constructor(socket: AdbSocket) { + this.#socket = socket; + + let stdoutController!: PushReadableStreamController; + this.#stdout = new PushReadableStream((controller) => { + stdoutController = controller; + }); + + socket.readable + .pipeThrough(new StructDeserializeStream(AdbShellProtocolPacket)) + .pipeTo( + new WritableStream({ + write: async (chunk) => { + switch (chunk.id) { + case AdbShellProtocolId.Exit: + this.#exited.resolve(chunk.data[0]!); + break; + case AdbShellProtocolId.Stdout: + await stdoutController.enqueue(chunk.data); + break; + } + }, + }), + ) + .then( + () => { + stdoutController.close(); + // If `#exit` has already resolved, this will be a no-op + this.#exited.reject( + new Error("Socket ended without exit message"), + ); + }, + (e) => { + stdoutController.error(e); + // If `#exit` has already resolved, this will be a no-op + this.#exited.reject(e); + }, + ); + + this.#writer = this.#socket.writable.getWriter(); + this.#input = new MaybeConsumable.WritableStream({ + write: (chunk) => this.#writeStdin(chunk), + }); + } + + #writeStdin(chunk: Uint8Array) { + return this.#writer.write( + AdbShellProtocolPacket.serialize({ + id: AdbShellProtocolId.Stdin, + data: chunk, + }), + ); + } + + async resize(rows: number, cols: number) { + await this.#writer.write( + AdbShellProtocolPacket.serialize({ + id: AdbShellProtocolId.WindowSizeChange, + // The "correct" format is `${rows}x${cols},${x_pixels}x${y_pixels}` + // However, according to https://linux.die.net/man/4/tty_ioctl + // `x_pixels` and `y_pixels` are unused, so always sending `0` should be fine. + data: encodeUtf8(`${rows}x${cols},0x0\0`), + }), + ); + } + + sigint() { + return this.#writeStdin(new Uint8Array([0x03])); + } + + kill() { + return this.#socket.close(); + } +} diff --git a/libraries/adb/src/commands/subprocess/shell/service.ts b/libraries/adb/src/commands/subprocess/shell/service.ts new file mode 100644 index 00000000..dd31db5c --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/service.ts @@ -0,0 +1,58 @@ +import type { Adb } from "../../../adb.js"; +import { AdbFeature } from "../../../features.js"; + +import { AdbShellProtocolProcessImpl } from "./process.js"; +import { AdbShellProtocolPtyProcess } from "./pty.js"; +import { AdbShellProtocolSpawner } from "./spawner.js"; + +export class AdbShellProtocolSubprocessService extends AdbShellProtocolSpawner { + readonly #adb: Adb; + get adb() { + return this.#adb; + } + + get isSupported() { + return this.#adb.canUseFeature(AdbFeature.ShellV2); + } + + constructor(adb: Adb) { + super(async (command, signal) => { + const socket = await this.#adb.createSocket( + `shell,v2,raw:${command.join(" ")}`, + ); + + if (signal?.aborted) { + await socket.close(); + throw signal.reason; + } + + return new AdbShellProtocolProcessImpl(socket, signal); + }); + this.#adb = adb; + } + + async pty(options?: { + command?: string | string[] | undefined; + terminalType?: string; + }): Promise { + let service = "shell,v2,pty"; + + if (options?.terminalType) { + service += `,TERM=` + options.terminalType; + } + + service += ":"; + + if (options) { + if (typeof options.command === "string") { + service += options.command; + } else if (Array.isArray(options.command)) { + service += options.command.join(" "); + } + } + + return new AdbShellProtocolPtyProcess( + await this.#adb.createSocket(service), + ); + } +} diff --git a/libraries/adb/src/commands/subprocess/shell/shared.spec.ts b/libraries/adb/src/commands/subprocess/shell/shared.spec.ts new file mode 100644 index 00000000..4a7beff1 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/shared.spec.ts @@ -0,0 +1,16 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; + +import { AdbShellProtocolId, AdbShellProtocolPacket } from "./shared.js"; + +describe("AdbShellProtocolPacket", () => { + it("should serialize", () => { + assert.deepStrictEqual( + AdbShellProtocolPacket.serialize({ + id: AdbShellProtocolId.Stdout, + data: new Uint8Array([1, 2, 3, 4]), + }), + new Uint8Array([1, 4, 0, 0, 0, 1, 2, 3, 4]), + ); + }); +}); diff --git a/libraries/adb/src/commands/subprocess/shell/shared.ts b/libraries/adb/src/commands/subprocess/shell/shared.ts new file mode 100644 index 00000000..710bf4c3 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/shared.ts @@ -0,0 +1,25 @@ +import type { StructValue } from "@yume-chan/struct"; +import { buffer, struct, u32, u8 } from "@yume-chan/struct"; + +export const AdbShellProtocolId = { + Stdin: 0, + Stdout: 1, + Stderr: 2, + Exit: 3, + CloseStdin: 4, + WindowSizeChange: 5, +} as const; + +export type AdbShellProtocolId = + (typeof AdbShellProtocolId)[keyof typeof AdbShellProtocolId]; + +// This packet format is used in both directions. +export const AdbShellProtocolPacket = struct( + { + id: u8(), + data: buffer(u32), + }, + { littleEndian: true }, +); + +export type AdbShellProtocolPacket = StructValue; diff --git a/libraries/adb/src/commands/subprocess/shell/spawner.ts b/libraries/adb/src/commands/subprocess/shell/spawner.ts new file mode 100644 index 00000000..f9894d25 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/shell/spawner.ts @@ -0,0 +1,90 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; +import type { + AbortSignal, + MaybeConsumable, + ReadableStream, + WritableStream, +} from "@yume-chan/stream-extra"; +import { + ConcatBufferStream, + ConcatStringStream, + TextDecoderStream, +} from "@yume-chan/stream-extra"; + +import { splitCommand } from "../utils.js"; + +export interface AdbShellProtocolProcess { + get stdin(): WritableStream>; + + get stdout(): ReadableStream; + get stderr(): ReadableStream; + + get exited(): Promise; + + kill(): MaybePromiseLike; +} + +export class AdbShellProtocolSpawner { + readonly #spawn: ( + command: string[], + signal: AbortSignal | undefined, + ) => Promise; + + constructor( + spawn: ( + command: string[], + signal: AbortSignal | undefined, + ) => Promise, + ) { + this.#spawn = spawn; + } + + spawn( + command: string | string[], + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + + if (typeof command === "string") { + command = splitCommand(command); + } + + return this.#spawn(command, signal); + } + + async spawnWait( + command: string | string[], + ): Promise> { + const process = await this.spawn(command); + const [stdout, stderr, exitCode] = await Promise.all([ + process.stdout.pipeThrough(new ConcatBufferStream()), + process.stderr.pipeThrough(new ConcatBufferStream()), + process.exited, + ]); + return { stdout, stderr, exitCode }; + } + + async spawnWaitText( + command: string | string[], + ): Promise> { + const process = await this.spawn(command); + const [stdout, stderr, exitCode] = await Promise.all([ + process.stdout + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new ConcatStringStream()), + process.stderr + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new ConcatStringStream()), + process.exited, + ]); + return { stdout, stderr, exitCode }; + } +} + +export namespace AdbShellProtocolSpawner { + export interface WaitResult { + stdout: T; + stderr: T; + exitCode: number; + } +} diff --git a/libraries/adb/src/commands/subprocess/utils.ts b/libraries/adb/src/commands/subprocess/utils.ts index edfba3aa..ed7ce648 100644 --- a/libraries/adb/src/commands/subprocess/utils.ts +++ b/libraries/adb/src/commands/subprocess/utils.ts @@ -18,3 +18,44 @@ export function escapeArg(s: string) { result += `'`; return result; } + +export function splitCommand(command: string): string[] { + const result: string[] = []; + let quote: string | undefined; + let isEscaped = false; + let start = 0; + + for (let i = 0, len = command.length; i < len; i += 1) { + if (isEscaped) { + isEscaped = false; + continue; + } + + const char = command.charAt(i); + switch (char) { + case " ": + if (!quote && i !== start) { + result.push(command.substring(start, i)); + start = i + 1; + } + break; + case "'": + case '"': + if (!quote) { + quote = char; + } else if (char === quote) { + quote = undefined; + } + break; + case "\\": + isEscaped = true; + break; + } + } + + if (start < command.length) { + result.push(command.substring(start)); + } + + return result; +} diff --git a/libraries/adb/src/commands/sync/socket.spec.ts b/libraries/adb/src/commands/sync/socket.spec.ts index 4e343ffc..b7a4bc12 100644 --- a/libraries/adb/src/commands/sync/socket.spec.ts +++ b/libraries/adb/src/commands/sync/socket.spec.ts @@ -14,7 +14,7 @@ describe("AdbSyncSocket", () => { { service: "", close() {}, - closed: Promise.resolve(), + closed: Promise.resolve(undefined), readable: new ReadableStream(), writable: new WritableStream(), }, diff --git a/libraries/adb/src/commands/sync/sync.ts b/libraries/adb/src/commands/sync/sync.ts index 61fd15dd..aa0771a4 100644 --- a/libraries/adb/src/commands/sync/sync.ts +++ b/libraries/adb/src/commands/sync/sync.ts @@ -151,7 +151,7 @@ export class AdbSync { // It may fail if `filename` already exists. // Ignore the result. // TODO: sync: test push mkdir workaround (need an Android 8 device) - await this._adb.subprocess.spawnAndWait([ + await this._adb.subprocess.noneProtocol.spawnWait([ "mkdir", "-p", escapeArg(dirname(options.filename)), diff --git a/libraries/adb/src/commands/tcpip.ts b/libraries/adb/src/commands/tcpip.ts index 8810b2f2..7b5a2f8c 100644 --- a/libraries/adb/src/commands/tcpip.ts +++ b/libraries/adb/src/commands/tcpip.ts @@ -1,4 +1,4 @@ -import { AdbCommandBase } from "./base.js"; +import { AdbServiceBase } from "./base.js"; /** * ADB daemon checks for the following properties in the order of @@ -27,7 +27,7 @@ function parsePort(value: string): number | undefined { return Number.parseInt(value, 10); } -export class AdbTcpIpCommand extends AdbCommandBase { +export class AdbTcpIpCommand extends AdbServiceBase { async getListenAddresses(): Promise { const serviceListenAddresses = await this.adb.getProp( "service.adb.listen_addrs", diff --git a/libraries/adb/src/daemon/socket.ts b/libraries/adb/src/daemon/socket.ts index 981edaf0..c6eff004 100644 --- a/libraries/adb/src/daemon/socket.ts +++ b/libraries/adb/src/daemon/socket.ts @@ -54,7 +54,7 @@ export class AdbDaemonSocketController #closed = false; - #closedPromise = new PromiseResolver(); + #closedPromise = new PromiseResolver(); get closed() { return this.#closedPromise.promise; } @@ -170,7 +170,7 @@ export class AdbDaemonSocketController dispose() { this.#readableController.close(); - this.#closedPromise.resolve(); + this.#closedPromise.resolve(undefined); } } @@ -200,7 +200,7 @@ export class AdbDaemonSocket implements AdbDaemonSocketInfo, AdbSocket { return this.#controller.writable; } - get closed(): Promise { + get closed(): Promise { return this.#controller.closed; } diff --git a/libraries/adb/src/server/client.ts b/libraries/adb/src/server/client.ts index 4713b31b..a3d56c97 100644 --- a/libraries/adb/src/server/client.ts +++ b/libraries/adb/src/server/client.ts @@ -458,10 +458,7 @@ export class AdbServerClient { disconnected, ); - transport.disconnected.then( - () => waitAbortController.abort(), - () => waitAbortController.abort(), - ); + void transport.disconnected.finally(() => waitAbortController.abort()); return transport; } @@ -507,7 +504,7 @@ export namespace AdbServerClient { export interface ServerConnection extends ReadableWritablePair>, Closeable { - get closed(): Promise; + get closed(): Promise; } export interface ServerConnector { diff --git a/libraries/android-bin/src/am.ts b/libraries/android-bin/src/am.ts index a3638088..f64ba178 100644 --- a/libraries/android-bin/src/am.ts +++ b/libraries/android-bin/src/am.ts @@ -1,8 +1,7 @@ import type { Adb } from "@yume-chan/adb"; -import { AdbCommandBase } from "@yume-chan/adb"; -import { ConcatStringStream, TextDecoderStream } from "@yume-chan/stream-extra"; +import { AdbServiceBase } from "@yume-chan/adb"; -import { Cmd } from "./cmd.js"; +import { CmdNoneProtocolService } from "./cmd.js"; import type { IntentBuilder } from "./intent.js"; import type { SingleUser } from "./utils.js"; import { buildArguments } from "./utils.js"; @@ -24,39 +23,33 @@ const START_ACTIVITY_OPTIONS_MAP: Partial< user: "--user", }; -export class ActivityManager extends AdbCommandBase { - #cmd: Cmd; +export class ActivityManager extends AdbServiceBase { + static ServiceName = "activity"; + static CommandName = "am"; + + #cmd: CmdNoneProtocolService; constructor(adb: Adb) { super(adb); - this.#cmd = new Cmd(adb); - } - - async #cmdOrSubprocess(args: string[]) { - if (this.#cmd.supportsCmd) { - args.shift(); - return await this.#cmd.spawn(false, "activity", ...args); - } - - return this.adb.subprocess.spawn(args); + this.#cmd = new CmdNoneProtocolService( + adb, + ActivityManager.CommandName, + ); } async startActivity( options: ActivityManagerStartActivityOptions, ): Promise { let args = buildArguments( - ["am", "start-activity", "-W"], + [ActivityManager.ServiceName, "start-activity", "-W"], options, START_ACTIVITY_OPTIONS_MAP, ); args = args.concat(options.intent.build()); - const process = await this.#cmdOrSubprocess(args); - - const output = await process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()) + const output = await this.#cmd + .spawnWaitText(args) .then((output) => output.trim()); for (const line of output) { diff --git a/libraries/android-bin/src/bu.ts b/libraries/android-bin/src/bu.ts index 7f0e45ca..a4ce61c2 100644 --- a/libraries/android-bin/src/bu.ts +++ b/libraries/android-bin/src/bu.ts @@ -1,6 +1,5 @@ -import { AdbCommandBase } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra"; -import { ConcatStringStream, TextDecoderStream } from "@yume-chan/stream-extra"; export interface AdbBackupOptions { user: number; @@ -18,7 +17,7 @@ export interface AdbRestoreOptions { file: ReadableStream>; } -export class AdbBackup extends AdbCommandBase { +export class AdbBackup extends AdbServiceBase { /** * User must confirm backup on device within 60 seconds. */ @@ -55,26 +54,19 @@ export class AdbBackup extends AdbCommandBase { args.push(...options.packages); } - const process = await this.adb.subprocess.spawn(args); - return process.stdout; + const process = await this.adb.subprocess.noneProtocol.spawn(args); + return process.output; } /** * User must enter the password (if any) and * confirm restore on device within 60 seconds. */ - async restore(options: AdbRestoreOptions): Promise { + restore(options: AdbRestoreOptions): Promise { const args = ["bu", "restore"]; if (options.user !== undefined) { args.push("--user", options.user.toString()); } - const process = await this.adb.subprocess.spawn(args); - const [output] = await Promise.all([ - process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - options.file.pipeTo(process.stdin), - ]); - return output; + return this.adb.subprocess.noneProtocol.spawnWaitText(args); } } diff --git a/libraries/android-bin/src/bug-report.ts b/libraries/android-bin/src/bug-report.ts index e6fda2fc..7de44d56 100644 --- a/libraries/android-bin/src/bug-report.ts +++ b/libraries/android-bin/src/bug-report.ts @@ -2,7 +2,7 @@ // cspell: ignore bugreportz import type { Adb, AdbSync } from "@yume-chan/adb"; -import { AdbCommandBase, AdbSubprocessShellProtocol } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import type { AbortSignal, ReadableStream } from "@yume-chan/stream-extra"; import { AbortController, @@ -31,7 +31,7 @@ export interface BugReportZOptions { onProgress?: ((completed: string, total: string) => void) | undefined; } -export class BugReport extends AdbCommandBase { +export class BugReport extends AdbServiceBase { static VERSION_REGEX: RegExp = /(\d+)\.(\d+)/; static BEGIN_REGEX: RegExp = /BEGIN:(.*)/; @@ -47,7 +47,7 @@ export class BugReport extends AdbCommandBase { */ static async queryCapabilities(adb: Adb): Promise { // bugreportz requires shell protocol - if (!AdbSubprocessShellProtocol.isSupported(adb)) { + if (!adb.subprocess.shellProtocol) { return new BugReport(adb, { supportsBugReport: true, bugReportZVersion: undefined, @@ -57,11 +57,11 @@ export class BugReport extends AdbCommandBase { }); } - const { stderr, exitCode } = await adb.subprocess.spawnAndWait([ + const result = await adb.subprocess.shellProtocol.spawnWaitText([ "bugreportz", "-v", ]); - if (exitCode !== 0 || stderr === "") { + if (result.exitCode !== 0 || result.stderr === "") { return new BugReport(adb, { supportsBugReport: true, bugReportZVersion: undefined, @@ -71,7 +71,7 @@ export class BugReport extends AdbCommandBase { }); } - const match = stderr.match(BugReport.VERSION_REGEX); + const match = result.stderr.match(BugReport.VERSION_REGEX); if (!match) { return new BugReport(adb, { supportsBugReport: true, @@ -170,8 +170,9 @@ export class BugReport extends AdbCommandBase { return new WrapReadableStream(async () => { // https://cs.android.com/android/platform/superproject/+/master:frameworks/native/cmds/bugreport/bugreport.cpp;drc=9b73bf07d73dbab5b792632e1e233edbad77f5fd;bpv=0;bpt=0 - const process = await this.adb.subprocess.spawn(["bugreport"]); - return process.stdout; + const process = + await this.adb.subprocess.noneProtocol.spawn("bugreport"); + return process.output; }); } @@ -200,9 +201,8 @@ export class BugReport extends AdbCommandBase { args.push("-p"); } - const process = await this.adb.subprocess.spawn(args, { - protocols: [AdbSubprocessShellProtocol], - }); + // `subprocess.shellProtocol` must be defined when `this.#supportsBugReportZ` is `true` + const process = await this.adb.subprocess.shellProtocol!.spawn(args); options?.signal?.addEventListener("abort", () => { void process.kill(); @@ -258,10 +258,10 @@ export class BugReport extends AdbCommandBase { */ bugReportZStream(): ReadableStream { return new PushReadableStream(async (controller) => { - const process = await this.adb.subprocess.spawn( - ["bugreportz", "-s"], - { protocols: [AdbSubprocessShellProtocol] }, - ); + const process = await this.adb.subprocess.shellProtocol!.spawn([ + "bugreportz", + "-s", + ]); process.stdout .pipeTo( new WritableStream({ @@ -285,7 +285,7 @@ export class BugReport extends AdbCommandBase { .catch((e) => { controller.error(e); }); - await process.exit; + await process.exited; }); } diff --git a/libraries/android-bin/src/cmd.ts b/libraries/android-bin/src/cmd.ts index 4b8068f1..35f9b738 100644 --- a/libraries/android-bin/src/cmd.ts +++ b/libraries/android-bin/src/cmd.ts @@ -1,21 +1,17 @@ -import type { - Adb, - AdbSubprocessProtocol, - AdbSubprocessProtocolConstructor, - AdbSubprocessWaitResult, -} from "@yume-chan/adb"; +import type { Adb, AdbShellProtocolProcess } from "@yume-chan/adb"; import { - AdbCommandBase, AdbFeature, - AdbSubprocessNoneProtocol, - AdbSubprocessShellProtocol, + AdbNoneProtocolProcessImpl, + AdbNoneProtocolSpawner, + AdbServiceBase, + AdbShellProtocolProcessImpl, + AdbShellProtocolSpawner, } from "@yume-chan/adb"; -import { ConcatStringStream, TextDecoderStream } from "@yume-chan/stream-extra"; -export class Cmd extends AdbCommandBase { - #supportsShellV2: boolean; - get supportsShellV2(): boolean { - return this.#supportsShellV2; +export class CmdNoneProtocolService extends AdbNoneProtocolSpawner { + #supportsAbbExec: boolean; + get supportsAbbExec(): boolean { + return this.#supportsAbbExec; } #supportsCmd: boolean; @@ -23,91 +19,146 @@ export class Cmd extends AdbCommandBase { return this.#supportsCmd; } + get isSupported() { + return this.#supportsAbbExec || this.#supportsCmd; + } + + constructor( + adb: Adb, + fallback?: + | string + | Record + | ((service: string) => string), + ) { + super(async (command) => { + if (this.#supportsAbbExec) { + return new AdbNoneProtocolProcessImpl( + await adb.createSocket(`abb_exec:${command.join("\0")}\0`), + ); + } + + if (this.#supportsCmd) { + return adb.subprocess.noneProtocol.spawn( + `cmd ${command.join(" ")}`, + ); + } + + if (typeof fallback === "function") { + fallback = fallback(command[0]!); + } else if (typeof fallback === "object") { + fallback = fallback[command[0]!]; + } + + if (!fallback) { + throw new Error("Unsupported"); + } + + command[0] = fallback; + return adb.subprocess.noneProtocol.spawn(command); + }); + + this.#supportsCmd = adb.canUseFeature(AdbFeature.Cmd); + this.#supportsAbbExec = adb.canUseFeature(AdbFeature.AbbExec); + } +} + +export class CmdShellProtocolService extends AdbShellProtocolSpawner { + #adb: Adb; + + #supportsCmd: boolean; + get supportsCmd(): boolean { + return this.#supportsCmd; + } + #supportsAbb: boolean; get supportsAbb(): boolean { return this.#supportsAbb; } - #supportsAbbExec: boolean; - get supportsAbbExec(): boolean { - return this.#supportsAbbExec; + get isSupported() { + return ( + this.#supportsAbb || + (this.#supportsCmd && !!this.#adb.subprocess.shellProtocol) + ); } - constructor(adb: Adb) { - super(adb); - this.#supportsShellV2 = adb.canUseFeature(AdbFeature.ShellV2); + constructor( + adb: Adb, + fallback?: + | string + | Record + | ((service: string) => string), + ) { + super(async (command): Promise => { + if (this.#supportsAbb) { + return new AdbShellProtocolProcessImpl( + await this.#adb.createSocket(`abb:${command.join("\0")}\0`), + ); + } + + if (!adb.subprocess.shellProtocol) { + throw new Error("Unsupported"); + } + + if (this.#supportsCmd) { + return adb.subprocess.shellProtocol.spawn( + `cmd ${command.join(" ")}`, + ); + } + + if (typeof fallback === "function") { + fallback = fallback(command[0]!); + } else if (typeof fallback === "object") { + fallback = fallback[command[0]!]; + } + + if (!fallback) { + throw new Error("Unsupported"); + } + + command[0] = fallback; + return adb.subprocess.shellProtocol.spawn(command); + }); + + this.#adb = adb; this.#supportsCmd = adb.canUseFeature(AdbFeature.Cmd); this.#supportsAbb = adb.canUseFeature(AdbFeature.Abb); - this.#supportsAbbExec = adb.canUseFeature(AdbFeature.AbbExec); - } - - /** - * Spawn a new `cmd` command. It will use ADB's `abb` command if available. - * - * @param shellProtocol - * Whether to use shell protocol. If `true`, `stdout` and `stderr` will be separated. - * - * `cmd` doesn't use PTY, so even when shell protocol is used, - * resizing terminal size and closing `stdin` are not supported. - * @param command The command to run. - * @param args The arguments to pass to the command. - * @returns An `AdbSubprocessProtocol` that provides output streams. - */ - async spawn( - shellProtocol: boolean, - command: string, - ...args: string[] - ): Promise { - let supportsAbb: boolean; - let supportsCmd: boolean = this.#supportsCmd; - let service: string; - let Protocol: AdbSubprocessProtocolConstructor; - if (shellProtocol) { - supportsAbb = this.#supportsAbb; - supportsCmd &&= this.supportsShellV2; - service = "abb"; - Protocol = AdbSubprocessShellProtocol; - } else { - supportsAbb = this.#supportsAbbExec; - service = "abb_exec"; - Protocol = AdbSubprocessNoneProtocol; - } - - if (supportsAbb) { - return new Protocol( - await this.adb.createSocket( - `${service}:${command}\0${args.join("\0")}\0`, - ), - ); - } - - if (supportsCmd) { - return Protocol.raw(this.adb, `cmd ${command} ${args.join(" ")}`); - } - - throw new Error("Not supported"); - } - - async spawnAndWait( - command: string, - ...args: string[] - ): Promise { - const process = await this.spawn(true, command, ...args); - - const [stdout, stderr, exitCode] = await Promise.all([ - process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.stderr - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.exit, - ]); - - return { - stdout, - stderr, - exitCode, - }; + } +} + +export class Cmd extends AdbServiceBase { + #noneProtocol: CmdNoneProtocolService | undefined; + get noneProtocol() { + return this.#noneProtocol; + } + + #shellProtocol: CmdShellProtocolService | undefined; + get shellProtocol() { + return this.#shellProtocol; + } + + constructor( + adb: Adb, + fallback?: + | string + | Record + | ((service: string) => string), + ) { + super(adb); + + if ( + adb.canUseFeature(AdbFeature.AbbExec) || + adb.canUseFeature(AdbFeature.Cmd) + ) { + this.#noneProtocol = new CmdNoneProtocolService(adb, fallback); + } + + if ( + adb.canUseFeature(AdbFeature.Abb) || + (adb.canUseFeature(AdbFeature.Cmd) && + adb.canUseFeature(AdbFeature.ShellV2)) + ) { + this.#shellProtocol = new CmdShellProtocolService(adb, fallback); + } } } diff --git a/libraries/android-bin/src/demo-mode.ts b/libraries/android-bin/src/demo-mode.ts index ed2e661b..29230875 100644 --- a/libraries/android-bin/src/demo-mode.ts +++ b/libraries/android-bin/src/demo-mode.ts @@ -5,7 +5,7 @@ // cspell: ignore sysui import type { Adb } from "@yume-chan/adb"; -import { AdbCommandBase } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import { Settings } from "./settings.js"; @@ -51,7 +51,7 @@ export const DemoModeStatusBarModes = [ export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number]; -export class DemoMode extends AdbCommandBase { +export class DemoMode extends AdbServiceBase { #settings: Settings; constructor(adb: Adb) { @@ -112,7 +112,7 @@ export class DemoMode extends AdbCommandBase { command: string, extra?: Record, ): Promise { - await this.adb.subprocess.spawnAndWaitLegacy([ + await this.adb.subprocess.noneProtocol.spawnWaitText([ "am", "broadcast", "-a", diff --git a/libraries/android-bin/src/dumpsys.ts b/libraries/android-bin/src/dumpsys.ts index e1b425b7..67b20ef9 100644 --- a/libraries/android-bin/src/dumpsys.ts +++ b/libraries/android-bin/src/dumpsys.ts @@ -1,4 +1,4 @@ -import { AdbCommandBase } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; const BatteryDumpFields: Record< string, @@ -54,17 +54,17 @@ const Battery = { Health, }; -export class DumpSys extends AdbCommandBase { +export class DumpSys extends AdbServiceBase { static readonly Battery = Battery; async diskStats() { - const output = await this.adb.subprocess.spawnAndWaitLegacy([ + const result = await this.adb.subprocess.noneProtocol.spawnWaitText([ "dumpsys", "diskstats", ]); function getSize(name: string) { - const match = output.match( + const match = result.match( new RegExp(`${name}-Free: (\\d+)K / (\\d+)K`), ); if (!match) { @@ -91,7 +91,7 @@ export class DumpSys extends AdbCommandBase { } async battery(): Promise { - const output = await this.adb.subprocess.spawnAndWaitLegacy([ + const result = await this.adb.subprocess.noneProtocol.spawnWaitText([ "dumpsys", "battery", ]); @@ -105,7 +105,7 @@ export class DumpSys extends AdbCommandBase { health: DumpSys.Battery.Health.Unknown, }; - for (const line of output.split("\n")) { + for (const line of result.split("\n")) { const parts = line.split(":").map((part) => part.trim()); if (parts.length !== 2) { continue; diff --git a/libraries/android-bin/src/logcat.ts b/libraries/android-bin/src/logcat.ts index 39f98f28..e542ecd7 100644 --- a/libraries/android-bin/src/logcat.ts +++ b/libraries/android-bin/src/logcat.ts @@ -1,7 +1,7 @@ // cspell: ignore logcat // cspell: ignore usec -import { AdbCommandBase, AdbSubprocessNoneProtocol } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import type { ReadableStream } from "@yume-chan/stream-extra"; import { BufferedTransformStream, @@ -405,7 +405,7 @@ export interface LogSize { maxPayloadSize: number; } -export class Logcat extends AdbCommandBase { +export class Logcat extends AdbServiceBase { static logIdToName(id: LogId): string { return LogIdName[id]!; } @@ -435,14 +435,14 @@ export class Logcat extends AdbCommandBase { /(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed, (.*) (.*)B readable\), max entry is (.*) B, max payload is (.*) B/; async getLogSize(ids?: LogId[]): Promise { - const { stdout } = await this.adb.subprocess.spawn([ + const process = await this.adb.subprocess.noneProtocol.spawn([ "logcat", "-g", ...(ids ? ["-b", Logcat.joinLogId(ids)] : []), ]); const result: LogSize[] = []; - for await (const line of stdout + for await (const line of process.output .pipeThrough(new TextDecoderStream()) .pipeThrough(new SplitStringStream("\n"))) { let match = line.match(Logcat.LOG_SIZE_REGEX_11); @@ -494,7 +494,7 @@ export class Logcat extends AdbCommandBase { args.push("-b", Logcat.joinLogId(ids)); } - await this.adb.subprocess.spawnAndWait(args); + await this.adb.subprocess.noneProtocol.spawnWaitText(args); } binary(options?: LogcatOptions): ReadableStream { @@ -520,11 +520,8 @@ export class Logcat extends AdbCommandBase { // TODO: make `spawn` return synchronously with streams pending // so it's easier to chain them. - const { stdout } = await this.adb.subprocess.spawn(args, { - // PERF: None protocol is 150% faster then Shell protocol - protocols: [AdbSubprocessNoneProtocol], - }); - return stdout; + const process = await this.adb.subprocess.noneProtocol.spawn(args); + return process.output; }).pipeThrough( new BufferedTransformStream((stream) => { return deserializeAndroidLogEntry(stream); diff --git a/libraries/android-bin/src/overlay-display.ts b/libraries/android-bin/src/overlay-display.ts index ea4efdac..783452c4 100644 --- a/libraries/android-bin/src/overlay-display.ts +++ b/libraries/android-bin/src/overlay-display.ts @@ -1,5 +1,5 @@ import type { Adb } from "@yume-chan/adb"; -import { AdbCommandBase } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import { Settings } from "./settings.js"; import { p } from "./string-format.js"; @@ -17,7 +17,7 @@ export interface OverlayDisplayDevice { showSystemDecorations: boolean; } -export class OverlayDisplay extends AdbCommandBase { +export class OverlayDisplay extends AdbServiceBase { #settings: Settings; static readonly SETTING_KEY = "overlay_display_devices"; diff --git a/libraries/android-bin/src/pm.ts b/libraries/android-bin/src/pm.ts index 12b2e17f..d048a31d 100644 --- a/libraries/android-bin/src/pm.ts +++ b/libraries/android-bin/src/pm.ts @@ -4,7 +4,7 @@ // cspell:ignore versioncode import type { Adb } from "@yume-chan/adb"; -import { AdbCommandBase, escapeArg } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra"; import { ConcatStringStream, @@ -12,7 +12,7 @@ import { TextDecoderStream, } from "@yume-chan/stream-extra"; -import { Cmd } from "./cmd.js"; +import { CmdNoneProtocolService } from "./cmd.js"; import type { IntentBuilder } from "./intent.js"; import type { SingleUserOrAll } from "./utils.js"; import { buildArguments } from "./utils.js"; @@ -260,7 +260,7 @@ function buildInstallArguments( options: Partial | undefined, ): string[] { const args = buildArguments( - ["pm", command], + [PackageManager.ServiceName, command], options, PACKAGE_MANAGER_INSTALL_OPTIONS_MAP, ); @@ -281,12 +281,15 @@ function buildInstallArguments( return args; } -export class PackageManager extends AdbCommandBase { - #cmd: Cmd; +export class PackageManager extends AdbServiceBase { + static ServiceName = "package"; + static CommandName = "pm"; + + #cmd: CmdNoneProtocolService; constructor(adb: Adb) { super(adb); - this.#cmd = new Cmd(adb); + this.#cmd = new CmdNoneProtocolService(adb, PackageManager.CommandName); } /** @@ -299,28 +302,9 @@ export class PackageManager extends AdbCommandBase { options?: Partial, ): Promise { const args = buildInstallArguments("install", options); + args[0] = PackageManager.CommandName; // WIP: old version of pm doesn't support multiple apks args.push(...apks); - return await this.adb.subprocess.spawnAndWaitLegacy(args); - } - - async pushAndInstallStream( - stream: ReadableStream>, - options?: Partial, - ): Promise { - const sync = await this.adb.sync(); - - const fileName = Math.random().toString().substring(2); - const filePath = `/data/local/tmp/${fileName}.apk`; - - try { - await sync.write({ - filename: filePath, - file: stream, - }); - } finally { - await sync.dispose(); - } // Starting from Android 7, `pm` becomes a wrapper to `cmd package`. // The benefit of `cmd package` is it starts faster than the old `pm`, @@ -331,17 +315,37 @@ export class PackageManager extends AdbCommandBase { // read files in `/data/local/tmp` (and many other places) due to SELinux policies, // so installing files must still use `pm`. // (the starting executable file decides which SELinux policies to apply) - const args = buildInstallArguments("install", options); - args.push(filePath); + const output = await this.adb.subprocess.noneProtocol + .spawnWaitText(args) + .then((output) => output.trim()); + + if (output !== "Success") { + throw new Error(output); + } + + return output; + } + + async pushAndInstallStream( + stream: ReadableStream>, + options?: Partial, + ): Promise { + const fileName = Math.random().toString().substring(2); + const filePath = `/data/local/tmp/${fileName}.apk`; + + const sync = await this.adb.sync(); try { - const output = await this.adb.subprocess - .spawnAndWaitLegacy(args.map(escapeArg)) - .then((output) => output.trim()); + await sync.write({ + filename: filePath, + file: stream, + }); + } finally { + await sync.dispose(); + } - if (output !== "Success") { - throw new Error(output); - } + try { + return await this.install([filePath], options); } finally { await this.adb.rm(filePath); } @@ -356,20 +360,17 @@ export class PackageManager extends AdbCommandBase { // It's hard to detect whether `pm` supports streaming install (unless actually trying), // so check for whether `cmd` is supported, // and assume `pm` streaming install support status is same as that. - if (!this.#cmd.supportsCmd) { + if (!this.#cmd.isSupported) { // Fall back to push file then install await this.pushAndInstallStream(stream, options); return; } const args = buildInstallArguments("install", options); - // Remove `pm` from args, `Cmd#spawn` will prepend `cmd ` so the final args - // will be `cmd package install ` - args.shift(); args.push("-S", size.toString()); - const process = await this.#cmd.spawn(false, "package", ...args); + const process = await this.#cmd.spawn(args); - const output = process.stdout + const output = process.output .pipeThrough(new TextDecoderStream()) .pipeThrough(new ConcatStringStream()) .then((output) => output.trim()); @@ -437,20 +438,11 @@ export class PackageManager extends AdbCommandBase { }; } - async #cmdOrSubprocess(args: string[]) { - if (this.#cmd.supportsCmd) { - args.shift(); - return await this.#cmd.spawn(false, "package", ...args); - } - - return this.adb.subprocess.spawn(args); - } - async *listPackages( options?: Partial, ): AsyncGenerator { const args = buildArguments( - ["pm", "list", "packages"], + ["package", "list", "packages"], options, PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP, ); @@ -458,12 +450,9 @@ export class PackageManager extends AdbCommandBase { args.push(options.filter); } - const process = await this.#cmdOrSubprocess(args); - const reader = process.stdout + const process = await this.#cmd.spawn(args); + const reader = process.output .pipeThrough(new TextDecoderStream()) - // FIXME: `SplitStringStream` will throw away some data - // if it doesn't end with a separator. So each chunk of data - // must contain several complete lines. .pipeThrough(new SplitStringStream("\n")) .getReader(); while (true) { @@ -475,12 +464,11 @@ export class PackageManager extends AdbCommandBase { } } - async getPackages(packageName: string): Promise { - const args = ["pm", "-p", packageName]; - - const process = await this.#cmdOrSubprocess(args); + async getPackageSources(packageName: string): Promise { + const args = [PackageManager.ServiceName, "-p", packageName]; + const process = await this.#cmd.spawn(args); const result: string[] = []; - for await (const line of process.stdout + for await (const line of process.output .pipeThrough(new TextDecoderStream()) .pipeThrough(new SplitStringStream("\n"))) { if (line.startsWith("package:")) { @@ -496,7 +484,7 @@ export class PackageManager extends AdbCommandBase { options?: Partial, ): Promise { const args = buildArguments( - ["pm", "uninstall"], + [PackageManager.ServiceName, "uninstall"], options, PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP, ); @@ -505,10 +493,8 @@ export class PackageManager extends AdbCommandBase { args.push(...options.splitNames); } - const process = await this.#cmdOrSubprocess(args); - const output = await process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()) + const output = await this.#cmd + .spawnWaitText(args) .then((output) => output.trim()); if (output !== "Success") { throw new Error(output); @@ -519,17 +505,15 @@ export class PackageManager extends AdbCommandBase { options: PackageManagerResolveActivityOptions, ): Promise { let args = buildArguments( - ["pm", "resolve-activity", "--components"], + [PackageManager.ServiceName, "resolve-activity", "--components"], options, PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP, ); args = args.concat(options.intent.build()); - const process = await this.#cmdOrSubprocess(args); - const output = await process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()) + const output = await this.#cmd + .spawnWaitText(args) .then((output) => output.trim()); if (output === "No activity found") { @@ -554,10 +538,8 @@ export class PackageManager extends AdbCommandBase { ): Promise { const args = buildInstallArguments("install-create", options); - const process = await this.#cmdOrSubprocess(args); - const output = await process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()) + const output = await this.#cmd + .spawnWaitText(args) .then((output) => output.trim()); const sessionIdString = output.match(/.*\[(\d+)\].*/); @@ -568,6 +550,17 @@ export class PackageManager extends AdbCommandBase { return Number.parseInt(sessionIdString[1]!, 10); } + async checkResult(stream: ReadableStream) { + const output = await stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new ConcatStringStream()) + .then((output) => output.trim()); + + if (!output.startsWith("Success")) { + throw new Error(output); + } + } + async sessionAddSplit( sessionId: number, splitName: string, @@ -581,12 +574,8 @@ export class PackageManager extends AdbCommandBase { path, ]; - const output = await this.adb.subprocess - .spawnAndWaitLegacy(args) - .then((output) => output.trim()); - if (!output.startsWith("Success")) { - throw new Error(output); - } + const process = await this.adb.subprocess.noneProtocol.spawn(args); + await this.checkResult(process.output); } async sessionAddSplitStream( @@ -596,7 +585,7 @@ export class PackageManager extends AdbCommandBase { stream: ReadableStream>, ): Promise { const args: string[] = [ - "pm", + PackageManager.ServiceName, "install-write", "-S", size.toString(), @@ -605,40 +594,31 @@ export class PackageManager extends AdbCommandBase { "-", ]; - const process = await this.#cmdOrSubprocess(args); - const output = process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()) - .then((output) => output.trim()); - + const process = await this.#cmd.spawn(args); await Promise.all([ stream.pipeTo(process.stdin), - output.then((output) => { - if (!output.startsWith("Success")) { - throw new Error(output); - } - }), + this.checkResult(process.output), ]); } async sessionCommit(sessionId: number): Promise { - const args: string[] = ["pm", "install-commit", sessionId.toString()]; - const output = await this.adb.subprocess - .spawnAndWaitLegacy(args) - .then((output) => output.trim()); - if (output !== "Success") { - throw new Error(output); - } + const args: string[] = [ + PackageManager.ServiceName, + "install-commit", + sessionId.toString(), + ]; + const process = await this.#cmd.spawn(args); + await this.checkResult(process.output); } async sessionAbandon(sessionId: number): Promise { - const args: string[] = ["pm", "install-abandon", sessionId.toString()]; - const output = await this.adb.subprocess - .spawnAndWaitLegacy(args) - .then((output) => output.trim()); - if (output !== "Success") { - throw new Error(output); - } + const args: string[] = [ + PackageManager.ServiceName, + "install-abandon", + sessionId.toString(), + ]; + const process = await this.#cmd.spawn(args); + await this.checkResult(process.output); } } diff --git a/libraries/android-bin/src/settings.ts b/libraries/android-bin/src/settings.ts index a64b0ffe..ee066190 100644 --- a/libraries/android-bin/src/settings.ts +++ b/libraries/android-bin/src/settings.ts @@ -1,7 +1,7 @@ -import type { Adb, AdbSubprocessWaitResult } from "@yume-chan/adb"; -import { AdbCommandBase } from "@yume-chan/adb"; +import type { Adb } from "@yume-chan/adb"; +import { AdbServiceBase } from "@yume-chan/adb"; -import { Cmd } from "./cmd.js"; +import { CmdNoneProtocolService } from "./cmd.js"; import type { SingleUser } from "./utils.js"; export type SettingsNamespace = "system" | "secure" | "global"; @@ -25,21 +25,24 @@ export interface SettingsPutOptions extends SettingsOptions { } // frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsService.java -export class Settings extends AdbCommandBase { - #cmd: Cmd; +export class Settings extends AdbServiceBase { + static ServiceName = "settings"; + static CommandName = "settings"; + + #cmd: CmdNoneProtocolService; constructor(adb: Adb) { super(adb); - this.#cmd = new Cmd(adb); + this.#cmd = new CmdNoneProtocolService(adb, Settings.CommandName); } - async base( + base( verb: string, namespace: SettingsNamespace, options: SettingsOptions | undefined, ...args: string[] ): Promise { - let command = ["settings"]; + let command = [Settings.ServiceName]; if (options?.user !== undefined) { command.push("--user", options.user.toString()); @@ -48,21 +51,7 @@ export class Settings extends AdbCommandBase { command.push(verb, namespace); command = command.concat(args); - let output: AdbSubprocessWaitResult; - if (this.#cmd.supportsCmd) { - output = await this.#cmd.spawnAndWait( - command[0]!, - ...command.slice(1), - ); - } else { - output = await this.adb.subprocess.spawnAndWait(command); - } - - if (output.stderr) { - throw new Error(output.stderr); - } - - return output.stdout; + return this.#cmd.spawnWaitText(command); } async get( diff --git a/libraries/scrcpy/src/utils/wrapper.ts b/libraries/scrcpy/src/utils/wrapper.ts index 041b14ce..925f3158 100644 --- a/libraries/scrcpy/src/utils/wrapper.ts +++ b/libraries/scrcpy/src/utils/wrapper.ts @@ -40,8 +40,8 @@ export class ScrcpyOptionsWrapper return this.#base.uHidOutput; } - constructor(options: ScrcpyOptions) { - this.#base = options; + constructor(base: ScrcpyOptions) { + this.#base = base; } serialize(): string[] {