mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-03 09:49:24 +02:00
690 lines
19 KiB
TypeScript
690 lines
19 KiB
TypeScript
// cspell:ignore dont
|
|
// cspell:ignore instantapp
|
|
// cspell:ignore apks
|
|
// cspell:ignore versioncode
|
|
|
|
import type { Adb } from "@yume-chan/adb";
|
|
import { AdbCommandBase, escapeArg } from "@yume-chan/adb";
|
|
import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
|
|
import {
|
|
ConcatStringStream,
|
|
SplitStringStream,
|
|
TextDecoderStream,
|
|
} from "@yume-chan/stream-extra";
|
|
|
|
import { Cmd } from "./cmd.js";
|
|
import type { IntentBuilder } from "./intent.js";
|
|
import type { SingleUserOrAll } from "./utils.js";
|
|
import { buildArguments } from "./utils.js";
|
|
|
|
export enum PackageManagerInstallLocation {
|
|
Auto,
|
|
InternalOnly,
|
|
PreferExternal,
|
|
}
|
|
|
|
export enum PackageManagerInstallReason {
|
|
Unknown,
|
|
AdminPolicy,
|
|
DeviceRestore,
|
|
DeviceSetup,
|
|
UserRequest,
|
|
}
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/pm/PackageManagerShellCommand.java;l=3046;drc=6d14d35d0241f6fee145f8e54ffd77252e8d29fd
|
|
export interface PackageManagerInstallOptions {
|
|
/**
|
|
* `-R`
|
|
*/
|
|
skipExisting: boolean;
|
|
/**
|
|
* `-i`
|
|
*/
|
|
installerPackageName: string;
|
|
/**
|
|
* `-t`
|
|
*/
|
|
allowTest: boolean;
|
|
/**
|
|
* `-f`
|
|
*/
|
|
internalStorage: boolean;
|
|
/**
|
|
* `-d`
|
|
*/
|
|
requestDowngrade: boolean;
|
|
/**
|
|
* `-g`
|
|
*/
|
|
grantRuntimePermissions: boolean;
|
|
/**
|
|
* `--restrict-permissions`
|
|
*/
|
|
restrictPermissions: boolean;
|
|
/**
|
|
* `--dont-kill`
|
|
*/
|
|
doNotKill: boolean;
|
|
/**
|
|
* `--originating-uri`
|
|
*/
|
|
originatingUri: string;
|
|
/**
|
|
* `--referrer`
|
|
*/
|
|
refererUri: string;
|
|
/**
|
|
* `-p`
|
|
*/
|
|
inheritFrom: string;
|
|
/**
|
|
* `--pkg`
|
|
*/
|
|
packageName: string;
|
|
/**
|
|
* `--abi`
|
|
*/
|
|
abi: string;
|
|
/**
|
|
* `--ephemeral`/`--instant`/`--instantapp`
|
|
*/
|
|
instantApp: boolean;
|
|
/**
|
|
* `--full`
|
|
*/
|
|
full: boolean;
|
|
/**
|
|
* `--preload`
|
|
*/
|
|
preload: boolean;
|
|
/**
|
|
* `--user`
|
|
*/
|
|
user: SingleUserOrAll;
|
|
/**
|
|
* `--install-location`
|
|
*/
|
|
installLocation: PackageManagerInstallLocation;
|
|
/**
|
|
* `--install-reason`
|
|
*/
|
|
installReason: PackageManagerInstallReason;
|
|
/**
|
|
* `--force-uuid`
|
|
*/
|
|
forceUuid: string;
|
|
/**
|
|
* `--apex`
|
|
*/
|
|
apex: boolean;
|
|
/**
|
|
* `--force-non-staged`
|
|
*/
|
|
forceNonStaged: boolean;
|
|
/**
|
|
* `--staged`
|
|
*/
|
|
staged: boolean;
|
|
/**
|
|
* `--force-queryable`
|
|
*/
|
|
forceQueryable: boolean;
|
|
/**
|
|
* `--enable-rollback`
|
|
*/
|
|
enableRollback: boolean;
|
|
/**
|
|
* `--staged-ready-timeout`
|
|
*/
|
|
stagedReadyTimeout: number;
|
|
/**
|
|
* `--skip-verification`
|
|
*/
|
|
skipVerification: boolean;
|
|
/**
|
|
* `--bypass-low-target-sdk-block`
|
|
*/
|
|
bypassLowTargetSdkBlock: boolean;
|
|
}
|
|
|
|
export const PACKAGE_MANAGER_INSTALL_OPTIONS_MAP: Record<
|
|
keyof PackageManagerInstallOptions,
|
|
string
|
|
> = {
|
|
skipExisting: "-R",
|
|
installerPackageName: "-i",
|
|
allowTest: "-t",
|
|
internalStorage: "-f",
|
|
requestDowngrade: "-d",
|
|
grantRuntimePermissions: "-g",
|
|
restrictPermissions: "--restrict-permissions",
|
|
doNotKill: "--dont-kill",
|
|
originatingUri: "--originating-uri",
|
|
refererUri: "--referrer",
|
|
inheritFrom: "-p",
|
|
packageName: "--pkg",
|
|
abi: "--abi",
|
|
instantApp: "--instant",
|
|
full: "--full",
|
|
preload: "--preload",
|
|
user: "--user",
|
|
installLocation: "--install-location",
|
|
installReason: "--install-reason",
|
|
forceUuid: "--force-uuid",
|
|
apex: "--apex",
|
|
forceNonStaged: "--force-non-staged",
|
|
staged: "--staged",
|
|
forceQueryable: "--force-queryable",
|
|
enableRollback: "--enable-rollback",
|
|
stagedReadyTimeout: "--staged-ready-timeout",
|
|
skipVerification: "--skip-verification",
|
|
bypassLowTargetSdkBlock: "--bypass-low-target-sdk-block",
|
|
};
|
|
|
|
export interface PackageManagerListPackagesOptions {
|
|
listDisabled: boolean;
|
|
listEnabled: boolean;
|
|
showSourceDir: boolean;
|
|
showInstaller: boolean;
|
|
listSystem: boolean;
|
|
showUid: boolean;
|
|
listThirdParty: boolean;
|
|
showVersionCode: boolean;
|
|
listApexOnly: boolean;
|
|
user: SingleUserOrAll;
|
|
uid: number;
|
|
filter: string;
|
|
}
|
|
|
|
export const PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP: Record<
|
|
keyof PackageManagerListPackagesOptions,
|
|
string
|
|
> = {
|
|
listDisabled: "-d",
|
|
listEnabled: "-e",
|
|
showSourceDir: "-f",
|
|
showInstaller: "-i",
|
|
listSystem: "-s",
|
|
showUid: "-U",
|
|
listThirdParty: "-3",
|
|
showVersionCode: "--show-versioncode",
|
|
listApexOnly: "--apex-only",
|
|
user: "--user",
|
|
uid: "--uid",
|
|
filter: "",
|
|
};
|
|
|
|
export interface PackageManagerListPackagesResult {
|
|
packageName: string;
|
|
sourceDir?: string | undefined;
|
|
versionCode?: number | undefined;
|
|
installer?: string | undefined;
|
|
uid?: number | undefined;
|
|
}
|
|
|
|
export interface PackageManagerUninstallOptions {
|
|
keepData: boolean;
|
|
user: SingleUserOrAll;
|
|
versionCode: number;
|
|
/**
|
|
* Only remove the specified splits, not the entire app
|
|
*
|
|
* On Android 10 and lower, only one split name can be specified.
|
|
*/
|
|
splitNames: string[];
|
|
}
|
|
|
|
const PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP: Record<
|
|
keyof PackageManagerUninstallOptions,
|
|
string
|
|
> = {
|
|
keepData: "-k",
|
|
user: "--user",
|
|
versionCode: "--versionCode",
|
|
splitNames: "",
|
|
};
|
|
|
|
export interface PackageManagerResolveActivityOptions {
|
|
user?: SingleUserOrAll;
|
|
intent: IntentBuilder;
|
|
}
|
|
|
|
const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
|
|
Record<keyof PackageManagerResolveActivityOptions, string>
|
|
> = {
|
|
user: "--user",
|
|
};
|
|
|
|
function buildInstallArguments(
|
|
command: string,
|
|
options: Partial<PackageManagerInstallOptions> | undefined,
|
|
): string[] {
|
|
const args = buildArguments(
|
|
["pm", command],
|
|
options,
|
|
PACKAGE_MANAGER_INSTALL_OPTIONS_MAP,
|
|
);
|
|
if (!options?.skipExisting) {
|
|
/*
|
|
* | behavior | previous version | modern version |
|
|
* | -------------------- | -------------------- | -------------------- |
|
|
* | replace existing app | requires `-r` | default behavior [1] |
|
|
* | skip existing app | default behavior [2] | requires `-R` |
|
|
*
|
|
* [1]: `-r` recognized but ignored
|
|
* [2]: `-R` not recognized but ignored
|
|
*
|
|
* So add `-r` when `skipExisting` is `false` for compatibility.
|
|
*/
|
|
args.push("-r");
|
|
}
|
|
return args;
|
|
}
|
|
|
|
export class PackageManager extends AdbCommandBase {
|
|
#cmd: Cmd;
|
|
|
|
constructor(adb: Adb) {
|
|
super(adb);
|
|
this.#cmd = new Cmd(adb);
|
|
}
|
|
|
|
/**
|
|
* Install the apk file.
|
|
*
|
|
* @param apks Path to the apk file. It must exist on the device. On Android 10 and lower, only one apk can be specified.
|
|
*/
|
|
async install(
|
|
apks: string[],
|
|
options?: Partial<PackageManagerInstallOptions>,
|
|
): Promise<string> {
|
|
const args = buildInstallArguments("install", options);
|
|
// WIP: old version of pm doesn't support multiple apks
|
|
args.push(...apks);
|
|
return await this.adb.subprocess.spawnAndWaitLegacy(args);
|
|
}
|
|
|
|
async pushAndInstallStream(
|
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
|
options?: Partial<PackageManagerInstallOptions>,
|
|
): Promise<void> {
|
|
const sync = await this.adb.sync();
|
|
|
|
const fileName = Math.random().toString().substring(2);
|
|
const filePath = `/data/local/tmp/${fileName}.apk`;
|
|
|
|
try {
|
|
await sync.write({
|
|
filename: filePath,
|
|
file: stream,
|
|
});
|
|
} finally {
|
|
await sync.dispose();
|
|
}
|
|
|
|
// Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
|
|
// The benefit of `cmd package` is it starts faster than the old `pm`,
|
|
// because it connects to the already running `system` process,
|
|
// instead of initializing all system components from scratch.
|
|
//
|
|
// But launching `cmd package` directly causes it to not be able to
|
|
// read files in `/data/local/tmp` (and many other places) due to SELinux policies,
|
|
// so installing files must still use `pm`.
|
|
// (the starting executable file decides which SELinux policies to apply)
|
|
const args = buildInstallArguments("install", options);
|
|
args.push(filePath);
|
|
|
|
try {
|
|
const output = await this.adb.subprocess
|
|
.spawnAndWaitLegacy(args.map(escapeArg))
|
|
.then((output) => output.trim());
|
|
|
|
if (output !== "Success") {
|
|
throw new Error(output);
|
|
}
|
|
} finally {
|
|
await this.adb.rm(filePath);
|
|
}
|
|
}
|
|
|
|
async installStream(
|
|
size: number,
|
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
|
options?: Partial<PackageManagerInstallOptions>,
|
|
): Promise<void> {
|
|
// Android 7 added both `cmd` command and streaming install support,
|
|
// It's hard to detect whether `pm` supports streaming install (unless actually trying),
|
|
// so check for whether `cmd` is supported,
|
|
// and assume `pm` streaming install support status is same as that.
|
|
if (!this.#cmd.supportsCmd) {
|
|
// Fall back to push file then install
|
|
await this.pushAndInstallStream(stream, options);
|
|
return;
|
|
}
|
|
|
|
const args = buildInstallArguments("install", options);
|
|
// Remove `pm` from args, `Cmd#spawn` will prepend `cmd <command>` so the final args
|
|
// will be `cmd package install <args>`
|
|
args.shift();
|
|
args.push("-S", size.toString());
|
|
const process = await this.#cmd.spawn(false, "package", ...args);
|
|
|
|
const output = process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new ConcatStringStream())
|
|
.then((output) => output.trim());
|
|
|
|
await Promise.all([
|
|
stream.pipeTo(process.stdin),
|
|
output.then((output) => {
|
|
if (output !== "Success") {
|
|
throw new Error(output);
|
|
}
|
|
}),
|
|
]);
|
|
}
|
|
|
|
static parsePackageListItem(
|
|
line: string,
|
|
): PackageManagerListPackagesResult {
|
|
line = line.substring("package:".length);
|
|
|
|
let packageName: string;
|
|
let sourceDir: string | undefined;
|
|
let versionCode: number | undefined;
|
|
let installer: string | undefined;
|
|
let uid: number | undefined;
|
|
|
|
// The output format is easier to parse in backwards
|
|
let index = line.indexOf(" uid:");
|
|
if (index !== -1) {
|
|
uid = Number.parseInt(line.substring(index + " uid:".length), 10);
|
|
line = line.substring(0, index);
|
|
}
|
|
|
|
index = line.indexOf(" installer=");
|
|
if (index !== -1) {
|
|
installer = line.substring(index + " installer=".length);
|
|
line = line.substring(0, index);
|
|
}
|
|
|
|
index = line.indexOf(" versionCode:");
|
|
if (index !== -1) {
|
|
versionCode = Number.parseInt(
|
|
line.substring(index + " versionCode:".length),
|
|
10,
|
|
);
|
|
line = line.substring(0, index);
|
|
}
|
|
|
|
// `sourceDir` may contain `=` characters
|
|
// (because in newer Android versions it's a base64 string of encrypted package name),
|
|
// so use `lastIndexOf`
|
|
index = line.lastIndexOf("=");
|
|
if (index !== -1) {
|
|
sourceDir = line.substring(0, index);
|
|
packageName = line.substring(index + "=".length);
|
|
} else {
|
|
packageName = line;
|
|
}
|
|
|
|
return {
|
|
packageName,
|
|
sourceDir,
|
|
versionCode,
|
|
installer,
|
|
uid,
|
|
};
|
|
}
|
|
|
|
async #cmdOrSubprocess(args: string[]) {
|
|
if (this.#cmd.supportsCmd) {
|
|
args.shift();
|
|
return await this.#cmd.spawn(false, "package", ...args);
|
|
}
|
|
|
|
return this.adb.subprocess.spawn(args);
|
|
}
|
|
|
|
async *listPackages(
|
|
options?: Partial<PackageManagerListPackagesOptions>,
|
|
): AsyncGenerator<PackageManagerListPackagesResult, void, void> {
|
|
const args = buildArguments(
|
|
["pm", "list", "packages"],
|
|
options,
|
|
PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP,
|
|
);
|
|
if (options?.filter) {
|
|
args.push(options.filter);
|
|
}
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const reader = process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
// FIXME: `SplitStringStream` will throw away some data
|
|
// if it doesn't end with a separator. So each chunk of data
|
|
// must contain several complete lines.
|
|
.pipeThrough(new SplitStringStream("\n"))
|
|
.getReader();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
yield PackageManager.parsePackageListItem(value);
|
|
}
|
|
}
|
|
|
|
async getPackages(packageName: string): Promise<string[]> {
|
|
const args = ["pm", "-p", packageName];
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const result: string[] = [];
|
|
for await (const line of process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new SplitStringStream("\n"))) {
|
|
if (line.startsWith("package:")) {
|
|
result.push(line.substring("package:".length));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async uninstall(
|
|
packageName: string,
|
|
options?: Partial<PackageManagerUninstallOptions>,
|
|
): Promise<void> {
|
|
const args = buildArguments(
|
|
["pm", "uninstall"],
|
|
options,
|
|
PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP,
|
|
);
|
|
args.push(packageName);
|
|
if (options?.splitNames) {
|
|
args.push(...options.splitNames);
|
|
}
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const output = await process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new ConcatStringStream())
|
|
.then((output) => output.trim());
|
|
if (output !== "Success") {
|
|
throw new Error(output);
|
|
}
|
|
}
|
|
|
|
async resolveActivity(
|
|
options: PackageManagerResolveActivityOptions,
|
|
): Promise<string | undefined> {
|
|
let args = buildArguments(
|
|
["pm", "resolve-activity", "--components"],
|
|
options,
|
|
PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP,
|
|
);
|
|
|
|
args = args.concat(options.intent.build());
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const output = await process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new ConcatStringStream())
|
|
.then((output) => output.trim());
|
|
|
|
if (output === "No activity found") {
|
|
return undefined;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Creates a new install session.
|
|
*
|
|
* Install sessions are used to install apps with multiple splits, but it can also be used to install a single apk.
|
|
*
|
|
* Install sessions was added in Android 5.0 (API level 21).
|
|
*
|
|
* @param options Options for the install session
|
|
* @returns ID of the new install session
|
|
*/
|
|
async sessionCreate(
|
|
options?: Partial<PackageManagerInstallOptions>,
|
|
): Promise<number> {
|
|
const args = buildInstallArguments("install-create", options);
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const output = await process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new ConcatStringStream())
|
|
.then((output) => output.trim());
|
|
|
|
const sessionIdString = output.match(/.*\[(\d+)\].*/);
|
|
if (!sessionIdString) {
|
|
throw new Error("Failed to create install session");
|
|
}
|
|
|
|
return Number.parseInt(sessionIdString[1]!, 10);
|
|
}
|
|
|
|
async sessionAddSplit(
|
|
sessionId: number,
|
|
splitName: string,
|
|
path: string,
|
|
): Promise<void> {
|
|
const args: string[] = [
|
|
"pm",
|
|
"install-write",
|
|
sessionId.toString(),
|
|
splitName,
|
|
path,
|
|
];
|
|
|
|
const output = await this.adb.subprocess
|
|
.spawnAndWaitLegacy(args)
|
|
.then((output) => output.trim());
|
|
if (!output.startsWith("Success")) {
|
|
throw new Error(output);
|
|
}
|
|
}
|
|
|
|
async sessionAddSplitStream(
|
|
sessionId: number,
|
|
splitName: string,
|
|
size: number,
|
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
|
): Promise<void> {
|
|
const args: string[] = [
|
|
"pm",
|
|
"install-write",
|
|
"-S",
|
|
size.toString(),
|
|
sessionId.toString(),
|
|
splitName,
|
|
"-",
|
|
];
|
|
|
|
const process = await this.#cmdOrSubprocess(args);
|
|
const output = process.stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new ConcatStringStream())
|
|
.then((output) => output.trim());
|
|
|
|
await Promise.all([
|
|
stream.pipeTo(process.stdin),
|
|
output.then((output) => {
|
|
if (!output.startsWith("Success")) {
|
|
throw new Error(output);
|
|
}
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async sessionCommit(sessionId: number): Promise<void> {
|
|
const args: string[] = ["pm", "install-commit", sessionId.toString()];
|
|
const output = await this.adb.subprocess
|
|
.spawnAndWaitLegacy(args)
|
|
.then((output) => output.trim());
|
|
if (output !== "Success") {
|
|
throw new Error(output);
|
|
}
|
|
}
|
|
|
|
async sessionAbandon(sessionId: number): Promise<void> {
|
|
const args: string[] = ["pm", "install-abandon", sessionId.toString()];
|
|
const output = await this.adb.subprocess
|
|
.spawnAndWaitLegacy(args)
|
|
.then((output) => output.trim());
|
|
if (output !== "Success") {
|
|
throw new Error(output);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class PackageManagerInstallSession {
|
|
static async create(
|
|
packageManager: PackageManager,
|
|
options?: Partial<PackageManagerInstallOptions>,
|
|
): Promise<PackageManagerInstallSession> {
|
|
const id = await packageManager.sessionCreate(options);
|
|
return new PackageManagerInstallSession(packageManager, id);
|
|
}
|
|
|
|
#packageManager: PackageManager;
|
|
|
|
#id: number;
|
|
get id(): number {
|
|
return this.#id;
|
|
}
|
|
|
|
constructor(packageManager: PackageManager, id: number) {
|
|
this.#packageManager = packageManager;
|
|
this.#id = id;
|
|
}
|
|
|
|
addSplit(splitName: string, path: string): Promise<void> {
|
|
return this.#packageManager.sessionAddSplit(this.#id, splitName, path);
|
|
}
|
|
|
|
addSplitStream(
|
|
splitName: string,
|
|
size: number,
|
|
stream: ReadableStream<MaybeConsumable<Uint8Array>>,
|
|
): Promise<void> {
|
|
return this.#packageManager.sessionAddSplitStream(
|
|
this.#id,
|
|
splitName,
|
|
size,
|
|
stream,
|
|
);
|
|
}
|
|
|
|
commit(): Promise<void> {
|
|
return this.#packageManager.sessionCommit(this.#id);
|
|
}
|
|
|
|
abandon(): Promise<void> {
|
|
return this.#packageManager.sessionAbandon(this.#id);
|
|
}
|
|
}
|