Merge branch 'main' into v2.x

This commit is contained in:
Simon Chan 2025-09-09 16:21:39 +08:00
commit c738124241
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
134 changed files with 4104 additions and 2046 deletions

View file

@ -0,0 +1,5 @@
---
"@yume-chan/adb": major
---
Sync ADB feature list with latest ADB source code. Some features have been renamed to align with ADB source code. These feature flags are considered implementation details and generally not needed for outside consumers, but it's a breaking change anyway.

View file

@ -0,0 +1,6 @@
---
"@yume-chan/adb-credential-web": major
"@yume-chan/adb": major
---
Refactor daemon authentication API and add support for more credential storages

View file

@ -0,0 +1,5 @@
---
"@yume-chan/android-bin": major
---
Removed `IntentBuilder`. APIs now takes `Intent`s using plain objects (with TypeScript typing)

View file

@ -19,9 +19,9 @@ jobs:
with: with:
node-version: 20 node-version: 20
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with: with:
version: 9.5.0 version: 10.15.0
run_install: true run_install: true
- run: pnpm run build - run: pnpm run build

View file

@ -29,9 +29,9 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with: with:
version: 9.5.0 version: 10.15.0
run_install: true run_install: true
- run: pnpm run build - run: pnpm run build

View file

@ -27,9 +27,9 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with: with:
version: 9.5.0 version: 10.15.0
run_install: true run_install: true
- run: pnpm run build - run: pnpm run build

View file

@ -30,7 +30,9 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"@yume-chan/adb": "workspace:^" "@yume-chan/adb": "workspace:^",
"@yume-chan/async": "^4.1.3",
"@yume-chan/struct": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^", "@yume-chan/eslint-config": "workspace:^",

View file

@ -1,121 +1,2 @@
// cspell: ignore RSASSA export * from "./storage/index.js";
export * from "./store.js";
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb";
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("Tango", 1);
request.onerror = () => {
reject(request.error!);
};
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("Authentication", { autoIncrement: true });
};
request.onsuccess = () => {
const db = request.result;
resolve(db);
};
});
}
async function saveKey(key: Uint8Array): Promise<void> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction("Authentication", "readwrite");
const store = transaction.objectStore("Authentication");
const putRequest = store.add(key);
putRequest.onerror = () => {
reject(putRequest.error!);
};
putRequest.onsuccess = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error!);
};
transaction.oncomplete = () => {
db.close();
};
});
}
async function getAllKeys() {
const db = await openDatabase();
return new Promise<Uint8Array[]>((resolve, reject) => {
const transaction = db.transaction("Authentication", "readonly");
const store = transaction.objectStore("Authentication");
const getRequest = store.getAll();
getRequest.onerror = () => {
reject(getRequest.error!);
};
getRequest.onsuccess = () => {
resolve(getRequest.result as Uint8Array[]);
};
transaction.onerror = () => {
reject(transaction.error!);
};
transaction.oncomplete = () => {
db.close();
};
});
}
/**
* An `AdbCredentialStore` implementation that creates RSA private keys using Web Crypto API
* and stores them in IndexedDB.
*/
export default class AdbWebCredentialStore implements AdbCredentialStore {
readonly #appName: string;
constructor(appName = "Tango") {
this.#appName = appName;
}
/**
* Generates a RSA private key and store it into LocalStorage.
*
* Calling this method multiple times will overwrite the previous key.
*
* @returns The private key in PKCS #8 format.
*/
async generateKey(): Promise<AdbPrivateKey> {
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
// 65537
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: "SHA-1",
},
true,
["sign", "verify"],
);
const privateKey = new Uint8Array(
await crypto.subtle.exportKey("pkcs8", cryptoKey),
);
await saveKey(privateKey);
return {
buffer: privateKey,
name: `${this.#appName}@${globalThis.location.hostname}`,
};
}
/**
* Yields the stored RSA private key.
*
* This method returns a generator, so `for await...of...` loop should be used to read the key.
*/
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> {
for (const key of await getAllKeys()) {
yield {
buffer: key,
name: `${this.#appName}@${globalThis.location.hostname}`,
};
}
}
}

View file

@ -0,0 +1,5 @@
export * from "./indexed-db.js";
export * from "./local-storage.js";
export * from "./password.js";
export * from "./prf/index.js";
export * from "./type.js";

View file

@ -0,0 +1,89 @@
import type { TangoDataStorage } from "./type.js";
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("Tango", 1);
request.onerror = () => {
reject(request.error!);
};
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("Authentication", { autoIncrement: true });
};
request.onsuccess = () => {
const db = request.result;
resolve(db);
};
});
}
function createTransaction<T>(
database: IDBDatabase,
callback: (transaction: IDBTransaction) => T,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = database.transaction("Authentication", "readwrite");
transaction.onerror = () => {
reject(transaction.error!);
};
transaction.oncomplete = () => {
resolve(result);
};
transaction.onabort = () => {
reject(transaction.error ?? new Error("Transaction aborted"));
};
const result = callback(transaction);
});
}
export class TangoIndexedDbStorage implements TangoDataStorage {
async save(data: Uint8Array): Promise<undefined> {
const db = await openDatabase();
try {
await createTransaction(db, (tx) => {
const store = tx.objectStore("Authentication");
store.add(data);
});
} finally {
db.close();
}
}
async *load(): AsyncGenerator<Uint8Array, void, void> {
const db = await openDatabase();
try {
const keys = await createTransaction(db, (tx) => {
return new Promise<Uint8Array[]>((resolve, reject) => {
const store = tx.objectStore("Authentication");
const getRequest = store.getAll();
getRequest.onerror = () => {
reject(getRequest.error!);
};
getRequest.onsuccess = () => {
resolve(getRequest.result as Uint8Array[]);
};
});
});
yield* keys;
} finally {
db.close();
}
}
async clear() {
const db = await openDatabase();
try {
await createTransaction(db, (tx) => {
const store = tx.objectStore("Authentication");
store.clear();
});
} finally {
db.close();
}
}
}

View file

@ -0,0 +1,22 @@
import { decodeBase64, decodeUtf8, encodeBase64 } from "@yume-chan/adb";
import type { TangoDataStorage } from "./type.js";
export class TangoLocalStorage implements TangoDataStorage {
readonly #storageKey: string;
constructor(storageKey: string) {
this.#storageKey = storageKey;
}
save(data: Uint8Array): undefined {
localStorage.setItem(this.#storageKey, decodeUtf8(encodeBase64(data)));
}
*load(): Generator<Uint8Array, void, void> {
const data = localStorage.getItem(this.#storageKey);
if (data) {
yield decodeBase64(data);
}
}
}

View file

@ -0,0 +1,149 @@
import { encodeUtf8 } from "@yume-chan/adb";
import type { MaybePromiseLike } from "@yume-chan/async";
import {
buffer,
struct,
u16,
Uint8ArrayExactReadable,
} from "@yume-chan/struct";
import type { TangoDataStorage } from "./type.js";
const Pbkdf2SaltLength = 16;
const Pbkdf2Iterations = 1_000_000;
// AES-GCM recommends 12-byte (96-bit) IV for performance and interoperability
const AesIvLength = 12;
const Bundle = struct(
{
pbkdf2Salt: buffer(Pbkdf2SaltLength),
aesIv: buffer(AesIvLength),
encrypted: buffer(u16),
},
{ littleEndian: true },
);
async function deriveAesKey(password: string, salt?: Uint8Array<ArrayBuffer>) {
const baseKey = await crypto.subtle.importKey(
"raw",
encodeUtf8(password),
"PBKDF2",
false,
["deriveKey"],
);
if (!salt) {
salt = new Uint8Array(Pbkdf2SaltLength);
crypto.getRandomValues(salt);
}
const aesKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: Pbkdf2Iterations,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
return { salt, aesKey };
}
class PasswordIncorrectError extends Error {
constructor() {
super("Password incorrect");
}
}
export class TangoPasswordProtectedStorage implements TangoDataStorage {
static PasswordIncorrectError = PasswordIncorrectError;
readonly #storage: TangoDataStorage;
readonly #requestPassword: TangoPasswordProtectedStorage.RequestPassword;
constructor(
storage: TangoDataStorage,
requestPassword: TangoPasswordProtectedStorage.RequestPassword,
) {
this.#storage = storage;
this.#requestPassword = requestPassword;
}
async save(data: Uint8Array<ArrayBuffer>): Promise<undefined> {
const password = await this.#requestPassword("save");
const { salt, aesKey } = await deriveAesKey(password);
const iv = new Uint8Array(AesIvLength);
crypto.getRandomValues(iv);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
data,
);
const bundle = Bundle.serialize({
pbkdf2Salt: salt,
aesIv: iv,
encrypted: new Uint8Array(encrypted),
});
await this.#storage.save(bundle);
// Clear secret memory
// * No way to clear `password` and `aesKey`
// * `salt`, `iv`, `encrypted` and `bundle` are not secrets
// * `data` is owned by caller and will be cleared by caller
}
async *load(): AsyncGenerator<Uint8Array, void, void> {
for await (const serialized of this.#storage.load()) {
const bundle = Bundle.deserialize(
new Uint8ArrayExactReadable(serialized),
);
const password = await this.#requestPassword("load");
const { aesKey } = await deriveAesKey(
password,
bundle.pbkdf2Salt as Uint8Array<ArrayBuffer>,
);
try {
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
},
aesKey,
bundle.encrypted as Uint8Array<ArrayBuffer>,
);
yield new Uint8Array(decrypted);
// Clear secret memory
// * No way to clear `password` and `aesKey`
// * all values in `bundle` are not secrets
// * Caller is not allowed to use `decrypted` after `yield` returns
new Uint8Array(decrypted).fill(0);
} catch (e) {
if (e instanceof DOMException && e.name === "OperationError") {
throw new PasswordIncorrectError();
}
throw e;
}
}
}
}
export namespace TangoPasswordProtectedStorage {
export type RequestPassword = (
reason: "save" | "load",
) => MaybePromiseLike<string>;
export type PasswordIncorrectError = typeof PasswordIncorrectError;
}

View file

@ -0,0 +1,3 @@
export * from "./source.js";
export * from "./storage.js";
export * from "./web-authn.js";

View file

@ -0,0 +1,11 @@
export interface TangoPrfSource {
create(input: Uint8Array<ArrayBuffer>): Promise<{
output: BufferSource;
id: Uint8Array<ArrayBuffer>;
}>;
get(
id: BufferSource,
input: Uint8Array<ArrayBuffer>,
): Promise<BufferSource>;
}

View file

@ -0,0 +1,168 @@
import {
buffer,
struct,
u16,
Uint8ArrayExactReadable,
} from "@yume-chan/struct";
import type { TangoDataStorage } from "../type.js";
import type { TangoPrfSource } from "./source.js";
// PRF generally uses FIDO HMAC secret extension, which uses HMAC with SHA-256,
// and this input is used as salt, so should be 32 bytes
const PrfInputLength = 32;
const HkdfInfoLength = 32;
// We use HMAC with SHA-512, so should be 64 bytes
const HkdfSaltLength = 64;
// AES-GCM recommends 12-byte (96-bit) IV for performance and interoperability
const AesIvLength = 12;
async function deriveAesKey(
source: BufferSource,
info: Uint8Array<ArrayBuffer>,
salt: Uint8Array<ArrayBuffer>,
): Promise<CryptoKey> {
const baseKey = await crypto.subtle.importKey(
"raw",
source,
"HKDF",
false,
["deriveKey"],
);
return await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-512",
info,
salt,
} satisfies globalThis.HkdfParams,
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function toUint8Array(source: BufferSource) {
if (source instanceof ArrayBuffer) {
return new Uint8Array(source);
}
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
}
const Bundle = struct(
{
id: buffer(u16),
prfInput: buffer(PrfInputLength),
hkdfInfo: buffer(HkdfInfoLength),
hkdfSalt: buffer(HkdfSaltLength),
aesIv: buffer(AesIvLength),
encrypted: buffer(u16),
},
{ littleEndian: true },
);
export class TangoPrfStorage implements TangoDataStorage {
readonly #storage: TangoDataStorage;
readonly #source: TangoPrfSource;
#prevId: Uint8Array<ArrayBuffer> | undefined;
constructor(storage: TangoDataStorage, source: TangoPrfSource) {
this.#storage = storage;
this.#source = source;
}
async save(data: Uint8Array<ArrayBuffer>): Promise<undefined> {
const prfInput = new Uint8Array(PrfInputLength);
crypto.getRandomValues(prfInput);
// Maybe reuse the credential, but use different PRF input and HKDF params
let id: Uint8Array<ArrayBuffer>;
let prfOutput: BufferSource;
if (this.#prevId) {
prfOutput = await this.#source.get(this.#prevId, prfInput);
id = this.#prevId;
} else {
({ output: prfOutput, id } = await this.#source.create(prfInput));
this.#prevId = id;
}
const info = new Uint8Array(HkdfInfoLength);
crypto.getRandomValues(info);
const salt = new Uint8Array(HkdfSaltLength);
crypto.getRandomValues(salt);
const aesKey = await deriveAesKey(prfOutput, info, salt);
const iv = new Uint8Array(AesIvLength);
crypto.getRandomValues(iv);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
data,
);
const bundle = Bundle.serialize({
id,
prfInput,
hkdfInfo: info,
hkdfSalt: salt,
aesIv: iv,
encrypted: new Uint8Array(encrypted),
});
await this.#storage.save(bundle);
// Clear secret memory
// * No way to clear `aesKey`
// * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets
// * `data` is owned by caller and will be cleared by caller
// * Need to clear `prfOutput`
toUint8Array(prfOutput).fill(0);
}
async *load(): AsyncGenerator<Uint8Array, void, void> {
for await (const serialized of this.#storage.load()) {
const bundle = Bundle.deserialize(
new Uint8ArrayExactReadable(serialized),
);
const prfOutput = await this.#source.get(
bundle.id as Uint8Array<ArrayBuffer>,
bundle.prfInput as Uint8Array<ArrayBuffer>,
);
this.#prevId = bundle.id as Uint8Array<ArrayBuffer>;
const aesKey = await deriveAesKey(
prfOutput,
bundle.hkdfInfo as Uint8Array<ArrayBuffer>,
bundle.hkdfSalt as Uint8Array<ArrayBuffer>,
);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
},
aesKey,
bundle.encrypted as Uint8Array<ArrayBuffer>,
);
yield new Uint8Array(decrypted);
// Clear secret memory
// * No way to clear `aesKey`
// * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets
// * `data` is owned by caller and will be cleared by caller
// * Caller is not allowed to use `decrypted` after `yield` returns
// * Need to clear `prfOutput`
toUint8Array(prfOutput).fill(0);
new Uint8Array(decrypted).fill(0);
}
}
}

View file

@ -0,0 +1,145 @@
import type { TangoPrfSource } from "./source.js";
function checkCredential(
credential: Credential | null,
): asserts credential is PublicKeyCredential {
if (!credential || !(credential instanceof PublicKeyCredential)) {
throw new Error("Can't create credential");
}
}
function getPrfOutput(credential: PublicKeyCredential) {
const extensions = credential.getClientExtensionResults();
const prf = extensions["prf"];
if (!prf) {
throw new NotSupportedError();
}
return prf;
}
class NotSupportedError extends Error {
constructor() {
super("PRF extension is not supported");
}
}
class AssertionFailedError extends Error {
constructor() {
super("Assertion failed");
}
}
export class TangoWebAuthnPrfSource implements TangoPrfSource {
static NotSupportedError = NotSupportedError;
static AssertionFailedError = AssertionFailedError;
static async isSupported(): Promise<boolean> {
if (typeof PublicKeyCredential === "undefined") {
return false;
}
if (!PublicKeyCredential.getClientCapabilities) {
return false;
}
const clientCapabilities =
await PublicKeyCredential.getClientCapabilities();
if (!clientCapabilities["extension:prf"]) {
return false;
}
return true;
}
readonly #appName: string;
readonly #userName: string;
/**
* Create a new instance of TangoWebAuthnPrfSource
* @param appName Name of your website shows in Passkey manager
* @param userName Display name of the credential shows in Passkey manager
*/
constructor(appName: string, userName: string) {
this.#appName = appName;
this.#userName = userName;
}
async create(input: Uint8Array<ArrayBuffer>): Promise<{
output: BufferSource;
id: Uint8Array<ArrayBuffer>;
}> {
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const attestation = await navigator.credentials.create({
publicKey: {
challenge,
extensions: { prf: { eval: { first: input } } },
pubKeyCredParams: [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -257 },
],
rp: { name: this.#appName },
user: {
id: challenge,
name: this.#userName,
displayName: this.#userName,
},
},
});
checkCredential(attestation);
const prf = getPrfOutput(attestation);
if (prf.enabled === undefined) {
throw new NotSupportedError();
}
const id = new Uint8Array(attestation.rawId);
if (prf.results) {
return { output: prf.results.first, id };
}
// Some authenticators only support getting PRF in assertion
const output = await this.get(id, input);
return { output, id };
}
async get(
id: BufferSource,
input: Uint8Array<ArrayBuffer>,
): Promise<BufferSource> {
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
let assertion;
try {
assertion = await navigator.credentials.get({
publicKey: {
allowCredentials: [{ type: "public-key", id }],
challenge,
extensions: { prf: { eval: { first: input } } },
},
});
} catch {
throw new AssertionFailedError();
}
checkCredential(assertion);
const prfOutput = getPrfOutput(assertion);
if (!prfOutput.results) {
throw new NotSupportedError();
}
return prfOutput.results.first;
}
}
export namespace TangoWebAuthnPrfSource {
export type NotSupportedError = typeof NotSupportedError;
export type AssertionFailedError = typeof AssertionFailedError;
}

View file

@ -0,0 +1,7 @@
import type { MaybePromiseLike } from "@yume-chan/async";
export interface TangoDataStorage {
save(data: Uint8Array): MaybePromiseLike<undefined>;
load(): Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
}

View file

@ -0,0 +1,63 @@
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb";
import { rsaParsePrivateKey } from "@yume-chan/adb";
import type { TangoDataStorage } from "./storage/index.js";
export class AdbWebCryptoCredentialStore implements AdbCredentialStore {
readonly #storage: TangoDataStorage;
readonly #appName: string;
constructor(storage: TangoDataStorage, appName: string = "Tango") {
this.#storage = storage;
this.#appName = appName;
}
async generateKey(): Promise<AdbPrivateKey> {
// NOTE: ADB public key authentication doesn't use standard
// RSASSA-PKCS1-v1_5 algorithm to sign and verify data.
// We implemented ADB public key authentication ourselves in core package,
// so some parameters for Web Crypto API are not used.
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
// 65537
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
// Not used
hash: "SHA-1",
},
true,
// Not used
["sign"],
);
const privateKey = new Uint8Array(
await crypto.subtle.exportKey("pkcs8", cryptoKey),
);
const parsed = rsaParsePrivateKey(privateKey);
await this.#storage.save(privateKey);
// Clear secret memory
// * `privateKey` is not allowed to be used after `save`
privateKey.fill(0);
return {
...parsed,
name: `${this.#appName}@${globalThis.location.hostname}`,
};
}
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> {
for await (const privateKey of this.#storage.load()) {
// `privateKey` is owned by `#storage` and will be cleared by it
yield {
...rsaParsePrivateKey(privateKey),
name: `${this.#appName}@${globalThis.location.hostname}`,
};
}
}
}

View file

@ -143,8 +143,20 @@ export class AdbDaemonWebUsbConnection
new MaybeConsumable.WritableStream({ new MaybeConsumable.WritableStream({
write: async (chunk) => { write: async (chunk) => {
try { try {
if (
typeof SharedArrayBuffer !== "undefined" &&
chunk.buffer instanceof SharedArrayBuffer
) {
// Copy data to a non-shared ArrayBuffer
const copy = new Uint8Array(chunk.byteLength);
copy.set(chunk);
chunk = copy;
}
await device.raw.transferOut( await device.raw.transferOut(
outEndpoint.endpointNumber, outEndpoint.endpointNumber,
// WebUSB doesn't support SharedArrayBuffer
// https://github.com/WICG/webusb/issues/243
toLocalUint8Array(chunk), toLocalUint8Array(chunk),
); );

View file

@ -36,6 +36,7 @@
"@yume-chan/adb": "workspace:^", "@yume-chan/adb": "workspace:^",
"@yume-chan/async": "^4.1.3", "@yume-chan/async": "^4.1.3",
"@yume-chan/event": "workspace:^", "@yume-chan/event": "workspace:^",
"@yume-chan/media-codec": "workspace:^",
"@yume-chan/scrcpy": "workspace:^", "@yume-chan/scrcpy": "workspace:^",
"@yume-chan/stream-extra": "workspace:^", "@yume-chan/stream-extra": "workspace:^",
"@yume-chan/struct": "workspace:^" "@yume-chan/struct": "workspace:^"

View file

@ -134,9 +134,9 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
} }
const args = [ const args = [
// Use `CLASSPATH=` as `-cp` argument requires Android 8.0
`CLASSPATH=${path}`,
"app_process", "app_process",
"-cp",
path,
/* unused */ "/", /* unused */ "/",
"com.genymobile.scrcpy.Server", "com.genymobile.scrcpy.Server",
options.version, options.version,
@ -144,14 +144,14 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
]; ];
if (options.spawner) { if (options.spawner) {
process = await options.spawner.spawn(args); process = await options.spawner(args);
} else { } else {
process = await adb.subprocess.noneProtocol.spawn(args); process = await adb.subprocess.noneProtocol.spawn(args);
} }
const output = process.output const output = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n")); .pipeThrough(new SplitStringStream("\n", { trimEnd: true }));
// Must read all streams, otherwise the whole connection will be blocked. // Must read all streams, otherwise the whole connection will be blocked.
const lines: string[] = []; const lines: string[] = [];

View file

@ -1,20 +1,16 @@
import { StickyEventEmitter } from "@yume-chan/event"; import { Av1, H264, H265 } from "@yume-chan/media-codec";
import type { import type {
ScrcpyMediaStreamPacket, ScrcpyMediaStreamPacket,
ScrcpyVideoSize,
ScrcpyVideoStreamMetadata, ScrcpyVideoStreamMetadata,
} from "@yume-chan/scrcpy"; } from "@yume-chan/scrcpy";
import { import { ScrcpyVideoCodecId, ScrcpyVideoSizeImpl } from "@yume-chan/scrcpy";
Av1,
h264ParseConfiguration,
h265ParseConfiguration,
ScrcpyVideoCodecId,
} from "@yume-chan/scrcpy";
import type { ReadableStream } from "@yume-chan/stream-extra"; import type { ReadableStream } from "@yume-chan/stream-extra";
import { InspectStream } from "@yume-chan/stream-extra"; import { InspectStream } from "@yume-chan/stream-extra";
import type { AdbScrcpyOptions } from "./types.js"; import type { AdbScrcpyOptions } from "./types.js";
export class AdbScrcpyVideoStream { export class AdbScrcpyVideoStream implements ScrcpyVideoSize {
#options: AdbScrcpyOptions<object>; #options: AdbScrcpyOptions<object>;
#metadata: ScrcpyVideoStreamMetadata; #metadata: ScrcpyVideoStreamMetadata;
@ -27,19 +23,15 @@ export class AdbScrcpyVideoStream {
return this.#stream; return this.#stream;
} }
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>(); #size = new ScrcpyVideoSizeImpl();
get sizeChanged() {
return this.#sizeChanged.event;
}
#width: number = 0;
get width() { get width() {
return this.#width; return this.#size.width;
} }
#height: number = 0;
get height() { get height() {
return this.#height; return this.#size.height;
}
get sizeChanged() {
return this.#size.sizeChanged;
} }
constructor( constructor(
@ -49,43 +41,46 @@ export class AdbScrcpyVideoStream {
) { ) {
this.#options = options; this.#options = options;
this.#metadata = metadata; this.#metadata = metadata;
this.#stream = stream this.#stream = stream
.pipeThrough(this.#options.createMediaStreamTransformer()) .pipeThrough(this.#options.createMediaStreamTransformer())
.pipeThrough( .pipeThrough(
new InspectStream((packet) => { new InspectStream(
if (packet.type === "configuration") { (packet): undefined => {
switch (metadata.codec) { if (packet.type === "configuration") {
case ScrcpyVideoCodecId.H264: switch (this.#metadata.codec) {
this.#configureH264(packet.data); case ScrcpyVideoCodecId.H264:
break; this.#configureH264(packet.data);
case ScrcpyVideoCodecId.H265: break;
this.#configureH265(packet.data); case ScrcpyVideoCodecId.H265:
break; this.#configureH265(packet.data);
case ScrcpyVideoCodecId.AV1: break;
// AV1 configuration is in data packet case ScrcpyVideoCodecId.AV1:
break; // AV1 configuration is in data packet
break;
}
} else if (
this.#metadata.codec === ScrcpyVideoCodecId.AV1
) {
this.#configureAv1(packet.data);
} }
} else if (metadata.codec === ScrcpyVideoCodecId.AV1) { },
this.#configureAv1(packet.data); {
} close: () => this.#size.dispose(),
}), cancel: () => this.#size.dispose(),
},
),
); );
} }
#configureH264(data: Uint8Array) { #configureH264(data: Uint8Array) {
const { croppedWidth, croppedHeight } = h264ParseConfiguration(data); const { croppedWidth, croppedHeight } = H264.parseConfiguration(data);
this.#size.setSize(croppedWidth, croppedHeight);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
} }
#configureH265(data: Uint8Array) { #configureH265(data: Uint8Array) {
const { croppedWidth, croppedHeight } = h265ParseConfiguration(data); const { croppedWidth, croppedHeight } = H265.parseConfiguration(data);
this.#size.setSize(croppedWidth, croppedHeight);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
} }
#configureAv1(data: Uint8Array) { #configureAv1(data: Uint8Array) {
@ -101,8 +96,6 @@ export class AdbScrcpyVideoStream {
const width = max_frame_width_minus_1 + 1; const width = max_frame_width_minus_1 + 1;
const height = max_frame_height_minus_1 + 1; const height = max_frame_height_minus_1 + 1;
this.#width = width; this.#size.setSize(width, height);
this.#height = height;
this.#sizeChanged.fire({ width, height });
} }
} }

View file

@ -12,6 +12,9 @@
{ {
"path": "../event/tsconfig.build.json" "path": "../event/tsconfig.build.json"
}, },
{
"path": "../media-codec/tsconfig.build.json"
},
{ {
"path": "../scrcpy/tsconfig.build.json" "path": "../scrcpy/tsconfig.build.json"
}, },

View file

@ -9,12 +9,4 @@
"node" "node"
] ]
}, },
"references": [
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
}
]
} }

