feat(bin): add wrapper for bu

fixes #354
This commit is contained in:
Simon Chan 2023-07-07 18:30:01 +08:00
parent 721e14fa7a
commit 2abec924e8
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
12 changed files with 587 additions and 103 deletions

View file

@ -1,11 +1,7 @@
module.exports = { module.exports = {
"extends": [ extends: ["@yume-chan"],
"@yume-chan"
],
parserOptions: { parserOptions: {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
project: [ project: ["./tsconfig.test.json"],
"./tsconfig.build.json"
],
}, },
} };

View file

@ -0,0 +1,14 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{ tsconfig: "tsconfig.test.json", useESM: true },
],
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};

View file

@ -27,6 +27,7 @@
"scripts": { "scripts": {
"build": "tsc -b tsconfig.build.json", "build": "tsc -b tsconfig.build.json",
"build:watch": "tsc -b tsconfig.build.json", "build:watch": "tsc -b tsconfig.build.json",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"lint": "eslint src/**/*.ts --fix && prettier src/**/*.ts --write --tab-width 4", "lint": "eslint src/**/*.ts --fix && prettier src/**/*.ts --write --tab-width 4",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
@ -34,13 +35,17 @@
"@yume-chan/adb": "workspace:^0.0.20", "@yume-chan/adb": "workspace:^0.0.20",
"@yume-chan/stream-extra": "workspace:^0.0.20", "@yume-chan/stream-extra": "workspace:^0.0.20",
"@yume-chan/struct": "workspace:^0.0.20", "@yume-chan/struct": "workspace:^0.0.20",
"tslib": "^2.5.2" "tslib": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.6.1",
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",
"@yume-chan/tsconfig": "workspace:^1.0.0", "@yume-chan/tsconfig": "workspace:^1.0.0",
"eslint": "^8.41.0", "cross-env": "^7.0.3",
"prettier": "^2.8.8", "eslint": "^8.44.0",
"typescript": "^5.0.3" "jest": "^29.5.0",
"prettier": "^3.0.0",
"ts-jest": "^29.1.1",
"typescript": "^5.1.6"
} }
} }

View file

@ -1,26 +1,74 @@
// cspell: ignore apks
// cspell: ignore obbs
import { AdbCommandBase } from "@yume-chan/adb"; import { AdbCommandBase } from "@yume-chan/adb";
import type { Consumable, ReadableStream } from "@yume-chan/stream-extra";
export interface AdbBackupOptions { export interface AdbBackupOptions {
apps: string[] | "all" | "all-including-system";
apks: boolean;
obbs: boolean;
shared: boolean;
widgets: boolean;
compress: boolean;
user: number; user: number;
saveSharedStorage?: boolean;
saveWidgets?: boolean;
packages: string[] | "user" | "all";
savePackageApk: boolean;
savePackageObb: boolean;
savePackageKeyValue: boolean;
compress: boolean;
}
export interface AdbRestoreOptions {
user: number;
file: ReadableStream<Consumable<Uint8Array>>;
} }
export class AdbBackup extends AdbCommandBase { export class AdbBackup extends AdbCommandBase {
backup(options: AdbBackupOptions): Promise<void> { /**
void options; * User must confirm backup on device within 60 seconds.
throw new Error("Not implemented"); */
public async backup(
options: AdbBackupOptions
): Promise<ReadableStream<Uint8Array>> {
const args = ["bu", "backup"];
if (options.user !== undefined) {
args.push("--user", options.user.toString());
} }
restore(options: AdbBackupOptions): Promise<void> { args.push(options.saveSharedStorage ? "--shared" : "--no-shared");
void options; args.push(options.saveWidgets ? "--widgets" : "--no-widgets");
throw new Error("Not implemented");
args.push(options.savePackageApk ? "--apk" : "--no-apk");
args.push(options.savePackageObb ? "--obb" : "--no-obb");
args.push(
options.savePackageKeyValue ? "--key-value" : "--no-key-value"
);
args.push(options.compress ? "--compress" : "--no-compress");
if (typeof options.packages === "string") {
switch (options.packages) {
case "user":
args.push("--all", "--no-system");
break;
case "all":
args.push("--all", "--system");
break;
}
} else {
args.push(...options.packages);
}
const process = await this.adb.subprocess.spawn(args);
return process.stdout;
}
/**
* User must enter the password (if any) and
* confirm restore on device within 60 seconds.
*/
public async restore(options: AdbRestoreOptions): Promise<void> {
const args = ["bu", "restore"];
if (options.user !== undefined) {
args.push("--user", options.user.toString());
}
const process = await this.adb.subprocess.spawn(args);
await options.file.pipeTo(process.stdin);
await process.exit;
} }
} }

