diff --git a/.changeset/four-ants-post.md b/.changeset/four-ants-post.md new file mode 100644 index 00000000..4a9707ce --- /dev/null +++ b/.changeset/four-ants-post.md @@ -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. diff --git a/.changeset/tangy-bottles-dream.md b/.changeset/tangy-bottles-dream.md new file mode 100644 index 00000000..b9bb0a83 --- /dev/null +++ b/.changeset/tangy-bottles-dream.md @@ -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 diff --git a/.changeset/tangy-trains-return.md b/.changeset/tangy-trains-return.md new file mode 100644 index 00000000..da2e0aae --- /dev/null +++ b/.changeset/tangy-trains-return.md @@ -0,0 +1,5 @@ +--- +"@yume-chan/android-bin": major +--- + +Removed `IntentBuilder`. APIs now takes `Intent`s using plain objects (with TypeScript typing) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 76025ab1..e4b86fe9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ec3940f8..a8bad67f 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0ed02e8..9cd5ab89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/libraries/adb-credential-web/package.json b/libraries/adb-credential-web/package.json index ff780949..d11ba7ba 100644 --- a/libraries/adb-credential-web/package.json +++ b/libraries/adb-credential-web/package.json @@ -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:^", diff --git a/libraries/adb-credential-web/src/index.ts b/libraries/adb-credential-web/src/index.ts index 5e099a50..6528b220 100644 --- a/libraries/adb-credential-web/src/index.ts +++ b/libraries/adb-credential-web/src/index.ts @@ -1,121 +1,2 @@ -// cspell: ignore RSASSA - -import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb"; - -function openDatabase() { - return new Promise((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 { - 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((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 { - 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 { - for (const key of await getAllKeys()) { - yield { - buffer: key, - name: `${this.#appName}@${globalThis.location.hostname}`, - }; - } - } -} +export * from "./storage/index.js"; +export * from "./store.js"; diff --git a/libraries/adb-credential-web/src/storage/index.ts b/libraries/adb-credential-web/src/storage/index.ts new file mode 100644 index 00000000..57d21510 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/index.ts @@ -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"; diff --git a/libraries/adb-credential-web/src/storage/indexed-db.ts b/libraries/adb-credential-web/src/storage/indexed-db.ts new file mode 100644 index 00000000..c303319e --- /dev/null +++ b/libraries/adb-credential-web/src/storage/indexed-db.ts @@ -0,0 +1,89 @@ +import type { TangoDataStorage } from "./type.js"; + +function openDatabase() { + return new Promise((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( + database: IDBDatabase, + callback: (transaction: IDBTransaction) => T, +): Promise { + return new Promise((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 { + const db = await openDatabase(); + + try { + await createTransaction(db, (tx) => { + const store = tx.objectStore("Authentication"); + store.add(data); + }); + } finally { + db.close(); + } + } + + async *load(): AsyncGenerator { + const db = await openDatabase(); + + try { + const keys = await createTransaction(db, (tx) => { + return new Promise((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(); + } + } +} diff --git a/libraries/adb-credential-web/src/storage/local-storage.ts b/libraries/adb-credential-web/src/storage/local-storage.ts new file mode 100644 index 00000000..22d40679 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/local-storage.ts @@ -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 { + const data = localStorage.getItem(this.#storageKey); + if (data) { + yield decodeBase64(data); + } + } +} diff --git a/libraries/adb-credential-web/src/storage/password.ts b/libraries/adb-credential-web/src/storage/password.ts new file mode 100644 index 00000000..8a889908 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/password.ts @@ -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) { + 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): Promise { + 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 { + 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, + ); + + try { + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: bundle.aesIv as Uint8Array, + }, + aesKey, + bundle.encrypted as Uint8Array, + ); + + 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; + + export type PasswordIncorrectError = typeof PasswordIncorrectError; +} diff --git a/libraries/adb-credential-web/src/storage/prf/index.ts b/libraries/adb-credential-web/src/storage/prf/index.ts new file mode 100644 index 00000000..22a6e124 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/prf/index.ts @@ -0,0 +1,3 @@ +export * from "./source.js"; +export * from "./storage.js"; +export * from "./web-authn.js"; diff --git a/libraries/adb-credential-web/src/storage/prf/source.ts b/libraries/adb-credential-web/src/storage/prf/source.ts new file mode 100644 index 00000000..dabcd81f --- /dev/null +++ b/libraries/adb-credential-web/src/storage/prf/source.ts @@ -0,0 +1,11 @@ +export interface TangoPrfSource { + create(input: Uint8Array): Promise<{ + output: BufferSource; + id: Uint8Array; + }>; + + get( + id: BufferSource, + input: Uint8Array, + ): Promise; +} diff --git a/libraries/adb-credential-web/src/storage/prf/storage.ts b/libraries/adb-credential-web/src/storage/prf/storage.ts new file mode 100644 index 00000000..ec97a306 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/prf/storage.ts @@ -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, + salt: Uint8Array, +): Promise { + 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 | undefined; + + constructor(storage: TangoDataStorage, source: TangoPrfSource) { + this.#storage = storage; + this.#source = source; + } + + async save(data: Uint8Array): Promise { + const prfInput = new Uint8Array(PrfInputLength); + crypto.getRandomValues(prfInput); + + // Maybe reuse the credential, but use different PRF input and HKDF params + let id: Uint8Array; + 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 { + 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, + bundle.prfInput as Uint8Array, + ); + + this.#prevId = bundle.id as Uint8Array; + + const aesKey = await deriveAesKey( + prfOutput, + bundle.hkdfInfo as Uint8Array, + bundle.hkdfSalt as Uint8Array, + ); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: bundle.aesIv as Uint8Array, + }, + aesKey, + bundle.encrypted as Uint8Array, + ); + + 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); + } + } +} diff --git a/libraries/adb-credential-web/src/storage/prf/web-authn.ts b/libraries/adb-credential-web/src/storage/prf/web-authn.ts new file mode 100644 index 00000000..fdf60845 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/prf/web-authn.ts @@ -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 { + 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): Promise<{ + output: BufferSource; + id: Uint8Array; + }> { + 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, + ): Promise { + 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; +} diff --git a/libraries/adb-credential-web/src/storage/type.ts b/libraries/adb-credential-web/src/storage/type.ts new file mode 100644 index 00000000..301b95a4 --- /dev/null +++ b/libraries/adb-credential-web/src/storage/type.ts @@ -0,0 +1,7 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; + +export interface TangoDataStorage { + save(data: Uint8Array): MaybePromiseLike; + + load(): Iterable | AsyncIterable; +} diff --git a/libraries/adb-credential-web/src/store.ts b/libraries/adb-credential-web/src/store.ts new file mode 100644 index 00000000..14bdd2ae --- /dev/null +++ b/libraries/adb-credential-web/src/store.ts @@ -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 { + // 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 { + 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}`, + }; + } + } +} diff --git a/libraries/adb-daemon-webusb/src/device.ts b/libraries/adb-daemon-webusb/src/device.ts index a63409fb..31cab37c 100644 --- a/libraries/adb-daemon-webusb/src/device.ts +++ b/libraries/adb-daemon-webusb/src/device.ts @@ -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), ); diff --git a/libraries/adb-scrcpy/package.json b/libraries/adb-scrcpy/package.json index e3ae88db..ccfaa169 100644 --- a/libraries/adb-scrcpy/package.json +++ b/libraries/adb-scrcpy/package.json @@ -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:^" diff --git a/libraries/adb-scrcpy/src/client.ts b/libraries/adb-scrcpy/src/client.ts index c5694d3f..23406e94 100644 --- a/libraries/adb-scrcpy/src/client.ts +++ b/libraries/adb-scrcpy/src/client.ts @@ -134,9 +134,9 @@ export class AdbScrcpyClient> { } 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> { ]; 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[] = []; diff --git a/libraries/adb-scrcpy/src/video.ts b/libraries/adb-scrcpy/src/video.ts index aabdc195..56da9f73 100644 --- a/libraries/adb-scrcpy/src/video.ts +++ b/libraries/adb-scrcpy/src/video.ts @@ -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; #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,43 +41,46 @@ export class AdbScrcpyVideoStream { ) { this.#options = options; this.#metadata = metadata; + this.#stream = stream .pipeThrough(this.#options.createMediaStreamTransformer()) .pipeThrough( - new InspectStream((packet) => { - if (packet.type === "configuration") { - switch (metadata.codec) { - case ScrcpyVideoCodecId.H264: - this.#configureH264(packet.data); - break; - case ScrcpyVideoCodecId.H265: - this.#configureH265(packet.data); - break; - case ScrcpyVideoCodecId.AV1: - // AV1 configuration is in data packet - break; + new InspectStream( + (packet): undefined => { + if (packet.type === "configuration") { + switch (this.#metadata.codec) { + case ScrcpyVideoCodecId.H264: + this.#configureH264(packet.data); + break; + case ScrcpyVideoCodecId.H265: + this.#configureH265(packet.data); + break; + case ScrcpyVideoCodecId.AV1: + // AV1 configuration is in data packet + break; + } + } else if ( + this.#metadata.codec === ScrcpyVideoCodecId.AV1 + ) { + this.#configureAv1(packet.data); } - } else if (metadata.codec === ScrcpyVideoCodecId.AV1) { - this.#configureAv1(packet.data); - } - }), + }, + { + close: () => this.#size.dispose(), + cancel: () => this.#size.dispose(), + }, + ), ); } #configureH264(data: Uint8Array) { - 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); } } diff --git a/libraries/adb-scrcpy/tsconfig.json b/libraries/adb-scrcpy/tsconfig.json index a716ab0b..ed004b3c 100644 --- a/libraries/adb-scrcpy/tsconfig.json +++ b/libraries/adb-scrcpy/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../event/tsconfig.build.json" }, + { + "path": "../media-codec/tsconfig.build.json" + }, { "path": "../scrcpy/tsconfig.build.json" }, diff --git a/libraries/adb-server-node-tcp/tsconfig.build.json b/libraries/adb-server-node-tcp/tsconfig.build.json index f6d3c11a..4208f657 100644 --- a/libraries/adb-server-node-tcp/tsconfig.build.json +++ b/libraries/adb-server-node-tcp/tsconfig.build.json @@ -9,12 +9,4 @@ "node" ] }, - "references": [ - { - "path": "../adb/tsconfig.build.json" - }, - { - "path": "../stream-extra/tsconfig.build.json" - } - ] } diff --git a/libraries/adb-server-node-tcp/tsconfig.json b/libraries/adb-server-node-tcp/tsconfig.json index a96f9b5d..115014f1 100644 --- a/libraries/adb-server-node-tcp/tsconfig.json +++ b/libraries/adb-server-node-tcp/tsconfig.json @@ -2,6 +2,12 @@ "references": [ { "path": "./tsconfig.build.json" + }, + { + "path": "../adb/tsconfig.build.json" + }, + { + "path": "../stream-extra/tsconfig.build.json" } ] } diff --git a/libraries/adb/src/adb.ts b/libraries/adb/src/adb.ts index 4ade9dd9..d373b270 100644 --- a/libraries/adb/src/adb.ts +++ b/libraries/adb/src/adb.ts @@ -125,10 +125,12 @@ export class Adb implements Closeable { .pipeThrough(new ConcatStringStream()); } - getProp(key: string): Promise { - return this.subprocess.noneProtocol - .spawnWaitText(["getprop", key]) - .then((output) => output.trim()); + async getProp(key: string): Promise { + 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(" output.trim()); } async sync(): Promise { diff --git a/libraries/adb/src/commands/power.ts b/libraries/adb/src/commands/power.ts index 11877012..6aff0b3d 100644 --- a/libraries/adb/src/commands/power.ts +++ b/libraries/adb/src/commands/power.ts @@ -36,7 +36,10 @@ export class AdbPower extends AdbServiceBase { } powerOff(): Promise { - return this.adb.subprocess.noneProtocol.spawnWaitText(["reboot", "-p"]); + return this.adb.subprocess.noneProtocol + .spawn(["reboot", "-p"]) + .wait() + .toString(); } powerButton(longPress = false): Promise { @@ -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(); } /** diff --git a/libraries/adb/src/commands/subprocess/index.ts b/libraries/adb/src/commands/subprocess/index.ts index 483ec9ab..5a2af35f 100644 --- a/libraries/adb/src/commands/subprocess/index.ts +++ b/libraries/adb/src/commands/subprocess/index.ts @@ -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"; diff --git a/libraries/adb/src/commands/subprocess/none/service.ts b/libraries/adb/src/commands/subprocess/none/service.ts index 23c15a25..09776d8e 100644 --- a/libraries/adb/src/commands/subprocess/none/service.ts +++ b/libraries/adb/src/commands/subprocess/none/service.ts @@ -2,38 +2,47 @@ 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. - const socket = await this.#adb.createSocket( - `exec:${command.join(" ")}`, - ); - - if (signal?.aborted) { - await socket.close(); - throw signal.reason; - } - - return new AdbNoneProtocolProcessImpl(socket, signal); - }); this.#adb = adb; } + spawn = adbNoneProtocolSpawner(async (command, signal) => { + // Android 7 added `shell,raw:${command}` service which also triggers raw mode, + // but we want to use the most compatible one. + // + // Similar to SSH, we don't escape the `command`, + // because the command will be invoked by `sh -c`, + // it can contain environment variables (`KEY=value command`), + // and shell expansions (`echo "$KEY"` vs `echo '$KEY'`), + // which we can't know how to properly escape. + const socket = await this.#adb.createSocket( + `exec:${command.join(" ")}`, + ); + + if (signal?.aborted) { + await socket.close(); + throw signal.reason; + } + + return new AdbNoneProtocolProcessImpl(socket, signal); + }); + async pty( command?: string | readonly string[], ): Promise { 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(" "); } diff --git a/libraries/adb/src/commands/subprocess/none/spawner.ts b/libraries/adb/src/commands/subprocess/none/spawner.ts index a828bf78..9f0f0e12 100644 --- a/libraries/adb/src/commands/subprocess/none/spawner.ts +++ b/libraries/adb/src/commands/subprocess/none/spawner.ts @@ -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>; @@ -26,43 +28,54 @@ export interface AdbNoneProtocolProcess { kill(): MaybePromiseLike; } -export class AdbNoneProtocolSpawner { - readonly #spawn: ( +export type AdbNoneProtocolSpawner = ( + command: string | readonly string[], + signal?: AbortSignal, +) => Promise & + AdbSubprocessSpawner.Wait; + +export function adbNoneProtocolSpawner( + spawn: ( command: readonly string[], signal: AbortSignal | undefined, - ) => Promise; - - constructor( - spawn: ( - command: readonly string[], - signal: AbortSignal | undefined, - ) => Promise, - ) { - this.#spawn = spawn; - } - - spawn( - command: string | readonly string[], - signal?: AbortSignal, - ): Promise { + ) => Promise, +): 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 & + AdbSubprocessSpawner.Wait; - async spawnWait(command: string | readonly string[]): Promise { - 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 { - 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; + }; } diff --git a/libraries/adb/src/commands/subprocess/service.ts b/libraries/adb/src/commands/subprocess/service.ts index 725d11de..235abab5 100644 --- a/libraries/adb/src/commands/subprocess/service.ts +++ b/libraries/adb/src/commands/subprocess/service.ts @@ -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); } } diff --git a/libraries/adb/src/commands/subprocess/shell/process.ts b/libraries/adb/src/commands/subprocess/shell/process.ts index 2d73c5c4..0ffeda6d 100644 --- a/libraries/adb/src/commands/subprocess/shell/process.ts +++ b/libraries/adb/src/commands/subprocess/shell/process.ts @@ -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({ - 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, + }), + ), }); } diff --git a/libraries/adb/src/commands/subprocess/shell/service.ts b/libraries/adb/src/commands/subprocess/shell/service.ts index bfa9d684..3b58443b 100644 --- a/libraries/adb/src/commands/subprocess/shell/service.ts +++ b/libraries/adb/src/commands/subprocess/shell/service.ts @@ -3,34 +3,36 @@ 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) => { - const socket = await this.#adb.createSocket( - `shell,v2,raw:${command.join(" ")}`, - ); - - if (signal?.aborted) { - await socket.close(); - throw signal.reason; - } - - return new AdbShellProtocolProcessImpl(socket, signal); - }); this.#adb = adb; } + spawn = adbShellProtocolSpawner(async (command, signal) => { + // Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details. + const socket = await this.#adb.createSocket( + `shell,v2,raw:${command.join(" ")}`, + ); + + if (signal?.aborted) { + await socket.close(); + throw signal.reason; + } + + return new AdbShellProtocolProcessImpl(socket, signal); + }); + async pty(options?: { command?: string | readonly string[] | undefined; terminalType?: string; @@ -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)) { diff --git a/libraries/adb/src/commands/subprocess/shell/spawner.ts b/libraries/adb/src/commands/subprocess/shell/spawner.ts index 7d7b4f87..a06c659e 100644 --- a/libraries/adb/src/commands/subprocess/shell/spawner.ts +++ b/libraries/adb/src/commands/subprocess/shell/spawner.ts @@ -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>; @@ -24,62 +26,14 @@ export interface AdbShellProtocolProcess { kill(): MaybePromiseLike; } -export class AdbShellProtocolSpawner { - readonly #spawn: ( - command: readonly string[], - signal: AbortSignal | undefined, - ) => Promise; - - constructor( - spawn: ( - command: readonly string[], - signal: AbortSignal | undefined, - ) => Promise, - ) { - this.#spawn = spawn; - } - - spawn( - command: string | readonly string[], - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted(); - - if (typeof command === "string") { - command = splitCommand(command); - } - - return this.#spawn(command, signal); - } - - async spawnWait( - command: string | readonly string[], - ): Promise> { - const process = await this.spawn(command); - const [stdout, stderr, exitCode] = await Promise.all([ - process.stdout.pipeThrough(new ConcatBufferStream()), - process.stderr.pipeThrough(new ConcatBufferStream()), - process.exited, - ]); - return { stdout, stderr, exitCode }; - } - - async spawnWaitText( - command: string | readonly string[], - ): Promise> { - const process = await this.spawn(command); - const [stdout, stderr, exitCode] = await Promise.all([ - process.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.stderr - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new ConcatStringStream()), - process.exited, - ]); - return { stdout, stderr, exitCode }; - } -} +export type AdbShellProtocolSpawner = ( + command: string | readonly string[], + signal?: AbortSignal, +) => Promise & + AdbSubprocessSpawner.Wait< + AdbShellProtocolSpawner.WaitResult, + AdbShellProtocolSpawner.WaitResult + >; export namespace AdbShellProtocolSpawner { export interface WaitResult { @@ -88,3 +42,68 @@ export namespace AdbShellProtocolSpawner { exitCode: number; } } + +export function adbShellProtocolSpawner( + spawn: ( + command: readonly string[], + signal: AbortSignal | undefined, + ) => Promise, +): AdbShellProtocolSpawner { + return (command, signal) => { + signal?.throwIfAborted(); + + if (typeof command === "string") { + command = splitCommand(command); + } + + const processPromise = spawn( + command, + signal, + ) as Promise & + AdbSubprocessSpawner.Wait< + AdbShellProtocolSpawner.WaitResult, + AdbShellProtocolSpawner.WaitResult + >; + + 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; + }); + + 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; + }; +} diff --git a/libraries/adb/src/commands/subprocess/types.ts b/libraries/adb/src/commands/subprocess/types.ts new file mode 100644 index 00000000..4d07258a --- /dev/null +++ b/libraries/adb/src/commands/subprocess/types.ts @@ -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 { + toString(): Promise; + } + + export interface WaitOptions { + stdin?: ReadableStream> | undefined; + } + + export interface Wait { + wait( + options?: WaitOptions, + ): LazyPromise>; + } +} diff --git a/libraries/adb/src/commands/subprocess/utils.ts b/libraries/adb/src/commands/subprocess/utils.ts index ed7ce648..cda30700 100644 --- a/libraries/adb/src/commands/subprocess/utils.ts +++ b/libraries/adb/src/commands/subprocess/utils.ts @@ -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 = Omit, 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 & { asU(): Promise }`, + * 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 unknown>, +>(initializer: () => Promise, methods: U): LazyPromise { + let promise: Promise | 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 as LazyPromise; + + for (const [key, value] of Object.entries(methods)) { + Object.defineProperty(result, key, { + configurable: true, + writable: true, + enumerable: false, + value, + }); + } + + return result; +} + +export class ToArrayStream extends AccumulateStream { + 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; +} diff --git a/libraries/adb/src/commands/sync/id-common.ts b/libraries/adb/src/commands/sync/id-common.ts new file mode 100644 index 00000000..ba4769ca --- /dev/null +++ b/libraries/adb/src/commands/sync/id-common.ts @@ -0,0 +1,33 @@ +import { getUint32LittleEndian } from "@yume-chan/no-data-view"; + +function encodeAsciiUnchecked(value: string): Uint8Array { + 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"); diff --git a/libraries/adb/src/commands/sync/id-request.ts b/libraries/adb/src/commands/sync/id-request.ts new file mode 100644 index 00000000..76d31c5b --- /dev/null +++ b/libraries/adb/src/commands/sync/id-request.ts @@ -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"); diff --git a/libraries/adb/src/commands/sync/id-response.ts b/libraries/adb/src/commands/sync/id-response.ts new file mode 100644 index 00000000..03f85d91 --- /dev/null +++ b/libraries/adb/src/commands/sync/id-response.ts @@ -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"); diff --git a/libraries/adb/src/commands/sync/id.ts b/libraries/adb/src/commands/sync/id.ts new file mode 100644 index 00000000..ced9ddd1 --- /dev/null +++ b/libraries/adb/src/commands/sync/id.ts @@ -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 }; diff --git a/libraries/adb/src/commands/sync/index.ts b/libraries/adb/src/commands/sync/index.ts index 2ce53228..565440ec 100644 --- a/libraries/adb/src/commands/sync/index.ts +++ b/libraries/adb/src/commands/sync/index.ts @@ -1,3 +1,4 @@ +export * from "./id.js"; export * from "./list.js"; export * from "./pull.js"; export * from "./push.js"; diff --git a/libraries/adb/src/commands/sync/list.ts b/libraries/adb/src/commands/sync/list.ts index 531cdcb6..e769f4c8 100644 --- a/libraries/adb/src/commands/sync/list.ts +++ b/libraries/adb/src/commands/sync/list.ts @@ -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. diff --git a/libraries/adb/src/commands/sync/pull.ts b/libraries/adb/src/commands/sync/pull.ts index df937288..7dcb0e1e 100644 --- a/libraries/adb/src/commands/sync/pull.ts +++ b/libraries/adb/src/commands/sync/pull.ts @@ -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( diff --git a/libraries/adb/src/commands/sync/push.ts b/libraries/adb/src/commands/sync/push.ts index 1b93f5e6..7c53ed0c 100644 --- a/libraries/adb/src/commands/sync/push.ts +++ b/libraries/adb/src/commands/sync/push.ts @@ -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"; diff --git a/libraries/adb/src/commands/sync/request.ts b/libraries/adb/src/commands/sync/request.ts index 0e39029c..09b714df 100644 --- a/libraries/adb/src/commands/sync/request.ts +++ b/libraries/adb/src/commands/sync/request.ts @@ -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 { - if (typeof id === "string") { - id = adbSyncEncodeId(id); - } - if (typeof value === "number") { await writable.write( AdbSyncNumberRequest.serialize({ id, arg: value }), diff --git a/libraries/adb/src/commands/sync/response.ts b/libraries/adb/src/commands/sync/response.ts index eba2ccf1..663b8c52 100644 --- a/libraries/adb/src/commands/sync/response.ts +++ b/libraries/adb/src/commands/sync/response.ts @@ -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 { - 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( stream: AsyncExactReadable, - id: number | string, + id: number, type: StructDeserializer, ): Promise { - 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( export async function* adbSyncReadResponses( stream: AsyncExactReadable, - id: number | string, + id: number, type: StructDeserializer, ): AsyncGenerator { - if (typeof id === "string") { - id = adbSyncEncodeId(id); - } - while (true) { const buffer = await stream.readExactly(4); switch (getUint32LittleEndian(buffer, 0)) { diff --git a/libraries/adb/src/commands/sync/stat.ts b/libraries/adb/src/commands/sync/stat.ts index 152a7e48..6aa1623b 100644 --- a/libraries/adb/src/commands/sync/stat.ts +++ b/libraries/adb/src/commands/sync/stat.ts @@ -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 { diff --git a/libraries/adb/src/commands/sync/sync.ts b/libraries/adb/src/commands/sync/sync.ts index aa0771a4..f7dcabd7 100644 --- a/libraries/adb/src/commands/sync/sync.ts +++ b/libraries/adb/src/commands/sync/sync.ts @@ -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 { - 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, }); diff --git a/libraries/adb/src/daemon/auth.spec.ts b/libraries/adb/src/daemon/auth.spec.ts index 0b239ed5..806ac67c 100644 --- a/libraries/adb/src/daemon/auth.spec.ts +++ b/libraries/adb/src/daemon/auth.spec.ts @@ -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({ - command: AdbCommand.Auth, - arg0: AdbAuthType.Token, - arg1: 0, - payload: EmptyUint8Array, - }), - ); + const authenticator = new AdbDefaultAuthenticator(store); + const challenge = new Uint8Array(20); - const results: AdbPacketData[] = []; - for await (const result of authenticator) { - results.push(result); - } + const first = await authenticator.authenticate({ + command: AdbCommand.Auth, + arg0: AdbAuthType.Token, + arg1: 0, + payload: challenge, + }); + // This test focuses on public key authentication, so only check + // the first response is type Signature and ignore other fields + assert.strictEqual(first.command, AdbCommand.Auth); + assert.strictEqual(first.arg0, AdbAuthType.Signature); - assert.deepStrictEqual(results, [ - { - command: AdbCommand.Auth, - arg0: AdbAuthType.PublicKey, - arg1: 0, - payload: encodeUtf8(`${PUBLIC_KEY}\0`), - }, - ]); + const result = await authenticator.authenticate({ + command: AdbCommand.Auth, + arg0: AdbAuthType.Token, + arg1: 0, + payload: challenge, + }); + + 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({ - command: AdbCommand.Auth, - arg0: AdbAuthType.Token, - arg1: 0, - payload: EmptyUint8Array, - }), - ); + const authenticator = new AdbDefaultAuthenticator(store); + const challenge = new Uint8Array(20); - const results: AdbPacketData[] = []; - for await (const result of authenticator) { - results.push(result); - } + const first = await authenticator.authenticate({ + command: AdbCommand.Auth, + arg0: AdbAuthType.Token, + arg1: 0, + payload: challenge, + }); + // This test focuses on public key authentication, so only check + // the first response is type Signature and ignore other fields + assert.strictEqual(first.command, AdbCommand.Auth); + assert.strictEqual(first.arg0, AdbAuthType.Signature); - assert.deepStrictEqual(results, [ - { - command: AdbCommand.Auth, - arg0: AdbAuthType.PublicKey, - arg1: 0, - payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`), - }, - ]); + const result = await authenticator.authenticate({ + command: AdbCommand.Auth, + arg0: AdbAuthType.Token, + arg1: 0, + payload: challenge, + }); + + assert.deepStrictEqual(result, { + command: AdbCommand.Auth, + arg0: AdbAuthType.PublicKey, + arg1: 0, + payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`), + }); }); }); }); diff --git a/libraries/adb/src/daemon/auth.ts b/libraries/adb/src/daemon/auth.ts index cfc6fc7c..8bb2a1b5 100644 --- a/libraries/adb/src/daemon/auth.ts +++ b/libraries/adb/src/daemon/auth.ts @@ -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,153 +48,101 @@ 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, - ): AsyncIterable; + authenticate(packet: AdbPacketData): Promise; + + close?(): MaybePromiseLike; } -export const AdbSignatureAuthenticator: AdbAuthenticator = async function* ( - credentialStore: AdbCredentialStore, - getNextRequest: () => Promise, -): AsyncIterable { - for await (const key of credentialStore.iterateKeys()) { - const packet = await getNextRequest(); +export class AdbDefaultAuthenticator implements AdbAuthenticator { + #credentialStore: AdbCredentialStore; + #iterator: + | Iterator + | AsyncIterator + | undefined; + #firstKey: AdbPrivateKey | undefined; - if (packet.arg0 !== AdbAuthType.Token) { - return; - } - - const signature = rsaSign(key.buffer, packet.payload); - yield { - command: AdbCommand.Auth, - arg0: AdbAuthType.Signature, - arg1: 0, - payload: signature, - }; - } -}; - -export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* ( - credentialStore: AdbCredentialStore, - getNextRequest: () => Promise, -): AsyncIterable { - const packet = await getNextRequest(); - - if (packet.arg0 !== AdbAuthType.Token) { - return; + #onPublicKeyAuthentication = new EventEmitter(); + get onPublicKeyAuthentication() { + return this.#onPublicKeyAuthentication.event; } - let privateKey: AdbPrivateKey | undefined; - for await (const key of credentialStore.iterateKeys()) { - privateKey = key; - break; - } - - if (!privateKey) { - privateKey = await credentialStore.generateKey(); - } - - const publicKeyLength = adbGetPublicKeySize(); - const [publicKeyBase64Length] = - calculateBase64EncodedLength(publicKeyLength); - - const nameBuffer = privateKey.name?.length - ? encodeUtf8(privateKey.name) - : EmptyUint8Array; - const publicKeyBuffer = new Uint8Array( - publicKeyBase64Length + - (nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name - 1, // Null character - ); - - adbGeneratePublicKey(privateKey.buffer, publicKeyBuffer); - encodeBase64(publicKeyBuffer.subarray(0, publicKeyLength), publicKeyBuffer); - - if (nameBuffer.length) { - publicKeyBuffer[publicKeyBase64Length] = 0x20; - publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1); - } - - yield { - command: AdbCommand.Auth, - arg0: AdbAuthType.PublicKey, - arg1: 0, - payload: publicKeyBuffer, - }; -}; - -export const ADB_DEFAULT_AUTHENTICATORS: readonly AdbAuthenticator[] = [ - AdbSignatureAuthenticator, - AdbPublicKeyAuthenticator, -]; - -export class AdbAuthenticationProcessor implements Disposable { - readonly authenticators: readonly AdbAuthenticator[]; - - readonly #credentialStore: AdbCredentialStore; - - #pendingRequest = new PromiseResolver(); - #iterator: AsyncIterator | undefined; - - constructor( - authenticators: readonly AdbAuthenticator[], - credentialStore: AdbCredentialStore, - ) { - this.authenticators = authenticators; + constructor(credentialStore: AdbCredentialStore) { this.#credentialStore = credentialStore; } - #getNextRequest = (): Promise => { - return this.#pendingRequest.promise; - }; + async authenticate(packet: AdbPacketData): Promise { + if (packet.arg0 !== AdbAuthType.Token) { + throw new Error("Unsupported authentication packet"); + } - async *#invokeAuthenticator(): AsyncGenerator { - 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(); + 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"); + } + } - // Yield the response to outer layer - yield packet; + const { done, value } = await this.#iterator.next(); + if (!done) { + if (!this.#firstKey) { + this.#firstKey = value; } - // If the authenticator returned, - // Next authenticator will be given the same `pendingRequest` + return { + command: AdbCommand.Auth, + arg0: AdbAuthType.Signature, + arg1: 0, + payload: rsaSign(value, packet.payload), + }; } + + this.#onPublicKeyAuthentication.fire(); + + let key = this.#firstKey; + if (!key) { + key = await this.#credentialStore.generateKey(); + } + + const publicKeyLength = adbGetPublicKeySize(); + const [publicKeyBase64Length] = + calculateBase64EncodedLength(publicKeyLength); + + const nameBuffer = key.name?.length + ? encodeUtf8(key.name) + : EmptyUint8Array; + const publicKeyBuffer = new Uint8Array( + publicKeyBase64Length + + (nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name + 1, // Null character + ); + + adbGeneratePublicKey(key, publicKeyBuffer); + encodeBase64( + publicKeyBuffer.subarray(0, publicKeyLength), + publicKeyBuffer, + ); + + if (nameBuffer.length) { + publicKeyBuffer[publicKeyBase64Length] = 0x20; + publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1); + } + + return { + command: AdbCommand.Auth, + arg0: AdbAuthType.PublicKey, + arg1: 0, + payload: publicKeyBuffer, + }; } - async process(packet: AdbPacketData): Promise { - if (!this.#iterator) { - this.#iterator = this.#invokeAuthenticator(); - } + async close(): Promise { + await this.#iterator?.return?.(); - 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; } } diff --git a/libraries/adb/src/daemon/crypto.spec.ts b/libraries/adb/src/daemon/crypto.spec.ts index 9aff743c..a90a0cd0 100644 --- a/libraries/adb/src/daemon/crypto.spec.ts +++ b/libraries/adb/src/daemon/crypto.spec.ts @@ -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/, ); }); diff --git a/libraries/adb/src/daemon/crypto.ts b/libraries/adb/src/daemon/crypto.ts index 424dc8db..dbf24a93 100644 --- a/libraries/adb/src/daemon/crypto.ts +++ b/libraries/adb/src/daemon/crypto.ts @@ -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; 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 { - 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); diff --git a/libraries/adb/src/daemon/transport.ts b/libraries/adb/src/daemon/transport.ts index 9df8e25b..c660f1d9 100644 --- a/libraries/adb/src/daemon/transport.ts +++ b/libraries/adb/src/daemon/transport.ts @@ -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 { + }: AdbDaemonAuthenticationOptions & + ( + | { authenticator: AdbAuthenticator } + | { + credentialStore: AdbCredentialStore; + onPublicKeyAuthentication?: (() => void) | undefined; + } + )): Promise { // 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(); - 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, }); diff --git a/libraries/adb/src/features-value.ts b/libraries/adb/src/features-value.ts new file mode 100644 index 00000000..3e892423 --- /dev/null +++ b/libraries/adb/src/features-value.ts @@ -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"; diff --git a/libraries/adb/src/features.ts b/libraries/adb/src/features.ts index 7b7553c0..d3591fec 100644 --- a/libraries/adb/src/features.ts +++ b/libraries/adb/src/features.ts @@ -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[]; diff --git a/libraries/adb/src/server/transport.ts b/libraries/adb/src/server/transport.ts index 9b87440a..e86ff5b5 100644 --- a/libraries/adb/src/server/transport.ts +++ b/libraries/adb/src/server/transport.ts @@ -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 diff --git a/libraries/android-bin/src/am.ts b/libraries/android-bin/src/am.ts index f64ba178..741c5955 100644 --- a/libraries/android-bin/src/am.ts +++ b/libraries/android-bin/src/am.ts @@ -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 { - 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(); + } } } diff --git a/libraries/android-bin/src/bu.ts b/libraries/android-bin/src/bu.ts index 82acbfaa..8f6e5ef5 100644 --- a/libraries/android-bin/src/bu.ts +++ b/libraries/android-bin/src/bu.ts @@ -13,7 +13,7 @@ export interface AdbBackupOptions { } export interface AdbRestoreOptions { - user: number; + user?: number | undefined; file: ReadableStream>; } @@ -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(); } } diff --git a/libraries/android-bin/src/bug-report.ts b/libraries/android-bin/src/bug-report.ts index 7de44d56..efcb31f0 100644 --- a/libraries/android-bin/src/bug-report.ts +++ b/libraries/android-bin/src/bug-report.ts @@ -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) { diff --git a/libraries/android-bin/src/cmd.ts b/libraries/android-bin/src/cmd.ts deleted file mode 100644 index 6d354719..00000000 --- a/libraries/android-bin/src/cmd.ts +++ /dev/null @@ -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 - | ((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 - | ((service: string) => string), - ) { - super(async (command): Promise => { - if (this.#supportsAbb) { - return new AdbShellProtocolProcessImpl( - await this.#adb.createSocket(`abb:${command.join("\0")}\0`), - ); - } - - if (!adb.subprocess.shellProtocol) { - throw new Error("Unsupported"); - } - - if (this.#supportsCmd) { - return adb.subprocess.shellProtocol.spawn( - `cmd ${command.join(" ")}`, - ); - } - - if (typeof fallback === "function") { - fallback = fallback(command[0]!); - } else if (typeof fallback === "object") { - fallback = fallback[command[0]!]; - } - - if (!fallback) { - throw new Error("Unsupported"); - } - - 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 - | ((service: string) => string), - ) { - super(adb); - - if ( - adb.canUseFeature(AdbFeature.AbbExec) || - adb.canUseFeature(AdbFeature.Cmd) - ) { - this.#noneProtocol = new CmdNoneProtocolService(adb, fallback); - } - - if ( - adb.canUseFeature(AdbFeature.Abb) || - (adb.canUseFeature(AdbFeature.Cmd) && - adb.canUseFeature(AdbFeature.ShellV2)) - ) { - this.#shellProtocol = new CmdShellProtocolService(adb, fallback); - } - } -} diff --git a/libraries/android-bin/src/cmd/index.ts b/libraries/android-bin/src/cmd/index.ts new file mode 100644 index 00000000..2f69ec29 --- /dev/null +++ b/libraries/android-bin/src/cmd/index.ts @@ -0,0 +1 @@ +export * from "./service.js"; diff --git a/libraries/android-bin/src/cmd/none.ts b/libraries/android-bin/src/cmd/none.ts new file mode 100644 index 00000000..47bb08a7 --- /dev/null +++ b/libraries/android-bin/src/cmd/none.ts @@ -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.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; +} diff --git a/libraries/android-bin/src/cmd/service.ts b/libraries/android-bin/src/cmd/service.ts new file mode 100644 index 00000000..05591d76 --- /dev/null +++ b/libraries/android-bin/src/cmd/service.ts @@ -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 + | ((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; + } +} diff --git a/libraries/android-bin/src/cmd/shell.ts b/libraries/android-bin/src/cmd/shell.ts new file mode 100644 index 00000000..0c1eb95e --- /dev/null +++ b/libraries/android-bin/src/cmd/shell.ts @@ -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.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; +} diff --git a/libraries/android-bin/src/cmd/utils.ts b/libraries/android-bin/src/cmd/utils.ts new file mode 100644 index 00000000..d789866b --- /dev/null +++ b/libraries/android-bin/src/cmd/utils.ts @@ -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`; +} diff --git a/libraries/android-bin/src/demo-mode.ts b/libraries/android-bin/src/demo-mode.ts index 29230875..e288d663 100644 --- a/libraries/android-bin/src/demo-mode.ts +++ b/libraries/android-bin/src/demo-mode.ts @@ -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, - ): Promise { - await this.adb.subprocess.noneProtocol.spawnWaitText([ - "am", - "broadcast", - "-a", - "com.android.systemui.demo", - "-e", - "command", - command, - ...(extra - ? Object.entries(extra).flatMap(([key, value]) => [ - "-e", - key, - value, - ]) - : []), - ]); + broadcast(command: string, extras?: Record): Promise { + return this.#am.broadcast({ + action: "com.android.systemui.demo", + extras: { + command, + ...extras, + }, + }); } async setBatteryLevel(level: number): Promise { diff --git a/libraries/android-bin/src/dumpsys.ts b/libraries/android-bin/src/dumpsys.ts index 67b20ef9..353d2046 100644 --- a/libraries/android-bin/src/dumpsys.ts +++ b/libraries/android-bin/src/dumpsys.ts @@ -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 { - 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, diff --git a/libraries/android-bin/src/index.ts b/libraries/android-bin/src/index.ts index 942cef46..3fabb74a 100644 --- a/libraries/android-bin/src/index.ts +++ b/libraries/android-bin/src/index.ts @@ -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"; diff --git a/libraries/android-bin/src/intent.spec.ts b/libraries/android-bin/src/intent.spec.ts index 79d75cc9..88ceb7d1 100644 --- a/libraries/android-bin/src/intent.spec.ts +++ b/libraries/android-bin/src/intent.spec.ts @@ -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", () => { - assert.deepStrictEqual( - new IntentBuilder().setData("data_1").build(), - ["-d", "data_1"], - ); - }); + describe("extras", () => { + it("should serialize string extras", () => { + assert.deepStrictEqual( + serializeIntent({ + extras: { + key1: "value1", + }, + }), + ["--es", "key1", "value1"], + ); + }); - it("should pass intent extras", () => { - assert.deepStrictEqual( - new IntentBuilder().addStringExtra("key1", "value1").build(), - ["--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"], + ); + }); }); }); }); diff --git a/libraries/android-bin/src/intent.ts b/libraries/android-bin/src/intent.ts index 11747807..f51db882 100644 --- a/libraries/android-bin/src/intent.ts +++ b/libraries/android-bin/src/intent.ts @@ -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(); +// 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[] { - const result: string[] = []; - - if (this.#action) { - result.push("-a", this.#action); - } - - for (const category of this.#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) { - result.push("--es", key, value); - } - - return result; +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 (intent.action) { + result.push("-a", intent.action); + } + + if (intent.data) { + result.push("-d", intent.data); + } + + if (intent.type) { + result.push("-t", intent.type); + } + + if (intent.identifier) { + result.push("-i", intent.identifier); + } + + if (intent.categories) { + for (const category of intent.categories) { + result.push("-c", category); + } + } + + if (intent.extras) { + for (const [key, value] of Object.entries(intent.extras)) { + switch (typeof value) { + case "string": + result.push("--es", key, value); + break; + case "object": + if (value === null) { + result.push("--esn", key); + break; + } + + if ("packageName" in value) { + result.push( + "--ecn", + key, + value.packageName + "/" + value.className, + ); + break; + } + + switch (value.type) { + case "uri": + result.push("--eu", key, value.value); + break; + case "array": + case "arrayList": + { + const [type, valueString] = + serializeArray(value); + result.push(type, key, valueString); + } + break; + default: + result.push( + getNumberType(value.type), + key, + value.value.toString(), + ); + break; + } + break; + case "number": + result.push("--ei", key, value.toString()); + break; + case "boolean": + result.push("--ez", key, value ? "true" : "false"); + break; + } + } + } + + if (intent.component) { + result.push( + "-n", + intent.component.packageName + "/" + intent.component.className, + ); + } + + if (intent.package) { + result.push("-p", intent.package); + } + + // `0` is the default value for `flags` when deserializing + // so it can be omitted if it's either `undefined` or `0` + if (intent.flags) { + result.push("-f", intent.flags.toString()); + } + + return result; +} diff --git a/libraries/android-bin/src/logcat.ts b/libraries/android-bin/src/logcat.ts index 72bf92a5..a69790c9 100644 --- a/libraries/android-bin/src/logcat.ts +++ b/libraries/android-bin/src/logcat.ts @@ -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 { diff --git a/libraries/android-bin/src/pm.ts b/libraries/android-bin/src/pm.ts index bb00c8c5..57266060 100644 --- a/libraries/android-bin/src/pm.ts +++ b/libraries/android-bin/src/pm.ts @@ -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 { + 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( + name: string, + minApiLevel?: number, + maxApiLevel?: number, +): OptionDefinition { + return { + name, + minApiLevel, + maxApiLevel, + } as OptionDefinition; +} + +// 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("-l", undefined, 28), + replaceExisting: option("-r", undefined, 27), + skipExisting: option("-R", 28), + installerPackageName: option("-i"), + allowTest: option("-t"), + externalStorage: option("-s", undefined, 28), + internalStorage: option("-f"), + requestDowngrade: option("-d"), + grantRuntimePermissions: option("-g", 23), + restrictPermissions: option("--restrict-permissions", 29), + doNotKill: option("--dont-kill"), + originatingUri: option("--originating-uri"), + referrerUri: option("--referrer"), + inheritFrom: option("-p", 24), + packageName: option("--pkg", 28), + abi: option("--abi", 21), + instantApp: option("--ephemeral", 24), + full: option("--full", 26), + preload: option("--preload", 28), + user: option("--user", 21), + installLocation: option( + "--install-location", + 24, + ), + installReason: option("--install-reason", 29), + updateOwnership: option("--update-ownership", 34), + forceUuid: option("--force-uuid", 24), + forceSdk: option("--force-sdk", 24), + apex: option("--apex", 29), + forceNonStaged: option("--force-non-staged", 31), + multiPackage: option("--multi-package", 29), + staged: option("--staged", 29), + nonStaged: option("--non-staged", 35), + forceQueryable: option("--force-queryable", 30), + enableRollback: option("--enable-rollback", 29), + rollbackImpactLevel: option("--rollback-impact-level", 35), + wait: option("--wait", 30, 30), + noWait: option("--no-wait", 30, 30), + stagedReadyTimeout: option("--staged-ready-timeout", 31), + skipVerification: option("--skip-verification", 30), + skipEnable: option("--skip-enable", 34), + bypassLowTargetSdkBlock: option( + "--bypass-low-target-sdk-block", + 34, + ), + ignoreDexoptProfile: option("--ignore-dexopt-profile", 35), + packageSource: option("--package-source", 35), + dexoptCompilerFilter: option("--dexopt-compiler-filter", 35), + disableAutoInstallDependencies: option( + "--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 | 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, - ): Promise { - const args = buildInstallArguments("install", options); - args[0] = PackageManager.CommandName; + options?: PackageManagerInstallOptions, + ): Promise { + 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>, - options?: Partial, - ): Promise { + options?: PackageManagerInstallOptions, + ): Promise { 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>, - options?: Partial, + options?: PackageManagerInstallOptions, ): Promise { - // 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, + options?: Optional, ): AsyncGenerator { - 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 { - const args = [PackageManager.ServiceName, "-p", packageName]; - const process = await this.#cmd.spawn(args); - const result: string[] = []; - for await (const line of process.output + /** + * 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 { + // `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"))) { - if (line.startsWith("package:")) { - result.push(line.substring("package:".length)); + .pipeThrough(new SplitStringStream("\n", { trim: true })); + + const result: string[] = []; + 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, + options?: Optional, ): Promise { - 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 { - 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, + options?: PackageManagerInstallOptions, ): Promise { - 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) { @@ -566,15 +620,18 @@ export class PackageManager extends AdbServiceBase { splitName: string, path: string, ): Promise { - 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>, ): Promise { - 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 { - 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 { - 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, + options?: PackageManagerInstallOptions, ): Promise { 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 { return this.#packageManager.sessionCommit(this.#id); } diff --git a/libraries/android-bin/src/settings.ts b/libraries/android-bin/src/settings.ts index ee066190..160e15a7 100644 --- a/libraries/android-bin/src/settings.ts +++ b/libraries/android-bin/src/settings.ts @@ -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 { - 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 { 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( diff --git a/libraries/android-bin/src/utils.ts b/libraries/android-bin/src/utils.ts index aa943466..06d6813e 100644 --- a/libraries/android-bin/src/utils.ts +++ b/libraries/android-bin/src/utils.ts @@ -1,4 +1,4 @@ -export function buildArguments( +export function buildCommand( commands: readonly string[], options: Partial | undefined, map: Partial>, @@ -6,19 +6,34 @@ export function buildArguments( const args = commands.slice(); if (options) { for (const [key, value] of Object.entries(options)) { - if (value) { - const option = map[key as keyof T]; - if (option) { - args.push(option); - switch (typeof value) { - case "number": - args.push(value.toString()); - break; - case "string": - args.push(value); - break; + if (value === undefined || value === null) { + continue; + } + + const option = map[key as keyof T]; + // 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(option, value.toString()); + break; + case "string": + args.push(option, value); + break; + default: + throw new Error( + `Unsupported type for option ${key}: ${typeof value}`, + ); } } } @@ -27,3 +42,5 @@ export function buildArguments( export type SingleUser = number | "current"; export type SingleUserOrAll = SingleUser | "all"; + +export type Optional = { [K in keyof T]?: T[K] | undefined }; diff --git a/libraries/android-bin/tsconfig.build.json b/libraries/android-bin/tsconfig.build.json index 9f83c319..785678ad 100644 --- a/libraries/android-bin/tsconfig.build.json +++ b/libraries/android-bin/tsconfig.build.json @@ -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" - } - ] } diff --git a/libraries/android-bin/tsconfig.json b/libraries/android-bin/tsconfig.json index d6787a22..3c679408 100644 --- a/libraries/android-bin/tsconfig.json +++ b/libraries/android-bin/tsconfig.json @@ -5,6 +5,15 @@ }, { "path": "./tsconfig.test.json" + }, + { + "path": "../adb/tsconfig.build.json" + }, + { + "path": "../stream-extra/tsconfig.build.json" + }, + { + "path": "../struct/tsconfig.build.json" } ] } diff --git a/libraries/aoa/src/hid.ts b/libraries/aoa/src/hid.ts index e15b8ec0..bda9a7a6 100644 --- a/libraries/aoa/src/hid.ts +++ b/libraries/aoa/src/hid.ts @@ -20,7 +20,7 @@ export async function aoaHidRegister( export async function aoaHidSetReportDescriptor( device: USBDevice, accessoryId: number, - reportDescriptor: Uint8Array, + 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, + event: BufferSource, ) { await device.controlTransferOut( { @@ -80,9 +80,9 @@ export class AoaHidDevice { static async register( device: USBDevice, accessoryId: number, - reportDescriptor: Uint8Array, + 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) { + async sendInputReport(event: BufferSource) { await aoaHidSendInputReport(this.#device, this.#accessoryId, event); } diff --git a/libraries/media-codec/.npmignore b/libraries/media-codec/.npmignore new file mode 100644 index 00000000..e44e2e62 --- /dev/null +++ b/libraries/media-codec/.npmignore @@ -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 diff --git a/libraries/media-codec/LICENSE b/libraries/media-codec/LICENSE new file mode 100644 index 00000000..248899ac --- /dev/null +++ b/libraries/media-codec/LICENSE @@ -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. diff --git a/libraries/media-codec/README.md b/libraries/media-codec/README.md new file mode 100644 index 00000000..b0d446b6 --- /dev/null +++ b/libraries/media-codec/README.md @@ -0,0 +1,3 @@ +# @yume-chan/media-codec + +H.264, H.265 and AV1 configuration packet parser diff --git a/libraries/media-codec/package.json b/libraries/media-codec/package.json new file mode 100644 index 00000000..a9255358 --- /dev/null +++ b/libraries/media-codec/package.json @@ -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" + } +} diff --git a/libraries/scrcpy/src/codec/av1.ts b/libraries/media-codec/src/av1.ts similarity index 91% rename from libraries/scrcpy/src/codec/av1.ts rename to libraries/media-codec/src/av1.ts index ce3cc7b1..eedb75d7 100644 --- a/libraries/scrcpy/src/codec/av1.ts +++ b/libraries/media-codec/src/av1.ts @@ -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() { diff --git a/libraries/media-codec/src/format.ts b/libraries/media-codec/src/format.ts new file mode 100644 index 00000000..6754b5eb --- /dev/null +++ b/libraries/media-codec/src/format.ts @@ -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); +} diff --git a/libraries/scrcpy/src/codec/h264.ts b/libraries/media-codec/src/h264.ts similarity index 88% rename from libraries/scrcpy/src/codec/h264.ts rename to libraries/media-codec/src/h264.ts index 23223fa0..9c72a621 100644 --- a/libraries/scrcpy/src/codec/h264.ts +++ b/libraries/media-codec/src/h264.ts @@ -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) + ); +} diff --git a/libraries/scrcpy/src/codec/h265.spec.ts b/libraries/media-codec/src/h265.spec.ts similarity index 98% rename from libraries/scrcpy/src/codec/h265.spec.ts rename to libraries/media-codec/src/h265.spec.ts index 2bf71d0b..cf7f3196 100644 --- a/libraries/scrcpy/src/codec/h265.spec.ts +++ b/libraries/media-codec/src/h265.spec.ts @@ -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); + } satisfies ReturnType); }); 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); + } satisfies ReturnType); }); }); }); diff --git a/libraries/scrcpy/src/codec/h265.ts b/libraries/media-codec/src/h265.ts similarity index 89% rename from libraries/scrcpy/src/codec/h265.ts rename to libraries/media-codec/src/h265.ts index 8ffb95be..fb5c4eff 100644 --- a/libraries/scrcpy/src/codec/h265.ts +++ b/libraries/media-codec/src/h265.ts @@ -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; +export type NaluHeader = ReturnType; -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; +export type ProfileTier = ReturnType; -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; +export type VuiParameters = ReturnType; /** * 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; +export type HrdParameters = ReturnType; /** * 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; +export type Sps3dExtension = ReturnType; -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("."); +} diff --git a/libraries/media-codec/src/index.ts b/libraries/media-codec/src/index.ts new file mode 100644 index 00000000..5a161e5b --- /dev/null +++ b/libraries/media-codec/src/index.ts @@ -0,0 +1,4 @@ +export * from "./av1.js"; +export * as H264 from "./h264.js"; +export * as H265 from "./h265.js"; +export * from "./nalu.js"; diff --git a/libraries/scrcpy/src/codec/nalu.spec.ts b/libraries/media-codec/src/nalu.spec.ts similarity index 100% rename from libraries/scrcpy/src/codec/nalu.spec.ts rename to libraries/media-codec/src/nalu.spec.ts diff --git a/libraries/scrcpy/src/codec/nalu.ts b/libraries/media-codec/src/nalu.ts similarity index 100% rename from libraries/scrcpy/src/codec/nalu.ts rename to libraries/media-codec/src/nalu.ts diff --git a/libraries/media-codec/tsconfig.build.json b/libraries/media-codec/tsconfig.build.json new file mode 100644 index 00000000..2cb23249 --- /dev/null +++ b/libraries/media-codec/tsconfig.build.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json" +} diff --git a/libraries/media-codec/tsconfig.json b/libraries/media-codec/tsconfig.json new file mode 100644 index 00000000..f6f0ecb4 --- /dev/null +++ b/libraries/media-codec/tsconfig.json @@ -0,0 +1,13 @@ +{ + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "../no-data-view/tsconfig.build.json" + }, + ] +} diff --git a/libraries/media-codec/tsconfig.test.json b/libraries/media-codec/tsconfig.test.json new file mode 100644 index 00000000..6a105912 --- /dev/null +++ b/libraries/media-codec/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "types": [ + "node" + ], + }, + "exclude": [] +} diff --git a/libraries/scrcpy-decoder-tinyh264/package.json b/libraries/scrcpy-decoder-tinyh264/package.json index b516a521..62e7070a 100644 --- a/libraries/scrcpy-decoder-tinyh264/package.json +++ b/libraries/scrcpy-decoder-tinyh264/package.json @@ -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", diff --git a/libraries/scrcpy-decoder-tinyh264/src/decoder.ts b/libraries/scrcpy-decoder-tinyh264/src/decoder.ts index 5b7ced21..d02d9d3d 100644 --- a/libraries/scrcpy-decoder-tinyh264/src/decoder.ts +++ b/libraries/scrcpy-decoder-tinyh264/src/decoder.ts @@ -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 = { @@ -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; @@ -75,118 +73,165 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder { return this.#writable; } - #yuvCanvas: YuvCanvas | undefined; - #initializer: PromiseResolver | undefined; + #renderer: YuvCanvas | undefined; + #decoder: Promise | undefined; constructor({ canvas }: TinyH264Decoder.Options = {}) { if (canvas) { - this.#renderer = canvas; + this.#canvas = canvas; } else { - this.#renderer = createCanvas(); + this.#canvas = createCanvas(); } - this.#writable = new WritableStream({ - write: async (packet) => { - switch (packet.type) { - case "configuration": - await this.#configure(packet.data); - break; - case "data": { - if (!this.#initializer) { - throw new Error("Decoder not configured"); - } + this.#renderer = YuvCanvas.attach(this.#canvas, { + // yuv-canvas supports detecting WebGL support by creating a 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, + }), + }); - const wrapper = await this.#initializer.promise; - wrapper.feed(packet.data.slice().buffer); - break; - } + this.#pause = new PauseControllerImpl( + this.#configure, + async (packet) => { + if (!this.#decoder) { + throw new Error("Decoder not configured"); } + + // TinyH264 decoder doesn't support associating metadata + // with each frame's input/output + // so skipping frames when resuming from pause is not supported + + const decoder = await this.#decoder; + + // `packet.data` might be from a `BufferCombiner` so we have to copy it using `slice` + decoder.feed(packet.data.slice().buffer); }, + ); + + this.#writable = new WritableStream({ + 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 => { + this.#disposeDecoder(); - this.#initializer = new PromiseResolver(); - if (!this.#yuvCanvas) { - // yuv-canvas detects WebGL support by creating a 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(); + this.#decoder = resolver.promise; + + try { + const { + encodedWidth, + encodedHeight, + croppedWidth, + croppedHeight, + cropLeft, + cropTop, + } = H264.parseConfiguration(data); + + 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 + const chromaWidth = encodedWidth / 2; + const chromaHeight = encodedHeight / 2; + + // YUVCanvas will set canvas size when format changes + const format = YuvBuffer.format({ + width: encodedWidth, + height: encodedHeight, + chromaWidth, + chromaHeight, + cropLeft: cropLeft, + cropTop: cropTop, + cropWidth: croppedWidth, + cropHeight: croppedHeight, + displayWidth: croppedWidth, + displayHeight: croppedHeight, }); + + const decoder = await createTinyH264Wrapper(); + + const uPlaneOffset = encodedWidth * encodedHeight; + const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight; + decoder.onPictureReady(({ data }) => { + const array = new Uint8Array(data); + const frame = YuvBuffer.frame( + format, + YuvBuffer.lumaPlane(format, array, encodedWidth, 0), + YuvBuffer.chromaPlane( + format, + array, + chromaWidth, + uPlaneOffset, + ), + YuvBuffer.chromaPlane( + format, + array, + chromaWidth, + vPlaneOffset, + ), + ); + + // Can't know if yuv-canvas is dropping frames or not + this.#renderer!.drawFrame(frame); + this.#counter.increaseFramesDrawn(); + }); + + decoder.feed(data.slice().buffer); + + resolver.resolve(decoder); + } catch (e) { + resolver.reject(e); + } + }; + + pause(): void { + this.#pause.pause(); + } + + resume(): Promise { + return this.#pause.resume(); + } + + /** + * Only dispose the TinyH264 decoder instance. + * + * This will be called when re-configuring multiple times, + * we don't want to dispose other parts (e.g. `#counter`) on that case + */ + #disposeDecoder() { + if (!this.#decoder) { + return; } - const { - encodedWidth, - encodedHeight, - croppedWidth, - croppedHeight, - cropLeft, - cropTop, - } = h264ParseConfiguration(data); - - this.#width = croppedWidth; - this.#height = croppedHeight; - this.#sizeChanged.fire({ - width: croppedWidth, - height: croppedHeight, - }); - - // H.264 Baseline profile only supports YUV 420 pixel format - // So chroma width/height is each half of video width/height - const chromaWidth = encodedWidth / 2; - const chromaHeight = encodedHeight / 2; - - // YUVCanvas will set canvas size when format changes - const format = YuvBuffer.format({ - width: encodedWidth, - height: encodedHeight, - chromaWidth, - chromaHeight, - cropLeft: cropLeft, - cropTop: cropTop, - cropWidth: croppedWidth, - cropHeight: croppedHeight, - displayWidth: croppedWidth, - displayHeight: croppedHeight, - }); - - const wrapper = await createTinyH264Wrapper(); - this.#initializer.resolve(wrapper); - - const uPlaneOffset = encodedWidth * encodedHeight; - const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight; - wrapper.onPictureReady(({ data }) => { - this.#frameRendered += 1; - const array = new Uint8Array(data); - const frame = YuvBuffer.frame( - format, - YuvBuffer.lumaPlane(format, array, encodedWidth, 0), - YuvBuffer.chromaPlane(format, array, chromaWidth, uPlaneOffset), - YuvBuffer.chromaPlane(format, array, chromaWidth, vPlaneOffset), - ); - this.#yuvCanvas!.drawFrame(frame); - }); - - wrapper.feed(data.slice().buffer); + 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; } } diff --git a/libraries/scrcpy-decoder-tinyh264/src/index.ts b/libraries/scrcpy-decoder-tinyh264/src/index.ts index 1e1b695b..bf066aed 100644 --- a/libraries/scrcpy-decoder-tinyh264/src/index.ts +++ b/libraries/scrcpy-decoder-tinyh264/src/index.ts @@ -1,3 +1,4 @@ export * from "./decoder.js"; export * from "./types.js"; +export * from "./utils/index.js"; export * from "./wrapper.js"; diff --git a/libraries/scrcpy-decoder-tinyh264/src/types.ts b/libraries/scrcpy-decoder-tinyh264/src/types.ts index 32a00dd9..59dad419 100644 --- a/libraries/scrcpy-decoder-tinyh264/src/types.ts +++ b/libraries/scrcpy-decoder-tinyh264/src/types.ts @@ -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; +} + +export interface ScrcpyVideoDecoder + extends ScrcpyVideoDecoderPerformanceCounter, + ScrcpyVideoDecoderPauseController, + ScrcpyVideoSize, + Disposable { readonly writable: WritableStream; } diff --git a/libraries/scrcpy-decoder-tinyh264/src/utils/gl.ts b/libraries/scrcpy-decoder-tinyh264/src/utils/gl.ts new file mode 100644 index 00000000..f2e647e4 --- /dev/null +++ b/libraries/scrcpy-decoder-tinyh264/src/utils/gl.ts @@ -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; +} diff --git a/libraries/scrcpy-decoder-tinyh264/src/utils/index.ts b/libraries/scrcpy-decoder-tinyh264/src/utils/index.ts new file mode 100644 index 00000000..e3816bd2 --- /dev/null +++ b/libraries/scrcpy-decoder-tinyh264/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./gl.js"; +export * from "./pause.js"; +export * from "./performance.js"; diff --git a/libraries/scrcpy-decoder-tinyh264/src/utils/pause.ts b/libraries/scrcpy-decoder-tinyh264/src/utils/pause.ts new file mode 100644 index 00000000..79fa3396 --- /dev/null +++ b/libraries/scrcpy-decoder-tinyh264/src/utils/pause.ts @@ -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; + #onFrame: ( + packet: ScrcpyMediaStreamDataPacket, + skipRendering: boolean, + ) => MaybePromiseLike; + + /** + * 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; + + #disposed = false; + + constructor( + onConfiguration: ( + packet: ScrcpyMediaStreamConfigurationPacket, + ) => MaybePromiseLike, + onFrame: ( + packet: ScrcpyMediaStreamDataPacket, + skipRendering: boolean, + ) => MaybePromiseLike, + ) { + this.#onConfiguration = onConfiguration; + this.#onFrame = onFrame; + } + + write = async (packet: ScrcpyMediaStreamPacket): Promise => { + 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 { + if (this.#disposed) { + throw new Error("Attempt to resume a closed decoder"); + } + + if (!this.#paused) { + return; + } + + const resolver = new PromiseResolver(); + 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; + } +} diff --git a/libraries/scrcpy-decoder-tinyh264/src/utils/performance.ts b/libraries/scrcpy-decoder-tinyh264/src/utils/performance.ts new file mode 100644 index 00000000..fd9214c5 --- /dev/null +++ b/libraries/scrcpy-decoder-tinyh264/src/utils/performance.ts @@ -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); + } + } +} diff --git a/libraries/scrcpy-decoder-tinyh264/tsconfig.build.json b/libraries/scrcpy-decoder-tinyh264/tsconfig.build.json index ff4d5014..bae95b66 100644 --- a/libraries/scrcpy-decoder-tinyh264/tsconfig.build.json +++ b/libraries/scrcpy-decoder-tinyh264/tsconfig.build.json @@ -6,12 +6,4 @@ "DOM" ] }, - "references": [ - { - "path": "../scrcpy/tsconfig.build.json" - }, - { - "path": "../stream-extra/tsconfig.build.json" - } - ] } diff --git a/libraries/scrcpy-decoder-tinyh264/tsconfig.json b/libraries/scrcpy-decoder-tinyh264/tsconfig.json index d6787a22..bbb4c49b 100644 --- a/libraries/scrcpy-decoder-tinyh264/tsconfig.json +++ b/libraries/scrcpy-decoder-tinyh264/tsconfig.json @@ -5,6 +5,15 @@ }, { "path": "./tsconfig.test.json" - } + }, + { + "path": "../media-codec/tsconfig.build.json" + }, + { + "path": "../scrcpy/tsconfig.build.json" + }, + { + "path": "../stream-extra/tsconfig.build.json" + }, ] } diff --git a/libraries/scrcpy-decoder-webcodecs/package.json b/libraries/scrcpy-decoder-webcodecs/package.json index 1941b7ee..2ccaf72b 100644 --- a/libraries/scrcpy-decoder-webcodecs/package.json +++ b/libraries/scrcpy-decoder-webcodecs/package.json @@ -35,6 +35,7 @@ "dependencies": { "@yume-chan/async": "^4.1.3", "@yume-chan/event": "workspace:^", + "@yume-chan/media-codec": "workspace:^", "@yume-chan/no-data-view": "workspace:^", "@yume-chan/scrcpy": "workspace:^", "@yume-chan/scrcpy-decoder-tinyh264": "workspace:^", diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/codec/av1.ts b/libraries/scrcpy-decoder-webcodecs/src/video/codec/av1.ts index e1b269d4..f2447e59 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/codec/av1.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/codec/av1.ts @@ -1,8 +1,7 @@ +import { Av1 } from "@yume-chan/media-codec"; import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy"; -import { Av1 } from "@yume-chan/scrcpy"; import type { CodecDecoder } from "./type.js"; -import { decimalTwoDigits } from "./utils.js"; export class Av1Codec implements CodecDecoder { #decoder: VideoDecoder; @@ -24,66 +23,17 @@ export class Av1Codec implements CodecDecoder { return; } - const { - seq_profile: seqProfile, - seq_level_idx: [seqLevelIdx = 0], - max_frame_width_minus_1, - max_frame_height_minus_1, - 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; - } - - const width = max_frame_width_minus_1 + 1; - const height = max_frame_height_minus_1 + 1; - + const width = sequenceHeader.max_frame_width_minus_1 + 1; + const height = sequenceHeader.max_frame_height_minus_1 + 1; this.#updateSize(width, height); - const codec = [ - "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("."); this.#decoder.configure({ - codec, + codec: Av1.toCodecString(sequenceHeader), optimizeForLatency: true, }); } - decode(packet: ScrcpyMediaStreamPacket): void { + decode(packet: ScrcpyMediaStreamPacket): undefined { if (packet.type === "configuration") { return; } @@ -93,7 +43,10 @@ export class Av1Codec implements CodecDecoder { new EncodedVideoChunk({ // Treat `undefined` as `key`, otherwise it won't decode. type: packet.keyframe === false ? "delta" : "key", - timestamp: 0, + // HACK: `timestamp` is only used as a marker to skip paused frames, + // so it's fine as long as we can differentiate `0` from non-zeros. + // Hope `packet.pts` won't be too large to lose precision. + timestamp: packet.pts !== undefined ? Number(packet.pts) : 1, data: packet.data, }), ); diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h264.ts b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h264.ts index c03ed06d..de6df854 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h264.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h264.ts @@ -1,7 +1,6 @@ -import { h264ParseConfiguration } from "@yume-chan/scrcpy"; +import { H264 } from "@yume-chan/media-codec"; import { H26xDecoder } from "./h26x.js"; -import { hexTwoDigits } from "./utils.js"; export class H264Decoder extends H26xDecoder { #decoder: VideoDecoder; @@ -17,25 +16,15 @@ export class H264Decoder extends H26xDecoder { } override configure(data: Uint8Array): void { - const { - profileIndex, - constraintSet, - levelIndex, - croppedWidth, - croppedHeight, - } = h264ParseConfiguration(data); + const configuration = H264.parseConfiguration(data); - this.#updateSize(croppedWidth, croppedHeight); + this.#updateSize( + configuration.croppedWidth, + configuration.croppedHeight, + ); - // https://www.rfc-editor.org/rfc/rfc6381#section-3.3 - // ISO Base Media File Format Name Space - const codec = - "avc1." + - hexTwoDigits(profileIndex) + - hexTwoDigits(constraintSet) + - hexTwoDigits(levelIndex); this.#decoder.configure({ - codec: codec, + codec: H264.toCodecString(configuration), optimizeForLatency: true, }); } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h265.ts b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h265.ts index 3b81f9f8..99057e5f 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h265.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h265.ts @@ -1,8 +1,6 @@ -import { getUint32LittleEndian } from "@yume-chan/no-data-view"; -import { h265ParseConfiguration } from "@yume-chan/scrcpy"; +import { H265 } from "@yume-chan/media-codec"; import { H26xDecoder } from "./h26x.js"; -import { hexDigits } from "./utils.js"; export class H265Decoder extends H26xDecoder { #decoder: VideoDecoder; @@ -18,32 +16,20 @@ export class H265Decoder extends H26xDecoder { } override configure(data: Uint8Array): void { - const { - generalProfileSpace, - generalProfileIndex, - generalProfileCompatibilitySet, - generalTierFlag, - generalLevelIndex, - generalConstraintSet, - croppedWidth, - croppedHeight, - } = h265ParseConfiguration(data); + const configuration = H265.parseConfiguration(data); - this.#updateSize(croppedWidth, croppedHeight); + this.#updateSize( + configuration.croppedWidth, + configuration.croppedHeight, + ); - const codec = [ - "hev1", - ["", "A", "B", "C"][generalProfileSpace]! + - generalProfileIndex.toString(), - hexDigits(getUint32LittleEndian(generalProfileCompatibilitySet, 0)), - (generalTierFlag ? "H" : "L") + generalLevelIndex.toString(), - ...Array.from(generalConstraintSet, hexDigits), - ].join("."); this.#decoder.configure({ - codec, - // Microsoft Edge requires explicit size to work - codedWidth: croppedWidth, - codedHeight: croppedHeight, + codec: H265.toCodecString(configuration), + // Microsoft Edge on Windows requires explicit size, + // otherwise it returns frames in incorrect size. + // And it needs cropped size, as opposed to the option name. + codedWidth: configuration.croppedWidth, + codedHeight: configuration.croppedHeight, optimizeForLatency: true, }); } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h26x.ts b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h26x.ts index ee79e2e9..a483fae8 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/codec/h26x.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/codec/h26x.ts @@ -12,7 +12,7 @@ export abstract class H26xDecoder implements CodecDecoder { abstract configure(data: Uint8Array): void; - decode(packet: ScrcpyMediaStreamPacket): void { + decode(packet: ScrcpyMediaStreamPacket): undefined { if (packet.type === "configuration") { this.#config = packet.data; this.configure(packet.data); @@ -37,7 +37,10 @@ export abstract class H26xDecoder implements CodecDecoder { new EncodedVideoChunk({ // Treat `undefined` as `key`, otherwise won't decode. type: packet.keyframe === false ? "delta" : "key", - timestamp: 0, + // HACK: `timestamp` is only used as a marker to skip paused frames, + // so it's fine as long as we can differentiate `0` from non-zeros. + // Hope `packet.pts` won't be too large to lose precision. + timestamp: packet.pts !== undefined ? Number(packet.pts) : 1, data, }), ); diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/codec/type.ts b/libraries/scrcpy-decoder-webcodecs/src/video/codec/type.ts index 1ed859b7..f81e99f5 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/codec/type.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/codec/type.ts @@ -1,7 +1,7 @@ import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy"; export interface CodecDecoder { - decode(packet: ScrcpyMediaStreamPacket): void; + decode(packet: ScrcpyMediaStreamPacket): undefined; } export interface CodecDecoderConstructor { diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/decoder.ts b/libraries/scrcpy-decoder-webcodecs/src/video/decoder.ts index bb61953a..2c4a1774 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/decoder.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/decoder.ts @@ -1,10 +1,13 @@ -import { StickyEventEmitter } from "@yume-chan/event"; import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy"; -import { ScrcpyVideoCodecId } from "@yume-chan/scrcpy"; +import { ScrcpyVideoCodecId, ScrcpyVideoSizeImpl } from "@yume-chan/scrcpy"; import type { ScrcpyVideoDecoder, ScrcpyVideoDecoderCapability, } from "@yume-chan/scrcpy-decoder-tinyh264"; +import { + PauseControllerImpl, + PerformanceCounterImpl, +} from "@yume-chan/scrcpy-decoder-tinyh264"; import type { WritableStreamDefaultController } from "@yume-chan/stream-extra"; import { WritableStream } from "@yume-chan/stream-extra"; @@ -35,55 +38,65 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { return this.#codec; } - #codecDecoder: CodecDecoder; + #error: Error | undefined; #writable: WritableStream; + #controller!: WritableStreamDefaultController; get writable() { return this.#writable; } - #error: Error | undefined; - #controller!: WritableStreamDefaultController; - #renderer: VideoFrameRenderer; get renderer() { return this.#renderer; } - #framesDraw = 0; - #framesPresented = 0; - get framesRendered() { - return this.#framesPresented; - } - - #framesSkipped = 0; - get framesSkipped() { - return this.#framesSkipped; - } - - #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; } - #decoder: VideoDecoder; + #counter = new PerformanceCounterImpl(); + get framesDrawn() { + return this.#counter.framesDrawn; + } + get framesPresented() { + return this.#counter.framesPresented; + } + get framesSkipped() { + return this.#counter.framesSkipped; + } + + #pause: PauseControllerImpl; + get paused() { + return this.#pause.paused; + } + + #rawDecoder: VideoDecoder; + #decoder: CodecDecoder; + + #framesDecoded = 0; + get framesDecoded() { + return this.#framesDecoded; + } + #decodingTime = 0; + /** + * Accumulated decoding time in milliseconds + */ + get decodingTime() { + return this.#decodingTime; + } #drawing = false; #nextFrame: VideoFrame | undefined; #captureFrame: VideoFrame | undefined; - #animationFrameId = 0; - /** * Create a new WebCodecs video decoder. */ @@ -92,21 +105,27 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { this.#renderer = renderer; - this.#decoder = new VideoDecoder({ + this.#rawDecoder = new VideoDecoder({ output: (frame) => { + if (this.#error) { + frame.close(); + return; + } + + // Skip rendering frames while resuming from pause + if (frame.timestamp === 0) { + frame.close(); + return; + } + + this.#framesDecoded += 1; + this.#decodingTime += + performance.now() - frame.timestamp / 1000; + this.#captureFrame?.close(); // PERF: `VideoFrame#clone` is cheap this.#captureFrame = frame.clone(); - if (this.#drawing) { - if (this.#nextFrame) { - this.#nextFrame.close(); - this.#framesSkipped += 1; - } - this.#nextFrame = frame; - return; - } - void this.#draw(frame); }, error: (error) => { @@ -116,27 +135,60 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { switch (this.#codec) { case ScrcpyVideoCodecId.H264: - this.#codecDecoder = new H264Decoder( - this.#decoder, + this.#decoder = new H264Decoder( + this.#rawDecoder, this.#updateSize, ); break; case ScrcpyVideoCodecId.H265: - this.#codecDecoder = new H265Decoder( - this.#decoder, + this.#decoder = new H265Decoder( + this.#rawDecoder, this.#updateSize, ); break; case ScrcpyVideoCodecId.AV1: - this.#codecDecoder = new Av1Codec( - this.#decoder, + this.#decoder = new Av1Codec( + this.#rawDecoder, this.#updateSize, ); break; default: - throw new Error(`Unsupported codec: ${this.#codec as number}`); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unsupported codec: ${this.#codec}`); } + this.#pause = new PauseControllerImpl( + (packet) => this.#decoder.decode(packet), + (packet, skipRendering) => { + let pts: bigint; + + if (skipRendering) { + // Set `pts` to 0 as a marker for skipping rendering this frame + pts = 0n; + } else { + // Set `pts` to current time to track decoding time + + // Technically `performance.now()` can return 0 (when document starts loading), + // but in practice it's impossible to call it at that time. + const now = performance.now(); + + // `now` can be an integer, so `us` needs a default value + const [ms, us = ""] = now.toString().split("."); + + // Multiply `performance.now()` by 1000 to get microseconds. + // Use string concatenation to prevent precision loss. + pts = BigInt(ms + (us + "000").slice(0, 3)); + } + + // Create a copy of `packet` because other code (like recording) + // needs the original `pts` + return this.#decoder.decode({ + ...packet, + pts, + }); + }, + ); + this.#writable = new WritableStream({ start: (controller) => { if (this.#error) { @@ -145,46 +197,66 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { this.#controller = controller; } }, - write: (packet) => { - this.#codecDecoder.decode(packet); - }, + 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, + // and they need to be presented. }); - - this.#handleAnimationFrame(); } #setError(error: Error) { - if (this.#controller) { - try { - this.#controller.error(error); - } catch { - // ignore - } - } else { - this.#error = error; + if (this.#error) { + return; } + + this.#error = error; + + try { + this.#controller?.error(error); + } catch { + // ignore + } + + this.dispose(); } async #draw(frame: VideoFrame) { try { - this.#drawing = true; - // PERF: Draw every frame to minimize latency at cost of performance. - // When multiple frames are drawn in one vertical sync interval, - // only the last one is visible to users. - // But this ensures users can always see the most up-to-date screen. - // This is also the behavior of official Scrcpy client. - // https://github.com/Genymobile/scrcpy/issues/3679 - this.#updateSize(frame.displayWidth, frame.displayHeight); - await this.#renderer.draw(frame); - this.#framesDraw += 1; - frame.close(); - - if (this.#nextFrame) { - const frame = this.#nextFrame; - this.#nextFrame = undefined; - await this.#draw(frame); + if (this.#drawing) { + if (this.#nextFrame) { + // Frame `n` is still drawing, frame `n + m` (m > 0) is waiting, and frame `n + m + 1` comes. + // Dispose frame `n + m` and set frame `n + m + 1` as the next frame. + this.#nextFrame.close(); + this.#counter.increaseFramesSkipped(); + } + this.#nextFrame = frame; + return; } + this.#drawing = true; + + do { + this.#updateSize(frame.displayWidth, frame.displayHeight); + + // PERF: Draw every frame to minimize latency at cost of performance. + // When multiple frames are drawn in one vertical sync interval, + // only the last one is visible to users. + // But this ensures users can always see the most up-to-date screen. + // This is also the behavior of official Scrcpy client. + // https://github.com/Genymobile/scrcpy/issues/3679 + await this.#renderer.draw(frame); + frame.close(); + + this.#counter.increaseFramesDrawn(); + + if (this.#nextFrame) { + frame = this.#nextFrame; + this.#nextFrame = undefined; + } else { + break; + } + } while (true); + this.#drawing = false; } catch (error) { this.#setError(error as Error); @@ -193,20 +265,7 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { #updateSize = (width: number, height: number) => { this.#renderer.setSize(width, height); - this.#width = width; - this.#height = height; - this.#sizeChanged.fire({ width, height }); - }; - - #handleAnimationFrame = () => { - if (this.#framesDraw > 0) { - this.#framesPresented += 1; - this.#framesSkipped += this.#framesDraw - 1; - this.#framesDraw = 0; - } - this.#animationFrameId = requestAnimationFrame( - this.#handleAnimationFrame, - ); + this.#size.setSize(width, height); }; async snapshot() { @@ -216,18 +275,38 @@ export class WebCodecsVideoDecoder implements ScrcpyVideoDecoder { } const capturer = await VideoFrameCapturerPool.borrow(); - const result = await capturer.capture(frame); - VideoFrameCapturerPool.return(capturer); - return result; + try { + return await capturer.capture(frame); + } finally { + VideoFrameCapturerPool.return(capturer); + } + } + + pause(): void { + this.#pause.pause(); + } + + resume(): Promise { + return this.#pause.resume(); } dispose() { - cancelAnimationFrame(this.#animationFrameId); - if (this.#decoder.state !== "closed") { - this.#decoder.close(); - } - this.#nextFrame?.close(); this.#captureFrame?.close(); + + this.#counter.dispose(); + this.#renderer.dispose(); + this.#size.dispose(); + this.#nextFrame?.close(); + + if (this.#rawDecoder.state !== "closed") { + this.#rawDecoder.close(); + } + + // 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.#setError(new Error("Attempt to write to a disposed decoder")); } } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/render/canvas.ts b/libraries/scrcpy-decoder-webcodecs/src/video/render/canvas.ts index c8e362db..ce8d47fd 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/render/canvas.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/render/canvas.ts @@ -1,3 +1,4 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; import { createCanvas } from "@yume-chan/scrcpy-decoder-tinyh264"; import type { VideoFrameRenderer } from "./type.js"; @@ -24,4 +25,10 @@ export abstract class CanvasVideoFrameRenderer implements VideoFrameRenderer { } abstract draw(frame: VideoFrame): Promise; + + dispose(): MaybePromiseLike { + this.#canvas.width = 0; + this.#canvas.height = 0; + return undefined; + } } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/render/insertable-stream.ts b/libraries/scrcpy-decoder-webcodecs/src/video/render/insertable-stream.ts index 40518a76..4efab048 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/render/insertable-stream.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/render/insertable-stream.ts @@ -1,5 +1,9 @@ // cspell: ignore insertable +import type { MaybePromiseLike } from "@yume-chan/async"; +import type { WritableStreamDefaultWriter } from "@yume-chan/stream-extra"; +import { tryClose } from "@yume-chan/stream-extra"; + import type { VideoFrameRenderer } from "./type.js"; declare class MediaStreamTrackGenerator extends MediaStreamTrack { @@ -34,6 +38,7 @@ export class InsertableStreamVideoFrameRenderer implements VideoFrameRenderer { } this.#element.muted = true; this.#element.autoplay = true; + this.#element.playsInline = true; this.#element.disablePictureInPicture = true; this.#element.disableRemotePlayback = true; @@ -41,7 +46,10 @@ export class InsertableStreamVideoFrameRenderer implements VideoFrameRenderer { // But Chrome has not implemented it yet. // https://issues.chromium.org/issues/40058895 this.#generator = new MediaStreamTrackGenerator({ kind: "video" }); - this.#writer = this.#generator.writable.getWriter(); + this.#generator.contentHint = "motion"; + + this.#writer = + this.#generator.writable.getWriter() as WritableStreamDefaultWriter; this.#stream = new MediaStream([this.#generator]); this.#element.srcObject = this.#stream; @@ -54,7 +62,12 @@ export class InsertableStreamVideoFrameRenderer implements VideoFrameRenderer { } } - async draw(frame: VideoFrame): Promise { - await this.#writer.write(frame); + draw(frame: VideoFrame): Promise { + return this.#writer.write(frame); + } + + dispose(): MaybePromiseLike { + tryClose(this.#writer); + return undefined; } } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/render/type.ts b/libraries/scrcpy-decoder-webcodecs/src/video/render/type.ts index eb3f9732..c1b13d5e 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/render/type.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/render/type.ts @@ -4,4 +4,6 @@ export interface VideoFrameRenderer { setSize(width: number, height: number): void; draw(frame: VideoFrame): MaybePromiseLike; + + dispose(): MaybePromiseLike; } diff --git a/libraries/scrcpy-decoder-webcodecs/src/video/render/webgl.ts b/libraries/scrcpy-decoder-webcodecs/src/video/render/webgl.ts index d3d8556d..915cb1de 100644 --- a/libraries/scrcpy-decoder-webcodecs/src/video/render/webgl.ts +++ b/libraries/scrcpy-decoder-webcodecs/src/video/render/webgl.ts @@ -1,31 +1,16 @@ -import { createCanvas } from "@yume-chan/scrcpy-decoder-tinyh264"; +import type { MaybePromiseLike } from "@yume-chan/async"; +import { + glCreateContext, + glIsSupported, + glLoseContext, +} from "@yume-chan/scrcpy-decoder-tinyh264"; import { CanvasVideoFrameRenderer } from "./canvas.js"; const Resolved = Promise.resolve(); -function createContext( - canvas: HTMLCanvasElement | OffscreenCanvas, - enableCapture?: boolean, -): WebGLRenderingContext | null { - const attributes: WebGLContextAttributes = { - // Low-power GPU should be enough for video rendering. - powerPreference: "low-power", - alpha: false, - // Disallow software rendering. - // Other rendering methods are faster than software-based WebGL. - failIfMajorPerformanceCaveat: true, - preserveDrawingBuffer: !!enableCapture, - }; - - return ( - canvas.getContext("webgl2", attributes) || - canvas.getContext("webgl", attributes) - ); -} - export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { - static vertexShaderSource = ` + static VertexShaderSource = ` attribute vec2 xy; varying highp vec2 uv; @@ -38,9 +23,10 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { } `; - static fragmentShaderSource = ` - varying highp vec2 uv; + static FragmentShaderSource = ` + precision mediump float; + varying highp vec2 uv; uniform sampler2D texture; void main(void) { @@ -49,11 +35,14 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { `; static get isSupported() { - const canvas = createCanvas(); - return !!createContext(canvas); + return glIsSupported({ + // Disallow software rendering. + // `ImageBitmapRenderingContext` is faster than software-based WebGL. + failIfMajorPerformanceCaveat: true, + }); } - #context: WebGLRenderingContext; + #context: WebGLRenderingContext | WebGL2RenderingContext; /** * Create a new WebGL frame renderer. @@ -68,16 +57,30 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { ) { super(canvas); - const gl = createContext(this.canvas, enableCapture); + const gl = glCreateContext(this.canvas, { + // Low-power GPU should be enough for video rendering. + powerPreference: "low-power", + alpha: false, + // Disallow software rendering. + // `ImageBitmapRenderingContext` is faster than software-based WebGL. + failIfMajorPerformanceCaveat: true, + preserveDrawingBuffer: !!enableCapture, + // Enable desynchronized mode when not capturing to reduce latency. + desynchronized: !enableCapture, + antialias: false, + depth: false, + premultipliedAlpha: true, + stencil: false, + }); if (!gl) { - throw new Error("WebGL not supported"); + throw new Error("WebGL not supported, check `isSupported` first"); } this.#context = gl; const vertexShader = gl.createShader(gl.VERTEX_SHADER)!; gl.shaderSource( vertexShader, - WebGLVideoFrameRenderer.vertexShaderSource, + WebGLVideoFrameRenderer.VertexShaderSource, ); gl.compileShader(vertexShader); if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) { @@ -87,7 +90,7 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!; gl.shaderSource( fragmentShader, - WebGLVideoFrameRenderer.fragmentShaderSource, + WebGLVideoFrameRenderer.FragmentShaderSource, ); gl.compileShader(fragmentShader); if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) { @@ -120,7 +123,14 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + // WebGL 1 doesn't support mipmaps for non-power-of-two textures + gl instanceof WebGL2RenderingContext + ? gl.NEAREST_MIPMAP_LINEAR + : gl.NEAREST, + ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); } @@ -136,9 +146,19 @@ export class WebGLVideoFrameRenderer extends CanvasVideoFrameRenderer { frame, ); + // WebGL 1 doesn't support mipmaps for non-power-of-two textures + if (gl instanceof WebGL2RenderingContext) { + gl.generateMipmap(gl.TEXTURE_2D); + } + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); return Resolved; } + + override dispose(): MaybePromiseLike { + glLoseContext(this.#context); + return undefined; + } } diff --git a/libraries/scrcpy-decoder-webcodecs/tsconfig.build.json b/libraries/scrcpy-decoder-webcodecs/tsconfig.build.json index 4bb2510f..4581198d 100644 --- a/libraries/scrcpy-decoder-webcodecs/tsconfig.build.json +++ b/libraries/scrcpy-decoder-webcodecs/tsconfig.build.json @@ -7,18 +7,4 @@ ], "types": [] }, - "references": [ - { - "path": "../no-data-view/tsconfig.build.json" - }, - { - "path": "../scrcpy-decoder-tinyh264/tsconfig.build.json" - }, - { - "path": "../scrcpy/tsconfig.build.json" - }, - { - "path": "../stream-extra/tsconfig.build.json" - } - ] } diff --git a/libraries/scrcpy-decoder-webcodecs/tsconfig.json b/libraries/scrcpy-decoder-webcodecs/tsconfig.json index d6787a22..f7966eec 100644 --- a/libraries/scrcpy-decoder-webcodecs/tsconfig.json +++ b/libraries/scrcpy-decoder-webcodecs/tsconfig.json @@ -5,6 +5,21 @@ }, { "path": "./tsconfig.test.json" + }, + { + "path": "../media-codec/tsconfig.build.json" + }, + { + "path": "../no-data-view/tsconfig.build.json" + }, + { + "path": "../scrcpy-decoder-tinyh264/tsconfig.build.json" + }, + { + "path": "../scrcpy/tsconfig.build.json" + }, + { + "path": "../stream-extra/tsconfig.build.json" } ] } diff --git a/libraries/scrcpy/package.json b/libraries/scrcpy/package.json index 43340a01..0f474b6a 100644 --- a/libraries/scrcpy/package.json +++ b/libraries/scrcpy/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@yume-chan/async": "^4.1.3", + "@yume-chan/event": "workspace:^", "@yume-chan/no-data-view": "workspace:^", "@yume-chan/stream-extra": "workspace:^", "@yume-chan/struct": "workspace:^" diff --git a/libraries/scrcpy/src/1_21/options.spec.ts b/libraries/scrcpy/src/1_21/options.spec.ts index 6a16f934..ca6b7b37 100644 --- a/libraries/scrcpy/src/1_21/options.spec.ts +++ b/libraries/scrcpy/src/1_21/options.spec.ts @@ -1,7 +1,7 @@ import * as assert from "node:assert"; import { describe, it } from "node:test"; -import { AndroidAvcProfile } from "../codec/index.js"; +import { AndroidAvcProfile } from "../video/index.js"; import { CodecOptions } from "./impl/index.js"; import { ScrcpyOptions1_21 } from "./options.js"; diff --git a/libraries/scrcpy/src/codec/index.ts b/libraries/scrcpy/src/codec/index.ts deleted file mode 100644 index 1c572e8c..00000000 --- a/libraries/scrcpy/src/codec/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./av1.js"; -export * from "./h264.js"; -export * from "./h265.js"; -export * from "./nalu.js"; diff --git a/libraries/scrcpy/src/index.ts b/libraries/scrcpy/src/index.ts index 68b343e2..cde34c89 100644 --- a/libraries/scrcpy/src/index.ts +++ b/libraries/scrcpy/src/index.ts @@ -31,7 +31,7 @@ export * from "./3_3_1/index.js"; export * from "./3_3_2.js"; export * from "./android/index.js"; export * from "./base/index.js"; -export * from "./codec/index.js"; export * from "./control/index.js"; export * from "./latest.js"; export * from "./utils/index.js"; +export * from "./video/index.js"; diff --git a/libraries/scrcpy/src/video/android.ts b/libraries/scrcpy/src/video/android.ts new file mode 100644 index 00000000..640d28b3 --- /dev/null +++ b/libraries/scrcpy/src/video/android.ts @@ -0,0 +1,72 @@ +// 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, +}; + +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, +}; diff --git a/libraries/scrcpy/src/video/index.ts b/libraries/scrcpy/src/video/index.ts new file mode 100644 index 00000000..0387a55f --- /dev/null +++ b/libraries/scrcpy/src/video/index.ts @@ -0,0 +1,2 @@ +export * from "./android.js"; +export * from "./size.js"; diff --git a/libraries/scrcpy/src/video/size.ts b/libraries/scrcpy/src/video/size.ts new file mode 100644 index 00000000..fc33caf1 --- /dev/null +++ b/libraries/scrcpy/src/video/size.ts @@ -0,0 +1,40 @@ +import type { Event } from "@yume-chan/event"; +import { StickyEventEmitter } from "@yume-chan/event"; + +export interface ScrcpyVideoSize { + readonly width: number; + readonly height: number; + + readonly sizeChanged: Event<{ width: number; height: number }>; +} + +export class ScrcpyVideoSizeImpl implements ScrcpyVideoSize { + #width: number = 0; + get width() { + return this.#width; + } + + #height: number = 0; + get height() { + return this.#height; + } + + #sizeChanged = new StickyEventEmitter<{ width: number; height: number }>(); + get sizeChanged() { + return this.#sizeChanged.event; + } + + setSize(width: number, height: number) { + if (this.#width === width && this.#height === height) { + return; + } + + this.#width = width; + this.#height = height; + this.#sizeChanged.fire({ width, height }); + } + + dispose() { + this.#sizeChanged.dispose(); + } +} diff --git a/libraries/scrcpy/tsconfig.build.json b/libraries/scrcpy/tsconfig.build.json index 468186f0..785678ad 100644 --- a/libraries/scrcpy/tsconfig.build.json +++ b/libraries/scrcpy/tsconfig.build.json @@ -1,11 +1,3 @@ { "extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json", - "references": [ - { - "path": "../stream-extra/tsconfig.build.json" - }, - { - "path": "../struct/tsconfig.build.json" - } - ] } diff --git a/libraries/scrcpy/tsconfig.json b/libraries/scrcpy/tsconfig.json index d6787a22..d33fdead 100644 --- a/libraries/scrcpy/tsconfig.json +++ b/libraries/scrcpy/tsconfig.json @@ -5,6 +5,12 @@ }, { "path": "./tsconfig.test.json" + }, + { + "path": "../stream-extra/tsconfig.build.json" + }, + { + "path": "../struct/tsconfig.build.json" } ] } diff --git a/libraries/stream-extra/src/concat.spec.ts b/libraries/stream-extra/src/concat.spec.ts index 0e6e9517..e48f3dcd 100644 --- a/libraries/stream-extra/src/concat.spec.ts +++ b/libraries/stream-extra/src/concat.spec.ts @@ -49,7 +49,7 @@ describe("ConcatStringStream", () => { const stream = new ConcatStringStream(); const reason = new Error("aborted"); await stream.writable.getWriter().abort(reason); - await assert.rejects(() => stream.readable, reason); + await assert.rejects(async () => await stream.readable, reason); await assert.rejects(() => stream.readable.getReader().read(), reason); }); }); @@ -123,7 +123,7 @@ describe("ConcatBufferStream", () => { const stream = new ConcatBufferStream(); const reason = new Error("aborted"); await stream.writable.getWriter().abort(reason); - await assert.rejects(() => stream.readable, reason); + await assert.rejects(async () => await stream.readable, reason); await assert.rejects(() => stream.readable.getReader().read(), reason); }); }); diff --git a/libraries/stream-extra/src/concat.ts b/libraries/stream-extra/src/concat.ts index 90841c10..aae5b35d 100644 --- a/libraries/stream-extra/src/concat.ts +++ b/libraries/stream-extra/src/concat.ts @@ -1,162 +1,167 @@ import { PromiseResolver } from "@yume-chan/async"; import { EmptyUint8Array } from "@yume-chan/struct"; -import type { ReadableStreamDefaultController } from "./stream.js"; +import type { ReadableWritablePair } from "./stream.js"; import { ReadableStream, WritableStream } from "./stream.js"; -export interface ConcatStringReadableStream - extends ReadableStream, - Promise {} - // `TransformStream` only calls its `source.flush` method when its `readable` is being read. -// If the user want to use the `Promise` interface, the `flush` method will never be called, +// If the user wants to use the `Promise` interface, the `flush` method will never be called, // so the `PromiseResolver` will never be resolved. // Thus we need to implement our own `TransformStream` using a `WritableStream` and a `ReadableStream`. +export class AccumulateStream + implements ReadableWritablePair +{ + #current: Accumulated; + + #write: (chunk: Input, current: Accumulated) => Accumulated; + #finalize: (current: Accumulated) => Output; + + #resolver = new PromiseResolver(); + + #writable = new WritableStream({ + write: (chunk) => { + try { + this.#current = this.#write(chunk, this.#current); + } catch (e) { + this.#resolver.reject(e); + throw e; + } + }, + close: () => { + try { + const output = this.#finalize(this.#current); + this.#resolver.resolve(output); + } catch (e) { + this.#resolver.reject(e); + throw e; + } + }, + abort: (reason) => { + this.#resolver.reject(reason); + }, + }); + get writable() { + return this.#writable; + } + + #readable = new ReadableStream( + { + pull: async (controller) => { + const output = await this.#resolver.promise; + controller.enqueue(output); + controller.close(); + }, + // Ignore `abort` event since the user might use the `Promise` interface. + }, + // `highWaterMark: 0` makes the `pull` method + // only be called when the `ReadableStream` is being read. + // If the user only uses the `Promise` interface, + // it's unnecessary to run the `pull` method. + { highWaterMark: 0 }, + ) as ReadableStream & + Omit, typeof Symbol.toStringTag>; + get readable() { + return this.#readable; + } + + constructor( + initial: Accumulated, + write: (chunk: Input, current: Accumulated) => Accumulated, + finalize: (current: Accumulated) => Output, + ) { + this.#current = initial; + this.#write = write; + this.#finalize = finalize; + + // biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable + this.#readable.then = (onfulfilled, onrejected) => { + return this.#resolver.promise.then(onfulfilled, onrejected); + }; + // biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable + this.#readable.catch = (onrejected) => { + return this.#resolver.promise.catch(onrejected); + }; + // biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable + this.#readable.finally = (onfinally) => { + return this.#resolver.promise.finally(onfinally); + }; + } +} /** * A `TransformStream` that concatenates strings. * - * Its `readable` is also a `Promise`, so it's possible to `await` it to get the result. + * Its `readable` is also a Promise-like, that can be `await`ed to get the result. * * ```ts * const result: string = await readable.pipeThrough(new ConcatStringStream()); * ``` */ -export class ConcatStringStream { - // PERF: rope (concat strings) is faster than `[].join('')` - #result = ""; - - #resolver = new PromiseResolver(); - - #writable = new WritableStream({ - write: (chunk) => { - this.#result += chunk; - }, - close: () => { - this.#resolver.resolve(this.#result); - this.#readableController.enqueue(this.#result); - this.#readableController.close(); - }, - abort: (reason) => { - this.#resolver.reject(reason); - this.#readableController.error(reason); - }, - }); - get writable(): WritableStream { - return this.#writable; - } - - #readableController!: ReadableStreamDefaultController; - #readable = new ReadableStream({ - start: (controller) => { - this.#readableController = controller; - }, - }) as ConcatStringReadableStream; - get readable(): ConcatStringReadableStream { - return this.#readable; - } - +export class ConcatStringStream extends AccumulateStream { constructor() { - void Object.defineProperties(this.#readable, { - then: { - get: () => - this.#resolver.promise.then.bind(this.#resolver.promise), - }, - catch: { - get: () => - this.#resolver.promise.catch.bind(this.#resolver.promise), - }, - finally: { - get: () => - this.#resolver.promise.finally.bind(this.#resolver.promise), - }, - }); + // PERF: rope (concat strings) is faster than `[].join('')` + super( + "", + (chunk, current) => current + chunk, + (output) => output, + ); } } -export interface ConcatBufferReadableStream - extends ReadableStream, - Promise {} +/** + * Concatenate all chunks into a single `Uint8Array`. + * + * If there is only one chunk, it will be returned directly. + * @param chunks An array of `Uint8Array`s to concatenate + * @returns An `Uint8Array` containing all chunks. If there is only one chunk, it will be returned directly. + */ +export function concatUint8Arrays(chunks: readonly Uint8Array[]): Uint8Array { + switch (chunks.length) { + case 0: + return EmptyUint8Array; + case 1: + return chunks[0]!; + } + + const length = chunks.reduce((a, b) => a + b.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +} /** * A `TransformStream` that concatenates `Uint8Array`s. * - * If you want to decode the result as string, + * Its `readable` is also a Promise-like, that can be `await`ed to get the result. + * + * To convert a `ReadableStream` to a string, * prefer `.pipeThrough(new TextDecoderStream()).pipeThrough(new ConcatStringStream())`, - * than `.pipeThough(new ConcatBufferStream()).pipeThrough(new TextDecoderStream())`, - * because of JavaScript engine optimizations, + * to `.pipeThrough(new ConcatBufferStream()).pipeThrough(new TextDecoderStream())`. + * Because of JavaScript engine optimizations, * concatenating strings is faster than concatenating `Uint8Array`s. */ -export class ConcatBufferStream { - #segments: Uint8Array[] = []; - - #resolver = new PromiseResolver(); - - #writable = new WritableStream({ - write: (chunk) => { - this.#segments.push(chunk); - }, - close: () => { - let result: Uint8Array; - let offset = 0; - switch (this.#segments.length) { - case 0: - result = EmptyUint8Array; - break; - case 1: - result = this.#segments[0]!; - break; - default: - result = new Uint8Array( - this.#segments.reduce( - (prev, item) => prev + item.length, - 0, - ), - ); - for (const segment of this.#segments) { - result.set(segment, offset); - offset += segment.length; - } - break; - } - - this.#resolver.resolve(result); - this.#readableController.enqueue(result); - this.#readableController.close(); - }, - abort: (reason) => { - this.#resolver.reject(reason); - this.#readableController.error(reason); - }, - }); - get writable(): WritableStream { - return this.#writable; - } - - #readableController!: ReadableStreamDefaultController; - #readable = new ReadableStream({ - start: (controller) => { - this.#readableController = controller; - }, - }) as ConcatBufferReadableStream; - get readable(): ConcatBufferReadableStream { - return this.#readable; - } - +export class ConcatBufferStream extends AccumulateStream< + Uint8Array, + Uint8Array, + Uint8Array[] +> { constructor() { - void Object.defineProperties(this.#readable, { - then: { - get: () => - this.#resolver.promise.then.bind(this.#resolver.promise), + super( + [], + (chunk, current) => { + current.push(chunk); + return current; }, - catch: { - get: () => - this.#resolver.promise.catch.bind(this.#resolver.promise), + (current) => { + const result = concatUint8Arrays(current); + // `current` is no longer needed. Clear it to free memory. + current.length = 0; + return result; }, - finally: { - get: () => - this.#resolver.promise.finally.bind(this.#resolver.promise), - }, - }); + ); } } diff --git a/libraries/stream-extra/src/inspect.ts b/libraries/stream-extra/src/inspect.ts index 133c5dfc..f0bfce65 100644 --- a/libraries/stream-extra/src/inspect.ts +++ b/libraries/stream-extra/src/inspect.ts @@ -1,12 +1,22 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; import { TransformStream } from "./stream.js"; export class InspectStream extends TransformStream { - constructor(callback: (value: T) => void) { + constructor( + write: (value: T) => MaybePromiseLike, + extras?: { close: () => void; cancel: () => void }, + ) { super({ - transform(chunk, controller) { - callback(chunk); + async transform(chunk, controller) { + await write(chunk); controller.enqueue(chunk); }, + flush() { + extras?.close?.(); + }, + cancel() { + extras?.cancel?.(); + }, }); } } diff --git a/libraries/stream-extra/src/split-string.ts b/libraries/stream-extra/src/split-string.ts index ad00a5c5..ca1e79e0 100644 --- a/libraries/stream-extra/src/split-string.ts +++ b/libraries/stream-extra/src/split-string.ts @@ -1,12 +1,42 @@ +import type { TransformStreamDefaultController } from "./stream.js"; import { TransformStream } from "./stream.js"; export class SplitStringStream extends TransformStream { - constructor(separator: string) { + constructor( + separator: string, + options?: { + trim?: boolean | undefined; + trimEnd?: boolean | undefined; + skipEmpty?: boolean | undefined; + }, + ) { let remaining: string | undefined = undefined; + const separatorLength = separator.length; + if (separatorLength === 0) { + throw new Error("separator must not be empty"); + } + + const trim = !!options?.trim; + const trimEnd = !!options?.trimEnd; + const skipEmpty = !!options?.skipEmpty; + const enqueue = ( + controller: TransformStreamDefaultController, + value: string, + ) => { + if (trim) { + value = value.trim(); + } else if (trimEnd) { + value = value.trimEnd(); + } + + if (value || !skipEmpty) { + controller.enqueue(value); + } + }; super({ transform(chunk, controller) { - if (remaining) { + if (remaining !== undefined) { chunk = remaining + chunk; remaining = undefined; } @@ -15,17 +45,21 @@ export class SplitStringStream extends TransformStream { while (start < chunk.length) { const index = chunk.indexOf(separator, start); if (index === -1) { + // `remaining` can't be an empty string + // because `start` is less than `chunk.length` remaining = chunk.substring(start); break; } - controller.enqueue(chunk.substring(start, index)); - start = index + 1; + const value = chunk.substring(start, index); + enqueue(controller, value); + + start = index + separatorLength; } }, flush(controller) { - if (remaining) { - controller.enqueue(remaining); + if (remaining !== undefined) { + enqueue(controller, remaining); } }, }); diff --git a/libraries/stream-extra/src/types.ts b/libraries/stream-extra/src/types.ts index 7ad06220..df67f9da 100644 --- a/libraries/stream-extra/src/types.ts +++ b/libraries/stream-extra/src/types.ts @@ -491,7 +491,7 @@ export declare interface StreamPipeOptions { * `AbortController`. In this case, the source readable stream will be canceled, and the destination writable stream * aborted, unless the respective options `preventCancel` or `preventAbort` are set. */ - signal?: AbortSignal; + signal?: AbortSignal | undefined; } /** diff --git a/libraries/struct/src/buffer.ts b/libraries/struct/src/buffer.ts index 832752ec..8158dae9 100644 --- a/libraries/struct/src/buffer.ts +++ b/libraries/struct/src/buffer.ts @@ -3,6 +3,23 @@ import { field } from "./field/index.js"; export const EmptyUint8Array = new Uint8Array(0); +function copyMaybeDifferentLength( + dist: Uint8Array, + source: Uint8Array, + index: number, + length: number, +) { + if (source.length < length) { + dist.set(source, index); + // Clear trailing bytes + dist.fill(0, index + source.length, index + length); + } else if (source.length === length) { + dist.set(source, index); + } else { + dist.set(source.subarray(0, length), index); + } +} + export interface Converter { convert: (value: From) => To; back: (value: To) => From; @@ -102,7 +119,12 @@ function _buffer( lengthOrField, "byob", (value, { buffer, index }) => { - buffer.set(value.slice(0, lengthOrField), index); + copyMaybeDifferentLength( + buffer, + value, + index, + lengthOrField, + ); }, function* (then, reader) { const array = yield* then( @@ -134,7 +156,7 @@ function _buffer( lengthOrField, "byob", (value, { buffer, index }) => { - buffer.set(value.slice(0, lengthOrField), index); + copyMaybeDifferentLength(buffer, value, index, lengthOrField); }, // eslint-disable-next-line require-yield function* (_then, reader) { diff --git a/libraries/struct/src/index.ts b/libraries/struct/src/index.ts index f56b7738..7354b231 100644 --- a/libraries/struct/src/index.ts +++ b/libraries/struct/src/index.ts @@ -1,15 +1,3 @@ -declare global { - interface ArrayBuffer { - // Disallow assigning `Uint8Array` to `Arraybuffer` - __brand: never; - } - - interface SharedArrayBuffer { - // Allow `SharedArrayBuffer` to be assigned to `ArrayBuffer` - __brand: never; - } -} - export * from "./bipedal.js"; export * from "./buffer.js"; export * from "./concat.js"; diff --git a/libraries/struct/src/utils.ts b/libraries/struct/src/utils.ts index a2aa4166..5aeebab1 100644 --- a/libraries/struct/src/utils.ts +++ b/libraries/struct/src/utils.ts @@ -7,7 +7,7 @@ // So there is no point to do that. Let's just assume they exist in global. interface TextEncoder { - encode(input: string): Uint8Array; + encode(input: string): Uint8Array; } interface TextDecoder { @@ -29,7 +29,7 @@ const SharedEncoder = /* #__PURE__ */ new TextEncoder(); const SharedDecoder = /* #__PURE__ */ new TextDecoder(); /* #__NO_SIDE_EFFECTS__ */ -export function encodeUtf8(input: string): Uint8Array { +export function encodeUtf8(input: string): Uint8Array { return SharedEncoder.encode(input); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27dd058f..b7a77388 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,12 @@ importers: '@yume-chan/adb': specifier: workspace:^ version: link:../adb + '@yume-chan/async': + specifier: ^4.1.3 + version: 4.1.3 + '@yume-chan/struct': + specifier: workspace:^ + version: link:../struct devDependencies: '@yume-chan/eslint-config': specifier: workspace:^ @@ -153,6 +159,9 @@ importers: '@yume-chan/event': specifier: workspace:^ version: link:../event + '@yume-chan/media-codec': + specifier: workspace:^ + version: link:../media-codec '@yume-chan/scrcpy': specifier: workspace:^ version: link:../scrcpy @@ -298,6 +307,31 @@ importers: specifier: ^24.3.1 version: 24.3.1 + libraries/media-codec: + dependencies: + '@yume-chan/no-data-view': + specifier: workspace:^ + version: link:../no-data-view + devDependencies: + '@types/node': + specifier: ^24.3.0 + version: 24.3.1 + '@yume-chan/eslint-config': + specifier: workspace:^ + version: link:../../toolchain/eslint-config + '@yume-chan/test-runner': + specifier: workspace:^ + version: link:../../toolchain/test-runner + '@yume-chan/tsconfig': + specifier: workspace:^ + version: link:../../toolchain/tsconfig + prettier: + specifier: ^3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + libraries/no-data-view: devDependencies: '@types/node': @@ -345,6 +379,9 @@ importers: '@yume-chan/async': specifier: ^4.1.3 version: 4.1.3 + '@yume-chan/event': + specifier: workspace:^ + version: link:../event '@yume-chan/no-data-view': specifier: workspace:^ version: link:../no-data-view @@ -382,6 +419,9 @@ importers: '@yume-chan/event': specifier: workspace:^ version: link:../event + '@yume-chan/media-codec': + specifier: workspace:^ + version: link:../media-codec '@yume-chan/scrcpy': specifier: workspace:^ version: link:../scrcpy @@ -419,6 +459,9 @@ importers: '@yume-chan/event': specifier: workspace:^ version: link:../event + '@yume-chan/media-codec': + specifier: workspace:^ + version: link:../media-codec '@yume-chan/no-data-view': specifier: workspace:^ version: link:../no-data-view diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ff27541..0fc3daf2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,6 @@ packages: - apps/* - libraries/* - toolchain/* + +onlyBuiltDependencies: + - unrs-resolver