View file

@ -2,6 +2,12 @@
"references": [ "references": [
{ {
"path": "./tsconfig.build.json" "path": "./tsconfig.build.json"
},
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
} }
] ]
} }

View file

@ -125,10 +125,12 @@ export class Adb implements Closeable {
.pipeThrough(new ConcatStringStream()); .pipeThrough(new ConcatStringStream());
} }
getProp(key: string): Promise<string> { async getProp(key: string): Promise<string> {
return this.subprocess.noneProtocol const output = await this.subprocess.noneProtocol
.spawnWaitText(["getprop", key]) .spawn(["getprop", key])
.then((output) => output.trim()); .wait()
.toString();
return output.trim();
} }
rm( rm(
@ -154,7 +156,11 @@ export class Adb implements Closeable {
// https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984 // https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984
args.push("</dev/null"); args.push("</dev/null");
return this.subprocess.noneProtocol.spawnWaitText(args); return this.subprocess.noneProtocol
.spawn(args)
.wait()
.toString()
.then((output) => output.trim());
} }
async sync(): Promise<AdbSync> { async sync(): Promise<AdbSync> {

View file

@ -36,7 +36,10 @@ export class AdbPower extends AdbServiceBase {
} }
powerOff(): Promise<string> { powerOff(): Promise<string> {
return this.adb.subprocess.noneProtocol.spawnWaitText(["reboot", "-p"]); return this.adb.subprocess.noneProtocol
.spawn(["reboot", "-p"])
.wait()
.toString();
} }
powerButton(longPress = false): Promise<string> { powerButton(longPress = false): Promise<string> {
@ -46,7 +49,7 @@ export class AdbPower extends AdbServiceBase {
} }
args.push("POWER"); args.push("POWER");
return this.adb.subprocess.noneProtocol.spawnWaitText(args); return this.adb.subprocess.noneProtocol.spawn(args).wait().toString();
} }
/** /**

View file

@ -1,4 +1,5 @@
export * from "./none/index.js"; export * from "./none/index.js";
export * from "./service.js"; export * from "./service.js";
export * from "./shell/index.js"; export * from "./shell/index.js";
export * from "./types.js";
export * from "./utils.js"; export * from "./utils.js";

View file

@ -2,38 +2,47 @@ import type { Adb } from "../../../adb.js";
import { AdbNoneProtocolProcessImpl } from "./process.js"; import { AdbNoneProtocolProcessImpl } from "./process.js";
import { AdbNoneProtocolPtyProcess } from "./pty.js"; import { AdbNoneProtocolPtyProcess } from "./pty.js";
import { AdbNoneProtocolSpawner } from "./spawner.js"; import { adbNoneProtocolSpawner } from "./spawner.js";
export class AdbNoneProtocolSubprocessService extends AdbNoneProtocolSpawner { export class AdbNoneProtocolSubprocessService {
readonly #adb: Adb; readonly #adb: Adb;
get adb(): Adb { get adb(): Adb {
return this.#adb; return this.#adb;
} }
constructor(adb: 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; this.#adb = adb;
} }
spawn = adbNoneProtocolSpawner(async (command, signal) => {
// Android 7 added `shell,raw:${command}` service which also triggers raw mode,
// but we want to use the most compatible one.
//
// Similar to SSH, we don't escape the `command`,
// because the command will be invoked by `sh -c`,
// it can contain environment variables (`KEY=value command`),
// and shell expansions (`echo "$KEY"` vs `echo '$KEY'`),
// which we can't know how to properly escape.
const socket = await this.#adb.createSocket(
`exec:${command.join(" ")}`,
);
if (signal?.aborted) {
await socket.close();
throw signal.reason;
}
return new AdbNoneProtocolProcessImpl(socket, signal);
});
async pty( async pty(
command?: string | readonly string[], command?: string | readonly string[],
): Promise<AdbNoneProtocolPtyProcess> { ): Promise<AdbNoneProtocolPtyProcess> {
if (command === undefined) { if (command === undefined) {
// Run the default shell
command = ""; command = "";
} else if (Array.isArray(command)) { } else if (Array.isArray(command)) {
// Don't escape `command`. See `spawn` above for details
command = command.join(" "); command = command.join(" ");
} }

View file

@ -5,13 +5,15 @@ import type {
ReadableStream, ReadableStream,
WritableStream, WritableStream,
} from "@yume-chan/stream-extra"; } from "@yume-chan/stream-extra";
import { import { concatUint8Arrays } from "@yume-chan/stream-extra";
ConcatBufferStream,
ConcatStringStream,
TextDecoderStream,
} from "@yume-chan/stream-extra";
import { splitCommand } from "../utils.js"; import type { AdbSubprocessSpawner } from "../types.js";
import {
createLazyPromise,
decodeUtf8Chunked,
splitCommand,
ToArrayStream,
} from "../utils.js";
export interface AdbNoneProtocolProcess { export interface AdbNoneProtocolProcess {
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>; get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
@ -26,43 +28,54 @@ export interface AdbNoneProtocolProcess {
kill(): MaybePromiseLike<void>; kill(): MaybePromiseLike<void>;
} }
export class AdbNoneProtocolSpawner { export type AdbNoneProtocolSpawner = (
readonly #spawn: ( command: string | readonly string[],
signal?: AbortSignal,
) => Promise<AdbNoneProtocolProcess> &
AdbSubprocessSpawner.Wait<Uint8Array, string>;
export function adbNoneProtocolSpawner(
spawn: (
command: readonly string[], command: readonly string[],
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
) => Promise<AdbNoneProtocolProcess>; ) => Promise<AdbNoneProtocolProcess>,
): AdbNoneProtocolSpawner {
constructor( return (command, signal) => {
spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbNoneProtocolProcess>,
) {
this.#spawn = spawn;
}
spawn(
command: string | readonly string[],
signal?: AbortSignal,
): Promise<AdbNoneProtocolProcess> {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (typeof command === "string") { if (typeof command === "string") {
command = splitCommand(command); command = splitCommand(command);
} }
return this.#spawn(command, signal); const processPromise = spawn(
} command,
signal,
) as Promise<AdbNoneProtocolProcess> &
AdbSubprocessSpawner.Wait<Uint8Array, string>;
async spawnWait(command: string | readonly string[]): Promise<Uint8Array> { processPromise.wait = (options) => {
const process = await this.spawn(command); const waitPromise = processPromise.then(async (process) => {
return await process.output.pipeThrough(new ConcatBufferStream()); const [, output] = await Promise.all([
} options?.stdin?.pipeTo(process.stdin),
process.output.pipeThrough(new ToArrayStream()),
]);
return output;
});
async spawnWaitText(command: string | readonly string[]): Promise<string> { return createLazyPromise(
const process = await this.spawn(command); async () => {
return await process.output const chunks = await waitPromise;
.pipeThrough(new TextDecoderStream()) return concatUint8Arrays(chunks);
.pipeThrough(new ConcatStringStream()); },
} {
async toString() {
const chunks = await waitPromise;
return decodeUtf8Chunked(chunks);
},
},
);
};
return processPromise;
};
} }

View file

@ -25,7 +25,7 @@ export class AdbSubprocessService {
this.#noneProtocol = new AdbNoneProtocolSubprocessService(adb); this.#noneProtocol = new AdbNoneProtocolSubprocessService(adb);
if (adb.canUseFeature(AdbFeature.ShellV2)) { if (adb.canUseFeature(AdbFeature.Shell2)) {
this.#shellProtocol = new AdbShellProtocolSubprocessService(adb); this.#shellProtocol = new AdbShellProtocolSubprocessService(adb);
} }
} }

View file

@ -12,6 +12,7 @@ import {
StructDeserializeStream, StructDeserializeStream,
WritableStream, WritableStream,
} from "@yume-chan/stream-extra"; } from "@yume-chan/stream-extra";
import { EmptyUint8Array } from "@yume-chan/struct";
import type { AdbSocket } from "../../../adb.js"; import type { AdbSocket } from "../../../adb.js";
@ -110,14 +111,21 @@ export class AdbShellProtocolProcessImpl implements AdbShellProtocolProcess {
this.#writer = this.#socket.writable.getWriter(); this.#writer = this.#socket.writable.getWriter();
this.#stdin = new MaybeConsumable.WritableStream<Uint8Array>({ this.#stdin = new MaybeConsumable.WritableStream<Uint8Array>({
write: async (chunk) => { write: (chunk) =>
await this.#writer.write( this.#writer.write(
AdbShellProtocolPacket.serialize({ AdbShellProtocolPacket.serialize({
id: AdbShellProtocolId.Stdin, id: AdbShellProtocolId.Stdin,
data: chunk, data: chunk,
}), }),
); ),
}, close: () =>
// Only shell protocol + raw mode supports closing stdin
this.#writer.write(
AdbShellProtocolPacket.serialize({
id: AdbShellProtocolId.CloseStdin,
data: EmptyUint8Array,
}),
),
}); });
} }

View file

@ -3,34 +3,36 @@ import { AdbFeature } from "../../../features.js";
import { AdbShellProtocolProcessImpl } from "./process.js"; import { AdbShellProtocolProcessImpl } from "./process.js";
import { AdbShellProtocolPtyProcess } from "./pty.js"; import { AdbShellProtocolPtyProcess } from "./pty.js";
import { AdbShellProtocolSpawner } from "./spawner.js"; import { adbShellProtocolSpawner } from "./spawner.js";
export class AdbShellProtocolSubprocessService extends AdbShellProtocolSpawner { export class AdbShellProtocolSubprocessService {
readonly #adb: Adb; readonly #adb: Adb;
get adb() { get adb() {
return this.#adb; return this.#adb;
} }
get isSupported() { get isSupported() {
return this.#adb.canUseFeature(AdbFeature.ShellV2); return this.#adb.canUseFeature(AdbFeature.Shell2);
} }
constructor(adb: Adb) { 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; this.#adb = adb;
} }
spawn = adbShellProtocolSpawner(async (command, signal) => {
// Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details.
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);
});
async pty(options?: { async pty(options?: {
command?: string | readonly string[] | undefined; command?: string | readonly string[] | undefined;
terminalType?: string; terminalType?: string;
@ -44,6 +46,7 @@ export class AdbShellProtocolSubprocessService extends AdbShellProtocolSpawner {
service += ":"; service += ":";
if (options) { if (options) {
// Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details.
if (typeof options.command === "string") { if (typeof options.command === "string") {
service += options.command; service += options.command;
} else if (Array.isArray(options.command)) { } else if (Array.isArray(options.command)) {

View file

@ -5,13 +5,15 @@ import type {
ReadableStream, ReadableStream,
WritableStream, WritableStream,
} from "@yume-chan/stream-extra"; } from "@yume-chan/stream-extra";
import { import { concatUint8Arrays } from "@yume-chan/stream-extra";
ConcatBufferStream,
ConcatStringStream,
TextDecoderStream,
} from "@yume-chan/stream-extra";
import { splitCommand } from "../utils.js"; import type { AdbSubprocessSpawner } from "../types.js";
import {
createLazyPromise,
decodeUtf8Chunked,
splitCommand,
ToArrayStream,
} from "../utils.js";
export interface AdbShellProtocolProcess { export interface AdbShellProtocolProcess {
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>; get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
@ -24,62 +26,14 @@ export interface AdbShellProtocolProcess {
kill(): MaybePromiseLike<void>; kill(): MaybePromiseLike<void>;
} }
export class AdbShellProtocolSpawner { export type AdbShellProtocolSpawner = (
readonly #spawn: ( command: string | readonly string[],
command: readonly string[], signal?: AbortSignal,
signal: AbortSignal | undefined, ) => Promise<AdbShellProtocolProcess> &
) => Promise<AdbShellProtocolProcess>; AdbSubprocessSpawner.Wait<
AdbShellProtocolSpawner.WaitResult<Uint8Array>,
constructor( AdbShellProtocolSpawner.WaitResult<string>
spawn: ( >;
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbShellProtocolProcess>,
) {
this.#spawn = spawn;
}
spawn(
command: string | readonly string[],
signal?: AbortSignal,
): Promise<AdbShellProtocolProcess> {
signal?.throwIfAborted();
if (typeof command === "string") {
command = splitCommand(command);
}
return this.#spawn(command, signal);
}
async spawnWait(
command: string | readonly string[],
): Promise<AdbShellProtocolSpawner.WaitResult<Uint8Array>> {
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 | readonly string[],
): Promise<AdbShellProtocolSpawner.WaitResult<string>> {
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 namespace AdbShellProtocolSpawner {
export interface WaitResult<T> { export interface WaitResult<T> {
@ -88,3 +42,68 @@ export namespace AdbShellProtocolSpawner {
exitCode: number; exitCode: number;
} }
} }
export function adbShellProtocolSpawner(
spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbShellProtocolProcess>,
): AdbShellProtocolSpawner {
return (command, signal) => {
signal?.throwIfAborted();
if (typeof command === "string") {
command = splitCommand(command);
}
const processPromise = spawn(
command,
signal,
) as Promise<AdbShellProtocolProcess> &
AdbSubprocessSpawner.Wait<
AdbShellProtocolSpawner.WaitResult<Uint8Array>,
AdbShellProtocolSpawner.WaitResult<string>
>;
processPromise.wait = (options) => {
const waitPromise = processPromise.then(async (process) => {
const [, stdout, stderr, exitCode] = await Promise.all([
options?.stdin?.pipeTo(process.stdin),
process.stdout.pipeThrough(new ToArrayStream()),
process.stderr.pipeThrough(new ToArrayStream()),
process.exited,
]);
return {
stdout,
stderr,
exitCode,
} satisfies AdbShellProtocolSpawner.WaitResult<Uint8Array[]>;
});
return createLazyPromise(
async () => {
const { stdout, stderr, exitCode } = await waitPromise;
return {
stdout: concatUint8Arrays(stdout),
stderr: concatUint8Arrays(stderr),
exitCode,
};
},
{
async toString() {
const { stdout, stderr, exitCode } = await waitPromise;
return {
stdout: decodeUtf8Chunked(stdout),
stderr: decodeUtf8Chunked(stderr),
exitCode,
};
},
},
);
};
return processPromise;
};
}

View file

@ -0,0 +1,19 @@
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
import type { LazyPromise } from "./utils.js";
export namespace AdbSubprocessSpawner {
export interface WaitToString<T> {
toString(): Promise<T>;
}
export interface WaitOptions {
stdin?: ReadableStream<MaybeConsumable<Uint8Array>> | undefined;
}
export interface Wait<TBuffer, TString> {
wait(
options?: WaitOptions,
): LazyPromise<TBuffer, WaitToString<TString>>;
}
}

View file

@ -1,3 +1,6 @@
import { AccumulateStream } from "@yume-chan/stream-extra";
import { TextDecoder } from "@yume-chan/struct";
export function escapeArg(s: string) { export function escapeArg(s: string) {
let result = ""; let result = "";
result += `'`; result += `'`;
@ -10,7 +13,8 @@ export function escapeArg(s: string) {
break; break;
} }
result += s.substring(base, found); result += s.substring(base, found);
// a'b becomes a'\'b (the backslash is not a escape character) // a'b becomes 'a'\'b', which is 'a' + \' + 'b'
// (quoted string 'a', escaped single quote, and quoted string 'b')
result += String.raw`'\''`; result += String.raw`'\''`;
base = found + 1; base = found + 1;
} }
@ -19,23 +23,32 @@ export function escapeArg(s: string) {
return result; return result;
} }
export function splitCommand(command: string): string[] { /**
* Split the command.
*
* Quotes and escaped characters are supported, and will be returned as-is.
* @param input The input command
* @returns An array of string containing the arguments
*/
export function splitCommand(input: string): string[] {
const result: string[] = []; const result: string[] = [];
let quote: string | undefined; let quote: string | undefined;
let isEscaped = false; let isEscaped = false;
let start = 0; let start = 0;
for (let i = 0, len = command.length; i < len; i += 1) { for (let i = 0, len = input.length; i < len; i += 1) {
if (isEscaped) { if (isEscaped) {
isEscaped = false; isEscaped = false;
continue; continue;
} }
const char = command.charAt(i); const char = input.charAt(i);
switch (char) { switch (char) {
case " ": case " ":
if (!quote && i !== start) { if (!quote) {
result.push(command.substring(start, i)); if (i !== start) {
result.push(input.substring(start, i));
}
start = i + 1; start = i + 1;
} }
break; break;
@ -53,9 +66,101 @@ export function splitCommand(command: string): string[] {
} }
} }
if (start < command.length) { if (start < input.length) {
result.push(command.substring(start)); result.push(input.substring(start));
} }
return result; return result;
} }
// Omit `Symbol.toStringTag` so it's incompatible with `Promise`.
// It can't be returned from async function like `Promise`s.
export type LazyPromise<T, U> = Omit<Promise<T>, typeof Symbol.toStringTag> & U;
/**
* Creates a `Promise`-like object that lazily computes the result
* only when it's being used as a `Promise`.
*
* For example, if an API returns a value `p` of type `Promise<T> & { asU(): Promise<U> }`,
* and the user calls `p.asU()` instead of using it as a `Promise` (`p.then()`, `await p`, etc.),
* is unnecessary to compute the result `T` (unless `asU` also depends on it).
*
* By using `createLazyPromise(computeT, { asU: computeU })`,
* `computeT` will only run when `p` is used as a `Promise`.
*
* Note that the result object can't be returned from an async function,
* as async functions always creates a new `Promise` with the return value,
* which runs `initializer` immediately, and discards any extra `methods` attached.
* @param initializer
* The initializer function when the result object is being used as a `Promise`.
*
* The result value will be cached.
* @param methods Any extra methods to add to the result object
* @returns
* A `Promise`-like object that runs `initializer` when used as a `Promise`, and contains `methods`.
*/
export function createLazyPromise<
T,
U extends Record<PropertyKey, () => unknown>,
>(initializer: () => Promise<T>, methods: U): LazyPromise<T, U> {
let promise: Promise<T> | undefined;
const getOrCreatePromise = () => {
if (!promise) {
promise = initializer();
}
return promise;
};
const result = {
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
then(onfulfilled, onrejected) {
return getOrCreatePromise().then(onfulfilled, onrejected);
},
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
catch(onrejected) {
return getOrCreatePromise().catch(onrejected);
},
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
finally(onfinally) {
return getOrCreatePromise().finally(onfinally);
},
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
} satisfies LazyPromise<T, {}> as LazyPromise<T, U>;
for (const [key, value] of Object.entries(methods)) {
Object.defineProperty(result, key, {
configurable: true,
writable: true,
enumerable: false,
value,
});
}
return result;
}
export class ToArrayStream<T> extends AccumulateStream<T, T[]> {
constructor() {
super(
[],
(chunk, current) => {
current.push(chunk);
return current;
},
(output) => output,
);
}
}
export function decodeUtf8Chunked(chunks: Uint8Array[]): string {
// PERF: `TextDecoder`'s `stream` mode can decode from `chunks` directly.
// This avoids an extra allocation and copy.
const decoder = new TextDecoder();
let output = "";
for (const chunk of chunks) {
output += decoder.decode(chunk, { stream: true });
}
output += decoder.decode();
return output;
}

View file

@ -0,0 +1,33 @@
import { getUint32LittleEndian } from "@yume-chan/no-data-view";
function encodeAsciiUnchecked(value: string): Uint8Array<ArrayBuffer> {
const result = new Uint8Array(value.length);
for (let i = 0; i < value.length; i += 1) {
result[i] = value.charCodeAt(i);
}
return result;
}
/**
* Encode ID to numbers for faster comparison.
*
* This function skips all checks. The caller must ensure the input is valid.
*
* @param value A 4 ASCII character string.
* @returns A 32-bit integer by encoding the string as little-endian
*
* #__NO_SIDE_EFFECTS__
*/
export function adbSyncEncodeId(value: string): number {
const buffer = encodeAsciiUnchecked(value);
return getUint32LittleEndian(buffer, 0);
}
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
export const Lstat = adbSyncEncodeId("STAT");
export const Stat = adbSyncEncodeId("STA2");
export const LstatV2 = adbSyncEncodeId("LST2");
export const Done = adbSyncEncodeId("DONE");
export const Data = adbSyncEncodeId("DATA");

View file

@ -0,0 +1,12 @@
import { adbSyncEncodeId } from "./id-common.js";
export { Data, Done, Lstat, LstatV2, Stat } from "./id-common.js";
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
export const List = adbSyncEncodeId("LIST");
export const ListV2 = adbSyncEncodeId("LIS2");
export const Send = adbSyncEncodeId("SEND");
export const SendV2 = adbSyncEncodeId("SND2");
export const Receive = adbSyncEncodeId("RECV");

View file

@ -0,0 +1,11 @@
import { adbSyncEncodeId } from "./id-common.js";
export { Data, Done, Lstat, LstatV2, Stat } from "./id-common.js";
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
export const Entry = adbSyncEncodeId("DENT");
export const EntryV2 = adbSyncEncodeId("DNT2");
export const Ok = adbSyncEncodeId("OKAY");
export const Fail = adbSyncEncodeId("FAIL");

View file

@ -0,0 +1,7 @@
import * as AdbSyncRequestId from "./id-request.js";
import * as AdbSyncResponseId from "./id-response.js";
// Values of `AdbSyncRequestId` and `AdbSyncResponseId` are all generic `number`s
// so there is no point creating types for them
export { AdbSyncRequestId, AdbSyncResponseId };

View file

@ -1,3 +1,4 @@
export * from "./id.js";
export * from "./list.js"; export * from "./list.js";
export * from "./pull.js"; export * from "./pull.js";
export * from "./push.js"; export * from "./push.js";

View file

@ -1,8 +1,9 @@
import type { StructValue } from "@yume-chan/struct"; import type { StructValue } from "@yume-chan/struct";
import { extend, string, u32 } from "@yume-chan/struct"; import { extend, string, u32 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { AdbSyncResponseId, adbSyncReadResponses } from "./response.js"; import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponses } from "./response.js";
import type { AdbSyncSocket } from "./socket.js"; import type { AdbSyncSocket } from "./socket.js";
import type { AdbSyncStat } from "./stat.js"; import type { AdbSyncStat } from "./stat.js";
import { import {
@ -36,7 +37,7 @@ export async function* adbSyncOpenDirV2(
await adbSyncWriteRequest(locked, AdbSyncRequestId.ListV2, path); await adbSyncWriteRequest(locked, AdbSyncRequestId.ListV2, path);
for await (const item of adbSyncReadResponses( for await (const item of adbSyncReadResponses(
locked, locked,
AdbSyncResponseId.Entry2, AdbSyncResponseId.EntryV2,
AdbSyncEntry2Response, AdbSyncEntry2Response,
)) { )) {
// `LST2` can return error codes for failed `lstat` calls. // `LST2` can return error codes for failed `lstat` calls.

View file

@ -2,8 +2,9 @@ import { ReadableStream } from "@yume-chan/stream-extra";
import type { StructValue } from "@yume-chan/struct"; import type { StructValue } from "@yume-chan/struct";
import { buffer, struct, u32 } from "@yume-chan/struct"; import { buffer, struct, u32 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { adbSyncReadResponses, AdbSyncResponseId } from "./response.js"; import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponses } from "./response.js";
import type { AdbSyncSocket } from "./socket.js"; import type { AdbSyncSocket } from "./socket.js";
export const AdbSyncDataResponse = struct( export const AdbSyncDataResponse = struct(

View file

@ -8,8 +8,9 @@ import { struct, u32 } from "@yume-chan/struct";
import { NOOP } from "../../utils/index.js"; import { NOOP } from "../../utils/index.js";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { AdbSyncResponseId, adbSyncReadResponse } from "./response.js"; import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponse } from "./response.js";
import type { AdbSyncSocket, AdbSyncSocketLocked } from "./socket.js"; import type { AdbSyncSocket, AdbSyncSocketLocked } from "./socket.js";
import { LinuxFileType } from "./stat.js"; import { LinuxFileType } from "./stat.js";

View file

@ -1,20 +1,5 @@
import { encodeUtf8, struct, u32 } from "@yume-chan/struct"; import { encodeUtf8, struct, u32 } from "@yume-chan/struct";
import { adbSyncEncodeId } from "./response.js";
export const AdbSyncRequestId = {
List: adbSyncEncodeId("LIST"),
ListV2: adbSyncEncodeId("LIS2"),
Send: adbSyncEncodeId("SEND"),
SendV2: adbSyncEncodeId("SND2"),
Lstat: adbSyncEncodeId("STAT"),
Stat: adbSyncEncodeId("STA2"),
LstatV2: adbSyncEncodeId("LST2"),
Data: adbSyncEncodeId("DATA"),
Done: adbSyncEncodeId("DONE"),
Receive: adbSyncEncodeId("RECV"),
} as const;
export const AdbSyncNumberRequest = struct( export const AdbSyncNumberRequest = struct(
{ id: u32, arg: u32 }, { id: u32, arg: u32 },
{ littleEndian: true }, { littleEndian: true },
@ -26,13 +11,9 @@ export interface AdbSyncWritable {
export async function adbSyncWriteRequest( export async function adbSyncWriteRequest(
writable: AdbSyncWritable, writable: AdbSyncWritable,
id: number | string, id: number,
value: number | string | Uint8Array, value: number | string | Uint8Array,
): Promise<void> { ): Promise<void> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
if (typeof value === "number") { if (typeof value === "number") {
await writable.write( await writable.write(
AdbSyncNumberRequest.serialize({ id, arg: value }), AdbSyncNumberRequest.serialize({ id, arg: value }),

View file

@ -2,39 +2,9 @@ import { getUint32LittleEndian } from "@yume-chan/no-data-view";
import type { AsyncExactReadable, StructDeserializer } from "@yume-chan/struct"; import type { AsyncExactReadable, StructDeserializer } from "@yume-chan/struct";
import { decodeUtf8, string, struct, u32 } from "@yume-chan/struct"; import { decodeUtf8, string, struct, u32 } from "@yume-chan/struct";
import { unreachable } from "../../utils/no-op.js"; import { unreachable } from "../../utils/index.js";
function encodeAsciiUnchecked(value: string): Uint8Array<ArrayBuffer> { import { AdbSyncResponseId } from "./id.js";
const result = new Uint8Array(value.length);
for (let i = 0; i < value.length; i += 1) {
result[i] = value.charCodeAt(i);
}
return result;
}
/**
* Encode ID to numbers for faster comparison
* @param value A 4-character string
* @returns A 32-bit integer by encoding the string as little-endian
*
* #__NO_SIDE_EFFECTS__
*/
export function adbSyncEncodeId(value: string): number {
const buffer = encodeAsciiUnchecked(value);
return getUint32LittleEndian(buffer, 0);
}
export const AdbSyncResponseId = {
Entry: adbSyncEncodeId("DENT"),
Entry2: adbSyncEncodeId("DNT2"),
Lstat: adbSyncEncodeId("STAT"),
Stat: adbSyncEncodeId("STA2"),
Lstat2: adbSyncEncodeId("LST2"),
Done: adbSyncEncodeId("DONE"),
Data: adbSyncEncodeId("DATA"),
Ok: adbSyncEncodeId("OKAY"),
Fail: adbSyncEncodeId("FAIL"),
};
export class AdbSyncError extends Error {} export class AdbSyncError extends Error {}
@ -50,18 +20,14 @@ export const AdbSyncFailResponse = struct(
export async function adbSyncReadResponse<T>( export async function adbSyncReadResponse<T>(
stream: AsyncExactReadable, stream: AsyncExactReadable,
id: number | string, id: number,
type: StructDeserializer<T>, type: StructDeserializer<T>,
): Promise<T> { ): Promise<T> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
const buffer = await stream.readExactly(4); const buffer = await stream.readExactly(4);
switch (getUint32LittleEndian(buffer, 0)) { switch (getUint32LittleEndian(buffer, 0)) {
case AdbSyncResponseId.Fail: case AdbSyncResponseId.Fail:
await AdbSyncFailResponse.deserialize(stream); await AdbSyncFailResponse.deserialize(stream);
throw new Error("Unreachable"); unreachable();
case id: case id:
return await type.deserialize(stream); return await type.deserialize(stream);
default: default:
@ -73,13 +39,9 @@ export async function adbSyncReadResponse<T>(
export async function* adbSyncReadResponses<T>( export async function* adbSyncReadResponses<T>(
stream: AsyncExactReadable, stream: AsyncExactReadable,
id: number | string, id: number,
type: StructDeserializer<T>, type: StructDeserializer<T>,
): AsyncGenerator<T, void, void> { ): AsyncGenerator<T, void, void> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
while (true) { while (true) {
const buffer = await stream.readExactly(4); const buffer = await stream.readExactly(4);
switch (getUint32LittleEndian(buffer, 0)) { switch (getUint32LittleEndian(buffer, 0)) {

View file

@ -1,8 +1,9 @@
import type { StructValue } from "@yume-chan/struct"; import type { StructValue } from "@yume-chan/struct";
import { struct, u32, u64 } from "@yume-chan/struct"; import { struct, u32, u64 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { AdbSyncResponseId, adbSyncReadResponse } from "./response.js"; import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponse } from "./response.js";
import type { AdbSyncSocket } from "./socket.js"; import type { AdbSyncSocket } from "./socket.js";
// https://github.com/python/cpython/blob/4e581d64b8aff3e2eda99b12f080c877bb78dfca/Lib/stat.py#L36 // https://github.com/python/cpython/blob/4e581d64b8aff3e2eda99b12f080c877bb78dfca/Lib/stat.py#L36
@ -131,7 +132,7 @@ export async function adbSyncLstat(
await adbSyncWriteRequest(locked, AdbSyncRequestId.LstatV2, path); await adbSyncWriteRequest(locked, AdbSyncRequestId.LstatV2, path);
return await adbSyncReadResponse( return await adbSyncReadResponse(
locked, locked,
AdbSyncResponseId.Lstat2, AdbSyncResponseId.LstatV2,
AdbSyncStatResponse, AdbSyncStatResponse,
); );
} else { } else {

View file

@ -21,7 +21,7 @@ import { adbSyncLstat, adbSyncStat } from "./stat.js";
export function dirname(path: string): string { export function dirname(path: string): string {
const end = path.lastIndexOf("/"); const end = path.lastIndexOf("/");
if (end === -1) { if (end === -1) {
throw new Error(`Invalid path`); throw new Error(`Invalid absolute unix path: ${path}`);
} }
if (end === 0) { if (end === 0) {
return "/"; return "/";
@ -43,25 +43,25 @@ export class AdbSync {
protected _socket: AdbSyncSocket; protected _socket: AdbSyncSocket;
readonly #supportsStat: boolean; readonly #supportsStat: boolean;
readonly #supportsListV2: boolean; readonly #supportsLs2: boolean;
readonly #fixedPushMkdir: boolean; readonly #fixedPushMkdir: boolean;
readonly #supportsSendReceiveV2: boolean; readonly #supportsSendReceive2: boolean;
readonly #needPushMkdirWorkaround: boolean; readonly #needPushMkdirWorkaround: boolean;
get supportsStat(): boolean { get supportsStat(): boolean {
return this.#supportsStat; return this.#supportsStat;
} }
get supportsListV2(): boolean { get supportsLs2(): boolean {
return this.#supportsListV2; return this.#supportsLs2;
} }
get fixedPushMkdir(): boolean { get fixedPushMkdir(): boolean {
return this.#fixedPushMkdir; return this.#fixedPushMkdir;
} }
get supportsSendReceiveV2(): boolean { get supportsSendReceive2(): boolean {
return this.#supportsSendReceiveV2; return this.#supportsSendReceive2;
} }
get needPushMkdirWorkaround(): boolean { get needPushMkdirWorkaround(): boolean {
@ -72,15 +72,13 @@ export class AdbSync {
this._adb = adb; this._adb = adb;
this._socket = new AdbSyncSocket(socket, adb.maxPayloadSize); this._socket = new AdbSyncSocket(socket, adb.maxPayloadSize);
this.#supportsStat = adb.canUseFeature(AdbFeature.StatV2); this.#supportsStat = adb.canUseFeature(AdbFeature.Stat2);
this.#supportsListV2 = adb.canUseFeature(AdbFeature.ListV2); this.#supportsLs2 = adb.canUseFeature(AdbFeature.Ls2);
this.#fixedPushMkdir = adb.canUseFeature(AdbFeature.FixedPushMkdir); this.#fixedPushMkdir = adb.canUseFeature(AdbFeature.FixedPushMkdir);
this.#supportsSendReceiveV2 = adb.canUseFeature( this.#supportsSendReceive2 = adb.canUseFeature(AdbFeature.SendReceive2);
AdbFeature.SendReceiveV2,
);
// https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361 // https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361
this.#needPushMkdirWorkaround = this.#needPushMkdirWorkaround =
this._adb.canUseFeature(AdbFeature.ShellV2) && !this.fixedPushMkdir; this._adb.canUseFeature(AdbFeature.Shell2) && !this.fixedPushMkdir;
} }
/** /**
@ -120,7 +118,7 @@ export class AdbSync {
} }
opendir(path: string): AsyncGenerator<AdbSyncEntry, void, void> { opendir(path: string): AsyncGenerator<AdbSyncEntry, void, void> {
return adbSyncOpenDir(this._socket, path, this.supportsListV2); return adbSyncOpenDir(this._socket, path, this.supportsLs2);
} }
async readdir(path: string) { async readdir(path: string) {
@ -151,15 +149,13 @@ export class AdbSync {
// It may fail if `filename` already exists. // It may fail if `filename` already exists.
// Ignore the result. // Ignore the result.
// TODO: sync: test push mkdir workaround (need an Android 8 device) // TODO: sync: test push mkdir workaround (need an Android 8 device)
await this._adb.subprocess.noneProtocol.spawnWait([ await this._adb.subprocess.noneProtocol
"mkdir", .spawn(["mkdir", "-p", escapeArg(dirname(options.filename))])
"-p", .wait();
escapeArg(dirname(options.filename)),
]);
} }
await adbSyncPush({ await adbSyncPush({
v2: this.supportsSendReceiveV2, v2: this.supportsSendReceive2,
socket: this._socket, socket: this._socket,
...options, ...options,
}); });

View file

@ -1,34 +1,35 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { EmptyUint8Array, encodeUtf8 } from "@yume-chan/struct"; import { encodeUtf8 } from "@yume-chan/struct";
import { decodeBase64 } from "../utils/base64.js"; import { decodeBase64 } from "../utils/base64.js";
import type { AdbCredentialStore } from "./auth.js"; import type { AdbCredentialStore } from "./auth.js";
import { AdbAuthType, AdbPublicKeyAuthenticator } from "./auth.js"; import { AdbAuthType, AdbDefaultAuthenticator } from "./auth.js";
import type { AdbPacketData } from "./packet.js"; import type { SimpleRsaPrivateKey } from "./crypto.js";
import { rsaParsePrivateKey } from "./crypto.js";
import { AdbCommand } from "./packet.js"; import { AdbCommand } from "./packet.js";
class MockCredentialStore implements AdbCredentialStore { class MockCredentialStore implements AdbCredentialStore {
key: Uint8Array; key: SimpleRsaPrivateKey;
name: string | undefined; name: string | undefined;
constructor(key: Uint8Array, name: string | undefined) { constructor(key: Uint8Array, name: string | undefined) {
this.key = key; this.key = rsaParsePrivateKey(key);
this.name = name; this.name = name;
} }
*iterateKeys() { *iterateKeys() {
yield { yield {
buffer: this.key, ...this.key,
name: this.name, name: this.name,
}; };
} }
generateKey() { generateKey() {
return { return {
buffer: this.key, ...this.key,
name: this.name, name: this.name,
}; };
} }
@ -74,35 +75,40 @@ const PUBLIC_KEY =
"QAAAANVsDNqDk46/2Qg74n5POy5nK/XA8glCLkvXMks9p885+GQ2WiVUctG8LP/W5cII11Pk1KsZ+90ccZV2fdjv+tnW/8li9iEWTC+G1udFMxsIQ+HRPvJF0Xl9JXDsC6pvdo9ic4d6r5BC9BGiijd0enoG/tHkJhMhbPf/j7+MWXDrF+BeJeyj0mWArbqS599IO2qUCZiNjRakAa/iESG6Om4xCJWTT8wGhSTs81cHcEeSmQ2ixRwS+uaa/8iK/mv6BvCep5qgFrJW1G9LD2WciVgTpOSc6B1N/OA92hwJYp2lHLPWZl6bJIYHqrzdHCxc4EEVVYHkSBdFy1w2vhg2YgRTlpbP00NVrZb6Car8BTqPnwTRIkHBC6nnrg6cWMQ0xusMtxChKBoYGhCLHY4iKK6ra3P1Ou1UXu0WySau3s+Av9FFXxtAuMAJUA+5GSMQGGECRhwLX910OfnHHN+VxqJkHQye4vNhIH5C1dJ39HJoxAdwH2tF7v7GF2fwsy2lUa3Vj6bBssWivCB9cKyJR0GVPZJZ1uah24ecvspwtAqbtxvj7ZD9l7AD92geEJdLrsbfhNaDyAioQ2grI32gdp80su/7BrdAsPaSomxCYBB8opmS+oJq6qTYxNZ0doT9EEyT5D9rl9UXXxq+rQbDpKV1rOQo5zJJ2GkELhUrslFm6n4+JQEAAQA="; "QAAAANVsDNqDk46/2Qg74n5POy5nK/XA8glCLkvXMks9p885+GQ2WiVUctG8LP/W5cII11Pk1KsZ+90ccZV2fdjv+tnW/8li9iEWTC+G1udFMxsIQ+HRPvJF0Xl9JXDsC6pvdo9ic4d6r5BC9BGiijd0enoG/tHkJhMhbPf/j7+MWXDrF+BeJeyj0mWArbqS599IO2qUCZiNjRakAa/iESG6Om4xCJWTT8wGhSTs81cHcEeSmQ2ixRwS+uaa/8iK/mv6BvCep5qgFrJW1G9LD2WciVgTpOSc6B1N/OA92hwJYp2lHLPWZl6bJIYHqrzdHCxc4EEVVYHkSBdFy1w2vhg2YgRTlpbP00NVrZb6Car8BTqPnwTRIkHBC6nnrg6cWMQ0xusMtxChKBoYGhCLHY4iKK6ra3P1Ou1UXu0WySau3s+Av9FFXxtAuMAJUA+5GSMQGGECRhwLX910OfnHHN+VxqJkHQye4vNhIH5C1dJ39HJoxAdwH2tF7v7GF2fwsy2lUa3Vj6bBssWivCB9cKyJR0GVPZJZ1uah24ecvspwtAqbtxvj7ZD9l7AD92geEJdLrsbfhNaDyAioQ2grI32gdp80su/7BrdAsPaSomxCYBB8opmS+oJq6qTYxNZ0doT9EEyT5D9rl9UXXxq+rQbDpKV1rOQo5zJJ2GkELhUrslFm6n4+JQEAAQA=";
describe("auth", () => { describe("auth", () => {
describe("PublicKeyAuthenticator", () => { describe("AdbDefaultAuthenticator", () => {
it("should generate correct public key without name", async () => { it("should generate correct public key without name", async () => {
const store = new MockCredentialStore( const store = new MockCredentialStore(
new Uint8Array(PRIVATE_KEY), new Uint8Array(PRIVATE_KEY),
undefined, undefined,
); );
const authenticator = AdbPublicKeyAuthenticator(store, () => const authenticator = new AdbDefaultAuthenticator(store);
Promise.resolve({ const challenge = new Uint8Array(20);
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: EmptyUint8Array,
}),
);
const results: AdbPacketData[] = []; const first = await authenticator.authenticate({
for await (const result of authenticator) { command: AdbCommand.Auth,
results.push(result); arg0: AdbAuthType.Token,
} arg1: 0,
payload: challenge,
});
// This test focuses on public key authentication, so only check
// the first response is type Signature and ignore other fields
assert.strictEqual(first.command, AdbCommand.Auth);
assert.strictEqual(first.arg0, AdbAuthType.Signature);
assert.deepStrictEqual(results, [ const result = await authenticator.authenticate({
{ command: AdbCommand.Auth,
command: AdbCommand.Auth, arg0: AdbAuthType.Token,
arg0: AdbAuthType.PublicKey, arg1: 0,
arg1: 0, payload: challenge,
payload: encodeUtf8(`${PUBLIC_KEY}\0`), });
},
]); assert.deepStrictEqual(result, {
command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey,
arg1: 0,
payload: encodeUtf8(`${PUBLIC_KEY}\0`),
});
}); });
it("should generate correct public key name", async () => { it("should generate correct public key name", async () => {
@ -113,28 +119,33 @@ describe("auth", () => {
name, name,
); );
const authenticator = AdbPublicKeyAuthenticator(store, () => const authenticator = new AdbDefaultAuthenticator(store);
Promise.resolve({ const challenge = new Uint8Array(20);
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: EmptyUint8Array,
}),
);
const results: AdbPacketData[] = []; const first = await authenticator.authenticate({
for await (const result of authenticator) { command: AdbCommand.Auth,
results.push(result); arg0: AdbAuthType.Token,
} arg1: 0,
payload: challenge,
});
// This test focuses on public key authentication, so only check
// the first response is type Signature and ignore other fields
assert.strictEqual(first.command, AdbCommand.Auth);
assert.strictEqual(first.arg0, AdbAuthType.Signature);
assert.deepStrictEqual(results, [ const result = await authenticator.authenticate({
{ command: AdbCommand.Auth,
command: AdbCommand.Auth, arg0: AdbAuthType.Token,
arg0: AdbAuthType.PublicKey, arg1: 0,
arg1: 0, payload: challenge,
payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`), });
},
]); assert.deepStrictEqual(result, {
command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey,
arg1: 0,
payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`),
});
}); });
}); });
}); });

View file

@ -1,6 +1,5 @@
import type { MaybePromiseLike } from "@yume-chan/async"; import type { MaybePromiseLike } from "@yume-chan/async";
import { PromiseResolver } from "@yume-chan/async"; import { EventEmitter } from "@yume-chan/event";
import type { Disposable } from "@yume-chan/event";
import { EmptyUint8Array } from "@yume-chan/struct"; import { EmptyUint8Array } from "@yume-chan/struct";
import { import {
@ -9,6 +8,7 @@ import {
encodeUtf8, encodeUtf8,
} from "../utils/index.js"; } from "../utils/index.js";
import type { SimpleRsaPrivateKey } from "./crypto.js";
import { import {
adbGeneratePublicKey, adbGeneratePublicKey,
adbGetPublicKeySize, adbGetPublicKeySize,
@ -17,11 +17,7 @@ import {
import type { AdbPacketData } from "./packet.js"; import type { AdbPacketData } from "./packet.js";
import { AdbCommand } from "./packet.js"; import { AdbCommand } from "./packet.js";
export interface AdbPrivateKey { export interface AdbPrivateKey extends SimpleRsaPrivateKey {
/**
* The private key in PKCS #8 format.
*/
buffer: Uint8Array;
name?: string | undefined; name?: string | undefined;
} }
@ -52,153 +48,101 @@ export const AdbAuthType = {
export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType]; export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType];
export interface AdbAuthenticator { export interface AdbAuthenticator {
/** authenticate(packet: AdbPacketData): Promise<AdbPacketData>;
* @param getNextRequest
* close?(): MaybePromiseLike<undefined>;
* Call this function to get the next authentication request packet from device.
*
* After calling `getNextRequest`, authenticator can `yield` a packet as response, or `return` to indicate its incapability of handling the request.
*
* After `return`, the `AdbAuthenticatorHandler` will move on to next authenticator and never go back.
*
* Calling `getNextRequest` multiple times without `yield` or `return` will always return the same request.
*/
(
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData>;
} }
export const AdbSignatureAuthenticator: AdbAuthenticator = async function* ( export class AdbDefaultAuthenticator implements AdbAuthenticator {
credentialStore: AdbCredentialStore, #credentialStore: AdbCredentialStore;
getNextRequest: () => Promise<AdbPacketData>, #iterator:
): AsyncIterable<AdbPacketData> { | Iterator<AdbPrivateKey, void, void>
for await (const key of credentialStore.iterateKeys()) { | AsyncIterator<AdbPrivateKey, void, void>
const packet = await getNextRequest(); | undefined;
#firstKey: AdbPrivateKey | undefined;
if (packet.arg0 !== AdbAuthType.Token) { #onPublicKeyAuthentication = new EventEmitter<void>();
return; get onPublicKeyAuthentication() {
} return this.#onPublicKeyAuthentication.event;
const signature = rsaSign(key.buffer, packet.payload);
yield {
command: AdbCommand.Auth,
arg0: AdbAuthType.Signature,
arg1: 0,
payload: signature,
};
}
};
export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData> {
const packet = await getNextRequest();
if (packet.arg0 !== AdbAuthType.Token) {
return;
} }
let privateKey: AdbPrivateKey | undefined; constructor(credentialStore: AdbCredentialStore) {
for await (const key of credentialStore.iterateKeys()) {
privateKey = key;
break;
}
if (!privateKey) {
privateKey = await credentialStore.generateKey();
}
const publicKeyLength = adbGetPublicKeySize();
const [publicKeyBase64Length] =
calculateBase64EncodedLength(publicKeyLength);
const nameBuffer = privateKey.name?.length
? encodeUtf8(privateKey.name)
: EmptyUint8Array;
const publicKeyBuffer = new Uint8Array(
publicKeyBase64Length +
(nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name
1, // Null character
);
adbGeneratePublicKey(privateKey.buffer, publicKeyBuffer);
encodeBase64(publicKeyBuffer.subarray(0, publicKeyLength), publicKeyBuffer);
if (nameBuffer.length) {
publicKeyBuffer[publicKeyBase64Length] = 0x20;
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
}
yield {
command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey,
arg1: 0,
payload: publicKeyBuffer,
};
};
export const ADB_DEFAULT_AUTHENTICATORS: readonly AdbAuthenticator[] = [
AdbSignatureAuthenticator,
AdbPublicKeyAuthenticator,
];
export class AdbAuthenticationProcessor implements Disposable {
readonly authenticators: readonly AdbAuthenticator[];
readonly #credentialStore: AdbCredentialStore;
#pendingRequest = new PromiseResolver<AdbPacketData>();
#iterator: AsyncIterator<AdbPacketData, void, void> | undefined;
constructor(
authenticators: readonly AdbAuthenticator[],
credentialStore: AdbCredentialStore,
) {
this.authenticators = authenticators;
this.#credentialStore = credentialStore; this.#credentialStore = credentialStore;
} }
#getNextRequest = (): Promise<AdbPacketData> => { async authenticate(packet: AdbPacketData): Promise<AdbPacketData> {
return this.#pendingRequest.promise; if (packet.arg0 !== AdbAuthType.Token) {
}; throw new Error("Unsupported authentication packet");
}
async *#invokeAuthenticator(): AsyncGenerator<AdbPacketData, void, void> { if (!this.#iterator) {
for (const authenticator of this.authenticators) { const iterable = this.#credentialStore.iterateKeys();
for await (const packet of authenticator( if (Symbol.iterator in iterable) {
this.#credentialStore, this.#iterator = iterable[Symbol.iterator]();
this.#getNextRequest, } else if (Symbol.asyncIterator in iterable) {
)) { this.#iterator = iterable[Symbol.asyncIterator]();
// If the authenticator yielded a response } else {
// Prepare `nextRequest` for next authentication request throw new Error("`iterateKeys` doesn't return an iterator");
this.#pendingRequest = new PromiseResolver(); }
}
// Yield the response to outer layer const { done, value } = await this.#iterator.next();
yield packet; if (!done) {
if (!this.#firstKey) {
this.#firstKey = value;
} }
// If the authenticator returned, return {
// Next authenticator will be given the same `pendingRequest` command: AdbCommand.Auth,
arg0: AdbAuthType.Signature,
arg1: 0,
payload: rsaSign(value, packet.payload),
};
} }
this.#onPublicKeyAuthentication.fire();
let key = this.#firstKey;
if (!key) {
key = await this.#credentialStore.generateKey();
}
const publicKeyLength = adbGetPublicKeySize();
const [publicKeyBase64Length] =
calculateBase64EncodedLength(publicKeyLength);
const nameBuffer = key.name?.length
? encodeUtf8(key.name)
: EmptyUint8Array;
const publicKeyBuffer = new Uint8Array(
publicKeyBase64Length +
(nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name
1, // Null character
);
adbGeneratePublicKey(key, publicKeyBuffer);
encodeBase64(
publicKeyBuffer.subarray(0, publicKeyLength),
publicKeyBuffer,
);
if (nameBuffer.length) {
publicKeyBuffer[publicKeyBase64Length] = 0x20;
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
}
return {
command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey,
arg1: 0,
payload: publicKeyBuffer,
};
} }
async process(packet: AdbPacketData): Promise<AdbPacketData> { async close(): Promise<undefined> {
if (!this.#iterator) { await this.#iterator?.return?.();
this.#iterator = this.#invokeAuthenticator();
}
this.#pendingRequest.resolve(packet); this.#iterator = undefined;
this.#firstKey = undefined;
const result = await this.#iterator.next();
if (result.done) {
throw new Error("No authenticator can handle the request");
}
return result.value;
}
dispose() {
void this.#iterator?.return?.();
} }
} }

View file

@ -3,7 +3,11 @@ import { describe, it } from "node:test";
import { decodeBase64 } from "../utils/base64.js"; import { decodeBase64 } from "../utils/base64.js";
import { adbGeneratePublicKey, modInverse } from "./crypto.js"; import {
adbGeneratePublicKey,
modInverse,
rsaParsePrivateKey,
} from "./crypto.js";
describe("modInverse", () => { describe("modInverse", () => {
it("should return correct value", () => { it("should return correct value", () => {
@ -72,7 +76,8 @@ const PUBLIC_KEY = decodeBase64(
describe("adbGeneratePublicKey", () => { describe("adbGeneratePublicKey", () => {
it("should return correct value", () => { it("should return correct value", () => {
const generated = adbGeneratePublicKey(PRIVATE_KEY); const simpleKey = rsaParsePrivateKey(PRIVATE_KEY);
const generated = adbGeneratePublicKey(simpleKey);
assert.deepStrictEqual( assert.deepStrictEqual(
generated.subarray(0, 4), generated.subarray(0, 4),
PUBLIC_KEY.subarray(0, 4), PUBLIC_KEY.subarray(0, 4),
@ -96,8 +101,9 @@ describe("adbGeneratePublicKey", () => {
}); });
it("should throw if output is too small", () => { it("should throw if output is too small", () => {
const simpleKey = rsaParsePrivateKey(PRIVATE_KEY);
assert.throws( assert.throws(
() => adbGeneratePublicKey(PRIVATE_KEY, new Uint8Array(1)), () => adbGeneratePublicKey(simpleKey, new Uint8Array(1)),
/output buffer is too small/, /output buffer is too small/,
); );
}); });

View file

@ -48,11 +48,14 @@ export function setBigUint(
littleEndian?: boolean, littleEndian?: boolean,
) { ) {
if (littleEndian) { if (littleEndian) {
const end = byteOffset + length;
while (value > 0n) { while (value > 0n) {
setInt64LittleEndian(array, byteOffset, value); setInt64LittleEndian(array, byteOffset, value);
byteOffset += 8; byteOffset += 8;
value >>= 64n; value >>= 64n;
} }
// Clear the trailing bytes
array.subarray(byteOffset, end).fill(0);
} else { } else {
let position = byteOffset + length - 8; let position = byteOffset + length - 8;
while (value > 0n) { while (value > 0n) {
@ -60,9 +63,16 @@ export function setBigUint(
position -= 8; position -= 8;
value >>= 64n; value >>= 64n;
} }
// Clear the leading bytes
array.subarray(byteOffset, position + 8).fill(0);
} }
} }
export interface SimpleRsaPrivateKey {
n: bigint;
d: bigint;
}
// These values are correct only if // These values are correct only if
// modulus length is 2048 and // modulus length is 2048 and
// public exponent (e) is 65537 // public exponent (e) is 65537
@ -89,10 +99,16 @@ const RsaPrivateKeyNLength = 2048 / 8;
const RsaPrivateKeyDOffset = 303; const RsaPrivateKeyDOffset = 303;
const RsaPrivateKeyDLength = 2048 / 8; const RsaPrivateKeyDLength = 2048 / 8;
export function rsaParsePrivateKey(key: Uint8Array): [n: bigint, d: bigint] { export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) {
throw new Error(
"RSA private key is too short. Expecting a PKCS#8 formatted RSA private key with modulus length 2048 bits and public exponent 65537.",
);
}
const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength); const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength); const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
return [n, d]; return { n, d };
} }
function nonNegativeMod(m: number, d: number) { function nonNegativeMod(m: number, d: number) {
@ -141,14 +157,14 @@ export function adbGetPublicKeySize() {
} }
export function adbGeneratePublicKey( export function adbGeneratePublicKey(
privateKey: Uint8Array, privateKey: SimpleRsaPrivateKey,
): Uint8Array<ArrayBuffer>; ): Uint8Array<ArrayBuffer>;
export function adbGeneratePublicKey( export function adbGeneratePublicKey(
privateKey: Uint8Array, privateKey: SimpleRsaPrivateKey,
output: Uint8Array, output: Uint8Array,
): number; ): number;
export function adbGeneratePublicKey( export function adbGeneratePublicKey(
privateKey: Uint8Array, privateKey: SimpleRsaPrivateKey,
output?: Uint8Array, output?: Uint8Array,
): Uint8Array | number { ): Uint8Array | number {
// cspell: ignore: mincrypt // cspell: ignore: mincrypt
@ -198,7 +214,7 @@ export function adbGeneratePublicKey(
outputOffset += 4; outputOffset += 4;
// extract `n` from private key // extract `n` from private key
const [n] = rsaParsePrivateKey(privateKey); const { n } = privateKey;
// Calculate `n0inv` // Calculate `n0inv`
const n0inv = -modInverse(Number(n % 2n ** 32n), 2 ** 32); const n0inv = -modInverse(Number(n % 2n ** 32n), 2 ** 32);
@ -283,17 +299,27 @@ export const SHA1_DIGEST_INFO = new Uint8Array([
SHA1_DIGEST_LENGTH, SHA1_DIGEST_LENGTH,
]); ]);
// SubtleCrypto.sign() will hash the given data and sign the hash // Standard `RSASSA-PKCS1-v1_5` algorithm will hash the given data
// But we don't need the hashing step // and sign the hash
// (In another word, ADB just requires the client to // https://datatracker.ietf.org/doc/html/rfc8017#section-8.2
// encrypt the given data with its private key) //
// However SubtileCrypto.encrypt() doesn't accept 'RSASSA-PKCS1-v1_5' algorithm // But ADB authentication passes 20 bytes of random value to
// So we need to implement the encryption by ourself // OpenSSL's `RSA_sign` method which treat the input as a hash
// https://docs.openssl.org/1.0.2/man3/RSA_sign/
//
// Since it's non-standard and not supported by Web Crypto API,
// we need to implement the signing by ourself
export function rsaSign( export function rsaSign(
privateKey: Uint8Array, privateKey: SimpleRsaPrivateKey,
data: Uint8Array, data: Uint8Array,
): Uint8Array<ArrayBuffer> { ): Uint8Array<ArrayBuffer> {
const [n, d] = rsaParsePrivateKey(privateKey); if (data.length !== SHA1_DIGEST_LENGTH) {
throw new Error(
`rsaSign expects ${SHA1_DIGEST_LENGTH} bytes (SHA-1 digest length) of data but got ${data.length} bytes`,
);
}
const { n, d } = privateKey;
// PKCS#1 padding // PKCS#1 padding
const padded = new Uint8Array(256); const padded = new Uint8Array(256);

View file

@ -14,42 +14,15 @@ import type {
AdbTransport, AdbTransport,
} from "../adb.js"; } from "../adb.js";
import { AdbBanner } from "../banner.js"; import { AdbBanner } from "../banner.js";
import { AdbFeature } from "../features.js"; import { AdbDeviceFeatures, AdbFeature } from "../features.js";
import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js"; import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js";
import { import { AdbDefaultAuthenticator } from "./auth.js";
ADB_DEFAULT_AUTHENTICATORS,
AdbAuthenticationProcessor,
} from "./auth.js";
import { AdbPacketDispatcher } from "./dispatcher.js"; import { AdbPacketDispatcher } from "./dispatcher.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import { AdbCommand, calculateChecksum } from "./packet.js"; import { AdbCommand, calculateChecksum } from "./packet.js";
export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001; export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001;
// 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).
export const ADB_DAEMON_DEFAULT_FEATURES = /* #__PURE__ */ (() =>
[
AdbFeature.ShellV2,
AdbFeature.Cmd,
AdbFeature.StatV2,
AdbFeature.ListV2,
AdbFeature.FixedPushMkdir,
"apex",
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",
AdbFeature.AbbExec,
"remount_shell",
"track_app",
AdbFeature.SendReceiveV2,
"sendrecv_v2_brotli",
"sendrecv_v2_lz4",
"sendrecv_v2_zstd",
"sendrecv_v2_dry_run_send",
AdbFeature.DelayedAck,
] as readonly AdbFeature[])();
export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024; export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024;
export type AdbDaemonConnection = ReadableWritablePair< export type AdbDaemonConnection = ReadableWritablePair<
@ -60,8 +33,6 @@ export type AdbDaemonConnection = ReadableWritablePair<
export interface AdbDaemonAuthenticationOptions { export interface AdbDaemonAuthenticationOptions {
serial: string; serial: string;
connection: AdbDaemonConnection; connection: AdbDaemonConnection;
credentialStore: AdbCredentialStore;
authenticators?: readonly AdbAuthenticator[];
features?: readonly AdbFeature[]; features?: readonly AdbFeature[];
/** /**
@ -159,22 +130,36 @@ export class AdbDaemonTransport implements AdbTransport {
static async authenticate({ static async authenticate({
serial, serial,
connection, connection,
credentialStore, features = AdbDeviceFeatures,
authenticators = ADB_DEFAULT_AUTHENTICATORS,
features = ADB_DAEMON_DEFAULT_FEATURES,
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE, initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
...options ...options
}: AdbDaemonAuthenticationOptions): Promise<AdbDaemonTransport> { }: AdbDaemonAuthenticationOptions &
(
| { authenticator: AdbAuthenticator }
| {
credentialStore: AdbCredentialStore;
onPublicKeyAuthentication?: (() => void) | undefined;
}
)): Promise<AdbDaemonTransport> {
// Initially, set to highest-supported version and payload size. // Initially, set to highest-supported version and payload size.
let version = 0x01000001; let version = 0x01000001;
// Android 4: 4K, Android 7: 256K, Android 9: 1M // Android 4: 4K, Android 7: 256K, Android 9: 1M
let maxPayloadSize = 1024 * 1024; let maxPayloadSize = 1024 * 1024;
const resolver = new PromiseResolver<string>(); const resolver = new PromiseResolver<string>();
const authProcessor = new AdbAuthenticationProcessor( let authenticator: AdbAuthenticator;
authenticators, if ("authenticator" in options) {
credentialStore, authenticator = options.authenticator;
); } else {
authenticator = new AdbDefaultAuthenticator(
options.credentialStore,
);
if (options.onPublicKeyAuthentication) {
(
authenticator as AdbDefaultAuthenticator
).onPublicKeyAuthentication(options.onPublicKeyAuthentication);
}
}
// Here is similar to `AdbPacketDispatcher`, // Here is similar to `AdbPacketDispatcher`,
// But the received packet types and send packet processing are different. // But the received packet types and send packet processing are different.
@ -193,9 +178,9 @@ export class AdbDaemonTransport implements AdbTransport {
resolver.resolve(decodeUtf8(packet.payload)); resolver.resolve(decodeUtf8(packet.payload));
break; break;
case AdbCommand.Auth: { case AdbCommand.Auth: {
const response = await sendPacket(
await authProcessor.process(packet); await authenticator.authenticate(packet),
await sendPacket(response); );
break; break;
} }
default: default:
@ -215,13 +200,17 @@ export class AdbDaemonTransport implements AdbTransport {
}, },
) )
.then( .then(
() => { async () => {
await authenticator.close?.();
// If `resolver` is already settled, call `reject` won't do anything. // If `resolver` is already settled, call `reject` won't do anything.
resolver.reject( resolver.reject(
new Error("Connection closed unexpectedly"), new Error("Connection closed unexpectedly"),
); );
}, },
(e) => { async (e) => {
await authenticator.close?.();
resolver.reject(e); resolver.reject(e);
}, },
); );
@ -238,11 +227,10 @@ export class AdbDaemonTransport implements AdbTransport {
); );
} }
const actualFeatures = features.slice();
if (initialDelayedAckBytes <= 0) { if (initialDelayedAckBytes <= 0) {
const index = features.indexOf(AdbFeature.DelayedAck); const index = features.indexOf(AdbFeature.DelayedAck);
if (index !== -1) { if (index !== -1) {
actualFeatures.splice(index, 1); features = features.toSpliced(index, 1);
} }
} }
@ -254,9 +242,7 @@ export class AdbDaemonTransport implements AdbTransport {
arg1: maxPayloadSize, arg1: maxPayloadSize,
// The terminating `;` is required in formal definition // The terminating `;` is required in formal definition
// But ADB daemon (all versions) can still work without it // But ADB daemon (all versions) can still work without it
payload: encodeUtf8( payload: encodeUtf8(`host::features=${features.join(",")}`),
`host::features=${actualFeatures.join(",")}`,
),
}); });
banner = await resolver.promise; banner = await resolver.promise;
@ -276,9 +262,10 @@ export class AdbDaemonTransport implements AdbTransport {
version, version,
maxPayloadSize, maxPayloadSize,
banner, banner,
features: actualFeatures, features,
initialDelayedAckBytes, initialDelayedAckBytes,
...options, preserveConnection: options.preserveConnection,
readTimeLimit: options.readTimeLimit,
}); });
} }
@ -322,7 +309,7 @@ export class AdbDaemonTransport implements AdbTransport {
connection, connection,
version, version,
banner, banner,
features = ADB_DAEMON_DEFAULT_FEATURES, features = AdbDeviceFeatures,
initialDelayedAckBytes, initialDelayedAckBytes,
...options ...options
}: AdbDaemonSocketConnectorConstructionOptions) { }: AdbDaemonSocketConnectorConstructionOptions) {
@ -345,19 +332,19 @@ export class AdbDaemonTransport implements AdbTransport {
initialDelayedAckBytes = 0; initialDelayedAckBytes = 0;
} }
let calculateChecksum: boolean; let shouldCalculateChecksum: boolean;
let appendNullToServiceString: boolean; let shouldAppendNullToServiceString: boolean;
if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) { if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) {
calculateChecksum = false; shouldCalculateChecksum = false;
appendNullToServiceString = false; shouldAppendNullToServiceString = false;
} else { } else {
calculateChecksum = true; shouldCalculateChecksum = true;
appendNullToServiceString = true; shouldAppendNullToServiceString = true;
} }
this.#dispatcher = new AdbPacketDispatcher(connection, { this.#dispatcher = new AdbPacketDispatcher(connection, {
calculateChecksum, calculateChecksum: shouldCalculateChecksum,
appendNullToServiceString, appendNullToServiceString: shouldAppendNullToServiceString,
initialDelayedAckBytes, initialDelayedAckBytes,
...options, ...options,
}); });

View file

@ -0,0 +1,47 @@
// cspell: ignore Libusb
// cspell: ignore devraw
// cspell: ignore Openscreen
// cspell: ignore devicetracker
// The order follows
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/transport.cpp;l=81;drc=2d3e62c2af54a3e8f8803ea10492e63b8dfe709f
export const Shell2 = "shell_v2";
export const Cmd = "cmd";
export const Stat2 = "stat_v2";
export const Ls2 = "ls_v2";
/**
* server only
*/
export const Libusb = "libusb";
/**
* server only
*/
export const PushSync = "push_sync";
export const Apex = "apex";
export const FixedPushMkdir = "fixed_push_mkdir";
export const Abb = "abb";
export const FixedPushSymlinkTimestamp = "fixed_push_symlink_timestamp";
export const AbbExec = "abb_exec";
export const RemountShell = "remount_shell";
export const TrackApp = "track_app";
export const SendReceive2 = "sendrecv_v2";
export const SendReceive2Brotli = "sendrecv_v2_brotli";
export const SendReceive2Lz4 = "sendrecv_v2_lz4";
export const SendReceive2Zstd = "sendrecv_v2_zstd";
export const SendReceive2DryRunSend = "sendrecv_v2_dry_run_send";
export const DelayedAck = "delayed_ack";
/**
* server only
*/
export const OpenscreenMdns = "openscreen_mdns";
/**
* server only
*/
export const DeviceTrackerProtoFormat = "devicetracker_proto_format";
export const DevRaw = "devraw";
export const AppInfo = "app_info";
/**
* server only
*/
export const ServerStatus = "server_status";

View file

@ -1,15 +1,32 @@
// The order follows import * as AdbFeature from "./features-value.js";
// https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/transport.cpp;l=77;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
export const AdbFeature = {
ShellV2: "shell_v2",
Cmd: "cmd",
StatV2: "stat_v2",
ListV2: "ls_v2",
FixedPushMkdir: "fixed_push_mkdir",
Abb: "abb",
AbbExec: "abb_exec",
SendReceiveV2: "sendrecv_v2",
DelayedAck: "delayed_ack",
} as const;
export type AdbFeature = (typeof AdbFeature)[keyof typeof AdbFeature]; // biome-ignore lint/suspicious/noRedeclare: TypeScript declaration merging for enum-like object
type AdbFeature = (typeof AdbFeature)[keyof typeof AdbFeature];
export { AdbFeature };
// 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).
export const AdbDeviceFeatures = [
AdbFeature.Shell2,
AdbFeature.Cmd,
AdbFeature.Stat2,
AdbFeature.Ls2,
AdbFeature.FixedPushMkdir,
AdbFeature.Apex,
AdbFeature.Abb,
// only tells the client the symlink timestamp issue in `adb push --sync` has been fixed.
// No special handling required.
AdbFeature.FixedPushSymlinkTimestamp,
AdbFeature.AbbExec,
AdbFeature.RemountShell,
AdbFeature.TrackApp,
AdbFeature.SendReceive2,
AdbFeature.SendReceive2Brotli,
AdbFeature.SendReceive2Lz4,
AdbFeature.SendReceive2Zstd,
AdbFeature.SendReceive2DryRunSend,
AdbFeature.DevRaw,
AdbFeature.AppInfo,
AdbFeature.DelayedAck,
] as readonly AdbFeature[];

View file

@ -6,32 +6,10 @@ import type {
AdbTransport, AdbTransport,
} from "../adb.js"; } from "../adb.js";
import type { AdbBanner } from "../banner.js"; import type { AdbBanner } from "../banner.js";
import { AdbFeature } from "../features.js"; import { AdbDeviceFeatures } from "../features.js";
import type { AdbServerClient } from "./client.js"; import type { AdbServerClient } from "./client.js";
export const ADB_SERVER_DEFAULT_FEATURES = /* #__PURE__ */ (() =>
[
AdbFeature.ShellV2,
AdbFeature.Cmd,
AdbFeature.StatV2,
AdbFeature.ListV2,
AdbFeature.FixedPushMkdir,
"apex",
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",
AdbFeature.AbbExec,
"remount_shell",
"track_app",
AdbFeature.SendReceiveV2,
"sendrecv_v2_brotli",
"sendrecv_v2_lz4",
"sendrecv_v2_zstd",
"sendrecv_v2_dry_run_send",
] as readonly AdbFeature[])();
export class AdbServerTransport implements AdbTransport { export class AdbServerTransport implements AdbTransport {
#client: AdbServerClient; #client: AdbServerClient;
@ -52,9 +30,14 @@ export class AdbServerTransport implements AdbTransport {
} }
get clientFeatures() { get clientFeatures() {
// No need to get host features (features supported by ADB server) // This list tells the `Adb` instance how to invoke some commands.
// Because we create all ADB packets ourselves //
return ADB_SERVER_DEFAULT_FEATURES; // Because all device commands are created by the `Adb` instance, not ADB server,
// we don't need to fetch current server's feature list using `host-features` command.
//
// And because all server commands are created by the `AdbServerClient` instance, not `Adb`,
// we don't need to pass server-only features to `Adb` in this list.
return AdbDeviceFeatures;
} }
// eslint-disable-next-line @typescript-eslint/max-params // eslint-disable-next-line @typescript-eslint/max-params

View file

@ -1,17 +1,19 @@
import type { Adb } from "@yume-chan/adb"; import type { Adb, AdbNoneProtocolProcess } from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb"; import { AdbServiceBase, escapeArg } from "@yume-chan/adb";
import { SplitStringStream, TextDecoderStream } from "@yume-chan/stream-extra";
import { CmdNoneProtocolService } from "./cmd.js"; import { Cmd } from "./cmd/index.js";
import type { IntentBuilder } from "./intent.js"; import type { Intent } from "./intent.js";
import type { SingleUser } from "./utils.js"; import { serializeIntent } from "./intent.js";
import { buildArguments } from "./utils.js"; import type { SingleUser, SingleUserOrAll } from "./utils.js";
import { buildCommand } from "./utils.js";
export interface ActivityManagerStartActivityOptions { export interface ActivityManagerStartActivityOptions {
displayId?: number; displayId?: number;
windowingMode?: number; windowingMode?: number;
forceStop?: boolean; forceStop?: boolean;
user?: SingleUser; user?: SingleUser;
intent: IntentBuilder; intent: Intent;
} }
const START_ACTIVITY_OPTIONS_MAP: Partial< const START_ACTIVITY_OPTIONS_MAP: Partial<
@ -24,41 +26,96 @@ const START_ACTIVITY_OPTIONS_MAP: Partial<
}; };
export class ActivityManager extends AdbServiceBase { export class ActivityManager extends AdbServiceBase {
static ServiceName = "activity"; static readonly ServiceName = "activity";
static CommandName = "am"; static readonly CommandName = "am";
#cmd: CmdNoneProtocolService; #apiLevel: number | undefined;
#cmd: Cmd.NoneProtocolService;
constructor(adb: Adb) { constructor(adb: Adb, apiLevel?: number) {
super(adb); super(adb);
this.#cmd = new CmdNoneProtocolService(
adb, this.#apiLevel = apiLevel;
ActivityManager.CommandName, this.#cmd = Cmd.createNoneProtocol(adb, ActivityManager.CommandName);
);
} }
async startActivity( async startActivity(
options: ActivityManagerStartActivityOptions, options: ActivityManagerStartActivityOptions,
): Promise<void> { ): Promise<void> {
let args = buildArguments( // Android 8 added "start-activity" alias to "start"
[ActivityManager.ServiceName, "start-activity", "-W"], // but we want to use the most compatible one.
const command = buildCommand(
[ActivityManager.ServiceName, "start", "-W"],
options, options,
START_ACTIVITY_OPTIONS_MAP, START_ACTIVITY_OPTIONS_MAP,
); );
args = args.concat(options.intent.build()); for (const arg of serializeIntent(options.intent)) {
command.push(arg);
}
const output = await this.#cmd // Android 7 supports `cmd activity` but not `cmd activity start` command
.spawnWaitText(args) let process: AdbNoneProtocolProcess;
.then((output) => output.trim()); if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
command[0] = ActivityManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}
for (const line of output) { const lines = process.output
.pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n", { trim: true }));
for await (const line of lines) {
if (line.startsWith("Error:")) { if (line.startsWith("Error:")) {
// Exit from the `for await` loop will cancel `lines`,
// and subsequently, `process`, which is fine, as the work is already done
throw new Error(line.substring("Error:".length).trim()); throw new Error(line.substring("Error:".length).trim());
} }
if (line === "Complete") { if (line === "Complete") {
// Same as above
return; return;
} }
} }
// Ensure the subprocess exits before returning
await process.exited;
}
async broadcast(
intent: Intent,
options?: { user?: SingleUserOrAll; receiverPermission?: string },
) {
const command = [ActivityManager.ServiceName, "broadcast"];
if (options) {
if (options.user !== undefined) {
command.push("--user", options.user.toString());
}
if (options.receiverPermission) {
command.push(
"--receiver-permission",
options.receiverPermission,
);
}
}
for (const arg of serializeIntent(intent)) {
command.push(arg);
}
// Android 7 supports `cmd activity` but not `cmd activity broadcast` command
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
command[0] = ActivityManager.CommandName;
await this.adb.subprocess.noneProtocol
.spawn(command.map(escapeArg))
.wait();
} else {
await this.#cmd.spawn(command).wait();
}
} }
} }

View file

@ -13,7 +13,7 @@ export interface AdbBackupOptions {
} }
export interface AdbRestoreOptions { export interface AdbRestoreOptions {
user: number; user?: number | undefined;
file: ReadableStream<MaybeConsumable<Uint8Array>>; file: ReadableStream<MaybeConsumable<Uint8Array>>;
} }
@ -67,6 +67,9 @@ export class AdbBackup extends AdbServiceBase {
if (options.user !== undefined) { if (options.user !== undefined) {
args.push("--user", options.user.toString()); args.push("--user", options.user.toString());
} }
return this.adb.subprocess.noneProtocol.spawnWaitText(args); return this.adb.subprocess.noneProtocol
.spawn(args)
.wait({ stdin: options.file })
.toString();
} }
} }

View file

@ -57,10 +57,10 @@ export class BugReport extends AdbServiceBase {
}); });
} }
const result = await adb.subprocess.shellProtocol.spawnWaitText([ const result = await adb.subprocess.shellProtocol
"bugreportz", .spawn(["bugreportz", "-v"])
"-v", .wait()
]); .toString();
if (result.exitCode !== 0 || result.stderr === "") { if (result.exitCode !== 0 || result.stderr === "") {
return new BugReport(adb, { return new BugReport(adb, {
supportsBugReport: true, supportsBugReport: true,
@ -211,10 +211,10 @@ export class BugReport extends AdbServiceBase {
let filename: string | undefined; let filename: string | undefined;
let error: string | undefined; let error: string | undefined;
for await (const line of process.stdout const lines = process.stdout
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
// Each chunk should contain one or several full lines .pipeThrough(new SplitStringStream("\n", { trim: true }));
.pipeThrough(new SplitStringStream("\n"))) { for await (const line of lines) {
// `BEGIN:` and `PROGRESS:` only appear when `-p` is specified. // `BEGIN:` and `PROGRESS:` only appear when `-p` is specified.
let match = line.match(BugReport.PROGRESS_REGEX); let match = line.match(BugReport.PROGRESS_REGEX);
if (match) { if (match) {

View file

@ -1,166 +0,0 @@
import type { Adb, AdbShellProtocolProcess } from "@yume-chan/adb";
import {
AdbFeature,
AdbNoneProtocolProcessImpl,
AdbNoneProtocolSpawner,
AdbServiceBase,
AdbShellProtocolProcessImpl,
AdbShellProtocolSpawner,
} from "@yume-chan/adb";
export class CmdNoneProtocolService extends AdbNoneProtocolSpawner {
#supportsAbbExec: boolean;
get supportsAbbExec(): boolean {
return this.#supportsAbbExec;
}
#supportsCmd: boolean;
get supportsCmd(): boolean {
return this.#supportsCmd;
}
get isSupported() {
return this.#supportsAbbExec || this.#supportsCmd;
}
constructor(
adb: Adb,
fallback?:
| string
| Record<string, string>
| ((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");
}
const fallbackCommand = command.slice();
fallbackCommand[0] = fallback;
return adb.subprocess.noneProtocol.spawn(fallbackCommand);
});
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;
}
get isSupported() {
return (
this.#supportsAbb ||
(this.#supportsCmd && !!this.#adb.subprocess.shellProtocol)
);
}
constructor(
adb: Adb,
fallback?:
| string
| Record<string, string>
| ((service: string) => string),
) {
super(async (command): Promise<AdbShellProtocolProcess> => {
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");
}
const fallbackCommand = command.slice();
fallbackCommand[0] = fallback;
return adb.subprocess.shellProtocol.spawn(fallbackCommand);
});
this.#adb = adb;
this.#supportsCmd = adb.canUseFeature(AdbFeature.Cmd);
this.#supportsAbb = adb.canUseFeature(AdbFeature.Abb);
}
}
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<string, string>
| ((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);
}
}
}

View file

@ -0,0 +1 @@
export * from "./service.js";

View file

@ -0,0 +1,62 @@
import type { Adb } from "@yume-chan/adb";
import {
AdbFeature,
AdbNoneProtocolProcessImpl,
adbNoneProtocolSpawner,
escapeArg,
} from "@yume-chan/adb";
import { Cmd } from "./service.js";
import { checkCommand, resolveFallback, serializeAbbService } from "./utils.js";
export function createNoneProtocol(
adb: Adb,
fallback: NonNullable<Cmd.Fallback>,
): Cmd.NoneProtocolService;
export function createNoneProtocol(
adb: Adb,
fallback?: Cmd.Fallback,
): Cmd.NoneProtocolService | undefined;
export function createNoneProtocol(
adb: Adb,
fallback?: Cmd.Fallback,
): Cmd.NoneProtocolService | undefined {
if (adb.canUseFeature(AdbFeature.AbbExec)) {
return {
mode: Cmd.Mode.Abb,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
const service = serializeAbbService("abb_exec", command);
const socket = await adb.createSocket(service);
return new AdbNoneProtocolProcessImpl(socket, signal);
}),
};
}
if (adb.canUseFeature(AdbFeature.Cmd)) {
return {
mode: Cmd.Mode.Cmd,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command);
const newCommand = command.map(escapeArg);
newCommand.unshift("cmd");
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}),
};
}
if (fallback) {
return {
mode: Cmd.Mode.Fallback,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command);
const newCommand = command.map(escapeArg);
newCommand[0] = resolveFallback(fallback, command[0]!);
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}),
};
}
return undefined;
}

View file

@ -0,0 +1,57 @@
import type {
Adb,
AdbNoneProtocolSpawner,
AdbShellProtocolSpawner,
} from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb";
import { createNoneProtocol } from "./none.js";
import { createShellProtocol } from "./shell.js";
export class Cmd extends AdbServiceBase {
static readonly Mode = {
Abb: 0,
Cmd: 1,
Fallback: 2,
} as const;
static readonly createNoneProtocol = createNoneProtocol;
static readonly createShellProtocol = createShellProtocol;
#noneProtocol: Cmd.NoneProtocolService | undefined;
get noneProtocol() {
return this.#noneProtocol;
}
#shellProtocol: Cmd.ShellProtocolService | undefined;
get shellProtocol() {
return this.#shellProtocol;
}
constructor(adb: Adb, fallback?: Cmd.Fallback) {
super(adb);
this.#noneProtocol = createNoneProtocol(adb, fallback);
this.#shellProtocol = createShellProtocol(adb, fallback);
}
}
export namespace Cmd {
export type Fallback =
| string
| Record<string, string>
| ((service: string) => string)
| undefined;
export type Mode = (typeof Cmd.Mode)[keyof typeof Cmd.Mode];
export interface NoneProtocolService {
mode: Mode;
spawn: AdbNoneProtocolSpawner;
}
export interface ShellProtocolService {
mode: Mode;
spawn: AdbShellProtocolSpawner;
}
}

View file

@ -0,0 +1,67 @@
import type { Adb } from "@yume-chan/adb";
import {
AdbFeature,
AdbShellProtocolProcessImpl,
adbShellProtocolSpawner,
escapeArg,
} from "@yume-chan/adb";
import { Cmd } from "./service.js";
import { checkCommand, resolveFallback, serializeAbbService } from "./utils.js";
export function createShellProtocol(
adb: Adb,
fallback: NonNullable<Cmd.Fallback>,
): Cmd.ShellProtocolService;
export function createShellProtocol(
adb: Adb,
fallback?: Cmd.Fallback,
): Cmd.ShellProtocolService | undefined;
export function createShellProtocol(
adb: Adb,
fallback?: Cmd.Fallback,
): Cmd.ShellProtocolService | undefined {
if (adb.canUseFeature(AdbFeature.Abb)) {
return {
mode: Cmd.Mode.Abb,
spawn: adbShellProtocolSpawner(async (command, signal) => {
const service = serializeAbbService("abb", command);
const socket = await adb.createSocket(service);
return new AdbShellProtocolProcessImpl(socket, signal);
}),
};
}
const shellProtocolService = adb.subprocess.shellProtocol;
if (!shellProtocolService) {
return undefined;
}
if (adb.canUseFeature(AdbFeature.Cmd)) {
return {
mode: Cmd.Mode.Cmd,
spawn: adbShellProtocolSpawner(async (command, signal) => {
checkCommand(command);
const newCommand = command.map(escapeArg);
newCommand.unshift("cmd");
return shellProtocolService.spawn(newCommand, signal);
}),
};
}
if (fallback) {
return {
mode: Cmd.Mode.Fallback,
spawn: adbShellProtocolSpawner(async (command, signal) => {
checkCommand(command);
const newCommand = command.map(escapeArg);
newCommand[0] = resolveFallback(fallback, command[0]!);
return shellProtocolService.spawn(newCommand, signal);
}),
};
}
return undefined;
}

View file

@ -0,0 +1,35 @@
import type { Cmd } from "./service.js";
export function resolveFallback(
fallback: Cmd.Fallback,
command: string,
): string {
if (typeof fallback === "function") {
fallback = fallback(command);
} else if (typeof fallback === "object" && fallback !== null) {
fallback = fallback[command];
}
if (!fallback) {
throw new Error(`No fallback configured for command "${command}"`);
}
return fallback;
}
export function checkCommand(command: readonly string[]) {
if (!command.length) {
throw new TypeError("Command is empty");
}
}
export function serializeAbbService(
prefix: string,
command: readonly string[],
): string {
checkCommand(command);
// `abb` mode uses `\0` as the separator, allowing space in arguments.
// The last `\0` is required for older versions of `adb`.
return `${prefix}:${command.join("\0")}\0`;
}

View file

@ -7,6 +7,7 @@
import type { Adb } from "@yume-chan/adb"; import type { Adb } from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb"; import { AdbServiceBase } from "@yume-chan/adb";
import { ActivityManager } from "./am.js";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
export enum DemoModeSignalStrength { export enum DemoModeSignalStrength {
@ -52,10 +53,12 @@ export const DemoModeStatusBarModes = [
export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number]; export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number];
export class DemoMode extends AdbServiceBase { export class DemoMode extends AdbServiceBase {
#am: ActivityManager;
#settings: Settings; #settings: Settings;
constructor(adb: Adb) { constructor(adb: Adb, apiLevel?: number) {
super(adb); super(adb);
this.#am = new ActivityManager(adb, apiLevel);
this.#settings = new Settings(adb); this.#settings = new Settings(adb);
} }
@ -108,26 +111,14 @@ export class DemoMode extends AdbServiceBase {
} }
} }
async broadcast( broadcast(command: string, extras?: Record<string, string>): Promise<void> {
command: string, return this.#am.broadcast({
extra?: Record<string, string>, action: "com.android.systemui.demo",
): Promise<void> { extras: {
await this.adb.subprocess.noneProtocol.spawnWaitText([ command,
"am", ...extras,
"broadcast", },
"-a", });
"com.android.systemui.demo",
"-e",
"command",
command,
...(extra
? Object.entries(extra).flatMap(([key, value]) => [
"-e",
key,
value,
])
: []),
]);
} }
async setBatteryLevel(level: number): Promise<void> { async setBatteryLevel(level: number): Promise<void> {

View file

@ -58,10 +58,10 @@ export class DumpSys extends AdbServiceBase {
static readonly Battery = Battery; static readonly Battery = Battery;
async diskStats() { async diskStats() {
const result = await this.adb.subprocess.noneProtocol.spawnWaitText([ const result = await this.adb.subprocess.noneProtocol
"dumpsys", .spawn(["dumpsys", "diskstats"])
"diskstats", .wait()
]); .toString();
function getSize(name: string) { function getSize(name: string) {
const match = result.match( const match = result.match(
@ -91,10 +91,10 @@ export class DumpSys extends AdbServiceBase {
} }
async battery(): Promise<DumpSys.Battery.Info> { async battery(): Promise<DumpSys.Battery.Info> {
const result = await this.adb.subprocess.noneProtocol.spawnWaitText([ const result = await this.adb.subprocess.noneProtocol
"dumpsys", .spawn(["dumpsys", "battery"])
"battery", .wait()
]); .toString();
const info: DumpSys.Battery.Info = { const info: DumpSys.Battery.Info = {
acPowered: false, acPowered: false,

View file

@ -3,7 +3,7 @@
export * from "./am.js"; export * from "./am.js";
export * from "./bu.js"; export * from "./bu.js";
export * from "./bug-report.js"; export * from "./bug-report.js";
export * from "./cmd.js"; export * from "./cmd/index.js";
export * from "./demo-mode.js"; export * from "./demo-mode.js";
export * from "./dumpsys.js"; export * from "./dumpsys.js";
export * from "./intent.js"; export * from "./intent.js";

View file

@ -1,53 +1,165 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { IntentBuilder } from "./intent.js"; import { serializeIntent } from "./intent.js";
describe("Intent", () => { describe("Intent", () => {
describe("IntentBuilder", () => { describe("serializeIntent", () => {
it("should set intent action", () => { it("should serialize intent action", () => {
assert.deepStrictEqual( assert.deepStrictEqual(serializeIntent({ action: "test_action" }), [
new IntentBuilder().setAction("test_action").build(), "-a",
["-a", "test_action"], "test_action",
); ]);
}); });
it("should set intent categories", () => { it("should serialize intent categories", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
new IntentBuilder() serializeIntent({ categories: ["category_1", "category_2"] }),
.addCategory("category_1")
.addCategory("category_2")
.build(),
["-c", "category_1", "-c", "category_2"], ["-c", "category_1", "-c", "category_2"],
); );
}); });
it("should set intent package", () => { it("should serialize intent package", () => {
assert.deepStrictEqual(serializeIntent({ package: "package_1" }), [
"-p",
"package_1",
]);
});
it("should serialize intent component", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
new IntentBuilder().setPackage("package_1").build(), serializeIntent({
["-p", "package_1"], component: {
packageName: "package_1",
className: "component_1",
},
}),
["-n", "package_1/component_1"],
); );
}); });
it("should set intent component", () => { it("should serialize intent data", () => {
assert.deepStrictEqual( assert.deepStrictEqual(serializeIntent({ data: "data_1" }), [
new IntentBuilder().setComponent("component_1").build(), "-d",
["-n", "component_1"], "data_1",
); ]);
}); });
it("should set intent data", () => { describe("extras", () => {
assert.deepStrictEqual( it("should serialize string extras", () => {
new IntentBuilder().setData("data_1").build(), assert.deepStrictEqual(
["-d", "data_1"], serializeIntent({
); extras: {
}); key1: "value1",
},
}),
["--es", "key1", "value1"],
);
});
it("should pass intent extras", () => { it("should serialize null extras", () => {
assert.deepStrictEqual( assert.deepStrictEqual(
new IntentBuilder().addStringExtra("key1", "value1").build(), serializeIntent({
["--es", "key1", "value1"], extras: {
); key1: null,
},
}),
["--esn", "key1"],
);
});
it("should serialize integer extras", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: 1,
},
}),
["--ei", "key1", "1"],
);
});
it("should serialize URI extras", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: {
type: "uri",
value: "http://example.com",
},
},
}),
["--eu", "key1", "http://example.com"],
);
});
it("should serialize component name", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: {
packageName: "com.example.package_1",
className: "com.example.component_1",
},
},
}),
[
"--ecn",
"key1",
"com.example.package_1/com.example.component_1",
],
);
});
it("should serialize integer array extras", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: {
type: "array",
itemType: "int",
value: [1, 2, 3],
},
},
}),
["--eia", "key1", "1,2,3"],
);
});
it("should serialize integer array list extras", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: {
type: "arrayList",
itemType: "int",
value: [1, 2, 3],
},
},
}),
["--eial", "key1", "1,2,3"],
);
});
it("should serialize boolean extras", () => {
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: true,
},
}),
["--ez", "key1", "true"],
);
assert.deepStrictEqual(
serializeIntent({
extras: {
key1: false,
},
}),
["--ez", "key1", "false"],
);
});
}); });
}); });
}); });

View file

@ -1,73 +1,194 @@
export class IntentBuilder { // cspell: ignore eial
#action: string | undefined; // cspell: ignore elal
#categories: string[] = []; // cspell: ignore efal
#packageName: string | undefined; // cspell: ignore esal
#component: string | undefined; // cspell: ignore edal
#data: string | undefined;
#type: string | undefined;
#stringExtras = new Map<string, string>();
setAction(action: string): this { export interface IntentNumberExtra {
this.#action = action; type: "long" | "float" | "double";
return this; value: number;
} }
addCategory(category: string): this { export interface IntentStringExtra {
this.#categories.push(category); type: "uri";
return this; value: string;
} }
setPackage(packageName: string): this { export interface IntentNumberArrayExtra {
this.#packageName = packageName; type: "array" | "arrayList";
return this; itemType: "int" | IntentNumberExtra["type"];
} value: number[];
}
setComponent(component: string): this { export interface IntentStringArrayExtra {
this.#component = component; type: "array" | "arrayList";
return this; itemType: "string";
} value: string[];
}
setData(data: string): this { export interface ComponentName {
this.#data = data; packageName: string;
return this; className: string;
} }
addStringExtra(key: string, value: string): this { export interface Intent {
this.#stringExtras.set(key, value); action?: string | undefined;
return this; data?: string | undefined;
} type?: string | undefined;
identifier?: string | undefined;
categories?: string[] | undefined;
extras?:
| Record<
string,
| string
| null
| number
| IntentStringExtra
| ComponentName
| IntentNumberArrayExtra
| IntentStringArrayExtra
| IntentNumberExtra
| boolean
>
| undefined;
flags?: number | undefined;
package?: string | undefined;
component?: ComponentName | undefined;
}
build(): string[] { function getNumberType(type: "int" | IntentNumberExtra["type"]) {
const result: string[] = []; switch (type) {
case "int":
if (this.#action) { return "--ei";
result.push("-a", this.#action); case "long":
} return "--el";
case "float":
for (const category of this.#categories) { return "--ef";
result.push("-c", category); case "double":
} return "--ed";
default:
if (this.#packageName) { throw new Error(`Unknown number type: ${type as string}`);
result.push("-p", this.#packageName);
}
if (this.#component) {
result.push("-n", this.#component);
}
if (this.#data) {
result.push("-d", this.#data);
}
if (this.#type) {
result.push("-t", this.#type);
}
for (const [key, value] of this.#stringExtras) {
result.push("--es", key, value);
}
return result;
} }
} }
function serializeArray(
array: IntentNumberArrayExtra | IntentStringArrayExtra,
): [type: string, value: string] {
let type: string;
let value: string;
if (array.itemType === "string") {
type = "--es";
value = array.value
.map((item) => item.replaceAll(",", "\\,"))
.join(",");
} else {
type = getNumberType(array.itemType);
value = array.value.join(",");
}
if (array.type === "array") {
type += "a";
} else {
type += "al";
}
return [type, value];
}
export function serializeIntent(intent: Intent) {
const result: string[] = [];
if (intent.action) {
result.push("-a", intent.action);
}
if (intent.data) {
result.push("-d", intent.data);
}
if (intent.type) {
result.push("-t", intent.type);
}
if (intent.identifier) {
result.push("-i", intent.identifier);
}
if (intent.categories) {
for (const category of intent.categories) {
result.push("-c", category);
}
}
if (intent.extras) {
for (const [key, value] of Object.entries(intent.extras)) {
switch (typeof value) {
case "string":
result.push("--es", key, value);
break;
case "object":
if (value === null) {
result.push("--esn", key);
break;
}
if ("packageName" in value) {
result.push(
"--ecn",
key,
value.packageName + "/" + value.className,
);
break;
}
switch (value.type) {
case "uri":
result.push("--eu", key, value.value);
break;
case "array":
case "arrayList":
{
const [type, valueString] =
serializeArray(value);
result.push(type, key, valueString);
}
break;
default:
result.push(
getNumberType(value.type),
key,
value.value.toString(),
);
break;
}
break;
case "number":
result.push("--ei", key, value.toString());
break;
case "boolean":
result.push("--ez", key, value ? "true" : "false");
break;
}
}
}
if (intent.component) {
result.push(
"-n",
intent.component.packageName + "/" + intent.component.className,
);
}
if (intent.package) {
result.push("-p", intent.package);
}
// `0` is the default value for `flags` when deserializing
// so it can be omitted if it's either `undefined` or `0`
if (intent.flags) {
result.push("-f", intent.flags.toString());
}
return result;
}

View file

@ -444,7 +444,7 @@ export class Logcat extends AdbServiceBase {
const result: LogSize[] = []; const result: LogSize[] = [];
for await (const line of process.output for await (const line of process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"))) { .pipeThrough(new SplitStringStream("\n", { trim: true }))) {
let match = line.match(Logcat.LOG_SIZE_REGEX_11); let match = line.match(Logcat.LOG_SIZE_REGEX_11);
if (match) { if (match) {
result.push({ result.push({
@ -494,7 +494,7 @@ export class Logcat extends AdbServiceBase {
args.push("-b", Logcat.joinLogId(ids)); args.push("-b", Logcat.joinLogId(ids));
} }
await this.adb.subprocess.noneProtocol.spawnWaitText(args); await this.adb.subprocess.noneProtocol.spawn(args).wait();
} }
binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> { binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> {

View file

@ -2,9 +2,10 @@
// cspell:ignore instantapp // cspell:ignore instantapp
// cspell:ignore apks // cspell:ignore apks
// cspell:ignore versioncode // cspell:ignore versioncode
// cspell:ignore dexopt
import type { Adb } from "@yume-chan/adb"; import type { Adb, AdbNoneProtocolProcess } from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb"; import { AdbServiceBase, escapeArg } from "@yume-chan/adb";
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra"; import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
import { import {
ConcatStringStream, ConcatStringStream,
@ -12,10 +13,11 @@ import {
TextDecoderStream, TextDecoderStream,
} from "@yume-chan/stream-extra"; } from "@yume-chan/stream-extra";
import { CmdNoneProtocolService } from "./cmd.js"; import { Cmd } from "./cmd/index.js";
import type { IntentBuilder } from "./intent.js"; import type { Intent } from "./intent.js";
import type { SingleUserOrAll } from "./utils.js"; import { serializeIntent } from "./intent.js";
import { buildArguments } from "./utils.js"; import type { Optional, SingleUserOrAll } from "./utils.js";
import { buildCommand } from "./utils.js";
export enum PackageManagerInstallLocation { export enum PackageManagerInstallLocation {
Auto, Auto,
@ -31,154 +33,85 @@ export enum PackageManagerInstallReason {
UserRequest, UserRequest,
} }
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd interface OptionDefinition<T> {
export interface PackageManagerInstallOptions { type: T;
/** name: string;
* `-R` minApiLevel?: number;
*/ maxApiLevel?: number;
skipExisting: boolean;
/**
* `-i`
*/
installerPackageName: string;
/**
* `-t`
*/
allowTest: boolean;
/**
* `-f`
*/
internalStorage: boolean;
/**
* `-d`
*/
requestDowngrade: boolean;
/**
* `-g`
*/
grantRuntimePermissions: boolean;
/**
* `--restrict-permissions`
*/
restrictPermissions: boolean;
/**
* `--dont-kill`
*/
doNotKill: boolean;
/**
* `--originating-uri`
*/
originatingUri: string;
/**
* `--referrer`
*/
refererUri: string;
/**
* `-p`
*/
inheritFrom: string;
/**
* `--pkg`
*/
packageName: string;
/**
* `--abi`
*/
abi: string;
/**
* `--ephemeral`/`--instant`/`--instantapp`
*/
instantApp: boolean;
/**
* `--full`
*/
full: boolean;
/**
* `--preload`
*/
preload: boolean;
/**
* `--user`
*/
user: SingleUserOrAll;
/**
* `--install-location`
*/
installLocation: PackageManagerInstallLocation;
/**
* `--install-reason`
*/
installReason: PackageManagerInstallReason;
/**
* `--force-uuid`
*/
forceUuid: string;
/**
* `--apex`
*/
apex: boolean;
/**
* `--force-non-staged`
*/
forceNonStaged: boolean;
/**
* `--staged`
*/
staged: boolean;
/**
* `--force-queryable`
*/
forceQueryable: boolean;
/**
* `--enable-rollback`
*/
enableRollback: boolean;
/**
* `--staged-ready-timeout`
*/
stagedReadyTimeout: number;
/**
* `--skip-verification`
*/
skipVerification: boolean;
/**
* `--bypass-low-target-sdk-block`
*/
bypassLowTargetSdkBlock: boolean;
} }
export const PACKAGE_MANAGER_INSTALL_OPTIONS_MAP: Record< function option<T>(
keyof PackageManagerInstallOptions, name: string,
string minApiLevel?: number,
> = { maxApiLevel?: number,
skipExisting: "-R", ): OptionDefinition<T> {
installerPackageName: "-i", return {
allowTest: "-t", name,
internalStorage: "-f", minApiLevel,
requestDowngrade: "-d", maxApiLevel,
grantRuntimePermissions: "-g", } as OptionDefinition<T>;
restrictPermissions: "--restrict-permissions", }
doNotKill: "--dont-kill",
originatingUri: "--originating-uri", // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
refererUri: "--referrer", export const PackageManagerInstallOptions = {
inheritFrom: "-p", forwardLock: option<boolean>("-l", undefined, 28),
packageName: "--pkg", replaceExisting: option<boolean>("-r", undefined, 27),
abi: "--abi", skipExisting: option<boolean>("-R", 28),
instantApp: "--instant", installerPackageName: option<string>("-i"),
full: "--full", allowTest: option<boolean>("-t"),
preload: "--preload", externalStorage: option<boolean>("-s", undefined, 28),
user: "--user", internalStorage: option<boolean>("-f"),
installLocation: "--install-location", requestDowngrade: option<boolean>("-d"),
installReason: "--install-reason", grantRuntimePermissions: option<boolean>("-g", 23),
forceUuid: "--force-uuid", restrictPermissions: option<boolean>("--restrict-permissions", 29),
apex: "--apex", doNotKill: option<boolean>("--dont-kill"),
forceNonStaged: "--force-non-staged", originatingUri: option<string>("--originating-uri"),
staged: "--staged", referrerUri: option<string>("--referrer"),
forceQueryable: "--force-queryable", inheritFrom: option<string>("-p", 24),
enableRollback: "--enable-rollback", packageName: option<string>("--pkg", 28),
stagedReadyTimeout: "--staged-ready-timeout", abi: option<string>("--abi", 21),
skipVerification: "--skip-verification", instantApp: option<boolean>("--ephemeral", 24),
bypassLowTargetSdkBlock: "--bypass-low-target-sdk-block", full: option<boolean>("--full", 26),
preload: option<boolean>("--preload", 28),
user: option<SingleUserOrAll>("--user", 21),
installLocation: option<PackageManagerInstallLocation>(
"--install-location",
24,
),
installReason: option<PackageManagerInstallReason>("--install-reason", 29),
updateOwnership: option<boolean>("--update-ownership", 34),
forceUuid: option<string>("--force-uuid", 24),
forceSdk: option<number>("--force-sdk", 24),
apex: option<boolean>("--apex", 29),
forceNonStaged: option<boolean>("--force-non-staged", 31),
multiPackage: option<boolean>("--multi-package", 29),
staged: option<boolean>("--staged", 29),
nonStaged: option<boolean>("--non-staged", 35),
forceQueryable: option<boolean>("--force-queryable", 30),
enableRollback: option<boolean | number>("--enable-rollback", 29),
rollbackImpactLevel: option<number>("--rollback-impact-level", 35),
wait: option<boolean | number>("--wait", 30, 30),
noWait: option<boolean>("--no-wait", 30, 30),
stagedReadyTimeout: option<number>("--staged-ready-timeout", 31),
skipVerification: option<boolean>("--skip-verification", 30),
skipEnable: option<boolean>("--skip-enable", 34),
bypassLowTargetSdkBlock: option<boolean>(
"--bypass-low-target-sdk-block",
34,
),
ignoreDexoptProfile: option<boolean>("--ignore-dexopt-profile", 35),
packageSource: option<number>("--package-source", 35),
dexoptCompilerFilter: option<string>("--dexopt-compiler-filter", 35),
disableAutoInstallDependencies: option<boolean>(
"--disable-auto-install-dependencies",
36,
),
} as const;
export type PackageManagerInstallOptions = {
[K in keyof typeof PackageManagerInstallOptions]?:
| (typeof PackageManagerInstallOptions)[K]["type"]
| undefined;
}; };
export interface PackageManagerListPackagesOptions { export interface PackageManagerListPackagesOptions {
@ -246,7 +179,7 @@ const PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP: Record<
export interface PackageManagerResolveActivityOptions { export interface PackageManagerResolveActivityOptions {
user?: SingleUserOrAll; user?: SingleUserOrAll;
intent: IntentBuilder; intent: Intent;
} }
const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial< const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
@ -255,15 +188,13 @@ const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
user: "--user", user: "--user",
}; };
function buildInstallArguments( function buildInstallCommand(
command: string, command: string,
options: Partial<PackageManagerInstallOptions> | undefined, options: PackageManagerInstallOptions | undefined,
apiLevel: number | undefined,
): string[] { ): string[] {
const args = buildArguments( const args = [PackageManager.ServiceName, command];
[PackageManager.ServiceName, command],
options,
PACKAGE_MANAGER_INSTALL_OPTIONS_MAP,
);
if (!options?.skipExisting) { if (!options?.skipExisting) {
/* /*
* | behavior | previous version | modern version | * | behavior | previous version | modern version |
@ -278,18 +209,74 @@ function buildInstallArguments(
*/ */
args.push("-r"); args.push("-r");
} }
if (!options) {
return args;
}
for (const [key, value] of Object.entries(options)) {
if (value === undefined || value === null) {
continue;
}
const option =
PackageManagerInstallOptions[
key as keyof PackageManagerInstallOptions
];
if (option === undefined) {
continue;
}
if (apiLevel !== undefined) {
if (
option.minApiLevel !== undefined &&
apiLevel < option.minApiLevel
) {
continue;
}
if (
option.maxApiLevel !== undefined &&
apiLevel > option.maxApiLevel
) {
continue;
}
}
switch (typeof value) {
case "boolean":
if (value) {
args.push(option.name);
}
break;
case "number":
args.push(option.name, value.toString());
break;
case "string":
args.push(option.name, value);
break;
default:
throw new Error(
`Unsupported type for option ${key}: ${typeof value}`,
);
}
}
return args; return args;
} }
export class PackageManager extends AdbServiceBase { export class PackageManager extends AdbServiceBase {
static ServiceName = "package"; static readonly ServiceName = "package";
static CommandName = "pm"; static readonly CommandName = "pm";
#cmd: CmdNoneProtocolService; #apiLevel: number | undefined;
#cmd: Cmd.NoneProtocolService;
constructor(adb: Adb) { constructor(adb: Adb, apiLevel?: number) {
super(adb); super(adb);
this.#cmd = new CmdNoneProtocolService(adb, PackageManager.CommandName);
this.#apiLevel = apiLevel;
this.#cmd = Cmd.createNoneProtocol(adb, PackageManager.CommandName);
} }
/** /**
@ -299,37 +286,41 @@ export class PackageManager extends AdbServiceBase {
*/ */
async install( async install(
apks: readonly string[], apks: readonly string[],
options?: Partial<PackageManagerInstallOptions>, options?: PackageManagerInstallOptions,
): Promise<string> { ): Promise<void> {
const args = buildInstallArguments("install", options); const command = buildInstallCommand("install", options, this.#apiLevel);
args[0] = PackageManager.CommandName;
command[0] = PackageManager.CommandName;
// WIP: old version of pm doesn't support multiple apks // WIP: old version of pm doesn't support multiple apks
args.push(...apks); for (const apk of apks) {
command.push(apk);
}
// Starting from Android 7, `pm` becomes a wrapper to `cmd package`. // Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
// The benefit of `cmd package` is it starts faster than the old `pm`, // The benefit of `cmd package` is it starts faster than the old `pm`,
// because it connects to the already running `system` process, // because it connects to the already running `system` process,
// instead of initializing all system components from scratch. // instead of initializing all system components from scratch.
// //
// But launching `cmd package` directly causes it to not be able to // But `cmd` executable can't read files in `/data/local/tmp`
// read files in `/data/local/tmp` (and many other places) due to SELinux policies, // (and many other places) due to SELinux policies,
// so installing files must still use `pm`. // so installing from files must still use `pm`.
// (the starting executable file decides which SELinux policies to apply) // (the starting executable file decides which SELinux policies to apply)
const output = await this.adb.subprocess.noneProtocol const output = await this.adb.subprocess.noneProtocol
.spawnWaitText(args) .spawn(command.map(escapeArg))
.wait()
.toString()
.then((output) => output.trim()); .then((output) => output.trim());
if (output !== "Success") { if (output !== "Success") {
throw new Error(output); throw new Error(output);
} }
return output;
} }
async pushAndInstallStream( async pushAndInstallStream(
stream: ReadableStream<MaybeConsumable<Uint8Array>>, stream: ReadableStream<MaybeConsumable<Uint8Array>>,
options?: Partial<PackageManagerInstallOptions>, options?: PackageManagerInstallOptions,
): Promise<string> { ): Promise<void> {
const fileName = Math.random().toString().substring(2); const fileName = Math.random().toString().substring(2);
const filePath = `/data/local/tmp/${fileName}.apk`; const filePath = `/data/local/tmp/${fileName}.apk`;
@ -345,7 +336,7 @@ export class PackageManager extends AdbServiceBase {
} }
try { try {
return await this.install([filePath], options); await this.install([filePath], options);
} finally { } finally {
await this.adb.rm(filePath); await this.adb.rm(filePath);
} }
@ -354,21 +345,21 @@ export class PackageManager extends AdbServiceBase {
async installStream( async installStream(
size: number, size: number,
stream: ReadableStream<MaybeConsumable<Uint8Array>>, stream: ReadableStream<MaybeConsumable<Uint8Array>>,
options?: Partial<PackageManagerInstallOptions>, options?: PackageManagerInstallOptions,
): Promise<void> { ): Promise<void> {
// Android 7 added both `cmd` command and streaming install support, // Technically `cmd` support and streaming install support are unrelated,
// It's hard to detect whether `pm` supports streaming install (unless actually trying), // but it's impossible to detect streaming install support without actually trying it.
// so check for whether `cmd` is supported, // As they are both added in Android 7,
// and assume `pm` streaming install support status is same as that. // assume `cmd` support also means streaming install support (and vice versa).
if (!this.#cmd.isSupported) { if (this.#cmd.mode === Cmd.Mode.Fallback) {
// Fall back to push file then install // Fall back to push file then install
await this.pushAndInstallStream(stream, options); await this.pushAndInstallStream(stream, options);
return; return;
} }
const args = buildInstallArguments("install", options); const command = buildInstallCommand("install", options, this.#apiLevel);
args.push("-S", size.toString()); command.push("-S", size.toString());
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
const output = process.output const output = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
@ -385,10 +376,12 @@ export class PackageManager extends AdbServiceBase {
]); ]);
} }
static readonly PackageListItemPrefix = "package:";
static parsePackageListItem( static parsePackageListItem(
line: string, line: string,
): PackageManagerListPackagesResult { ): PackageManagerListPackagesResult {
line = line.substring("package:".length); line = line.substring(PackageManager.PackageListItemPrefix.length);
let packageName: string; let packageName: string;
let sourceDir: string | undefined; let sourceDir: string | undefined;
@ -439,41 +432,84 @@ export class PackageManager extends AdbServiceBase {
} }
async *listPackages( async *listPackages(
options?: Partial<PackageManagerListPackagesOptions>, options?: Optional<PackageManagerListPackagesOptions>,
): AsyncGenerator<PackageManagerListPackagesResult, void, void> { ): AsyncGenerator<PackageManagerListPackagesResult, void, void> {
const args = buildArguments( const command = buildCommand(
["package", "list", "packages"], ["package", "list", "packages"],
options, options,
PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP, PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP,
); );
if (options?.filter) { if (options?.filter) {
args.push(options.filter); command.push(options.filter);
} }
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
const reader = process.output
const output = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n")) .pipeThrough(new SplitStringStream("\n", { trim: true }));
.getReader();
while (true) { for await (const line of output) {
const { done, value } = await reader.read(); if (!line.startsWith(PackageManager.PackageListItemPrefix)) {
if (done) { continue;
break;
} }
yield PackageManager.parsePackageListItem(value);
yield PackageManager.parsePackageListItem(line);
} }
} }
async getPackageSources(packageName: string): Promise<string[]> { /**
const args = [PackageManager.ServiceName, "-p", packageName]; * Gets APK file paths for a package.
const process = await this.#cmd.spawn(args); *
const result: string[] = []; * On supported Android versions, all split APKs are included.
for await (const line of process.output * @param packageName The package name to query
* @param options The user ID to query
* @returns An array of APK file paths
*/
async getPackageSources(
packageName: string,
options?: {
/**
* The user ID to query
*/
user?: number | undefined;
},
): Promise<string[]> {
// `pm path` and `pm -p` are the same,
// but `pm path` allows an optional `--user` option.
const command = [PackageManager.ServiceName, "path"];
if (options?.user !== undefined) {
command.push("--user", options.user.toString());
}
command.push(packageName);
// Android 7 and 8 support `cmd package` but not `cmd package path` command
let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 27) {
command[0] = PackageManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}
const lines = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"))) { .pipeThrough(new SplitStringStream("\n", { trim: true }));
if (line.startsWith("package:")) {
result.push(line.substring("package:".length)); const result: string[] = [];
for await (const line of lines) {
if (!line.startsWith(PackageManager.PackageListItemPrefix)) {
continue;
} }
result.push(
line.substring(PackageManager.PackageListItemPrefix.length),
);
} }
return result; return result;
@ -481,20 +517,26 @@ export class PackageManager extends AdbServiceBase {
async uninstall( async uninstall(
packageName: string, packageName: string,
options?: Partial<PackageManagerUninstallOptions>, options?: Optional<PackageManagerUninstallOptions>,
): Promise<void> { ): Promise<void> {
const args = buildArguments( const command = buildCommand(
[PackageManager.ServiceName, "uninstall"], [PackageManager.ServiceName, "uninstall"],
options, options,
PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP, PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP,
); );
args.push(packageName);
command.push(packageName);
if (options?.splitNames) { if (options?.splitNames) {
args.push(...options.splitNames); for (const splitName of options.splitNames) {
command.push(splitName);
}
} }
const output = await this.#cmd const output = await this.#cmd
.spawnWaitText(args) .spawn(command)
.wait()
.toString()
.then((output) => output.trim()); .then((output) => output.trim());
if (output !== "Success") { if (output !== "Success") {
throw new Error(output); throw new Error(output);
@ -504,16 +546,20 @@ export class PackageManager extends AdbServiceBase {
async resolveActivity( async resolveActivity(
options: PackageManagerResolveActivityOptions, options: PackageManagerResolveActivityOptions,
): Promise<string | undefined> { ): Promise<string | undefined> {
let args = buildArguments( const command = buildCommand(
[PackageManager.ServiceName, "resolve-activity", "--components"], [PackageManager.ServiceName, "resolve-activity", "--components"],
options, options,
PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP, PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP,
); );
args = args.concat(options.intent.build()); for (const arg of serializeIntent(options.intent)) {
command.push(arg);
}
const output = await this.#cmd const output = await this.#cmd
.spawnWaitText(args) .spawn(command)
.wait()
.toString()
.then((output) => output.trim()); .then((output) => output.trim());
if (output === "No activity found") { if (output === "No activity found") {
@ -534,20 +580,28 @@ export class PackageManager extends AdbServiceBase {
* @returns ID of the new install session * @returns ID of the new install session
*/ */
async sessionCreate( async sessionCreate(
options?: Partial<PackageManagerInstallOptions>, options?: PackageManagerInstallOptions,
): Promise<number> { ): Promise<number> {
const args = buildInstallArguments("install-create", options); const command = buildInstallCommand(
"install-create",
options,
this.#apiLevel,
);
const output = await this.#cmd const output = await this.#cmd
.spawnWaitText(args) .spawn(command)
.wait()
.toString()
.then((output) => output.trim()); .then((output) => output.trim());
const sessionIdString = output.match(/.*\[(\d+)\].*/); // The output format won't change to make it easier to parse
if (!sessionIdString) { // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=1744;drc=e38fa24e5738513d721ec2d9fd2dd00f32e327c1
throw new Error("Failed to create install session"); const match = output.match(/\[(\d+)\]/);
if (!match) {
throw new Error(output);
} }
return Number.parseInt(sessionIdString[1]!, 10); return Number.parseInt(match[1]!, 10);
} }
async checkResult(stream: ReadableStream<Uint8Array>) { async checkResult(stream: ReadableStream<Uint8Array>) {
@ -566,15 +620,18 @@ export class PackageManager extends AdbServiceBase {
splitName: string, splitName: string,
path: string, path: string,
): Promise<void> { ): Promise<void> {
const args: string[] = [ const command: string[] = [
"pm", PackageManager.CommandName,
"install-write", "install-write",
sessionId.toString(), sessionId.toString(),
splitName, splitName,
path, path,
]; ];
const process = await this.adb.subprocess.noneProtocol.spawn(args); // Similar to `install`, must use `adb.subprocess` so it can read `path`
const process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
await this.checkResult(process.output); await this.checkResult(process.output);
} }
@ -584,7 +641,7 @@ export class PackageManager extends AdbServiceBase {
size: number, size: number,
stream: ReadableStream<MaybeConsumable<Uint8Array>>, stream: ReadableStream<MaybeConsumable<Uint8Array>>,
): Promise<void> { ): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-write", "install-write",
"-S", "-S",
@ -594,30 +651,48 @@ export class PackageManager extends AdbServiceBase {
"-", "-",
]; ];
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
await Promise.all([ await Promise.all([
stream.pipeTo(process.stdin), stream.pipeTo(process.stdin),
this.checkResult(process.output), this.checkResult(process.output),
]); ]);
} }
/**
* Commit an install session.
* @param sessionId ID of install session returned by `createSession`
* @returns A `Promise` that resolves when the session is committed
*/
async sessionCommit(sessionId: number): Promise<void> { async sessionCommit(sessionId: number): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-commit", "install-commit",
sessionId.toString(), sessionId.toString(),
]; ];
const process = await this.#cmd.spawn(args);
// Android 7 did support `cmd package install-commit` command,
// but it wrote the "Success" message to an incorrect output stream,
// causing `checkResult` to fail with an empty message
let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
command[0] = PackageManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}
await this.checkResult(process.output); await this.checkResult(process.output);
} }
async sessionAbandon(sessionId: number): Promise<void> { async sessionAbandon(sessionId: number): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-abandon", "install-abandon",
sessionId.toString(), sessionId.toString(),
]; ];
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
await this.checkResult(process.output); await this.checkResult(process.output);
} }
} }
@ -625,7 +700,7 @@ export class PackageManager extends AdbServiceBase {
export class PackageManagerInstallSession { export class PackageManagerInstallSession {
static async create( static async create(
packageManager: PackageManager, packageManager: PackageManager,
options?: Partial<PackageManagerInstallOptions>, options?: PackageManagerInstallOptions,
): Promise<PackageManagerInstallSession> { ): Promise<PackageManagerInstallSession> {
const id = await packageManager.sessionCreate(options); const id = await packageManager.sessionCreate(options);
return new PackageManagerInstallSession(packageManager, id); return new PackageManagerInstallSession(packageManager, id);
@ -660,6 +735,10 @@ export class PackageManagerInstallSession {
); );
} }
/**
* Commit this install session.
* @returns A `Promise` that resolves when the session is committed
*/
commit(): Promise<void> { commit(): Promise<void> {
return this.#packageManager.sessionCommit(this.#id); return this.#packageManager.sessionCommit(this.#id);
} }

View file

@ -1,7 +1,7 @@
import type { Adb } from "@yume-chan/adb"; import type { Adb } from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb"; import { AdbServiceBase } from "@yume-chan/adb";
import { CmdNoneProtocolService } from "./cmd.js"; import { Cmd } from "./cmd/index.js";
import type { SingleUser } from "./utils.js"; import type { SingleUser } from "./utils.js";
export type SettingsNamespace = "system" | "secure" | "global"; export type SettingsNamespace = "system" | "secure" | "global";
@ -29,11 +29,11 @@ export class Settings extends AdbServiceBase {
static ServiceName = "settings"; static ServiceName = "settings";
static CommandName = "settings"; static CommandName = "settings";
#cmd: CmdNoneProtocolService; #cmd: Cmd.NoneProtocolService;
constructor(adb: Adb) { constructor(adb: Adb) {
super(adb); super(adb);
this.#cmd = new CmdNoneProtocolService(adb, Settings.CommandName); this.#cmd = Cmd.createNoneProtocol(adb, Settings.CommandName);
} }
base( base(
@ -42,16 +42,19 @@ export class Settings extends AdbServiceBase {
options: SettingsOptions | undefined, options: SettingsOptions | undefined,
...args: string[] ...args: string[]
): Promise<string> { ): Promise<string> {
let command = [Settings.ServiceName]; const command = [Settings.ServiceName];
if (options?.user !== undefined) { if (options?.user !== undefined) {
command.push("--user", options.user.toString()); command.push("--user", options.user.toString());
} }
command.push(verb, namespace); command.push(verb, namespace);
command = command.concat(args);
return this.#cmd.spawnWaitText(command); for (const arg of args) {
command.push(arg);
}
return this.#cmd.spawn(command).wait().toString();
} }
async get( async get(
@ -61,7 +64,7 @@ export class Settings extends AdbServiceBase {
): Promise<string> { ): Promise<string> {
const output = await this.base("get", namespace, options, key); const output = await this.base("get", namespace, options, key);
// Remove last \n // Remove last \n
return output.substring(0, output.length - 1); return output.endsWith("\n") ? output.slice(0, -1) : output;
} }
async delete( async delete(

View file

@ -1,4 +1,4 @@
export function buildArguments<T>( export function buildCommand<T>(
commands: readonly string[], commands: readonly string[],
options: Partial<T> | undefined, options: Partial<T> | undefined,
map: Partial<Record<keyof T, string>>, map: Partial<Record<keyof T, string>>,
@ -6,19 +6,34 @@ export function buildArguments<T>(
const args = commands.slice(); const args = commands.slice();
if (options) { if (options) {
for (const [key, value] of Object.entries(options)) { for (const [key, value] of Object.entries(options)) {
if (value) { if (value === undefined || value === null) {
const option = map[key as keyof T]; continue;
if (option) { }
args.push(option);
switch (typeof value) { const option = map[key as keyof T];
case "number": // Empty string means positional argument,
args.push(value.toString()); // they must be added at the end,
break; // so let the caller handle it.
case "string": if (option === undefined || option === "") {
args.push(value); continue;
break; }
switch (typeof value) {
case "boolean":
if (value) {
args.push(option);
} }
} break;
case "number":
args.push(option, value.toString());
break;
case "string":
args.push(option, value);
break;
default:
throw new Error(
`Unsupported type for option ${key}: ${typeof value}`,
);
} }
} }
} }
@ -27,3 +42,5 @@ export function buildArguments<T>(
export type SingleUser = number | "current"; export type SingleUser = number | "current";
export type SingleUserOrAll = SingleUser | "all"; export type SingleUserOrAll = SingleUser | "all";
export type Optional<T extends object> = { [K in keyof T]?: T[K] | undefined };

View file

@ -1,14 +1,3 @@
{ {
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json", "extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json",
"references": [
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
},
{
"path": "../struct/tsconfig.build.json"
}
]
} }

View file

@ -5,6 +5,15 @@
}, },
{ {
"path": "./tsconfig.test.json" "path": "./tsconfig.test.json"
},
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
},
{
"path": "../struct/tsconfig.build.json"
} }
] ]
} }

View file

@ -20,7 +20,7 @@ export async function aoaHidRegister(
export async function aoaHidSetReportDescriptor( export async function aoaHidSetReportDescriptor(
device: USBDevice, device: USBDevice,
accessoryId: number, accessoryId: number,
reportDescriptor: Uint8Array<ArrayBuffer>, reportDescriptor: BufferSource,
) { ) {
await device.controlTransferOut( await device.controlTransferOut(
{ {
@ -50,7 +50,7 @@ export async function aoaHidUnregister(device: USBDevice, accessoryId: number) {
export async function aoaHidSendInputReport( export async function aoaHidSendInputReport(
device: USBDevice, device: USBDevice,
accessoryId: number, accessoryId: number,
event: Uint8Array<ArrayBuffer>, event: BufferSource,
) { ) {
await device.controlTransferOut( await device.controlTransferOut(
{ {
@ -80,9 +80,9 @@ export class AoaHidDevice {
static async register( static async register(
device: USBDevice, device: USBDevice,
accessoryId: number, accessoryId: number,
reportDescriptor: Uint8Array<ArrayBuffer>, reportDescriptor: BufferSource,
) { ) {
await aoaHidRegister(device, accessoryId, reportDescriptor.length); await aoaHidRegister(device, accessoryId, reportDescriptor.byteLength);
await aoaHidSetReportDescriptor(device, accessoryId, reportDescriptor); await aoaHidSetReportDescriptor(device, accessoryId, reportDescriptor);
return new AoaHidDevice(device, accessoryId); return new AoaHidDevice(device, accessoryId);
} }
@ -95,7 +95,7 @@ export class AoaHidDevice {
this.#accessoryId = accessoryId; this.#accessoryId = accessoryId;
} }
async sendInputReport(event: Uint8Array<ArrayBuffer>) { async sendInputReport(event: BufferSource) {
await aoaHidSendInputReport(this.#device, this.#accessoryId, event); await aoaHidSendInputReport(this.#device, this.#accessoryId, event);
} }

View file

@ -0,0 +1,16 @@
.rush
# Test
coverage
**/*.spec.ts
**/*.spec.js
**/*.spec.js.map
**/__helpers__
jest.config.js
.eslintrc.cjs
tsconfig.json
tsconfig.test.json
# Logs
*.log

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2025 Simon Chan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,3 @@
# @yume-chan/media-codec
H.264, H.265 and AV1 configuration packet parser

View file

@ -0,0 +1,44 @@
{
"name": "@yume-chan/media-codec",
"version": "2.0.0",
"description": "H.264, H.265 and AV1 configuration packet parser",
"keywords": [
"typescript"
],
"license": "MIT",
"author": {
"name": "Simon Chan",
"email": "cnsimonchan@live.com",
"url": "https://chensi.moe/blog"
},
"homepage": "https://github.com/yume-chan/ya-webadb/tree/main/libraries/media-codec#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/yume-chan/ya-webadb.git",
"directory": "libraries/media-codec"
},
"bugs": {
"url": "https://github.com/yume-chan/ya-webadb/issues"
},
"type": "module",
"main": "esm/index.js",
"types": "esm/index.d.ts",
"sideEffects": false,
"scripts": {
"build": "tsc -b tsconfig.build.json",
"lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4",
"prepublishOnly": "npm run build",
"test": "run-test"
},
"dependencies": {
"@yume-chan/no-data-view": "workspace:^"
},
"devDependencies": {
"@types/node": "^24.3.0",
"@yume-chan/eslint-config": "workspace:^",
"@yume-chan/test-runner": "workspace:^",
"@yume-chan/tsconfig": "workspace:^",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
}
}

View file

@ -6,6 +6,8 @@
// cspell: ignore Smpte // cspell: ignore Smpte
// cspell: ignore Chromat // cspell: ignore Chromat
import { decimalTwoDigits } from "./format.js";
export const AndroidAv1Profile = { export const AndroidAv1Profile = {
Main8: 1 << 0, Main8: 1 << 0,
Main10: 1 << 1, Main10: 1 << 1,
@ -198,6 +200,62 @@ export class Av1 extends BitReader {
static TransferCharacteristics = TransferCharacteristics; static TransferCharacteristics = TransferCharacteristics;
static MatrixCoefficients = MatrixCoefficients; static MatrixCoefficients = MatrixCoefficients;
/**
* Generate a codec string from an AV1 sequence header
* per Section 5 of AV1 Codec ISO Media File Format Binding
* https://aomediacodec.github.io/av1-isobmff/#codecsparam
* @param sequenceHeader The parsed AV1 sequence header
* @returns A codec string
*/
static toCodecString(sequenceHeader: Av1.SequenceHeaderObu) {
const {
seq_profile: seqProfile,
seq_level_idx: [seqLevelIdx = 0],
color_config: {
BitDepth,
mono_chrome: monoChrome,
subsampling_x: subsamplingX,
subsampling_y: subsamplingY,
chroma_sample_position: chromaSamplePosition,
color_description_present_flag,
},
} = sequenceHeader;
let colorPrimaries: Av1.ColorPrimaries;
let transferCharacteristics: Av1.TransferCharacteristics;
let matrixCoefficients: Av1.MatrixCoefficients;
let colorRange: boolean;
if (color_description_present_flag) {
({
color_primaries: colorPrimaries,
transfer_characteristics: transferCharacteristics,
matrix_coefficients: matrixCoefficients,
color_range: colorRange,
} = sequenceHeader.color_config);
} else {
colorPrimaries = Av1.ColorPrimaries.Bt709;
transferCharacteristics = Av1.TransferCharacteristics.Bt709;
matrixCoefficients = Av1.MatrixCoefficients.Bt709;
colorRange = false;
}
return [
"av01",
seqProfile.toString(16),
decimalTwoDigits(seqLevelIdx) +
(sequenceHeader.seq_tier[0] ? "H" : "M"),
decimalTwoDigits(BitDepth),
monoChrome ? "1" : "0",
(subsamplingX ? "1" : "0") +
(subsamplingY ? "1" : "0") +
chromaSamplePosition.toString(),
decimalTwoDigits(colorPrimaries),
decimalTwoDigits(transferCharacteristics),
decimalTwoDigits(matrixCoefficients),
colorRange ? "1" : "0",
].join(".");
}
#Leb128Bytes: number = 0; #Leb128Bytes: number = 0;
uvlc() { uvlc() {

View file

@ -0,0 +1,53 @@
export function hexDigits(value: number) {
if (value % 1 !== 0) {
// This also checks NaN and Infinity
throw new Error("Value must be an integer");
}
if (value < 0) {
throw new Error("Value must be positive");
}
return value.toString(16).toUpperCase();
}
export function hexTwoDigits(value: number) {
if (value % 1 !== 0) {
// This also checks NaN and Infinity
throw new Error("Value must be an integer");
}
if (value < 0) {
throw new Error("Value must be positive");
}
if (value >= 256) {
throw new Error("Value must be less than 256");
}
// Small optimization
if (value < 16) {
return "0" + value.toString(16).toUpperCase();
}
return value.toString(16).toUpperCase();
}
export function decimalTwoDigits(value: number) {
if (value % 1 !== 0) {
// This also checks NaN and Infinity
throw new Error("Value must be an integer");
}
if (value < 0) {
throw new Error("Value must be positive");
}
if (value >= 100) {
throw new Error("Value must be less than 256");
}
if (value < 10) {
return "0" + value.toString(10);
}
return value.toString(10);
}

View file

@ -2,44 +2,9 @@
// cspell: ignore qpprime // cspell: ignore qpprime
// cspell: ignore colour // cspell: ignore colour
import { hexTwoDigits } from "./format.js";
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js"; import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
// From https://developer.android.com/reference/android/media/MediaCodecInfo.CodecProfileLevel
export const AndroidAvcProfile = {
Baseline: 1 << 0,
Main: 1 << 1,
Extended: 1 << 2,
High: 1 << 3,
High10: 1 << 4,
High422: 1 << 5,
High444: 1 << 6,
ConstrainedBaseline: 1 << 16,
ConstrainedHigh: 1 << 19,
};
export const AndroidAvcLevel = {
Level1: 1 << 0,
Level1b: 1 << 1,
Level11: 1 << 2,
Level12: 1 << 3,
Level13: 1 << 4,
Level2: 1 << 5,
Level21: 1 << 6,
Level22: 1 << 7,
Level3: 1 << 8,
Level31: 1 << 9,
Level32: 1 << 10,
Level4: 1 << 11,
Level41: 1 << 12,
Level42: 1 << 13,
Level5: 1 << 14,
Level51: 1 << 15,
Level52: 1 << 16,
Level6: 1 << 17,
Level61: 1 << 18,
Level62: 1 << 19,
};
// H.264 has two standards: ITU-T H.264 and ISO/IEC 14496-10 // H.264 has two standards: ITU-T H.264 and ISO/IEC 14496-10
// they have the same content, and refer themselves as "H.264". // they have the same content, and refer themselves as "H.264".
// The name "AVC" (Advanced Video Coding) is only used in ISO spec name, // The name "AVC" (Advanced Video Coding) is only used in ISO spec name,
@ -49,7 +14,7 @@ export const AndroidAvcLevel = {
// 7.3.2.1.1 Sequence parameter set data syntax // 7.3.2.1.1 Sequence parameter set data syntax
// Variable names in this method uses the snake_case convention as in the spec for easier referencing. // Variable names in this method uses the snake_case convention as in the spec for easier referencing.
export function h264ParseSequenceParameterSet(nalu: Uint8Array) { export function parseSequenceParameterSet(nalu: Uint8Array) {
const reader = new NaluSodbBitReader(nalu); const reader = new NaluSodbBitReader(nalu);
if (reader.next() !== 0) { if (reader.next() !== 0) {
throw new Error("Invalid data"); throw new Error("Invalid data");
@ -218,7 +183,7 @@ export function h264ParseSequenceParameterSet(nalu: Uint8Array) {
* Find Sequence Parameter Set (SPS) and Picture Parameter Set (PPS) * Find Sequence Parameter Set (SPS) and Picture Parameter Set (PPS)
* from H.264 Annex B formatted data. * from H.264 Annex B formatted data.
*/ */
export function h264SearchConfiguration(buffer: Uint8Array) { export function searchConfiguration(buffer: Uint8Array) {
let sequenceParameterSet: Uint8Array | undefined; let sequenceParameterSet: Uint8Array | undefined;
let pictureParameterSet: Uint8Array | undefined; let pictureParameterSet: Uint8Array | undefined;
@ -252,7 +217,7 @@ export function h264SearchConfiguration(buffer: Uint8Array) {
throw new Error("Invalid data"); throw new Error("Invalid data");
} }
export interface H264Configuration { export interface Configuration {
pictureParameterSet: Uint8Array; pictureParameterSet: Uint8Array;
sequenceParameterSet: Uint8Array; sequenceParameterSet: Uint8Array;
@ -271,9 +236,9 @@ export interface H264Configuration {
croppedHeight: number; croppedHeight: number;
} }
export function h264ParseConfiguration(data: Uint8Array): H264Configuration { export function parseConfiguration(data: Uint8Array): Configuration {
const { sequenceParameterSet, pictureParameterSet } = const { sequenceParameterSet, pictureParameterSet } =
h264SearchConfiguration(data); searchConfiguration(data);
const { const {
profile_idc: profileIndex, profile_idc: profileIndex,
@ -286,7 +251,7 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
frame_crop_right_offset, frame_crop_right_offset,
frame_crop_top_offset, frame_crop_top_offset,
frame_crop_bottom_offset, frame_crop_bottom_offset,
} = h264ParseSequenceParameterSet(sequenceParameterSet); } = parseSequenceParameterSet(sequenceParameterSet);
const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16; const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16;
const encodedHeight = const encodedHeight =
@ -315,3 +280,16 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
croppedHeight, croppedHeight,
}; };
} }
export function toCodecString(configuration: Configuration) {
const { profileIndex, constraintSet, levelIndex } = configuration;
// https://www.rfc-editor.org/rfc/rfc6381#section-3.3
// ISO Base Media File Format Name Space
return (
"avc1." +
hexTwoDigits(profileIndex) +
hexTwoDigits(constraintSet) +
hexTwoDigits(levelIndex)
);
}

View file

@ -7,7 +7,7 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { h265ParseSequenceParameterSet } from "./h265.js"; import { parseSequenceParameterSet } from "./h265.js";
describe("h265", () => { describe("h265", () => {
describe("h265ParseSequenceParameterSet", () => { describe("h265ParseSequenceParameterSet", () => {
@ -21,7 +21,7 @@ describe("h265", () => {
0x80, 0x80,
]); ]);
const sps = h265ParseSequenceParameterSet(buffer); const sps = parseSequenceParameterSet(buffer);
assert.deepStrictEqual(sps, { assert.deepStrictEqual(sps, {
sps_video_parameter_set_id: 0, sps_video_parameter_set_id: 0,
@ -355,7 +355,7 @@ describe("h265", () => {
}, },
sps_extension_4bits: 0, sps_extension_4bits: 0,
sps_extension_data_flag: undefined, sps_extension_data_flag: undefined,
} satisfies ReturnType<typeof h265ParseSequenceParameterSet>); } satisfies ReturnType<typeof parseSequenceParameterSet>);
}); });
it("issue #732", () => { it("issue #732", () => {
@ -365,7 +365,7 @@ describe("h265", () => {
151, 43, 182, 64, 151, 43, 182, 64,
]); ]);
const sps = h265ParseSequenceParameterSet(buffer); const sps = parseSequenceParameterSet(buffer);
assert.deepStrictEqual(sps, { assert.deepStrictEqual(sps, {
sps_video_parameter_set_id: 4, sps_video_parameter_set_id: 4,
@ -552,7 +552,7 @@ describe("h265", () => {
spsMultilayerExtension: undefined, spsMultilayerExtension: undefined,
sps3dExtension: undefined, sps3dExtension: undefined,
sps_extension_data_flag: undefined, sps_extension_data_flag: undefined,
} satisfies ReturnType<typeof h265ParseSequenceParameterSet>); } satisfies ReturnType<typeof parseSequenceParameterSet>);
}); });
}); });
}); });

View file

@ -18,45 +18,11 @@
// cspell: ignore sodb // cspell: ignore sodb
// cspell: ignore luma // cspell: ignore luma
import { getUint32LittleEndian } from "@yume-chan/no-data-view";
import { hexDigits } from "./format.js";
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js"; import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
export const AndroidHevcProfile = {
Main: 1 << 0,
Main10: 1 << 1,
MainStill: 1 << 2,
Main10Hdr10: 1 << 12,
Main10Hdr10Plus: 1 << 13,
};
export const AndroidHevcLevel = {
MainTierLevel1: 1 << 0,
HighTierLevel1: 1 << 1,
MainTierLevel2: 1 << 2,
HighTierLevel2: 1 << 3,
MainTierLevel21: 1 << 4,
HighTierLevel21: 1 << 5,
MainTierLevel3: 1 << 6,
HighTierLevel3: 1 << 7,
MainTierLevel31: 1 << 8,
HighTierLevel31: 1 << 9,
MainTierLevel4: 1 << 10,
HighTierLevel4: 1 << 11,
MainTierLevel41: 1 << 12,
HighTierLevel41: 1 << 13,
MainTierLevel5: 1 << 14,
HighTierLevel5: 1 << 15,
MainTierLevel51: 1 << 16,
HighTierLevel51: 1 << 17,
MainTierLevel52: 1 << 18,
HighTierLevel52: 1 << 19,
MainTierLevel6: 1 << 20,
HighTierLevel6: 1 << 21,
MainTierLevel61: 1 << 22,
HighTierLevel61: 1 << 23,
MainTierLevel62: 1 << 24,
HighTierLevel62: 1 << 25,
};
/** /**
* 6.2 Source, decoded and output picture formats * 6.2 Source, decoded and output picture formats
*/ */
@ -92,7 +58,7 @@ export function getSubHeightC(chroma_format_idc: number) {
/** /**
* 7.3.1.1 General NAL unit syntax * 7.3.1.1 General NAL unit syntax
*/ */
export function h265ParseNaluHeader(nalu: Uint8Array) { export function parseNaluHeader(nalu: Uint8Array) {
const reader = new NaluSodbBitReader(nalu); const reader = new NaluSodbBitReader(nalu);
if (reader.next() !== 0) { if (reader.next() !== 0) {
throw new Error("Invalid NALU header"); throw new Error("Invalid NALU header");
@ -109,9 +75,9 @@ export function h265ParseNaluHeader(nalu: Uint8Array) {
}; };
} }
export type H265NaluHeader = ReturnType<typeof h265ParseNaluHeader>; export type NaluHeader = ReturnType<typeof parseNaluHeader>;
export interface H265NaluRaw extends H265NaluHeader { export interface NaluRaw extends NaluHeader {
data: Uint8Array; data: Uint8Array;
rbsp: Uint8Array; rbsp: Uint8Array;
} }
@ -119,7 +85,7 @@ export interface H265NaluRaw extends H265NaluHeader {
/** /**
* 7.3.2.1 Video parameter set RBSP syntax * 7.3.2.1 Video parameter set RBSP syntax
*/ */
export function h265ParseVideoParameterSet(nalu: Uint8Array) { export function parseVideoParameterSet(nalu: Uint8Array) {
const reader = new NaluSodbBitReader(nalu); const reader = new NaluSodbBitReader(nalu);
const vps_video_parameter_set_id = reader.read(4); const vps_video_parameter_set_id = reader.read(4);
@ -130,7 +96,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
const vps_temporal_id_nesting_flag = !!reader.next(); const vps_temporal_id_nesting_flag = !!reader.next();
reader.skip(16); reader.skip(16);
const profileTierLevel = h265ParseProfileTierLevel( const profileTierLevel = parseProfileTierLevel(
reader, reader,
true, true,
vps_max_sub_layers_minus1, vps_max_sub_layers_minus1,
@ -172,7 +138,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
let vps_num_hrd_parameters: number | undefined; let vps_num_hrd_parameters: number | undefined;
let hrd_layer_set_idx: number[] | undefined; let hrd_layer_set_idx: number[] | undefined;
let cprms_present_flag: boolean[] | undefined; let cprms_present_flag: boolean[] | undefined;
let hrdParameters: H265HrdParameters[] | undefined; let hrdParameters: HrdParameters[] | undefined;
if (vps_timing_info_present_flag) { if (vps_timing_info_present_flag) {
vps_num_units_in_tick = reader.read(32); vps_num_units_in_tick = reader.read(32);
vps_time_scale = reader.read(32); vps_time_scale = reader.read(32);
@ -193,7 +159,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
if (i > 0) { if (i > 0) {
cprms_present_flag[i] = !!reader.next(); cprms_present_flag[i] = !!reader.next();
} }
hrdParameters[i] = h265ParseHrdParameters( hrdParameters[i] = parseHrdParameters(
reader, reader,
cprms_present_flag[i]!, cprms_present_flag[i]!,
vps_max_sub_layers_minus1, vps_max_sub_layers_minus1,
@ -232,20 +198,20 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
} }
export type SubLayerHrdParameters = ReturnType< export type SubLayerHrdParameters = ReturnType<
typeof h265ParseSubLayerHrdParameters typeof parseSubLayerHrdParameters
>; >;
/** /**
* 7.3.2.2.1 General sequence parameter set RBSP syntax * 7.3.2.2.1 General sequence parameter set RBSP syntax
*/ */
export function h265ParseSequenceParameterSet(nalu: Uint8Array) { export function parseSequenceParameterSet(nalu: Uint8Array) {
const reader = new NaluSodbBitReader(nalu); const reader = new NaluSodbBitReader(nalu);
const sps_video_parameter_set_id = reader.read(4); const sps_video_parameter_set_id = reader.read(4);
const sps_max_sub_layers_minus1 = reader.read(3); const sps_max_sub_layers_minus1 = reader.read(3);
const sps_temporal_id_nesting_flag = !!reader.next(); const sps_temporal_id_nesting_flag = !!reader.next();
const profileTierLevel = h265ParseProfileTierLevel( const profileTierLevel = parseProfileTierLevel(
reader, reader,
true, true,
sps_max_sub_layers_minus1, sps_max_sub_layers_minus1,
@ -315,7 +281,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
if (scaling_list_enabled_flag) { if (scaling_list_enabled_flag) {
sps_scaling_list_data_present_flag = !!reader.next(); sps_scaling_list_data_present_flag = !!reader.next();
if (sps_scaling_list_data_present_flag) { if (sps_scaling_list_data_present_flag) {
scalingListData = h265ParseScalingListData(reader); scalingListData = parseScalingListData(reader);
} }
} }
@ -340,7 +306,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
const num_short_term_ref_pic_sets = reader.decodeExponentialGolombNumber(); const num_short_term_ref_pic_sets = reader.decodeExponentialGolombNumber();
const shortTermRefPicSets: ShortTermReferencePictureSet[] = []; const shortTermRefPicSets: ShortTermReferencePictureSet[] = [];
for (let i = 0; i < num_short_term_ref_pic_sets; i += 1) { for (let i = 0; i < num_short_term_ref_pic_sets; i += 1) {
shortTermRefPicSets[i] = h265ParseShortTermReferencePictureSet( shortTermRefPicSets[i] = parseShortTermReferencePictureSet(
reader, reader,
i, i,
num_short_term_ref_pic_sets, num_short_term_ref_pic_sets,
@ -367,12 +333,9 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
const sps_temporal_mvp_enabled_flag = !!reader.next(); const sps_temporal_mvp_enabled_flag = !!reader.next();
const strong_intra_smoothing_enabled_flag = !!reader.next(); const strong_intra_smoothing_enabled_flag = !!reader.next();
const vui_parameters_present_flag = !!reader.next(); const vui_parameters_present_flag = !!reader.next();
let vuiParameters: H265VuiParameters | undefined; let vuiParameters: VuiParameters | undefined;
if (vui_parameters_present_flag) { if (vui_parameters_present_flag) {
vuiParameters = h265ParseVuiParameters( vuiParameters = parseVuiParameters(reader, sps_max_sub_layers_minus1);
reader,
sps_max_sub_layers_minus1,
);
} }
const sps_extension_present_flag = !!reader.next(); const sps_extension_present_flag = !!reader.next();
@ -393,14 +356,14 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
let spsMultilayerExtension: H265SpsMultilayerExtension | undefined; let spsMultilayerExtension: SpsMultilayerExtension | undefined;
if (sps_multilayer_extension_flag) { if (sps_multilayer_extension_flag) {
spsMultilayerExtension = h265ParseSpsMultilayerExtension(reader); spsMultilayerExtension = parseSpsMultilayerExtension(reader);
} }
let sps3dExtension: H265Sps3dExtension | undefined; let sps3dExtension: Sps3dExtension | undefined;
if (sps_3d_extension_flag) { if (sps_3d_extension_flag) {
sps3dExtension = h265ParseSps3dExtension(reader); sps3dExtension = parseSps3dExtension(reader);
} }
if (sps_scc_extension_flag) { if (sps_scc_extension_flag) {
@ -484,7 +447,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
* Common part between general_profile_tier_level and * Common part between general_profile_tier_level and
* sub_layer_profile_tier_level * sub_layer_profile_tier_level
*/ */
function h265ParseProfileTier(reader: NaluSodbBitReader) { function parseProfileTier(reader: NaluSodbBitReader) {
const profile_space = reader.read(2); const profile_space = reader.read(2);
const tier_flag = !!reader.next(); const tier_flag = !!reader.next();
const profile_idc = reader.read(5); const profile_idc = reader.read(5);
@ -609,43 +572,43 @@ function h265ParseProfileTier(reader: NaluSodbBitReader) {
}; };
} }
export type H265ProfileTier = ReturnType<typeof h265ParseProfileTier>; export type ProfileTier = ReturnType<typeof parseProfileTier>;
export interface H265ProfileTierLevel { export interface ProfileTierLevel {
generalProfileTier: H265ProfileTier | undefined; generalProfileTier: ProfileTier | undefined;
general_level_idc: number; general_level_idc: number;
sub_layer_profile_present_flag: boolean[]; sub_layer_profile_present_flag: boolean[];
sub_layer_level_present_flag: boolean[]; sub_layer_level_present_flag: boolean[];
subLayerProfileTier: H265ProfileTier[]; subLayerProfileTier: ProfileTier[];
sub_layer_level_idc: number[]; sub_layer_level_idc: number[];
} }
/** /**
* 7.3.3 Profile, tier and level syntax * 7.3.3 Profile, tier and level syntax
*/ */
function h265ParseProfileTierLevel( function parseProfileTierLevel(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
profilePresentFlag: true, profilePresentFlag: true,
maxNumSubLayersMinus1: number, maxNumSubLayersMinus1: number,
): H265ProfileTierLevel & { generalProfileTier: H265ProfileTier }; ): ProfileTierLevel & { generalProfileTier: ProfileTier };
function h265ParseProfileTierLevel( function parseProfileTierLevel(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
profilePresentFlag: false, profilePresentFlag: false,
maxNumSubLayersMinus1: number, maxNumSubLayersMinus1: number,
): H265ProfileTierLevel & { generalProfileTier: undefined }; ): ProfileTierLevel & { generalProfileTier: undefined };
function h265ParseProfileTierLevel( function parseProfileTierLevel(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
profilePresentFlag: boolean, profilePresentFlag: boolean,
maxNumSubLayersMinus1: number, maxNumSubLayersMinus1: number,
): H265ProfileTierLevel; ): ProfileTierLevel;
function h265ParseProfileTierLevel( function parseProfileTierLevel(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
profilePresentFlag: boolean, profilePresentFlag: boolean,
maxNumSubLayersMinus1: number, maxNumSubLayersMinus1: number,
): H265ProfileTierLevel { ): ProfileTierLevel {
let generalProfileTier: H265ProfileTier | undefined; let generalProfileTier: ProfileTier | undefined;
if (profilePresentFlag) { if (profilePresentFlag) {
generalProfileTier = h265ParseProfileTier(reader); generalProfileTier = parseProfileTier(reader);
} }
const general_level_idc = reader.read(8); const general_level_idc = reader.read(8);
@ -663,11 +626,11 @@ function h265ParseProfileTierLevel(
} }
} }
const subLayerProfileTier: H265ProfileTier[] = []; const subLayerProfileTier: ProfileTier[] = [];
const sub_layer_level_idc: number[] = []; const sub_layer_level_idc: number[] = [];
for (let i = 0; i < maxNumSubLayersMinus1; i += 1) { for (let i = 0; i < maxNumSubLayersMinus1; i += 1) {
if (sub_layer_profile_present_flag[i]) { if (sub_layer_profile_present_flag[i]) {
subLayerProfileTier[i] = h265ParseProfileTier(reader); subLayerProfileTier[i] = parseProfileTier(reader);
} }
if (sub_layer_level_present_flag[i]) { if (sub_layer_level_present_flag[i]) {
sub_layer_level_idc[i] = reader.read(8); sub_layer_level_idc[i] = reader.read(8);
@ -687,7 +650,7 @@ function h265ParseProfileTierLevel(
/** /**
* 7.3.4 Scaling list data syntax * 7.3.4 Scaling list data syntax
*/ */
export function h265ParseScalingListData(reader: NaluSodbBitReader) { export function parseScalingListData(reader: NaluSodbBitReader) {
const scaling_list: number[][][] = []; const scaling_list: number[][][] = [];
for (let sizeId = 0; sizeId < 4; sizeId += 1) { for (let sizeId = 0; sizeId < 4; sizeId += 1) {
scaling_list[sizeId] = []; scaling_list[sizeId] = [];
@ -737,7 +700,7 @@ interface ShortTermReferencePictureSet {
/** /**
* 7.3.7 Short-term reference picture set syntax * 7.3.7 Short-term reference picture set syntax
*/ */
export function h265ParseShortTermReferencePictureSet( export function parseShortTermReferencePictureSet(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
stRpsIdx: number, stRpsIdx: number,
num_short_term_ref_pic_sets: number, num_short_term_ref_pic_sets: number,
@ -896,7 +859,7 @@ export function h265ParseShortTermReferencePictureSet(
}; };
} }
export const H265AspectRatioIndicator = { export const AspectRatioIndicator = {
Unspecified: 0, Unspecified: 0,
Square: 1, Square: 1,
_12_11: 2, _12_11: 2,
@ -917,23 +880,23 @@ export const H265AspectRatioIndicator = {
Extended: 255, Extended: 255,
} as const; } as const;
export type H265AspectRatioIndicator = export type AspectRatioIndicator =
(typeof H265AspectRatioIndicator)[keyof typeof H265AspectRatioIndicator]; (typeof AspectRatioIndicator)[keyof typeof AspectRatioIndicator];
/** /**
* E.2.1 VUI parameters syntax * E.2.1 VUI parameters syntax
*/ */
export function h265ParseVuiParameters( export function parseVuiParameters(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
sps_max_sub_layers_minus1: number, sps_max_sub_layers_minus1: number,
) { ) {
const aspect_ratio_info_present_flag = !!reader.next(); const aspect_ratio_info_present_flag = !!reader.next();
let aspect_ratio_idc: H265AspectRatioIndicator | undefined; let aspect_ratio_idc: AspectRatioIndicator | undefined;
let sar_width: number | undefined; let sar_width: number | undefined;
let sar_height: number | undefined; let sar_height: number | undefined;
if (aspect_ratio_info_present_flag) { if (aspect_ratio_info_present_flag) {
aspect_ratio_idc = reader.read(8) as H265AspectRatioIndicator; aspect_ratio_idc = reader.read(8) as AspectRatioIndicator;
if (aspect_ratio_idc === H265AspectRatioIndicator.Extended) { if (aspect_ratio_idc === AspectRatioIndicator.Extended) {
sar_width = reader.read(16); sar_width = reader.read(16);
sar_height = reader.read(16); sar_height = reader.read(16);
} }
@ -995,7 +958,7 @@ export function h265ParseVuiParameters(
let vui_poc_proportional_to_timing_flag: boolean | undefined; let vui_poc_proportional_to_timing_flag: boolean | undefined;
let vui_num_ticks_poc_diff_one_minus1: number | undefined; let vui_num_ticks_poc_diff_one_minus1: number | undefined;
let vui_hrd_parameters_present_flag: boolean | undefined; let vui_hrd_parameters_present_flag: boolean | undefined;
let vui_hrd_parameters: H265HrdParameters | undefined; let vui_hrd_parameters: HrdParameters | undefined;
if (vui_timing_info_present_flag) { if (vui_timing_info_present_flag) {
vui_num_units_in_tick = reader.read(32); vui_num_units_in_tick = reader.read(32);
vui_time_scale = reader.read(32); vui_time_scale = reader.read(32);
@ -1006,7 +969,7 @@ export function h265ParseVuiParameters(
} }
vui_hrd_parameters_present_flag = !!reader.next(); vui_hrd_parameters_present_flag = !!reader.next();
if (vui_hrd_parameters_present_flag) { if (vui_hrd_parameters_present_flag) {
vui_hrd_parameters = h265ParseHrdParameters( vui_hrd_parameters = parseHrdParameters(
reader, reader,
true, true,
sps_max_sub_layers_minus1, sps_max_sub_layers_minus1,
@ -1085,12 +1048,12 @@ export function h265ParseVuiParameters(
}; };
} }
export type H265VuiParameters = ReturnType<typeof h265ParseVuiParameters>; export type VuiParameters = ReturnType<typeof parseVuiParameters>;
/** /**
* E.2.2 HRD parameters syntax * E.2.2 HRD parameters syntax
*/ */
export function h265ParseHrdParameters( export function parseHrdParameters(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
commonInfPresentFlag: boolean, commonInfPresentFlag: boolean,
maxNumSubLayersMinus1: number, maxNumSubLayersMinus1: number,
@ -1157,14 +1120,14 @@ export function h265ParseHrdParameters(
} }
if (nal_hrd_parameters_present_flag) { if (nal_hrd_parameters_present_flag) {
nalHrdParameters[i] = h265ParseSubLayerHrdParameters( nalHrdParameters[i] = parseSubLayerHrdParameters(
reader, reader,
i, i,
getCpbCnt(cpb_cnt_minus1[i]!), getCpbCnt(cpb_cnt_minus1[i]!),
); );
} }
if (vcl_hrd_parameters_present_flag) { if (vcl_hrd_parameters_present_flag) {
vclHrdParameters[i] = h265ParseSubLayerHrdParameters( vclHrdParameters[i] = parseSubLayerHrdParameters(
reader, reader,
i, i,
getCpbCnt(cpb_cnt_minus1[i]!), getCpbCnt(cpb_cnt_minus1[i]!),
@ -1196,12 +1159,12 @@ export function h265ParseHrdParameters(
}; };
} }
export type H265HrdParameters = ReturnType<typeof h265ParseHrdParameters>; export type HrdParameters = ReturnType<typeof parseHrdParameters>;
/** /**
* E.2.3 Sub-layer HRD parameters syntax * E.2.3 Sub-layer HRD parameters syntax
*/ */
export function h265ParseSubLayerHrdParameters( export function parseSubLayerHrdParameters(
reader: NaluSodbBitReader, reader: NaluSodbBitReader,
subLayerId: number, subLayerId: number,
CpbCnt: number, CpbCnt: number,
@ -1234,15 +1197,15 @@ function getCpbCnt(cpb_cnt_minus_1: number) {
return cpb_cnt_minus_1 + 1; return cpb_cnt_minus_1 + 1;
} }
export function h265SearchConfiguration(buffer: Uint8Array) { export function searchConfiguration(buffer: Uint8Array) {
let videoParameterSet!: H265NaluRaw; let videoParameterSet!: NaluRaw;
let sequenceParameterSet!: H265NaluRaw; let sequenceParameterSet!: NaluRaw;
let pictureParameterSet!: H265NaluRaw; let pictureParameterSet!: NaluRaw;
let count = 0; let count = 0;
for (const nalu of annexBSplitNalu(buffer)) { for (const nalu of annexBSplitNalu(buffer)) {
const header = h265ParseNaluHeader(nalu); const header = parseNaluHeader(nalu);
const raw: H265NaluRaw = { const raw: NaluRaw = {
...header, ...header,
data: nalu, data: nalu,
rbsp: nalu.subarray(2), rbsp: nalu.subarray(2),
@ -1274,18 +1237,18 @@ export function h265SearchConfiguration(buffer: Uint8Array) {
throw new Error("Invalid data"); throw new Error("Invalid data");
} }
export function h265ParseSpsMultilayerExtension(reader: NaluSodbBitReader) { export function parseSpsMultilayerExtension(reader: NaluSodbBitReader) {
const inter_view_mv_vert_constraint_flag = !!reader.next(); const inter_view_mv_vert_constraint_flag = !!reader.next();
return { return {
inter_view_mv_vert_constraint_flag, inter_view_mv_vert_constraint_flag,
}; };
} }
export type H265SpsMultilayerExtension = ReturnType< export type SpsMultilayerExtension = ReturnType<
typeof h265ParseSpsMultilayerExtension typeof parseSpsMultilayerExtension
>; >;
export function h265ParseSps3dExtension(reader: NaluSodbBitReader) { export function parseSps3dExtension(reader: NaluSodbBitReader) {
const iv_di_mc_enabled_flag: boolean[] = []; const iv_di_mc_enabled_flag: boolean[] = [];
const iv_mv_scal_enabled_flag: boolean[] = []; const iv_mv_scal_enabled_flag: boolean[] = [];
@ -1328,12 +1291,12 @@ export function h265ParseSps3dExtension(reader: NaluSodbBitReader) {
}; };
} }
export type H265Sps3dExtension = ReturnType<typeof h265ParseSps3dExtension>; export type Sps3dExtension = ReturnType<typeof parseSps3dExtension>;
export interface H265Configuration { export interface Configuration {
videoParameterSet: H265NaluRaw; videoParameterSet: NaluRaw;
sequenceParameterSet: H265NaluRaw; sequenceParameterSet: NaluRaw;
pictureParameterSet: H265NaluRaw; pictureParameterSet: NaluRaw;
generalProfileSpace: number; generalProfileSpace: number;
generalProfileIndex: number; generalProfileIndex: number;
@ -1353,9 +1316,9 @@ export interface H265Configuration {
croppedHeight: number; croppedHeight: number;
} }
export function h265ParseConfiguration(data: Uint8Array): H265Configuration { export function parseConfiguration(data: Uint8Array): Configuration {
const { videoParameterSet, sequenceParameterSet, pictureParameterSet } = const { videoParameterSet, sequenceParameterSet, pictureParameterSet } =
h265SearchConfiguration(data); searchConfiguration(data);
const { const {
profileTierLevel: { profileTierLevel: {
@ -1368,7 +1331,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
}, },
general_level_idc: generalLevelIndex, general_level_idc: generalLevelIndex,
}, },
} = h265ParseVideoParameterSet(videoParameterSet.rbsp); } = parseVideoParameterSet(videoParameterSet.rbsp);
const { const {
chroma_format_idc, chroma_format_idc,
@ -1378,7 +1341,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
conf_win_right_offset: cropRight = 0, conf_win_right_offset: cropRight = 0,
conf_win_top_offset: cropTop = 0, conf_win_top_offset: cropTop = 0,
conf_win_bottom_offset: cropBottom = 0, conf_win_bottom_offset: cropBottom = 0,
} = h265ParseSequenceParameterSet(sequenceParameterSet.rbsp); } = parseSequenceParameterSet(sequenceParameterSet.rbsp);
const SubWidthC = getSubWidthC(chroma_format_idc); const SubWidthC = getSubWidthC(chroma_format_idc);
const SubHeightC = getSubHeightC(chroma_format_idc); const SubHeightC = getSubHeightC(chroma_format_idc);
@ -1408,3 +1371,23 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
croppedHeight, croppedHeight,
}; };
} }
export function toCodecString(configuration: Configuration) {
const {
generalProfileSpace,
generalProfileIndex,
generalProfileCompatibilitySet,
generalTierFlag,
generalLevelIndex,
generalConstraintSet,
} = configuration;
return [
"hev1",
["", "A", "B", "C"][generalProfileSpace]! +
generalProfileIndex.toString(),
hexDigits(getUint32LittleEndian(generalProfileCompatibilitySet, 0)),
(generalTierFlag ? "H" : "L") + generalLevelIndex.toString(),
...Array.from(generalConstraintSet, hexDigits),
].join(".");
}

View file

@ -0,0 +1,4 @@
export * from "./av1.js";
export * as H264 from "./h264.js";
export * as H265 from "./h265.js";
export * from "./nalu.js";

View file

@ -0,0 +1,3 @@
{
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json"
}

View file

@ -0,0 +1,13 @@
{
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.test.json"
},
{
"path": "../no-data-view/tsconfig.build.json"
},
]
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"types": [
"node"
],
},
"exclude": []
}

View file

@ -35,6 +35,7 @@
"dependencies": { "dependencies": {
"@yume-chan/async": "^4.1.3", "@yume-chan/async": "^4.1.3",
"@yume-chan/event": "workspace:^", "@yume-chan/event": "workspace:^",
"@yume-chan/media-codec": "workspace:^",
"@yume-chan/scrcpy": "workspace:^", "@yume-chan/scrcpy": "workspace:^",
"@yume-chan/stream-extra": "workspace:^", "@yume-chan/stream-extra": "workspace:^",
"tinyh264": "^0.0.7", "tinyh264": "^0.0.7",

View file

@ -1,10 +1,13 @@
import { PromiseResolver } from "@yume-chan/async"; import { PromiseResolver } from "@yume-chan/async";
import { StickyEventEmitter } from "@yume-chan/event"; import { H264 } from "@yume-chan/media-codec";
import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy"; import type {
ScrcpyMediaStreamConfigurationPacket,
ScrcpyMediaStreamPacket,
} from "@yume-chan/scrcpy";
import { import {
AndroidAvcLevel, AndroidAvcLevel,
AndroidAvcProfile, AndroidAvcProfile,
h264ParseConfiguration, ScrcpyVideoSizeImpl,
} from "@yume-chan/scrcpy"; } from "@yume-chan/scrcpy";
import { WritableStream } from "@yume-chan/stream-extra"; import { WritableStream } from "@yume-chan/stream-extra";
import YuvBuffer from "yuv-buffer"; import YuvBuffer from "yuv-buffer";
@ -14,6 +17,9 @@ import type {
ScrcpyVideoDecoder, ScrcpyVideoDecoder,
ScrcpyVideoDecoderCapability, ScrcpyVideoDecoderCapability,
} from "./types.js"; } from "./types.js";
import { createCanvas, glIsSupported } from "./utils/index.js";
import { PauseControllerImpl } from "./utils/pause.js";
import { PerformanceCounterImpl } from "./utils/performance.js";
import type { TinyH264Wrapper } from "./wrapper.js"; import type { TinyH264Wrapper } from "./wrapper.js";
import { createTinyH264Wrapper } from "./wrapper.js"; import { createTinyH264Wrapper } from "./wrapper.js";
@ -21,16 +27,6 @@ const noop = () => {
// no-op // no-op
}; };
export function createCanvas() {
if (typeof document !== "undefined") {
return document.createElement("canvas");
}
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(1, 1);
}
throw new Error("no canvas input found nor any canvas can be created");
}
export class TinyH264Decoder implements ScrcpyVideoDecoder { export class TinyH264Decoder implements ScrcpyVideoDecoder {
static readonly capabilities: Record<string, ScrcpyVideoDecoderCapability> = static readonly capabilities: Record<string, ScrcpyVideoDecoderCapability> =
{ {
@ -40,34 +36,36 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
}, },
}; };
#renderer: HTMLCanvasElement | OffscreenCanvas; #canvas: HTMLCanvasElement | OffscreenCanvas;
get renderer() { get canvas() {
return this.#renderer; return this.#canvas;
} }
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>(); #size = new ScrcpyVideoSizeImpl();
get sizeChanged() {
return this.#sizeChanged.event;
}
#width: number = 0;
get width() { get width() {
return this.#width; return this.#size.width;
} }
#height: number = 0;
get height() { get height() {
return this.#height; return this.#size.height;
}
get sizeChanged() {
return this.#size.sizeChanged;
} }
#frameRendered = 0; #counter = new PerformanceCounterImpl();
get framesRendered() { get framesDrawn() {
return this.#frameRendered; return this.#counter.framesDrawn;
}
get framesPresented() {
return this.#counter.framesPresented;
} }
#frameSkipped = 0;
get framesSkipped() { get framesSkipped() {
return this.#frameSkipped; return this.#counter.framesSkipped;
}
#pause: PauseControllerImpl;
get paused() {
return this.#pause.paused;
} }
#writable: WritableStream<ScrcpyMediaStreamPacket>; #writable: WritableStream<ScrcpyMediaStreamPacket>;
@ -75,118 +73,165 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
return this.#writable; return this.#writable;
} }
#yuvCanvas: YuvCanvas | undefined; #renderer: YuvCanvas | undefined;
#initializer: PromiseResolver<TinyH264Wrapper> | undefined; #decoder: Promise<TinyH264Wrapper> | undefined;
constructor({ canvas }: TinyH264Decoder.Options = {}) { constructor({ canvas }: TinyH264Decoder.Options = {}) {
if (canvas) { if (canvas) {
this.#renderer = canvas; this.#canvas = canvas;
} else { } else {
this.#renderer = createCanvas(); this.#canvas = createCanvas();
} }
this.#writable = new WritableStream<ScrcpyMediaStreamPacket>({ this.#renderer = YuvCanvas.attach(this.#canvas, {
write: async (packet) => { // yuv-canvas supports detecting WebGL support by creating a <canvas> itself
switch (packet.type) { // But this doesn't work in Web Worker (with OffscreenCanvas)
case "configuration": // so we implement our own check here
await this.#configure(packet.data); webGL: glIsSupported({
break; // Disallow software rendering.
case "data": { // yuv-canvas also supports 2d canvas
if (!this.#initializer) { // which is faster than software-based WebGL.
throw new Error("Decoder not configured"); failIfMajorPerformanceCaveat: true,
} }),
});
const wrapper = await this.#initializer.promise; this.#pause = new PauseControllerImpl(
wrapper.feed(packet.data.slice().buffer); this.#configure,
break; async (packet) => {
} if (!this.#decoder) {
throw new Error("Decoder not configured");
} }
// TinyH264 decoder doesn't support associating metadata
// with each frame's input/output
// so skipping frames when resuming from pause is not supported
const decoder = await this.#decoder;
// `packet.data` might be from a `BufferCombiner` so we have to copy it using `slice`
decoder.feed(packet.data.slice().buffer);
}, },
);
this.#writable = new WritableStream<ScrcpyMediaStreamPacket>({
write: this.#pause.write,
// Nothing can be disposed when the stream is aborted/closed
// No new frames will arrive, but some frames might still be decoding and/or rendering
}); });
} }
async #configure(data: Uint8Array) { #configure = async ({
this.dispose(); data,
}: ScrcpyMediaStreamConfigurationPacket): Promise<undefined> => {
this.#disposeDecoder();
this.#initializer = new PromiseResolver<TinyH264Wrapper>(); const resolver = new PromiseResolver<TinyH264Wrapper>();
if (!this.#yuvCanvas) { this.#decoder = resolver.promise;
// yuv-canvas detects WebGL support by creating a <canvas> itself
// not working in worker try {
const canvas = createCanvas(); const {
const attributes: WebGLContextAttributes = { encodedWidth,
// Disallow software rendering. encodedHeight,
// Other rendering methods are faster than software-based WebGL. croppedWidth,
failIfMajorPerformanceCaveat: true, croppedHeight,
}; cropLeft,
const gl = cropTop,
canvas.getContext("webgl2", attributes) || } = H264.parseConfiguration(data);
canvas.getContext("webgl", attributes);
this.#yuvCanvas = YuvCanvas.attach(this.#renderer, { this.#size.setSize(croppedWidth, croppedHeight);
webGL: !!gl,
// H.264 Baseline profile only supports YUV 420 pixel format
// So chroma width/height is each half of video width/height
const chromaWidth = encodedWidth / 2;
const chromaHeight = encodedHeight / 2;
// YUVCanvas will set canvas size when format changes
const format = YuvBuffer.format({
width: encodedWidth,
height: encodedHeight,
chromaWidth,
chromaHeight,
cropLeft: cropLeft,
cropTop: cropTop,
cropWidth: croppedWidth,
cropHeight: croppedHeight,
displayWidth: croppedWidth,
displayHeight: croppedHeight,
}); });
const decoder = await createTinyH264Wrapper();
const uPlaneOffset = encodedWidth * encodedHeight;
const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight;
decoder.onPictureReady(({ data }) => {
const array = new Uint8Array(data);
const frame = YuvBuffer.frame(
format,
YuvBuffer.lumaPlane(format, array, encodedWidth, 0),
YuvBuffer.chromaPlane(
format,
array,
chromaWidth,
uPlaneOffset,
),
YuvBuffer.chromaPlane(
format,
array,
chromaWidth,
vPlaneOffset,
),
);
// Can't know if yuv-canvas is dropping frames or not
this.#renderer!.drawFrame(frame);
this.#counter.increaseFramesDrawn();
});
decoder.feed(data.slice().buffer);
resolver.resolve(decoder);
} catch (e) {
resolver.reject(e);
}
};
pause(): void {
this.#pause.pause();
}
resume(): Promise<undefined> {
return this.#pause.resume();
}
/**
* Only dispose the TinyH264 decoder instance.
*
* This will be called when re-configuring multiple times,
* we don't want to dispose other parts (e.g. `#counter`) on that case
*/
#disposeDecoder() {
if (!this.#decoder) {
return;
} }
const { this.#decoder
encodedWidth, .then((decoder) => decoder.dispose())
encodedHeight, // NOOP: It's disposed so nobody cares about the error
croppedWidth, .catch(noop);
croppedHeight, this.#decoder = undefined;
cropLeft,
cropTop,
} = h264ParseConfiguration(data);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({
width: croppedWidth,
height: croppedHeight,
});
// H.264 Baseline profile only supports YUV 420 pixel format
// So chroma width/height is each half of video width/height
const chromaWidth = encodedWidth / 2;
const chromaHeight = encodedHeight / 2;
// YUVCanvas will set canvas size when format changes
const format = YuvBuffer.format({
width: encodedWidth,
height: encodedHeight,
chromaWidth,
chromaHeight,
cropLeft: cropLeft,
cropTop: cropTop,
cropWidth: croppedWidth,
cropHeight: croppedHeight,
displayWidth: croppedWidth,
displayHeight: croppedHeight,
});
const wrapper = await createTinyH264Wrapper();
this.#initializer.resolve(wrapper);
const uPlaneOffset = encodedWidth * encodedHeight;
const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight;
wrapper.onPictureReady(({ data }) => {
this.#frameRendered += 1;
const array = new Uint8Array(data);
const frame = YuvBuffer.frame(
format,
YuvBuffer.lumaPlane(format, array, encodedWidth, 0),
YuvBuffer.chromaPlane(format, array, chromaWidth, uPlaneOffset),
YuvBuffer.chromaPlane(format, array, chromaWidth, vPlaneOffset),
);
this.#yuvCanvas!.drawFrame(frame);
});
wrapper.feed(data.slice().buffer);
} }
dispose(): void { dispose(): void {
this.#initializer?.promise // This class doesn't need to guard against multiple dispose calls
.then((wrapper) => wrapper.dispose()) // since most of the logic is already handled in `#pause`
// NOOP: It's disposed so nobody cares about the error this.#pause.dispose();
.catch(noop);
this.#initializer = undefined; this.#disposeDecoder();
this.#counter.dispose();
this.#size.dispose();
this.#canvas.width = 0;
this.#canvas.height = 0;
} }
} }

View file

@ -1,3 +1,4 @@
export * from "./decoder.js"; export * from "./decoder.js";
export * from "./types.js"; export * from "./types.js";
export * from "./utils/index.js";
export * from "./wrapper.js"; export * from "./wrapper.js";

View file

@ -1,7 +1,8 @@
import type { Disposable, Event } from "@yume-chan/event"; import type { Disposable } from "@yume-chan/event";
import type { import type {
ScrcpyMediaStreamPacket, ScrcpyMediaStreamPacket,
ScrcpyVideoCodecId, ScrcpyVideoCodecId,
ScrcpyVideoSize,
} from "@yume-chan/scrcpy"; } from "@yume-chan/scrcpy";
import type { WritableStream } from "@yume-chan/stream-extra"; import type { WritableStream } from "@yume-chan/stream-extra";
@ -10,14 +11,37 @@ export interface ScrcpyVideoDecoderCapability {
maxLevel?: number; maxLevel?: number;
} }
export interface ScrcpyVideoDecoder extends Disposable { export interface ScrcpyVideoDecoderPerformanceCounter {
readonly sizeChanged: Event<{ width: number; height: number }>; /**
readonly width: number; * Gets the number of frames that have been drawn on the renderer
readonly height: number; */
readonly framesDrawn: number;
readonly framesRendered: number; /**
* Gets the number of frames that's visible to the user
*
* Might be `0` if the renderer is in a nested Web Worker on Chrome due to a Chrome bug.
* https://issues.chromium.org/issues/41483010
*/
readonly framesPresented: number;
/**
* Gets the number of frames that wasn't drawn on the renderer
* because the renderer can't keep up
*/
readonly framesSkipped: number; readonly framesSkipped: number;
}
export interface ScrcpyVideoDecoderPauseController {
readonly paused: boolean;
pause(): void;
resume(): Promise<undefined>;
}
export interface ScrcpyVideoDecoder
extends ScrcpyVideoDecoderPerformanceCounter,
ScrcpyVideoDecoderPauseController,
ScrcpyVideoSize,
Disposable {
readonly writable: WritableStream<ScrcpyMediaStreamPacket>; readonly writable: WritableStream<ScrcpyMediaStreamPacket>;
} }

View file

@ -0,0 +1,78 @@
export function createCanvas() {
if (typeof document !== "undefined") {
return document.createElement("canvas");
}
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(1, 1);
}
throw new Error("no canvas input found nor any canvas can be created");
}
export function glCreateContext(
canvas: HTMLCanvasElement | OffscreenCanvas,
attributes?: WebGLContextAttributes,
): WebGLRenderingContext | WebGL2RenderingContext | null {
// `HTMLCanvasElement.getContext` returns `null` for unsupported `contextId`,
// but `OffscreenCanvas.getContext` will throw an error,
// so `try...catch...` is required
try {
const context = canvas.getContext(
"webgl2",
attributes,
) as WebGL2RenderingContext | null;
if (context) {
return context;
}
} catch {
// ignore
}
try {
const context = canvas.getContext(
"webgl",
attributes,
) as WebGLRenderingContext | null;
if (context) {
return context;
}
} catch {
// ignore
}
// Support very old browsers just in case
// `OffscreenCanvas` doesn't support `experimental-webgl`
if (canvas instanceof HTMLCanvasElement) {
const context = canvas.getContext(
"experimental-webgl",
attributes,
) as WebGLRenderingContext | null;
if (context) {
return context;
}
}
return null;
}
export function glLoseContext(context: WebGLRenderingContext) {
try {
context.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
// ignore
}
}
export function glIsSupported(attributes?: WebGLContextAttributes): boolean {
const canvas = createCanvas();
const gl = glCreateContext(canvas, attributes);
if (gl) {
glLoseContext(gl);
}
return !!gl;
}

View file

@ -0,0 +1,3 @@
export * from "./gl.js";
export * from "./pause.js";
export * from "./performance.js";

View file

@ -0,0 +1,167 @@
import type { MaybePromiseLike } from "@yume-chan/async";
import { PromiseResolver } from "@yume-chan/async";
import type {
ScrcpyMediaStreamConfigurationPacket,
ScrcpyMediaStreamDataPacket,
ScrcpyMediaStreamPacket,
} from "@yume-chan/scrcpy";
import type { ScrcpyVideoDecoderPauseController } from "../types.js";
export class PauseControllerImpl implements ScrcpyVideoDecoderPauseController {
#paused = false;
get paused() {
return this.#paused;
}
#onConfiguration: (
packet: ScrcpyMediaStreamConfigurationPacket,
) => MaybePromiseLike<undefined>;
#onFrame: (
packet: ScrcpyMediaStreamDataPacket,
skipRendering: boolean,
) => MaybePromiseLike<undefined>;
/**
* Store incoming configuration change when paused,
* to recreate the decoder on resume
*/
#pendingConfiguration: ScrcpyMediaStreamConfigurationPacket | undefined;
/**
* Store incoming frames when paused, so the latest frame can be rendered on resume
* Because non-key frames require their previous frames to be decoded,
* we need to store several frames.
*
* There can be two situations:
*
* 1. **All pending frames are non-key frames:**
* the decoder still holds the previous frames to decode them directly.
*
* 2. **A keyframe is encountered while pausing:**
* The list is cleared before pushing the keyframe.
* The decoder can start decoding from the keyframe directly.
*/
#pendingFrames: ScrcpyMediaStreamDataPacket[] = [];
/** Block incoming frames while resuming */
#resuming: Promise<undefined> | undefined;
#disposed = false;
constructor(
onConfiguration: (
packet: ScrcpyMediaStreamConfigurationPacket,
) => MaybePromiseLike<undefined>,
onFrame: (
packet: ScrcpyMediaStreamDataPacket,
skipRendering: boolean,
) => MaybePromiseLike<undefined>,
) {
this.#onConfiguration = onConfiguration;
this.#onFrame = onFrame;
}
write = async (packet: ScrcpyMediaStreamPacket): Promise<undefined> => {
if (this.#disposed) {
throw new Error("Attempt to write to a closed decoder");
}
if (this.#paused) {
switch (packet.type) {
case "configuration":
this.#pendingConfiguration = packet;
this.#pendingFrames.length = 0;
break;
case "data":
if (packet.keyframe) {
this.#pendingFrames.length = 0;
}
// Generally there won't be too many non-key frames
// (because that's bad for video quality),
// Also all frames are required for proper decoding
this.#pendingFrames.push(packet);
break;
}
return;
}
await this.#resuming;
if (this.#disposed) {
return;
}
switch (packet.type) {
case "configuration":
await this.#onConfiguration(packet);
break;
case "data":
await this.#onFrame(packet, false);
break;
}
};
pause(): void {
if (this.#disposed) {
throw new Error("Attempt to pause a closed decoder");
}
this.#paused = true;
}
async resume(): Promise<undefined> {
if (this.#disposed) {
throw new Error("Attempt to resume a closed decoder");
}
if (!this.#paused) {
return;
}
const resolver = new PromiseResolver<undefined>();
this.#resuming = resolver.promise;
this.#paused = false;
if (this.#pendingConfiguration) {
await this.#onConfiguration(this.#pendingConfiguration);
this.#pendingConfiguration = undefined;
if (this.#disposed) {
return;
}
}
for (
let i = 0, length = this.#pendingFrames.length;
i < length;
i += 1
) {
const frame = this.#pendingFrames[i]!;
// All pending frames except the last one don't need to be rendered
// because they are decoded in quick succession by the decoder
// and won't be visible
await this.#onFrame(frame, i !== length - 1);
if (this.#disposed) {
return;
}
}
this.#pendingFrames.length = 0;
resolver.resolve(undefined);
this.#resuming = undefined;
}
dispose() {
if (this.#disposed) {
return;
}
this.#disposed = true;
this.#pendingConfiguration = undefined;
this.#pendingFrames.length = 0;
}
}

View file

@ -0,0 +1,66 @@
import type { ScrcpyVideoDecoderPerformanceCounter } from "../types.js";
export class PerformanceCounterImpl
implements ScrcpyVideoDecoderPerformanceCounter
{
#framesDrawn = 0;
get framesDrawn() {
return this.#framesDrawn;
}
#framesPresented = 0;
get framesPresented() {
return this.#framesPresented;
}
#framesSkipped = 0;
get framesSkipped() {
return this.#framesSkipped;
}
#animationFrameId: number | undefined;
constructor() {
// `requestAnimationFrame` is available in Web Worker
// https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame
try {
this.#animationFrameId = requestAnimationFrame(
this.#handleAnimationFrame,
);
} catch {
// Chrome has a bug that `requestAnimationFrame` doesn't work in nested Workers
// https://issues.chromium.org/issues/41483010
// Because we need actual vertical sync to count presented frames,
// `setTimeout` with a fixed delay also doesn't work.
// In this case just leave `framesPresented` at `0`
}
}
#handleAnimationFrame = () => {
// Animation frame handler is called on every vertical sync interval.
// Only then a frame is visible to the user.
if (this.#framesDrawn > 0) {
this.#framesPresented += 1;
this.#framesDrawn = 0;
}
this.#animationFrameId = requestAnimationFrame(
this.#handleAnimationFrame,
);
};
increaseFramesSkipped() {
this.#framesSkipped += 1;
}
increaseFramesDrawn() {
this.#framesDrawn += 1;
}
dispose() {
// `0` is a valid value for RAF ID
if (this.#animationFrameId !== undefined) {
cancelAnimationFrame(this.#animationFrameId);
}
}
}

Some files were not shown because too many files have changed in this diff Show more