View file

@ -1,7 +1,7 @@
import { AdbCommandBase } from "@yume-chan/adb"; import { AdbCommandBase } from "@yume-chan/adb";
export class DumpSys extends AdbCommandBase { export class DumpSys extends AdbCommandBase {
async diskStats() { public async diskStats() {
const output = await this.adb.subprocess.spawnAndWaitLegacy([ const output = await this.adb.subprocess.spawnAndWaitLegacy([
"dumpsys", "dumpsys",
"diskstats", "diskstats",
@ -33,4 +33,59 @@ export class DumpSys extends AdbCommandBase {
systemTotal, systemTotal,
}; };
} }
public async battery() {
const output = await this.adb.subprocess.spawnAndWaitLegacy([
"dumpsys",
"battery",
]);
let acPowered = false;
let usbPowered = false;
let wirelessPowered = false;
let level: number | undefined;
let scale: number | undefined;
let voltage: number | undefined;
let current: number | undefined;
for (const line of output) {
const parts = line.split(":");
if (parts.length !== 2) {
continue;
}
switch (parts[0]!.trim()) {
case "AC powered":
acPowered = parts[1]!.trim() === "true";
break;
case "USB powered":
usbPowered = parts[1]!.trim() === "true";
break;
case "Wireless powered":
wirelessPowered = parts[1]!.trim() === "true";
break;
case "level":
level = Number.parseInt(parts[1]!.trim(), 10);
break;
case "scale":
scale = Number.parseInt(parts[1]!.trim(), 10);
break;
case "voltage":
voltage = Number.parseInt(parts[1]!.trim(), 10);
break;
case "current now":
current = Number.parseInt(parts[1]!.trim(), 10);
break;
}
}
return {
acPowered,
usbPowered,
wirelessPowered,
level,
scale,
voltage,
current,
};
}
} }

View file

@ -1,8 +1,10 @@
// cspell: ignore logcat // cspell: ignore logcat
export * from "./bu.js";
export * from "./bug-report.js"; export * from "./bug-report.js";
export * from "./cmd.js"; export * from "./cmd.js";
export * from "./demo-mode.js"; export * from "./demo-mode.js";
export * from "./dumpsys.js";
export * from "./logcat.js"; export * from "./logcat.js";
export * from "./overlay-display.js"; export * from "./overlay-display.js";
export * from "./pm.js"; export * from "./pm.js";

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from "@jest/globals";
import { OverlayDisplay } from "./overlay-display.js";
describe("OverlayDisplay", () => {
describe("OverlayDisplayDevicesFormat", () => {
// values are from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/packages/SettingsLib/res/values/arrays.xml;l=468;drc=60c1d392225bc6e1601693c7d5cfdf1d7f510015
it("should parse 0 device", () => {
expect(
OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: "",
position: 0,
}),
).toEqual([]);
});
it("should parse 1 mode", () => {
expect(
OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: "720x480/142",
position: 0,
}),
).toEqual([
{
flags: [],
modes: [
{
density: 142,
height: 480,
width: 720,
},
],
},
]);
});
it("should parse 2 modes", () => {
expect(
OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: "1920x1080/320|3840x2160/640",
position: 0,
}),
).toEqual([
{
flags: [],
modes: [
{
density: 320,
height: 1080,
width: 1920,
},
{
density: 640,
height: 2160,
width: 3840,
},
],
},
]);
});
it("should parse 2 device", () => {
expect(
OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: "1280x720/213;1920x1080/320",
position: 0,
}),
).toEqual([
{
flags: [],
modes: [
{
density: 213,
height: 720,
width: 1280,
},
],
},
{
flags: [],
modes: [
{
density: 320,
height: 1080,
width: 1920,
},
],
},
]);
});
it("should parse flags", () => {
expect(
OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: "1920x1080/320|3840x2160/640,secure",
position: 0,
}),
).toEqual([
{
flags: ["secure"],
modes: [
{
density: 320,
height: 1080,
width: 1920,
},
{
density: 640,
height: 2160,
width: 3840,
},
],
},
]);
});
});
});

View file

