mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-03 09:49:24 +02:00
Merge branch 'main' into v2.x
This commit is contained in:
commit
c738124241
134 changed files with 4104 additions and 2046 deletions
5
.changeset/four-ants-post.md
Normal file
5
.changeset/four-ants-post.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@yume-chan/adb": major
|
||||||
|
---
|
||||||
|
|
||||||
|
Sync ADB feature list with latest ADB source code. Some features have been renamed to align with ADB source code. These feature flags are considered implementation details and generally not needed for outside consumers, but it's a breaking change anyway.
|
6
.changeset/tangy-bottles-dream.md
Normal file
6
.changeset/tangy-bottles-dream.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
"@yume-chan/adb-credential-web": major
|
||||||
|
"@yume-chan/adb": major
|
||||||
|
---
|
||||||
|
|
||||||
|
Refactor daemon authentication API and add support for more credential storages
|
5
.changeset/tangy-trains-return.md
Normal file
5
.changeset/tangy-trains-return.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@yume-chan/android-bin": major
|
||||||
|
---
|
||||||
|
|
||||||
|
Removed `IntentBuilder`. APIs now takes `Intent`s using plain objects (with TypeScript typing)
|
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
@ -19,9 +19,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9.5.0
|
version: 10.15.0
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
- run: pnpm run build
|
- run: pnpm run build
|
||||||
|
|
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
|
@ -29,9 +29,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9.5.0
|
version: 10.15.0
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
- run: pnpm run build
|
- run: pnpm run build
|
||||||
|
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
@ -27,9 +27,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9.5.0
|
version: 10.15.0
|
||||||
run_install: true
|
run_install: true
|
||||||
|
|
||||||
- run: pnpm run build
|
- run: pnpm run build
|
||||||
|
|
|
@ -30,7 +30,9 @@
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yume-chan/adb": "workspace:^"
|
"@yume-chan/adb": "workspace:^",
|
||||||
|
"@yume-chan/async": "^4.1.3",
|
||||||
|
"@yume-chan/struct": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@yume-chan/eslint-config": "workspace:^",
|
"@yume-chan/eslint-config": "workspace:^",
|
||||||
|
|
|
@ -1,121 +1,2 @@
|
||||||
// cspell: ignore RSASSA
|
export * from "./storage/index.js";
|
||||||
|
export * from "./store.js";
|
||||||
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb";
|
|
||||||
|
|
||||||
function openDatabase() {
|
|
||||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
||||||
const request = indexedDB.open("Tango", 1);
|
|
||||||
request.onerror = () => {
|
|
||||||
reject(request.error!);
|
|
||||||
};
|
|
||||||
request.onupgradeneeded = () => {
|
|
||||||
const db = request.result;
|
|
||||||
db.createObjectStore("Authentication", { autoIncrement: true });
|
|
||||||
};
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const db = request.result;
|
|
||||||
resolve(db);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveKey(key: Uint8Array): Promise<void> {
|
|
||||||
const db = await openDatabase();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = db.transaction("Authentication", "readwrite");
|
|
||||||
const store = transaction.objectStore("Authentication");
|
|
||||||
const putRequest = store.add(key);
|
|
||||||
putRequest.onerror = () => {
|
|
||||||
reject(putRequest.error!);
|
|
||||||
};
|
|
||||||
putRequest.onsuccess = () => {
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
transaction.onerror = () => {
|
|
||||||
reject(transaction.error!);
|
|
||||||
};
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
db.close();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAllKeys() {
|
|
||||||
const db = await openDatabase();
|
|
||||||
|
|
||||||
return new Promise<Uint8Array[]>((resolve, reject) => {
|
|
||||||
const transaction = db.transaction("Authentication", "readonly");
|
|
||||||
const store = transaction.objectStore("Authentication");
|
|
||||||
const getRequest = store.getAll();
|
|
||||||
getRequest.onerror = () => {
|
|
||||||
reject(getRequest.error!);
|
|
||||||
};
|
|
||||||
getRequest.onsuccess = () => {
|
|
||||||
resolve(getRequest.result as Uint8Array[]);
|
|
||||||
};
|
|
||||||
transaction.onerror = () => {
|
|
||||||
reject(transaction.error!);
|
|
||||||
};
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
db.close();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An `AdbCredentialStore` implementation that creates RSA private keys using Web Crypto API
|
|
||||||
* and stores them in IndexedDB.
|
|
||||||
*/
|
|
||||||
export default class AdbWebCredentialStore implements AdbCredentialStore {
|
|
||||||
readonly #appName: string;
|
|
||||||
|
|
||||||
constructor(appName = "Tango") {
|
|
||||||
this.#appName = appName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a RSA private key and store it into LocalStorage.
|
|
||||||
*
|
|
||||||
* Calling this method multiple times will overwrite the previous key.
|
|
||||||
*
|
|
||||||
* @returns The private key in PKCS #8 format.
|
|
||||||
*/
|
|
||||||
async generateKey(): Promise<AdbPrivateKey> {
|
|
||||||
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "RSASSA-PKCS1-v1_5",
|
|
||||||
modulusLength: 2048,
|
|
||||||
// 65537
|
|
||||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
||||||
hash: "SHA-1",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["sign", "verify"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const privateKey = new Uint8Array(
|
|
||||||
await crypto.subtle.exportKey("pkcs8", cryptoKey),
|
|
||||||
);
|
|
||||||
await saveKey(privateKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer: privateKey,
|
|
||||||
name: `${this.#appName}@${globalThis.location.hostname}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Yields the stored RSA private key.
|
|
||||||
*
|
|
||||||
* This method returns a generator, so `for await...of...` loop should be used to read the key.
|
|
||||||
*/
|
|
||||||
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> {
|
|
||||||
for (const key of await getAllKeys()) {
|
|
||||||
yield {
|
|
||||||
buffer: key,
|
|
||||||
name: `${this.#appName}@${globalThis.location.hostname}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
5
libraries/adb-credential-web/src/storage/index.ts
Normal file
5
libraries/adb-credential-web/src/storage/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./indexed-db.js";
|
||||||
|
export * from "./local-storage.js";
|
||||||
|
export * from "./password.js";
|
||||||
|
export * from "./prf/index.js";
|
||||||
|
export * from "./type.js";
|
89
libraries/adb-credential-web/src/storage/indexed-db.ts
Normal file
89
libraries/adb-credential-web/src/storage/indexed-db.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import type { TangoDataStorage } from "./type.js";
|
||||||
|
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("Tango", 1);
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error!);
|
||||||
|
};
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
db.createObjectStore("Authentication", { autoIncrement: true });
|
||||||
|
};
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransaction<T>(
|
||||||
|
database: IDBDatabase,
|
||||||
|
callback: (transaction: IDBTransaction) => T,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const transaction = database.transaction("Authentication", "readwrite");
|
||||||
|
transaction.onerror = () => {
|
||||||
|
reject(transaction.error!);
|
||||||
|
};
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
transaction.onabort = () => {
|
||||||
|
reject(transaction.error ?? new Error("Transaction aborted"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = callback(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TangoIndexedDbStorage implements TangoDataStorage {
|
||||||
|
async save(data: Uint8Array): Promise<undefined> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createTransaction(db, (tx) => {
|
||||||
|
const store = tx.objectStore("Authentication");
|
||||||
|
store.add(data);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *load(): AsyncGenerator<Uint8Array, void, void> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = await createTransaction(db, (tx) => {
|
||||||
|
return new Promise<Uint8Array[]>((resolve, reject) => {
|
||||||
|
const store = tx.objectStore("Authentication");
|
||||||
|
const getRequest = store.getAll();
|
||||||
|
getRequest.onerror = () => {
|
||||||
|
reject(getRequest.error!);
|
||||||
|
};
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
resolve(getRequest.result as Uint8Array[]);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
yield* keys;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
const db = await openDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createTransaction(db, (tx) => {
|
||||||
|
const store = tx.objectStore("Authentication");
|
||||||
|
store.clear();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
libraries/adb-credential-web/src/storage/local-storage.ts
Normal file
22
libraries/adb-credential-web/src/storage/local-storage.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { decodeBase64, decodeUtf8, encodeBase64 } from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import type { TangoDataStorage } from "./type.js";
|
||||||
|
|
||||||
|
export class TangoLocalStorage implements TangoDataStorage {
|
||||||
|
readonly #storageKey: string;
|
||||||
|
|
||||||
|
constructor(storageKey: string) {
|
||||||
|
this.#storageKey = storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(data: Uint8Array): undefined {
|
||||||
|
localStorage.setItem(this.#storageKey, decodeUtf8(encodeBase64(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
*load(): Generator<Uint8Array, void, void> {
|
||||||
|
const data = localStorage.getItem(this.#storageKey);
|
||||||
|
if (data) {
|
||||||
|
yield decodeBase64(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
149
libraries/adb-credential-web/src/storage/password.ts
Normal file
149
libraries/adb-credential-web/src/storage/password.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import { encodeUtf8 } from "@yume-chan/adb";
|
||||||
|
import type { MaybePromiseLike } from "@yume-chan/async";
|
||||||
|
import {
|
||||||
|
buffer,
|
||||||
|
struct,
|
||||||
|
u16,
|
||||||
|
Uint8ArrayExactReadable,
|
||||||
|
} from "@yume-chan/struct";
|
||||||
|
|
||||||
|
import type { TangoDataStorage } from "./type.js";
|
||||||
|
|
||||||
|
const Pbkdf2SaltLength = 16;
|
||||||
|
const Pbkdf2Iterations = 1_000_000;
|
||||||
|
// AES-GCM recommends 12-byte (96-bit) IV for performance and interoperability
|
||||||
|
const AesIvLength = 12;
|
||||||
|
|
||||||
|
const Bundle = struct(
|
||||||
|
{
|
||||||
|
pbkdf2Salt: buffer(Pbkdf2SaltLength),
|
||||||
|
aesIv: buffer(AesIvLength),
|
||||||
|
encrypted: buffer(u16),
|
||||||
|
},
|
||||||
|
{ littleEndian: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function deriveAesKey(password: string, salt?: Uint8Array<ArrayBuffer>) {
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encodeUtf8(password),
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!salt) {
|
||||||
|
salt = new Uint8Array(Pbkdf2SaltLength);
|
||||||
|
crypto.getRandomValues(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aesKey = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: Pbkdf2Iterations,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { salt, aesKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
class PasswordIncorrectError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Password incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TangoPasswordProtectedStorage implements TangoDataStorage {
|
||||||
|
static PasswordIncorrectError = PasswordIncorrectError;
|
||||||
|
|
||||||
|
readonly #storage: TangoDataStorage;
|
||||||
|
readonly #requestPassword: TangoPasswordProtectedStorage.RequestPassword;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
storage: TangoDataStorage,
|
||||||
|
requestPassword: TangoPasswordProtectedStorage.RequestPassword,
|
||||||
|
) {
|
||||||
|
this.#storage = storage;
|
||||||
|
this.#requestPassword = requestPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(data: Uint8Array<ArrayBuffer>): Promise<undefined> {
|
||||||
|
const password = await this.#requestPassword("save");
|
||||||
|
const { salt, aesKey } = await deriveAesKey(password);
|
||||||
|
|
||||||
|
const iv = new Uint8Array(AesIvLength);
|
||||||
|
crypto.getRandomValues(iv);
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
aesKey,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = Bundle.serialize({
|
||||||
|
pbkdf2Salt: salt,
|
||||||
|
aesIv: iv,
|
||||||
|
encrypted: new Uint8Array(encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#storage.save(bundle);
|
||||||
|
|
||||||
|
// Clear secret memory
|
||||||
|
// * No way to clear `password` and `aesKey`
|
||||||
|
// * `salt`, `iv`, `encrypted` and `bundle` are not secrets
|
||||||
|
// * `data` is owned by caller and will be cleared by caller
|
||||||
|
}
|
||||||
|
|
||||||
|
async *load(): AsyncGenerator<Uint8Array, void, void> {
|
||||||
|
for await (const serialized of this.#storage.load()) {
|
||||||
|
const bundle = Bundle.deserialize(
|
||||||
|
new Uint8ArrayExactReadable(serialized),
|
||||||
|
);
|
||||||
|
|
||||||
|
const password = await this.#requestPassword("load");
|
||||||
|
const { aesKey } = await deriveAesKey(
|
||||||
|
password,
|
||||||
|
bundle.pbkdf2Salt as Uint8Array<ArrayBuffer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
bundle.encrypted as Uint8Array<ArrayBuffer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield new Uint8Array(decrypted);
|
||||||
|
|
||||||
|
// Clear secret memory
|
||||||
|
// * No way to clear `password` and `aesKey`
|
||||||
|
// * all values in `bundle` are not secrets
|
||||||
|
// * Caller is not allowed to use `decrypted` after `yield` returns
|
||||||
|
new Uint8Array(decrypted).fill(0);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === "OperationError") {
|
||||||
|
throw new PasswordIncorrectError();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace TangoPasswordProtectedStorage {
|
||||||
|
export type RequestPassword = (
|
||||||
|
reason: "save" | "load",
|
||||||
|
) => MaybePromiseLike<string>;
|
||||||
|
|
||||||
|
export type PasswordIncorrectError = typeof PasswordIncorrectError;
|
||||||
|
}
|
3
libraries/adb-credential-web/src/storage/prf/index.ts
Normal file
3
libraries/adb-credential-web/src/storage/prf/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./source.js";
|
||||||
|
export * from "./storage.js";
|
||||||
|
export * from "./web-authn.js";
|
11
libraries/adb-credential-web/src/storage/prf/source.ts
Normal file
11
libraries/adb-credential-web/src/storage/prf/source.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export interface TangoPrfSource {
|
||||||
|
create(input: Uint8Array<ArrayBuffer>): Promise<{
|
||||||
|
output: BufferSource;
|
||||||
|
id: Uint8Array<ArrayBuffer>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
get(
|
||||||
|
id: BufferSource,
|
||||||
|
input: Uint8Array<ArrayBuffer>,
|
||||||
|
): Promise<BufferSource>;
|
||||||
|
}
|
168
libraries/adb-credential-web/src/storage/prf/storage.ts
Normal file
168
libraries/adb-credential-web/src/storage/prf/storage.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
buffer,
|
||||||
|
struct,
|
||||||
|
u16,
|
||||||
|
Uint8ArrayExactReadable,
|
||||||
|
} from "@yume-chan/struct";
|
||||||
|
|
||||||
|
import type { TangoDataStorage } from "../type.js";
|
||||||
|
|
||||||
|
import type { TangoPrfSource } from "./source.js";
|
||||||
|
|
||||||
|
// PRF generally uses FIDO HMAC secret extension, which uses HMAC with SHA-256,
|
||||||
|
// and this input is used as salt, so should be 32 bytes
|
||||||
|
const PrfInputLength = 32;
|
||||||
|
const HkdfInfoLength = 32;
|
||||||
|
// We use HMAC with SHA-512, so should be 64 bytes
|
||||||
|
const HkdfSaltLength = 64;
|
||||||
|
// AES-GCM recommends 12-byte (96-bit) IV for performance and interoperability
|
||||||
|
const AesIvLength = 12;
|
||||||
|
|
||||||
|
async function deriveAesKey(
|
||||||
|
source: BufferSource,
|
||||||
|
info: Uint8Array<ArrayBuffer>,
|
||||||
|
salt: Uint8Array<ArrayBuffer>,
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
source,
|
||||||
|
"HKDF",
|
||||||
|
false,
|
||||||
|
["deriveKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
return await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "HKDF",
|
||||||
|
hash: "SHA-512",
|
||||||
|
info,
|
||||||
|
salt,
|
||||||
|
} satisfies globalThis.HkdfParams,
|
||||||
|
baseKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUint8Array(source: BufferSource) {
|
||||||
|
if (source instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(source);
|
||||||
|
}
|
||||||
|
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Bundle = struct(
|
||||||
|
{
|
||||||
|
id: buffer(u16),
|
||||||
|
prfInput: buffer(PrfInputLength),
|
||||||
|
hkdfInfo: buffer(HkdfInfoLength),
|
||||||
|
hkdfSalt: buffer(HkdfSaltLength),
|
||||||
|
aesIv: buffer(AesIvLength),
|
||||||
|
encrypted: buffer(u16),
|
||||||
|
},
|
||||||
|
{ littleEndian: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export class TangoPrfStorage implements TangoDataStorage {
|
||||||
|
readonly #storage: TangoDataStorage;
|
||||||
|
readonly #source: TangoPrfSource;
|
||||||
|
#prevId: Uint8Array<ArrayBuffer> | undefined;
|
||||||
|
|
||||||
|
constructor(storage: TangoDataStorage, source: TangoPrfSource) {
|
||||||
|
this.#storage = storage;
|
||||||
|
this.#source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(data: Uint8Array<ArrayBuffer>): Promise<undefined> {
|
||||||
|
const prfInput = new Uint8Array(PrfInputLength);
|
||||||
|
crypto.getRandomValues(prfInput);
|
||||||
|
|
||||||
|
// Maybe reuse the credential, but use different PRF input and HKDF params
|
||||||
|
let id: Uint8Array<ArrayBuffer>;
|
||||||
|
let prfOutput: BufferSource;
|
||||||
|
if (this.#prevId) {
|
||||||
|
prfOutput = await this.#source.get(this.#prevId, prfInput);
|
||||||
|
id = this.#prevId;
|
||||||
|
} else {
|
||||||
|
({ output: prfOutput, id } = await this.#source.create(prfInput));
|
||||||
|
this.#prevId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = new Uint8Array(HkdfInfoLength);
|
||||||
|
crypto.getRandomValues(info);
|
||||||
|
|
||||||
|
const salt = new Uint8Array(HkdfSaltLength);
|
||||||
|
crypto.getRandomValues(salt);
|
||||||
|
|
||||||
|
const aesKey = await deriveAesKey(prfOutput, info, salt);
|
||||||
|
|
||||||
|
const iv = new Uint8Array(AesIvLength);
|
||||||
|
crypto.getRandomValues(iv);
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
aesKey,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = Bundle.serialize({
|
||||||
|
id,
|
||||||
|
prfInput,
|
||||||
|
hkdfInfo: info,
|
||||||
|
hkdfSalt: salt,
|
||||||
|
aesIv: iv,
|
||||||
|
encrypted: new Uint8Array(encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#storage.save(bundle);
|
||||||
|
|
||||||
|
// Clear secret memory
|
||||||
|
// * No way to clear `aesKey`
|
||||||
|
// * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets
|
||||||
|
// * `data` is owned by caller and will be cleared by caller
|
||||||
|
// * Need to clear `prfOutput`
|
||||||
|
toUint8Array(prfOutput).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *load(): AsyncGenerator<Uint8Array, void, void> {
|
||||||
|
for await (const serialized of this.#storage.load()) {
|
||||||
|
const bundle = Bundle.deserialize(
|
||||||
|
new Uint8ArrayExactReadable(serialized),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prfOutput = await this.#source.get(
|
||||||
|
bundle.id as Uint8Array<ArrayBuffer>,
|
||||||
|
bundle.prfInput as Uint8Array<ArrayBuffer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#prevId = bundle.id as Uint8Array<ArrayBuffer>;
|
||||||
|
|
||||||
|
const aesKey = await deriveAesKey(
|
||||||
|
prfOutput,
|
||||||
|
bundle.hkdfInfo as Uint8Array<ArrayBuffer>,
|
||||||
|
bundle.hkdfSalt as Uint8Array<ArrayBuffer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
bundle.encrypted as Uint8Array<ArrayBuffer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield new Uint8Array(decrypted);
|
||||||
|
|
||||||
|
// Clear secret memory
|
||||||
|
// * No way to clear `aesKey`
|
||||||
|
// * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets
|
||||||
|
// * `data` is owned by caller and will be cleared by caller
|
||||||
|
// * Caller is not allowed to use `decrypted` after `yield` returns
|
||||||
|
// * Need to clear `prfOutput`
|
||||||
|
toUint8Array(prfOutput).fill(0);
|
||||||
|
new Uint8Array(decrypted).fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
145
libraries/adb-credential-web/src/storage/prf/web-authn.ts
Normal file
145
libraries/adb-credential-web/src/storage/prf/web-authn.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import type { TangoPrfSource } from "./source.js";
|
||||||
|
|
||||||
|
function checkCredential(
|
||||||
|
credential: Credential | null,
|
||||||
|
): asserts credential is PublicKeyCredential {
|
||||||
|
if (!credential || !(credential instanceof PublicKeyCredential)) {
|
||||||
|
throw new Error("Can't create credential");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrfOutput(credential: PublicKeyCredential) {
|
||||||
|
const extensions = credential.getClientExtensionResults();
|
||||||
|
|
||||||
|
const prf = extensions["prf"];
|
||||||
|
if (!prf) {
|
||||||
|
throw new NotSupportedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return prf;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotSupportedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("PRF extension is not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssertionFailedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Assertion failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TangoWebAuthnPrfSource implements TangoPrfSource {
|
||||||
|
static NotSupportedError = NotSupportedError;
|
||||||
|
static AssertionFailedError = AssertionFailedError;
|
||||||
|
|
||||||
|
static async isSupported(): Promise<boolean> {
|
||||||
|
if (typeof PublicKeyCredential === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PublicKeyCredential.getClientCapabilities) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientCapabilities =
|
||||||
|
await PublicKeyCredential.getClientCapabilities();
|
||||||
|
if (!clientCapabilities["extension:prf"]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly #appName: string;
|
||||||
|
readonly #userName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance of TangoWebAuthnPrfSource
|
||||||
|
* @param appName Name of your website shows in Passkey manager
|
||||||
|
* @param userName Display name of the credential shows in Passkey manager
|
||||||
|
*/
|
||||||
|
constructor(appName: string, userName: string) {
|
||||||
|
this.#appName = appName;
|
||||||
|
this.#userName = userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: Uint8Array<ArrayBuffer>): Promise<{
|
||||||
|
output: BufferSource;
|
||||||
|
id: Uint8Array<ArrayBuffer>;
|
||||||
|
}> {
|
||||||
|
const challenge = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(challenge);
|
||||||
|
|
||||||
|
const attestation = await navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
challenge,
|
||||||
|
extensions: { prf: { eval: { first: input } } },
|
||||||
|
pubKeyCredParams: [
|
||||||
|
{ type: "public-key", alg: -7 },
|
||||||
|
{ type: "public-key", alg: -257 },
|
||||||
|
],
|
||||||
|
rp: { name: this.#appName },
|
||||||
|
user: {
|
||||||
|
id: challenge,
|
||||||
|
name: this.#userName,
|
||||||
|
displayName: this.#userName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
checkCredential(attestation);
|
||||||
|
|
||||||
|
const prf = getPrfOutput(attestation);
|
||||||
|
if (prf.enabled === undefined) {
|
||||||
|
throw new NotSupportedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = new Uint8Array(attestation.rawId);
|
||||||
|
|
||||||
|
if (prf.results) {
|
||||||
|
return { output: prf.results.first, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some authenticators only support getting PRF in assertion
|
||||||
|
const output = await this.get(id, input);
|
||||||
|
return { output, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
id: BufferSource,
|
||||||
|
input: Uint8Array<ArrayBuffer>,
|
||||||
|
): Promise<BufferSource> {
|
||||||
|
const challenge = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(challenge);
|
||||||
|
|
||||||
|
let assertion;
|
||||||
|
try {
|
||||||
|
assertion = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
allowCredentials: [{ type: "public-key", id }],
|
||||||
|
challenge,
|
||||||
|
extensions: { prf: { eval: { first: input } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new AssertionFailedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCredential(assertion);
|
||||||
|
|
||||||
|
const prfOutput = getPrfOutput(assertion);
|
||||||
|
if (!prfOutput.results) {
|
||||||
|
throw new NotSupportedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return prfOutput.results.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace TangoWebAuthnPrfSource {
|
||||||
|
export type NotSupportedError = typeof NotSupportedError;
|
||||||
|
export type AssertionFailedError = typeof AssertionFailedError;
|
||||||
|
}
|
7
libraries/adb-credential-web/src/storage/type.ts
Normal file
7
libraries/adb-credential-web/src/storage/type.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import type { MaybePromiseLike } from "@yume-chan/async";
|
||||||
|
|
||||||
|
export interface TangoDataStorage {
|
||||||
|
save(data: Uint8Array): MaybePromiseLike<undefined>;
|
||||||
|
|
||||||
|
load(): Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||||
|
}
|
63
libraries/adb-credential-web/src/store.ts
Normal file
63
libraries/adb-credential-web/src/store.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb";
|
||||||
|
import { rsaParsePrivateKey } from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import type { TangoDataStorage } from "./storage/index.js";
|
||||||
|
|
||||||
|
export class AdbWebCryptoCredentialStore implements AdbCredentialStore {
|
||||||
|
readonly #storage: TangoDataStorage;
|
||||||
|
|
||||||
|
readonly #appName: string;
|
||||||
|
|
||||||
|
constructor(storage: TangoDataStorage, appName: string = "Tango") {
|
||||||
|
this.#storage = storage;
|
||||||
|
this.#appName = appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateKey(): Promise<AdbPrivateKey> {
|
||||||
|
// NOTE: ADB public key authentication doesn't use standard
|
||||||
|
// RSASSA-PKCS1-v1_5 algorithm to sign and verify data.
|
||||||
|
// We implemented ADB public key authentication ourselves in core package,
|
||||||
|
// so some parameters for Web Crypto API are not used.
|
||||||
|
|
||||||
|
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "RSASSA-PKCS1-v1_5",
|
||||||
|
modulusLength: 2048,
|
||||||
|
// 65537
|
||||||
|
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||||
|
// Not used
|
||||||
|
hash: "SHA-1",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
// Not used
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateKey = new Uint8Array(
|
||||||
|
await crypto.subtle.exportKey("pkcs8", cryptoKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = rsaParsePrivateKey(privateKey);
|
||||||
|
|
||||||
|
await this.#storage.save(privateKey);
|
||||||
|
|
||||||
|
// Clear secret memory
|
||||||
|
// * `privateKey` is not allowed to be used after `save`
|
||||||
|
privateKey.fill(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
name: `${this.#appName}@${globalThis.location.hostname}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> {
|
||||||
|
for await (const privateKey of this.#storage.load()) {
|
||||||
|
// `privateKey` is owned by `#storage` and will be cleared by it
|
||||||
|
yield {
|
||||||
|
...rsaParsePrivateKey(privateKey),
|
||||||
|
name: `${this.#appName}@${globalThis.location.hostname}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -143,8 +143,20 @@ export class AdbDaemonWebUsbConnection
|
||||||
new MaybeConsumable.WritableStream({
|
new MaybeConsumable.WritableStream({
|
||||||
write: async (chunk) => {
|
write: async (chunk) => {
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
typeof SharedArrayBuffer !== "undefined" &&
|
||||||
|
chunk.buffer instanceof SharedArrayBuffer
|
||||||
|
) {
|
||||||
|
// Copy data to a non-shared ArrayBuffer
|
||||||
|
const copy = new Uint8Array(chunk.byteLength);
|
||||||
|
copy.set(chunk);
|
||||||
|
chunk = copy;
|
||||||
|
}
|
||||||
|
|
||||||
await device.raw.transferOut(
|
await device.raw.transferOut(
|
||||||
outEndpoint.endpointNumber,
|
outEndpoint.endpointNumber,
|
||||||
|
// WebUSB doesn't support SharedArrayBuffer
|
||||||
|
// https://github.com/WICG/webusb/issues/243
|
||||||
toLocalUint8Array(chunk),
|
toLocalUint8Array(chunk),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
"@yume-chan/adb": "workspace:^",
|
"@yume-chan/adb": "workspace:^",
|
||||||
"@yume-chan/async": "^4.1.3",
|
"@yume-chan/async": "^4.1.3",
|
||||||
"@yume-chan/event": "workspace:^",
|
"@yume-chan/event": "workspace:^",
|
||||||
|
"@yume-chan/media-codec": "workspace:^",
|
||||||
"@yume-chan/scrcpy": "workspace:^",
|
"@yume-chan/scrcpy": "workspace:^",
|
||||||
"@yume-chan/stream-extra": "workspace:^",
|
"@yume-chan/stream-extra": "workspace:^",
|
||||||
"@yume-chan/struct": "workspace:^"
|
"@yume-chan/struct": "workspace:^"
|
||||||
|
|
|
@ -134,9 +134,9 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
|
// Use `CLASSPATH=` as `-cp` argument requires Android 8.0
|
||||||
|
`CLASSPATH=${path}`,
|
||||||
"app_process",
|
"app_process",
|
||||||
"-cp",
|
|
||||||
path,
|
|
||||||
/* unused */ "/",
|
/* unused */ "/",
|
||||||
"com.genymobile.scrcpy.Server",
|
"com.genymobile.scrcpy.Server",
|
||||||
options.version,
|
options.version,
|
||||||
|
@ -144,14 +144,14 @@ export class AdbScrcpyClient<TOptions extends AdbScrcpyOptions<object>> {
|
||||||
];
|
];
|
||||||
|
|
||||||
if (options.spawner) {
|
if (options.spawner) {
|
||||||
process = await options.spawner.spawn(args);
|
process = await options.spawner(args);
|
||||||
} else {
|
} else {
|
||||||
process = await adb.subprocess.noneProtocol.spawn(args);
|
process = await adb.subprocess.noneProtocol.spawn(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = process.output
|
const output = process.output
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
.pipeThrough(new SplitStringStream("\n"));
|
.pipeThrough(new SplitStringStream("\n", { trimEnd: true }));
|
||||||
|
|
||||||
// Must read all streams, otherwise the whole connection will be blocked.
|
// Must read all streams, otherwise the whole connection will be blocked.
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
|
@ -1,20 +1,16 @@
|
||||||
import { StickyEventEmitter } from "@yume-chan/event";
|
import { Av1, H264, H265 } from "@yume-chan/media-codec";
|
||||||
import type {
|
import type {
|
||||||
ScrcpyMediaStreamPacket,
|
ScrcpyMediaStreamPacket,
|
||||||
|
ScrcpyVideoSize,
|
||||||
ScrcpyVideoStreamMetadata,
|
ScrcpyVideoStreamMetadata,
|
||||||
} from "@yume-chan/scrcpy";
|
} from "@yume-chan/scrcpy";
|
||||||
import {
|
import { ScrcpyVideoCodecId, ScrcpyVideoSizeImpl } from "@yume-chan/scrcpy";
|
||||||
Av1,
|
|
||||||
h264ParseConfiguration,
|
|
||||||
h265ParseConfiguration,
|
|
||||||
ScrcpyVideoCodecId,
|
|
||||||
} from "@yume-chan/scrcpy";
|
|
||||||
import type { ReadableStream } from "@yume-chan/stream-extra";
|
import type { ReadableStream } from "@yume-chan/stream-extra";
|
||||||
import { InspectStream } from "@yume-chan/stream-extra";
|
import { InspectStream } from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
import type { AdbScrcpyOptions } from "./types.js";
|
import type { AdbScrcpyOptions } from "./types.js";
|
||||||
|
|
||||||
export class AdbScrcpyVideoStream {
|
export class AdbScrcpyVideoStream implements ScrcpyVideoSize {
|
||||||
#options: AdbScrcpyOptions<object>;
|
#options: AdbScrcpyOptions<object>;
|
||||||
|
|
||||||
#metadata: ScrcpyVideoStreamMetadata;
|
#metadata: ScrcpyVideoStreamMetadata;
|
||||||
|
@ -27,19 +23,15 @@ export class AdbScrcpyVideoStream {
|
||||||
return this.#stream;
|
return this.#stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>();
|
#size = new ScrcpyVideoSizeImpl();
|
||||||
get sizeChanged() {
|
|
||||||
return this.#sizeChanged.event;
|
|
||||||
}
|
|
||||||
|
|
||||||
#width: number = 0;
|
|
||||||
get width() {
|
get width() {
|
||||||
return this.#width;
|
return this.#size.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
#height: number = 0;
|
|
||||||
get height() {
|
get height() {
|
||||||
return this.#height;
|
return this.#size.height;
|
||||||
|
}
|
||||||
|
get sizeChanged() {
|
||||||
|
return this.#size.sizeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -49,43 +41,46 @@ export class AdbScrcpyVideoStream {
|
||||||
) {
|
) {
|
||||||
this.#options = options;
|
this.#options = options;
|
||||||
this.#metadata = metadata;
|
this.#metadata = metadata;
|
||||||
|
|
||||||
this.#stream = stream
|
this.#stream = stream
|
||||||
.pipeThrough(this.#options.createMediaStreamTransformer())
|
.pipeThrough(this.#options.createMediaStreamTransformer())
|
||||||
.pipeThrough(
|
.pipeThrough(
|
||||||
new InspectStream((packet) => {
|
new InspectStream(
|
||||||
if (packet.type === "configuration") {
|
(packet): undefined => {
|
||||||
switch (metadata.codec) {
|
if (packet.type === "configuration") {
|
||||||
case ScrcpyVideoCodecId.H264:
|
switch (this.#metadata.codec) {
|
||||||
this.#configureH264(packet.data);
|
case ScrcpyVideoCodecId.H264:
|
||||||
break;
|
this.#configureH264(packet.data);
|
||||||
case ScrcpyVideoCodecId.H265:
|
break;
|
||||||
this.#configureH265(packet.data);
|
case ScrcpyVideoCodecId.H265:
|
||||||
break;
|
this.#configureH265(packet.data);
|
||||||
case ScrcpyVideoCodecId.AV1:
|
break;
|
||||||
// AV1 configuration is in data packet
|
case ScrcpyVideoCodecId.AV1:
|
||||||
break;
|
// AV1 configuration is in data packet
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
this.#metadata.codec === ScrcpyVideoCodecId.AV1
|
||||||
|
) {
|
||||||
|
this.#configureAv1(packet.data);
|
||||||
}
|
}
|
||||||
} else if (metadata.codec === ScrcpyVideoCodecId.AV1) {
|
},
|
||||||
this.#configureAv1(packet.data);
|
{
|
||||||
}
|
close: () => this.#size.dispose(),
|
||||||
}),
|
cancel: () => this.#size.dispose(),
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#configureH264(data: Uint8Array) {
|
#configureH264(data: Uint8Array) {
|
||||||
const { croppedWidth, croppedHeight } = h264ParseConfiguration(data);
|
const { croppedWidth, croppedHeight } = H264.parseConfiguration(data);
|
||||||
|
this.#size.setSize(croppedWidth, croppedHeight);
|
||||||
this.#width = croppedWidth;
|
|
||||||
this.#height = croppedHeight;
|
|
||||||
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#configureH265(data: Uint8Array) {
|
#configureH265(data: Uint8Array) {
|
||||||
const { croppedWidth, croppedHeight } = h265ParseConfiguration(data);
|
const { croppedWidth, croppedHeight } = H265.parseConfiguration(data);
|
||||||
|
this.#size.setSize(croppedWidth, croppedHeight);
|
||||||
this.#width = croppedWidth;
|
|
||||||
this.#height = croppedHeight;
|
|
||||||
this.#sizeChanged.fire({ width: croppedWidth, height: croppedHeight });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#configureAv1(data: Uint8Array) {
|
#configureAv1(data: Uint8Array) {
|
||||||
|
@ -101,8 +96,6 @@ export class AdbScrcpyVideoStream {
|
||||||
const width = max_frame_width_minus_1 + 1;
|
const width = max_frame_width_minus_1 + 1;
|
||||||
const height = max_frame_height_minus_1 + 1;
|
const height = max_frame_height_minus_1 + 1;
|
||||||
|
|
||||||
this.#width = width;
|
this.#size.setSize(width, height);
|
||||||
this.#height = height;
|
|
||||||
this.#sizeChanged.fire({ width, height });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,9 @@
|
||||||
{
|
{
|
||||||
"path": "../event/tsconfig.build.json"
|
"path": "../event/tsconfig.build.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../media-codec/tsconfig.build.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../scrcpy/tsconfig.build.json"
|
"path": "../scrcpy/tsconfig.build.json"
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,12 +9,4 @@
|
||||||
"node"
|
"node"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../adb/tsconfig.build.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../stream-extra/tsconfig.build.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,12 @@
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.build.json"
|
"path": "./tsconfig.build.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../adb/tsconfig.build.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../stream-extra/tsconfig.build.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -125,10 +125,12 @@ export class Adb implements Closeable {
|
||||||
.pipeThrough(new ConcatStringStream());
|
.pipeThrough(new ConcatStringStream());
|
||||||
}
|
}
|
||||||
|
|
||||||
getProp(key: string): Promise<string> {
|
async getProp(key: string): Promise<string> {
|
||||||
return this.subprocess.noneProtocol
|
const output = await this.subprocess.noneProtocol
|
||||||
.spawnWaitText(["getprop", key])
|
.spawn(["getprop", key])
|
||||||
.then((output) => output.trim());
|
.wait()
|
||||||
|
.toString();
|
||||||
|
return output.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
rm(
|
rm(
|
||||||
|
@ -154,7 +156,11 @@ export class Adb implements Closeable {
|
||||||
// https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984
|
// https://android.googlesource.com/platform/packages/modules/adb/+/1a0fb8846d4e6b671c8aa7f137a8c21d7b248716/client/adb_install.cpp#984
|
||||||
args.push("</dev/null");
|
args.push("</dev/null");
|
||||||
|
|
||||||
return this.subprocess.noneProtocol.spawnWaitText(args);
|
return this.subprocess.noneProtocol
|
||||||
|
.spawn(args)
|
||||||
|
.wait()
|
||||||
|
.toString()
|
||||||
|
.then((output) => output.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(): Promise<AdbSync> {
|
async sync(): Promise<AdbSync> {
|
||||||
|
|
|
@ -36,7 +36,10 @@ export class AdbPower extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
powerOff(): Promise<string> {
|
powerOff(): Promise<string> {
|
||||||
return this.adb.subprocess.noneProtocol.spawnWaitText(["reboot", "-p"]);
|
return this.adb.subprocess.noneProtocol
|
||||||
|
.spawn(["reboot", "-p"])
|
||||||
|
.wait()
|
||||||
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
powerButton(longPress = false): Promise<string> {
|
powerButton(longPress = false): Promise<string> {
|
||||||
|
@ -46,7 +49,7 @@ export class AdbPower extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
args.push("POWER");
|
args.push("POWER");
|
||||||
|
|
||||||
return this.adb.subprocess.noneProtocol.spawnWaitText(args);
|
return this.adb.subprocess.noneProtocol.spawn(args).wait().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from "./none/index.js";
|
export * from "./none/index.js";
|
||||||
export * from "./service.js";
|
export * from "./service.js";
|
||||||
export * from "./shell/index.js";
|
export * from "./shell/index.js";
|
||||||
|
export * from "./types.js";
|
||||||
export * from "./utils.js";
|
export * from "./utils.js";
|
||||||
|
|
|
@ -2,38 +2,47 @@ import type { Adb } from "../../../adb.js";
|
||||||
|
|
||||||
import { AdbNoneProtocolProcessImpl } from "./process.js";
|
import { AdbNoneProtocolProcessImpl } from "./process.js";
|
||||||
import { AdbNoneProtocolPtyProcess } from "./pty.js";
|
import { AdbNoneProtocolPtyProcess } from "./pty.js";
|
||||||
import { AdbNoneProtocolSpawner } from "./spawner.js";
|
import { adbNoneProtocolSpawner } from "./spawner.js";
|
||||||
|
|
||||||
export class AdbNoneProtocolSubprocessService extends AdbNoneProtocolSpawner {
|
export class AdbNoneProtocolSubprocessService {
|
||||||
readonly #adb: Adb;
|
readonly #adb: Adb;
|
||||||
get adb(): Adb {
|
get adb(): Adb {
|
||||||
return this.#adb;
|
return this.#adb;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb) {
|
||||||
super(async (command, signal) => {
|
|
||||||
// `shell,raw:${command}` also triggers raw mode,
|
|
||||||
// But is not supported on Android version <7.
|
|
||||||
const socket = await this.#adb.createSocket(
|
|
||||||
`exec:${command.join(" ")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
await socket.close();
|
|
||||||
throw signal.reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AdbNoneProtocolProcessImpl(socket, signal);
|
|
||||||
});
|
|
||||||
this.#adb = adb;
|
this.#adb = adb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spawn = adbNoneProtocolSpawner(async (command, signal) => {
|
||||||
|
// Android 7 added `shell,raw:${command}` service which also triggers raw mode,
|
||||||
|
// but we want to use the most compatible one.
|
||||||
|
//
|
||||||
|
// Similar to SSH, we don't escape the `command`,
|
||||||
|
// because the command will be invoked by `sh -c`,
|
||||||
|
// it can contain environment variables (`KEY=value command`),
|
||||||
|
// and shell expansions (`echo "$KEY"` vs `echo '$KEY'`),
|
||||||
|
// which we can't know how to properly escape.
|
||||||
|
const socket = await this.#adb.createSocket(
|
||||||
|
`exec:${command.join(" ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
await socket.close();
|
||||||
|
throw signal.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdbNoneProtocolProcessImpl(socket, signal);
|
||||||
|
});
|
||||||
|
|
||||||
async pty(
|
async pty(
|
||||||
command?: string | readonly string[],
|
command?: string | readonly string[],
|
||||||
): Promise<AdbNoneProtocolPtyProcess> {
|
): Promise<AdbNoneProtocolPtyProcess> {
|
||||||
if (command === undefined) {
|
if (command === undefined) {
|
||||||
|
// Run the default shell
|
||||||
command = "";
|
command = "";
|
||||||
} else if (Array.isArray(command)) {
|
} else if (Array.isArray(command)) {
|
||||||
|
// Don't escape `command`. See `spawn` above for details
|
||||||
command = command.join(" ");
|
command = command.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,15 @@ import type {
|
||||||
ReadableStream,
|
ReadableStream,
|
||||||
WritableStream,
|
WritableStream,
|
||||||
} from "@yume-chan/stream-extra";
|
} from "@yume-chan/stream-extra";
|
||||||
import {
|
import { concatUint8Arrays } from "@yume-chan/stream-extra";
|
||||||
ConcatBufferStream,
|
|
||||||
ConcatStringStream,
|
|
||||||
TextDecoderStream,
|
|
||||||
} from "@yume-chan/stream-extra";
|
|
||||||
|
|
||||||
import { splitCommand } from "../utils.js";
|
import type { AdbSubprocessSpawner } from "../types.js";
|
||||||
|
import {
|
||||||
|
createLazyPromise,
|
||||||
|
decodeUtf8Chunked,
|
||||||
|
splitCommand,
|
||||||
|
ToArrayStream,
|
||||||
|
} from "../utils.js";
|
||||||
|
|
||||||
export interface AdbNoneProtocolProcess {
|
export interface AdbNoneProtocolProcess {
|
||||||
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
|
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
|
||||||
|
@ -26,43 +28,54 @@ export interface AdbNoneProtocolProcess {
|
||||||
kill(): MaybePromiseLike<void>;
|
kill(): MaybePromiseLike<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdbNoneProtocolSpawner {
|
export type AdbNoneProtocolSpawner = (
|
||||||
readonly #spawn: (
|
command: string | readonly string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<AdbNoneProtocolProcess> &
|
||||||
|
AdbSubprocessSpawner.Wait<Uint8Array, string>;
|
||||||
|
|
||||||
|
export function adbNoneProtocolSpawner(
|
||||||
|
spawn: (
|
||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
) => Promise<AdbNoneProtocolProcess>;
|
) => Promise<AdbNoneProtocolProcess>,
|
||||||
|
): AdbNoneProtocolSpawner {
|
||||||
constructor(
|
return (command, signal) => {
|
||||||
spawn: (
|
|
||||||
command: readonly string[],
|
|
||||||
signal: AbortSignal | undefined,
|
|
||||||
) => Promise<AdbNoneProtocolProcess>,
|
|
||||||
) {
|
|
||||||
this.#spawn = spawn;
|
|
||||||
}
|
|
||||||
|
|
||||||
spawn(
|
|
||||||
command: string | readonly string[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<AdbNoneProtocolProcess> {
|
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
if (typeof command === "string") {
|
if (typeof command === "string") {
|
||||||
command = splitCommand(command);
|
command = splitCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.#spawn(command, signal);
|
const processPromise = spawn(
|
||||||
}
|
command,
|
||||||
|
signal,
|
||||||
|
) as Promise<AdbNoneProtocolProcess> &
|
||||||
|
AdbSubprocessSpawner.Wait<Uint8Array, string>;
|
||||||
|
|
||||||
async spawnWait(command: string | readonly string[]): Promise<Uint8Array> {
|
processPromise.wait = (options) => {
|
||||||
const process = await this.spawn(command);
|
const waitPromise = processPromise.then(async (process) => {
|
||||||
return await process.output.pipeThrough(new ConcatBufferStream());
|
const [, output] = await Promise.all([
|
||||||
}
|
options?.stdin?.pipeTo(process.stdin),
|
||||||
|
process.output.pipeThrough(new ToArrayStream()),
|
||||||
|
]);
|
||||||
|
return output;
|
||||||
|
});
|
||||||
|
|
||||||
async spawnWaitText(command: string | readonly string[]): Promise<string> {
|
return createLazyPromise(
|
||||||
const process = await this.spawn(command);
|
async () => {
|
||||||
return await process.output
|
const chunks = await waitPromise;
|
||||||
.pipeThrough(new TextDecoderStream())
|
return concatUint8Arrays(chunks);
|
||||||
.pipeThrough(new ConcatStringStream());
|
},
|
||||||
}
|
{
|
||||||
|
async toString() {
|
||||||
|
const chunks = await waitPromise;
|
||||||
|
return decodeUtf8Chunked(chunks);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return processPromise;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export class AdbSubprocessService {
|
||||||
|
|
||||||
this.#noneProtocol = new AdbNoneProtocolSubprocessService(adb);
|
this.#noneProtocol = new AdbNoneProtocolSubprocessService(adb);
|
||||||
|
|
||||||
if (adb.canUseFeature(AdbFeature.ShellV2)) {
|
if (adb.canUseFeature(AdbFeature.Shell2)) {
|
||||||
this.#shellProtocol = new AdbShellProtocolSubprocessService(adb);
|
this.#shellProtocol = new AdbShellProtocolSubprocessService(adb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
StructDeserializeStream,
|
StructDeserializeStream,
|
||||||
WritableStream,
|
WritableStream,
|
||||||
} from "@yume-chan/stream-extra";
|
} from "@yume-chan/stream-extra";
|
||||||
|
import { EmptyUint8Array } from "@yume-chan/struct";
|
||||||
|
|
||||||
import type { AdbSocket } from "../../../adb.js";
|
import type { AdbSocket } from "../../../adb.js";
|
||||||
|
|
||||||
|
@ -110,14 +111,21 @@ export class AdbShellProtocolProcessImpl implements AdbShellProtocolProcess {
|
||||||
|
|
||||||
this.#writer = this.#socket.writable.getWriter();
|
this.#writer = this.#socket.writable.getWriter();
|
||||||
this.#stdin = new MaybeConsumable.WritableStream<Uint8Array>({
|
this.#stdin = new MaybeConsumable.WritableStream<Uint8Array>({
|
||||||
write: async (chunk) => {
|
write: (chunk) =>
|
||||||
await this.#writer.write(
|
this.#writer.write(
|
||||||
AdbShellProtocolPacket.serialize({
|
AdbShellProtocolPacket.serialize({
|
||||||
id: AdbShellProtocolId.Stdin,
|
id: AdbShellProtocolId.Stdin,
|
||||||
data: chunk,
|
data: chunk,
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
},
|
close: () =>
|
||||||
|
// Only shell protocol + raw mode supports closing stdin
|
||||||
|
this.#writer.write(
|
||||||
|
AdbShellProtocolPacket.serialize({
|
||||||
|
id: AdbShellProtocolId.CloseStdin,
|
||||||
|
data: EmptyUint8Array,
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,34 +3,36 @@ import { AdbFeature } from "../../../features.js";
|
||||||
|
|
||||||
import { AdbShellProtocolProcessImpl } from "./process.js";
|
import { AdbShellProtocolProcessImpl } from "./process.js";
|
||||||
import { AdbShellProtocolPtyProcess } from "./pty.js";
|
import { AdbShellProtocolPtyProcess } from "./pty.js";
|
||||||
import { AdbShellProtocolSpawner } from "./spawner.js";
|
import { adbShellProtocolSpawner } from "./spawner.js";
|
||||||
|
|
||||||
export class AdbShellProtocolSubprocessService extends AdbShellProtocolSpawner {
|
export class AdbShellProtocolSubprocessService {
|
||||||
readonly #adb: Adb;
|
readonly #adb: Adb;
|
||||||
get adb() {
|
get adb() {
|
||||||
return this.#adb;
|
return this.#adb;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isSupported() {
|
get isSupported() {
|
||||||
return this.#adb.canUseFeature(AdbFeature.ShellV2);
|
return this.#adb.canUseFeature(AdbFeature.Shell2);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb) {
|
||||||
super(async (command, signal) => {
|
|
||||||
const socket = await this.#adb.createSocket(
|
|
||||||
`shell,v2,raw:${command.join(" ")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
await socket.close();
|
|
||||||
throw signal.reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AdbShellProtocolProcessImpl(socket, signal);
|
|
||||||
});
|
|
||||||
this.#adb = adb;
|
this.#adb = adb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spawn = adbShellProtocolSpawner(async (command, signal) => {
|
||||||
|
// Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details.
|
||||||
|
const socket = await this.#adb.createSocket(
|
||||||
|
`shell,v2,raw:${command.join(" ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
await socket.close();
|
||||||
|
throw signal.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdbShellProtocolProcessImpl(socket, signal);
|
||||||
|
});
|
||||||
|
|
||||||
async pty(options?: {
|
async pty(options?: {
|
||||||
command?: string | readonly string[] | undefined;
|
command?: string | readonly string[] | undefined;
|
||||||
terminalType?: string;
|
terminalType?: string;
|
||||||
|
@ -44,6 +46,7 @@ export class AdbShellProtocolSubprocessService extends AdbShellProtocolSpawner {
|
||||||
service += ":";
|
service += ":";
|
||||||
|
|
||||||
if (options) {
|
if (options) {
|
||||||
|
// Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details.
|
||||||
if (typeof options.command === "string") {
|
if (typeof options.command === "string") {
|
||||||
service += options.command;
|
service += options.command;
|
||||||
} else if (Array.isArray(options.command)) {
|
} else if (Array.isArray(options.command)) {
|
||||||
|
|
|
@ -5,13 +5,15 @@ import type {
|
||||||
ReadableStream,
|
ReadableStream,
|
||||||
WritableStream,
|
WritableStream,
|
||||||
} from "@yume-chan/stream-extra";
|
} from "@yume-chan/stream-extra";
|
||||||
import {
|
import { concatUint8Arrays } from "@yume-chan/stream-extra";
|
||||||
ConcatBufferStream,
|
|
||||||
ConcatStringStream,
|
|
||||||
TextDecoderStream,
|
|
||||||
} from "@yume-chan/stream-extra";
|
|
||||||
|
|
||||||
import { splitCommand } from "../utils.js";
|
import type { AdbSubprocessSpawner } from "../types.js";
|
||||||
|
import {
|
||||||
|
createLazyPromise,
|
||||||
|
decodeUtf8Chunked,
|
||||||
|
splitCommand,
|
||||||
|
ToArrayStream,
|
||||||
|
} from "../utils.js";
|
||||||
|
|
||||||
export interface AdbShellProtocolProcess {
|
export interface AdbShellProtocolProcess {
|
||||||
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
|
get stdin(): WritableStream<MaybeConsumable<Uint8Array>>;
|
||||||
|
@ -24,62 +26,14 @@ export interface AdbShellProtocolProcess {
|
||||||
kill(): MaybePromiseLike<void>;
|
kill(): MaybePromiseLike<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdbShellProtocolSpawner {
|
export type AdbShellProtocolSpawner = (
|
||||||
readonly #spawn: (
|
command: string | readonly string[],
|
||||||
command: readonly string[],
|
signal?: AbortSignal,
|
||||||
signal: AbortSignal | undefined,
|
) => Promise<AdbShellProtocolProcess> &
|
||||||
) => Promise<AdbShellProtocolProcess>;
|
AdbSubprocessSpawner.Wait<
|
||||||
|
AdbShellProtocolSpawner.WaitResult<Uint8Array>,
|
||||||
constructor(
|
AdbShellProtocolSpawner.WaitResult<string>
|
||||||
spawn: (
|
>;
|
||||||
command: readonly string[],
|
|
||||||
signal: AbortSignal | undefined,
|
|
||||||
) => Promise<AdbShellProtocolProcess>,
|
|
||||||
) {
|
|
||||||
this.#spawn = spawn;
|
|
||||||
}
|
|
||||||
|
|
||||||
spawn(
|
|
||||||
command: string | readonly string[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<AdbShellProtocolProcess> {
|
|
||||||
signal?.throwIfAborted();
|
|
||||||
|
|
||||||
if (typeof command === "string") {
|
|
||||||
command = splitCommand(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.#spawn(command, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
async spawnWait(
|
|
||||||
command: string | readonly string[],
|
|
||||||
): Promise<AdbShellProtocolSpawner.WaitResult<Uint8Array>> {
|
|
||||||
const process = await this.spawn(command);
|
|
||||||
const [stdout, stderr, exitCode] = await Promise.all([
|
|
||||||
process.stdout.pipeThrough(new ConcatBufferStream()),
|
|
||||||
process.stderr.pipeThrough(new ConcatBufferStream()),
|
|
||||||
process.exited,
|
|
||||||
]);
|
|
||||||
return { stdout, stderr, exitCode };
|
|
||||||
}
|
|
||||||
|
|
||||||
async spawnWaitText(
|
|
||||||
command: string | readonly string[],
|
|
||||||
): Promise<AdbShellProtocolSpawner.WaitResult<string>> {
|
|
||||||
const process = await this.spawn(command);
|
|
||||||
const [stdout, stderr, exitCode] = await Promise.all([
|
|
||||||
process.stdout
|
|
||||||
.pipeThrough(new TextDecoderStream())
|
|
||||||
.pipeThrough(new ConcatStringStream()),
|
|
||||||
process.stderr
|
|
||||||
.pipeThrough(new TextDecoderStream())
|
|
||||||
.pipeThrough(new ConcatStringStream()),
|
|
||||||
process.exited,
|
|
||||||
]);
|
|
||||||
return { stdout, stderr, exitCode };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace AdbShellProtocolSpawner {
|
export namespace AdbShellProtocolSpawner {
|
||||||
export interface WaitResult<T> {
|
export interface WaitResult<T> {
|
||||||
|
@ -88,3 +42,68 @@ export namespace AdbShellProtocolSpawner {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adbShellProtocolSpawner(
|
||||||
|
spawn: (
|
||||||
|
command: readonly string[],
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
) => Promise<AdbShellProtocolProcess>,
|
||||||
|
): AdbShellProtocolSpawner {
|
||||||
|
return (command, signal) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
|
if (typeof command === "string") {
|
||||||
|
command = splitCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const processPromise = spawn(
|
||||||
|
command,
|
||||||
|
signal,
|
||||||
|
) as Promise<AdbShellProtocolProcess> &
|
||||||
|
AdbSubprocessSpawner.Wait<
|
||||||
|
AdbShellProtocolSpawner.WaitResult<Uint8Array>,
|
||||||
|
AdbShellProtocolSpawner.WaitResult<string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
processPromise.wait = (options) => {
|
||||||
|
const waitPromise = processPromise.then(async (process) => {
|
||||||
|
const [, stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
options?.stdin?.pipeTo(process.stdin),
|
||||||
|
process.stdout.pipeThrough(new ToArrayStream()),
|
||||||
|
process.stderr.pipeThrough(new ToArrayStream()),
|
||||||
|
process.exited,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode,
|
||||||
|
} satisfies AdbShellProtocolSpawner.WaitResult<Uint8Array[]>;
|
||||||
|
});
|
||||||
|
|
||||||
|
return createLazyPromise(
|
||||||
|
async () => {
|
||||||
|
const { stdout, stderr, exitCode } = await waitPromise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: concatUint8Arrays(stdout),
|
||||||
|
stderr: concatUint8Arrays(stderr),
|
||||||
|
exitCode,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
async toString() {
|
||||||
|
const { stdout, stderr, exitCode } = await waitPromise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: decodeUtf8Chunked(stdout),
|
||||||
|
stderr: decodeUtf8Chunked(stderr),
|
||||||
|
exitCode,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return processPromise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
19
libraries/adb/src/commands/subprocess/types.ts
Normal file
19
libraries/adb/src/commands/subprocess/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
|
import type { LazyPromise } from "./utils.js";
|
||||||
|
|
||||||
|
export namespace AdbSubprocessSpawner {
|
||||||
|
export interface WaitToString<T> {
|
||||||
|
toString(): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitOptions {
|
||||||
|
stdin?: ReadableStream<MaybeConsumable<Uint8Array>> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wait<TBuffer, TString> {
|
||||||
|
wait(
|
||||||
|
options?: WaitOptions,
|
||||||
|
): LazyPromise<TBuffer, WaitToString<TString>>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { AccumulateStream } from "@yume-chan/stream-extra";
|
||||||
|
import { TextDecoder } from "@yume-chan/struct";
|
||||||
|
|
||||||
export function escapeArg(s: string) {
|
export function escapeArg(s: string) {
|
||||||
let result = "";
|
let result = "";
|
||||||
result += `'`;
|
result += `'`;
|
||||||
|
@ -10,7 +13,8 @@ export function escapeArg(s: string) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
result += s.substring(base, found);
|
result += s.substring(base, found);
|
||||||
// a'b becomes a'\'b (the backslash is not a escape character)
|
// a'b becomes 'a'\'b', which is 'a' + \' + 'b'
|
||||||
|
// (quoted string 'a', escaped single quote, and quoted string 'b')
|
||||||
result += String.raw`'\''`;
|
result += String.raw`'\''`;
|
||||||
base = found + 1;
|
base = found + 1;
|
||||||
}
|
}
|
||||||
|
@ -19,23 +23,32 @@ export function escapeArg(s: string) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitCommand(command: string): string[] {
|
/**
|
||||||
|
* Split the command.
|
||||||
|
*
|
||||||
|
* Quotes and escaped characters are supported, and will be returned as-is.
|
||||||
|
* @param input The input command
|
||||||
|
* @returns An array of string containing the arguments
|
||||||
|
*/
|
||||||
|
export function splitCommand(input: string): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let quote: string | undefined;
|
let quote: string | undefined;
|
||||||
let isEscaped = false;
|
let isEscaped = false;
|
||||||
let start = 0;
|
let start = 0;
|
||||||
|
|
||||||
for (let i = 0, len = command.length; i < len; i += 1) {
|
for (let i = 0, len = input.length; i < len; i += 1) {
|
||||||
if (isEscaped) {
|
if (isEscaped) {
|
||||||
isEscaped = false;
|
isEscaped = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char = command.charAt(i);
|
const char = input.charAt(i);
|
||||||
switch (char) {
|
switch (char) {
|
||||||
case " ":
|
case " ":
|
||||||
if (!quote && i !== start) {
|
if (!quote) {
|
||||||
result.push(command.substring(start, i));
|
if (i !== start) {
|
||||||
|
result.push(input.substring(start, i));
|
||||||
|
}
|
||||||
start = i + 1;
|
start = i + 1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -53,9 +66,101 @@ export function splitCommand(command: string): string[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start < command.length) {
|
if (start < input.length) {
|
||||||
result.push(command.substring(start));
|
result.push(input.substring(start));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Omit `Symbol.toStringTag` so it's incompatible with `Promise`.
|
||||||
|
// It can't be returned from async function like `Promise`s.
|
||||||
|
export type LazyPromise<T, U> = Omit<Promise<T>, typeof Symbol.toStringTag> & U;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a `Promise`-like object that lazily computes the result
|
||||||
|
* only when it's being used as a `Promise`.
|
||||||
|
*
|
||||||
|
* For example, if an API returns a value `p` of type `Promise<T> & { asU(): Promise<U> }`,
|
||||||
|
* and the user calls `p.asU()` instead of using it as a `Promise` (`p.then()`, `await p`, etc.),
|
||||||
|
* is unnecessary to compute the result `T` (unless `asU` also depends on it).
|
||||||
|
*
|
||||||
|
* By using `createLazyPromise(computeT, { asU: computeU })`,
|
||||||
|
* `computeT` will only run when `p` is used as a `Promise`.
|
||||||
|
*
|
||||||
|
* Note that the result object can't be returned from an async function,
|
||||||
|
* as async functions always creates a new `Promise` with the return value,
|
||||||
|
* which runs `initializer` immediately, and discards any extra `methods` attached.
|
||||||
|
* @param initializer
|
||||||
|
* The initializer function when the result object is being used as a `Promise`.
|
||||||
|
*
|
||||||
|
* The result value will be cached.
|
||||||
|
* @param methods Any extra methods to add to the result object
|
||||||
|
* @returns
|
||||||
|
* A `Promise`-like object that runs `initializer` when used as a `Promise`, and contains `methods`.
|
||||||
|
*/
|
||||||
|
export function createLazyPromise<
|
||||||
|
T,
|
||||||
|
U extends Record<PropertyKey, () => unknown>,
|
||||||
|
>(initializer: () => Promise<T>, methods: U): LazyPromise<T, U> {
|
||||||
|
let promise: Promise<T> | undefined;
|
||||||
|
|
||||||
|
const getOrCreatePromise = () => {
|
||||||
|
if (!promise) {
|
||||||
|
promise = initializer();
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
|
||||||
|
then(onfulfilled, onrejected) {
|
||||||
|
return getOrCreatePromise().then(onfulfilled, onrejected);
|
||||||
|
},
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
|
||||||
|
catch(onrejected) {
|
||||||
|
return getOrCreatePromise().catch(onrejected);
|
||||||
|
},
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: This object is intentionally thenable
|
||||||
|
finally(onfinally) {
|
||||||
|
return getOrCreatePromise().finally(onfinally);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
} satisfies LazyPromise<T, {}> as LazyPromise<T, U>;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(methods)) {
|
||||||
|
Object.defineProperty(result, key, {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
enumerable: false,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToArrayStream<T> extends AccumulateStream<T, T[]> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
[],
|
||||||
|
(chunk, current) => {
|
||||||
|
current.push(chunk);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
(output) => output,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeUtf8Chunked(chunks: Uint8Array[]): string {
|
||||||
|
// PERF: `TextDecoder`'s `stream` mode can decode from `chunks` directly.
|
||||||
|
// This avoids an extra allocation and copy.
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let output = "";
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
output += decoder.decode(chunk, { stream: true });
|
||||||
|
}
|
||||||
|
output += decoder.decode();
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
33
libraries/adb/src/commands/sync/id-common.ts
Normal file
33
libraries/adb/src/commands/sync/id-common.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { getUint32LittleEndian } from "@yume-chan/no-data-view";
|
||||||
|
|
||||||
|
function encodeAsciiUnchecked(value: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const result = new Uint8Array(value.length);
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
result[i] = value.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode ID to numbers for faster comparison.
|
||||||
|
*
|
||||||
|
* This function skips all checks. The caller must ensure the input is valid.
|
||||||
|
*
|
||||||
|
* @param value A 4 ASCII character string.
|
||||||
|
* @returns A 32-bit integer by encoding the string as little-endian
|
||||||
|
*
|
||||||
|
* #__NO_SIDE_EFFECTS__
|
||||||
|
*/
|
||||||
|
export function adbSyncEncodeId(value: string): number {
|
||||||
|
const buffer = encodeAsciiUnchecked(value);
|
||||||
|
return getUint32LittleEndian(buffer, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
|
||||||
|
|
||||||
|
export const Lstat = adbSyncEncodeId("STAT");
|
||||||
|
export const Stat = adbSyncEncodeId("STA2");
|
||||||
|
export const LstatV2 = adbSyncEncodeId("LST2");
|
||||||
|
|
||||||
|
export const Done = adbSyncEncodeId("DONE");
|
||||||
|
export const Data = adbSyncEncodeId("DATA");
|
12
libraries/adb/src/commands/sync/id-request.ts
Normal file
12
libraries/adb/src/commands/sync/id-request.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { adbSyncEncodeId } from "./id-common.js";
|
||||||
|
|
||||||
|
export { Data, Done, Lstat, LstatV2, Stat } from "./id-common.js";
|
||||||
|
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
|
||||||
|
|
||||||
|
export const List = adbSyncEncodeId("LIST");
|
||||||
|
export const ListV2 = adbSyncEncodeId("LIS2");
|
||||||
|
|
||||||
|
export const Send = adbSyncEncodeId("SEND");
|
||||||
|
export const SendV2 = adbSyncEncodeId("SND2");
|
||||||
|
export const Receive = adbSyncEncodeId("RECV");
|
11
libraries/adb/src/commands/sync/id-response.ts
Normal file
11
libraries/adb/src/commands/sync/id-response.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { adbSyncEncodeId } from "./id-common.js";
|
||||||
|
|
||||||
|
export { Data, Done, Lstat, LstatV2, Stat } from "./id-common.js";
|
||||||
|
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/file_sync_protocol.h;l=23;drc=888a54dcbf954fdffacc8283a793290abcc589cd
|
||||||
|
|
||||||
|
export const Entry = adbSyncEncodeId("DENT");
|
||||||
|
export const EntryV2 = adbSyncEncodeId("DNT2");
|
||||||
|
|
||||||
|
export const Ok = adbSyncEncodeId("OKAY");
|
||||||
|
export const Fail = adbSyncEncodeId("FAIL");
|
7
libraries/adb/src/commands/sync/id.ts
Normal file
7
libraries/adb/src/commands/sync/id.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import * as AdbSyncRequestId from "./id-request.js";
|
||||||
|
import * as AdbSyncResponseId from "./id-response.js";
|
||||||
|
|
||||||
|
// Values of `AdbSyncRequestId` and `AdbSyncResponseId` are all generic `number`s
|
||||||
|
// so there is no point creating types for them
|
||||||
|
|
||||||
|
export { AdbSyncRequestId, AdbSyncResponseId };
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./id.js";
|
||||||
export * from "./list.js";
|
export * from "./list.js";
|
||||||
export * from "./pull.js";
|
export * from "./pull.js";
|
||||||
export * from "./push.js";
|
export * from "./push.js";
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { StructValue } from "@yume-chan/struct";
|
import type { StructValue } from "@yume-chan/struct";
|
||||||
import { extend, string, u32 } from "@yume-chan/struct";
|
import { extend, string, u32 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
|
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
|
||||||
import { AdbSyncResponseId, adbSyncReadResponses } from "./response.js";
|
import { adbSyncWriteRequest } from "./request.js";
|
||||||
|
import { adbSyncReadResponses } from "./response.js";
|
||||||
import type { AdbSyncSocket } from "./socket.js";
|
import type { AdbSyncSocket } from "./socket.js";
|
||||||
import type { AdbSyncStat } from "./stat.js";
|
import type { AdbSyncStat } from "./stat.js";
|
||||||
import {
|
import {
|
||||||
|
@ -36,7 +37,7 @@ export async function* adbSyncOpenDirV2(
|
||||||
await adbSyncWriteRequest(locked, AdbSyncRequestId.ListV2, path);
|
await adbSyncWriteRequest(locked, AdbSyncRequestId.ListV2, path);
|
||||||
for await (const item of adbSyncReadResponses(
|
for await (const item of adbSyncReadResponses(
|
||||||
locked,
|
locked,
|
||||||
AdbSyncResponseId.Entry2,
|
AdbSyncResponseId.EntryV2,
|
||||||
AdbSyncEntry2Response,
|
AdbSyncEntry2Response,
|
||||||
)) {
|
)) {
|
||||||
// `LST2` can return error codes for failed `lstat` calls.
|
// `LST2` can return error codes for failed `lstat` calls.
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { ReadableStream } from "@yume-chan/stream-extra";
|
||||||
import type { StructValue } from "@yume-chan/struct";
|
import type { StructValue } from "@yume-chan/struct";
|
||||||
import { buffer, struct, u32 } from "@yume-chan/struct";
|
import { buffer, struct, u32 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
|
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
|
||||||
import { adbSyncReadResponses, AdbSyncResponseId } from "./response.js";
|
import { adbSyncWriteRequest } from "./request.js";
|
||||||
|
import { adbSyncReadResponses } from "./response.js";
|
||||||
import type { AdbSyncSocket } from "./socket.js";
|
import type { AdbSyncSocket } from "./socket.js";
|
||||||
|
|
||||||
export const AdbSyncDataResponse = struct(
|
export const AdbSyncDataResponse = struct(
|
||||||
|
|
|
@ -8,8 +8,9 @@ import { struct, u32 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { NOOP } from "../../utils/index.js";
|
import { NOOP } from "../../utils/index.js";
|
||||||
|
|
||||||
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
|
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
|
||||||
import { AdbSyncResponseId, adbSyncReadResponse } from "./response.js";
|
import { adbSyncWriteRequest } from "./request.js";
|
||||||
|
import { adbSyncReadResponse } from "./response.js";
|
||||||
import type { AdbSyncSocket, AdbSyncSocketLocked } from "./socket.js";
|
import type { AdbSyncSocket, AdbSyncSocketLocked } from "./socket.js";
|
||||||
import { LinuxFileType } from "./stat.js";
|
import { LinuxFileType } from "./stat.js";
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,5 @@
|
||||||
import { encodeUtf8, struct, u32 } from "@yume-chan/struct";
|
import { encodeUtf8, struct, u32 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { adbSyncEncodeId } from "./response.js";
|
|
||||||
|
|
||||||
export const AdbSyncRequestId = {
|
|
||||||
List: adbSyncEncodeId("LIST"),
|
|
||||||
ListV2: adbSyncEncodeId("LIS2"),
|
|
||||||
Send: adbSyncEncodeId("SEND"),
|
|
||||||
SendV2: adbSyncEncodeId("SND2"),
|
|
||||||
Lstat: adbSyncEncodeId("STAT"),
|
|
||||||
Stat: adbSyncEncodeId("STA2"),
|
|
||||||
LstatV2: adbSyncEncodeId("LST2"),
|
|
||||||
Data: adbSyncEncodeId("DATA"),
|
|
||||||
Done: adbSyncEncodeId("DONE"),
|
|
||||||
Receive: adbSyncEncodeId("RECV"),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const AdbSyncNumberRequest = struct(
|
export const AdbSyncNumberRequest = struct(
|
||||||
{ id: u32, arg: u32 },
|
{ id: u32, arg: u32 },
|
||||||
{ littleEndian: true },
|
{ littleEndian: true },
|
||||||
|
@ -26,13 +11,9 @@ export interface AdbSyncWritable {
|
||||||
|
|
||||||
export async function adbSyncWriteRequest(
|
export async function adbSyncWriteRequest(
|
||||||
writable: AdbSyncWritable,
|
writable: AdbSyncWritable,
|
||||||
id: number | string,
|
id: number,
|
||||||
value: number | string | Uint8Array,
|
value: number | string | Uint8Array,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (typeof id === "string") {
|
|
||||||
id = adbSyncEncodeId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
await writable.write(
|
await writable.write(
|
||||||
AdbSyncNumberRequest.serialize({ id, arg: value }),
|
AdbSyncNumberRequest.serialize({ id, arg: value }),
|
||||||
|
|
|
@ -2,39 +2,9 @@ import { getUint32LittleEndian } from "@yume-chan/no-data-view";
|
||||||
import type { AsyncExactReadable, StructDeserializer } from "@yume-chan/struct";
|
import type { AsyncExactReadable, StructDeserializer } from "@yume-chan/struct";
|
||||||
import { decodeUtf8, string, struct, u32 } from "@yume-chan/struct";
|
import { decodeUtf8, string, struct, u32 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { unreachable } from "../../utils/no-op.js";
|
import { unreachable } from "../../utils/index.js";
|
||||||
|
|
||||||
function encodeAsciiUnchecked(value: string): Uint8Array<ArrayBuffer> {
|
import { AdbSyncResponseId } from "./id.js";
|
||||||
const result = new Uint8Array(value.length);
|
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
|
||||||
result[i] = value.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode ID to numbers for faster comparison
|
|
||||||
* @param value A 4-character string
|
|
||||||
* @returns A 32-bit integer by encoding the string as little-endian
|
|
||||||
*
|
|
||||||
* #__NO_SIDE_EFFECTS__
|
|
||||||
*/
|
|
||||||
export function adbSyncEncodeId(value: string): number {
|
|
||||||
const buffer = encodeAsciiUnchecked(value);
|
|
||||||
return getUint32LittleEndian(buffer, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdbSyncResponseId = {
|
|
||||||
Entry: adbSyncEncodeId("DENT"),
|
|
||||||
Entry2: adbSyncEncodeId("DNT2"),
|
|
||||||
Lstat: adbSyncEncodeId("STAT"),
|
|
||||||
Stat: adbSyncEncodeId("STA2"),
|
|
||||||
Lstat2: adbSyncEncodeId("LST2"),
|
|
||||||
Done: adbSyncEncodeId("DONE"),
|
|
||||||
Data: adbSyncEncodeId("DATA"),
|
|
||||||
Ok: adbSyncEncodeId("OKAY"),
|
|
||||||
Fail: adbSyncEncodeId("FAIL"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AdbSyncError extends Error {}
|
export class AdbSyncError extends Error {}
|
||||||
|
|
||||||
|
@ -50,18 +20,14 @@ export const AdbSyncFailResponse = struct(
|
||||||
|
|
||||||
export async function adbSyncReadResponse<T>(
|
export async function adbSyncReadResponse<T>(
|
||||||
stream: AsyncExactReadable,
|
stream: AsyncExactReadable,
|
||||||
id: number | string,
|
id: number,
|
||||||
type: StructDeserializer<T>,
|
type: StructDeserializer<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (typeof id === "string") {
|
|
||||||
id = adbSyncEncodeId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await stream.readExactly(4);
|
const buffer = await stream.readExactly(4);
|
||||||
switch (getUint32LittleEndian(buffer, 0)) {
|
switch (getUint32LittleEndian(buffer, 0)) {
|
||||||
case AdbSyncResponseId.Fail:
|
case AdbSyncResponseId.Fail:
|
||||||
await AdbSyncFailResponse.deserialize(stream);
|
await AdbSyncFailResponse.deserialize(stream);
|
||||||
throw new Error("Unreachable");
|
unreachable();
|
||||||
case id:
|
case id:
|
||||||
return await type.deserialize(stream);
|
return await type.deserialize(stream);
|
||||||
default:
|
default:
|
||||||
|
@ -73,13 +39,9 @@ export async function adbSyncReadResponse<T>(
|
||||||
|
|
||||||
export async function* adbSyncReadResponses<T>(
|
export async function* adbSyncReadResponses<T>(
|
||||||
stream: AsyncExactReadable,
|
stream: AsyncExactReadable,
|
||||||
id: number | string,
|
id: number,
|
||||||
type: StructDeserializer<T>,
|
type: StructDeserializer<T>,
|
||||||
): AsyncGenerator<T, void, void> {
|
): AsyncGenerator<T, void, void> {
|
||||||
if (typeof id === "string") {
|
|
||||||
id = adbSyncEncodeId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const buffer = await stream.readExactly(4);
|
const buffer = await stream.readExactly(4);
|
||||||
switch (getUint32LittleEndian(buffer, 0)) {
|
switch (getUint32LittleEndian(buffer, 0)) {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { StructValue } from "@yume-chan/struct";
|
import type { StructValue } from "@yume-chan/struct";
|
||||||
import { struct, u32, u64 } from "@yume-chan/struct";
|
import { struct, u32, u64 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js";
|
import { AdbSyncRequestId, AdbSyncResponseId } from "./id.js";
|
||||||
import { AdbSyncResponseId, adbSyncReadResponse } from "./response.js";
|
import { adbSyncWriteRequest } from "./request.js";
|
||||||
|
import { adbSyncReadResponse } from "./response.js";
|
||||||
import type { AdbSyncSocket } from "./socket.js";
|
import type { AdbSyncSocket } from "./socket.js";
|
||||||
|
|
||||||
// https://github.com/python/cpython/blob/4e581d64b8aff3e2eda99b12f080c877bb78dfca/Lib/stat.py#L36
|
// https://github.com/python/cpython/blob/4e581d64b8aff3e2eda99b12f080c877bb78dfca/Lib/stat.py#L36
|
||||||
|
@ -131,7 +132,7 @@ export async function adbSyncLstat(
|
||||||
await adbSyncWriteRequest(locked, AdbSyncRequestId.LstatV2, path);
|
await adbSyncWriteRequest(locked, AdbSyncRequestId.LstatV2, path);
|
||||||
return await adbSyncReadResponse(
|
return await adbSyncReadResponse(
|
||||||
locked,
|
locked,
|
||||||
AdbSyncResponseId.Lstat2,
|
AdbSyncResponseId.LstatV2,
|
||||||
AdbSyncStatResponse,
|
AdbSyncStatResponse,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { adbSyncLstat, adbSyncStat } from "./stat.js";
|
||||||
export function dirname(path: string): string {
|
export function dirname(path: string): string {
|
||||||
const end = path.lastIndexOf("/");
|
const end = path.lastIndexOf("/");
|
||||||
if (end === -1) {
|
if (end === -1) {
|
||||||
throw new Error(`Invalid path`);
|
throw new Error(`Invalid absolute unix path: ${path}`);
|
||||||
}
|
}
|
||||||
if (end === 0) {
|
if (end === 0) {
|
||||||
return "/";
|
return "/";
|
||||||
|
@ -43,25 +43,25 @@ export class AdbSync {
|
||||||
protected _socket: AdbSyncSocket;
|
protected _socket: AdbSyncSocket;
|
||||||
|
|
||||||
readonly #supportsStat: boolean;
|
readonly #supportsStat: boolean;
|
||||||
readonly #supportsListV2: boolean;
|
readonly #supportsLs2: boolean;
|
||||||
readonly #fixedPushMkdir: boolean;
|
readonly #fixedPushMkdir: boolean;
|
||||||
readonly #supportsSendReceiveV2: boolean;
|
readonly #supportsSendReceive2: boolean;
|
||||||
readonly #needPushMkdirWorkaround: boolean;
|
readonly #needPushMkdirWorkaround: boolean;
|
||||||
|
|
||||||
get supportsStat(): boolean {
|
get supportsStat(): boolean {
|
||||||
return this.#supportsStat;
|
return this.#supportsStat;
|
||||||
}
|
}
|
||||||
|
|
||||||
get supportsListV2(): boolean {
|
get supportsLs2(): boolean {
|
||||||
return this.#supportsListV2;
|
return this.#supportsLs2;
|
||||||
}
|
}
|
||||||
|
|
||||||
get fixedPushMkdir(): boolean {
|
get fixedPushMkdir(): boolean {
|
||||||
return this.#fixedPushMkdir;
|
return this.#fixedPushMkdir;
|
||||||
}
|
}
|
||||||
|
|
||||||
get supportsSendReceiveV2(): boolean {
|
get supportsSendReceive2(): boolean {
|
||||||
return this.#supportsSendReceiveV2;
|
return this.#supportsSendReceive2;
|
||||||
}
|
}
|
||||||
|
|
||||||
get needPushMkdirWorkaround(): boolean {
|
get needPushMkdirWorkaround(): boolean {
|
||||||
|
@ -72,15 +72,13 @@ export class AdbSync {
|
||||||
this._adb = adb;
|
this._adb = adb;
|
||||||
this._socket = new AdbSyncSocket(socket, adb.maxPayloadSize);
|
this._socket = new AdbSyncSocket(socket, adb.maxPayloadSize);
|
||||||
|
|
||||||
this.#supportsStat = adb.canUseFeature(AdbFeature.StatV2);
|
this.#supportsStat = adb.canUseFeature(AdbFeature.Stat2);
|
||||||
this.#supportsListV2 = adb.canUseFeature(AdbFeature.ListV2);
|
this.#supportsLs2 = adb.canUseFeature(AdbFeature.Ls2);
|
||||||
this.#fixedPushMkdir = adb.canUseFeature(AdbFeature.FixedPushMkdir);
|
this.#fixedPushMkdir = adb.canUseFeature(AdbFeature.FixedPushMkdir);
|
||||||
this.#supportsSendReceiveV2 = adb.canUseFeature(
|
this.#supportsSendReceive2 = adb.canUseFeature(AdbFeature.SendReceive2);
|
||||||
AdbFeature.SendReceiveV2,
|
|
||||||
);
|
|
||||||
// https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361
|
// https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361
|
||||||
this.#needPushMkdirWorkaround =
|
this.#needPushMkdirWorkaround =
|
||||||
this._adb.canUseFeature(AdbFeature.ShellV2) && !this.fixedPushMkdir;
|
this._adb.canUseFeature(AdbFeature.Shell2) && !this.fixedPushMkdir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -120,7 +118,7 @@ export class AdbSync {
|
||||||
}
|
}
|
||||||
|
|
||||||
opendir(path: string): AsyncGenerator<AdbSyncEntry, void, void> {
|
opendir(path: string): AsyncGenerator<AdbSyncEntry, void, void> {
|
||||||
return adbSyncOpenDir(this._socket, path, this.supportsListV2);
|
return adbSyncOpenDir(this._socket, path, this.supportsLs2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readdir(path: string) {
|
async readdir(path: string) {
|
||||||
|
@ -151,15 +149,13 @@ export class AdbSync {
|
||||||
// It may fail if `filename` already exists.
|
// It may fail if `filename` already exists.
|
||||||
// Ignore the result.
|
// Ignore the result.
|
||||||
// TODO: sync: test push mkdir workaround (need an Android 8 device)
|
// TODO: sync: test push mkdir workaround (need an Android 8 device)
|
||||||
await this._adb.subprocess.noneProtocol.spawnWait([
|
await this._adb.subprocess.noneProtocol
|
||||||
"mkdir",
|
.spawn(["mkdir", "-p", escapeArg(dirname(options.filename))])
|
||||||
"-p",
|
.wait();
|
||||||
escapeArg(dirname(options.filename)),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await adbSyncPush({
|
await adbSyncPush({
|
||||||
v2: this.supportsSendReceiveV2,
|
v2: this.supportsSendReceive2,
|
||||||
socket: this._socket,
|
socket: this._socket,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,34 +1,35 @@
|
||||||
import * as assert from "node:assert";
|
import * as assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { EmptyUint8Array, encodeUtf8 } from "@yume-chan/struct";
|
import { encodeUtf8 } from "@yume-chan/struct";
|
||||||
|
|
||||||
import { decodeBase64 } from "../utils/base64.js";
|
import { decodeBase64 } from "../utils/base64.js";
|
||||||
|
|
||||||
import type { AdbCredentialStore } from "./auth.js";
|
import type { AdbCredentialStore } from "./auth.js";
|
||||||
import { AdbAuthType, AdbPublicKeyAuthenticator } from "./auth.js";
|
import { AdbAuthType, AdbDefaultAuthenticator } from "./auth.js";
|
||||||
import type { AdbPacketData } from "./packet.js";
|
import type { SimpleRsaPrivateKey } from "./crypto.js";
|
||||||
|
import { rsaParsePrivateKey } from "./crypto.js";
|
||||||
import { AdbCommand } from "./packet.js";
|
import { AdbCommand } from "./packet.js";
|
||||||
|
|
||||||
class MockCredentialStore implements AdbCredentialStore {
|
class MockCredentialStore implements AdbCredentialStore {
|
||||||
key: Uint8Array;
|
key: SimpleRsaPrivateKey;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
|
|
||||||
constructor(key: Uint8Array, name: string | undefined) {
|
constructor(key: Uint8Array, name: string | undefined) {
|
||||||
this.key = key;
|
this.key = rsaParsePrivateKey(key);
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
*iterateKeys() {
|
*iterateKeys() {
|
||||||
yield {
|
yield {
|
||||||
buffer: this.key,
|
...this.key,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
generateKey() {
|
generateKey() {
|
||||||
return {
|
return {
|
||||||
buffer: this.key,
|
...this.key,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -74,35 +75,40 @@ const PUBLIC_KEY =
|
||||||
"QAAAANVsDNqDk46/2Qg74n5POy5nK/XA8glCLkvXMks9p885+GQ2WiVUctG8LP/W5cII11Pk1KsZ+90ccZV2fdjv+tnW/8li9iEWTC+G1udFMxsIQ+HRPvJF0Xl9JXDsC6pvdo9ic4d6r5BC9BGiijd0enoG/tHkJhMhbPf/j7+MWXDrF+BeJeyj0mWArbqS599IO2qUCZiNjRakAa/iESG6Om4xCJWTT8wGhSTs81cHcEeSmQ2ixRwS+uaa/8iK/mv6BvCep5qgFrJW1G9LD2WciVgTpOSc6B1N/OA92hwJYp2lHLPWZl6bJIYHqrzdHCxc4EEVVYHkSBdFy1w2vhg2YgRTlpbP00NVrZb6Car8BTqPnwTRIkHBC6nnrg6cWMQ0xusMtxChKBoYGhCLHY4iKK6ra3P1Ou1UXu0WySau3s+Av9FFXxtAuMAJUA+5GSMQGGECRhwLX910OfnHHN+VxqJkHQye4vNhIH5C1dJ39HJoxAdwH2tF7v7GF2fwsy2lUa3Vj6bBssWivCB9cKyJR0GVPZJZ1uah24ecvspwtAqbtxvj7ZD9l7AD92geEJdLrsbfhNaDyAioQ2grI32gdp80su/7BrdAsPaSomxCYBB8opmS+oJq6qTYxNZ0doT9EEyT5D9rl9UXXxq+rQbDpKV1rOQo5zJJ2GkELhUrslFm6n4+JQEAAQA=";
|
"QAAAANVsDNqDk46/2Qg74n5POy5nK/XA8glCLkvXMks9p885+GQ2WiVUctG8LP/W5cII11Pk1KsZ+90ccZV2fdjv+tnW/8li9iEWTC+G1udFMxsIQ+HRPvJF0Xl9JXDsC6pvdo9ic4d6r5BC9BGiijd0enoG/tHkJhMhbPf/j7+MWXDrF+BeJeyj0mWArbqS599IO2qUCZiNjRakAa/iESG6Om4xCJWTT8wGhSTs81cHcEeSmQ2ixRwS+uaa/8iK/mv6BvCep5qgFrJW1G9LD2WciVgTpOSc6B1N/OA92hwJYp2lHLPWZl6bJIYHqrzdHCxc4EEVVYHkSBdFy1w2vhg2YgRTlpbP00NVrZb6Car8BTqPnwTRIkHBC6nnrg6cWMQ0xusMtxChKBoYGhCLHY4iKK6ra3P1Ou1UXu0WySau3s+Av9FFXxtAuMAJUA+5GSMQGGECRhwLX910OfnHHN+VxqJkHQye4vNhIH5C1dJ39HJoxAdwH2tF7v7GF2fwsy2lUa3Vj6bBssWivCB9cKyJR0GVPZJZ1uah24ecvspwtAqbtxvj7ZD9l7AD92geEJdLrsbfhNaDyAioQ2grI32gdp80su/7BrdAsPaSomxCYBB8opmS+oJq6qTYxNZ0doT9EEyT5D9rl9UXXxq+rQbDpKV1rOQo5zJJ2GkELhUrslFm6n4+JQEAAQA=";
|
||||||
|
|
||||||
describe("auth", () => {
|
describe("auth", () => {
|
||||||
describe("PublicKeyAuthenticator", () => {
|
describe("AdbDefaultAuthenticator", () => {
|
||||||
it("should generate correct public key without name", async () => {
|
it("should generate correct public key without name", async () => {
|
||||||
const store = new MockCredentialStore(
|
const store = new MockCredentialStore(
|
||||||
new Uint8Array(PRIVATE_KEY),
|
new Uint8Array(PRIVATE_KEY),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const authenticator = AdbPublicKeyAuthenticator(store, () =>
|
const authenticator = new AdbDefaultAuthenticator(store);
|
||||||
Promise.resolve({
|
const challenge = new Uint8Array(20);
|
||||||
command: AdbCommand.Auth,
|
|
||||||
arg0: AdbAuthType.Token,
|
|
||||||
arg1: 0,
|
|
||||||
payload: EmptyUint8Array,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: AdbPacketData[] = [];
|
const first = await authenticator.authenticate({
|
||||||
for await (const result of authenticator) {
|
command: AdbCommand.Auth,
|
||||||
results.push(result);
|
arg0: AdbAuthType.Token,
|
||||||
}
|
arg1: 0,
|
||||||
|
payload: challenge,
|
||||||
|
});
|
||||||
|
// This test focuses on public key authentication, so only check
|
||||||
|
// the first response is type Signature and ignore other fields
|
||||||
|
assert.strictEqual(first.command, AdbCommand.Auth);
|
||||||
|
assert.strictEqual(first.arg0, AdbAuthType.Signature);
|
||||||
|
|
||||||
assert.deepStrictEqual(results, [
|
const result = await authenticator.authenticate({
|
||||||
{
|
command: AdbCommand.Auth,
|
||||||
command: AdbCommand.Auth,
|
arg0: AdbAuthType.Token,
|
||||||
arg0: AdbAuthType.PublicKey,
|
arg1: 0,
|
||||||
arg1: 0,
|
payload: challenge,
|
||||||
payload: encodeUtf8(`${PUBLIC_KEY}\0`),
|
});
|
||||||
},
|
|
||||||
]);
|
assert.deepStrictEqual(result, {
|
||||||
|
command: AdbCommand.Auth,
|
||||||
|
arg0: AdbAuthType.PublicKey,
|
||||||
|
arg1: 0,
|
||||||
|
payload: encodeUtf8(`${PUBLIC_KEY}\0`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should generate correct public key name", async () => {
|
it("should generate correct public key name", async () => {
|
||||||
|
@ -113,28 +119,33 @@ describe("auth", () => {
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|
||||||
const authenticator = AdbPublicKeyAuthenticator(store, () =>
|
const authenticator = new AdbDefaultAuthenticator(store);
|
||||||
Promise.resolve({
|
const challenge = new Uint8Array(20);
|
||||||
command: AdbCommand.Auth,
|
|
||||||
arg0: AdbAuthType.Token,
|
|
||||||
arg1: 0,
|
|
||||||
payload: EmptyUint8Array,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: AdbPacketData[] = [];
|
const first = await authenticator.authenticate({
|
||||||
for await (const result of authenticator) {
|
command: AdbCommand.Auth,
|
||||||
results.push(result);
|
arg0: AdbAuthType.Token,
|
||||||
}
|
arg1: 0,
|
||||||
|
payload: challenge,
|
||||||
|
});
|
||||||
|
// This test focuses on public key authentication, so only check
|
||||||
|
// the first response is type Signature and ignore other fields
|
||||||
|
assert.strictEqual(first.command, AdbCommand.Auth);
|
||||||
|
assert.strictEqual(first.arg0, AdbAuthType.Signature);
|
||||||
|
|
||||||
assert.deepStrictEqual(results, [
|
const result = await authenticator.authenticate({
|
||||||
{
|
command: AdbCommand.Auth,
|
||||||
command: AdbCommand.Auth,
|
arg0: AdbAuthType.Token,
|
||||||
arg0: AdbAuthType.PublicKey,
|
arg1: 0,
|
||||||
arg1: 0,
|
payload: challenge,
|
||||||
payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`),
|
});
|
||||||
},
|
|
||||||
]);
|
assert.deepStrictEqual(result, {
|
||||||
|
command: AdbCommand.Auth,
|
||||||
|
arg0: AdbAuthType.PublicKey,
|
||||||
|
arg1: 0,
|
||||||
|
payload: encodeUtf8(`${PUBLIC_KEY} ${name}\0`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import type { MaybePromiseLike } from "@yume-chan/async";
|
import type { MaybePromiseLike } from "@yume-chan/async";
|
||||||
import { PromiseResolver } from "@yume-chan/async";
|
import { EventEmitter } from "@yume-chan/event";
|
||||||
import type { Disposable } from "@yume-chan/event";
|
|
||||||
import { EmptyUint8Array } from "@yume-chan/struct";
|
import { EmptyUint8Array } from "@yume-chan/struct";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -9,6 +8,7 @@ import {
|
||||||
encodeUtf8,
|
encodeUtf8,
|
||||||
} from "../utils/index.js";
|
} from "../utils/index.js";
|
||||||
|
|
||||||
|
import type { SimpleRsaPrivateKey } from "./crypto.js";
|
||||||
import {
|
import {
|
||||||
adbGeneratePublicKey,
|
adbGeneratePublicKey,
|
||||||
adbGetPublicKeySize,
|
adbGetPublicKeySize,
|
||||||
|
@ -17,11 +17,7 @@ import {
|
||||||
import type { AdbPacketData } from "./packet.js";
|
import type { AdbPacketData } from "./packet.js";
|
||||||
import { AdbCommand } from "./packet.js";
|
import { AdbCommand } from "./packet.js";
|
||||||
|
|
||||||
export interface AdbPrivateKey {
|
export interface AdbPrivateKey extends SimpleRsaPrivateKey {
|
||||||
/**
|
|
||||||
* The private key in PKCS #8 format.
|
|
||||||
*/
|
|
||||||
buffer: Uint8Array;
|
|
||||||
name?: string | undefined;
|
name?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,153 +48,101 @@ export const AdbAuthType = {
|
||||||
export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType];
|
export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType];
|
||||||
|
|
||||||
export interface AdbAuthenticator {
|
export interface AdbAuthenticator {
|
||||||
/**
|
authenticate(packet: AdbPacketData): Promise<AdbPacketData>;
|
||||||
* @param getNextRequest
|
|
||||||
*
|
close?(): MaybePromiseLike<undefined>;
|
||||||
* Call this function to get the next authentication request packet from device.
|
|
||||||
*
|
|
||||||
* After calling `getNextRequest`, authenticator can `yield` a packet as response, or `return` to indicate its incapability of handling the request.
|
|
||||||
*
|
|
||||||
* After `return`, the `AdbAuthenticatorHandler` will move on to next authenticator and never go back.
|
|
||||||
*
|
|
||||||
* Calling `getNextRequest` multiple times without `yield` or `return` will always return the same request.
|
|
||||||
*/
|
|
||||||
(
|
|
||||||
credentialStore: AdbCredentialStore,
|
|
||||||
getNextRequest: () => Promise<AdbPacketData>,
|
|
||||||
): AsyncIterable<AdbPacketData>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdbSignatureAuthenticator: AdbAuthenticator = async function* (
|
export class AdbDefaultAuthenticator implements AdbAuthenticator {
|
||||||
credentialStore: AdbCredentialStore,
|
#credentialStore: AdbCredentialStore;
|
||||||
getNextRequest: () => Promise<AdbPacketData>,
|
#iterator:
|
||||||
): AsyncIterable<AdbPacketData> {
|
| Iterator<AdbPrivateKey, void, void>
|
||||||
for await (const key of credentialStore.iterateKeys()) {
|
| AsyncIterator<AdbPrivateKey, void, void>
|
||||||
const packet = await getNextRequest();
|
| undefined;
|
||||||
|
#firstKey: AdbPrivateKey | undefined;
|
||||||
|
|
||||||
if (packet.arg0 !== AdbAuthType.Token) {
|
#onPublicKeyAuthentication = new EventEmitter<void>();
|
||||||
return;
|
get onPublicKeyAuthentication() {
|
||||||
}
|
return this.#onPublicKeyAuthentication.event;
|
||||||
|
|
||||||
const signature = rsaSign(key.buffer, packet.payload);
|
|
||||||
yield {
|
|
||||||
command: AdbCommand.Auth,
|
|
||||||
arg0: AdbAuthType.Signature,
|
|
||||||
arg1: 0,
|
|
||||||
payload: signature,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
|
|
||||||
credentialStore: AdbCredentialStore,
|
|
||||||
getNextRequest: () => Promise<AdbPacketData>,
|
|
||||||
): AsyncIterable<AdbPacketData> {
|
|
||||||
const packet = await getNextRequest();
|
|
||||||
|
|
||||||
if (packet.arg0 !== AdbAuthType.Token) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let privateKey: AdbPrivateKey | undefined;
|
constructor(credentialStore: AdbCredentialStore) {
|
||||||
for await (const key of credentialStore.iterateKeys()) {
|
|
||||||
privateKey = key;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!privateKey) {
|
|
||||||
privateKey = await credentialStore.generateKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKeyLength = adbGetPublicKeySize();
|
|
||||||
const [publicKeyBase64Length] =
|
|
||||||
calculateBase64EncodedLength(publicKeyLength);
|
|
||||||
|
|
||||||
const nameBuffer = privateKey.name?.length
|
|
||||||
? encodeUtf8(privateKey.name)
|
|
||||||
: EmptyUint8Array;
|
|
||||||
const publicKeyBuffer = new Uint8Array(
|
|
||||||
publicKeyBase64Length +
|
|
||||||
(nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name
|
|
||||||
1, // Null character
|
|
||||||
);
|
|
||||||
|
|
||||||
adbGeneratePublicKey(privateKey.buffer, publicKeyBuffer);
|
|
||||||
encodeBase64(publicKeyBuffer.subarray(0, publicKeyLength), publicKeyBuffer);
|
|
||||||
|
|
||||||
if (nameBuffer.length) {
|
|
||||||
publicKeyBuffer[publicKeyBase64Length] = 0x20;
|
|
||||||
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
command: AdbCommand.Auth,
|
|
||||||
arg0: AdbAuthType.PublicKey,
|
|
||||||
arg1: 0,
|
|
||||||
payload: publicKeyBuffer,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ADB_DEFAULT_AUTHENTICATORS: readonly AdbAuthenticator[] = [
|
|
||||||
AdbSignatureAuthenticator,
|
|
||||||
AdbPublicKeyAuthenticator,
|
|
||||||
];
|
|
||||||
|
|
||||||
export class AdbAuthenticationProcessor implements Disposable {
|
|
||||||
readonly authenticators: readonly AdbAuthenticator[];
|
|
||||||
|
|
||||||
readonly #credentialStore: AdbCredentialStore;
|
|
||||||
|
|
||||||
#pendingRequest = new PromiseResolver<AdbPacketData>();
|
|
||||||
#iterator: AsyncIterator<AdbPacketData, void, void> | undefined;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
authenticators: readonly AdbAuthenticator[],
|
|
||||||
credentialStore: AdbCredentialStore,
|
|
||||||
) {
|
|
||||||
this.authenticators = authenticators;
|
|
||||||
this.#credentialStore = credentialStore;
|
this.#credentialStore = credentialStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
#getNextRequest = (): Promise<AdbPacketData> => {
|
async authenticate(packet: AdbPacketData): Promise<AdbPacketData> {
|
||||||
return this.#pendingRequest.promise;
|
if (packet.arg0 !== AdbAuthType.Token) {
|
||||||
};
|
throw new Error("Unsupported authentication packet");
|
||||||
|
}
|
||||||
|
|
||||||
async *#invokeAuthenticator(): AsyncGenerator<AdbPacketData, void, void> {
|
if (!this.#iterator) {
|
||||||
for (const authenticator of this.authenticators) {
|
const iterable = this.#credentialStore.iterateKeys();
|
||||||
for await (const packet of authenticator(
|
if (Symbol.iterator in iterable) {
|
||||||
this.#credentialStore,
|
this.#iterator = iterable[Symbol.iterator]();
|
||||||
this.#getNextRequest,
|
} else if (Symbol.asyncIterator in iterable) {
|
||||||
)) {
|
this.#iterator = iterable[Symbol.asyncIterator]();
|
||||||
// If the authenticator yielded a response
|
} else {
|
||||||
// Prepare `nextRequest` for next authentication request
|
throw new Error("`iterateKeys` doesn't return an iterator");
|
||||||
this.#pendingRequest = new PromiseResolver();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Yield the response to outer layer
|
const { done, value } = await this.#iterator.next();
|
||||||
yield packet;
|
if (!done) {
|
||||||
|
if (!this.#firstKey) {
|
||||||
|
this.#firstKey = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the authenticator returned,
|
return {
|
||||||
// Next authenticator will be given the same `pendingRequest`
|
command: AdbCommand.Auth,
|
||||||
|
arg0: AdbAuthType.Signature,
|
||||||
|
arg1: 0,
|
||||||
|
payload: rsaSign(value, packet.payload),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#onPublicKeyAuthentication.fire();
|
||||||
|
|
||||||
|
let key = this.#firstKey;
|
||||||
|
if (!key) {
|
||||||
|
key = await this.#credentialStore.generateKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyLength = adbGetPublicKeySize();
|
||||||
|
const [publicKeyBase64Length] =
|
||||||
|
calculateBase64EncodedLength(publicKeyLength);
|
||||||
|
|
||||||
|
const nameBuffer = key.name?.length
|
||||||
|
? encodeUtf8(key.name)
|
||||||
|
: EmptyUint8Array;
|
||||||
|
const publicKeyBuffer = new Uint8Array(
|
||||||
|
publicKeyBase64Length +
|
||||||
|
(nameBuffer.length ? nameBuffer.length + 1 : 0) + // Space character + name
|
||||||
|
1, // Null character
|
||||||
|
);
|
||||||
|
|
||||||
|
adbGeneratePublicKey(key, publicKeyBuffer);
|
||||||
|
encodeBase64(
|
||||||
|
publicKeyBuffer.subarray(0, publicKeyLength),
|
||||||
|
publicKeyBuffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nameBuffer.length) {
|
||||||
|
publicKeyBuffer[publicKeyBase64Length] = 0x20;
|
||||||
|
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: AdbCommand.Auth,
|
||||||
|
arg0: AdbAuthType.PublicKey,
|
||||||
|
arg1: 0,
|
||||||
|
payload: publicKeyBuffer,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(packet: AdbPacketData): Promise<AdbPacketData> {
|
async close(): Promise<undefined> {
|
||||||
if (!this.#iterator) {
|
await this.#iterator?.return?.();
|
||||||
this.#iterator = this.#invokeAuthenticator();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#pendingRequest.resolve(packet);
|
this.#iterator = undefined;
|
||||||
|
this.#firstKey = undefined;
|
||||||
const result = await this.#iterator.next();
|
|
||||||
if (result.done) {
|
|
||||||
throw new Error("No authenticator can handle the request");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
void this.#iterator?.return?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,11 @@ import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { decodeBase64 } from "../utils/base64.js";
|
import { decodeBase64 } from "../utils/base64.js";
|
||||||
|
|
||||||
import { adbGeneratePublicKey, modInverse } from "./crypto.js";
|
import {
|
||||||
|
adbGeneratePublicKey,
|
||||||
|
modInverse,
|
||||||
|
rsaParsePrivateKey,
|
||||||
|
} from "./crypto.js";
|
||||||
|
|
||||||
describe("modInverse", () => {
|
describe("modInverse", () => {
|
||||||
it("should return correct value", () => {
|
it("should return correct value", () => {
|
||||||
|
@ -72,7 +76,8 @@ const PUBLIC_KEY = decodeBase64(
|
||||||
|
|
||||||
describe("adbGeneratePublicKey", () => {
|
describe("adbGeneratePublicKey", () => {
|
||||||
it("should return correct value", () => {
|
it("should return correct value", () => {
|
||||||
const generated = adbGeneratePublicKey(PRIVATE_KEY);
|
const simpleKey = rsaParsePrivateKey(PRIVATE_KEY);
|
||||||
|
const generated = adbGeneratePublicKey(simpleKey);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
generated.subarray(0, 4),
|
generated.subarray(0, 4),
|
||||||
PUBLIC_KEY.subarray(0, 4),
|
PUBLIC_KEY.subarray(0, 4),
|
||||||
|
@ -96,8 +101,9 @@ describe("adbGeneratePublicKey", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw if output is too small", () => {
|
it("should throw if output is too small", () => {
|
||||||
|
const simpleKey = rsaParsePrivateKey(PRIVATE_KEY);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => adbGeneratePublicKey(PRIVATE_KEY, new Uint8Array(1)),
|
() => adbGeneratePublicKey(simpleKey, new Uint8Array(1)),
|
||||||
/output buffer is too small/,
|
/output buffer is too small/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -48,11 +48,14 @@ export function setBigUint(
|
||||||
littleEndian?: boolean,
|
littleEndian?: boolean,
|
||||||
) {
|
) {
|
||||||
if (littleEndian) {
|
if (littleEndian) {
|
||||||
|
const end = byteOffset + length;
|
||||||
while (value > 0n) {
|
while (value > 0n) {
|
||||||
setInt64LittleEndian(array, byteOffset, value);
|
setInt64LittleEndian(array, byteOffset, value);
|
||||||
byteOffset += 8;
|
byteOffset += 8;
|
||||||
value >>= 64n;
|
value >>= 64n;
|
||||||
}
|
}
|
||||||
|
// Clear the trailing bytes
|
||||||
|
array.subarray(byteOffset, end).fill(0);
|
||||||
} else {
|
} else {
|
||||||
let position = byteOffset + length - 8;
|
let position = byteOffset + length - 8;
|
||||||
while (value > 0n) {
|
while (value > 0n) {
|
||||||
|
@ -60,9 +63,16 @@ export function setBigUint(
|
||||||
position -= 8;
|
position -= 8;
|
||||||
value >>= 64n;
|
value >>= 64n;
|
||||||
}
|
}
|
||||||
|
// Clear the leading bytes
|
||||||
|
array.subarray(byteOffset, position + 8).fill(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimpleRsaPrivateKey {
|
||||||
|
n: bigint;
|
||||||
|
d: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
// These values are correct only if
|
// These values are correct only if
|
||||||
// modulus length is 2048 and
|
// modulus length is 2048 and
|
||||||
// public exponent (e) is 65537
|
// public exponent (e) is 65537
|
||||||
|
@ -89,10 +99,16 @@ const RsaPrivateKeyNLength = 2048 / 8;
|
||||||
const RsaPrivateKeyDOffset = 303;
|
const RsaPrivateKeyDOffset = 303;
|
||||||
const RsaPrivateKeyDLength = 2048 / 8;
|
const RsaPrivateKeyDLength = 2048 / 8;
|
||||||
|
|
||||||
export function rsaParsePrivateKey(key: Uint8Array): [n: bigint, d: bigint] {
|
export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
|
||||||
|
if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) {
|
||||||
|
throw new Error(
|
||||||
|
"RSA private key is too short. Expecting a PKCS#8 formatted RSA private key with modulus length 2048 bits and public exponent 65537.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
|
const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
|
||||||
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
|
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
|
||||||
return [n, d];
|
return { n, d };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nonNegativeMod(m: number, d: number) {
|
function nonNegativeMod(m: number, d: number) {
|
||||||
|
@ -141,14 +157,14 @@ export function adbGetPublicKeySize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adbGeneratePublicKey(
|
export function adbGeneratePublicKey(
|
||||||
privateKey: Uint8Array,
|
privateKey: SimpleRsaPrivateKey,
|
||||||
): Uint8Array<ArrayBuffer>;
|
): Uint8Array<ArrayBuffer>;
|
||||||
export function adbGeneratePublicKey(
|
export function adbGeneratePublicKey(
|
||||||
privateKey: Uint8Array,
|
privateKey: SimpleRsaPrivateKey,
|
||||||
output: Uint8Array,
|
output: Uint8Array,
|
||||||
): number;
|
): number;
|
||||||
export function adbGeneratePublicKey(
|
export function adbGeneratePublicKey(
|
||||||
privateKey: Uint8Array,
|
privateKey: SimpleRsaPrivateKey,
|
||||||
output?: Uint8Array,
|
output?: Uint8Array,
|
||||||
): Uint8Array | number {
|
): Uint8Array | number {
|
||||||
// cspell: ignore: mincrypt
|
// cspell: ignore: mincrypt
|
||||||
|
@ -198,7 +214,7 @@ export function adbGeneratePublicKey(
|
||||||
outputOffset += 4;
|
outputOffset += 4;
|
||||||
|
|
||||||
// extract `n` from private key
|
// extract `n` from private key
|
||||||
const [n] = rsaParsePrivateKey(privateKey);
|
const { n } = privateKey;
|
||||||
|
|
||||||
// Calculate `n0inv`
|
// Calculate `n0inv`
|
||||||
const n0inv = -modInverse(Number(n % 2n ** 32n), 2 ** 32);
|
const n0inv = -modInverse(Number(n % 2n ** 32n), 2 ** 32);
|
||||||
|
@ -283,17 +299,27 @@ export const SHA1_DIGEST_INFO = new Uint8Array([
|
||||||
SHA1_DIGEST_LENGTH,
|
SHA1_DIGEST_LENGTH,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SubtleCrypto.sign() will hash the given data and sign the hash
|
// Standard `RSASSA-PKCS1-v1_5` algorithm will hash the given data
|
||||||
// But we don't need the hashing step
|
// and sign the hash
|
||||||
// (In another word, ADB just requires the client to
|
// https://datatracker.ietf.org/doc/html/rfc8017#section-8.2
|
||||||
// encrypt the given data with its private key)
|
//
|
||||||
// However SubtileCrypto.encrypt() doesn't accept 'RSASSA-PKCS1-v1_5' algorithm
|
// But ADB authentication passes 20 bytes of random value to
|
||||||
// So we need to implement the encryption by ourself
|
// OpenSSL's `RSA_sign` method which treat the input as a hash
|
||||||
|
// https://docs.openssl.org/1.0.2/man3/RSA_sign/
|
||||||
|
//
|
||||||
|
// Since it's non-standard and not supported by Web Crypto API,
|
||||||
|
// we need to implement the signing by ourself
|
||||||
export function rsaSign(
|
export function rsaSign(
|
||||||
privateKey: Uint8Array,
|
privateKey: SimpleRsaPrivateKey,
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
): Uint8Array<ArrayBuffer> {
|
): Uint8Array<ArrayBuffer> {
|
||||||
const [n, d] = rsaParsePrivateKey(privateKey);
|
if (data.length !== SHA1_DIGEST_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`rsaSign expects ${SHA1_DIGEST_LENGTH} bytes (SHA-1 digest length) of data but got ${data.length} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { n, d } = privateKey;
|
||||||
|
|
||||||
// PKCS#1 padding
|
// PKCS#1 padding
|
||||||
const padded = new Uint8Array(256);
|
const padded = new Uint8Array(256);
|
||||||
|
|
|
@ -14,42 +14,15 @@ import type {
|
||||||
AdbTransport,
|
AdbTransport,
|
||||||
} from "../adb.js";
|
} from "../adb.js";
|
||||||
import { AdbBanner } from "../banner.js";
|
import { AdbBanner } from "../banner.js";
|
||||||
import { AdbFeature } from "../features.js";
|
import { AdbDeviceFeatures, AdbFeature } from "../features.js";
|
||||||
|
|
||||||
import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js";
|
import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js";
|
||||||
import {
|
import { AdbDefaultAuthenticator } from "./auth.js";
|
||||||
ADB_DEFAULT_AUTHENTICATORS,
|
|
||||||
AdbAuthenticationProcessor,
|
|
||||||
} from "./auth.js";
|
|
||||||
import { AdbPacketDispatcher } from "./dispatcher.js";
|
import { AdbPacketDispatcher } from "./dispatcher.js";
|
||||||
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
|
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
|
||||||
import { AdbCommand, calculateChecksum } from "./packet.js";
|
import { AdbCommand, calculateChecksum } from "./packet.js";
|
||||||
|
|
||||||
export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001;
|
export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001;
|
||||||
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252
|
|
||||||
// There are some other feature constants, but some of them are only used by ADB server, not devices (daemons).
|
|
||||||
export const ADB_DAEMON_DEFAULT_FEATURES = /* #__PURE__ */ (() =>
|
|
||||||
[
|
|
||||||
AdbFeature.ShellV2,
|
|
||||||
AdbFeature.Cmd,
|
|
||||||
AdbFeature.StatV2,
|
|
||||||
AdbFeature.ListV2,
|
|
||||||
AdbFeature.FixedPushMkdir,
|
|
||||||
"apex",
|
|
||||||
AdbFeature.Abb,
|
|
||||||
// only tells the client the symlink timestamp issue in `adb push --sync` has been fixed.
|
|
||||||
// No special handling required.
|
|
||||||
"fixed_push_symlink_timestamp",
|
|
||||||
AdbFeature.AbbExec,
|
|
||||||
"remount_shell",
|
|
||||||
"track_app",
|
|
||||||
AdbFeature.SendReceiveV2,
|
|
||||||
"sendrecv_v2_brotli",
|
|
||||||
"sendrecv_v2_lz4",
|
|
||||||
"sendrecv_v2_zstd",
|
|
||||||
"sendrecv_v2_dry_run_send",
|
|
||||||
AdbFeature.DelayedAck,
|
|
||||||
] as readonly AdbFeature[])();
|
|
||||||
export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024;
|
export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024;
|
||||||
|
|
||||||
export type AdbDaemonConnection = ReadableWritablePair<
|
export type AdbDaemonConnection = ReadableWritablePair<
|
||||||
|
@ -60,8 +33,6 @@ export type AdbDaemonConnection = ReadableWritablePair<
|
||||||
export interface AdbDaemonAuthenticationOptions {
|
export interface AdbDaemonAuthenticationOptions {
|
||||||
serial: string;
|
serial: string;
|
||||||
connection: AdbDaemonConnection;
|
connection: AdbDaemonConnection;
|
||||||
credentialStore: AdbCredentialStore;
|
|
||||||
authenticators?: readonly AdbAuthenticator[];
|
|
||||||
features?: readonly AdbFeature[];
|
features?: readonly AdbFeature[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -159,22 +130,36 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
static async authenticate({
|
static async authenticate({
|
||||||
serial,
|
serial,
|
||||||
connection,
|
connection,
|
||||||
credentialStore,
|
features = AdbDeviceFeatures,
|
||||||
authenticators = ADB_DEFAULT_AUTHENTICATORS,
|
|
||||||
features = ADB_DAEMON_DEFAULT_FEATURES,
|
|
||||||
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
|
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
|
||||||
...options
|
...options
|
||||||
}: AdbDaemonAuthenticationOptions): Promise<AdbDaemonTransport> {
|
}: AdbDaemonAuthenticationOptions &
|
||||||
|
(
|
||||||
|
| { authenticator: AdbAuthenticator }
|
||||||
|
| {
|
||||||
|
credentialStore: AdbCredentialStore;
|
||||||
|
onPublicKeyAuthentication?: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
)): Promise<AdbDaemonTransport> {
|
||||||
// Initially, set to highest-supported version and payload size.
|
// Initially, set to highest-supported version and payload size.
|
||||||
let version = 0x01000001;
|
let version = 0x01000001;
|
||||||
// Android 4: 4K, Android 7: 256K, Android 9: 1M
|
// Android 4: 4K, Android 7: 256K, Android 9: 1M
|
||||||
let maxPayloadSize = 1024 * 1024;
|
let maxPayloadSize = 1024 * 1024;
|
||||||
|
|
||||||
const resolver = new PromiseResolver<string>();
|
const resolver = new PromiseResolver<string>();
|
||||||
const authProcessor = new AdbAuthenticationProcessor(
|
let authenticator: AdbAuthenticator;
|
||||||
authenticators,
|
if ("authenticator" in options) {
|
||||||
credentialStore,
|
authenticator = options.authenticator;
|
||||||
);
|
} else {
|
||||||
|
authenticator = new AdbDefaultAuthenticator(
|
||||||
|
options.credentialStore,
|
||||||
|
);
|
||||||
|
if (options.onPublicKeyAuthentication) {
|
||||||
|
(
|
||||||
|
authenticator as AdbDefaultAuthenticator
|
||||||
|
).onPublicKeyAuthentication(options.onPublicKeyAuthentication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Here is similar to `AdbPacketDispatcher`,
|
// Here is similar to `AdbPacketDispatcher`,
|
||||||
// But the received packet types and send packet processing are different.
|
// But the received packet types and send packet processing are different.
|
||||||
|
@ -193,9 +178,9 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
resolver.resolve(decodeUtf8(packet.payload));
|
resolver.resolve(decodeUtf8(packet.payload));
|
||||||
break;
|
break;
|
||||||
case AdbCommand.Auth: {
|
case AdbCommand.Auth: {
|
||||||
const response =
|
await sendPacket(
|
||||||
await authProcessor.process(packet);
|
await authenticator.authenticate(packet),
|
||||||
await sendPacket(response);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -215,13 +200,17 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
() => {
|
async () => {
|
||||||
|
await authenticator.close?.();
|
||||||
|
|
||||||
// If `resolver` is already settled, call `reject` won't do anything.
|
// If `resolver` is already settled, call `reject` won't do anything.
|
||||||
resolver.reject(
|
resolver.reject(
|
||||||
new Error("Connection closed unexpectedly"),
|
new Error("Connection closed unexpectedly"),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(e) => {
|
async (e) => {
|
||||||
|
await authenticator.close?.();
|
||||||
|
|
||||||
resolver.reject(e);
|
resolver.reject(e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -238,11 +227,10 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualFeatures = features.slice();
|
|
||||||
if (initialDelayedAckBytes <= 0) {
|
if (initialDelayedAckBytes <= 0) {
|
||||||
const index = features.indexOf(AdbFeature.DelayedAck);
|
const index = features.indexOf(AdbFeature.DelayedAck);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
actualFeatures.splice(index, 1);
|
features = features.toSpliced(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,9 +242,7 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
arg1: maxPayloadSize,
|
arg1: maxPayloadSize,
|
||||||
// The terminating `;` is required in formal definition
|
// The terminating `;` is required in formal definition
|
||||||
// But ADB daemon (all versions) can still work without it
|
// But ADB daemon (all versions) can still work without it
|
||||||
payload: encodeUtf8(
|
payload: encodeUtf8(`host::features=${features.join(",")}`),
|
||||||
`host::features=${actualFeatures.join(",")}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
banner = await resolver.promise;
|
banner = await resolver.promise;
|
||||||
|
@ -276,9 +262,10 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
version,
|
version,
|
||||||
maxPayloadSize,
|
maxPayloadSize,
|
||||||
banner,
|
banner,
|
||||||
features: actualFeatures,
|
features,
|
||||||
initialDelayedAckBytes,
|
initialDelayedAckBytes,
|
||||||
...options,
|
preserveConnection: options.preserveConnection,
|
||||||
|
readTimeLimit: options.readTimeLimit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -322,7 +309,7 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
connection,
|
connection,
|
||||||
version,
|
version,
|
||||||
banner,
|
banner,
|
||||||
features = ADB_DAEMON_DEFAULT_FEATURES,
|
features = AdbDeviceFeatures,
|
||||||
initialDelayedAckBytes,
|
initialDelayedAckBytes,
|
||||||
...options
|
...options
|
||||||
}: AdbDaemonSocketConnectorConstructionOptions) {
|
}: AdbDaemonSocketConnectorConstructionOptions) {
|
||||||
|
@ -345,19 +332,19 @@ export class AdbDaemonTransport implements AdbTransport {
|
||||||
initialDelayedAckBytes = 0;
|
initialDelayedAckBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let calculateChecksum: boolean;
|
let shouldCalculateChecksum: boolean;
|
||||||
let appendNullToServiceString: boolean;
|
let shouldAppendNullToServiceString: boolean;
|
||||||
if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) {
|
if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) {
|
||||||
calculateChecksum = false;
|
shouldCalculateChecksum = false;
|
||||||
appendNullToServiceString = false;
|
shouldAppendNullToServiceString = false;
|
||||||
} else {
|
} else {
|
||||||
calculateChecksum = true;
|
shouldCalculateChecksum = true;
|
||||||
appendNullToServiceString = true;
|
shouldAppendNullToServiceString = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#dispatcher = new AdbPacketDispatcher(connection, {
|
this.#dispatcher = new AdbPacketDispatcher(connection, {
|
||||||
calculateChecksum,
|
calculateChecksum: shouldCalculateChecksum,
|
||||||
appendNullToServiceString,
|
appendNullToServiceString: shouldAppendNullToServiceString,
|
||||||
initialDelayedAckBytes,
|
initialDelayedAckBytes,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
47
libraries/adb/src/features-value.ts
Normal file
47
libraries/adb/src/features-value.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// cspell: ignore Libusb
|
||||||
|
// cspell: ignore devraw
|
||||||
|
// cspell: ignore Openscreen
|
||||||
|
// cspell: ignore devicetracker
|
||||||
|
|
||||||
|
// The order follows
|
||||||
|
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/transport.cpp;l=81;drc=2d3e62c2af54a3e8f8803ea10492e63b8dfe709f
|
||||||
|
|
||||||
|
export const Shell2 = "shell_v2";
|
||||||
|
export const Cmd = "cmd";
|
||||||
|
export const Stat2 = "stat_v2";
|
||||||
|
export const Ls2 = "ls_v2";
|
||||||
|
/**
|
||||||
|
* server only
|
||||||
|
*/
|
||||||
|
export const Libusb = "libusb";
|
||||||
|
/**
|
||||||
|
* server only
|
||||||
|
*/
|
||||||
|
export const PushSync = "push_sync";
|
||||||
|
export const Apex = "apex";
|
||||||
|
export const FixedPushMkdir = "fixed_push_mkdir";
|
||||||
|
export const Abb = "abb";
|
||||||
|
export const FixedPushSymlinkTimestamp = "fixed_push_symlink_timestamp";
|
||||||
|
export const AbbExec = "abb_exec";
|
||||||
|
export const RemountShell = "remount_shell";
|
||||||
|
export const TrackApp = "track_app";
|
||||||
|
export const SendReceive2 = "sendrecv_v2";
|
||||||
|
export const SendReceive2Brotli = "sendrecv_v2_brotli";
|
||||||
|
export const SendReceive2Lz4 = "sendrecv_v2_lz4";
|
||||||
|
export const SendReceive2Zstd = "sendrecv_v2_zstd";
|
||||||
|
export const SendReceive2DryRunSend = "sendrecv_v2_dry_run_send";
|
||||||
|
export const DelayedAck = "delayed_ack";
|
||||||
|
/**
|
||||||
|
* server only
|
||||||
|
*/
|
||||||
|
export const OpenscreenMdns = "openscreen_mdns";
|
||||||
|
/**
|
||||||
|
* server only
|
||||||
|
*/
|
||||||
|
export const DeviceTrackerProtoFormat = "devicetracker_proto_format";
|
||||||
|
export const DevRaw = "devraw";
|
||||||
|
export const AppInfo = "app_info";
|
||||||
|
/**
|
||||||
|
* server only
|
||||||
|
*/
|
||||||
|
export const ServerStatus = "server_status";
|
|
@ -1,15 +1,32 @@
|
||||||
// The order follows
|
import * as AdbFeature from "./features-value.js";
|
||||||
// https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/transport.cpp;l=77;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
|
|
||||||
export const AdbFeature = {
|
|
||||||
ShellV2: "shell_v2",
|
|
||||||
Cmd: "cmd",
|
|
||||||
StatV2: "stat_v2",
|
|
||||||
ListV2: "ls_v2",
|
|
||||||
FixedPushMkdir: "fixed_push_mkdir",
|
|
||||||
Abb: "abb",
|
|
||||||
AbbExec: "abb_exec",
|
|
||||||
SendReceiveV2: "sendrecv_v2",
|
|
||||||
DelayedAck: "delayed_ack",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AdbFeature = (typeof AdbFeature)[keyof typeof AdbFeature];
|
// biome-ignore lint/suspicious/noRedeclare: TypeScript declaration merging for enum-like object
|
||||||
|
type AdbFeature = (typeof AdbFeature)[keyof typeof AdbFeature];
|
||||||
|
|
||||||
|
export { AdbFeature };
|
||||||
|
|
||||||
|
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252
|
||||||
|
// There are some other feature constants, but some of them are only used by ADB server, not devices (daemons).
|
||||||
|
export const AdbDeviceFeatures = [
|
||||||
|
AdbFeature.Shell2,
|
||||||
|
AdbFeature.Cmd,
|
||||||
|
AdbFeature.Stat2,
|
||||||
|
AdbFeature.Ls2,
|
||||||
|
AdbFeature.FixedPushMkdir,
|
||||||
|
AdbFeature.Apex,
|
||||||
|
AdbFeature.Abb,
|
||||||
|
// only tells the client the symlink timestamp issue in `adb push --sync` has been fixed.
|
||||||
|
// No special handling required.
|
||||||
|
AdbFeature.FixedPushSymlinkTimestamp,
|
||||||
|
AdbFeature.AbbExec,
|
||||||
|
AdbFeature.RemountShell,
|
||||||
|
AdbFeature.TrackApp,
|
||||||
|
AdbFeature.SendReceive2,
|
||||||
|
AdbFeature.SendReceive2Brotli,
|
||||||
|
AdbFeature.SendReceive2Lz4,
|
||||||
|
AdbFeature.SendReceive2Zstd,
|
||||||
|
AdbFeature.SendReceive2DryRunSend,
|
||||||
|
AdbFeature.DevRaw,
|
||||||
|
AdbFeature.AppInfo,
|
||||||
|
AdbFeature.DelayedAck,
|
||||||
|
] as readonly AdbFeature[];
|
||||||
|
|
|
@ -6,32 +6,10 @@ import type {
|
||||||
AdbTransport,
|
AdbTransport,
|
||||||
} from "../adb.js";
|
} from "../adb.js";
|
||||||
import type { AdbBanner } from "../banner.js";
|
import type { AdbBanner } from "../banner.js";
|
||||||
import { AdbFeature } from "../features.js";
|
import { AdbDeviceFeatures } from "../features.js";
|
||||||
|
|
||||||
import type { AdbServerClient } from "./client.js";
|
import type { AdbServerClient } from "./client.js";
|
||||||
|
|
||||||
export const ADB_SERVER_DEFAULT_FEATURES = /* #__PURE__ */ (() =>
|
|
||||||
[
|
|
||||||
AdbFeature.ShellV2,
|
|
||||||
AdbFeature.Cmd,
|
|
||||||
AdbFeature.StatV2,
|
|
||||||
AdbFeature.ListV2,
|
|
||||||
AdbFeature.FixedPushMkdir,
|
|
||||||
"apex",
|
|
||||||
AdbFeature.Abb,
|
|
||||||
// only tells the client the symlink timestamp issue in `adb push --sync` has been fixed.
|
|
||||||
// No special handling required.
|
|
||||||
"fixed_push_symlink_timestamp",
|
|
||||||
AdbFeature.AbbExec,
|
|
||||||
"remount_shell",
|
|
||||||
"track_app",
|
|
||||||
AdbFeature.SendReceiveV2,
|
|
||||||
"sendrecv_v2_brotli",
|
|
||||||
"sendrecv_v2_lz4",
|
|
||||||
"sendrecv_v2_zstd",
|
|
||||||
"sendrecv_v2_dry_run_send",
|
|
||||||
] as readonly AdbFeature[])();
|
|
||||||
|
|
||||||
export class AdbServerTransport implements AdbTransport {
|
export class AdbServerTransport implements AdbTransport {
|
||||||
#client: AdbServerClient;
|
#client: AdbServerClient;
|
||||||
|
|
||||||
|
@ -52,9 +30,14 @@ export class AdbServerTransport implements AdbTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientFeatures() {
|
get clientFeatures() {
|
||||||
// No need to get host features (features supported by ADB server)
|
// This list tells the `Adb` instance how to invoke some commands.
|
||||||
// Because we create all ADB packets ourselves
|
//
|
||||||
return ADB_SERVER_DEFAULT_FEATURES;
|
// Because all device commands are created by the `Adb` instance, not ADB server,
|
||||||
|
// we don't need to fetch current server's feature list using `host-features` command.
|
||||||
|
//
|
||||||
|
// And because all server commands are created by the `AdbServerClient` instance, not `Adb`,
|
||||||
|
// we don't need to pass server-only features to `Adb` in this list.
|
||||||
|
return AdbDeviceFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/max-params
|
// eslint-disable-next-line @typescript-eslint/max-params
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import type { Adb } from "@yume-chan/adb";
|
import type { Adb, AdbNoneProtocolProcess } from "@yume-chan/adb";
|
||||||
import { AdbServiceBase } from "@yume-chan/adb";
|
import { AdbServiceBase, escapeArg } from "@yume-chan/adb";
|
||||||
|
import { SplitStringStream, TextDecoderStream } from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
import { CmdNoneProtocolService } from "./cmd.js";
|
import { Cmd } from "./cmd/index.js";
|
||||||
import type { IntentBuilder } from "./intent.js";
|
import type { Intent } from "./intent.js";
|
||||||
import type { SingleUser } from "./utils.js";
|
import { serializeIntent } from "./intent.js";
|
||||||
import { buildArguments } from "./utils.js";
|
import type { SingleUser, SingleUserOrAll } from "./utils.js";
|
||||||
|
import { buildCommand } from "./utils.js";
|
||||||
|
|
||||||
export interface ActivityManagerStartActivityOptions {
|
export interface ActivityManagerStartActivityOptions {
|
||||||
displayId?: number;
|
displayId?: number;
|
||||||
windowingMode?: number;
|
windowingMode?: number;
|
||||||
forceStop?: boolean;
|
forceStop?: boolean;
|
||||||
user?: SingleUser;
|
user?: SingleUser;
|
||||||
intent: IntentBuilder;
|
intent: Intent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const START_ACTIVITY_OPTIONS_MAP: Partial<
|
const START_ACTIVITY_OPTIONS_MAP: Partial<
|
||||||
|
@ -24,41 +26,96 @@ const START_ACTIVITY_OPTIONS_MAP: Partial<
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ActivityManager extends AdbServiceBase {
|
export class ActivityManager extends AdbServiceBase {
|
||||||
static ServiceName = "activity";
|
static readonly ServiceName = "activity";
|
||||||
static CommandName = "am";
|
static readonly CommandName = "am";
|
||||||
|
|
||||||
#cmd: CmdNoneProtocolService;
|
#apiLevel: number | undefined;
|
||||||
|
#cmd: Cmd.NoneProtocolService;
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb, apiLevel?: number) {
|
||||||
super(adb);
|
super(adb);
|
||||||
this.#cmd = new CmdNoneProtocolService(
|
|
||||||
adb,
|
this.#apiLevel = apiLevel;
|
||||||
ActivityManager.CommandName,
|
this.#cmd = Cmd.createNoneProtocol(adb, ActivityManager.CommandName);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async startActivity(
|
async startActivity(
|
||||||
options: ActivityManagerStartActivityOptions,
|
options: ActivityManagerStartActivityOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let args = buildArguments(
|
// Android 8 added "start-activity" alias to "start"
|
||||||
[ActivityManager.ServiceName, "start-activity", "-W"],
|
// but we want to use the most compatible one.
|
||||||
|
const command = buildCommand(
|
||||||
|
[ActivityManager.ServiceName, "start", "-W"],
|
||||||
options,
|
options,
|
||||||
START_ACTIVITY_OPTIONS_MAP,
|
START_ACTIVITY_OPTIONS_MAP,
|
||||||
);
|
);
|
||||||
|
|
||||||
args = args.concat(options.intent.build());
|
for (const arg of serializeIntent(options.intent)) {
|
||||||
|
command.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
const output = await this.#cmd
|
// Android 7 supports `cmd activity` but not `cmd activity start` command
|
||||||
.spawnWaitText(args)
|
let process: AdbNoneProtocolProcess;
|
||||||
.then((output) => output.trim());
|
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
|
||||||
|
command[0] = ActivityManager.CommandName;
|
||||||
|
process = await this.adb.subprocess.noneProtocol.spawn(
|
||||||
|
command.map(escapeArg),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process = await this.#cmd.spawn(command);
|
||||||
|
}
|
||||||
|
|
||||||
for (const line of output) {
|
const lines = process.output
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.pipeThrough(new SplitStringStream("\n", { trim: true }));
|
||||||
|
|
||||||
|
for await (const line of lines) {
|
||||||
if (line.startsWith("Error:")) {
|
if (line.startsWith("Error:")) {
|
||||||
|
// Exit from the `for await` loop will cancel `lines`,
|
||||||
|
// and subsequently, `process`, which is fine, as the work is already done
|
||||||
throw new Error(line.substring("Error:".length).trim());
|
throw new Error(line.substring("Error:".length).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line === "Complete") {
|
if (line === "Complete") {
|
||||||
|
// Same as above
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the subprocess exits before returning
|
||||||
|
await process.exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcast(
|
||||||
|
intent: Intent,
|
||||||
|
options?: { user?: SingleUserOrAll; receiverPermission?: string },
|
||||||
|
) {
|
||||||
|
const command = [ActivityManager.ServiceName, "broadcast"];
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
if (options.user !== undefined) {
|
||||||
|
command.push("--user", options.user.toString());
|
||||||
|
}
|
||||||
|
if (options.receiverPermission) {
|
||||||
|
command.push(
|
||||||
|
"--receiver-permission",
|
||||||
|
options.receiverPermission,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const arg of serializeIntent(intent)) {
|
||||||
|
command.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 7 supports `cmd activity` but not `cmd activity broadcast` command
|
||||||
|
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
|
||||||
|
command[0] = ActivityManager.CommandName;
|
||||||
|
await this.adb.subprocess.noneProtocol
|
||||||
|
.spawn(command.map(escapeArg))
|
||||||
|
.wait();
|
||||||
|
} else {
|
||||||
|
await this.#cmd.spawn(command).wait();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export interface AdbBackupOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdbRestoreOptions {
|
export interface AdbRestoreOptions {
|
||||||
user: number;
|
user?: number | undefined;
|
||||||
file: ReadableStream<MaybeConsumable<Uint8Array>>;
|
file: ReadableStream<MaybeConsumable<Uint8Array>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,6 +67,9 @@ export class AdbBackup extends AdbServiceBase {
|
||||||
if (options.user !== undefined) {
|
if (options.user !== undefined) {
|
||||||
args.push("--user", options.user.toString());
|
args.push("--user", options.user.toString());
|
||||||
}
|
}
|
||||||
return this.adb.subprocess.noneProtocol.spawnWaitText(args);
|
return this.adb.subprocess.noneProtocol
|
||||||
|
.spawn(args)
|
||||||
|
.wait({ stdin: options.file })
|
||||||
|
.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,10 +57,10 @@ export class BugReport extends AdbServiceBase {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await adb.subprocess.shellProtocol.spawnWaitText([
|
const result = await adb.subprocess.shellProtocol
|
||||||
"bugreportz",
|
.spawn(["bugreportz", "-v"])
|
||||||
"-v",
|
.wait()
|
||||||
]);
|
.toString();
|
||||||
if (result.exitCode !== 0 || result.stderr === "") {
|
if (result.exitCode !== 0 || result.stderr === "") {
|
||||||
return new BugReport(adb, {
|
return new BugReport(adb, {
|
||||||
supportsBugReport: true,
|
supportsBugReport: true,
|
||||||
|
@ -211,10 +211,10 @@ export class BugReport extends AdbServiceBase {
|
||||||
let filename: string | undefined;
|
let filename: string | undefined;
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
|
|
||||||
for await (const line of process.stdout
|
const lines = process.stdout
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
// Each chunk should contain one or several full lines
|
.pipeThrough(new SplitStringStream("\n", { trim: true }));
|
||||||
.pipeThrough(new SplitStringStream("\n"))) {
|
for await (const line of lines) {
|
||||||
// `BEGIN:` and `PROGRESS:` only appear when `-p` is specified.
|
// `BEGIN:` and `PROGRESS:` only appear when `-p` is specified.
|
||||||
let match = line.match(BugReport.PROGRESS_REGEX);
|
let match = line.match(BugReport.PROGRESS_REGEX);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|
|
@ -1,166 +0,0 @@
|
||||||
import type { Adb, AdbShellProtocolProcess } from "@yume-chan/adb";
|
|
||||||
import {
|
|
||||||
AdbFeature,
|
|
||||||
AdbNoneProtocolProcessImpl,
|
|
||||||
AdbNoneProtocolSpawner,
|
|
||||||
AdbServiceBase,
|
|
||||||
AdbShellProtocolProcessImpl,
|
|
||||||
AdbShellProtocolSpawner,
|
|
||||||
} from "@yume-chan/adb";
|
|
||||||
|
|
||||||
export class CmdNoneProtocolService extends AdbNoneProtocolSpawner {
|
|
||||||
#supportsAbbExec: boolean;
|
|
||||||
get supportsAbbExec(): boolean {
|
|
||||||
return this.#supportsAbbExec;
|
|
||||||
}
|
|
||||||
|
|
||||||
#supportsCmd: boolean;
|
|
||||||
get supportsCmd(): boolean {
|
|
||||||
return this.#supportsCmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSupported() {
|
|
||||||
return this.#supportsAbbExec || this.#supportsCmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
adb: Adb,
|
|
||||||
fallback?:
|
|
||||||
| string
|
|
||||||
| Record<string, string>
|
|
||||||
| ((service: string) => string),
|
|
||||||
) {
|
|
||||||
super(async (command) => {
|
|
||||||
if (this.#supportsAbbExec) {
|
|
||||||
return new AdbNoneProtocolProcessImpl(
|
|
||||||
await adb.createSocket(`abb_exec:${command.join("\0")}\0`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#supportsCmd) {
|
|
||||||
return adb.subprocess.noneProtocol.spawn(
|
|
||||||
`cmd ${command.join(" ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fallback === "function") {
|
|
||||||
fallback = fallback(command[0]!);
|
|
||||||
} else if (typeof fallback === "object") {
|
|
||||||
fallback = fallback[command[0]!];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fallback) {
|
|
||||||
throw new Error("Unsupported");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackCommand = command.slice();
|
|
||||||
fallbackCommand[0] = fallback;
|
|
||||||
return adb.subprocess.noneProtocol.spawn(fallbackCommand);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#supportsCmd = adb.canUseFeature(AdbFeature.Cmd);
|
|
||||||
this.#supportsAbbExec = adb.canUseFeature(AdbFeature.AbbExec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CmdShellProtocolService extends AdbShellProtocolSpawner {
|
|
||||||
#adb: Adb;
|
|
||||||
|
|
||||||
#supportsCmd: boolean;
|
|
||||||
get supportsCmd(): boolean {
|
|
||||||
return this.#supportsCmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
#supportsAbb: boolean;
|
|
||||||
get supportsAbb(): boolean {
|
|
||||||
return this.#supportsAbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSupported() {
|
|
||||||
return (
|
|
||||||
this.#supportsAbb ||
|
|
||||||
(this.#supportsCmd && !!this.#adb.subprocess.shellProtocol)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
adb: Adb,
|
|
||||||
fallback?:
|
|
||||||
| string
|
|
||||||
| Record<string, string>
|
|
||||||
| ((service: string) => string),
|
|
||||||
) {
|
|
||||||
super(async (command): Promise<AdbShellProtocolProcess> => {
|
|
||||||
if (this.#supportsAbb) {
|
|
||||||
return new AdbShellProtocolProcessImpl(
|
|
||||||
await this.#adb.createSocket(`abb:${command.join("\0")}\0`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adb.subprocess.shellProtocol) {
|
|
||||||
throw new Error("Unsupported");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#supportsCmd) {
|
|
||||||
return adb.subprocess.shellProtocol.spawn(
|
|
||||||
`cmd ${command.join(" ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fallback === "function") {
|
|
||||||
fallback = fallback(command[0]!);
|
|
||||||
} else if (typeof fallback === "object") {
|
|
||||||
fallback = fallback[command[0]!];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fallback) {
|
|
||||||
throw new Error("Unsupported");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackCommand = command.slice();
|
|
||||||
fallbackCommand[0] = fallback;
|
|
||||||
return adb.subprocess.shellProtocol.spawn(fallbackCommand);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#adb = adb;
|
|
||||||
this.#supportsCmd = adb.canUseFeature(AdbFeature.Cmd);
|
|
||||||
this.#supportsAbb = adb.canUseFeature(AdbFeature.Abb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Cmd extends AdbServiceBase {
|
|
||||||
#noneProtocol: CmdNoneProtocolService | undefined;
|
|
||||||
get noneProtocol() {
|
|
||||||
return this.#noneProtocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
#shellProtocol: CmdShellProtocolService | undefined;
|
|
||||||
get shellProtocol() {
|
|
||||||
return this.#shellProtocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
adb: Adb,
|
|
||||||
fallback?:
|
|
||||||
| string
|
|
||||||
| Record<string, string>
|
|
||||||
| ((service: string) => string),
|
|
||||||
) {
|
|
||||||
super(adb);
|
|
||||||
|
|
||||||
if (
|
|
||||||
adb.canUseFeature(AdbFeature.AbbExec) ||
|
|
||||||
adb.canUseFeature(AdbFeature.Cmd)
|
|
||||||
) {
|
|
||||||
this.#noneProtocol = new CmdNoneProtocolService(adb, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
adb.canUseFeature(AdbFeature.Abb) ||
|
|
||||||
(adb.canUseFeature(AdbFeature.Cmd) &&
|
|
||||||
adb.canUseFeature(AdbFeature.ShellV2))
|
|
||||||
) {
|
|
||||||
this.#shellProtocol = new CmdShellProtocolService(adb, fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
1
libraries/android-bin/src/cmd/index.ts
Normal file
1
libraries/android-bin/src/cmd/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./service.js";
|
62
libraries/android-bin/src/cmd/none.ts
Normal file
62
libraries/android-bin/src/cmd/none.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { Adb } from "@yume-chan/adb";
|
||||||
|
import {
|
||||||
|
AdbFeature,
|
||||||
|
AdbNoneProtocolProcessImpl,
|
||||||
|
adbNoneProtocolSpawner,
|
||||||
|
escapeArg,
|
||||||
|
} from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import { Cmd } from "./service.js";
|
||||||
|
import { checkCommand, resolveFallback, serializeAbbService } from "./utils.js";
|
||||||
|
|
||||||
|
export function createNoneProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback: NonNullable<Cmd.Fallback>,
|
||||||
|
): Cmd.NoneProtocolService;
|
||||||
|
export function createNoneProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback?: Cmd.Fallback,
|
||||||
|
): Cmd.NoneProtocolService | undefined;
|
||||||
|
export function createNoneProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback?: Cmd.Fallback,
|
||||||
|
): Cmd.NoneProtocolService | undefined {
|
||||||
|
if (adb.canUseFeature(AdbFeature.AbbExec)) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Abb,
|
||||||
|
spawn: adbNoneProtocolSpawner(async (command, signal) => {
|
||||||
|
const service = serializeAbbService("abb_exec", command);
|
||||||
|
const socket = await adb.createSocket(service);
|
||||||
|
return new AdbNoneProtocolProcessImpl(socket, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adb.canUseFeature(AdbFeature.Cmd)) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Cmd,
|
||||||
|
spawn: adbNoneProtocolSpawner(async (command, signal) => {
|
||||||
|
checkCommand(command);
|
||||||
|
|
||||||
|
const newCommand = command.map(escapeArg);
|
||||||
|
newCommand.unshift("cmd");
|
||||||
|
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Fallback,
|
||||||
|
spawn: adbNoneProtocolSpawner(async (command, signal) => {
|
||||||
|
checkCommand(command);
|
||||||
|
|
||||||
|
const newCommand = command.map(escapeArg);
|
||||||
|
newCommand[0] = resolveFallback(fallback, command[0]!);
|
||||||
|
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
57
libraries/android-bin/src/cmd/service.ts
Normal file
57
libraries/android-bin/src/cmd/service.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import type {
|
||||||
|
Adb,
|
||||||
|
AdbNoneProtocolSpawner,
|
||||||
|
AdbShellProtocolSpawner,
|
||||||
|
} from "@yume-chan/adb";
|
||||||
|
import { AdbServiceBase } from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import { createNoneProtocol } from "./none.js";
|
||||||
|
import { createShellProtocol } from "./shell.js";
|
||||||
|
|
||||||
|
export class Cmd extends AdbServiceBase {
|
||||||
|
static readonly Mode = {
|
||||||
|
Abb: 0,
|
||||||
|
Cmd: 1,
|
||||||
|
Fallback: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
static readonly createNoneProtocol = createNoneProtocol;
|
||||||
|
static readonly createShellProtocol = createShellProtocol;
|
||||||
|
|
||||||
|
#noneProtocol: Cmd.NoneProtocolService | undefined;
|
||||||
|
get noneProtocol() {
|
||||||
|
return this.#noneProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shellProtocol: Cmd.ShellProtocolService | undefined;
|
||||||
|
get shellProtocol() {
|
||||||
|
return this.#shellProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(adb: Adb, fallback?: Cmd.Fallback) {
|
||||||
|
super(adb);
|
||||||
|
|
||||||
|
this.#noneProtocol = createNoneProtocol(adb, fallback);
|
||||||
|
this.#shellProtocol = createShellProtocol(adb, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Cmd {
|
||||||
|
export type Fallback =
|
||||||
|
| string
|
||||||
|
| Record<string, string>
|
||||||
|
| ((service: string) => string)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type Mode = (typeof Cmd.Mode)[keyof typeof Cmd.Mode];
|
||||||
|
|
||||||
|
export interface NoneProtocolService {
|
||||||
|
mode: Mode;
|
||||||
|
spawn: AdbNoneProtocolSpawner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellProtocolService {
|
||||||
|
mode: Mode;
|
||||||
|
spawn: AdbShellProtocolSpawner;
|
||||||
|
}
|
||||||
|
}
|
67
libraries/android-bin/src/cmd/shell.ts
Normal file
67
libraries/android-bin/src/cmd/shell.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Adb } from "@yume-chan/adb";
|
||||||
|
import {
|
||||||
|
AdbFeature,
|
||||||
|
AdbShellProtocolProcessImpl,
|
||||||
|
adbShellProtocolSpawner,
|
||||||
|
escapeArg,
|
||||||
|
} from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import { Cmd } from "./service.js";
|
||||||
|
import { checkCommand, resolveFallback, serializeAbbService } from "./utils.js";
|
||||||
|
|
||||||
|
export function createShellProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback: NonNullable<Cmd.Fallback>,
|
||||||
|
): Cmd.ShellProtocolService;
|
||||||
|
export function createShellProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback?: Cmd.Fallback,
|
||||||
|
): Cmd.ShellProtocolService | undefined;
|
||||||
|
export function createShellProtocol(
|
||||||
|
adb: Adb,
|
||||||
|
fallback?: Cmd.Fallback,
|
||||||
|
): Cmd.ShellProtocolService | undefined {
|
||||||
|
if (adb.canUseFeature(AdbFeature.Abb)) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Abb,
|
||||||
|
spawn: adbShellProtocolSpawner(async (command, signal) => {
|
||||||
|
const service = serializeAbbService("abb", command);
|
||||||
|
const socket = await adb.createSocket(service);
|
||||||
|
return new AdbShellProtocolProcessImpl(socket, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellProtocolService = adb.subprocess.shellProtocol;
|
||||||
|
if (!shellProtocolService) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adb.canUseFeature(AdbFeature.Cmd)) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Cmd,
|
||||||
|
spawn: adbShellProtocolSpawner(async (command, signal) => {
|
||||||
|
checkCommand(command);
|
||||||
|
|
||||||
|
const newCommand = command.map(escapeArg);
|
||||||
|
newCommand.unshift("cmd");
|
||||||
|
return shellProtocolService.spawn(newCommand, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return {
|
||||||
|
mode: Cmd.Mode.Fallback,
|
||||||
|
spawn: adbShellProtocolSpawner(async (command, signal) => {
|
||||||
|
checkCommand(command);
|
||||||
|
|
||||||
|
const newCommand = command.map(escapeArg);
|
||||||
|
newCommand[0] = resolveFallback(fallback, command[0]!);
|
||||||
|
return shellProtocolService.spawn(newCommand, signal);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
35
libraries/android-bin/src/cmd/utils.ts
Normal file
35
libraries/android-bin/src/cmd/utils.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Cmd } from "./service.js";
|
||||||
|
|
||||||
|
export function resolveFallback(
|
||||||
|
fallback: Cmd.Fallback,
|
||||||
|
command: string,
|
||||||
|
): string {
|
||||||
|
if (typeof fallback === "function") {
|
||||||
|
fallback = fallback(command);
|
||||||
|
} else if (typeof fallback === "object" && fallback !== null) {
|
||||||
|
fallback = fallback[command];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fallback) {
|
||||||
|
throw new Error(`No fallback configured for command "${command}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCommand(command: readonly string[]) {
|
||||||
|
if (!command.length) {
|
||||||
|
throw new TypeError("Command is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAbbService(
|
||||||
|
prefix: string,
|
||||||
|
command: readonly string[],
|
||||||
|
): string {
|
||||||
|
checkCommand(command);
|
||||||
|
|
||||||
|
// `abb` mode uses `\0` as the separator, allowing space in arguments.
|
||||||
|
// The last `\0` is required for older versions of `adb`.
|
||||||
|
return `${prefix}:${command.join("\0")}\0`;
|
||||||
|
}
|
|
@ -7,6 +7,7 @@
|
||||||
import type { Adb } from "@yume-chan/adb";
|
import type { Adb } from "@yume-chan/adb";
|
||||||
import { AdbServiceBase } from "@yume-chan/adb";
|
import { AdbServiceBase } from "@yume-chan/adb";
|
||||||
|
|
||||||
|
import { ActivityManager } from "./am.js";
|
||||||
import { Settings } from "./settings.js";
|
import { Settings } from "./settings.js";
|
||||||
|
|
||||||
export enum DemoModeSignalStrength {
|
export enum DemoModeSignalStrength {
|
||||||
|
@ -52,10 +53,12 @@ export const DemoModeStatusBarModes = [
|
||||||
export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number];
|
export type DemoModeStatusBarMode = (typeof DemoModeStatusBarModes)[number];
|
||||||
|
|
||||||
export class DemoMode extends AdbServiceBase {
|
export class DemoMode extends AdbServiceBase {
|
||||||
|
#am: ActivityManager;
|
||||||
#settings: Settings;
|
#settings: Settings;
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb, apiLevel?: number) {
|
||||||
super(adb);
|
super(adb);
|
||||||
|
this.#am = new ActivityManager(adb, apiLevel);
|
||||||
this.#settings = new Settings(adb);
|
this.#settings = new Settings(adb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,26 +111,14 @@ export class DemoMode extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async broadcast(
|
broadcast(command: string, extras?: Record<string, string>): Promise<void> {
|
||||||
command: string,
|
return this.#am.broadcast({
|
||||||
extra?: Record<string, string>,
|
action: "com.android.systemui.demo",
|
||||||
): Promise<void> {
|
extras: {
|
||||||
await this.adb.subprocess.noneProtocol.spawnWaitText([
|
command,
|
||||||
"am",
|
...extras,
|
||||||
"broadcast",
|
},
|
||||||
"-a",
|
});
|
||||||
"com.android.systemui.demo",
|
|
||||||
"-e",
|
|
||||||
"command",
|
|
||||||
command,
|
|
||||||
...(extra
|
|
||||||
? Object.entries(extra).flatMap(([key, value]) => [
|
|
||||||
"-e",
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
])
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setBatteryLevel(level: number): Promise<void> {
|
async setBatteryLevel(level: number): Promise<void> {
|
||||||
|
|
|
@ -58,10 +58,10 @@ export class DumpSys extends AdbServiceBase {
|
||||||
static readonly Battery = Battery;
|
static readonly Battery = Battery;
|
||||||
|
|
||||||
async diskStats() {
|
async diskStats() {
|
||||||
const result = await this.adb.subprocess.noneProtocol.spawnWaitText([
|
const result = await this.adb.subprocess.noneProtocol
|
||||||
"dumpsys",
|
.spawn(["dumpsys", "diskstats"])
|
||||||
"diskstats",
|
.wait()
|
||||||
]);
|
.toString();
|
||||||
|
|
||||||
function getSize(name: string) {
|
function getSize(name: string) {
|
||||||
const match = result.match(
|
const match = result.match(
|
||||||
|
@ -91,10 +91,10 @@ export class DumpSys extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
async battery(): Promise<DumpSys.Battery.Info> {
|
async battery(): Promise<DumpSys.Battery.Info> {
|
||||||
const result = await this.adb.subprocess.noneProtocol.spawnWaitText([
|
const result = await this.adb.subprocess.noneProtocol
|
||||||
"dumpsys",
|
.spawn(["dumpsys", "battery"])
|
||||||
"battery",
|
.wait()
|
||||||
]);
|
.toString();
|
||||||
|
|
||||||
const info: DumpSys.Battery.Info = {
|
const info: DumpSys.Battery.Info = {
|
||||||
acPowered: false,
|
acPowered: false,
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
export * from "./am.js";
|
export * from "./am.js";
|
||||||
export * from "./bu.js";
|
export * from "./bu.js";
|
||||||
export * from "./bug-report.js";
|
export * from "./bug-report.js";
|
||||||
export * from "./cmd.js";
|
export * from "./cmd/index.js";
|
||||||
export * from "./demo-mode.js";
|
export * from "./demo-mode.js";
|
||||||
export * from "./dumpsys.js";
|
export * from "./dumpsys.js";
|
||||||
export * from "./intent.js";
|
export * from "./intent.js";
|
||||||
|
|
|
@ -1,53 +1,165 @@
|
||||||
import * as assert from "node:assert";
|
import * as assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { IntentBuilder } from "./intent.js";
|
import { serializeIntent } from "./intent.js";
|
||||||
|
|
||||||
describe("Intent", () => {
|
describe("Intent", () => {
|
||||||
describe("IntentBuilder", () => {
|
describe("serializeIntent", () => {
|
||||||
it("should set intent action", () => {
|
it("should serialize intent action", () => {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(serializeIntent({ action: "test_action" }), [
|
||||||
new IntentBuilder().setAction("test_action").build(),
|
"-a",
|
||||||
["-a", "test_action"],
|
"test_action",
|
||||||
);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set intent categories", () => {
|
it("should serialize intent categories", () => {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
new IntentBuilder()
|
serializeIntent({ categories: ["category_1", "category_2"] }),
|
||||||
.addCategory("category_1")
|
|
||||||
.addCategory("category_2")
|
|
||||||
.build(),
|
|
||||||
["-c", "category_1", "-c", "category_2"],
|
["-c", "category_1", "-c", "category_2"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set intent package", () => {
|
it("should serialize intent package", () => {
|
||||||
|
assert.deepStrictEqual(serializeIntent({ package: "package_1" }), [
|
||||||
|
"-p",
|
||||||
|
"package_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize intent component", () => {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
new IntentBuilder().setPackage("package_1").build(),
|
serializeIntent({
|
||||||
["-p", "package_1"],
|
component: {
|
||||||
|
packageName: "package_1",
|
||||||
|
className: "component_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["-n", "package_1/component_1"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set intent component", () => {
|
it("should serialize intent data", () => {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(serializeIntent({ data: "data_1" }), [
|
||||||
new IntentBuilder().setComponent("component_1").build(),
|
"-d",
|
||||||
["-n", "component_1"],
|
"data_1",
|
||||||
);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set intent data", () => {
|
describe("extras", () => {
|
||||||
assert.deepStrictEqual(
|
it("should serialize string extras", () => {
|
||||||
new IntentBuilder().setData("data_1").build(),
|
assert.deepStrictEqual(
|
||||||
["-d", "data_1"],
|
serializeIntent({
|
||||||
);
|
extras: {
|
||||||
});
|
key1: "value1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--es", "key1", "value1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should pass intent extras", () => {
|
it("should serialize null extras", () => {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
new IntentBuilder().addStringExtra("key1", "value1").build(),
|
serializeIntent({
|
||||||
["--es", "key1", "value1"],
|
extras: {
|
||||||
);
|
key1: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--esn", "key1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize integer extras", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--ei", "key1", "1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize URI extras", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: {
|
||||||
|
type: "uri",
|
||||||
|
value: "http://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--eu", "key1", "http://example.com"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize component name", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: {
|
||||||
|
packageName: "com.example.package_1",
|
||||||
|
className: "com.example.component_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
"--ecn",
|
||||||
|
"key1",
|
||||||
|
"com.example.package_1/com.example.component_1",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize integer array extras", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: {
|
||||||
|
type: "array",
|
||||||
|
itemType: "int",
|
||||||
|
value: [1, 2, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--eia", "key1", "1,2,3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize integer array list extras", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: {
|
||||||
|
type: "arrayList",
|
||||||
|
itemType: "int",
|
||||||
|
value: [1, 2, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--eial", "key1", "1,2,3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize boolean extras", () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--ez", "key1", "true"],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
serializeIntent({
|
||||||
|
extras: {
|
||||||
|
key1: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
["--ez", "key1", "false"],
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,73 +1,194 @@
|
||||||
export class IntentBuilder {
|
// cspell: ignore eial
|
||||||
#action: string | undefined;
|
// cspell: ignore elal
|
||||||
#categories: string[] = [];
|
// cspell: ignore efal
|
||||||
#packageName: string | undefined;
|
// cspell: ignore esal
|
||||||
#component: string | undefined;
|
// cspell: ignore edal
|
||||||
#data: string | undefined;
|
|
||||||
#type: string | undefined;
|
|
||||||
#stringExtras = new Map<string, string>();
|
|
||||||
|
|
||||||
setAction(action: string): this {
|
export interface IntentNumberExtra {
|
||||||
this.#action = action;
|
type: "long" | "float" | "double";
|
||||||
return this;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
addCategory(category: string): this {
|
export interface IntentStringExtra {
|
||||||
this.#categories.push(category);
|
type: "uri";
|
||||||
return this;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPackage(packageName: string): this {
|
export interface IntentNumberArrayExtra {
|
||||||
this.#packageName = packageName;
|
type: "array" | "arrayList";
|
||||||
return this;
|
itemType: "int" | IntentNumberExtra["type"];
|
||||||
}
|
value: number[];
|
||||||
|
}
|
||||||
|
|
||||||
setComponent(component: string): this {
|
export interface IntentStringArrayExtra {
|
||||||
this.#component = component;
|
type: "array" | "arrayList";
|
||||||
return this;
|
itemType: "string";
|
||||||
}
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
setData(data: string): this {
|
export interface ComponentName {
|
||||||
this.#data = data;
|
packageName: string;
|
||||||
return this;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
addStringExtra(key: string, value: string): this {
|
export interface Intent {
|
||||||
this.#stringExtras.set(key, value);
|
action?: string | undefined;
|
||||||
return this;
|
data?: string | undefined;
|
||||||
}
|
type?: string | undefined;
|
||||||
|
identifier?: string | undefined;
|
||||||
|
categories?: string[] | undefined;
|
||||||
|
extras?:
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| number
|
||||||
|
| IntentStringExtra
|
||||||
|
| ComponentName
|
||||||
|
| IntentNumberArrayExtra
|
||||||
|
| IntentStringArrayExtra
|
||||||
|
| IntentNumberExtra
|
||||||
|
| boolean
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
flags?: number | undefined;
|
||||||
|
package?: string | undefined;
|
||||||
|
component?: ComponentName | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
build(): string[] {
|
function getNumberType(type: "int" | IntentNumberExtra["type"]) {
|
||||||
const result: string[] = [];
|
switch (type) {
|
||||||
|
case "int":
|
||||||
if (this.#action) {
|
return "--ei";
|
||||||
result.push("-a", this.#action);
|
case "long":
|
||||||
}
|
return "--el";
|
||||||
|
case "float":
|
||||||
for (const category of this.#categories) {
|
return "--ef";
|
||||||
result.push("-c", category);
|
case "double":
|
||||||
}
|
return "--ed";
|
||||||
|
default:
|
||||||
if (this.#packageName) {
|
throw new Error(`Unknown number type: ${type as string}`);
|
||||||
result.push("-p", this.#packageName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#component) {
|
|
||||||
result.push("-n", this.#component);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#data) {
|
|
||||||
result.push("-d", this.#data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#type) {
|
|
||||||
result.push("-t", this.#type);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of this.#stringExtras) {
|
|
||||||
result.push("--es", key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeArray(
|
||||||
|
array: IntentNumberArrayExtra | IntentStringArrayExtra,
|
||||||
|
): [type: string, value: string] {
|
||||||
|
let type: string;
|
||||||
|
let value: string;
|
||||||
|
|
||||||
|
if (array.itemType === "string") {
|
||||||
|
type = "--es";
|
||||||
|
value = array.value
|
||||||
|
.map((item) => item.replaceAll(",", "\\,"))
|
||||||
|
.join(",");
|
||||||
|
} else {
|
||||||
|
type = getNumberType(array.itemType);
|
||||||
|
value = array.value.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array.type === "array") {
|
||||||
|
type += "a";
|
||||||
|
} else {
|
||||||
|
type += "al";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [type, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeIntent(intent: Intent) {
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
if (intent.action) {
|
||||||
|
result.push("-a", intent.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.data) {
|
||||||
|
result.push("-d", intent.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.type) {
|
||||||
|
result.push("-t", intent.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.identifier) {
|
||||||
|
result.push("-i", intent.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.categories) {
|
||||||
|
for (const category of intent.categories) {
|
||||||
|
result.push("-c", category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.extras) {
|
||||||
|
for (const [key, value] of Object.entries(intent.extras)) {
|
||||||
|
switch (typeof value) {
|
||||||
|
case "string":
|
||||||
|
result.push("--es", key, value);
|
||||||
|
break;
|
||||||
|
case "object":
|
||||||
|
if (value === null) {
|
||||||
|
result.push("--esn", key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("packageName" in value) {
|
||||||
|
result.push(
|
||||||
|
"--ecn",
|
||||||
|
key,
|
||||||
|
value.packageName + "/" + value.className,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (value.type) {
|
||||||
|
case "uri":
|
||||||
|
result.push("--eu", key, value.value);
|
||||||
|
break;
|
||||||
|
case "array":
|
||||||
|
case "arrayList":
|
||||||
|
{
|
||||||
|
const [type, valueString] =
|
||||||
|
serializeArray(value);
|
||||||
|
result.push(type, key, valueString);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.push(
|
||||||
|
getNumberType(value.type),
|
||||||
|
key,
|
||||||
|
value.value.toString(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
result.push("--ei", key, value.toString());
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
result.push("--ez", key, value ? "true" : "false");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.component) {
|
||||||
|
result.push(
|
||||||
|
"-n",
|
||||||
|
intent.component.packageName + "/" + intent.component.className,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.package) {
|
||||||
|
result.push("-p", intent.package);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `0` is the default value for `flags` when deserializing
|
||||||
|
// so it can be omitted if it's either `undefined` or `0`
|
||||||
|
if (intent.flags) {
|
||||||
|
result.push("-f", intent.flags.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
@ -444,7 +444,7 @@ export class Logcat extends AdbServiceBase {
|
||||||
const result: LogSize[] = [];
|
const result: LogSize[] = [];
|
||||||
for await (const line of process.output
|
for await (const line of process.output
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
.pipeThrough(new SplitStringStream("\n"))) {
|
.pipeThrough(new SplitStringStream("\n", { trim: true }))) {
|
||||||
let match = line.match(Logcat.LOG_SIZE_REGEX_11);
|
let match = line.match(Logcat.LOG_SIZE_REGEX_11);
|
||||||
if (match) {
|
if (match) {
|
||||||
result.push({
|
result.push({
|
||||||
|
@ -494,7 +494,7 @@ export class Logcat extends AdbServiceBase {
|
||||||
args.push("-b", Logcat.joinLogId(ids));
|
args.push("-b", Logcat.joinLogId(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.adb.subprocess.noneProtocol.spawnWaitText(args);
|
await this.adb.subprocess.noneProtocol.spawn(args).wait();
|
||||||
}
|
}
|
||||||
|
|
||||||
binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> {
|
binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> {
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
// cspell:ignore instantapp
|
// cspell:ignore instantapp
|
||||||
// cspell:ignore apks
|
// cspell:ignore apks
|
||||||
// cspell:ignore versioncode
|
// cspell:ignore versioncode
|
||||||
|
// cspell:ignore dexopt
|
||||||
|
|
||||||
import type { Adb } from "@yume-chan/adb";
|
import type { Adb, AdbNoneProtocolProcess } from "@yume-chan/adb";
|
||||||
import { AdbServiceBase } from "@yume-chan/adb";
|
import { AdbServiceBase, escapeArg } from "@yume-chan/adb";
|
||||||
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
|
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
|
||||||
import {
|
import {
|
||||||
ConcatStringStream,
|
ConcatStringStream,
|
||||||
|
@ -12,10 +13,11 @@ import {
|
||||||
TextDecoderStream,
|
TextDecoderStream,
|
||||||
} from "@yume-chan/stream-extra";
|
} from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
import { CmdNoneProtocolService } from "./cmd.js";
|
import { Cmd } from "./cmd/index.js";
|
||||||
import type { IntentBuilder } from "./intent.js";
|
import type { Intent } from "./intent.js";
|
||||||
import type { SingleUserOrAll } from "./utils.js";
|
import { serializeIntent } from "./intent.js";
|
||||||
import { buildArguments } from "./utils.js";
|
import type { Optional, SingleUserOrAll } from "./utils.js";
|
||||||
|
import { buildCommand } from "./utils.js";
|
||||||
|
|
||||||
export enum PackageManagerInstallLocation {
|
export enum PackageManagerInstallLocation {
|
||||||
Auto,
|
Auto,
|
||||||
|
@ -31,154 +33,85 @@ export enum PackageManagerInstallReason {
|
||||||
UserRequest,
|
UserRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
|
interface OptionDefinition<T> {
|
||||||
export interface PackageManagerInstallOptions {
|
type: T;
|
||||||
/**
|
name: string;
|
||||||
* `-R`
|
minApiLevel?: number;
|
||||||
*/
|
maxApiLevel?: number;
|
||||||
skipExisting: boolean;
|
|
||||||
/**
|
|
||||||
* `-i`
|
|
||||||
*/
|
|
||||||
installerPackageName: string;
|
|
||||||
/**
|
|
||||||
* `-t`
|
|
||||||
*/
|
|
||||||
allowTest: boolean;
|
|
||||||
/**
|
|
||||||
* `-f`
|
|
||||||
*/
|
|
||||||
internalStorage: boolean;
|
|
||||||
/**
|
|
||||||
* `-d`
|
|
||||||
*/
|
|
||||||
requestDowngrade: boolean;
|
|
||||||
/**
|
|
||||||
* `-g`
|
|
||||||
*/
|
|
||||||
grantRuntimePermissions: boolean;
|
|
||||||
/**
|
|
||||||
* `--restrict-permissions`
|
|
||||||
*/
|
|
||||||
restrictPermissions: boolean;
|
|
||||||
/**
|
|
||||||
* `--dont-kill`
|
|
||||||
*/
|
|
||||||
doNotKill: boolean;
|
|
||||||
/**
|
|
||||||
* `--originating-uri`
|
|
||||||
*/
|
|
||||||
originatingUri: string;
|
|
||||||
/**
|
|
||||||
* `--referrer`
|
|
||||||
*/
|
|
||||||
refererUri: string;
|
|
||||||
/**
|
|
||||||
* `-p`
|
|
||||||
*/
|
|
||||||
inheritFrom: string;
|
|
||||||
/**
|
|
||||||
* `--pkg`
|
|
||||||
*/
|
|
||||||
packageName: string;
|
|
||||||
/**
|
|
||||||
* `--abi`
|
|
||||||
*/
|
|
||||||
abi: string;
|
|
||||||
/**
|
|
||||||
* `--ephemeral`/`--instant`/`--instantapp`
|
|
||||||
*/
|
|
||||||
instantApp: boolean;
|
|
||||||
/**
|
|
||||||
* `--full`
|
|
||||||
*/
|
|
||||||
full: boolean;
|
|
||||||
/**
|
|
||||||
* `--preload`
|
|
||||||
*/
|
|
||||||
preload: boolean;
|
|
||||||
/**
|
|
||||||
* `--user`
|
|
||||||
*/
|
|
||||||
user: SingleUserOrAll;
|
|
||||||
/**
|
|
||||||
* `--install-location`
|
|
||||||
*/
|
|
||||||
installLocation: PackageManagerInstallLocation;
|
|
||||||
/**
|
|
||||||
* `--install-reason`
|
|
||||||
*/
|
|
||||||
installReason: PackageManagerInstallReason;
|
|
||||||
/**
|
|
||||||
* `--force-uuid`
|
|
||||||
*/
|
|
||||||
forceUuid: string;
|
|
||||||
/**
|
|
||||||
* `--apex`
|
|
||||||
*/
|
|
||||||
apex: boolean;
|
|
||||||
/**
|
|
||||||
* `--force-non-staged`
|
|
||||||
*/
|
|
||||||
forceNonStaged: boolean;
|
|
||||||
/**
|
|
||||||
* `--staged`
|
|
||||||
*/
|
|
||||||
staged: boolean;
|
|
||||||
/**
|
|
||||||
* `--force-queryable`
|
|
||||||
*/
|
|
||||||
forceQueryable: boolean;
|
|
||||||
/**
|
|
||||||
* `--enable-rollback`
|
|
||||||
*/
|
|
||||||
enableRollback: boolean;
|
|
||||||
/**
|
|
||||||
* `--staged-ready-timeout`
|
|
||||||
*/
|
|
||||||
stagedReadyTimeout: number;
|
|
||||||
/**
|
|
||||||
* `--skip-verification`
|
|
||||||
*/
|
|
||||||
skipVerification: boolean;
|
|
||||||
/**
|
|
||||||
* `--bypass-low-target-sdk-block`
|
|
||||||
*/
|
|
||||||
bypassLowTargetSdkBlock: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PACKAGE_MANAGER_INSTALL_OPTIONS_MAP: Record<
|
function option<T>(
|
||||||
keyof PackageManagerInstallOptions,
|
name: string,
|
||||||
string
|
minApiLevel?: number,
|
||||||
> = {
|
maxApiLevel?: number,
|
||||||
skipExisting: "-R",
|
): OptionDefinition<T> {
|
||||||
installerPackageName: "-i",
|
return {
|
||||||
allowTest: "-t",
|
name,
|
||||||
internalStorage: "-f",
|
minApiLevel,
|
||||||
requestDowngrade: "-d",
|
maxApiLevel,
|
||||||
grantRuntimePermissions: "-g",
|
} as OptionDefinition<T>;
|
||||||
restrictPermissions: "--restrict-permissions",
|
}
|
||||||
doNotKill: "--dont-kill",
|
|
||||||
originatingUri: "--originating-uri",
|
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
|
||||||
refererUri: "--referrer",
|
export const PackageManagerInstallOptions = {
|
||||||
inheritFrom: "-p",
|
forwardLock: option<boolean>("-l", undefined, 28),
|
||||||
packageName: "--pkg",
|
replaceExisting: option<boolean>("-r", undefined, 27),
|
||||||
abi: "--abi",
|
skipExisting: option<boolean>("-R", 28),
|
||||||
instantApp: "--instant",
|
installerPackageName: option<string>("-i"),
|
||||||
full: "--full",
|
allowTest: option<boolean>("-t"),
|
||||||
preload: "--preload",
|
externalStorage: option<boolean>("-s", undefined, 28),
|
||||||
user: "--user",
|
internalStorage: option<boolean>("-f"),
|
||||||
installLocation: "--install-location",
|
requestDowngrade: option<boolean>("-d"),
|
||||||
installReason: "--install-reason",
|
grantRuntimePermissions: option<boolean>("-g", 23),
|
||||||
forceUuid: "--force-uuid",
|
restrictPermissions: option<boolean>("--restrict-permissions", 29),
|
||||||
apex: "--apex",
|
doNotKill: option<boolean>("--dont-kill"),
|
||||||
forceNonStaged: "--force-non-staged",
|
originatingUri: option<string>("--originating-uri"),
|
||||||
staged: "--staged",
|
referrerUri: option<string>("--referrer"),
|
||||||
forceQueryable: "--force-queryable",
|
inheritFrom: option<string>("-p", 24),
|
||||||
enableRollback: "--enable-rollback",
|
packageName: option<string>("--pkg", 28),
|
||||||
stagedReadyTimeout: "--staged-ready-timeout",
|
abi: option<string>("--abi", 21),
|
||||||
skipVerification: "--skip-verification",
|
instantApp: option<boolean>("--ephemeral", 24),
|
||||||
bypassLowTargetSdkBlock: "--bypass-low-target-sdk-block",
|
full: option<boolean>("--full", 26),
|
||||||
|
preload: option<boolean>("--preload", 28),
|
||||||
|
user: option<SingleUserOrAll>("--user", 21),
|
||||||
|
installLocation: option<PackageManagerInstallLocation>(
|
||||||
|
"--install-location",
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
installReason: option<PackageManagerInstallReason>("--install-reason", 29),
|
||||||
|
updateOwnership: option<boolean>("--update-ownership", 34),
|
||||||
|
forceUuid: option<string>("--force-uuid", 24),
|
||||||
|
forceSdk: option<number>("--force-sdk", 24),
|
||||||
|
apex: option<boolean>("--apex", 29),
|
||||||
|
forceNonStaged: option<boolean>("--force-non-staged", 31),
|
||||||
|
multiPackage: option<boolean>("--multi-package", 29),
|
||||||
|
staged: option<boolean>("--staged", 29),
|
||||||
|
nonStaged: option<boolean>("--non-staged", 35),
|
||||||
|
forceQueryable: option<boolean>("--force-queryable", 30),
|
||||||
|
enableRollback: option<boolean | number>("--enable-rollback", 29),
|
||||||
|
rollbackImpactLevel: option<number>("--rollback-impact-level", 35),
|
||||||
|
wait: option<boolean | number>("--wait", 30, 30),
|
||||||
|
noWait: option<boolean>("--no-wait", 30, 30),
|
||||||
|
stagedReadyTimeout: option<number>("--staged-ready-timeout", 31),
|
||||||
|
skipVerification: option<boolean>("--skip-verification", 30),
|
||||||
|
skipEnable: option<boolean>("--skip-enable", 34),
|
||||||
|
bypassLowTargetSdkBlock: option<boolean>(
|
||||||
|
"--bypass-low-target-sdk-block",
|
||||||
|
34,
|
||||||
|
),
|
||||||
|
ignoreDexoptProfile: option<boolean>("--ignore-dexopt-profile", 35),
|
||||||
|
packageSource: option<number>("--package-source", 35),
|
||||||
|
dexoptCompilerFilter: option<string>("--dexopt-compiler-filter", 35),
|
||||||
|
disableAutoInstallDependencies: option<boolean>(
|
||||||
|
"--disable-auto-install-dependencies",
|
||||||
|
36,
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PackageManagerInstallOptions = {
|
||||||
|
[K in keyof typeof PackageManagerInstallOptions]?:
|
||||||
|
| (typeof PackageManagerInstallOptions)[K]["type"]
|
||||||
|
| undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PackageManagerListPackagesOptions {
|
export interface PackageManagerListPackagesOptions {
|
||||||
|
@ -246,7 +179,7 @@ const PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP: Record<
|
||||||
|
|
||||||
export interface PackageManagerResolveActivityOptions {
|
export interface PackageManagerResolveActivityOptions {
|
||||||
user?: SingleUserOrAll;
|
user?: SingleUserOrAll;
|
||||||
intent: IntentBuilder;
|
intent: Intent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
|
const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
|
||||||
|
@ -255,15 +188,13 @@ const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
|
||||||
user: "--user",
|
user: "--user",
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildInstallArguments(
|
function buildInstallCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: Partial<PackageManagerInstallOptions> | undefined,
|
options: PackageManagerInstallOptions | undefined,
|
||||||
|
apiLevel: number | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
const args = buildArguments(
|
const args = [PackageManager.ServiceName, command];
|
||||||
[PackageManager.ServiceName, command],
|
|
||||||
options,
|
|
||||||
PACKAGE_MANAGER_INSTALL_OPTIONS_MAP,
|
|
||||||
);
|
|
||||||
if (!options?.skipExisting) {
|
if (!options?.skipExisting) {
|
||||||
/*
|
/*
|
||||||
* | behavior | previous version | modern version |
|
* | behavior | previous version | modern version |
|
||||||
|
@ -278,18 +209,74 @@ function buildInstallArguments(
|
||||||
*/
|
*/
|
||||||
args.push("-r");
|
args.push("-r");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(options)) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option =
|
||||||
|
PackageManagerInstallOptions[
|
||||||
|
key as keyof PackageManagerInstallOptions
|
||||||
|
];
|
||||||
|
|
||||||
|
if (option === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiLevel !== undefined) {
|
||||||
|
if (
|
||||||
|
option.minApiLevel !== undefined &&
|
||||||
|
apiLevel < option.minApiLevel
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
option.maxApiLevel !== undefined &&
|
||||||
|
apiLevel > option.maxApiLevel
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
case "boolean":
|
||||||
|
if (value) {
|
||||||
|
args.push(option.name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
args.push(option.name, value.toString());
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
args.push(option.name, value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported type for option ${key}: ${typeof value}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PackageManager extends AdbServiceBase {
|
export class PackageManager extends AdbServiceBase {
|
||||||
static ServiceName = "package";
|
static readonly ServiceName = "package";
|
||||||
static CommandName = "pm";
|
static readonly CommandName = "pm";
|
||||||
|
|
||||||
#cmd: CmdNoneProtocolService;
|
#apiLevel: number | undefined;
|
||||||
|
#cmd: Cmd.NoneProtocolService;
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb, apiLevel?: number) {
|
||||||
super(adb);
|
super(adb);
|
||||||
this.#cmd = new CmdNoneProtocolService(adb, PackageManager.CommandName);
|
|
||||||
|
this.#apiLevel = apiLevel;
|
||||||
|
this.#cmd = Cmd.createNoneProtocol(adb, PackageManager.CommandName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -299,37 +286,41 @@ export class PackageManager extends AdbServiceBase {
|
||||||
*/
|
*/
|
||||||
async install(
|
async install(
|
||||||
apks: readonly string[],
|
apks: readonly string[],
|
||||||
options?: Partial<PackageManagerInstallOptions>,
|
options?: PackageManagerInstallOptions,
|
||||||
): Promise<string> {
|
): Promise<void> {
|
||||||
const args = buildInstallArguments("install", options);
|
const command = buildInstallCommand("install", options, this.#apiLevel);
|
||||||
args[0] = PackageManager.CommandName;
|
|
||||||
|
command[0] = PackageManager.CommandName;
|
||||||
|
|
||||||
// WIP: old version of pm doesn't support multiple apks
|
// WIP: old version of pm doesn't support multiple apks
|
||||||
args.push(...apks);
|
for (const apk of apks) {
|
||||||
|
command.push(apk);
|
||||||
|
}
|
||||||
|
|
||||||
// Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
|
// Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
|
||||||
// The benefit of `cmd package` is it starts faster than the old `pm`,
|
// The benefit of `cmd package` is it starts faster than the old `pm`,
|
||||||
// because it connects to the already running `system` process,
|
// because it connects to the already running `system` process,
|
||||||
// instead of initializing all system components from scratch.
|
// instead of initializing all system components from scratch.
|
||||||
//
|
//
|
||||||
// But launching `cmd package` directly causes it to not be able to
|
// But `cmd` executable can't read files in `/data/local/tmp`
|
||||||
// read files in `/data/local/tmp` (and many other places) due to SELinux policies,
|
// (and many other places) due to SELinux policies,
|
||||||
// so installing files must still use `pm`.
|
// so installing from files must still use `pm`.
|
||||||
// (the starting executable file decides which SELinux policies to apply)
|
// (the starting executable file decides which SELinux policies to apply)
|
||||||
const output = await this.adb.subprocess.noneProtocol
|
const output = await this.adb.subprocess.noneProtocol
|
||||||
.spawnWaitText(args)
|
.spawn(command.map(escapeArg))
|
||||||
|
.wait()
|
||||||
|
.toString()
|
||||||
.then((output) => output.trim());
|
.then((output) => output.trim());
|
||||||
|
|
||||||
if (output !== "Success") {
|
if (output !== "Success") {
|
||||||
throw new Error(output);
|
throw new Error(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async pushAndInstallStream(
|
async pushAndInstallStream(
|
||||||
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
||||||
options?: Partial<PackageManagerInstallOptions>,
|
options?: PackageManagerInstallOptions,
|
||||||
): Promise<string> {
|
): Promise<void> {
|
||||||
const fileName = Math.random().toString().substring(2);
|
const fileName = Math.random().toString().substring(2);
|
||||||
const filePath = `/data/local/tmp/${fileName}.apk`;
|
const filePath = `/data/local/tmp/${fileName}.apk`;
|
||||||
|
|
||||||
|
@ -345,7 +336,7 @@ export class PackageManager extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.install([filePath], options);
|
await this.install([filePath], options);
|
||||||
} finally {
|
} finally {
|
||||||
await this.adb.rm(filePath);
|
await this.adb.rm(filePath);
|
||||||
}
|
}
|
||||||
|
@ -354,21 +345,21 @@ export class PackageManager extends AdbServiceBase {
|
||||||
async installStream(
|
async installStream(
|
||||||
size: number,
|
size: number,
|
||||||
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
||||||
options?: Partial<PackageManagerInstallOptions>,
|
options?: PackageManagerInstallOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Android 7 added both `cmd` command and streaming install support,
|
// Technically `cmd` support and streaming install support are unrelated,
|
||||||
// It's hard to detect whether `pm` supports streaming install (unless actually trying),
|
// but it's impossible to detect streaming install support without actually trying it.
|
||||||
// so check for whether `cmd` is supported,
|
// As they are both added in Android 7,
|
||||||
// and assume `pm` streaming install support status is same as that.
|
// assume `cmd` support also means streaming install support (and vice versa).
|
||||||
if (!this.#cmd.isSupported) {
|
if (this.#cmd.mode === Cmd.Mode.Fallback) {
|
||||||
// Fall back to push file then install
|
// Fall back to push file then install
|
||||||
await this.pushAndInstallStream(stream, options);
|
await this.pushAndInstallStream(stream, options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = buildInstallArguments("install", options);
|
const command = buildInstallCommand("install", options, this.#apiLevel);
|
||||||
args.push("-S", size.toString());
|
command.push("-S", size.toString());
|
||||||
const process = await this.#cmd.spawn(args);
|
const process = await this.#cmd.spawn(command);
|
||||||
|
|
||||||
const output = process.output
|
const output = process.output
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
@ -385,10 +376,12 @@ export class PackageManager extends AdbServiceBase {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static readonly PackageListItemPrefix = "package:";
|
||||||
|
|
||||||
static parsePackageListItem(
|
static parsePackageListItem(
|
||||||
line: string,
|
line: string,
|
||||||
): PackageManagerListPackagesResult {
|
): PackageManagerListPackagesResult {
|
||||||
line = line.substring("package:".length);
|
line = line.substring(PackageManager.PackageListItemPrefix.length);
|
||||||
|
|
||||||
let packageName: string;
|
let packageName: string;
|
||||||
let sourceDir: string | undefined;
|
let sourceDir: string | undefined;
|
||||||
|
@ -439,41 +432,84 @@ export class PackageManager extends AdbServiceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
async *listPackages(
|
async *listPackages(
|
||||||
options?: Partial<PackageManagerListPackagesOptions>,
|
options?: Optional<PackageManagerListPackagesOptions>,
|
||||||
): AsyncGenerator<PackageManagerListPackagesResult, void, void> {
|
): AsyncGenerator<PackageManagerListPackagesResult, void, void> {
|
||||||
const args = buildArguments(
|
const command = buildCommand(
|
||||||
["package", "list", "packages"],
|
["package", "list", "packages"],
|
||||||
options,
|
options,
|
||||||
PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP,
|
PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options?.filter) {
|
if (options?.filter) {
|
||||||
args.push(options.filter);
|
command.push(options.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
const process = await this.#cmd.spawn(args);
|
const process = await this.#cmd.spawn(command);
|
||||||
const reader = process.output
|
|
||||||
|
const output = process.output
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
.pipeThrough(new SplitStringStream("\n"))
|
.pipeThrough(new SplitStringStream("\n", { trim: true }));
|
||||||
.getReader();
|
|
||||||
while (true) {
|
for await (const line of output) {
|
||||||
const { done, value } = await reader.read();
|
if (!line.startsWith(PackageManager.PackageListItemPrefix)) {
|
||||||
if (done) {
|
continue;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
yield PackageManager.parsePackageListItem(value);
|
|
||||||
|
yield PackageManager.parsePackageListItem(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPackageSources(packageName: string): Promise<string[]> {
|
/**
|
||||||
const args = [PackageManager.ServiceName, "-p", packageName];
|
* Gets APK file paths for a package.
|
||||||
const process = await this.#cmd.spawn(args);
|
*
|
||||||
const result: string[] = [];
|
* On supported Android versions, all split APKs are included.
|
||||||
for await (const line of process.output
|
* @param packageName The package name to query
|
||||||
|
* @param options The user ID to query
|
||||||
|
* @returns An array of APK file paths
|
||||||
|
*/
|
||||||
|
async getPackageSources(
|
||||||
|
packageName: string,
|
||||||
|
options?: {
|
||||||
|
/**
|
||||||
|
* The user ID to query
|
||||||
|
*/
|
||||||
|
user?: number | undefined;
|
||||||
|
},
|
||||||
|
): Promise<string[]> {
|
||||||
|
// `pm path` and `pm -p` are the same,
|
||||||
|
// but `pm path` allows an optional `--user` option.
|
||||||
|
const command = [PackageManager.ServiceName, "path"];
|
||||||
|
|
||||||
|
if (options?.user !== undefined) {
|
||||||
|
command.push("--user", options.user.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
command.push(packageName);
|
||||||
|
|
||||||
|
// Android 7 and 8 support `cmd package` but not `cmd package path` command
|
||||||
|
let process: AdbNoneProtocolProcess;
|
||||||
|
if (this.#apiLevel !== undefined && this.#apiLevel <= 27) {
|
||||||
|
command[0] = PackageManager.CommandName;
|
||||||
|
process = await this.adb.subprocess.noneProtocol.spawn(
|
||||||
|
command.map(escapeArg),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process = await this.#cmd.spawn(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = process.output
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
.pipeThrough(new SplitStringStream("\n"))) {
|
.pipeThrough(new SplitStringStream("\n", { trim: true }));
|
||||||
if (line.startsWith("package:")) {
|
|
||||||
result.push(line.substring("package:".length));
|
const result: string[] = [];
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (!line.startsWith(PackageManager.PackageListItemPrefix)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
line.substring(PackageManager.PackageListItemPrefix.length),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -481,20 +517,26 @@ export class PackageManager extends AdbServiceBase {
|
||||||
|
|
||||||
async uninstall(
|
async uninstall(
|
||||||
packageName: string,
|
packageName: string,
|
||||||
options?: Partial<PackageManagerUninstallOptions>,
|
options?: Optional<PackageManagerUninstallOptions>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args = buildArguments(
|
const command = buildCommand(
|
||||||
[PackageManager.ServiceName, "uninstall"],
|
[PackageManager.ServiceName, "uninstall"],
|
||||||
options,
|
options,
|
||||||
PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP,
|
PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP,
|
||||||
);
|
);
|
||||||
args.push(packageName);
|
|
||||||
|
command.push(packageName);
|
||||||
|
|
||||||
if (options?.splitNames) {
|
if (options?.splitNames) {
|
||||||
args.push(...options.splitNames);
|
for (const splitName of options.splitNames) {
|
||||||
|
command.push(splitName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = await this.#cmd
|
const output = await this.#cmd
|
||||||
.spawnWaitText(args)
|
.spawn(command)
|
||||||
|
.wait()
|
||||||
|
.toString()
|
||||||
.then((output) => output.trim());
|
.then((output) => output.trim());
|
||||||
if (output !== "Success") {
|
if (output !== "Success") {
|
||||||
throw new Error(output);
|
throw new Error(output);
|
||||||
|
@ -504,16 +546,20 @@ export class PackageManager extends AdbServiceBase {
|
||||||
async resolveActivity(
|
async resolveActivity(
|
||||||
options: PackageManagerResolveActivityOptions,
|
options: PackageManagerResolveActivityOptions,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
let args = buildArguments(
|
const command = buildCommand(
|
||||||
[PackageManager.ServiceName, "resolve-activity", "--components"],
|
[PackageManager.ServiceName, "resolve-activity", "--components"],
|
||||||
options,
|
options,
|
||||||
PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP,
|
PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP,
|
||||||
);
|
);
|
||||||
|
|
||||||
args = args.concat(options.intent.build());
|
for (const arg of serializeIntent(options.intent)) {
|
||||||
|
command.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
const output = await this.#cmd
|
const output = await this.#cmd
|
||||||
.spawnWaitText(args)
|
.spawn(command)
|
||||||
|
.wait()
|
||||||
|
.toString()
|
||||||
.then((output) => output.trim());
|
.then((output) => output.trim());
|
||||||
|
|
||||||
if (output === "No activity found") {
|
if (output === "No activity found") {
|
||||||
|
@ -534,20 +580,28 @@ export class PackageManager extends AdbServiceBase {
|
||||||
* @returns ID of the new install session
|
* @returns ID of the new install session
|
||||||
*/
|
*/
|
||||||
async sessionCreate(
|
async sessionCreate(
|
||||||
options?: Partial<PackageManagerInstallOptions>,
|
options?: PackageManagerInstallOptions,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const args = buildInstallArguments("install-create", options);
|
const command = buildInstallCommand(
|
||||||
|
"install-create",
|
||||||
|
options,
|
||||||
|
this.#apiLevel,
|
||||||
|
);
|
||||||
|
|
||||||
const output = await this.#cmd
|
const output = await this.#cmd
|
||||||
.spawnWaitText(args)
|
.spawn(command)
|
||||||
|
.wait()
|
||||||
|
.toString()
|
||||||
.then((output) => output.trim());
|
.then((output) => output.trim());
|
||||||
|
|
||||||
const sessionIdString = output.match(/.*\[(\d+)\].*/);
|
// The output format won't change to make it easier to parse
|
||||||
if (!sessionIdString) {
|
// https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=1744;drc=e38fa24e5738513d721ec2d9fd2dd00f32e327c1
|
||||||
throw new Error("Failed to create install session");
|
const match = output.match(/\[(\d+)\]/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Number.parseInt(sessionIdString[1]!, 10);
|
return Number.parseInt(match[1]!, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkResult(stream: ReadableStream<Uint8Array>) {
|
async checkResult(stream: ReadableStream<Uint8Array>) {
|
||||||
|
@ -566,15 +620,18 @@ export class PackageManager extends AdbServiceBase {
|
||||||
splitName: string,
|
splitName: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args: string[] = [
|
const command: string[] = [
|
||||||
"pm",
|
PackageManager.CommandName,
|
||||||
"install-write",
|
"install-write",
|
||||||
sessionId.toString(),
|
sessionId.toString(),
|
||||||
splitName,
|
splitName,
|
||||||
path,
|
path,
|
||||||
];
|
];
|
||||||
|
|
||||||
const process = await this.adb.subprocess.noneProtocol.spawn(args);
|
// Similar to `install`, must use `adb.subprocess` so it can read `path`
|
||||||
|
const process = await this.adb.subprocess.noneProtocol.spawn(
|
||||||
|
command.map(escapeArg),
|
||||||
|
);
|
||||||
await this.checkResult(process.output);
|
await this.checkResult(process.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -584,7 +641,7 @@ export class PackageManager extends AdbServiceBase {
|
||||||
size: number,
|
size: number,
|
||||||
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args: string[] = [
|
const command: string[] = [
|
||||||
PackageManager.ServiceName,
|
PackageManager.ServiceName,
|
||||||
"install-write",
|
"install-write",
|
||||||
"-S",
|
"-S",
|
||||||
|
@ -594,30 +651,48 @@ export class PackageManager extends AdbServiceBase {
|
||||||
"-",
|
"-",
|
||||||
];
|
];
|
||||||
|
|
||||||
const process = await this.#cmd.spawn(args);
|
const process = await this.#cmd.spawn(command);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
stream.pipeTo(process.stdin),
|
stream.pipeTo(process.stdin),
|
||||||
this.checkResult(process.output),
|
this.checkResult(process.output),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit an install session.
|
||||||
|
* @param sessionId ID of install session returned by `createSession`
|
||||||
|
* @returns A `Promise` that resolves when the session is committed
|
||||||
|
*/
|
||||||
async sessionCommit(sessionId: number): Promise<void> {
|
async sessionCommit(sessionId: number): Promise<void> {
|
||||||
const args: string[] = [
|
const command: string[] = [
|
||||||
PackageManager.ServiceName,
|
PackageManager.ServiceName,
|
||||||
"install-commit",
|
"install-commit",
|
||||||
sessionId.toString(),
|
sessionId.toString(),
|
||||||
];
|
];
|
||||||
const process = await this.#cmd.spawn(args);
|
|
||||||
|
// Android 7 did support `cmd package install-commit` command,
|
||||||
|
// but it wrote the "Success" message to an incorrect output stream,
|
||||||
|
// causing `checkResult` to fail with an empty message
|
||||||
|
let process: AdbNoneProtocolProcess;
|
||||||
|
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
|
||||||
|
command[0] = PackageManager.CommandName;
|
||||||
|
process = await this.adb.subprocess.noneProtocol.spawn(
|
||||||
|
command.map(escapeArg),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process = await this.#cmd.spawn(command);
|
||||||
|
}
|
||||||
|
|
||||||
await this.checkResult(process.output);
|
await this.checkResult(process.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sessionAbandon(sessionId: number): Promise<void> {
|
async sessionAbandon(sessionId: number): Promise<void> {
|
||||||
const args: string[] = [
|
const command: string[] = [
|
||||||
PackageManager.ServiceName,
|
PackageManager.ServiceName,
|
||||||
"install-abandon",
|
"install-abandon",
|
||||||
sessionId.toString(),
|
sessionId.toString(),
|
||||||
];
|
];
|
||||||
const process = await this.#cmd.spawn(args);
|
const process = await this.#cmd.spawn(command);
|
||||||
await this.checkResult(process.output);
|
await this.checkResult(process.output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -625,7 +700,7 @@ export class PackageManager extends AdbServiceBase {
|
||||||
export class PackageManagerInstallSession {
|
export class PackageManagerInstallSession {
|
||||||
static async create(
|
static async create(
|
||||||
packageManager: PackageManager,
|
packageManager: PackageManager,
|
||||||
options?: Partial<PackageManagerInstallOptions>,
|
options?: PackageManagerInstallOptions,
|
||||||
): Promise<PackageManagerInstallSession> {
|
): Promise<PackageManagerInstallSession> {
|
||||||
const id = await packageManager.sessionCreate(options);
|
const id = await packageManager.sessionCreate(options);
|
||||||
return new PackageManagerInstallSession(packageManager, id);
|
return new PackageManagerInstallSession(packageManager, id);
|
||||||
|
@ -660,6 +735,10 @@ export class PackageManagerInstallSession {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit this install session.
|
||||||
|
* @returns A `Promise` that resolves when the session is committed
|
||||||
|
*/
|
||||||
commit(): Promise<void> {
|
commit(): Promise<void> {
|
||||||
return this.#packageManager.sessionCommit(this.#id);
|
return this.#packageManager.sessionCommit(this.#id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Adb } from "@yume-chan/adb";
|
import type { Adb } from "@yume-chan/adb";
|
||||||
import { AdbServiceBase } from "@yume-chan/adb";
|
import { AdbServiceBase } from "@yume-chan/adb";
|
||||||
|
|
||||||
import { CmdNoneProtocolService } from "./cmd.js";
|
import { Cmd } from "./cmd/index.js";
|
||||||
import type { SingleUser } from "./utils.js";
|
import type { SingleUser } from "./utils.js";
|
||||||
|
|
||||||
export type SettingsNamespace = "system" | "secure" | "global";
|
export type SettingsNamespace = "system" | "secure" | "global";
|
||||||
|
@ -29,11 +29,11 @@ export class Settings extends AdbServiceBase {
|
||||||
static ServiceName = "settings";
|
static ServiceName = "settings";
|
||||||
static CommandName = "settings";
|
static CommandName = "settings";
|
||||||
|
|
||||||
#cmd: CmdNoneProtocolService;
|
#cmd: Cmd.NoneProtocolService;
|
||||||
|
|
||||||
constructor(adb: Adb) {
|
constructor(adb: Adb) {
|
||||||
super(adb);
|
super(adb);
|
||||||
this.#cmd = new CmdNoneProtocolService(adb, Settings.CommandName);
|
this.#cmd = Cmd.createNoneProtocol(adb, Settings.CommandName);
|
||||||
}
|
}
|
||||||
|
|
||||||
base(
|
base(
|
||||||
|
@ -42,16 +42,19 @@ export class Settings extends AdbServiceBase {
|
||||||
options: SettingsOptions | undefined,
|
options: SettingsOptions | undefined,
|
||||||
...args: string[]
|
...args: string[]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let command = [Settings.ServiceName];
|
const command = [Settings.ServiceName];
|
||||||
|
|
||||||
if (options?.user !== undefined) {
|
if (options?.user !== undefined) {
|
||||||
command.push("--user", options.user.toString());
|
command.push("--user", options.user.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
command.push(verb, namespace);
|
command.push(verb, namespace);
|
||||||
command = command.concat(args);
|
|
||||||
|
|
||||||
return this.#cmd.spawnWaitText(command);
|
for (const arg of args) {
|
||||||
|
command.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#cmd.spawn(command).wait().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
|
@ -61,7 +64,7 @@ export class Settings extends AdbServiceBase {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const output = await this.base("get", namespace, options, key);
|
const output = await this.base("get", namespace, options, key);
|
||||||
// Remove last \n
|
// Remove last \n
|
||||||
return output.substring(0, output.length - 1);
|
return output.endsWith("\n") ? output.slice(0, -1) : output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(
|
async delete(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export function buildArguments<T>(
|
export function buildCommand<T>(
|
||||||
commands: readonly string[],
|
commands: readonly string[],
|
||||||
options: Partial<T> | undefined,
|
options: Partial<T> | undefined,
|
||||||
map: Partial<Record<keyof T, string>>,
|
map: Partial<Record<keyof T, string>>,
|
||||||
|
@ -6,19 +6,34 @@ export function buildArguments<T>(
|
||||||
const args = commands.slice();
|
const args = commands.slice();
|
||||||
if (options) {
|
if (options) {
|
||||||
for (const [key, value] of Object.entries(options)) {
|
for (const [key, value] of Object.entries(options)) {
|
||||||
if (value) {
|
if (value === undefined || value === null) {
|
||||||
const option = map[key as keyof T];
|
continue;
|
||||||
if (option) {
|
}
|
||||||
args.push(option);
|
|
||||||
switch (typeof value) {
|
const option = map[key as keyof T];
|
||||||
case "number":
|
// Empty string means positional argument,
|
||||||
args.push(value.toString());
|
// they must be added at the end,
|
||||||
break;
|
// so let the caller handle it.
|
||||||
case "string":
|
if (option === undefined || option === "") {
|
||||||
args.push(value);
|
continue;
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
case "boolean":
|
||||||
|
if (value) {
|
||||||
|
args.push(option);
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
|
case "number":
|
||||||
|
args.push(option, value.toString());
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
args.push(option, value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported type for option ${key}: ${typeof value}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,3 +42,5 @@ export function buildArguments<T>(
|
||||||
|
|
||||||
export type SingleUser = number | "current";
|
export type SingleUser = number | "current";
|
||||||
export type SingleUserOrAll = SingleUser | "all";
|
export type SingleUserOrAll = SingleUser | "all";
|
||||||
|
|
||||||
|
export type Optional<T extends object> = { [K in keyof T]?: T[K] | undefined };
|
||||||
|
|
|
@ -1,14 +1,3 @@
|
||||||
{
|
{
|
||||||
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json",
|
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json",
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../adb/tsconfig.build.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../stream-extra/tsconfig.build.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../struct/tsconfig.build.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.test.json"
|
"path": "./tsconfig.test.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../adb/tsconfig.build.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../stream-extra/tsconfig.build.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../struct/tsconfig.build.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ export async function aoaHidRegister(
|
||||||
export async function aoaHidSetReportDescriptor(
|
export async function aoaHidSetReportDescriptor(
|
||||||
device: USBDevice,
|
device: USBDevice,
|
||||||
accessoryId: number,
|
accessoryId: number,
|
||||||
reportDescriptor: Uint8Array<ArrayBuffer>,
|
reportDescriptor: BufferSource,
|
||||||
) {
|
) {
|
||||||
await device.controlTransferOut(
|
await device.controlTransferOut(
|
||||||
{
|
{
|
||||||
|
@ -50,7 +50,7 @@ export async function aoaHidUnregister(device: USBDevice, accessoryId: number) {
|
||||||
export async function aoaHidSendInputReport(
|
export async function aoaHidSendInputReport(
|
||||||
device: USBDevice,
|
device: USBDevice,
|
||||||
accessoryId: number,
|
accessoryId: number,
|
||||||
event: Uint8Array<ArrayBuffer>,
|
event: BufferSource,
|
||||||
) {
|
) {
|
||||||
await device.controlTransferOut(
|
await device.controlTransferOut(
|
||||||
{
|
{
|
||||||
|
@ -80,9 +80,9 @@ export class AoaHidDevice {
|
||||||
static async register(
|
static async register(
|
||||||
device: USBDevice,
|
device: USBDevice,
|
||||||
accessoryId: number,
|
accessoryId: number,
|
||||||
reportDescriptor: Uint8Array<ArrayBuffer>,
|
reportDescriptor: BufferSource,
|
||||||
) {
|
) {
|
||||||
await aoaHidRegister(device, accessoryId, reportDescriptor.length);
|
await aoaHidRegister(device, accessoryId, reportDescriptor.byteLength);
|
||||||
await aoaHidSetReportDescriptor(device, accessoryId, reportDescriptor);
|
await aoaHidSetReportDescriptor(device, accessoryId, reportDescriptor);
|
||||||
return new AoaHidDevice(device, accessoryId);
|
return new AoaHidDevice(device, accessoryId);
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ export class AoaHidDevice {
|
||||||
this.#accessoryId = accessoryId;
|
this.#accessoryId = accessoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInputReport(event: Uint8Array<ArrayBuffer>) {
|
async sendInputReport(event: BufferSource) {
|
||||||
await aoaHidSendInputReport(this.#device, this.#accessoryId, event);
|
await aoaHidSendInputReport(this.#device, this.#accessoryId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
16
libraries/media-codec/.npmignore
Normal file
16
libraries/media-codec/.npmignore
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
.rush
|
||||||
|
|
||||||
|
# Test
|
||||||
|
coverage
|
||||||
|
**/*.spec.ts
|
||||||
|
**/*.spec.js
|
||||||
|
**/*.spec.js.map
|
||||||
|
**/__helpers__
|
||||||
|
jest.config.js
|
||||||
|
|
||||||
|
.eslintrc.cjs
|
||||||
|
tsconfig.json
|
||||||
|
tsconfig.test.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
21
libraries/media-codec/LICENSE
Normal file
21
libraries/media-codec/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020-2025 Simon Chan
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
3
libraries/media-codec/README.md
Normal file
3
libraries/media-codec/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# @yume-chan/media-codec
|
||||||
|
|
||||||
|
H.264, H.265 and AV1 configuration packet parser
|
44
libraries/media-codec/package.json
Normal file
44
libraries/media-codec/package.json
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"name": "@yume-chan/media-codec",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "H.264, H.265 and AV1 configuration packet parser",
|
||||||
|
"keywords": [
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "Simon Chan",
|
||||||
|
"email": "cnsimonchan@live.com",
|
||||||
|
"url": "https://chensi.moe/blog"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/yume-chan/ya-webadb/tree/main/libraries/media-codec#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/yume-chan/ya-webadb.git",
|
||||||
|
"directory": "libraries/media-codec"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/yume-chan/ya-webadb/issues"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "esm/index.js",
|
||||||
|
"types": "esm/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b tsconfig.build.json",
|
||||||
|
"lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4",
|
||||||
|
"prepublishOnly": "npm run build",
|
||||||
|
"test": "run-test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@yume-chan/no-data-view": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@yume-chan/eslint-config": "workspace:^",
|
||||||
|
"@yume-chan/test-runner": "workspace:^",
|
||||||
|
"@yume-chan/tsconfig": "workspace:^",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,8 @@
|
||||||
// cspell: ignore Smpte
|
// cspell: ignore Smpte
|
||||||
// cspell: ignore Chromat
|
// cspell: ignore Chromat
|
||||||
|
|
||||||
|
import { decimalTwoDigits } from "./format.js";
|
||||||
|
|
||||||
export const AndroidAv1Profile = {
|
export const AndroidAv1Profile = {
|
||||||
Main8: 1 << 0,
|
Main8: 1 << 0,
|
||||||
Main10: 1 << 1,
|
Main10: 1 << 1,
|
||||||
|
@ -198,6 +200,62 @@ export class Av1 extends BitReader {
|
||||||
static TransferCharacteristics = TransferCharacteristics;
|
static TransferCharacteristics = TransferCharacteristics;
|
||||||
static MatrixCoefficients = MatrixCoefficients;
|
static MatrixCoefficients = MatrixCoefficients;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a codec string from an AV1 sequence header
|
||||||
|
* per Section 5 of AV1 Codec ISO Media File Format Binding
|
||||||
|
* https://aomediacodec.github.io/av1-isobmff/#codecsparam
|
||||||
|
* @param sequenceHeader The parsed AV1 sequence header
|
||||||
|
* @returns A codec string
|
||||||
|
*/
|
||||||
|
static toCodecString(sequenceHeader: Av1.SequenceHeaderObu) {
|
||||||
|
const {
|
||||||
|
seq_profile: seqProfile,
|
||||||
|
seq_level_idx: [seqLevelIdx = 0],
|
||||||
|
color_config: {
|
||||||
|
BitDepth,
|
||||||
|
mono_chrome: monoChrome,
|
||||||
|
subsampling_x: subsamplingX,
|
||||||
|
subsampling_y: subsamplingY,
|
||||||
|
chroma_sample_position: chromaSamplePosition,
|
||||||
|
color_description_present_flag,
|
||||||
|
},
|
||||||
|
} = sequenceHeader;
|
||||||
|
|
||||||
|
let colorPrimaries: Av1.ColorPrimaries;
|
||||||
|
let transferCharacteristics: Av1.TransferCharacteristics;
|
||||||
|
let matrixCoefficients: Av1.MatrixCoefficients;
|
||||||
|
let colorRange: boolean;
|
||||||
|
if (color_description_present_flag) {
|
||||||
|
({
|
||||||
|
color_primaries: colorPrimaries,
|
||||||
|
transfer_characteristics: transferCharacteristics,
|
||||||
|
matrix_coefficients: matrixCoefficients,
|
||||||
|
color_range: colorRange,
|
||||||
|
} = sequenceHeader.color_config);
|
||||||
|
} else {
|
||||||
|
colorPrimaries = Av1.ColorPrimaries.Bt709;
|
||||||
|
transferCharacteristics = Av1.TransferCharacteristics.Bt709;
|
||||||
|
matrixCoefficients = Av1.MatrixCoefficients.Bt709;
|
||||||
|
colorRange = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"av01",
|
||||||
|
seqProfile.toString(16),
|
||||||
|
decimalTwoDigits(seqLevelIdx) +
|
||||||
|
(sequenceHeader.seq_tier[0] ? "H" : "M"),
|
||||||
|
decimalTwoDigits(BitDepth),
|
||||||
|
monoChrome ? "1" : "0",
|
||||||
|
(subsamplingX ? "1" : "0") +
|
||||||
|
(subsamplingY ? "1" : "0") +
|
||||||
|
chromaSamplePosition.toString(),
|
||||||
|
decimalTwoDigits(colorPrimaries),
|
||||||
|
decimalTwoDigits(transferCharacteristics),
|
||||||
|
decimalTwoDigits(matrixCoefficients),
|
||||||
|
colorRange ? "1" : "0",
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
#Leb128Bytes: number = 0;
|
#Leb128Bytes: number = 0;
|
||||||
|
|
||||||
uvlc() {
|
uvlc() {
|
53
libraries/media-codec/src/format.ts
Normal file
53
libraries/media-codec/src/format.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
export function hexDigits(value: number) {
|
||||||
|
if (value % 1 !== 0) {
|
||||||
|
// This also checks NaN and Infinity
|
||||||
|
throw new Error("Value must be an integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error("Value must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexTwoDigits(value: number) {
|
||||||
|
if (value % 1 !== 0) {
|
||||||
|
// This also checks NaN and Infinity
|
||||||
|
throw new Error("Value must be an integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error("Value must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 256) {
|
||||||
|
throw new Error("Value must be less than 256");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small optimization
|
||||||
|
if (value < 16) {
|
||||||
|
return "0" + value.toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
return value.toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decimalTwoDigits(value: number) {
|
||||||
|
if (value % 1 !== 0) {
|
||||||
|
// This also checks NaN and Infinity
|
||||||
|
throw new Error("Value must be an integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error("Value must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 100) {
|
||||||
|
throw new Error("Value must be less than 256");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 10) {
|
||||||
|
return "0" + value.toString(10);
|
||||||
|
}
|
||||||
|
return value.toString(10);
|
||||||
|
}
|
|
@ -2,44 +2,9 @@
|
||||||
// cspell: ignore qpprime
|
// cspell: ignore qpprime
|
||||||
// cspell: ignore colour
|
// cspell: ignore colour
|
||||||
|
|
||||||
|
import { hexTwoDigits } from "./format.js";
|
||||||
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
|
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
|
||||||
|
|
||||||
// From https://developer.android.com/reference/android/media/MediaCodecInfo.CodecProfileLevel
|
|
||||||
export const AndroidAvcProfile = {
|
|
||||||
Baseline: 1 << 0,
|
|
||||||
Main: 1 << 1,
|
|
||||||
Extended: 1 << 2,
|
|
||||||
High: 1 << 3,
|
|
||||||
High10: 1 << 4,
|
|
||||||
High422: 1 << 5,
|
|
||||||
High444: 1 << 6,
|
|
||||||
ConstrainedBaseline: 1 << 16,
|
|
||||||
ConstrainedHigh: 1 << 19,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AndroidAvcLevel = {
|
|
||||||
Level1: 1 << 0,
|
|
||||||
Level1b: 1 << 1,
|
|
||||||
Level11: 1 << 2,
|
|
||||||
Level12: 1 << 3,
|
|
||||||
Level13: 1 << 4,
|
|
||||||
Level2: 1 << 5,
|
|
||||||
Level21: 1 << 6,
|
|
||||||
Level22: 1 << 7,
|
|
||||||
Level3: 1 << 8,
|
|
||||||
Level31: 1 << 9,
|
|
||||||
Level32: 1 << 10,
|
|
||||||
Level4: 1 << 11,
|
|
||||||
Level41: 1 << 12,
|
|
||||||
Level42: 1 << 13,
|
|
||||||
Level5: 1 << 14,
|
|
||||||
Level51: 1 << 15,
|
|
||||||
Level52: 1 << 16,
|
|
||||||
Level6: 1 << 17,
|
|
||||||
Level61: 1 << 18,
|
|
||||||
Level62: 1 << 19,
|
|
||||||
};
|
|
||||||
|
|
||||||
// H.264 has two standards: ITU-T H.264 and ISO/IEC 14496-10
|
// H.264 has two standards: ITU-T H.264 and ISO/IEC 14496-10
|
||||||
// they have the same content, and refer themselves as "H.264".
|
// they have the same content, and refer themselves as "H.264".
|
||||||
// The name "AVC" (Advanced Video Coding) is only used in ISO spec name,
|
// The name "AVC" (Advanced Video Coding) is only used in ISO spec name,
|
||||||
|
@ -49,7 +14,7 @@ export const AndroidAvcLevel = {
|
||||||
|
|
||||||
// 7.3.2.1.1 Sequence parameter set data syntax
|
// 7.3.2.1.1 Sequence parameter set data syntax
|
||||||
// Variable names in this method uses the snake_case convention as in the spec for easier referencing.
|
// Variable names in this method uses the snake_case convention as in the spec for easier referencing.
|
||||||
export function h264ParseSequenceParameterSet(nalu: Uint8Array) {
|
export function parseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
const reader = new NaluSodbBitReader(nalu);
|
const reader = new NaluSodbBitReader(nalu);
|
||||||
if (reader.next() !== 0) {
|
if (reader.next() !== 0) {
|
||||||
throw new Error("Invalid data");
|
throw new Error("Invalid data");
|
||||||
|
@ -218,7 +183,7 @@ export function h264ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
* Find Sequence Parameter Set (SPS) and Picture Parameter Set (PPS)
|
* Find Sequence Parameter Set (SPS) and Picture Parameter Set (PPS)
|
||||||
* from H.264 Annex B formatted data.
|
* from H.264 Annex B formatted data.
|
||||||
*/
|
*/
|
||||||
export function h264SearchConfiguration(buffer: Uint8Array) {
|
export function searchConfiguration(buffer: Uint8Array) {
|
||||||
let sequenceParameterSet: Uint8Array | undefined;
|
let sequenceParameterSet: Uint8Array | undefined;
|
||||||
let pictureParameterSet: Uint8Array | undefined;
|
let pictureParameterSet: Uint8Array | undefined;
|
||||||
|
|
||||||
|
@ -252,7 +217,7 @@ export function h264SearchConfiguration(buffer: Uint8Array) {
|
||||||
throw new Error("Invalid data");
|
throw new Error("Invalid data");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface H264Configuration {
|
export interface Configuration {
|
||||||
pictureParameterSet: Uint8Array;
|
pictureParameterSet: Uint8Array;
|
||||||
sequenceParameterSet: Uint8Array;
|
sequenceParameterSet: Uint8Array;
|
||||||
|
|
||||||
|
@ -271,9 +236,9 @@ export interface H264Configuration {
|
||||||
croppedHeight: number;
|
croppedHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
|
export function parseConfiguration(data: Uint8Array): Configuration {
|
||||||
const { sequenceParameterSet, pictureParameterSet } =
|
const { sequenceParameterSet, pictureParameterSet } =
|
||||||
h264SearchConfiguration(data);
|
searchConfiguration(data);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
profile_idc: profileIndex,
|
profile_idc: profileIndex,
|
||||||
|
@ -286,7 +251,7 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
|
||||||
frame_crop_right_offset,
|
frame_crop_right_offset,
|
||||||
frame_crop_top_offset,
|
frame_crop_top_offset,
|
||||||
frame_crop_bottom_offset,
|
frame_crop_bottom_offset,
|
||||||
} = h264ParseSequenceParameterSet(sequenceParameterSet);
|
} = parseSequenceParameterSet(sequenceParameterSet);
|
||||||
|
|
||||||
const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16;
|
const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16;
|
||||||
const encodedHeight =
|
const encodedHeight =
|
||||||
|
@ -315,3 +280,16 @@ export function h264ParseConfiguration(data: Uint8Array): H264Configuration {
|
||||||
croppedHeight,
|
croppedHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toCodecString(configuration: Configuration) {
|
||||||
|
const { profileIndex, constraintSet, levelIndex } = configuration;
|
||||||
|
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc6381#section-3.3
|
||||||
|
// ISO Base Media File Format Name Space
|
||||||
|
return (
|
||||||
|
"avc1." +
|
||||||
|
hexTwoDigits(profileIndex) +
|
||||||
|
hexTwoDigits(constraintSet) +
|
||||||
|
hexTwoDigits(levelIndex)
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,7 +7,7 @@
|
||||||
import * as assert from "node:assert";
|
import * as assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { h265ParseSequenceParameterSet } from "./h265.js";
|
import { parseSequenceParameterSet } from "./h265.js";
|
||||||
|
|
||||||
describe("h265", () => {
|
describe("h265", () => {
|
||||||
describe("h265ParseSequenceParameterSet", () => {
|
describe("h265ParseSequenceParameterSet", () => {
|
||||||
|
@ -21,7 +21,7 @@ describe("h265", () => {
|
||||||
0x80,
|
0x80,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sps = h265ParseSequenceParameterSet(buffer);
|
const sps = parseSequenceParameterSet(buffer);
|
||||||
|
|
||||||
assert.deepStrictEqual(sps, {
|
assert.deepStrictEqual(sps, {
|
||||||
sps_video_parameter_set_id: 0,
|
sps_video_parameter_set_id: 0,
|
||||||
|
@ -355,7 +355,7 @@ describe("h265", () => {
|
||||||
},
|
},
|
||||||
sps_extension_4bits: 0,
|
sps_extension_4bits: 0,
|
||||||
sps_extension_data_flag: undefined,
|
sps_extension_data_flag: undefined,
|
||||||
} satisfies ReturnType<typeof h265ParseSequenceParameterSet>);
|
} satisfies ReturnType<typeof parseSequenceParameterSet>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("issue #732", () => {
|
it("issue #732", () => {
|
||||||
|
@ -365,7 +365,7 @@ describe("h265", () => {
|
||||||
151, 43, 182, 64,
|
151, 43, 182, 64,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sps = h265ParseSequenceParameterSet(buffer);
|
const sps = parseSequenceParameterSet(buffer);
|
||||||
|
|
||||||
assert.deepStrictEqual(sps, {
|
assert.deepStrictEqual(sps, {
|
||||||
sps_video_parameter_set_id: 4,
|
sps_video_parameter_set_id: 4,
|
||||||
|
@ -552,7 +552,7 @@ describe("h265", () => {
|
||||||
spsMultilayerExtension: undefined,
|
spsMultilayerExtension: undefined,
|
||||||
sps3dExtension: undefined,
|
sps3dExtension: undefined,
|
||||||
sps_extension_data_flag: undefined,
|
sps_extension_data_flag: undefined,
|
||||||
} satisfies ReturnType<typeof h265ParseSequenceParameterSet>);
|
} satisfies ReturnType<typeof parseSequenceParameterSet>);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -18,45 +18,11 @@
|
||||||
// cspell: ignore sodb
|
// cspell: ignore sodb
|
||||||
// cspell: ignore luma
|
// cspell: ignore luma
|
||||||
|
|
||||||
|
import { getUint32LittleEndian } from "@yume-chan/no-data-view";
|
||||||
|
|
||||||
|
import { hexDigits } from "./format.js";
|
||||||
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
|
import { NaluSodbBitReader, annexBSplitNalu } from "./nalu.js";
|
||||||
|
|
||||||
export const AndroidHevcProfile = {
|
|
||||||
Main: 1 << 0,
|
|
||||||
Main10: 1 << 1,
|
|
||||||
MainStill: 1 << 2,
|
|
||||||
Main10Hdr10: 1 << 12,
|
|
||||||
Main10Hdr10Plus: 1 << 13,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AndroidHevcLevel = {
|
|
||||||
MainTierLevel1: 1 << 0,
|
|
||||||
HighTierLevel1: 1 << 1,
|
|
||||||
MainTierLevel2: 1 << 2,
|
|
||||||
HighTierLevel2: 1 << 3,
|
|
||||||
MainTierLevel21: 1 << 4,
|
|
||||||
HighTierLevel21: 1 << 5,
|
|
||||||
MainTierLevel3: 1 << 6,
|
|
||||||
HighTierLevel3: 1 << 7,
|
|
||||||
MainTierLevel31: 1 << 8,
|
|
||||||
HighTierLevel31: 1 << 9,
|
|
||||||
MainTierLevel4: 1 << 10,
|
|
||||||
HighTierLevel4: 1 << 11,
|
|
||||||
MainTierLevel41: 1 << 12,
|
|
||||||
HighTierLevel41: 1 << 13,
|
|
||||||
MainTierLevel5: 1 << 14,
|
|
||||||
HighTierLevel5: 1 << 15,
|
|
||||||
MainTierLevel51: 1 << 16,
|
|
||||||
HighTierLevel51: 1 << 17,
|
|
||||||
MainTierLevel52: 1 << 18,
|
|
||||||
HighTierLevel52: 1 << 19,
|
|
||||||
MainTierLevel6: 1 << 20,
|
|
||||||
HighTierLevel6: 1 << 21,
|
|
||||||
MainTierLevel61: 1 << 22,
|
|
||||||
HighTierLevel61: 1 << 23,
|
|
||||||
MainTierLevel62: 1 << 24,
|
|
||||||
HighTierLevel62: 1 << 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 6.2 Source, decoded and output picture formats
|
* 6.2 Source, decoded and output picture formats
|
||||||
*/
|
*/
|
||||||
|
@ -92,7 +58,7 @@ export function getSubHeightC(chroma_format_idc: number) {
|
||||||
/**
|
/**
|
||||||
* 7.3.1.1 General NAL unit syntax
|
* 7.3.1.1 General NAL unit syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseNaluHeader(nalu: Uint8Array) {
|
export function parseNaluHeader(nalu: Uint8Array) {
|
||||||
const reader = new NaluSodbBitReader(nalu);
|
const reader = new NaluSodbBitReader(nalu);
|
||||||
if (reader.next() !== 0) {
|
if (reader.next() !== 0) {
|
||||||
throw new Error("Invalid NALU header");
|
throw new Error("Invalid NALU header");
|
||||||
|
@ -109,9 +75,9 @@ export function h265ParseNaluHeader(nalu: Uint8Array) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265NaluHeader = ReturnType<typeof h265ParseNaluHeader>;
|
export type NaluHeader = ReturnType<typeof parseNaluHeader>;
|
||||||
|
|
||||||
export interface H265NaluRaw extends H265NaluHeader {
|
export interface NaluRaw extends NaluHeader {
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
rbsp: Uint8Array;
|
rbsp: Uint8Array;
|
||||||
}
|
}
|
||||||
|
@ -119,7 +85,7 @@ export interface H265NaluRaw extends H265NaluHeader {
|
||||||
/**
|
/**
|
||||||
* 7.3.2.1 Video parameter set RBSP syntax
|
* 7.3.2.1 Video parameter set RBSP syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseVideoParameterSet(nalu: Uint8Array) {
|
export function parseVideoParameterSet(nalu: Uint8Array) {
|
||||||
const reader = new NaluSodbBitReader(nalu);
|
const reader = new NaluSodbBitReader(nalu);
|
||||||
|
|
||||||
const vps_video_parameter_set_id = reader.read(4);
|
const vps_video_parameter_set_id = reader.read(4);
|
||||||
|
@ -130,7 +96,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
|
||||||
const vps_temporal_id_nesting_flag = !!reader.next();
|
const vps_temporal_id_nesting_flag = !!reader.next();
|
||||||
reader.skip(16);
|
reader.skip(16);
|
||||||
|
|
||||||
const profileTierLevel = h265ParseProfileTierLevel(
|
const profileTierLevel = parseProfileTierLevel(
|
||||||
reader,
|
reader,
|
||||||
true,
|
true,
|
||||||
vps_max_sub_layers_minus1,
|
vps_max_sub_layers_minus1,
|
||||||
|
@ -172,7 +138,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
|
||||||
let vps_num_hrd_parameters: number | undefined;
|
let vps_num_hrd_parameters: number | undefined;
|
||||||
let hrd_layer_set_idx: number[] | undefined;
|
let hrd_layer_set_idx: number[] | undefined;
|
||||||
let cprms_present_flag: boolean[] | undefined;
|
let cprms_present_flag: boolean[] | undefined;
|
||||||
let hrdParameters: H265HrdParameters[] | undefined;
|
let hrdParameters: HrdParameters[] | undefined;
|
||||||
if (vps_timing_info_present_flag) {
|
if (vps_timing_info_present_flag) {
|
||||||
vps_num_units_in_tick = reader.read(32);
|
vps_num_units_in_tick = reader.read(32);
|
||||||
vps_time_scale = reader.read(32);
|
vps_time_scale = reader.read(32);
|
||||||
|
@ -193,7 +159,7 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
cprms_present_flag[i] = !!reader.next();
|
cprms_present_flag[i] = !!reader.next();
|
||||||
}
|
}
|
||||||
hrdParameters[i] = h265ParseHrdParameters(
|
hrdParameters[i] = parseHrdParameters(
|
||||||
reader,
|
reader,
|
||||||
cprms_present_flag[i]!,
|
cprms_present_flag[i]!,
|
||||||
vps_max_sub_layers_minus1,
|
vps_max_sub_layers_minus1,
|
||||||
|
@ -232,20 +198,20 @@ export function h265ParseVideoParameterSet(nalu: Uint8Array) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SubLayerHrdParameters = ReturnType<
|
export type SubLayerHrdParameters = ReturnType<
|
||||||
typeof h265ParseSubLayerHrdParameters
|
typeof parseSubLayerHrdParameters
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 7.3.2.2.1 General sequence parameter set RBSP syntax
|
* 7.3.2.2.1 General sequence parameter set RBSP syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
export function parseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
const reader = new NaluSodbBitReader(nalu);
|
const reader = new NaluSodbBitReader(nalu);
|
||||||
|
|
||||||
const sps_video_parameter_set_id = reader.read(4);
|
const sps_video_parameter_set_id = reader.read(4);
|
||||||
const sps_max_sub_layers_minus1 = reader.read(3);
|
const sps_max_sub_layers_minus1 = reader.read(3);
|
||||||
const sps_temporal_id_nesting_flag = !!reader.next();
|
const sps_temporal_id_nesting_flag = !!reader.next();
|
||||||
|
|
||||||
const profileTierLevel = h265ParseProfileTierLevel(
|
const profileTierLevel = parseProfileTierLevel(
|
||||||
reader,
|
reader,
|
||||||
true,
|
true,
|
||||||
sps_max_sub_layers_minus1,
|
sps_max_sub_layers_minus1,
|
||||||
|
@ -315,7 +281,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
if (scaling_list_enabled_flag) {
|
if (scaling_list_enabled_flag) {
|
||||||
sps_scaling_list_data_present_flag = !!reader.next();
|
sps_scaling_list_data_present_flag = !!reader.next();
|
||||||
if (sps_scaling_list_data_present_flag) {
|
if (sps_scaling_list_data_present_flag) {
|
||||||
scalingListData = h265ParseScalingListData(reader);
|
scalingListData = parseScalingListData(reader);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,7 +306,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
const num_short_term_ref_pic_sets = reader.decodeExponentialGolombNumber();
|
const num_short_term_ref_pic_sets = reader.decodeExponentialGolombNumber();
|
||||||
const shortTermRefPicSets: ShortTermReferencePictureSet[] = [];
|
const shortTermRefPicSets: ShortTermReferencePictureSet[] = [];
|
||||||
for (let i = 0; i < num_short_term_ref_pic_sets; i += 1) {
|
for (let i = 0; i < num_short_term_ref_pic_sets; i += 1) {
|
||||||
shortTermRefPicSets[i] = h265ParseShortTermReferencePictureSet(
|
shortTermRefPicSets[i] = parseShortTermReferencePictureSet(
|
||||||
reader,
|
reader,
|
||||||
i,
|
i,
|
||||||
num_short_term_ref_pic_sets,
|
num_short_term_ref_pic_sets,
|
||||||
|
@ -367,12 +333,9 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
const sps_temporal_mvp_enabled_flag = !!reader.next();
|
const sps_temporal_mvp_enabled_flag = !!reader.next();
|
||||||
const strong_intra_smoothing_enabled_flag = !!reader.next();
|
const strong_intra_smoothing_enabled_flag = !!reader.next();
|
||||||
const vui_parameters_present_flag = !!reader.next();
|
const vui_parameters_present_flag = !!reader.next();
|
||||||
let vuiParameters: H265VuiParameters | undefined;
|
let vuiParameters: VuiParameters | undefined;
|
||||||
if (vui_parameters_present_flag) {
|
if (vui_parameters_present_flag) {
|
||||||
vuiParameters = h265ParseVuiParameters(
|
vuiParameters = parseVuiParameters(reader, sps_max_sub_layers_minus1);
|
||||||
reader,
|
|
||||||
sps_max_sub_layers_minus1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sps_extension_present_flag = !!reader.next();
|
const sps_extension_present_flag = !!reader.next();
|
||||||
|
@ -393,14 +356,14 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
throw new Error("Not implemented");
|
throw new Error("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
let spsMultilayerExtension: H265SpsMultilayerExtension | undefined;
|
let spsMultilayerExtension: SpsMultilayerExtension | undefined;
|
||||||
if (sps_multilayer_extension_flag) {
|
if (sps_multilayer_extension_flag) {
|
||||||
spsMultilayerExtension = h265ParseSpsMultilayerExtension(reader);
|
spsMultilayerExtension = parseSpsMultilayerExtension(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sps3dExtension: H265Sps3dExtension | undefined;
|
let sps3dExtension: Sps3dExtension | undefined;
|
||||||
if (sps_3d_extension_flag) {
|
if (sps_3d_extension_flag) {
|
||||||
sps3dExtension = h265ParseSps3dExtension(reader);
|
sps3dExtension = parseSps3dExtension(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sps_scc_extension_flag) {
|
if (sps_scc_extension_flag) {
|
||||||
|
@ -484,7 +447,7 @@ export function h265ParseSequenceParameterSet(nalu: Uint8Array) {
|
||||||
* Common part between general_profile_tier_level and
|
* Common part between general_profile_tier_level and
|
||||||
* sub_layer_profile_tier_level
|
* sub_layer_profile_tier_level
|
||||||
*/
|
*/
|
||||||
function h265ParseProfileTier(reader: NaluSodbBitReader) {
|
function parseProfileTier(reader: NaluSodbBitReader) {
|
||||||
const profile_space = reader.read(2);
|
const profile_space = reader.read(2);
|
||||||
const tier_flag = !!reader.next();
|
const tier_flag = !!reader.next();
|
||||||
const profile_idc = reader.read(5);
|
const profile_idc = reader.read(5);
|
||||||
|
@ -609,43 +572,43 @@ function h265ParseProfileTier(reader: NaluSodbBitReader) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265ProfileTier = ReturnType<typeof h265ParseProfileTier>;
|
export type ProfileTier = ReturnType<typeof parseProfileTier>;
|
||||||
|
|
||||||
export interface H265ProfileTierLevel {
|
export interface ProfileTierLevel {
|
||||||
generalProfileTier: H265ProfileTier | undefined;
|
generalProfileTier: ProfileTier | undefined;
|
||||||
general_level_idc: number;
|
general_level_idc: number;
|
||||||
sub_layer_profile_present_flag: boolean[];
|
sub_layer_profile_present_flag: boolean[];
|
||||||
sub_layer_level_present_flag: boolean[];
|
sub_layer_level_present_flag: boolean[];
|
||||||
subLayerProfileTier: H265ProfileTier[];
|
subLayerProfileTier: ProfileTier[];
|
||||||
sub_layer_level_idc: number[];
|
sub_layer_level_idc: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 7.3.3 Profile, tier and level syntax
|
* 7.3.3 Profile, tier and level syntax
|
||||||
*/
|
*/
|
||||||
function h265ParseProfileTierLevel(
|
function parseProfileTierLevel(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
profilePresentFlag: true,
|
profilePresentFlag: true,
|
||||||
maxNumSubLayersMinus1: number,
|
maxNumSubLayersMinus1: number,
|
||||||
): H265ProfileTierLevel & { generalProfileTier: H265ProfileTier };
|
): ProfileTierLevel & { generalProfileTier: ProfileTier };
|
||||||
function h265ParseProfileTierLevel(
|
function parseProfileTierLevel(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
profilePresentFlag: false,
|
profilePresentFlag: false,
|
||||||
maxNumSubLayersMinus1: number,
|
maxNumSubLayersMinus1: number,
|
||||||
): H265ProfileTierLevel & { generalProfileTier: undefined };
|
): ProfileTierLevel & { generalProfileTier: undefined };
|
||||||
function h265ParseProfileTierLevel(
|
function parseProfileTierLevel(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
profilePresentFlag: boolean,
|
profilePresentFlag: boolean,
|
||||||
maxNumSubLayersMinus1: number,
|
maxNumSubLayersMinus1: number,
|
||||||
): H265ProfileTierLevel;
|
): ProfileTierLevel;
|
||||||
function h265ParseProfileTierLevel(
|
function parseProfileTierLevel(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
profilePresentFlag: boolean,
|
profilePresentFlag: boolean,
|
||||||
maxNumSubLayersMinus1: number,
|
maxNumSubLayersMinus1: number,
|
||||||
): H265ProfileTierLevel {
|
): ProfileTierLevel {
|
||||||
let generalProfileTier: H265ProfileTier | undefined;
|
let generalProfileTier: ProfileTier | undefined;
|
||||||
if (profilePresentFlag) {
|
if (profilePresentFlag) {
|
||||||
generalProfileTier = h265ParseProfileTier(reader);
|
generalProfileTier = parseProfileTier(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
const general_level_idc = reader.read(8);
|
const general_level_idc = reader.read(8);
|
||||||
|
@ -663,11 +626,11 @@ function h265ParseProfileTierLevel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subLayerProfileTier: H265ProfileTier[] = [];
|
const subLayerProfileTier: ProfileTier[] = [];
|
||||||
const sub_layer_level_idc: number[] = [];
|
const sub_layer_level_idc: number[] = [];
|
||||||
for (let i = 0; i < maxNumSubLayersMinus1; i += 1) {
|
for (let i = 0; i < maxNumSubLayersMinus1; i += 1) {
|
||||||
if (sub_layer_profile_present_flag[i]) {
|
if (sub_layer_profile_present_flag[i]) {
|
||||||
subLayerProfileTier[i] = h265ParseProfileTier(reader);
|
subLayerProfileTier[i] = parseProfileTier(reader);
|
||||||
}
|
}
|
||||||
if (sub_layer_level_present_flag[i]) {
|
if (sub_layer_level_present_flag[i]) {
|
||||||
sub_layer_level_idc[i] = reader.read(8);
|
sub_layer_level_idc[i] = reader.read(8);
|
||||||
|
@ -687,7 +650,7 @@ function h265ParseProfileTierLevel(
|
||||||
/**
|
/**
|
||||||
* 7.3.4 Scaling list data syntax
|
* 7.3.4 Scaling list data syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseScalingListData(reader: NaluSodbBitReader) {
|
export function parseScalingListData(reader: NaluSodbBitReader) {
|
||||||
const scaling_list: number[][][] = [];
|
const scaling_list: number[][][] = [];
|
||||||
for (let sizeId = 0; sizeId < 4; sizeId += 1) {
|
for (let sizeId = 0; sizeId < 4; sizeId += 1) {
|
||||||
scaling_list[sizeId] = [];
|
scaling_list[sizeId] = [];
|
||||||
|
@ -737,7 +700,7 @@ interface ShortTermReferencePictureSet {
|
||||||
/**
|
/**
|
||||||
* 7.3.7 Short-term reference picture set syntax
|
* 7.3.7 Short-term reference picture set syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseShortTermReferencePictureSet(
|
export function parseShortTermReferencePictureSet(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
stRpsIdx: number,
|
stRpsIdx: number,
|
||||||
num_short_term_ref_pic_sets: number,
|
num_short_term_ref_pic_sets: number,
|
||||||
|
@ -896,7 +859,7 @@ export function h265ParseShortTermReferencePictureSet(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const H265AspectRatioIndicator = {
|
export const AspectRatioIndicator = {
|
||||||
Unspecified: 0,
|
Unspecified: 0,
|
||||||
Square: 1,
|
Square: 1,
|
||||||
_12_11: 2,
|
_12_11: 2,
|
||||||
|
@ -917,23 +880,23 @@ export const H265AspectRatioIndicator = {
|
||||||
Extended: 255,
|
Extended: 255,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type H265AspectRatioIndicator =
|
export type AspectRatioIndicator =
|
||||||
(typeof H265AspectRatioIndicator)[keyof typeof H265AspectRatioIndicator];
|
(typeof AspectRatioIndicator)[keyof typeof AspectRatioIndicator];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E.2.1 VUI parameters syntax
|
* E.2.1 VUI parameters syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseVuiParameters(
|
export function parseVuiParameters(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
sps_max_sub_layers_minus1: number,
|
sps_max_sub_layers_minus1: number,
|
||||||
) {
|
) {
|
||||||
const aspect_ratio_info_present_flag = !!reader.next();
|
const aspect_ratio_info_present_flag = !!reader.next();
|
||||||
let aspect_ratio_idc: H265AspectRatioIndicator | undefined;
|
let aspect_ratio_idc: AspectRatioIndicator | undefined;
|
||||||
let sar_width: number | undefined;
|
let sar_width: number | undefined;
|
||||||
let sar_height: number | undefined;
|
let sar_height: number | undefined;
|
||||||
if (aspect_ratio_info_present_flag) {
|
if (aspect_ratio_info_present_flag) {
|
||||||
aspect_ratio_idc = reader.read(8) as H265AspectRatioIndicator;
|
aspect_ratio_idc = reader.read(8) as AspectRatioIndicator;
|
||||||
if (aspect_ratio_idc === H265AspectRatioIndicator.Extended) {
|
if (aspect_ratio_idc === AspectRatioIndicator.Extended) {
|
||||||
sar_width = reader.read(16);
|
sar_width = reader.read(16);
|
||||||
sar_height = reader.read(16);
|
sar_height = reader.read(16);
|
||||||
}
|
}
|
||||||
|
@ -995,7 +958,7 @@ export function h265ParseVuiParameters(
|
||||||
let vui_poc_proportional_to_timing_flag: boolean | undefined;
|
let vui_poc_proportional_to_timing_flag: boolean | undefined;
|
||||||
let vui_num_ticks_poc_diff_one_minus1: number | undefined;
|
let vui_num_ticks_poc_diff_one_minus1: number | undefined;
|
||||||
let vui_hrd_parameters_present_flag: boolean | undefined;
|
let vui_hrd_parameters_present_flag: boolean | undefined;
|
||||||
let vui_hrd_parameters: H265HrdParameters | undefined;
|
let vui_hrd_parameters: HrdParameters | undefined;
|
||||||
if (vui_timing_info_present_flag) {
|
if (vui_timing_info_present_flag) {
|
||||||
vui_num_units_in_tick = reader.read(32);
|
vui_num_units_in_tick = reader.read(32);
|
||||||
vui_time_scale = reader.read(32);
|
vui_time_scale = reader.read(32);
|
||||||
|
@ -1006,7 +969,7 @@ export function h265ParseVuiParameters(
|
||||||
}
|
}
|
||||||
vui_hrd_parameters_present_flag = !!reader.next();
|
vui_hrd_parameters_present_flag = !!reader.next();
|
||||||
if (vui_hrd_parameters_present_flag) {
|
if (vui_hrd_parameters_present_flag) {
|
||||||
vui_hrd_parameters = h265ParseHrdParameters(
|
vui_hrd_parameters = parseHrdParameters(
|
||||||
reader,
|
reader,
|
||||||
true,
|
true,
|
||||||
sps_max_sub_layers_minus1,
|
sps_max_sub_layers_minus1,
|
||||||
|
@ -1085,12 +1048,12 @@ export function h265ParseVuiParameters(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265VuiParameters = ReturnType<typeof h265ParseVuiParameters>;
|
export type VuiParameters = ReturnType<typeof parseVuiParameters>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E.2.2 HRD parameters syntax
|
* E.2.2 HRD parameters syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseHrdParameters(
|
export function parseHrdParameters(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
commonInfPresentFlag: boolean,
|
commonInfPresentFlag: boolean,
|
||||||
maxNumSubLayersMinus1: number,
|
maxNumSubLayersMinus1: number,
|
||||||
|
@ -1157,14 +1120,14 @@ export function h265ParseHrdParameters(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nal_hrd_parameters_present_flag) {
|
if (nal_hrd_parameters_present_flag) {
|
||||||
nalHrdParameters[i] = h265ParseSubLayerHrdParameters(
|
nalHrdParameters[i] = parseSubLayerHrdParameters(
|
||||||
reader,
|
reader,
|
||||||
i,
|
i,
|
||||||
getCpbCnt(cpb_cnt_minus1[i]!),
|
getCpbCnt(cpb_cnt_minus1[i]!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (vcl_hrd_parameters_present_flag) {
|
if (vcl_hrd_parameters_present_flag) {
|
||||||
vclHrdParameters[i] = h265ParseSubLayerHrdParameters(
|
vclHrdParameters[i] = parseSubLayerHrdParameters(
|
||||||
reader,
|
reader,
|
||||||
i,
|
i,
|
||||||
getCpbCnt(cpb_cnt_minus1[i]!),
|
getCpbCnt(cpb_cnt_minus1[i]!),
|
||||||
|
@ -1196,12 +1159,12 @@ export function h265ParseHrdParameters(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265HrdParameters = ReturnType<typeof h265ParseHrdParameters>;
|
export type HrdParameters = ReturnType<typeof parseHrdParameters>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E.2.3 Sub-layer HRD parameters syntax
|
* E.2.3 Sub-layer HRD parameters syntax
|
||||||
*/
|
*/
|
||||||
export function h265ParseSubLayerHrdParameters(
|
export function parseSubLayerHrdParameters(
|
||||||
reader: NaluSodbBitReader,
|
reader: NaluSodbBitReader,
|
||||||
subLayerId: number,
|
subLayerId: number,
|
||||||
CpbCnt: number,
|
CpbCnt: number,
|
||||||
|
@ -1234,15 +1197,15 @@ function getCpbCnt(cpb_cnt_minus_1: number) {
|
||||||
return cpb_cnt_minus_1 + 1;
|
return cpb_cnt_minus_1 + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function h265SearchConfiguration(buffer: Uint8Array) {
|
export function searchConfiguration(buffer: Uint8Array) {
|
||||||
let videoParameterSet!: H265NaluRaw;
|
let videoParameterSet!: NaluRaw;
|
||||||
let sequenceParameterSet!: H265NaluRaw;
|
let sequenceParameterSet!: NaluRaw;
|
||||||
let pictureParameterSet!: H265NaluRaw;
|
let pictureParameterSet!: NaluRaw;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const nalu of annexBSplitNalu(buffer)) {
|
for (const nalu of annexBSplitNalu(buffer)) {
|
||||||
const header = h265ParseNaluHeader(nalu);
|
const header = parseNaluHeader(nalu);
|
||||||
const raw: H265NaluRaw = {
|
const raw: NaluRaw = {
|
||||||
...header,
|
...header,
|
||||||
data: nalu,
|
data: nalu,
|
||||||
rbsp: nalu.subarray(2),
|
rbsp: nalu.subarray(2),
|
||||||
|
@ -1274,18 +1237,18 @@ export function h265SearchConfiguration(buffer: Uint8Array) {
|
||||||
throw new Error("Invalid data");
|
throw new Error("Invalid data");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function h265ParseSpsMultilayerExtension(reader: NaluSodbBitReader) {
|
export function parseSpsMultilayerExtension(reader: NaluSodbBitReader) {
|
||||||
const inter_view_mv_vert_constraint_flag = !!reader.next();
|
const inter_view_mv_vert_constraint_flag = !!reader.next();
|
||||||
return {
|
return {
|
||||||
inter_view_mv_vert_constraint_flag,
|
inter_view_mv_vert_constraint_flag,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265SpsMultilayerExtension = ReturnType<
|
export type SpsMultilayerExtension = ReturnType<
|
||||||
typeof h265ParseSpsMultilayerExtension
|
typeof parseSpsMultilayerExtension
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function h265ParseSps3dExtension(reader: NaluSodbBitReader) {
|
export function parseSps3dExtension(reader: NaluSodbBitReader) {
|
||||||
const iv_di_mc_enabled_flag: boolean[] = [];
|
const iv_di_mc_enabled_flag: boolean[] = [];
|
||||||
const iv_mv_scal_enabled_flag: boolean[] = [];
|
const iv_mv_scal_enabled_flag: boolean[] = [];
|
||||||
|
|
||||||
|
@ -1328,12 +1291,12 @@ export function h265ParseSps3dExtension(reader: NaluSodbBitReader) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type H265Sps3dExtension = ReturnType<typeof h265ParseSps3dExtension>;
|
export type Sps3dExtension = ReturnType<typeof parseSps3dExtension>;
|
||||||
|
|
||||||
export interface H265Configuration {
|
export interface Configuration {
|
||||||
videoParameterSet: H265NaluRaw;
|
videoParameterSet: NaluRaw;
|
||||||
sequenceParameterSet: H265NaluRaw;
|
sequenceParameterSet: NaluRaw;
|
||||||
pictureParameterSet: H265NaluRaw;
|
pictureParameterSet: NaluRaw;
|
||||||
|
|
||||||
generalProfileSpace: number;
|
generalProfileSpace: number;
|
||||||
generalProfileIndex: number;
|
generalProfileIndex: number;
|
||||||
|
@ -1353,9 +1316,9 @@ export interface H265Configuration {
|
||||||
croppedHeight: number;
|
croppedHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
|
export function parseConfiguration(data: Uint8Array): Configuration {
|
||||||
const { videoParameterSet, sequenceParameterSet, pictureParameterSet } =
|
const { videoParameterSet, sequenceParameterSet, pictureParameterSet } =
|
||||||
h265SearchConfiguration(data);
|
searchConfiguration(data);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
profileTierLevel: {
|
profileTierLevel: {
|
||||||
|
@ -1368,7 +1331,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
|
||||||
},
|
},
|
||||||
general_level_idc: generalLevelIndex,
|
general_level_idc: generalLevelIndex,
|
||||||
},
|
},
|
||||||
} = h265ParseVideoParameterSet(videoParameterSet.rbsp);
|
} = parseVideoParameterSet(videoParameterSet.rbsp);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
chroma_format_idc,
|
chroma_format_idc,
|
||||||
|
@ -1378,7 +1341,7 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
|
||||||
conf_win_right_offset: cropRight = 0,
|
conf_win_right_offset: cropRight = 0,
|
||||||
conf_win_top_offset: cropTop = 0,
|
conf_win_top_offset: cropTop = 0,
|
||||||
conf_win_bottom_offset: cropBottom = 0,
|
conf_win_bottom_offset: cropBottom = 0,
|
||||||
} = h265ParseSequenceParameterSet(sequenceParameterSet.rbsp);
|
} = parseSequenceParameterSet(sequenceParameterSet.rbsp);
|
||||||
|
|
||||||
const SubWidthC = getSubWidthC(chroma_format_idc);
|
const SubWidthC = getSubWidthC(chroma_format_idc);
|
||||||
const SubHeightC = getSubHeightC(chroma_format_idc);
|
const SubHeightC = getSubHeightC(chroma_format_idc);
|
||||||
|
@ -1408,3 +1371,23 @@ export function h265ParseConfiguration(data: Uint8Array): H265Configuration {
|
||||||
croppedHeight,
|
croppedHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toCodecString(configuration: Configuration) {
|
||||||
|
const {
|
||||||
|
generalProfileSpace,
|
||||||
|
generalProfileIndex,
|
||||||
|
generalProfileCompatibilitySet,
|
||||||
|
generalTierFlag,
|
||||||
|
generalLevelIndex,
|
||||||
|
generalConstraintSet,
|
||||||
|
} = configuration;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"hev1",
|
||||||
|
["", "A", "B", "C"][generalProfileSpace]! +
|
||||||
|
generalProfileIndex.toString(),
|
||||||
|
hexDigits(getUint32LittleEndian(generalProfileCompatibilitySet, 0)),
|
||||||
|
(generalTierFlag ? "H" : "L") + generalLevelIndex.toString(),
|
||||||
|
...Array.from(generalConstraintSet, hexDigits),
|
||||||
|
].join(".");
|
||||||
|
}
|
4
libraries/media-codec/src/index.ts
Normal file
4
libraries/media-codec/src/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./av1.js";
|
||||||
|
export * as H264 from "./h264.js";
|
||||||
|
export * as H265 from "./h265.js";
|
||||||
|
export * from "./nalu.js";
|
3
libraries/media-codec/tsconfig.build.json
Normal file
3
libraries/media-codec/tsconfig.build.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json"
|
||||||
|
}
|
13
libraries/media-codec/tsconfig.json
Normal file
13
libraries/media-codec/tsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.build.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.test.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../no-data-view/tsconfig.build.json"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
9
libraries/media-codec/tsconfig.test.json
Normal file
9
libraries/media-codec/tsconfig.test.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"exclude": []
|
||||||
|
}
|
|
@ -35,6 +35,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yume-chan/async": "^4.1.3",
|
"@yume-chan/async": "^4.1.3",
|
||||||
"@yume-chan/event": "workspace:^",
|
"@yume-chan/event": "workspace:^",
|
||||||
|
"@yume-chan/media-codec": "workspace:^",
|
||||||
"@yume-chan/scrcpy": "workspace:^",
|
"@yume-chan/scrcpy": "workspace:^",
|
||||||
"@yume-chan/stream-extra": "workspace:^",
|
"@yume-chan/stream-extra": "workspace:^",
|
||||||
"tinyh264": "^0.0.7",
|
"tinyh264": "^0.0.7",
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { PromiseResolver } from "@yume-chan/async";
|
import { PromiseResolver } from "@yume-chan/async";
|
||||||
import { StickyEventEmitter } from "@yume-chan/event";
|
import { H264 } from "@yume-chan/media-codec";
|
||||||
import type { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy";
|
import type {
|
||||||
|
ScrcpyMediaStreamConfigurationPacket,
|
||||||
|
ScrcpyMediaStreamPacket,
|
||||||
|
} from "@yume-chan/scrcpy";
|
||||||
import {
|
import {
|
||||||
AndroidAvcLevel,
|
AndroidAvcLevel,
|
||||||
AndroidAvcProfile,
|
AndroidAvcProfile,
|
||||||
h264ParseConfiguration,
|
ScrcpyVideoSizeImpl,
|
||||||
} from "@yume-chan/scrcpy";
|
} from "@yume-chan/scrcpy";
|
||||||
import { WritableStream } from "@yume-chan/stream-extra";
|
import { WritableStream } from "@yume-chan/stream-extra";
|
||||||
import YuvBuffer from "yuv-buffer";
|
import YuvBuffer from "yuv-buffer";
|
||||||
|
@ -14,6 +17,9 @@ import type {
|
||||||
ScrcpyVideoDecoder,
|
ScrcpyVideoDecoder,
|
||||||
ScrcpyVideoDecoderCapability,
|
ScrcpyVideoDecoderCapability,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { createCanvas, glIsSupported } from "./utils/index.js";
|
||||||
|
import { PauseControllerImpl } from "./utils/pause.js";
|
||||||
|
import { PerformanceCounterImpl } from "./utils/performance.js";
|
||||||
import type { TinyH264Wrapper } from "./wrapper.js";
|
import type { TinyH264Wrapper } from "./wrapper.js";
|
||||||
import { createTinyH264Wrapper } from "./wrapper.js";
|
import { createTinyH264Wrapper } from "./wrapper.js";
|
||||||
|
|
||||||
|
@ -21,16 +27,6 @@ const noop = () => {
|
||||||
// no-op
|
// no-op
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createCanvas() {
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
return document.createElement("canvas");
|
|
||||||
}
|
|
||||||
if (typeof OffscreenCanvas !== "undefined") {
|
|
||||||
return new OffscreenCanvas(1, 1);
|
|
||||||
}
|
|
||||||
throw new Error("no canvas input found nor any canvas can be created");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TinyH264Decoder implements ScrcpyVideoDecoder {
|
export class TinyH264Decoder implements ScrcpyVideoDecoder {
|
||||||
static readonly capabilities: Record<string, ScrcpyVideoDecoderCapability> =
|
static readonly capabilities: Record<string, ScrcpyVideoDecoderCapability> =
|
||||||
{
|
{
|
||||||
|
@ -40,34 +36,36 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#renderer: HTMLCanvasElement | OffscreenCanvas;
|
#canvas: HTMLCanvasElement | OffscreenCanvas;
|
||||||
get renderer() {
|
get canvas() {
|
||||||
return this.#renderer;
|
return this.#canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sizeChanged = new StickyEventEmitter<{ width: number; height: number }>();
|
#size = new ScrcpyVideoSizeImpl();
|
||||||
get sizeChanged() {
|
|
||||||
return this.#sizeChanged.event;
|
|
||||||
}
|
|
||||||
|
|
||||||
#width: number = 0;
|
|
||||||
get width() {
|
get width() {
|
||||||
return this.#width;
|
return this.#size.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
#height: number = 0;
|
|
||||||
get height() {
|
get height() {
|
||||||
return this.#height;
|
return this.#size.height;
|
||||||
|
}
|
||||||
|
get sizeChanged() {
|
||||||
|
return this.#size.sizeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
#frameRendered = 0;
|
#counter = new PerformanceCounterImpl();
|
||||||
get framesRendered() {
|
get framesDrawn() {
|
||||||
return this.#frameRendered;
|
return this.#counter.framesDrawn;
|
||||||
|
}
|
||||||
|
get framesPresented() {
|
||||||
|
return this.#counter.framesPresented;
|
||||||
}
|
}
|
||||||
|
|
||||||
#frameSkipped = 0;
|
|
||||||
get framesSkipped() {
|
get framesSkipped() {
|
||||||
return this.#frameSkipped;
|
return this.#counter.framesSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pause: PauseControllerImpl;
|
||||||
|
get paused() {
|
||||||
|
return this.#pause.paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
#writable: WritableStream<ScrcpyMediaStreamPacket>;
|
#writable: WritableStream<ScrcpyMediaStreamPacket>;
|
||||||
|
@ -75,118 +73,165 @@ export class TinyH264Decoder implements ScrcpyVideoDecoder {
|
||||||
return this.#writable;
|
return this.#writable;
|
||||||
}
|
}
|
||||||
|
|
||||||
#yuvCanvas: YuvCanvas | undefined;
|
#renderer: YuvCanvas | undefined;
|
||||||
#initializer: PromiseResolver<TinyH264Wrapper> | undefined;
|
#decoder: Promise<TinyH264Wrapper> | undefined;
|
||||||
|
|
||||||
constructor({ canvas }: TinyH264Decoder.Options = {}) {
|
constructor({ canvas }: TinyH264Decoder.Options = {}) {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
this.#renderer = canvas;
|
this.#canvas = canvas;
|
||||||
} else {
|
} else {
|
||||||
this.#renderer = createCanvas();
|
this.#canvas = createCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#writable = new WritableStream<ScrcpyMediaStreamPacket>({
|
this.#renderer = YuvCanvas.attach(this.#canvas, {
|
||||||
write: async (packet) => {
|
// yuv-canvas supports detecting WebGL support by creating a <canvas> itself
|
||||||
switch (packet.type) {
|
// But this doesn't work in Web Worker (with OffscreenCanvas)
|
||||||
case "configuration":
|
// so we implement our own check here
|
||||||
await this.#configure(packet.data);
|
webGL: glIsSupported({
|
||||||
break;
|
// Disallow software rendering.
|
||||||
case "data": {
|
// yuv-canvas also supports 2d canvas
|
||||||
if (!this.#initializer) {
|
// which is faster than software-based WebGL.
|
||||||
throw new Error("Decoder not configured");
|
failIfMajorPerformanceCaveat: true,
|
||||||
}
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const wrapper = await this.#initializer.promise;
|
this.#pause = new PauseControllerImpl(
|
||||||
wrapper.feed(packet.data.slice().buffer);
|
this.#configure,
|
||||||
break;
|
async (packet) => {
|
||||||
}
|
if (!this.#decoder) {
|
||||||
|
throw new Error("Decoder not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TinyH264 decoder doesn't support associating metadata
|
||||||
|
// with each frame's input/output
|
||||||
|
// so skipping frames when resuming from pause is not supported
|
||||||
|
|
||||||
|
const decoder = await this.#decoder;
|
||||||
|
|
||||||
|
// `packet.data` might be from a `BufferCombiner` so we have to copy it using `slice`
|
||||||
|
decoder.feed(packet.data.slice().buffer);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#writable = new WritableStream<ScrcpyMediaStreamPacket>({
|
||||||
|
write: this.#pause.write,
|
||||||
|
// Nothing can be disposed when the stream is aborted/closed
|
||||||
|
// No new frames will arrive, but some frames might still be decoding and/or rendering
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async #configure(data: Uint8Array) {
|
#configure = async ({
|
||||||
this.dispose();
|
data,
|
||||||
|
}: ScrcpyMediaStreamConfigurationPacket): Promise<undefined> => {
|
||||||
|
this.#disposeDecoder();
|
||||||
|
|
||||||
this.#initializer = new PromiseResolver<TinyH264Wrapper>();
|
const resolver = new PromiseResolver<TinyH264Wrapper>();
|
||||||
if (!this.#yuvCanvas) {
|
this.#decoder = resolver.promise;
|
||||||
// yuv-canvas detects WebGL support by creating a <canvas> itself
|
|
||||||
// not working in worker
|
try {
|
||||||
const canvas = createCanvas();
|
const {
|
||||||
const attributes: WebGLContextAttributes = {
|
encodedWidth,
|
||||||
// Disallow software rendering.
|
encodedHeight,
|
||||||
// Other rendering methods are faster than software-based WebGL.
|
croppedWidth,
|
||||||
failIfMajorPerformanceCaveat: true,
|
croppedHeight,
|
||||||
};
|
cropLeft,
|
||||||
const gl =
|
cropTop,
|
||||||
canvas.getContext("webgl2", attributes) ||
|
} = H264.parseConfiguration(data);
|
||||||
canvas.getContext("webgl", attributes);
|
|
||||||
this.#yuvCanvas = YuvCanvas.attach(this.#renderer, {
|
this.#size.setSize(croppedWidth, croppedHeight);
|
||||||
webGL: !!gl,
|
|
||||||
|
// H.264 Baseline profile only supports YUV 420 pixel format
|
||||||
|
// So chroma width/height is each half of video width/height
|
||||||
|
const chromaWidth = encodedWidth / 2;
|
||||||
|
const chromaHeight = encodedHeight / 2;
|
||||||
|
|
||||||
|
// YUVCanvas will set canvas size when format changes
|
||||||
|
const format = YuvBuffer.format({
|
||||||
|
width: encodedWidth,
|
||||||
|
height: encodedHeight,
|
||||||
|
chromaWidth,
|
||||||
|
chromaHeight,
|
||||||
|
cropLeft: cropLeft,
|
||||||
|
cropTop: cropTop,
|
||||||
|
cropWidth: croppedWidth,
|
||||||
|
cropHeight: croppedHeight,
|
||||||
|
displayWidth: croppedWidth,
|
||||||
|
displayHeight: croppedHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const decoder = await createTinyH264Wrapper();
|
||||||
|
|
||||||
|
const uPlaneOffset = encodedWidth * encodedHeight;
|
||||||
|
const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight;
|
||||||
|
decoder.onPictureReady(({ data }) => {
|
||||||
|
const array = new Uint8Array(data);
|
||||||
|
const frame = YuvBuffer.frame(
|
||||||
|
format,
|
||||||
|
YuvBuffer.lumaPlane(format, array, encodedWidth, 0),
|
||||||
|
YuvBuffer.chromaPlane(
|
||||||
|
format,
|
||||||
|
array,
|
||||||
|
chromaWidth,
|
||||||
|
uPlaneOffset,
|
||||||
|
),
|
||||||
|
YuvBuffer.chromaPlane(
|
||||||
|
format,
|
||||||
|
array,
|
||||||
|
chromaWidth,
|
||||||
|
vPlaneOffset,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Can't know if yuv-canvas is dropping frames or not
|
||||||
|
this.#renderer!.drawFrame(frame);
|
||||||
|
this.#counter.increaseFramesDrawn();
|
||||||
|
});
|
||||||
|
|
||||||
|
decoder.feed(data.slice().buffer);
|
||||||
|
|
||||||
|
resolver.resolve(decoder);
|
||||||
|
} catch (e) {
|
||||||
|
resolver.reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.#pause.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): Promise<undefined> {
|
||||||
|
return this.#pause.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only dispose the TinyH264 decoder instance.
|
||||||
|
*
|
||||||
|
* This will be called when re-configuring multiple times,
|
||||||
|
* we don't want to dispose other parts (e.g. `#counter`) on that case
|
||||||
|
*/
|
||||||
|
#disposeDecoder() {
|
||||||
|
if (!this.#decoder) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
this.#decoder
|
||||||
encodedWidth,
|
.then((decoder) => decoder.dispose())
|
||||||
encodedHeight,
|
// NOOP: It's disposed so nobody cares about the error
|
||||||
croppedWidth,
|
.catch(noop);
|
||||||
croppedHeight,
|
this.#decoder = undefined;
|
||||||
cropLeft,
|
|
||||||
cropTop,
|
|
||||||
} = h264ParseConfiguration(data);
|
|
||||||
|
|
||||||
this.#width = croppedWidth;
|
|
||||||
this.#height = croppedHeight;
|
|
||||||
this.#sizeChanged.fire({
|
|
||||||
width: croppedWidth,
|
|
||||||
height: croppedHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// H.264 Baseline profile only supports YUV 420 pixel format
|
|
||||||
// So chroma width/height is each half of video width/height
|
|
||||||
const chromaWidth = encodedWidth / 2;
|
|
||||||
const chromaHeight = encodedHeight / 2;
|
|
||||||
|
|
||||||
// YUVCanvas will set canvas size when format changes
|
|
||||||
const format = YuvBuffer.format({
|
|
||||||
width: encodedWidth,
|
|
||||||
height: encodedHeight,
|
|
||||||
chromaWidth,
|
|
||||||
chromaHeight,
|
|
||||||
cropLeft: cropLeft,
|
|
||||||
cropTop: cropTop,
|
|
||||||
cropWidth: croppedWidth,
|
|
||||||
cropHeight: croppedHeight,
|
|
||||||
displayWidth: croppedWidth,
|
|
||||||
displayHeight: croppedHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapper = await createTinyH264Wrapper();
|
|
||||||
this.#initializer.resolve(wrapper);
|
|
||||||
|
|
||||||
const uPlaneOffset = encodedWidth * encodedHeight;
|
|
||||||
const vPlaneOffset = uPlaneOffset + chromaWidth * chromaHeight;
|
|
||||||
wrapper.onPictureReady(({ data }) => {
|
|
||||||
this.#frameRendered += 1;
|
|
||||||
const array = new Uint8Array(data);
|
|
||||||
const frame = YuvBuffer.frame(
|
|
||||||
format,
|
|
||||||
YuvBuffer.lumaPlane(format, array, encodedWidth, 0),
|
|
||||||
YuvBuffer.chromaPlane(format, array, chromaWidth, uPlaneOffset),
|
|
||||||
YuvBuffer.chromaPlane(format, array, chromaWidth, vPlaneOffset),
|
|
||||||
);
|
|
||||||
this.#yuvCanvas!.drawFrame(frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.feed(data.slice().buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.#initializer?.promise
|
// This class doesn't need to guard against multiple dispose calls
|
||||||
.then((wrapper) => wrapper.dispose())
|
// since most of the logic is already handled in `#pause`
|
||||||
// NOOP: It's disposed so nobody cares about the error
|
this.#pause.dispose();
|
||||||
.catch(noop);
|
|
||||||
this.#initializer = undefined;
|
this.#disposeDecoder();
|
||||||
|
this.#counter.dispose();
|
||||||
|
this.#size.dispose();
|
||||||
|
|
||||||
|
this.#canvas.width = 0;
|
||||||
|
this.#canvas.height = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./decoder.js";
|
export * from "./decoder.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
|
export * from "./utils/index.js";
|
||||||
export * from "./wrapper.js";
|
export * from "./wrapper.js";
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Disposable, Event } from "@yume-chan/event";
|
import type { Disposable } from "@yume-chan/event";
|
||||||
import type {
|
import type {
|
||||||
ScrcpyMediaStreamPacket,
|
ScrcpyMediaStreamPacket,
|
||||||
ScrcpyVideoCodecId,
|
ScrcpyVideoCodecId,
|
||||||
|
ScrcpyVideoSize,
|
||||||
} from "@yume-chan/scrcpy";
|
} from "@yume-chan/scrcpy";
|
||||||
import type { WritableStream } from "@yume-chan/stream-extra";
|
import type { WritableStream } from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
|
@ -10,14 +11,37 @@ export interface ScrcpyVideoDecoderCapability {
|
||||||
maxLevel?: number;
|
maxLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScrcpyVideoDecoder extends Disposable {
|
export interface ScrcpyVideoDecoderPerformanceCounter {
|
||||||
readonly sizeChanged: Event<{ width: number; height: number }>;
|
/**
|
||||||
readonly width: number;
|
* Gets the number of frames that have been drawn on the renderer
|
||||||
readonly height: number;
|
*/
|
||||||
|
readonly framesDrawn: number;
|
||||||
readonly framesRendered: number;
|
/**
|
||||||
|
* Gets the number of frames that's visible to the user
|
||||||
|
*
|
||||||
|
* Might be `0` if the renderer is in a nested Web Worker on Chrome due to a Chrome bug.
|
||||||
|
* https://issues.chromium.org/issues/41483010
|
||||||
|
*/
|
||||||
|
readonly framesPresented: number;
|
||||||
|
/**
|
||||||
|
* Gets the number of frames that wasn't drawn on the renderer
|
||||||
|
* because the renderer can't keep up
|
||||||
|
*/
|
||||||
readonly framesSkipped: number;
|
readonly framesSkipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrcpyVideoDecoderPauseController {
|
||||||
|
readonly paused: boolean;
|
||||||
|
|
||||||
|
pause(): void;
|
||||||
|
resume(): Promise<undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrcpyVideoDecoder
|
||||||
|
extends ScrcpyVideoDecoderPerformanceCounter,
|
||||||
|
ScrcpyVideoDecoderPauseController,
|
||||||
|
ScrcpyVideoSize,
|
||||||
|
Disposable {
|
||||||
readonly writable: WritableStream<ScrcpyMediaStreamPacket>;
|
readonly writable: WritableStream<ScrcpyMediaStreamPacket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
78
libraries/scrcpy-decoder-tinyh264/src/utils/gl.ts
Normal file
78
libraries/scrcpy-decoder-tinyh264/src/utils/gl.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
export function createCanvas() {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
return document.createElement("canvas");
|
||||||
|
}
|
||||||
|
if (typeof OffscreenCanvas !== "undefined") {
|
||||||
|
return new OffscreenCanvas(1, 1);
|
||||||
|
}
|
||||||
|
throw new Error("no canvas input found nor any canvas can be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glCreateContext(
|
||||||
|
canvas: HTMLCanvasElement | OffscreenCanvas,
|
||||||
|
attributes?: WebGLContextAttributes,
|
||||||
|
): WebGLRenderingContext | WebGL2RenderingContext | null {
|
||||||
|
// `HTMLCanvasElement.getContext` returns `null` for unsupported `contextId`,
|
||||||
|
// but `OffscreenCanvas.getContext` will throw an error,
|
||||||
|
// so `try...catch...` is required
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = canvas.getContext(
|
||||||
|
"webgl2",
|
||||||
|
attributes,
|
||||||
|
) as WebGL2RenderingContext | null;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = canvas.getContext(
|
||||||
|
"webgl",
|
||||||
|
attributes,
|
||||||
|
) as WebGLRenderingContext | null;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support very old browsers just in case
|
||||||
|
// `OffscreenCanvas` doesn't support `experimental-webgl`
|
||||||
|
if (canvas instanceof HTMLCanvasElement) {
|
||||||
|
const context = canvas.getContext(
|
||||||
|
"experimental-webgl",
|
||||||
|
attributes,
|
||||||
|
) as WebGLRenderingContext | null;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glLoseContext(context: WebGLRenderingContext) {
|
||||||
|
try {
|
||||||
|
context.getExtension("WEBGL_lose_context")?.loseContext();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glIsSupported(attributes?: WebGLContextAttributes): boolean {
|
||||||
|
const canvas = createCanvas();
|
||||||
|
|
||||||
|
const gl = glCreateContext(canvas, attributes);
|
||||||
|
if (gl) {
|
||||||
|
glLoseContext(gl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!gl;
|
||||||
|
}
|
3
libraries/scrcpy-decoder-tinyh264/src/utils/index.ts
Normal file
3
libraries/scrcpy-decoder-tinyh264/src/utils/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./gl.js";
|
||||||
|
export * from "./pause.js";
|
||||||
|
export * from "./performance.js";
|
167
libraries/scrcpy-decoder-tinyh264/src/utils/pause.ts
Normal file
167
libraries/scrcpy-decoder-tinyh264/src/utils/pause.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import type { MaybePromiseLike } from "@yume-chan/async";
|
||||||
|
import { PromiseResolver } from "@yume-chan/async";
|
||||||
|
import type {
|
||||||
|
ScrcpyMediaStreamConfigurationPacket,
|
||||||
|
ScrcpyMediaStreamDataPacket,
|
||||||
|
ScrcpyMediaStreamPacket,
|
||||||
|
} from "@yume-chan/scrcpy";
|
||||||
|
|
||||||
|
import type { ScrcpyVideoDecoderPauseController } from "../types.js";
|
||||||
|
|
||||||
|
export class PauseControllerImpl implements ScrcpyVideoDecoderPauseController {
|
||||||
|
#paused = false;
|
||||||
|
get paused() {
|
||||||
|
return this.#paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onConfiguration: (
|
||||||
|
packet: ScrcpyMediaStreamConfigurationPacket,
|
||||||
|
) => MaybePromiseLike<undefined>;
|
||||||
|
#onFrame: (
|
||||||
|
packet: ScrcpyMediaStreamDataPacket,
|
||||||
|
skipRendering: boolean,
|
||||||
|
) => MaybePromiseLike<undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store incoming configuration change when paused,
|
||||||
|
* to recreate the decoder on resume
|
||||||
|
*/
|
||||||
|
#pendingConfiguration: ScrcpyMediaStreamConfigurationPacket | undefined;
|
||||||
|
/**
|
||||||
|
* Store incoming frames when paused, so the latest frame can be rendered on resume
|
||||||
|
* Because non-key frames require their previous frames to be decoded,
|
||||||
|
* we need to store several frames.
|
||||||
|
*
|
||||||
|
* There can be two situations:
|
||||||
|
*
|
||||||
|
* 1. **All pending frames are non-key frames:**
|
||||||
|
* the decoder still holds the previous frames to decode them directly.
|
||||||
|
*
|
||||||
|
* 2. **A keyframe is encountered while pausing:**
|
||||||
|
* The list is cleared before pushing the keyframe.
|
||||||
|
* The decoder can start decoding from the keyframe directly.
|
||||||
|
*/
|
||||||
|
#pendingFrames: ScrcpyMediaStreamDataPacket[] = [];
|
||||||
|
|
||||||
|
/** Block incoming frames while resuming */
|
||||||
|
#resuming: Promise<undefined> | undefined;
|
||||||
|
|
||||||
|
#disposed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
onConfiguration: (
|
||||||
|
packet: ScrcpyMediaStreamConfigurationPacket,
|
||||||
|
) => MaybePromiseLike<undefined>,
|
||||||
|
onFrame: (
|
||||||
|
packet: ScrcpyMediaStreamDataPacket,
|
||||||
|
skipRendering: boolean,
|
||||||
|
) => MaybePromiseLike<undefined>,
|
||||||
|
) {
|
||||||
|
this.#onConfiguration = onConfiguration;
|
||||||
|
this.#onFrame = onFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
write = async (packet: ScrcpyMediaStreamPacket): Promise<undefined> => {
|
||||||
|
if (this.#disposed) {
|
||||||
|
throw new Error("Attempt to write to a closed decoder");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#paused) {
|
||||||
|
switch (packet.type) {
|
||||||
|
case "configuration":
|
||||||
|
this.#pendingConfiguration = packet;
|
||||||
|
this.#pendingFrames.length = 0;
|
||||||
|
break;
|
||||||
|
case "data":
|
||||||
|
if (packet.keyframe) {
|
||||||
|
this.#pendingFrames.length = 0;
|
||||||
|
}
|
||||||
|
// Generally there won't be too many non-key frames
|
||||||
|
// (because that's bad for video quality),
|
||||||
|
// Also all frames are required for proper decoding
|
||||||
|
this.#pendingFrames.push(packet);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#resuming;
|
||||||
|
|
||||||
|
if (this.#disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (packet.type) {
|
||||||
|
case "configuration":
|
||||||
|
await this.#onConfiguration(packet);
|
||||||
|
break;
|
||||||
|
case "data":
|
||||||
|
await this.#onFrame(packet, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
if (this.#disposed) {
|
||||||
|
throw new Error("Attempt to pause a closed decoder");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#paused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<undefined> {
|
||||||
|
if (this.#disposed) {
|
||||||
|
throw new Error("Attempt to resume a closed decoder");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.#paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolver = new PromiseResolver<undefined>();
|
||||||
|
this.#resuming = resolver.promise;
|
||||||
|
|
||||||
|
this.#paused = false;
|
||||||
|
|
||||||
|
if (this.#pendingConfiguration) {
|
||||||
|
await this.#onConfiguration(this.#pendingConfiguration);
|
||||||
|
this.#pendingConfiguration = undefined;
|
||||||
|
|
||||||
|
if (this.#disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0, length = this.#pendingFrames.length;
|
||||||
|
i < length;
|
||||||
|
i += 1
|
||||||
|
) {
|
||||||
|
const frame = this.#pendingFrames[i]!;
|
||||||
|
// All pending frames except the last one don't need to be rendered
|
||||||
|
// because they are decoded in quick succession by the decoder
|
||||||
|
// and won't be visible
|
||||||
|
await this.#onFrame(frame, i !== length - 1);
|
||||||
|
|
||||||
|
if (this.#disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#pendingFrames.length = 0;
|
||||||
|
|
||||||
|
resolver.resolve(undefined);
|
||||||
|
this.#resuming = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.#disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#disposed = true;
|
||||||
|
|
||||||
|
this.#pendingConfiguration = undefined;
|
||||||
|
this.#pendingFrames.length = 0;
|
||||||
|
}
|
||||||
|
}
|
66
libraries/scrcpy-decoder-tinyh264/src/utils/performance.ts
Normal file
66
libraries/scrcpy-decoder-tinyh264/src/utils/performance.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import type { ScrcpyVideoDecoderPerformanceCounter } from "../types.js";
|
||||||
|
|
||||||
|
export class PerformanceCounterImpl
|
||||||
|
implements ScrcpyVideoDecoderPerformanceCounter
|
||||||
|
{
|
||||||
|
#framesDrawn = 0;
|
||||||
|
get framesDrawn() {
|
||||||
|
return this.#framesDrawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#framesPresented = 0;
|
||||||
|
get framesPresented() {
|
||||||
|
return this.#framesPresented;
|
||||||
|
}
|
||||||
|
|
||||||
|
#framesSkipped = 0;
|
||||||
|
get framesSkipped() {
|
||||||
|
return this.#framesSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
#animationFrameId: number | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// `requestAnimationFrame` is available in Web Worker
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame
|
||||||
|
try {
|
||||||
|
this.#animationFrameId = requestAnimationFrame(
|
||||||
|
this.#handleAnimationFrame,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Chrome has a bug that `requestAnimationFrame` doesn't work in nested Workers
|
||||||
|
// https://issues.chromium.org/issues/41483010
|
||||||
|
// Because we need actual vertical sync to count presented frames,
|
||||||
|
// `setTimeout` with a fixed delay also doesn't work.
|
||||||
|
// In this case just leave `framesPresented` at `0`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleAnimationFrame = () => {
|
||||||
|
// Animation frame handler is called on every vertical sync interval.
|
||||||
|
// Only then a frame is visible to the user.
|
||||||
|
if (this.#framesDrawn > 0) {
|
||||||
|
this.#framesPresented += 1;
|
||||||
|
this.#framesDrawn = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#animationFrameId = requestAnimationFrame(
|
||||||
|
this.#handleAnimationFrame,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
increaseFramesSkipped() {
|
||||||
|
this.#framesSkipped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
increaseFramesDrawn() {
|
||||||
|
this.#framesDrawn += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
// `0` is a valid value for RAF ID
|
||||||
|
if (this.#animationFrameId !== undefined) {
|
||||||
|
cancelAnimationFrame(this.#animationFrameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue