diff --git a/apps/demo/src/components/scrcpy/state.tsx b/apps/demo/src/components/scrcpy/state.tsx index c826da9b..9b20c315 100644 --- a/apps/demo/src/components/scrcpy/state.tsx +++ b/apps/demo/src/components/scrcpy/state.tsx @@ -67,13 +67,15 @@ export class ScrcpyPageState { async pushServer() { const serverBuffer = await fetchServer(); - - await new ReadableStream({ - start(controller) { - controller.enqueue(serverBuffer); - controller.close(); - }, - }).pipeTo(AdbScrcpyClient.pushServer(GLOBAL_STATE.device!)); + await AdbScrcpyClient.pushServer( + GLOBAL_STATE.device!, + new ReadableStream({ + start(controller) { + controller.enqueue(serverBuffer); + controller.close(); + }, + }) + ); } decoder: H264Decoder | undefined = undefined; @@ -186,21 +188,25 @@ export class ScrcpyPageState { ); try { - await new ReadableStream({ - start(controller) { - controller.enqueue(serverBuffer); - controller.close(); - }, - }) - .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) - .pipeThrough( - new ProgressStream( - action((progress) => { - this.serverUploadedSize = progress; - }) + await AdbScrcpyClient.pushServer( + GLOBAL_STATE.device!, + new ReadableStream({ + start(controller) { + controller.enqueue(serverBuffer); + controller.close(); + }, + }) + // In fact `pushServer` will pipe the stream through a ChunkStream, + // but without this pipeThrough, the progress will not be updated. + .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) + .pipeThrough( + new ProgressStream( + action((progress) => { + this.serverUploadedSize = progress; + }) + ) ) - ) - .pipeTo(AdbScrcpyClient.pushServer(GLOBAL_STATE.device!)); + ); runInAction(() => { this.serverUploadSpeed = diff --git a/apps/demo/src/components/terminal.tsx b/apps/demo/src/components/terminal.tsx index bd3eccbf..362e2e74 100644 --- a/apps/demo/src/components/terminal.tsx +++ b/apps/demo/src/components/terminal.tsx @@ -2,21 +2,22 @@ import { AdbSubprocessProtocol, encodeUtf8 } from "@yume-chan/adb"; import { AutoDisposable } from "@yume-chan/event"; -import { AbortController, WritableStream } from '@yume-chan/stream-extra'; -import { Terminal } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit'; -import { SearchAddon } from 'xterm-addon-search'; -import { WebglAddon } from 'xterm-addon-webgl'; +import { AbortController, WritableStream } from "@yume-chan/stream-extra"; +import { Terminal } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import { SearchAddon } from "xterm-addon-search"; +import { WebglAddon } from "xterm-addon-webgl"; export class AdbTerminal extends AutoDisposable { - private element = document.createElement('div'); + private element = document.createElement("div"); public terminal: Terminal = new Terminal({ allowProposedApi: true, allowTransparency: true, - cursorStyle: 'bar', + cursorStyle: "bar", cursorBlink: true, - fontFamily: '"Cascadia Code", Consolas, monospace, "Source Han Sans SC", "Microsoft YaHei"', + fontFamily: + '"Cascadia Code", Consolas, monospace, "Source Han Sans SC", "Microsoft YaHei"', letterSpacing: 1, scrollback: 9000, smoothScrollDuration: 50, @@ -29,7 +30,9 @@ export class AdbTerminal extends AutoDisposable { private _socket: AdbSubprocessProtocol | undefined; private _socketAbortController: AbortController | undefined; - public get socket() { return this._socket; } + public get socket() { + return this._socket; + } public set socket(value) { if (this._socket) { // Remove event listeners @@ -46,19 +49,26 @@ export class AdbTerminal extends AutoDisposable { this._socketAbortController = new AbortController(); // pty mode only has one stream - value.stdout.pipeTo(new WritableStream({ - write: (chunk) => { - this.terminal.write(chunk); - }, - }), { - signal: this._socketAbortController.signal, - }); + value.stdout + .pipeTo( + new WritableStream({ + write: (chunk) => { + this.terminal.write(chunk); + }, + }), + { + signal: this._socketAbortController.signal, + } + ) + .catch(() => {}); const _writer = value.stdin.getWriter(); - this.addDisposable(this.terminal.onData(data => { - const buffer = encodeUtf8(data); - _writer.write(buffer); - })); + this.addDisposable( + this.terminal.onData((data) => { + const buffer = encodeUtf8(data); + _writer.write(buffer); + }) + ); this.fit(); } @@ -67,9 +77,9 @@ export class AdbTerminal extends AutoDisposable { public constructor() { super(); - this.element.style.width = '100%'; - this.element.style.height = '100%'; - this.element.style.overflow = 'hidden'; + this.element.style.width = "100%"; + this.element.style.height = "100%"; + this.element.style.overflow = "hidden"; this.terminal.loadAddon(this.searchAddon); this.terminal.loadAddon(this.fitAddon); diff --git a/apps/demo/src/pages/device-info.tsx b/apps/demo/src/pages/device-info.tsx index 4b96f426..091fdc13 100644 --- a/apps/demo/src/pages/device-info.tsx +++ b/apps/demo/src/pages/device-info.tsx @@ -5,7 +5,7 @@ import { Stack, TooltipHost, } from "@fluentui/react"; -import { AdbFeatures } from "@yume-chan/adb"; +import { AdbFeature } from "@yume-chan/adb"; import { observer } from "mobx-react-lite"; import type { NextPage } from "next"; import Head from "next/head"; @@ -14,25 +14,28 @@ import { GLOBAL_STATE } from "../state"; import { Icons, RouteStackProps } from "../utils"; const KNOWN_FEATURES: Record = { - [AdbFeatures.ShellV2]: `"shell" command now supports separating child process's stdout and stderr, and returning exit code`, + [AdbFeature.ShellV2]: `"shell" command now supports separating child process's stdout and stderr, and returning exit code`, // 'cmd': '', - [AdbFeatures.StatV2]: + [AdbFeature.StatV2]: '"sync" command now supports "STA2" (returns more information of a file than old "STAT") and "LST2" (returns information of a directory) sub command', - [AdbFeatures.ListV2]: + [AdbFeature.ListV2]: '"sync" command now supports "LST2" sub command which returns more information when listing a directory than old "LIST"', - [AdbFeatures.FixedPushMkdir]: + [AdbFeature.FixedPushMkdir]: "Android 9 (P) introduced a bug that pushing files to a non-existing directory would fail. This feature indicates it's fixed (Android 10)", // 'apex': '', // 'abb': '', // 'fixed_push_symlink_timestamp': '', - abb_exec: - 'Support "exec" command which can stream stdin into child process', + [AdbFeature.AbbExec]: + 'Supports "abb_exec" variant that can be used to install App faster', // 'remount_shell': '', // 'track_app': '', // 'sendrecv_v2': '', - // 'sendrecv_v2_brotli': '', - // 'sendrecv_v2_lz4': '', - // 'sendrecv_v2_zstd': '', + sendrecv_v2_brotli: + 'Supports "brotli" compression algorithm when pushing/pulling files', + sendrecv_v2_lz4: + 'Supports "lz4" compression algorithm when pushing/pulling files', + sendrecv_v2_zstd: + 'Supports "zstd" compression algorithm when pushing/pulling files', // 'sendrecv_v2_dry_run_send': '', }; diff --git a/apps/demo/src/pages/file-manager.tsx b/apps/demo/src/pages/file-manager.tsx index 560583c3..d3c9e068 100644 --- a/apps/demo/src/pages/file-manager.tsx +++ b/apps/demo/src/pages/file-manager.tsx @@ -29,13 +29,7 @@ import { } from "@fluentui/react-file-type-icons"; import { useConst } from "@fluentui/react-hooks"; import { getIcon } from "@fluentui/style-utilities"; -import { - ADB_SYNC_MAX_PACKET_SIZE, - AdbFeatures, - LinuxFileType, - type AdbSyncEntry, -} from "@yume-chan/adb"; -import { ChunkStream } from "@yume-chan/stream-extra"; +import { AdbFeature, LinuxFileType, type AdbSyncEntry } from "@yume-chan/adb"; import { action, autorun, @@ -270,6 +264,7 @@ class FileManagerState { const bSortKey = b[this.sortKey]!; if (aSortKey === bSortKey) { + // use name as tie breaker result = compareCaseInsensitively(a.name!, b.name!); } else if (typeof aSortKey === "string") { result = compareCaseInsensitively( @@ -277,7 +272,8 @@ class FileManagerState { bSortKey as string ); } else { - result = aSortKey < bSortKey ? -1 : 1; + result = + (aSortKey as number) < (bSortKey as number) ? -1 : 1; } } @@ -391,7 +387,7 @@ class FileManagerState { }, ]; - if (GLOBAL_STATE.device?.features?.includes(AdbFeatures.ListV2)) { + if (GLOBAL_STATE.device?.supportsFeature(AdbFeature.ListV2)) { list.push( { key: "ctime", @@ -574,22 +570,18 @@ class FileManagerState { ); try { - await createFileStream(file) - .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) - .pipeThrough( + await sync.write( + itemPath, + createFileStream(file).pipeThrough( new ProgressStream( action((uploaded) => { this.uploadedSize = uploaded; }) ) - ) - .pipeTo( - sync.write( - itemPath, - (LinuxFileType.File << 12) | 0o666, - file.lastModified / 1000 - ) - ); + ), + (LinuxFileType.File << 12) | 0o666, + file.lastModified / 1000 + ); runInAction(() => { this.uploadSpeed = diff --git a/apps/demo/src/pages/install.tsx b/apps/demo/src/pages/install.tsx index 0a754ab8..e088ee8a 100644 --- a/apps/demo/src/pages/install.tsx +++ b/apps/demo/src/pages/install.tsx @@ -1,6 +1,14 @@ -import { DefaultButton, ProgressIndicator, Stack } from "@fluentui/react"; -import { ADB_SYNC_MAX_PACKET_SIZE } from "@yume-chan/adb"; -import { ChunkStream } from "@yume-chan/stream-extra"; +import { + Checkbox, + PrimaryButton, + ProgressIndicator, + Stack, +} from "@fluentui/react"; +import { + PackageManager, + PackageManagerInstallOptions, +} from "@yume-chan/android-bin"; +import { WritableStream } from "@yume-chan/stream-extra"; import { action, makeAutoObservable, observable, runInAction } from "mobx"; import { observer } from "mobx-react-lite"; import { NextPage } from "next"; @@ -38,10 +46,17 @@ class InstallPageState { progress: Progress | undefined = undefined; + log: string = ""; + + options: Partial = { + bypassLowTargetSdkBlock: false, + }; + constructor() { makeAutoObservable(this, { progress: observable.ref, install: false, + options: observable.deep, }); } @@ -60,11 +75,14 @@ class InstallPageState { totalSize: file.size, value: 0, }; + this.log = ""; }); - await createFileStream(file) - .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) - .pipeThrough( + const pm = new PackageManager(GLOBAL_STATE.device!); + const start = Date.now(); + const log = await pm.installStream( + file.size, + createFileStream(file).pipeThrough( new ProgressStream( action((uploaded) => { if (uploaded !== file.size) { @@ -87,7 +105,24 @@ class InstallPageState { }) ) ) - .pipeTo(GLOBAL_STATE.device!.install()); + ); + + const elapsed = Date.now() - start; + await log.pipeTo( + new WritableStream({ + write: action((chunk) => { + this.log += chunk; + }), + }) + ); + + const transferRate = ( + file.size / + (elapsed / 1000) / + 1024 / + 1024 + ).toFixed(2); + this.log += `Install finished in ${elapsed}ms at ${transferRate}MB/s`; runInAction(() => { this.progress = { @@ -112,9 +147,24 @@ const Install: NextPage = () => { - { + if (checked === undefined) { + return; + } + runInAction(() => { + state.options.bypassLowTargetSdkBlock = checked; + }); + }} + /> + + + + @@ -127,6 +177,8 @@ const Install: NextPage = () => { description={Stage[state.progress.stage]} /> )} + + {state.log &&
{state.log}
} ); }; diff --git a/apps/demo/src/utils/file.ts b/apps/demo/src/utils/file.ts index 259d4403..64c15154 100644 --- a/apps/demo/src/utils/file.ts +++ b/apps/demo/src/utils/file.ts @@ -1,16 +1,26 @@ -import { WrapReadableStream, WritableStream, type ReadableStream } from '@yume-chan/stream-extra'; +import { + WrapReadableStream, + WritableStream, + type ReadableStream, +} from "@yume-chan/stream-extra"; import getConfig from "next/config"; interface PickFileOptions { accept?: string; } -export function pickFile(options: { multiple: true; } & PickFileOptions): Promise; -export function pickFile(options: { multiple?: false; } & PickFileOptions): Promise; -export function pickFile(options: { multiple?: boolean; } & PickFileOptions): Promise { - return new Promise(resolve => { - const input = document.createElement('input'); - input.type = 'file'; +export function pickFile( + options: { multiple: true } & PickFileOptions +): Promise; +export function pickFile( + options: { multiple?: false } & PickFileOptions +): Promise; +export function pickFile( + options: { multiple?: boolean } & PickFileOptions +): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; if (options.multiple) { input.multiple = true; @@ -32,25 +42,26 @@ export function pickFile(options: { multiple?: boolean; } & PickFileOptions): Pr }); } -let StreamSaver: typeof import('@yume-chan/stream-saver'); -if (typeof window !== 'undefined') { +let StreamSaver: typeof import("@yume-chan/stream-saver"); +if (typeof window !== "undefined") { const { publicRuntimeConfig } = getConfig(); // Can't use `import` here because ESM is read-only (can't set `mitm` field) // Add `await` here because top-level await is on, so every import can be a `Promise` - StreamSaver = require('@yume-chan/stream-saver'); - StreamSaver.mitm = publicRuntimeConfig.basePath + '/StreamSaver/mitm.html'; + StreamSaver = require("@yume-chan/stream-saver"); + StreamSaver.mitm = publicRuntimeConfig.basePath + "/StreamSaver/mitm.html"; } export function saveFile(fileName: string, size?: number | undefined) { - return StreamSaver!.createWriteStream( - fileName, - { size } - ) as unknown as WritableStream; + return StreamSaver!.createWriteStream(fileName, { + size, + }) as unknown as WritableStream; } export function createFileStream(file: File) { // `@types/node` typing messed things up // https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/58079 // TODO: demo: remove the wrapper after switching to native stream implementation. - return new WrapReadableStream(file.stream() as unknown as ReadableStream); + return new WrapReadableStream( + file.stream() as unknown as ReadableStream + ); } diff --git a/libraries/adb/src/adb.ts b/libraries/adb/src/adb.ts index fd0470e5..fd003fd2 100644 --- a/libraries/adb/src/adb.ts +++ b/libraries/adb/src/adb.ts @@ -21,9 +21,8 @@ import { AdbTcpIpCommand, escapeArg, framebuffer, - install, } from "./commands/index.js"; -import { AdbFeatures } from "./features.js"; +import { AdbFeature } from "./features.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js"; import { AdbCommand, calculateChecksum } from "./packet.js"; import type { @@ -119,17 +118,17 @@ export class Adb implements Closeable { // https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252 // There are some other feature constants, but some of them are only used by ADB server, not devices (daemons). const features = [ - AdbFeatures.ShellV2, - AdbFeatures.Cmd, - AdbFeatures.StatV2, - AdbFeatures.ListV2, - AdbFeatures.FixedPushMkdir, + AdbFeature.ShellV2, + AdbFeature.Cmd, + AdbFeature.StatV2, + AdbFeature.ListV2, + AdbFeature.FixedPushMkdir, "apex", - "abb", + AdbFeature.Abb, // only tells the client the symlink timestamp issue in `adb push --sync` has been fixed. // No special handling required. "fixed_push_symlink_timestamp", - "abb_exec", + AdbFeature.AbbExec, "remount_shell", "track_app", "sendrecv_v2", @@ -188,7 +187,7 @@ export class Adb implements Closeable { return this._device; } - private _features: AdbFeatures[] = []; + private _features: AdbFeature[] = []; public get features() { return this._features; } @@ -256,14 +255,14 @@ export class Adb implements Closeable { this._device = value; break; case AdbPropKey.Features: - this._features = value!.split(",") as AdbFeatures[]; + this._features = value!.split(",") as AdbFeature[]; break; } } } } - public supportsFeature(feature: AdbFeatures): boolean { + public supportsFeature(feature: AdbFeature): boolean { return this._features.includes(feature); } @@ -298,18 +297,15 @@ export class Adb implements Closeable { } public async rm(...filenames: string[]): Promise { + // https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984 const stdout = await this.subprocess.spawnAndWaitLegacy([ "rm", - "-rf", ...filenames.map((arg) => escapeArg(arg)), + " { const socket = await this.createSocket("sync:"); return new AdbSync(this, socket); diff --git a/libraries/adb/src/commands/index.ts b/libraries/adb/src/commands/index.ts index e65ff862..a6c5ddb1 100644 --- a/libraries/adb/src/commands/index.ts +++ b/libraries/adb/src/commands/index.ts @@ -1,6 +1,5 @@ export * from "./base.js"; export * from "./framebuffer.js"; -export * from "./install.js"; export * from "./power.js"; export * from "./reverse.js"; export * from "./subprocess/index.js"; diff --git a/libraries/adb/src/commands/install.ts b/libraries/adb/src/commands/install.ts deleted file mode 100644 index edfe1b3f..00000000 --- a/libraries/adb/src/commands/install.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { WritableStream } from "@yume-chan/stream-extra"; -import { WrapWritableStream } from "@yume-chan/stream-extra"; - -import type { Adb } from "../adb.js"; - -import { escapeArg } from "./subprocess/index.js"; -import type { AdbSync } from "./sync/index.js"; - -export function install(adb: Adb): WritableStream { - const filename = `/data/local/tmp/${Math.random() - .toString() - .substring(2)}.apk`; - - let sync!: AdbSync; - return new WrapWritableStream({ - async start() { - // TODO: install: support other install apk methods (streaming, etc.) - // TODO: install: support split apk formats (`adb install-multiple`) - - // Upload apk file to tmp folder - sync = await adb.sync(); - return sync.write(filename, undefined, undefined); - }, - async close() { - await sync.dispose(); - - // Invoke `pm install` to install it - await adb.subprocess.spawnAndWaitLegacy([ - "pm", - "install", - escapeArg(filename), - ]); - - // Remove the temp file - await adb.rm(filename); - }, - }); -} diff --git a/libraries/adb/src/commands/subprocess/command.ts b/libraries/adb/src/commands/subprocess/command.ts index 8663071a..1b68ee5b 100644 --- a/libraries/adb/src/commands/subprocess/command.ts +++ b/libraries/adb/src/commands/subprocess/command.ts @@ -67,7 +67,10 @@ export class AdbSubprocess extends AdbCommandBase { } /** - * Spawns an executable in PTY (interactive) mode. + * 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. @@ -80,7 +83,10 @@ export class AdbSubprocess extends AdbCommandBase { } /** - * Spawns an executable and pipe the output. + * 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. diff --git a/libraries/adb/src/commands/subprocess/protocols/shell.ts b/libraries/adb/src/commands/subprocess/protocols/shell.ts index c9087d45..4ca530b9 100644 --- a/libraries/adb/src/commands/subprocess/protocols/shell.ts +++ b/libraries/adb/src/commands/subprocess/protocols/shell.ts @@ -16,7 +16,7 @@ import type { StructValueType } from "@yume-chan/struct"; import Struct, { placeholder } from "@yume-chan/struct"; import type { Adb } from "../../../adb.js"; -import { AdbFeatures } from "../../../features.js"; +import { AdbFeature } from "../../../features.js"; import type { AdbSocket } from "../../../socket/index.js"; import { encodeUtf8 } from "../../../utils/index.js"; @@ -124,7 +124,7 @@ class MultiplexStream { */ export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol { public static isSupported(adb: Adb) { - return adb.supportsFeature(AdbFeatures.ShellV2); + return adb.supportsFeature(AdbFeature.ShellV2); } public static async pty(adb: Adb, command: string) { diff --git a/libraries/adb/src/commands/subprocess/utils.spec.ts b/libraries/adb/src/commands/subprocess/utils.spec.ts new file mode 100644 index 00000000..aeef77d6 --- /dev/null +++ b/libraries/adb/src/commands/subprocess/utils.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@jest/globals"; + +import { escapeArg } from "./utils.js"; + +describe("escapeArg", () => { + it("should escape single quotes", () => { + // https://android.googlesource.com/platform/packages/modules/adb/+/72c82a8d8ea0648ecea9dd47e0f4176bad792cfa/adb_utils_test.cpp#84 + expect(String.raw`''`).toBe(escapeArg("")); + + expect(String.raw`'abc'`).toBe(escapeArg("abc")); + + function wrap(x: string) { + return "'" + x + "'"; + } + + const q = String.raw`'\''`; + expect(wrap(q)).toBe(escapeArg("'")); + expect(wrap(q + q)).toBe(escapeArg("''")); + expect(wrap(q + "abc" + q)).toBe(escapeArg("'abc'")); + expect(wrap(q + "abc")).toBe(escapeArg("'abc")); + expect(wrap("abc" + q)).toBe(escapeArg("abc'")); + expect(wrap("abc" + q + "def")).toBe(escapeArg("abc'def")); + expect(wrap("a" + q + "b" + q + "c")).toBe(escapeArg("a'b'c")); + expect(wrap("a" + q + "bcde" + q + "f")).toBe(escapeArg("a'bcde'f")); + + expect(String.raw`' abc'`).toBe(escapeArg(" abc")); + expect(String.raw`'"abc'`).toBe(escapeArg('"abc')); + expect(String.raw`'\abc'`).toBe(escapeArg("\\abc")); + expect(String.raw`'(abc'`).toBe(escapeArg("(abc")); + expect(String.raw`')abc'`).toBe(escapeArg(")abc")); + + expect(String.raw`'abc abc'`).toBe(escapeArg("abc abc")); + expect(String.raw`'abc"abc'`).toBe(escapeArg('abc"abc')); + expect(String.raw`'abc\abc'`).toBe(escapeArg("abc\\abc")); + expect(String.raw`'abc(abc'`).toBe(escapeArg("abc(abc")); + expect(String.raw`'abc)abc'`).toBe(escapeArg("abc)abc")); + + expect(String.raw`'abc '`).toBe(escapeArg("abc ")); + expect(String.raw`'abc"'`).toBe(escapeArg('abc"')); + expect(String.raw`'abc\'`).toBe(escapeArg("abc\\")); + expect(String.raw`'abc('`).toBe(escapeArg("abc(")); + expect(String.raw`'abc)'`).toBe(escapeArg("abc)")); + }); +}); diff --git a/libraries/adb/src/commands/sync/push.ts b/libraries/adb/src/commands/sync/push.ts index 7ac2d819..1e1f2279 100644 --- a/libraries/adb/src/commands/sync/push.ts +++ b/libraries/adb/src/commands/sync/push.ts @@ -1,8 +1,9 @@ import type { BufferedReadableStream, + ReadableStream, WritableStreamDefaultWriter, } from "@yume-chan/stream-extra"; -import { ChunkStream, WritableStream, pipeFrom } from "@yume-chan/stream-extra"; +import { ChunkStream, WritableStream } from "@yume-chan/stream-extra"; import Struct from "@yume-chan/struct"; import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; @@ -15,36 +16,26 @@ export const AdbSyncOkResponse = new Struct({ littleEndian: true }).uint32( export const ADB_SYNC_MAX_PACKET_SIZE = 64 * 1024; -export function adbSyncPush( +export async function adbSyncPush( stream: BufferedReadableStream, writer: WritableStreamDefaultWriter, filename: string, + file: ReadableStream, mode: number = (LinuxFileType.File << 12) | 0o666, mtime: number = (Date.now() / 1000) | 0, packetSize: number = ADB_SYNC_MAX_PACKET_SIZE -): WritableStream { - return pipeFrom( - new WritableStream({ - async start() { - const pathAndMode = `${filename},${mode.toString()}`; - await adbSyncWriteRequest( - writer, - AdbSyncRequestId.Send, - pathAndMode - ); - }, - async write(chunk) { +) { + const pathAndMode = `${filename},${mode.toString()}`; + await adbSyncWriteRequest(writer, AdbSyncRequestId.Send, pathAndMode); + + await file.pipeThrough(new ChunkStream(packetSize)).pipeTo( + new WritableStream({ + write: async (chunk) => { await adbSyncWriteRequest(writer, AdbSyncRequestId.Data, chunk); }, - async close() { - await adbSyncWriteRequest(writer, AdbSyncRequestId.Done, mtime); - await adbSyncReadResponse( - stream, - AdbSyncResponseId.Ok, - AdbSyncOkResponse - ); - }, - }), - new ChunkStream(packetSize) + }) ); + + await adbSyncWriteRequest(writer, AdbSyncRequestId.Done, mtime); + await adbSyncReadResponse(stream, AdbSyncResponseId.Ok, AdbSyncOkResponse); } diff --git a/libraries/adb/src/commands/sync/sync.ts b/libraries/adb/src/commands/sync/sync.ts index 522369b7..4a5da415 100644 --- a/libraries/adb/src/commands/sync/sync.ts +++ b/libraries/adb/src/commands/sync/sync.ts @@ -1,17 +1,15 @@ import { AutoDisposable } from "@yume-chan/event"; import type { ReadableStream, - WritableStream, WritableStreamDefaultWriter, } from "@yume-chan/stream-extra"; import { BufferedReadableStream, WrapReadableStream, - WrapWritableStream, } from "@yume-chan/stream-extra"; import type { Adb } from "../../adb.js"; -import { AdbFeatures } from "../../features.js"; +import { AdbFeature } from "../../features.js"; import type { AdbSocket } from "../../socket/index.js"; import { AutoResetEvent } from "../../utils/index.js"; import { escapeArg } from "../subprocess/index.js"; @@ -49,22 +47,21 @@ export class AdbSync extends AutoDisposable { protected sendLock = this.addDisposable(new AutoResetEvent()); public get supportsStat(): boolean { - return this.adb.supportsFeature(AdbFeatures.StatV2); + return this.adb.supportsFeature(AdbFeature.StatV2); } public get supportsList2(): boolean { - return this.adb.supportsFeature(AdbFeatures.ListV2); + return this.adb.supportsFeature(AdbFeature.ListV2); } public get fixedPushMkdir(): boolean { - return this.adb.supportsFeature(AdbFeatures.FixedPushMkdir); + return this.adb.supportsFeature(AdbFeature.FixedPushMkdir); } public get needPushMkdirWorkaround(): boolean { // https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361 return ( - this.adb.supportsFeature(AdbFeatures.ShellV2) && - !this.fixedPushMkdir + this.adb.supportsFeature(AdbFeature.ShellV2) && !this.fixedPushMkdir ); } @@ -161,42 +158,42 @@ export class AdbSync extends AutoDisposable { * Write (or overwrite) a file on device. * * @param filename The full path of the file on device to write. + * @param file The content to write. * @param mode The unix permissions of the file. * @param mtime The modified time of the file. * @returns A `WritableStream` that writes to the file. */ - public write( + public async write( filename: string, + file: ReadableStream, mode?: number, mtime?: number - ): WritableStream { - return new WrapWritableStream({ - start: async () => { - await this.sendLock.wait(); + ) { + await this.sendLock.wait(); - if (this.needPushMkdirWorkaround) { - // It may fail if the path is already existed. - // Ignore the result. - // TODO: sync: test push mkdir workaround (need an Android 8 device) - await this.adb.subprocess.spawnAndWait([ - "mkdir", - "-p", - escapeArg(dirname(filename)), - ]); - } + try { + if (this.needPushMkdirWorkaround) { + // It may fail if the path is already existed. + // Ignore the result. + // TODO: sync: test push mkdir workaround (need an Android 8 device) + await this.adb.subprocess.spawnAndWait([ + "mkdir", + "-p", + escapeArg(dirname(filename)), + ]); + } - return adbSyncPush( - this.stream, - this.writer, - filename, - mode, - mtime - ); - }, - close: () => { - this.sendLock.notifyOne(); - }, - }); + await adbSyncPush( + this.stream, + this.writer, + filename, + file, + mode, + mtime + ); + } finally { + this.sendLock.notifyOne(); + } } public override async dispose() { diff --git a/libraries/adb/src/features.ts b/libraries/adb/src/features.ts index d2819efa..351a4a9b 100644 --- a/libraries/adb/src/features.ts +++ b/libraries/adb/src/features.ts @@ -1,9 +1,11 @@ // The order follows // https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252 -export enum AdbFeatures { +export enum AdbFeature { ShellV2 = "shell_v2", Cmd = "cmd", StatV2 = "stat_v2", ListV2 = "ls_v2", + Abb = "abb", + AbbExec = "abb_exec", FixedPushMkdir = "fixed_push_mkdir", } diff --git a/libraries/android-bin/src/cmd.ts b/libraries/android-bin/src/cmd.ts new file mode 100644 index 00000000..89b7ed54 --- /dev/null +++ b/libraries/android-bin/src/cmd.ts @@ -0,0 +1,67 @@ +import type { Adb, AdbSubprocessProtocolConstructor } from "@yume-chan/adb"; +import { + AdbCommandBase, + AdbFeature, + AdbSubprocessNoneProtocol, + AdbSubprocessShellProtocol, +} from "@yume-chan/adb"; + +export class Cmd extends AdbCommandBase { + private _supportsShellV2: boolean; + private _supportsCmd: boolean; + private _supportsAbb: boolean; + private _supportsAbbExec: boolean; + + public get supportsShellV2() { + return this._supportsShellV2; + } + public get supportsCmd() { + return this._supportsCmd; + } + public get supportsAbb() { + return this._supportsAbb; + } + public get supportsAbbExec() { + return this._supportsAbbExec; + } + + public constructor(adb: Adb) { + super(adb); + this._supportsShellV2 = adb.supportsFeature(AdbFeature.ShellV2); + this._supportsCmd = adb.supportsFeature(AdbFeature.Cmd); + this._supportsAbb = adb.supportsFeature(AdbFeature.Abb); + this._supportsAbbExec = adb.supportsFeature(AdbFeature.AbbExec); + } + + public async spawn( + shellProtocol: boolean, + command: string, + ...args: string[] + ) { + let supportAbb: boolean; + let supportCmd: boolean = this.supportsCmd; + let service: string; + let Protocol: AdbSubprocessProtocolConstructor; + if (shellProtocol) { + supportAbb = this._supportsAbb; + supportCmd &&= this.supportsShellV2; + service = "abb"; + Protocol = AdbSubprocessShellProtocol; + } else { + supportAbb = this._supportsAbbExec; + service = "abb_exec"; + Protocol = AdbSubprocessNoneProtocol; + } + + if (supportAbb) { + const socket = await this.adb.createSocket( + `${service}:${command}\0${args.join("\0")}\0` + ); + return new Protocol(socket); + } else if (supportCmd) { + return Protocol.raw(this.adb, `cmd ${command} ${args.join(" ")}`); + } else { + throw new Error("Not supported"); + } + } +} diff --git a/libraries/android-bin/src/index.ts b/libraries/android-bin/src/index.ts index 2c0644b6..12406647 100644 --- a/libraries/android-bin/src/index.ts +++ b/libraries/android-bin/src/index.ts @@ -1,6 +1,7 @@ // cspell: ignore logcat export * from "./bug-report.js"; +export * from "./cmd.js"; export * from "./demo-mode.js"; export * from "./logcat.js"; export * from "./pm.js"; diff --git a/libraries/android-bin/src/pm.ts b/libraries/android-bin/src/pm.ts index b13f254c..57aa7763 100644 --- a/libraries/android-bin/src/pm.ts +++ b/libraries/android-bin/src/pm.ts @@ -2,8 +2,30 @@ // cspell:ignore instantapp // cspell:ignore apks -import { AdbCommandBase } from "@yume-chan/adb"; -import type { WritableStream } from "@yume-chan/stream-extra"; +import type { Adb } from "@yume-chan/adb"; +import { + AdbCommandBase, + AdbSubprocessNoneProtocol, + escapeArg, +} from "@yume-chan/adb"; +import type { ReadableStream } from "@yume-chan/stream-extra"; +import { DecodeUtf8Stream, WrapReadableStream } from "@yume-chan/stream-extra"; + +import { Cmd } from "./cmd.js"; + +export enum PackageManagerInstallLocation { + Auto, + InternalOnly, + PreferExternal, +} + +export enum PackageManagerInstallReason { + Unknown, + AdminPolicy, + DeviceRestore, + DeviceSetup, + UserRequest, +} // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd export interface PackageManagerInstallOptions { @@ -22,7 +44,7 @@ export interface PackageManagerInstallOptions { /** * `-f` */ - internal: boolean; + internalStorage: boolean; /** * `-d` */ @@ -78,11 +100,11 @@ export interface PackageManagerInstallOptions { /** * `--install-location` */ - installLocation: number; + installLocation: PackageManagerInstallLocation; /** * `--install-reason` */ - installReason: number; + installReason: PackageManagerInstallReason; /** * `--force-uuid` */ @@ -121,116 +143,135 @@ export interface PackageManagerInstallOptions { bypassLowTargetSdkBlock: boolean; } +export const PACKAGE_MANAGER_INSTALL_OPTIONS_MAP: Record< + keyof PackageManagerInstallOptions, + string +> = { + skipExisting: "-R", + installerPackageName: "-i", + allowTest: "-t", + internalStorage: "-f", + requestDowngrade: "-d", + grantRuntimePermissions: "-g", + restrictPermissions: "--restrict-permissions", + doNotKill: "--dont-kill", + originatingUri: "--originating-uri", + refererUri: "--referrer", + inheritFrom: "-p", + packageName: "--pkg", + abi: "--abi", + instantApp: "--instant", + full: "--full", + preload: "--preload", + userId: "--user", + installLocation: "--install-location", + installReason: "--install-reason", + forceUuid: "--force-uuid", + apex: "--apex", + forceNonStaged: "--force-non-staged", + staged: "--staged", + forceQueryable: "--force-queryable", + enableRollback: "--enable-rollback", + stagedReadyTimeout: "--staged-ready-timeout", + skipVerification: "--skip-verification", + bypassLowTargetSdkBlock: "--bypass-low-target-sdk-block", +}; + export class PackageManager extends AdbCommandBase { + private _cmd: Cmd; + + public constructor(adb: Adb) { + super(adb); + this._cmd = new Cmd(adb); + } + private buildInstallArgs( - options: Partial + options?: Partial ): string[] { const args = ["pm", "install"]; - if (options.skipExisting) { - args.push("-R"); - } - if (options.installerPackageName) { - args.push("-i", options.installerPackageName); - } - if (options.allowTest) { - args.push("-t"); - } - if (options.internal) { - args.push("-f"); - } - if (options.requestDowngrade) { - args.push("-d"); - } - if (options.grantRuntimePermissions) { - args.push("-g"); - } - if (options.restrictPermissions) { - args.push("--restrict-permissions"); - } - if (options.doNotKill) { - args.push("--dont-kill"); - } - if (options.originatingUri) { - args.push("--originating-uri", options.originatingUri); - } - if (options.refererUri) { - args.push("--referrer", options.refererUri); - } - if (options.inheritFrom) { - args.push("-p", options.inheritFrom); - } - if (options.packageName) { - args.push("--pkg", options.packageName); - } - if (options.abi) { - args.push("--abi", options.abi); - } - if (options.instantApp) { - args.push("--instant"); - } - if (options.full) { - args.push("--full"); - } - if (options.preload) { - args.push("--preload"); - } - if (options.userId) { - args.push("--user", options.userId.toString()); - } - if (options.installLocation) { - args.push("--install-location", options.installLocation.toString()); - } - if (options.installReason) { - args.push("--install-reason", options.installReason.toString()); - } - if (options.forceUuid) { - args.push("--force-uuid", options.forceUuid); - } - if (options.apex) { - args.push("--apex"); - } - if (options.forceNonStaged) { - args.push("--force-non-staged"); - } - if (options.staged) { - args.push("--staged"); - } - if (options.forceQueryable) { - args.push("--force-queryable"); - } - if (options.enableRollback) { - args.push("--enable-rollback"); - } - if (options.stagedReadyTimeout) { - args.push( - "--staged-ready-timeout", - options.stagedReadyTimeout.toString() - ); - } - if (options.skipVerification) { - args.push("--skip-verification"); - } - if (options.bypassLowTargetSdkBlock) { - args.push("--bypass-low-target-sdk-block"); + if (options) { + for (const [key, value] of Object.entries(options)) { + if (value) { + const option = + PACKAGE_MANAGER_INSTALL_OPTIONS_MAP[ + key as keyof PackageManagerInstallOptions + ]; + if (option) { + args.push(option); + switch (typeof value) { + case "number": + args.push(value.toString()); + break; + case "string": + args.push(value); + break; + } + } + } + } } return args; } public async install( - options: Partial, - ...apks: string[] - ): Promise { + apks: string[], + options?: Partial + ): Promise { const args = this.buildInstallArgs(options); + // WIP: old version of pm doesn't support multiple apks args.push(...apks); - await this.adb.subprocess.spawnAndWaitLegacy(args); + return await this.adb.subprocess.spawnAndWaitLegacy(args); + } + + public 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(filePath, stream); + } finally { + await sync.dispose(); + } + + const args = this.buildInstallArgs(options); + const process = await AdbSubprocessNoneProtocol.raw( + this.adb, + args.map(escapeArg).join(" ") + ); + return new WrapReadableStream({ + start: () => process.stdout.pipeThrough(new DecodeUtf8Stream()), + close: async () => { + await this.adb.rm(filePath); + }, + }); } public async installStream( - options: Partial, - size: number - ): Promise> { + size: number, + stream: ReadableStream, + options?: Partial + ): Promise> { + if (!this._cmd.supportsCmd) { + return this.pushAndInstallStream(stream, options); + } + + // Android 7 added `cmd package` and piping apk to stdin, + // the source code suggests using `cmd package` over `pm`, but didn't say why. + // However, `cmd package install` can't read `/data/local/tmp` folder due to SELinux policy, + // so even ADB today is still using `pm install` for non-streaming installs. const args = this.buildInstallArgs(options); + // Remove `pm` from args, final command will be `cmd package install` + args.shift(); args.push("-S", size.toString()); - return (await this.adb.subprocess.spawn(args)).stdin; + const process = await this._cmd.spawn(false, "package", ...args); + await stream.pipeTo(process.stdin); + return process.stdout.pipeThrough(new DecodeUtf8Stream()); } + + // TODO: install: support split apk formats (`adb install-multiple`) } diff --git a/libraries/scrcpy/src/adb/client.ts b/libraries/scrcpy/src/adb/client.ts index ee4a1c3b..9c33115a 100644 --- a/libraries/scrcpy/src/adb/client.ts +++ b/libraries/scrcpy/src/adb/client.ts @@ -1,4 +1,4 @@ -import type { Adb, AdbSubprocessProtocol, AdbSync } from "@yume-chan/adb"; +import type { Adb, AdbSubprocessProtocol } from "@yume-chan/adb"; import { AdbReverseNotSupportedError, AdbSubprocessNoneProtocol, @@ -14,7 +14,6 @@ import { InspectStream, ReadableStream, SplitStringStream, - WrapWritableStream, WritableStream, } from "@yume-chan/stream-extra"; @@ -96,17 +95,17 @@ export class ScrcpyExitedError extends Error { } export class AdbScrcpyClient { - public static pushServer(adb: Adb, path = DEFAULT_SERVER_PATH) { - let sync!: AdbSync; - return new WrapWritableStream({ - async start() { - sync = await adb.sync(); - return sync.write(path); - }, - async close() { - await sync.dispose(); - }, - }); + public static async pushServer( + adb: Adb, + file: ReadableStream, + path = DEFAULT_SERVER_PATH + ) { + const sync = await adb.sync(); + try { + await sync.write(path, file); + } finally { + await sync.dispose(); + } } public static async start(