@ -2,6 +2,7 @@ import type { Adb } from "@yume-chan/adb";
import { AdbCommandBase } from "@yume-chan/adb"; import { AdbCommandBase } from "@yume-chan/adb";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
import { p } from "./string-format.js";
export interface OverlayDisplayDeviceMode { export interface OverlayDisplayDeviceMode {
width: number; width: number;
@ -22,94 +23,91 @@ export class OverlayDisplay extends AdbCommandBase {
public static readonly OVERLAY_DISPLAY_DEVICES_KEY = public static readonly OVERLAY_DISPLAY_DEVICES_KEY =
"overlay_display_devices"; "overlay_display_devices";
public static readonly OverlayDisplayDevicesFormat = p.separated(
";",
p.sequence(
{
name: "modes",
format: p.separated(
"|",
p.sequence(
{ name: "width", format: p.digits() },
p.literal("x"),
{ name: "height", format: p.digits() },
p.literal("/"),
{ name: "density", format: p.digits() }
),
1
),
},
{
name: "flags",
format: p.map(
p.repeated(
p.sequence(p.literal(","), {
name: "flag",
format: p.union(
p.literal("secure"),
p.literal("own_content_only"),
p.literal("show_system_decorations")
),
})
),
(value) => value.map((item) => item.flag),
(value) => value.map((item) => ({ flag: item }))
),
}
)
);
constructor(adb: Adb) { constructor(adb: Adb) {
super(adb); super(adb);
this.settings = new Settings(adb); this.settings = new Settings(adb);
} }
public async get() { public async get() {
const devices: OverlayDisplayDevice[] = []; return OverlayDisplay.OverlayDisplayDevicesFormat.parse({
value: await this.settings.get(
const settingString = await this.settings.get(
"global", "global",
OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY
); ),
position: 0,
for (const displayString of settingString.split(";")) { }).map((device) => ({
const [modesString, ...flagStrings] = displayString.split(","); modes: device.modes,
secure: device.flags.includes("secure"),
if (!modesString) { ownContentOnly: device.flags.includes("own_content_only"),
continue; showSystemDecorations: device.flags.includes(
} "show_system_decorations"
),
const device: OverlayDisplayDevice = { }));
modes: [],
secure: false,
ownContentOnly: false,
showSystemDecorations: false,
};
for (const modeString of modesString.split("|")) {
const match = modeString.match(/(\d+)x(\d+)\/(\d+)/);
if (!match) {
continue;
}
device.modes.push({
width: parseInt(match[1]!, 10),
height: parseInt(match[2]!, 10),
density: parseInt(match[3]!, 10),
});
}
if (device.modes.length === 0) {
continue;
}
for (const flagString of flagStrings) {
switch (flagString) {
case "secure":
device.secure = true;
break;
case "own_content_only":
device.ownContentOnly = true;
break;
case "show_system_decorations":
device.showSystemDecorations = true;
break;
}
}
devices.push(device);
}
return devices;
} }
public async set(devices: OverlayDisplayDevice[]) { public async set(devices: OverlayDisplayDevice[]) {
let settingString = "";
for (const device of devices) {
if (settingString) {
settingString += ";";
}
settingString += device.modes
.map((mode) => `${mode.width}x${mode.height}/${mode.density}`)
.join("|");
if (device.secure) {
settingString += ",secure";
}
if (device.ownContentOnly) {
settingString += ",own_content_only";
}
if (device.showSystemDecorations) {
settingString += ",show_system_decorations";
}
}
await this.settings.put( await this.settings.put(
"global", "global",
OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY, OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY,
settingString OverlayDisplay.OverlayDisplayDevicesFormat.stringify(
devices.map((device) => {
const flags: (
| "secure"
| "own_content_only"
| "show_system_decorations"
)[] = [];
if (device.secure) {
flags.push("secure");
}
if (device.ownContentOnly) {
flags.push("own_content_only");
}
if (device.showSystemDecorations) {
flags.push("show_system_decorations");
}
return {
modes: device.modes,
flags,
};
})
)
); );
} }
} }

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "@jest/globals";
import type { Reader } from "./string-format.js";
import { ParseError, p } from "./string-format.js";
describe("StringFormat", () => {
describe("digits", () => {
it("should match 0-9", () => {
const reader: Reader = { value: "1234567890", position: 0 };
expect(p.digits().parse(reader)).toBe(1234567890);
});
const digitsError = new ParseError("0123456789".split(""));
it("should throw if input is empty", () => {
const reader: Reader = { value: "", position: 0 };
expect(() => p.digits().parse(reader)).toThrowError(digitsError);
});
it("should throw error if not 0-9", () => {
const reader: Reader = { value: "a", position: 0 };
expect(() => p.digits().parse(reader)).toThrowError(digitsError);
});
});
});

View file

@ -0,0 +1,213 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
export class ParseError extends Error {
#expected: string[];
get expected(): string[] {
return this.#expected;
}
public constructor(expected: string[]) {
super(`Expected ${expected.join(", ")}`);
this.#expected = expected;
}
}
export interface Reader {
value: string;
position: number;
}
export interface Format<T> {
parse(reader: Reader): T;
stringify(value: T): string;
}
type UnionResult<T extends readonly Format<unknown>[]> = Exclude<
{
[K in keyof T]: T[K] extends Format<infer F> ? F : never;
}[number],
undefined
>;
type UnionToIntersection<T> = (
T extends unknown ? (x: T) => void : never
) extends (x: infer R) => void
? R
: never;
type SequenceResult<
T extends readonly (
| Format<unknown>
| { name: string; format: Format<unknown> }
)[]
> = UnionToIntersection<
{
[K in keyof T]: T[K] extends {
name: string;
format: Format<unknown>;
}
? Record<
T[K]["name"],
T[K]["format"] extends Format<infer F>
? Exclude<F, undefined>
: never
>
: never;
}[number]
>;
type Evaluate<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
export const p = {
literal: <T extends string>(value: T): Format<T> => ({
parse(reader) {
if (!reader.value.startsWith(value, reader.position)) {
throw new ParseError([value.charAt(0)]);
}
reader.position += value.length;
return value;
},
stringify() {
return value;
},
}),
digits: (): Format<number> => ({
parse(reader) {
const match = reader.value.substring(reader.position).match(/^\d+/);
if (!match) {
throw new ParseError("0123456789".split(""));
}
reader.position += match[0].length;
return Number.parseInt(match[0], 10);
},
stringify(value) {
return value.toString();
},
}),
union: <const T extends readonly Format<unknown>[]>(
...args: T
): Format<UnionResult<T>> => ({
parse(reader) {
const expected: string[] = [];
for (const format of args) {
try {
return format.parse(reader) as UnionResult<T>;
} catch (e) {
if (e instanceof ParseError) {
expected.push(...e.expected);
} else {
throw e;
}
}
}
throw new ParseError(expected);
},
stringify(value) {
for (const format of args) {
try {
const result = format.stringify(value);
// Parse the result to make sure it is valid
format.parse({ value: result, position: 0 });
} catch (e) {
// ignore
}
}
throw new Error("No format matches");
},
}),
separated: <T>(
separator: string,
format: Format<T>,
min = 0,
max = Infinity
): Format<T[]> => ({
parse(reader: Reader) {
const result: T[] = [];
while (true) {
try {
result.push(format.parse(reader));
if (result.length === max) {
break;
}
} catch (e) {
if (result.length < min) {
throw e;
} else {
break;
}
}
if (reader.value.startsWith(separator, reader.position)) {
reader.position += separator.length;
} else if (result.length < min) {
throw new ParseError([separator.charAt(0)]);
} else {
break;
}
}
return result;
},
stringify(value) {
return value.map((item) => format.stringify(item)).join(separator);
},
}),
repeated: <T>(format: Format<T>): Format<T[]> => ({
parse(reader: Reader) {
const result: T[] = [];
while (true) {
try {
result.push(format.parse(reader));
} catch (e) {
break;
}
}
return result;
},
stringify(value: T[]) {
return value.map((item) => format.stringify(item)).join("");
},
}),
sequence: <
const T extends readonly (
| Format<unknown>
| { name: string; format: Format<unknown> }
)[]
>(
...args: T
): Format<Evaluate<SequenceResult<T>>> => ({
parse(reader: Reader) {
const result: Record<string, unknown> = {};
for (const part of args) {
if ("name" in part) {
result[part.name] = part.format.parse(reader);
} else {
void part.parse(reader);
}
}
return result as Evaluate<SequenceResult<T>>;
},
stringify: (value: Evaluate<SequenceResult<T>>) => {
let result = "";
for (const part of args) {
if ("name" in part) {
result += part.format.stringify(
value[part.name as keyof typeof value]
);
}
}
return result;
},
}),
map: <T, R>(
format: Format<T>,
map: (value: T) => R,
reverse: (value: R) => T
): Format<R> => ({
parse(reader: Reader) {
return map(format.parse(reader));
},
stringify(value: R) {
return format.stringify(reverse(value));
},
}),
} satisfies Record<string, (...args: never) => Format<unknown>>;

View file

@ -1,5 +1,8 @@
{ {
"references": [ "references": [
{
"path": "./tsconfig.test.json"
},
{ {
"path": "./tsconfig.build.json" "path": "./tsconfig.build.json"
}, },

View file

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