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:
node-version: 20
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
with:
version: 9.5.0
version: 10.15.0
run_install: true
- run: pnpm run build

View file

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

View file

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

View file

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

View file

@ -1,121 +1,2 @@
// cspell: ignore RSASSA
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}`,
};
}
}
}
export * from "./storage/index.js";
export * from "./store.js";

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({
write: async (chunk) => {
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(
outEndpoint.endpointNumber,
// WebUSB doesn't support SharedArrayBuffer
// https://github.com/WICG/webusb/issues/243
toLocalUint8Array(chunk),
);

View file

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

View file

@ -134,9 +134,9 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
}
const args = [
// Use `CLASSPATH=` as `-cp` argument requires Android 8.0
`CLASSPATH=${path}`,
"app_process",
"-cp",
path,
/* unused */ "/",
"com.genymobile.scrcpy.Server",
options.version,
@ -144,14 +144,14 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
];
if (options.spawner) {
process = await options.spawner.spawn(args);
process = await options.spawner(args);
} else {
process = await adb.subprocess.noneProtocol.spawn(args);
}
const output = process.output
.pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"));
.pipeThrough(new SplitStringStream("\n", { trimEnd: true }));
// Must read all streams, otherwise the whole connection will be blocked.
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 {
ScrcpyMediaStreamPacket,
ScrcpyVideoSize,
ScrcpyVideoStreamMetadata,
} from "@yume-chan/scrcpy";
import {
Av1,
h264ParseConfiguration,
h265ParseConfiguration,
ScrcpyVideoCodecId,
} from "@yume-chan/scrcpy";
import { ScrcpyVideoCodecId, ScrcpyVideoSizeImpl } from "@yume-chan/scrcpy";
import type { ReadableStream } from "@yume-chan/stream-extra";
import { InspectStream } from "@yume-chan/stream-extra";
import type { AdbScrcpyOptions } from "./types.js";
export class AdbScrcpyVideoStream {
export class AdbScrcpyVideoStream implements ScrcpyVideoSize {
#options: AdbScrcpyOptions<object>;
#metadata: ScrcpyVideoStreamMetadata;
@ -27,19 +23,15 @@ export class AdbScrcpyVideoStream {
return this.#stream;
}
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>();
get sizeChanged() {
return this.#sizeChanged.event;
}
#width: number = 0;
#size = new ScrcpyVideoSizeImpl();
get width() {
return this.#width;
return this.#size.width;
}
#height: number = 0;
get height() {
return this.#height;
return this.#size.height;
}
get sizeChanged() {
return this.#size.sizeChanged;
}
constructor(
@ -49,12 +41,14 @@ export class AdbScrcpyVideoStream {
) {
this.#options = options;
this.#metadata = metadata;
this.#stream = stream
.pipeThrough(this.#options.createMediaStreamTransformer())
.pipeThrough(
new InspectStream((packet) => {
new InspectStream(
(packet): undefined => {
if (packet.type === "configuration") {
switch (metadata.codec) {
switch (this.#metadata.codec) {
case ScrcpyVideoCodecId.H264:
this.#configureH264(packet.data);
break;
@ -65,27 +59,28 @@ export class AdbScrcpyVideoStream {
// AV1 configuration is in data packet
break;
}
} else if (metadata.codec === ScrcpyVideoCodecId.AV1) {
} else if (
this.#metadata.codec === ScrcpyVideoCodecId.AV1
) {
this.#configureAv1(packet.data);
}
}),
},
{
close: () => this.#size.dispose(),
cancel: () => this.#size.dispose(),
},
),
);
}
#configureH264(data: Uint8Array) {
const { croppedWidth, croppedHeight } = h264ParseConfiguration(data);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
const { croppedWidth, croppedHeight } = H264.parseConfiguration(data);
this.#size.setSize(croppedWidth, croppedHeight);
}
#configureH265(data: Uint8Array) {
const { croppedWidth, croppedHeight } = h265ParseConfiguration(data);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
const { croppedWidth, croppedHeight } = H265.parseConfiguration(data);
this.#size.setSize(croppedWidth, croppedHeight);
}
#configureAv1(data: Uint8Array) {
@ -101,8 +96,6 @@ export class AdbScrcpyVideoStream {
const width = max_frame_width_minus_1 + 1;
const height = max_frame_height_minus_1 + 1;
this.#width = width;
this.#height = height;
this.#sizeChanged.fire({ width, height });
this.#size.setSize(width, height);
}
}

View file

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

View file

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

View file

@ -2,6 +2,12 @@
"references": [
{
"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());
}
getProp(key: string): Promise<string> {
return this.subprocess.noneProtocol
.spawnWaitText(["getprop", key])
.then((output) => output.trim());
async getProp(key: string): Promise<string> {
const output = await this.subprocess.noneProtocol
.spawn(["getprop", key])
.wait()
.toString();
return output.trim();
}
rm(
@ -154,7 +156,11 @@ export class Adb implements Closeable {
// https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984
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> {

View file

@ -36,7 +36,10 @@ export class AdbPower extends AdbServiceBase {
}
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> {
@ -46,7 +49,7 @@ export class AdbPower extends AdbServiceBase {
}
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 "./service.js";
export * from "./shell/index.js";
export * from "./types.js";
export * from "./utils.js";

View file

@ -2,18 +2,27 @@ import type { Adb } from "../../../adb.js";
import { AdbNoneProtocolProcessImpl } from "./process.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;
get adb(): Adb {
return this.#adb;
}
constructor(adb: Adb) {
super(async (command, signal) => {
// `shell,raw:${command}` also triggers raw mode,
// But is not supported on Android version <7.
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(" ")}`,
);
@ -25,15 +34,15 @@ export class AdbNoneProtocolSubprocessService extends AdbNoneProtocolSpawner {
return new AdbNoneProtocolProcessImpl(socket, signal);
});
this.#adb = adb;
}
async pty(
command?: string | readonly string[],
): Promise<AdbNoneProtocolPtyProcess> {
if (command === undefined) {
// Run the default shell
command = "";
} else if (Array.isArray(command)) {
// Don't escape `command`. See `spawn` above for details
command = command.join(" ");
}

View file

@ -5,13 +5,15 @@ import type {
ReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import {
ConcatBufferStream,
ConcatStringStream,
TextDecoderStream,
} from "@yume-chan/stream-extra";
import { concatUint8Arrays } 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 {
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
@ -26,43 +28,54 @@ export interface AdbNoneProtocolProcess {
kill(): MaybePromiseLike<void>;
}
export class AdbNoneProtocolSpawner {
readonly #spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbNoneProtocolProcess>;
export type AdbNoneProtocolSpawner = (
command: string | readonly string[],
signal?: AbortSignal,
) => Promise<AdbNoneProtocolProcess> &
AdbSubprocessSpawner.Wait<Uint8Array, string>;
constructor(
export function adbNoneProtocolSpawner(
spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbNoneProtocolProcess>,
) {
this.#spawn = spawn;
}
spawn(
command: string | readonly string[],
signal?: AbortSignal,
): Promise<AdbNoneProtocolProcess> {
): AdbNoneProtocolSpawner {
return (command, signal) => {
signal?.throwIfAborted();
if (typeof command === "string") {
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> {
const process = await this.spawn(command);
return await process.output.pipeThrough(new ConcatBufferStream());
}
processPromise.wait = (options) => {
const waitPromise = processPromise.then(async (process) => {
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> {
const process = await this.spawn(command);
return await process.output
.pipeThrough(new TextDecoderStream())
.pipeThrough(new ConcatStringStream());
}
return createLazyPromise(
async () => {
const chunks = await waitPromise;
return concatUint8Arrays(chunks);
},
{
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);
if (adb.canUseFeature(AdbFeature.ShellV2)) {
if (adb.canUseFeature(AdbFeature.Shell2)) {
this.#shellProtocol = new AdbShellProtocolSubprocessService(adb);
}
}

View file

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

View file

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

View file

@ -5,13 +5,15 @@ import type {
ReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import {
ConcatBufferStream,
ConcatStringStream,
TextDecoderStream,
} from "@yume-chan/stream-extra";
import { concatUint8Arrays } 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 {
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
@ -24,62 +26,14 @@ export interface AdbShellProtocolProcess {
kill(): MaybePromiseLike<void>;
}
export class AdbShellProtocolSpawner {
readonly #spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbShellProtocolProcess>;
constructor(
spawn: (
command: readonly string[],
signal: AbortSignal | undefined,
) => Promise<AdbShellProtocolProcess>,
) {
this.#spawn = spawn;
}
spawn(
export type AdbShellProtocolSpawner = (
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 };
}
}
) => Promise<AdbShellProtocolProcess> &
AdbSubprocessSpawner.Wait<
AdbShellProtocolSpawner.WaitResult<Uint8Array>,
AdbShellProtocolSpawner.WaitResult<string>
>;
export namespace AdbShellProtocolSpawner {
export interface WaitResult<T> {
@ -88,3 +42,68 @@ export namespace AdbShellProtocolSpawner {
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) {
let result = "";
result += `'`;
@ -10,7 +13,8 @@ export function escapeArg(s: string) {
break;
}
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`'\''`;
base = found + 1;
}
@ -19,23 +23,32 @@ export function escapeArg(s: string) {
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[] = [];
let quote: string | undefined;
let isEscaped = false;
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) {
isEscaped = false;
continue;
}
const char = command.charAt(i);
const char = input.charAt(i);
switch (char) {
case " ":
if (!quote && i !== start) {
result.push(command.substring(start, i));
if (!quote) {
if (i !== start) {
result.push(input.substring(start, i));
}
start = i + 1;
}
break;
@ -53,9 +66,101 @@ export function splitCommand(command: string): string[] {
}
}
if (start < command.length) {
result.push(command.substring(start));
if (start < input.length) {
result.push(input.substring(start));
}
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 "./pull.js";
export * from "./push.js";

View file

@ -1,8 +1,9 @@
import type { StructValue } from "@yume-chan/struct";
import { extend, string, u32 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
import { AdbSyncResponseId, adbSyncReadResponses } from "./response.js";
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponses } from "./response.js";
import type { AdbSyncSocket } from "./socket.js";
import type { AdbSyncStat } from "./stat.js";
import {
@ -36,7 +37,7 @@ export async function* adbSyncOpenDirV2(
await adbSyncWriteRequest(locked, AdbSyncRequestId.ListV2, path);
for await (const item of adbSyncReadResponses(
locked,
AdbSyncResponseId.Entry2,
AdbSyncResponseId.EntryV2,
AdbSyncEntry2Response,
)) {
// `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 { buffer, struct, u32 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponses, AdbSyncResponseId } from "./response.js";
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponses } from "./response.js";
import type { AdbSyncSocket } from "./socket.js";
export const AdbSyncDataResponse = struct(

View file

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

View file

@ -1,20 +1,5 @@
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(
{ id: u32, arg: u32 },
{ littleEndian: true },
@ -26,13 +11,9 @@ export interface AdbSyncWritable {
export async function adbSyncWriteRequest(
writable: AdbSyncWritable,
id: number | string,
id: number,
value: number | string | Uint8Array,
): Promise<void> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
if (typeof value === "number") {
await writable.write(
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 { 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> {
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"),
};
import { AdbSyncResponseId } from "./id.js";
export class AdbSyncError extends Error {}
@ -50,18 +20,14 @@ export const AdbSyncFailResponse = struct(
export async function adbSyncReadResponse<T>(
stream: AsyncExactReadable,
id: number | string,
id: number,
type: StructDeserializer<T>,
): Promise<T> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
const buffer = await stream.readExactly(4);
switch (getUint32LittleEndian(buffer, 0)) {
case AdbSyncResponseId.Fail:
await AdbSyncFailResponse.deserialize(stream);
throw new Error("Unreachable");
unreachable();
case id:
return await type.deserialize(stream);
default:
@ -73,13 +39,9 @@ export async function adbSyncReadResponse<T>(
export async function* adbSyncReadResponses<T>(
stream: AsyncExactReadable,
id: number | string,
id: number,
type: StructDeserializer<T>,
): AsyncGenerator<T, void, void> {
if (typeof id === "string") {
id = adbSyncEncodeId(id);
}
while (true) {
const buffer = await stream.readExactly(4);
switch (getUint32LittleEndian(buffer, 0)) {

View file

@ -1,8 +1,9 @@
import type { StructValue } from "@yume-chan/struct";
import { struct, u32, u64 } from "@yume-chan/struct";
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
import { AdbSyncResponseId, adbSyncReadResponse } from "./response.js";
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
import { adbSyncWriteRequest } from "./request.js";
import { adbSyncReadResponse } from "./response.js";
import type { AdbSyncSocket } from "./socket.js";
// 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);
return await adbSyncReadResponse(
locked,
AdbSyncResponseId.Lstat2,
AdbSyncResponseId.LstatV2,
AdbSyncStatResponse,
);
} else {

View file

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

View file

@ -1,34 +1,35 @@
import * as assert from "node:assert";
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 type { AdbCredentialStore } from "./auth.js";
import { AdbAuthType, AdbPublicKeyAuthenticator } from "./auth.js";
import type { AdbPacketData } from "./packet.js";
import { AdbAuthType, AdbDefaultAuthenticator } from "./auth.js";
import type { SimpleRsaPrivateKey } from "./crypto.js";
import { rsaParsePrivateKey } from "./crypto.js";
import { AdbCommand } from "./packet.js";
class MockCredentialStore implements AdbCredentialStore {
key: Uint8Array;
key: SimpleRsaPrivateKey;
name: string | undefined;
constructor(key: Uint8Array, name: string | undefined) {
this.key = key;
this.key = rsaParsePrivateKey(key);
this.name = name;
}
*iterateKeys() {
yield {
buffer: this.key,
...this.key,
name: this.name,
};
}
generateKey() {
return {
buffer: this.key,
...this.key,
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=";
describe("auth", () => {
describe("PublicKeyAuthenticator", () => {
describe("AdbDefaultAuthenticator", () => {
it("should generate correct public key without name", async () => {
const store = new MockCredentialStore(
new Uint8Array(PRIVATE_KEY),
undefined,
);
const authenticator = AdbPublicKeyAuthenticator(store, () =>
Promise.resolve({
const authenticator = new AdbDefaultAuthenticator(store);
const challenge = new Uint8Array(20);
const first = await authenticator.authenticate({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: EmptyUint8Array,
}),
);
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);
const results: AdbPacketData[] = [];
for await (const result of authenticator) {
results.push(result);
}
const result = await authenticator.authenticate({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: challenge,
});
assert.deepStrictEqual(results, [
{
assert.deepStrictEqual(result, {
command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey,
arg1: 0,
payload: encodeUtf8(`${PUBLIC_KEY}\0`),
},
]);
});
});
it("should generate correct public key name", async () => {
@ -113,28 +119,33 @@ describe("auth", () => {
name,
);
const authenticator = AdbPublicKeyAuthenticator(store, () =>
Promise.resolve({
const authenticator = new AdbDefaultAuthenticator(store);
const challenge = new Uint8Array(20);
const first = await authenticator.authenticate({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: EmptyUint8Array,
}),
);
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);
const results: AdbPacketData[] = [];
for await (const result of authenticator) {
results.push(result);
}
const result = await authenticator.authenticate({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
payload: challenge,
});
assert.deepStrictEqual(results, [
{
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 { PromiseResolver } from "@yume-chan/async";
import type { Disposable } from "@yume-chan/event";
import { EventEmitter } from "@yume-chan/event";
import { EmptyUint8Array } from "@yume-chan/struct";
import {
@ -9,6 +8,7 @@ import {
encodeUtf8,
} from "../utils/index.js";
import type { SimpleRsaPrivateKey } from "./crypto.js";
import {
adbGeneratePublicKey,
adbGetPublicKeySize,
@ -17,11 +17,7 @@ import {
import type { AdbPacketData } from "./packet.js";
import { AdbCommand } from "./packet.js";
export interface AdbPrivateKey {
/**
* The private key in PKCS #8 format.
*/
buffer: Uint8Array;
export interface AdbPrivateKey extends SimpleRsaPrivateKey {
name?: string | undefined;
}
@ -52,70 +48,71 @@ export const AdbAuthType = {
export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType];
export interface AdbAuthenticator {
/**
* @param getNextRequest
*
* 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>;
authenticate(packet: AdbPacketData): Promise<AdbPacketData>;
close?(): MaybePromiseLike<undefined>;
}
export const AdbSignatureAuthenticator: AdbAuthenticator = async function* (
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData> {
for await (const key of credentialStore.iterateKeys()) {
const packet = await getNextRequest();
export class AdbDefaultAuthenticator implements AdbAuthenticator {
#credentialStore: AdbCredentialStore;
#iterator:
| Iterator<AdbPrivateKey, void, void>
| AsyncIterator<AdbPrivateKey, void, void>
| undefined;
#firstKey: AdbPrivateKey | undefined;
#onPublicKeyAuthentication = new EventEmitter<void>();
get onPublicKeyAuthentication() {
return this.#onPublicKeyAuthentication.event;
}
constructor(credentialStore: AdbCredentialStore) {
this.#credentialStore = credentialStore;
}
async authenticate(packet: AdbPacketData): Promise<AdbPacketData> {
if (packet.arg0 !== AdbAuthType.Token) {
return;
throw new Error("Unsupported authentication packet");
}
const signature = rsaSign(key.buffer, packet.payload);
yield {
if (!this.#iterator) {
const iterable = this.#credentialStore.iterateKeys();
if (Symbol.iterator in iterable) {
this.#iterator = iterable[Symbol.iterator]();
} else if (Symbol.asyncIterator in iterable) {
this.#iterator = iterable[Symbol.asyncIterator]();
} else {
throw new Error("`iterateKeys` doesn't return an iterator");
}
}
const { done, value } = await this.#iterator.next();
if (!done) {
if (!this.#firstKey) {
this.#firstKey = value;
}
return {
command: AdbCommand.Auth,
arg0: AdbAuthType.Signature,
arg1: 0,
payload: signature,
payload: rsaSign(value, packet.payload),
};
}
};
export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData> {
const packet = await getNextRequest();
this.#onPublicKeyAuthentication.fire();
if (packet.arg0 !== AdbAuthType.Token) {
return;
}
let privateKey: AdbPrivateKey | undefined;
for await (const key of credentialStore.iterateKeys()) {
privateKey = key;
break;
}
if (!privateKey) {
privateKey = await credentialStore.generateKey();
let key = this.#firstKey;
if (!key) {
key = await this.#credentialStore.generateKey();
}
const publicKeyLength = adbGetPublicKeySize();
const [publicKeyBase64Length] =
calculateBase64EncodedLength(publicKeyLength);
const nameBuffer = privateKey.name?.length
? encodeUtf8(privateKey.name)
const nameBuffer = key.name?.length
? encodeUtf8(key.name)
: EmptyUint8Array;
const publicKeyBuffer = new Uint8Array(
publicKeyBase64Length +
@ -123,82 +120,29 @@ export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
1, // Null character
);
adbGeneratePublicKey(privateKey.buffer, publicKeyBuffer);
encodeBase64(publicKeyBuffer.subarray(0, publicKeyLength), publicKeyBuffer);
adbGeneratePublicKey(key, publicKeyBuffer);
encodeBase64(
publicKeyBuffer.subarray(0, publicKeyLength),
publicKeyBuffer,
);
if (nameBuffer.length) {
publicKeyBuffer[publicKeyBase64Length] = 0x20;
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
}
yield {
return {
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;
}
#getNextRequest = (): Promise<AdbPacketData> => {
return this.#pendingRequest.promise;
};
async close(): Promise<undefined> {
await this.#iterator?.return?.();
async *#invokeAuthenticator(): AsyncGenerator<AdbPacketData, void, void> {
for (const authenticator of this.authenticators) {
for await (const packet of authenticator(
this.#credentialStore,
this.#getNextRequest,
)) {
// If the authenticator yielded a response
// Prepare `nextRequest` for next authentication request
this.#pendingRequest = new PromiseResolver();
// Yield the response to outer layer
yield packet;
}
// If the authenticator returned,
// Next authenticator will be given the same `pendingRequest`
}
}
async process(packet: AdbPacketData): Promise<AdbPacketData> {
if (!this.#iterator) {
this.#iterator = this.#invokeAuthenticator();
}
this.#pendingRequest.resolve(packet);
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?.();
this.#iterator = undefined;
this.#firstKey = undefined;
}
}

View file

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

View file

@ -48,11 +48,14 @@ export function setBigUint(
littleEndian?: boolean,
) {
if (littleEndian) {
const end = byteOffset + length;
while (value > 0n) {
setInt64LittleEndian(array, byteOffset, value);
byteOffset += 8;
value >>= 64n;
}
// Clear the trailing bytes
array.subarray(byteOffset, end).fill(0);
} else {
let position = byteOffset + length - 8;
while (value > 0n) {
@ -60,9 +63,16 @@ export function setBigUint(
position -= 8;
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
// modulus length is 2048 and
// public exponent (e) is 65537
@ -89,10 +99,16 @@ const RsaPrivateKeyNLength = 2048 / 8;
const RsaPrivateKeyDOffset = 303;
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 d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
return [n, d];
return { n, d };
}
function nonNegativeMod(m: number, d: number) {
@ -141,14 +157,14 @@ export function adbGetPublicKeySize() {
}
export function adbGeneratePublicKey(
privateKey: Uint8Array,
privateKey: SimpleRsaPrivateKey,
): Uint8Array<ArrayBuffer>;
export function adbGeneratePublicKey(
privateKey: Uint8Array,
privateKey: SimpleRsaPrivateKey,
output: Uint8Array,
): number;
export function adbGeneratePublicKey(
privateKey: Uint8Array,
privateKey: SimpleRsaPrivateKey,
output?: Uint8Array,
): Uint8Array | number {
// cspell: ignore: mincrypt
@ -198,7 +214,7 @@ export function adbGeneratePublicKey(
outputOffset += 4;
// extract `n` from private key
const [n] = rsaParsePrivateKey(privateKey);
const { n } = privateKey;
// Calculate `n0inv`
const n0inv = -modInverse(Number(n % 2n ** 32n), 2 ** 32);
@ -283,17 +299,27 @@ export const SHA1_DIGEST_INFO = new Uint8Array([
SHA1_DIGEST_LENGTH,
]);
// SubtleCrypto.sign() will hash the given data and sign the hash
// But we don't need the hashing step
// (In another word, ADB just requires the client to
// encrypt the given data with its private key)
// However SubtileCrypto.encrypt() doesn't accept 'RSASSA-PKCS1-v1_5' algorithm
// So we need to implement the encryption by ourself
// Standard `RSASSA-PKCS1-v1_5` algorithm will hash the given data
// and sign the hash
// https://datatracker.ietf.org/doc/html/rfc8017#section-8.2
//
// But ADB authentication passes 20 bytes of random value to
// 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(
privateKey: Uint8Array,
privateKey: SimpleRsaPrivateKey,
data: Uint8Array,
): 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
const padded = new Uint8Array(256);

View file

@ -14,42 +14,15 @@ import type {
AdbTransport,
} from "../adb.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 {
ADB_DEFAULT_AUTHENTICATORS,
AdbAuthenticationProcessor,
} from "./auth.js";
import { AdbDefaultAuthenticator } from "./auth.js";
import { AdbPacketDispatcher } from "./dispatcher.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import { AdbCommand, calculateChecksum } from "./packet.js";
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 type AdbDaemonConnection = ReadableWritablePair<
@ -60,8 +33,6 @@ export type AdbDaemonConnection = ReadableWritablePair<
export interface AdbDaemonAuthenticationOptions {
serial: string;
connection: AdbDaemonConnection;
credentialStore: AdbCredentialStore;
authenticators?: readonly AdbAuthenticator[];
features?: readonly AdbFeature[];
/**
@ -159,22 +130,36 @@ export class AdbDaemonTransport implements AdbTransport {
static async authenticate({
serial,
connection,
credentialStore,
authenticators = ADB_DEFAULT_AUTHENTICATORS,
features = ADB_DAEMON_DEFAULT_FEATURES,
features = AdbDeviceFeatures,
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
...options
}: AdbDaemonAuthenticationOptions): Promise<AdbDaemonTransport> {
}: AdbDaemonAuthenticationOptions &
(
| { authenticator: AdbAuthenticator }
| {
credentialStore: AdbCredentialStore;
onPublicKeyAuthentication?: (() => void) | undefined;
}
)): Promise<AdbDaemonTransport> {
// Initially, set to highest-supported version and payload size.
let version = 0x01000001;
// Android 4: 4K, Android 7: 256K, Android 9: 1M
let maxPayloadSize = 1024 * 1024;
const resolver = new PromiseResolver<string>();
const authProcessor = new AdbAuthenticationProcessor(
authenticators,
credentialStore,
let authenticator: AdbAuthenticator;
if ("authenticator" in options) {
authenticator = options.authenticator;
} else {
authenticator = new AdbDefaultAuthenticator(
options.credentialStore,
);
if (options.onPublicKeyAuthentication) {
(
authenticator as AdbDefaultAuthenticator
).onPublicKeyAuthentication(options.onPublicKeyAuthentication);
}
}
// Here is similar to `AdbPacketDispatcher`,
// 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));
break;
case AdbCommand.Auth: {
const response =
await authProcessor.process(packet);
await sendPacket(response);
await sendPacket(
await authenticator.authenticate(packet),
);
break;
}
default:
@ -215,13 +200,17 @@ export class AdbDaemonTransport implements AdbTransport {
},
)
.then(
() => {
async () => {
await authenticator.close?.();
// If `resolver` is already settled, call `reject` won't do anything.
resolver.reject(
new Error("Connection closed unexpectedly"),
);
},
(e) => {
async (e) => {
await authenticator.close?.();
resolver.reject(e);
},
);
@ -238,11 +227,10 @@ export class AdbDaemonTransport implements AdbTransport {
);
}
const actualFeatures = features.slice();
if (initialDelayedAckBytes <= 0) {
const index = features.indexOf(AdbFeature.DelayedAck);
if (index !== -1) {
actualFeatures.splice(index, 1);
features = features.toSpliced(index, 1);
}
}
@ -254,9 +242,7 @@ export class AdbDaemonTransport implements AdbTransport {
arg1: maxPayloadSize,
// The terminating `;` is required in formal definition
// But ADB daemon (all versions) can still work without it
payload: encodeUtf8(
`host::features=${actualFeatures.join(",")}`,
),
payload: encodeUtf8(`host::features=${features.join(",")}`),
});
banner = await resolver.promise;
@ -276,9 +262,10 @@ export class AdbDaemonTransport implements AdbTransport {
version,
maxPayloadSize,
banner,
features: actualFeatures,
features,
initialDelayedAckBytes,
...options,
preserveConnection: options.preserveConnection,
readTimeLimit: options.readTimeLimit,
});
}
@ -322,7 +309,7 @@ export class AdbDaemonTransport implements AdbTransport {
connection,
version,
banner,
features = ADB_DAEMON_DEFAULT_FEATURES,
features = AdbDeviceFeatures,
initialDelayedAckBytes,
...options
}: AdbDaemonSocketConnectorConstructionOptions) {
@ -345,19 +332,19 @@ export class AdbDaemonTransport implements AdbTransport {
initialDelayedAckBytes = 0;
}
let calculateChecksum: boolean;
let appendNullToServiceString: boolean;
let shouldCalculateChecksum: boolean;
let shouldAppendNullToServiceString: boolean;
if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) {
calculateChecksum = false;
appendNullToServiceString = false;
shouldCalculateChecksum = false;
shouldAppendNullToServiceString = false;
} else {
calculateChecksum = true;
appendNullToServiceString = true;
shouldCalculateChecksum = true;
shouldAppendNullToServiceString = true;
}
this.#dispatcher = new AdbPacketDispatcher(connection, {
calculateChecksum,
appendNullToServiceString,
calculateChecksum: shouldCalculateChecksum,
appendNullToServiceString: shouldAppendNullToServiceString,
initialDelayedAckBytes,
...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
// 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;
import * as AdbFeature from "./features-value.js";
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,
} from "../adb.js";
import type { AdbBanner } from "../banner.js";
import { AdbFeature } from "../features.js";
import { AdbDeviceFeatures } from "../features.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 {
#client: AdbServerClient;
@ -52,9 +30,14 @@ export class AdbServerTransport implements AdbTransport {
}
get clientFeatures() {
// No need to get host features (features supported by ADB server)
// Because we create all ADB packets ourselves
return ADB_SERVER_DEFAULT_FEATURES;
// This list tells the `Adb` instance how to invoke some commands.
//
// 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

View file

@ -1,17 +1,19 @@
import type { Adb } from "@yume-chan/adb";
import { AdbServiceBase } from "@yume-chan/adb";
import type { Adb, AdbNoneProtocolProcess } 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 type { IntentBuilder } from "./intent.js";
import type { SingleUser } from "./utils.js";
import { buildArguments } from "./utils.js";
import { Cmd } from "./cmd/index.js";
import type { Intent } from "./intent.js";
import { serializeIntent } from "./intent.js";
import type { SingleUser, SingleUserOrAll } from "./utils.js";
import { buildCommand } from "./utils.js";
export interface ActivityManagerStartActivityOptions {
displayId?: number;
windowingMode?: number;
forceStop?: boolean;
user?: SingleUser;
intent: IntentBuilder;
intent: Intent;
}
const START_ACTIVITY_OPTIONS_MAP: Partial<
@ -24,41 +26,96 @@ const START_ACTIVITY_OPTIONS_MAP: Partial<
};
export class ActivityManager extends AdbServiceBase {
static ServiceName = "activity";
static CommandName = "am";
static readonly ServiceName = "activity";
static readonly CommandName = "am";
#cmd: CmdNoneProtocolService;
#apiLevel: number | undefined;
#cmd: Cmd.NoneProtocolService;
constructor(adb: Adb) {
constructor(adb: Adb, apiLevel?: number) {
super(adb);
this.#cmd = new CmdNoneProtocolService(
adb,
ActivityManager.CommandName,
);
this.#apiLevel = apiLevel;
this.#cmd = Cmd.createNoneProtocol(adb, ActivityManager.CommandName);
}
async startActivity(
options: ActivityManagerStartActivityOptions,
): Promise<void> {
let args = buildArguments(
[ActivityManager.ServiceName, "start-activity", "-W"],
// Android 8 added "start-activity" alias to "start"
// but we want to use the most compatible one.
const command = buildCommand(
[ActivityManager.ServiceName, "start", "-W"],
options,
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
.spawnWaitText(args)
.then((output) => output.trim());
// Android 7 supports `cmd activity` but not `cmd activity start` command
let process: AdbNoneProtocolProcess;
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:")) {
// 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());
}
if (line === "Complete") {
// Same as above
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 {
user: number;
user?: number | undefined;
file: ReadableStream<MaybeConsumable<Uint8Array>>;
}
@ -67,6 +67,9 @@ export class AdbBackup extends AdbServiceBase {
if (options.user !== undefined) {
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([
"bugreportz",
"-v",
]);
const result = await adb.subprocess.shellProtocol
.spawn(["bugreportz", "-v"])
.wait()
.toString();
if (result.exitCode !== 0 || result.stderr === "") {
return new BugReport(adb, {
supportsBugReport: true,
@ -211,10 +211,10 @@ export class BugReport extends AdbServiceBase {
let filename: string | undefined;
let error: string | undefined;
for await (const line of process.stdout
const lines = process.stdout
.pipeThrough(new TextDecoderStream())
// Each chunk should contain one or several full lines
.pipeThrough(new SplitStringStream("\n"))) {
.pipeThrough(new SplitStringStream("\n", { trim: true }));
for await (const line of lines) {
// `BEGIN:` and `PROGRESS:` only appear when `-p` is specified.
let match = line.match(BugReport.PROGRESS_REGEX);
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 { AdbServiceBase } from "@yume-chan/adb";
import { ActivityManager } from "./am.js";
import { Settings } from "./settings.js";
export enum DemoModeSignalStrength {
@ -52,10 +53,12 @@ export const DemoModeStatusBarModes = [
export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number];
export class DemoMode extends AdbServiceBase {
#am: ActivityManager;
#settings: Settings;
constructor(adb: Adb) {
constructor(adb: Adb, apiLevel?: number) {
super(adb);
this.#am = new ActivityManager(adb, apiLevel);
this.#settings = new Settings(adb);
}
@ -108,26 +111,14 @@ export class DemoMode extends AdbServiceBase {
}
}
async broadcast(
command: string,
extra?: Record<string, string>,
): Promise<void> {
await this.adb.subprocess.noneProtocol.spawnWaitText([
"am",
"broadcast",
"-a",
"com.android.systemui.demo",
"-e",
"command",
broadcast(command: string, extras?: Record<string, string>): Promise<void> {
return this.#am.broadcast({
action: "com.android.systemui.demo",
extras: {
command,
...(extra
? Object.entries(extra).flatMap(([key, value]) => [
"-e",
key,
value,
])
: []),
]);
...extras,
},
});
}
async setBatteryLevel(level: number): Promise<void> {

View file

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

View file

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

View file

@ -1,53 +1,165 @@
import * as assert from "node:assert";
import { describe, it } from "node:test";
import { IntentBuilder } from "./intent.js";
import { serializeIntent } from "./intent.js";
describe("Intent", () => {
describe("IntentBuilder", () => {
it("should set intent action", () => {
assert.deepStrictEqual(
new IntentBuilder().setAction("test_action").build(),
["-a", "test_action"],
);
describe("serializeIntent", () => {
it("should serialize intent action", () => {
assert.deepStrictEqual(serializeIntent({ action: "test_action" }), [
"-a",
"test_action",
]);
});
it("should set intent categories", () => {
it("should serialize intent categories", () => {
assert.deepStrictEqual(
new IntentBuilder()
.addCategory("category_1")
.addCategory("category_2")
.build(),
serializeIntent({ categories: ["category_1", "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(
new IntentBuilder().setPackage("package_1").build(),
["-p", "package_1"],
serializeIntent({
component: {
packageName: "package_1",
className: "component_1",
},
}),
["-n", "package_1/component_1"],
);
});
it("should set intent component", () => {
assert.deepStrictEqual(
new IntentBuilder().setComponent("component_1").build(),
["-n", "component_1"],
);
it("should serialize intent data", () => {
assert.deepStrictEqual(serializeIntent({ data: "data_1" }), [
"-d",
"data_1",
]);
});
it("should set intent data", () => {
describe("extras", () => {
it("should serialize string extras", () => {
assert.deepStrictEqual(
new IntentBuilder().setData("data_1").build(),
["-d", "data_1"],
);
});
it("should pass intent extras", () => {
assert.deepStrictEqual(
new IntentBuilder().addStringExtra("key1", "value1").build(),
serializeIntent({
extras: {
key1: "value1",
},
}),
["--es", "key1", "value1"],
);
});
it("should serialize null extras", () => {
assert.deepStrictEqual(
serializeIntent({
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 {
#action: string | undefined;
#categories: string[] = [];
#packageName: string | undefined;
#component: string | undefined;
#data: string | undefined;
#type: string | undefined;
#stringExtras = new Map<string, string>();
// cspell: ignore eial
// cspell: ignore elal
// cspell: ignore efal
// cspell: ignore esal
// cspell: ignore edal
setAction(action: string): this {
this.#action = action;
return this;
export interface IntentNumberExtra {
type: "long" | "float" | "double";
value: number;
}
addCategory(category: string): this {
this.#categories.push(category);
return this;
export interface IntentStringExtra {
type: "uri";
value: string;
}
setPackage(packageName: string): this {
this.#packageName = packageName;
return this;
export interface IntentNumberArrayExtra {
type: "array" | "arrayList";
itemType: "int" | IntentNumberExtra["type"];
value: number[];
}
setComponent(component: string): this {
this.#component = component;
return this;
export interface IntentStringArrayExtra {
type: "array" | "arrayList";
itemType: "string";
value: string[];
}
setData(data: string): this {
this.#data = data;
return this;
export interface ComponentName {
packageName: string;
className: string;
}
addStringExtra(key: string, value: string): this {
this.#stringExtras.set(key, value);
return this;
export interface Intent {
action?: string | undefined;
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"]) {
switch (type) {
case "int":
return "--ei";
case "long":
return "--el";
case "float":
return "--ef";
case "double":
return "--ed";
default:
throw new Error(`Unknown number type: ${type as string}`);
}
}
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 (this.#action) {
result.push("-a", this.#action);
if (intent.action) {
result.push("-a", intent.action);
}
for (const category of this.#categories) {
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 (this.#packageName) {
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) {
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[] = [];
for await (const line of process.output
.pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"))) {
.pipeThrough(new SplitStringStream("\n", { trim: true }))) {
let match = line.match(Logcat.LOG_SIZE_REGEX_11);
if (match) {
result.push({
@ -494,7 +494,7 @@ export class Logcat extends AdbServiceBase {
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> {

View file

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

View file

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

View file

@ -1,4 +1,4 @@
export function buildArguments<T>(
export function buildCommand<T>(
commands: readonly string[],
options: Partial<T> | undefined,
map: Partial<Record<keyof T, string>>,
@ -6,19 +6,34 @@ export function buildArguments<T>(
const args = commands.slice();
if (options) {
for (const [key, value] of Object.entries(options)) {
if (value) {
if (value === undefined || value === null) {
continue;
}
const option = map[key as keyof T];
if (option) {
args.push(option);
// Empty string means positional argument,
// they must be added at the end,
// so let the caller handle it.
if (option === undefined || option === "") {
continue;
}
switch (typeof value) {
case "boolean":
if (value) {
args.push(option);
}
break;
case "number":
args.push(value.toString());
args.push(option, value.toString());
break;
case "string":
args.push(value);
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 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",
"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": "../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(
device: USBDevice,
accessoryId: number,
reportDescriptor: Uint8Array<ArrayBuffer>,
reportDescriptor: BufferSource,
) {
await device.controlTransferOut(
{
@ -50,7 +50,7 @@ export async function aoaHidUnregister(device: USBDevice, accessoryId: number) {
export async function aoaHidSendInputReport(
device: USBDevice,
accessoryId: number,
event: Uint8Array<ArrayBuffer>,
event: BufferSource,
) {
await device.controlTransferOut(
{
@ -80,9 +80,9 @@ export class AoaHidDevice {
static async register(
device: USBDevice,
accessoryId: number,
reportDescriptor: Uint8Array<ArrayBuffer>,
reportDescriptor: BufferSource,
) {
await aoaHidRegister(device, accessoryId, reportDescriptor.length);
await aoaHidRegister(device, accessoryId, reportDescriptor.byteLength);
await aoaHidSetReportDescriptor(device, accessoryId, reportDescriptor);
return new AoaHidDevice(device, accessoryId);
}
@ -95,7 +95,7 @@ export class AoaHidDevice {
this.#accessoryId = accessoryId;
}
async sendInputReport(event: Uint8Array<ArrayBuffer>) {
async sendInputReport(event: BufferSource) {
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 Chromat
import { decimalTwoDigits } from "./format.js";
export const AndroidAv1Profile = {
Main8: 1 << 0,
Main10: 1 << 1,
@ -198,6 +200,62 @@ export class Av1 extends BitReader {
static TransferCharacteristics = TransferCharacteristics;
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;
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 colour
import { hexTwoDigits } from "./format.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
// they have the same content, and refer themselves as "H.264".
// 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
// 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);
if (reader.next() !== 0) {
throw new Error("Invalid data");
@ -218,7 +183,7 @@ export function h264ParseSequenceParameterSet(nalu: Uint8Array) {
* Find Sequence Parameter Set (SPS) and Picture Parameter Set (PPS)
* from H.264 Annex B formatted data.
*/
export function h264SearchConfiguration(buffer: Uint8Array) {
export function searchConfiguration(buffer: Uint8Array) {
let sequenceParameterSet: Uint8Array | undefined;
let pictureParameterSet: Uint8Array | undefined;
@ -252,7 +217,7 @@ export function h264SearchConfiguration(buffer: Uint8Array) {
throw new Error("Invalid data");
}
export interface H264Configuration {
export interface Configuration {
pictureParameterSet: Uint8Array;
sequenceParameterSet: Uint8Array;
@ -271,9 +236,9 @@ export interface H264Configuration {
croppedHeight: number;
}
export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
export function parseConfiguration(data: Uint8Array): Configuration {
const { sequenceParameterSet, pictureParameterSet } =
h264SearchConfiguration(data);
searchConfiguration(data);
const {
profile_idc: profileIndex,
@ -286,7 +251,7 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
frame_crop_right_offset,
frame_crop_top_offset,
frame_crop_bottom_offset,
} = h264ParseSequenceParameterSet(sequenceParameterSet);
} = parseSequenceParameterSet(sequenceParameterSet);
const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16;
const encodedHeight =
@ -315,3 +280,16 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
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 { describe, it } from "node:test";
import { h265ParseSequenceParameterSet } from "./h265.js";
import { parseSequenceParameterSet } from "./h265.js";
describe("h265", () => {
describe("h265ParseSequenceParameterSet", () => {
@ -21,7 +21,7 @@ describe("h265", () => {
0x80,
]);
const sps = h265ParseSequenceParameterSet(buffer);
const sps = parseSequenceParameterSet(buffer);
assert.deepStrictEqual(sps, {
sps_video_parameter_set_id: 0,
@ -355,7 +355,7 @@ describe("h265", () => {
},
sps_extension_4bits: 0,
sps_extension_data_flag: undefined,
} satisfies ReturnType<typeof h265ParseSequenceParameterSet>);
} satisfies ReturnType<typeof parseSequenceParameterSet>);
});
it("issue #732", () => {
@ -365,7 +365,7 @@ describe("h265", () => {
151, 43, 182, 64,
]);
const sps = h265ParseSequenceParameterSet(buffer);
const sps = parseSequenceParameterSet(buffer);
assert.deepStrictEqual(sps, {
sps_video_parameter_set_id: 4,
@ -552,7 +552,7 @@ describe("h265", () => {
spsMultilayerExtension: undefined,
sps3dExtension: 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 luma
import { getUint32LittleEndian } from "@yume-chan/no-data-view";
import { hexDigits } from "./format.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
*/
@ -92,7 +58,7 @@ export function getSubHeightC(chroma_format_idc: number) {
/**
* 7.3.1.1 General NAL unit syntax
*/
export function h265ParseNaluHeader(nalu: Uint8Array) {
export function parseNaluHeader(nalu: Uint8Array) {
const reader = new NaluSodbBitReader(nalu);
if (reader.next() !== 0) {
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;
rbsp: Uint8Array;
}
@ -119,7 +85,7 @@ export interface H265NaluRaw extends H265NaluHeader {
/**
* 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 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();
reader.skip(16);
const profileTierLevel = h265ParseProfileTierLevel(
const profileTierLevel = parseProfileTierLevel(
reader,
true,
vps_max_sub_layers_minus1,
@ -172,7 +138,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
let vps_num_hrd_parameters: number | undefined;
let hrd_layer_set_idx: number[] | undefined;
let cprms_present_flag: boolean[] | undefined;
let hrdParameters: H265HrdParameters[] | undefined;
let hrdParameters: HrdParameters[] | undefined;
if (vps_timing_info_present_flag) {
vps_num_units_in_tick = reader.read(32);
vps_time_scale = reader.read(32);
@ -193,7 +159,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
if (i > 0) {
cprms_present_flag[i] = !!reader.next();
}
hrdParameters[i] = h265ParseHrdParameters(
hrdParameters[i] = parseHrdParameters(
reader,
cprms_present_flag[i]!,
vps_max_sub_layers_minus1,
@ -232,20 +198,20 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
}
export type SubLayerHrdParameters = ReturnType<
typeof h265ParseSubLayerHrdParameters
typeof parseSubLayerHrdParameters
>;
/**
* 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 sps_video_parameter_set_id = reader.read(4);
const sps_max_sub_layers_minus1 = reader.read(3);
const sps_temporal_id_nesting_flag = !!reader.next();
const profileTierLevel = h265ParseProfileTierLevel(
const profileTierLevel = parseProfileTierLevel(
reader,
true,
sps_max_sub_layers_minus1,
@ -315,7 +281,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
if (scaling_list_enabled_flag) {
sps_scaling_list_data_present_flag = !!reader.next();
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 shortTermRefPicSets: ShortTermReferencePictureSet[] = [];
for (let i = 0; i < num_short_term_ref_pic_sets; i += 1) {
shortTermRefPicSets[i] = h265ParseShortTermReferencePictureSet(
shortTermRefPicSets[i] = parseShortTermReferencePictureSet(
reader,
i,
num_short_term_ref_pic_sets,
@ -367,12 +333,9 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
const sps_temporal_mvp_enabled_flag = !!reader.next();
const strong_intra_smoothing_enabled_flag = !!reader.next();
const vui_parameters_present_flag = !!reader.next();
let vuiParameters: H265VuiParameters | undefined;
let vuiParameters: VuiParameters | undefined;
if (vui_parameters_present_flag) {
vuiParameters = h265ParseVuiParameters(
reader,
sps_max_sub_layers_minus1,
);
vuiParameters = parseVuiParameters(reader, sps_max_sub_layers_minus1);
}
const sps_extension_present_flag = !!reader.next();
@ -393,14 +356,14 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
throw new Error("Not implemented");
}
let spsMultilayerExtension: H265SpsMultilayerExtension | undefined;
let spsMultilayerExtension: SpsMultilayerExtension | undefined;
if (sps_multilayer_extension_flag) {
spsMultilayerExtension = h265ParseSpsMultilayerExtension(reader);
spsMultilayerExtension = parseSpsMultilayerExtension(reader);
}
let sps3dExtension: H265Sps3dExtension | undefined;
let sps3dExtension: Sps3dExtension | undefined;
if (sps_3d_extension_flag) {
sps3dExtension = h265ParseSps3dExtension(reader);
sps3dExtension = parseSps3dExtension(reader);
}
if (sps_scc_extension_flag) {
@ -484,7 +447,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
* Common part between general_profile_tier_level and
* sub_layer_profile_tier_level
*/
function h265ParseProfileTier(reader: NaluSodbBitReader) {
function parseProfileTier(reader: NaluSodbBitReader) {
const profile_space = reader.read(2);
const tier_flag = !!reader.next();
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 {
generalProfileTier: H265ProfileTier | undefined;
export interface ProfileTierLevel {
generalProfileTier: ProfileTier | undefined;
general_level_idc: number;
sub_layer_profile_present_flag: boolean[];
sub_layer_level_present_flag: boolean[];
subLayerProfileTier: H265ProfileTier[];
subLayerProfileTier: ProfileTier[];
sub_layer_level_idc: number[];
}
/**
* 7.3.3 Profile, tier and level syntax
*/
function h265ParseProfileTierLevel(
function parseProfileTierLevel(
reader: NaluSodbBitReader,
profilePresentFlag: true,
maxNumSubLayersMinus1: number,
): H265ProfileTierLevel & { generalProfileTier: H265ProfileTier };
function h265ParseProfileTierLevel(
): ProfileTierLevel & { generalProfileTier: ProfileTier };
function parseProfileTierLevel(
reader: NaluSodbBitReader,
profilePresentFlag: false,
maxNumSubLayersMinus1: number,
): H265ProfileTierLevel & { generalProfileTier: undefined };
function h265ParseProfileTierLevel(
): ProfileTierLevel & { generalProfileTier: undefined };
function parseProfileTierLevel(
reader: NaluSodbBitReader,
profilePresentFlag: boolean,
maxNumSubLayersMinus1: number,
): H265ProfileTierLevel;
function h265ParseProfileTierLevel(
): ProfileTierLevel;
function parseProfileTierLevel(
reader: NaluSodbBitReader,
profilePresentFlag: boolean,
maxNumSubLayersMinus1: number,
): H265ProfileTierLevel {
let generalProfileTier: H265ProfileTier | undefined;
): ProfileTierLevel {
let generalProfileTier: ProfileTier | undefined;
if (profilePresentFlag) {
generalProfileTier = h265ParseProfileTier(reader);
generalProfileTier = parseProfileTier(reader);
}
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[] = [];
for (let i = 0; i < maxNumSubLayersMinus1; i += 1) {
if (sub_layer_profile_present_flag[i]) {
subLayerProfileTier[i] = h265ParseProfileTier(reader);
subLayerProfileTier[i] = parseProfileTier(reader);
}
if (sub_layer_level_present_flag[i]) {
sub_layer_level_idc[i] = reader.read(8);
@ -687,7 +650,7 @@ function h265ParseProfileTierLevel(
/**
* 7.3.4 Scaling list data syntax
*/
export function h265ParseScalingListData(reader: NaluSodbBitReader) {
export function parseScalingListData(reader: NaluSodbBitReader) {
const scaling_list: number[][][] = [];
for (let sizeId = 0; sizeId < 4; sizeId += 1) {
scaling_list[sizeId] = [];
@ -737,7 +700,7 @@ interface ShortTermReferencePictureSet {
/**
* 7.3.7 Short-term reference picture set syntax
*/
export function h265ParseShortTermReferencePictureSet(
export function parseShortTermReferencePictureSet(
reader: NaluSodbBitReader,
stRpsIdx: number,
num_short_term_ref_pic_sets: number,
@ -896,7 +859,7 @@ export function h265ParseShortTermReferencePictureSet(
};
}
export const H265AspectRatioIndicator = {
export const AspectRatioIndicator = {
Unspecified: 0,
Square: 1,
_12_11: 2,
@ -917,23 +880,23 @@ export const H265AspectRatioIndicator = {
Extended: 255,
} as const;
export type H265AspectRatioIndicator =
(typeof H265AspectRatioIndicator)[keyof typeof H265AspectRatioIndicator];
export type AspectRatioIndicator =
(typeof AspectRatioIndicator)[keyof typeof AspectRatioIndicator];
/**
* E.2.1 VUI parameters syntax
*/
export function h265ParseVuiParameters(
export function parseVuiParameters(
reader: NaluSodbBitReader,
sps_max_sub_layers_minus1: number,
) {
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_height: number | undefined;
if (aspect_ratio_info_present_flag) {
aspect_ratio_idc = reader.read(8) as H265AspectRatioIndicator;
if (aspect_ratio_idc === H265AspectRatioIndicator.Extended) {
aspect_ratio_idc = reader.read(8) as AspectRatioIndicator;
if (aspect_ratio_idc === AspectRatioIndicator.Extended) {
sar_width = 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_num_ticks_poc_diff_one_minus1: number | 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) {
vui_num_units_in_tick = reader.read(32);
vui_time_scale = reader.read(32);
@ -1006,7 +969,7 @@ export function h265ParseVuiParameters(
}
vui_hrd_parameters_present_flag = !!reader.next();
if (vui_hrd_parameters_present_flag) {
vui_hrd_parameters = h265ParseHrdParameters(
vui_hrd_parameters = parseHrdParameters(
reader,
true,
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
*/
export function h265ParseHrdParameters(
export function parseHrdParameters(
reader: NaluSodbBitReader,
commonInfPresentFlag: boolean,
maxNumSubLayersMinus1: number,
@ -1157,14 +1120,14 @@ export function h265ParseHrdParameters(
}
if (nal_hrd_parameters_present_flag) {
nalHrdParameters[i] = h265ParseSubLayerHrdParameters(
nalHrdParameters[i] = parseSubLayerHrdParameters(
reader,
i,
getCpbCnt(cpb_cnt_minus1[i]!),
);
}
if (vcl_hrd_parameters_present_flag) {
vclHrdParameters[i] = h265ParseSubLayerHrdParameters(
vclHrdParameters[i] = parseSubLayerHrdParameters(
reader,
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
*/
export function h265ParseSubLayerHrdParameters(
export function parseSubLayerHrdParameters(
reader: NaluSodbBitReader,
subLayerId: number,
CpbCnt: number,
@ -1234,15 +1197,15 @@ function getCpbCnt(cpb_cnt_minus_1: number) {
return cpb_cnt_minus_1 + 1;
}
export function h265SearchConfiguration(buffer: Uint8Array) {
let videoParameterSet!: H265NaluRaw;
let sequenceParameterSet!: H265NaluRaw;
let pictureParameterSet!: H265NaluRaw;
export function searchConfiguration(buffer: Uint8Array) {
let videoParameterSet!: NaluRaw;
let sequenceParameterSet!: NaluRaw;
let pictureParameterSet!: NaluRaw;
let count = 0;
for (const nalu of annexBSplitNalu(buffer)) {
const header = h265ParseNaluHeader(nalu);
const raw: H265NaluRaw = {
const header = parseNaluHeader(nalu);
const raw: NaluRaw = {
...header,
data: nalu,
rbsp: nalu.subarray(2),
@ -1274,18 +1237,18 @@ export function h265SearchConfiguration(buffer: Uint8Array) {
throw new Error("Invalid data");
}
export function h265ParseSpsMultilayerExtension(reader: NaluSodbBitReader) {
export function parseSpsMultilayerExtension(reader: NaluSodbBitReader) {
const inter_view_mv_vert_constraint_flag = !!reader.next();
return {
inter_view_mv_vert_constraint_flag,
};
}
export type H265SpsMultilayerExtension = ReturnType<
typeof h265ParseSpsMultilayerExtension
export type SpsMultilayerExtension = ReturnType<
typeof parseSpsMultilayerExtension
>;
export function h265ParseSps3dExtension(reader: NaluSodbBitReader) {
export function parseSps3dExtension(reader: NaluSodbBitReader) {
const iv_di_mc_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 {
videoParameterSet: H265NaluRaw;
sequenceParameterSet: H265NaluRaw;
pictureParameterSet: H265NaluRaw;
export interface Configuration {
videoParameterSet: NaluRaw;
sequenceParameterSet: NaluRaw;
pictureParameterSet: NaluRaw;
generalProfileSpace: number;
generalProfileIndex: number;
@ -1353,9 +1316,9 @@ export interface H265Configuration {
croppedHeight: number;
}
export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
export function parseConfiguration(data: Uint8Array): Configuration {
const { videoParameterSet, sequenceParameterSet, pictureParameterSet } =
h265SearchConfiguration(data);
searchConfiguration(data);
const {
profileTierLevel: {
@ -1368,7 +1331,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
},
general_level_idc: generalLevelIndex,
},
} = h265ParseVideoParameterSet(videoParameterSet.rbsp);
} = parseVideoParameterSet(videoParameterSet.rbsp);
const {
chroma_format_idc,
@ -1378,7 +1341,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
conf_win_right_offset: cropRight = 0,
conf_win_top_offset: cropTop = 0,
conf_win_bottom_offset: cropBottom = 0,
} = h265ParseSequenceParameterSet(sequenceParameterSet.rbsp);
} = parseSequenceParameterSet(sequenceParameterSet.rbsp);
const SubWidthC = getSubWidthC(chroma_format_idc);
const SubHeightC = getSubHeightC(chroma_format_idc);
@ -1408,3 +1371,23 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
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": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/event": "workspace:^",
"@yume-chan/media-codec": "workspace:^",
"@yume-chan/scrcpy": "workspace:^",
"@yume-chan/stream-extra": "workspace:^",
"tinyh264": "^0.0.7",

View file

@ -1,10 +1,13 @@
import { PromiseResolver } from "@yume-chan/async";
import { StickyEventEmitter } from "@yume-chan/event";
import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy";
import { H264 } from "@yume-chan/media-codec";
import type {
ScrcpyMediaStreamConfigurationPacket,
ScrcpyMediaStreamPacket,
} from "@yume-chan/scrcpy";
import {
AndroidAvcLevel,
AndroidAvcProfile,
h264ParseConfiguration,
ScrcpyVideoSizeImpl,
} from "@yume-chan/scrcpy";
import { WritableStream } from "@yume-chan/stream-extra";
import YuvBuffer from "yuv-buffer";
@ -14,6 +17,9 @@ import type {
ScrcpyVideoDecoder,
ScrcpyVideoDecoderCapability,
} 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 { createTinyH264Wrapper } from "./wrapper.js";
@ -21,16 +27,6 @@ const noop = () => {
// 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 {
static readonly capabilities: Record<string, ScrcpyVideoDecoderCapability> =
{
@ -40,34 +36,36 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
},
};
#renderer: HTMLCanvasElement | OffscreenCanvas;
get renderer() {
return this.#renderer;
#canvas: HTMLCanvasElement | OffscreenCanvas;
get canvas() {
return this.#canvas;
}
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>();
get sizeChanged() {
return this.#sizeChanged.event;
}
#width: number = 0;
#size = new ScrcpyVideoSizeImpl();
get width() {
return this.#width;
return this.#size.width;
}
#height: number = 0;
get height() {
return this.#height;
return this.#size.height;
}
get sizeChanged() {
return this.#size.sizeChanged;
}
#frameRendered = 0;
get framesRendered() {
return this.#frameRendered;
#counter = new PerformanceCounterImpl();
get framesDrawn() {
return this.#counter.framesDrawn;
}
get framesPresented() {
return this.#counter.framesPresented;
}
#frameSkipped = 0;
get framesSkipped() {
return this.#frameSkipped;
return this.#counter.framesSkipped;
}
#pause: PauseControllerImpl;
get paused() {
return this.#pause.paused;
}
#writable: WritableStream<ScrcpyMediaStreamPacket>;
@ -75,57 +73,62 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
return this.#writable;
}
#yuvCanvas: YuvCanvas | undefined;
#initializer: PromiseResolver<TinyH264Wrapper> | undefined;
#renderer: YuvCanvas | undefined;
#decoder: Promise<TinyH264Wrapper> | undefined;
constructor({ canvas }: TinyH264Decoder.Options = {}) {
if (canvas) {
this.#renderer = canvas;
this.#canvas = canvas;
} else {
this.#renderer = createCanvas();
this.#canvas = createCanvas();
}
this.#writable = new WritableStream<ScrcpyMediaStreamPacket>({
write: async (packet) => {
switch (packet.type) {
case "configuration":
await this.#configure(packet.data);
break;
case "data": {
if (!this.#initializer) {
this.#renderer = YuvCanvas.attach(this.#canvas, {
// yuv-canvas supports detecting WebGL support by creating a <canvas> itself
// But this doesn't work in Web Worker (with OffscreenCanvas)
// so we implement our own check here
webGL: glIsSupported({
// Disallow software rendering.
// yuv-canvas also supports 2d canvas
// which is faster than software-based WebGL.
failIfMajorPerformanceCaveat: true,
}),
});
this.#pause = new PauseControllerImpl(
this.#configure,
async (packet) => {
if (!this.#decoder) {
throw new Error("Decoder not configured");
}
const wrapper = await this.#initializer.promise;
wrapper.feed(packet.data.slice().buffer);
break;
}
}
// 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) {
this.dispose();
#configure = async ({
data,
}: ScrcpyMediaStreamConfigurationPacket): Promise<undefined> => {
this.#disposeDecoder();
this.#initializer = new PromiseResolver<TinyH264Wrapper>();
if (!this.#yuvCanvas) {
// yuv-canvas detects WebGL support by creating a <canvas> itself
// not working in worker
const canvas = createCanvas();
const attributes: WebGLContextAttributes = {
// Disallow software rendering.
// Other rendering methods are faster than software-based WebGL.
failIfMajorPerformanceCaveat: true,
};
const gl =
canvas.getContext("webgl2", attributes) ||
canvas.getContext("webgl", attributes);
this.#yuvCanvas = YuvCanvas.attach(this.#renderer, {
webGL: !!gl,
});
}
const resolver = new PromiseResolver<TinyH264Wrapper>();
this.#decoder = resolver.promise;
try {
const {
encodedWidth,
encodedHeight,
@ -133,14 +136,9 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
croppedHeight,
cropLeft,
cropTop,
} = h264ParseConfiguration(data);
} = H264.parseConfiguration(data);
this.#width = croppedWidth;
this.#height = croppedHeight;
this.#sizeChanged.fire({
width: croppedWidth,
height: croppedHeight,
});
this.#size.setSize(croppedWidth, croppedHeight);
// H.264 Baseline profile only supports YUV 420 pixel format
// So chroma width/height is each half of video width/height
@ -161,32 +159,79 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
displayHeight: croppedHeight,
});
const wrapper = await createTinyH264Wrapper();
this.#initializer.resolve(wrapper);
const decoder = await createTinyH264Wrapper();
const uPlaneOffset = encodedWidth * encodedHeight;
const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight;
wrapper.onPictureReady(({ data }) => {
this.#frameRendered += 1;
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),
YuvBuffer.chromaPlane(
format,
array,
chromaWidth,
uPlaneOffset,
),
YuvBuffer.chromaPlane(
format,
array,
chromaWidth,
vPlaneOffset,
),
);
this.#yuvCanvas!.drawFrame(frame);
// Can't know if yuv-canvas is dropping frames or not
this.#renderer!.drawFrame(frame);
this.#counter.increaseFramesDrawn();
});
wrapper.feed(data.slice().buffer);
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;
}
this.#decoder
.then((decoder) => decoder.dispose())
// NOOP: It's disposed so nobody cares about the error
.catch(noop);
this.#decoder = undefined;
}
dispose(): void {
this.#initializer?.promise
.then((wrapper) => wrapper.dispose())
// NOOP: It's disposed so nobody cares about the error
.catch(noop);
this.#initializer = undefined;
// This class doesn't need to guard against multiple dispose calls
// since most of the logic is already handled in `#pause`
this.#pause.dispose();
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 "./types.js";
export * from "./utils/index.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 {
ScrcpyMediaStreamPacket,
ScrcpyVideoCodecId,
ScrcpyVideoSize,
} from "@yume-chan/scrcpy";
import type { WritableStream } from "@yume-chan/stream-extra";
@ -10,14 +11,37 @@ export interface ScrcpyVideoDecoderCapability {
maxLevel?: number;
}
export interface ScrcpyVideoDecoder extends Disposable {
readonly sizeChanged: Event<{ width: number; height: number }>;
readonly width: number;
readonly height: number;
readonly framesRendered: number;
export interface ScrcpyVideoDecoderPerformanceCounter {
/**
* Gets the number of frames that have been drawn on the renderer
*/
readonly framesDrawn: 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;
}
export interface ScrcpyVideoDecoderPauseController {
readonly paused: boolean;
pause(): void;
resume(): Promise<undefined>;
}
export interface ScrcpyVideoDecoder
extends ScrcpyVideoDecoderPerformanceCounter,
ScrcpyVideoDecoderPauseController,
ScrcpyVideoSize,
Disposable {
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