feat(bin): add cmd wrapper

This commit is contained in:
Simon Chan 2023-03-02 16:01:01 +08:00
parent 9c5d2d8a5c
commit 225e369f53
19 changed files with 511 additions and 332 deletions

View file

@ -67,13 +67,15 @@ export class ScrcpyPageState {
async pushServer() {
const serverBuffer = await fetchServer();
await new ReadableStream<Uint8Array>({
await AdbScrcpyClient.pushServer(
GLOBAL_STATE.device!,
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(serverBuffer);
controller.close();
},
}).pipeTo(AdbScrcpyClient.pushServer(GLOBAL_STATE.device!));
})
);
}
decoder: H264Decoder | undefined = undefined;
@ -186,12 +188,16 @@ export class ScrcpyPageState {
);
try {
await new ReadableStream<Uint8Array>({
await AdbScrcpyClient.pushServer(
GLOBAL_STATE.device!,
new ReadableStream<Uint8Array>({
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(
@ -200,7 +206,7 @@ export class ScrcpyPageState {
})
)
)
.pipeTo(AdbScrcpyClient.pushServer(GLOBAL_STATE.device!));
);
runInAction(() => {
this.serverUploadSpeed =

View file

@ -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<Uint8Array>({
value.stdout
.pipeTo(
new WritableStream<Uint8Array>({
write: (chunk) => {
this.terminal.write(chunk);
},
}), {
}),
{
signal: this._socketAbortController.signal,
});
}
)
.catch(() => {});
const _writer = value.stdin.getWriter();
this.addDisposable(this.terminal.onData(data => {
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);

View file

@ -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<string, string> = {
[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': '',
};

View file

@ -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,21 +570,17 @@ 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
)
);
runInAction(() => {

View file

@ -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<PackageManagerInstallOptions> = {
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 = () => {
</Head>
<Stack horizontal>
<DefaultButton
<Checkbox
label="--bypass-low-target-sdk-block (Android 14)"
checked={state.options.bypassLowTargetSdkBlock}
onChange={(_, checked) => {
if (checked === undefined) {
return;
}
runInAction(() => {
state.options.bypassLowTargetSdkBlock = checked;
});
}}
/>
</Stack>
<Stack horizontal>
<PrimaryButton
disabled={!GLOBAL_STATE.device || state.installing}
text="Open"
text="Browse APK"
onClick={state.install}
/>
</Stack>
@ -127,6 +177,8 @@ const Install: NextPage = () => {
description={Stage[state.progress.stage]}
/>
)}
{state.log && <pre>{state.log}</pre>}
</Stack>
);
};

View file

@ -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<FileList>;
export function pickFile(options: { multiple?: false; } & PickFileOptions): Promise<File | null>;
export function pickFile(options: { multiple?: boolean; } & PickFileOptions): Promise<FileList | File | null> {
return new Promise<FileList | File | null>(resolve => {
const input = document.createElement('input');
input.type = 'file';
export function pickFile(
options: { multiple: true } & PickFileOptions
): Promise<FileList>;
export function pickFile(
options: { multiple?: false } & PickFileOptions
): Promise<File | null>;
export function pickFile(
options: { multiple?: boolean } & PickFileOptions
): Promise<FileList | File | null> {
return new Promise<FileList | File | null>((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<Uint8Array>;
return StreamSaver!.createWriteStream(fileName, {
size,
}) as unknown as WritableStream<Uint8Array>;
}
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<Uint8Array>(file.stream() as unknown as ReadableStream<Uint8Array>);
return new WrapReadableStream<Uint8Array>(
file.stream() as unknown as ReadableStream<Uint8Array>
);
}

View file

@ -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<string> {
// 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)),
"</dev/null",
]);
return stdout;
}
public install() {
return install(this);
}
public async sync(): Promise<AdbSync> {
const socket = await this.createSocket("sync:");
return new AdbSync(this, socket);

View file

@ -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";

View file

@ -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<Uint8Array> {
const filename = `/data/local/tmp/${Math.random()
.toString()
.substring(2)}.apk`;
let sync!: AdbSync;
return new WrapWritableStream<Uint8Array>({
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);
},
});
}

View file

@ -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.

View file

@ -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<T> {
*/
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) {

View file

@ -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)"));
});
});

View file

@ -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<Uint8Array>,
filename: string,
file: ReadableStream<Uint8Array>,
mode: number = (LinuxFileType.File << 12) | 0o666,
mtime: number = (Date.now() / 1000) | 0,
packetSize: number = ADB_SYNC_MAX_PACKET_SIZE
): WritableStream<Uint8Array> {
return pipeFrom(
new WritableStream<Uint8Array>({
async start() {
) {
const pathAndMode = `${filename},${mode.toString()}`;
await adbSyncWriteRequest(
writer,
AdbSyncRequestId.Send,
pathAndMode
);
},
async write(chunk) {
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 adbSyncReadResponse(stream, AdbSyncResponseId.Ok, AdbSyncOkResponse);
}

View file

@ -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,19 +158,20 @@ 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<Uint8Array>,
mode?: number,
mtime?: number
): WritableStream<Uint8Array> {
return new WrapWritableStream({
start: async () => {
) {
await this.sendLock.wait();
try {
if (this.needPushMkdirWorkaround) {
// It may fail if the path is already existed.
// Ignore the result.
@ -185,18 +183,17 @@ export class AdbSync extends AutoDisposable {
]);
}
return adbSyncPush(
await adbSyncPush(
this.stream,
this.writer,
filename,
file,
mode,
mtime
);
},
close: () => {
} finally {
this.sendLock.notifyOne();
},
});
}
}
public override async dispose() {

View file

@ -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",
}

View file

@ -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");
}
}
}

View file

@ -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";

View file

@ -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<PackageManagerInstallOptions>
options?: Partial<PackageManagerInstallOptions>
): string[] {
const args = ["pm", "install"];
if (options.skipExisting) {
args.push("-R");
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;
}
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");
}
return args;
}
public async install(
options: Partial<PackageManagerInstallOptions>,
...apks: string[]
): Promise<void> {
apks: string[],
options?: Partial<PackageManagerInstallOptions>
): Promise<string> {
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<Uint8Array>,
options?: Partial<PackageManagerInstallOptions>
): Promise<ReadableStream<string>> {
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<PackageManagerInstallOptions>,
size: number
): Promise<WritableStream<Uint8Array>> {
const args = this.buildInstallArgs(options);
args.push("-S", size.toString());
return (await this.adb.subprocess.spawn(args)).stdin;
size: number,
stream: ReadableStream<Uint8Array>,
options?: Partial<PackageManagerInstallOptions>
): Promise<ReadableStream<string>> {
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());
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`)
}

View file

@ -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<Uint8Array>({
async start() {
sync = await adb.sync();
return sync.write(path);
},
async close() {
public static async pushServer(
adb: Adb,
file: ReadableStream<Uint8Array>,
path = DEFAULT_SERVER_PATH
) {
const sync = await adb.sync();
try {
await sync.write(path, file);
} finally {
await sync.dispose();
},
});
}
}
public static async start(