mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-04 02:09:18 +02:00
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
// cspell: ignore logcat
|
|
// cspell: ignore usec
|
|
|
|
import { AdbCommandBase, AdbSubprocessNoneProtocol } from "@yume-chan/adb";
|
|
import type { ReadableStream } from "@yume-chan/stream-extra";
|
|
import {
|
|
BufferedTransformStream,
|
|
SplitStringStream,
|
|
TextDecoderStream,
|
|
WrapReadableStream,
|
|
WritableStream,
|
|
} from "@yume-chan/stream-extra";
|
|
import type { AsyncExactReadable } from "@yume-chan/struct";
|
|
import Struct, { decodeUtf8 } from "@yume-chan/struct";
|
|
|
|
// `adb logcat` is an alias to `adb shell logcat`
|
|
// so instead of adding to core library, it's implemented here
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/android/log.h;l=141;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
|
|
export enum LogId {
|
|
All = -1,
|
|
Main,
|
|
Radio,
|
|
Events,
|
|
System,
|
|
Crash,
|
|
Stats,
|
|
Security,
|
|
Kernel,
|
|
}
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/android/log.h;l=73;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
|
|
export enum AndroidLogPriority {
|
|
Unknown,
|
|
Default,
|
|
Verbose,
|
|
Debug,
|
|
Info,
|
|
Warn,
|
|
Error,
|
|
Fatal,
|
|
Silent,
|
|
}
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;l=140;drc=8dbf3b2bb6b6d1652d9797e477b9abd03278bb79
|
|
export const AndroidLogPriorityToCharacter: Record<AndroidLogPriority, string> =
|
|
{
|
|
[AndroidLogPriority.Unknown]: "?",
|
|
[AndroidLogPriority.Default]: "?",
|
|
[AndroidLogPriority.Verbose]: "V",
|
|
[AndroidLogPriority.Debug]: "D",
|
|
[AndroidLogPriority.Info]: "I",
|
|
[AndroidLogPriority.Warn]: "W",
|
|
[AndroidLogPriority.Error]: "E",
|
|
[AndroidLogPriority.Fatal]: "F",
|
|
[AndroidLogPriority.Silent]: "S",
|
|
};
|
|
|
|
export enum LogcatFormat {
|
|
Brief,
|
|
Process,
|
|
Tag,
|
|
Thread,
|
|
Raw,
|
|
Time,
|
|
ThreadTime,
|
|
Long,
|
|
}
|
|
|
|
export interface LogcatFormatModifiers {
|
|
microseconds?: boolean;
|
|
nanoseconds?: boolean;
|
|
printable?: boolean;
|
|
year?: boolean;
|
|
timezone?: boolean;
|
|
epoch?: boolean;
|
|
monotonic?: boolean;
|
|
uid?: boolean;
|
|
descriptive?: boolean;
|
|
}
|
|
|
|
export interface LogcatOptions {
|
|
dump?: boolean;
|
|
pid?: number;
|
|
ids?: LogId[];
|
|
}
|
|
|
|
const NANOSECONDS_PER_SECOND = BigInt(1e9);
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/log/log_read.h;l=39;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
|
|
export const LoggerEntry = new Struct({ littleEndian: true })
|
|
.uint16("payloadSize")
|
|
.uint16("headerSize")
|
|
.int32("pid")
|
|
.uint32("tid")
|
|
.uint32("seconds")
|
|
.uint32("nanoseconds")
|
|
.uint32("logId")
|
|
.uint32("uid")
|
|
.extra({
|
|
get timestamp() {
|
|
return (
|
|
BigInt(this.seconds) * NANOSECONDS_PER_SECOND +
|
|
BigInt(this.nanoseconds)
|
|
);
|
|
},
|
|
});
|
|
|
|
export type LoggerEntry = (typeof LoggerEntry)["TDeserializeResult"];
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;drc=bbe77d66e7bee8bd1f0bc7e5492b5376b0207ef6;bpv=0
|
|
export interface AndroidLogEntry extends LoggerEntry {
|
|
priority: AndroidLogPriority;
|
|
tag: string;
|
|
message: string;
|
|
|
|
toString(format?: LogcatFormat, modifiers?: LogcatFormatModifiers): string;
|
|
}
|
|
|
|
function padZero(number: number, length: number) {
|
|
return number.toString().padStart(length, "0");
|
|
}
|
|
|
|
function formatSeconds(seconds: number, modifiers: LogcatFormatModifiers) {
|
|
if (modifiers.monotonic) {
|
|
return padZero(seconds, 6);
|
|
}
|
|
|
|
if (modifiers.epoch) {
|
|
return padZero(seconds, 19);
|
|
}
|
|
|
|
const date = new Date(seconds * 1000);
|
|
|
|
const month = padZero(date.getMonth() + 1, 2);
|
|
const day = padZero(date.getDate(), 2);
|
|
const hour = padZero(date.getHours(), 2);
|
|
const minute = padZero(date.getMinutes(), 2);
|
|
const second = padZero(date.getSeconds(), 2);
|
|
const result = `${month}-${day} ${hour}:${minute}:${second}`;
|
|
|
|
if (modifiers.year) {
|
|
const year = padZero(date.getFullYear(), 4);
|
|
return `${year}-${result}`;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function formatNanoseconds(
|
|
nanoseconds: number,
|
|
modifiers: LogcatFormatModifiers,
|
|
) {
|
|
if (modifiers.nanoseconds) {
|
|
return padZero(nanoseconds, 9);
|
|
}
|
|
|
|
if (modifiers.microseconds) {
|
|
return padZero(nanoseconds / 1000, 6);
|
|
}
|
|
|
|
return padZero(nanoseconds / 1000000, 3);
|
|
}
|
|
|
|
function formatTimezone(seconds: number, modifiers: LogcatFormatModifiers) {
|
|
if (!modifiers.timezone || modifiers.monotonic || modifiers.epoch) {
|
|
return "";
|
|
}
|
|
|
|
const date = new Date(seconds * 1000);
|
|
const offset = date.getTimezoneOffset();
|
|
const sign = offset <= 0 ? "+" : "-";
|
|
const absolute = Math.abs(offset);
|
|
const hours = (absolute / 60) | 0;
|
|
const minutes = absolute % 60;
|
|
|
|
// prettier-ignore
|
|
return ` ${
|
|
sign
|
|
}${
|
|
hours.toString().padStart(2, "0")
|
|
}:${
|
|
minutes.toString().padStart(2, "0")
|
|
}`;
|
|
}
|
|
|
|
function formatTime(
|
|
seconds: number,
|
|
nanoseconds: number,
|
|
modifiers: LogcatFormatModifiers,
|
|
) {
|
|
const secondsString = formatSeconds(seconds, modifiers);
|
|
const nanosecondsString = formatNanoseconds(nanoseconds, modifiers);
|
|
const zoneString = formatTimezone(seconds, modifiers);
|
|
return `${secondsString}.${nanosecondsString}${zoneString}`;
|
|
}
|
|
|
|
function formatUid(
|
|
uid: number,
|
|
modifiers: LogcatFormatModifiers,
|
|
suffix: string,
|
|
) {
|
|
return modifiers.uid ? `${uid.toString().padStart(5)}${suffix}` : "";
|
|
}
|
|
|
|
function getFormatPrefix(
|
|
entry: AndroidLogEntry,
|
|
format: LogcatFormat,
|
|
modifiers: LogcatFormatModifiers,
|
|
) {
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;l=1415;drc=8dbf3b2bb6b6d1652d9797e477b9abd03278bb79
|
|
switch (format) {
|
|
// TODO: implement other formats
|
|
case LogcatFormat.Tag:
|
|
// prettier-ignore
|
|
return `${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
}/${
|
|
entry.tag.padEnd(8)
|
|
}: `;
|
|
case LogcatFormat.Process:
|
|
// prettier-ignore
|
|
return `${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
}(${
|
|
formatUid(entry.uid, modifiers, ":")
|
|
}${
|
|
entry.pid.toString().padStart(5)
|
|
}) `;
|
|
case LogcatFormat.Thread:
|
|
// prettier-ignore
|
|
return `${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
}(${
|
|
formatUid(entry.uid, modifiers, ":")
|
|
}${
|
|
entry.pid.toString().padStart(5)
|
|
}:${
|
|
entry.tid.toString().padStart(5)
|
|
}) `;
|
|
case LogcatFormat.Raw:
|
|
return "";
|
|
case LogcatFormat.Time:
|
|
// prettier-ignore
|
|
return `${
|
|
formatTime(entry.seconds, entry.nanoseconds, modifiers)
|
|
} ${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
}/${
|
|
entry.tag.padEnd(8)
|
|
}(${
|
|
formatUid(entry.uid, modifiers, ":")
|
|
}${
|
|
entry.pid.toString().padStart(5)
|
|
}): `;
|
|
case LogcatFormat.ThreadTime:
|
|
// prettier-ignore
|
|
return `${
|
|
formatTime(entry.seconds, entry.nanoseconds, modifiers)
|
|
} ${
|
|
formatUid(entry.uid, modifiers, " ")
|
|
}${
|
|
entry.pid.toString().padStart(5)
|
|
} ${
|
|
entry.tid.toString().padStart(5)
|
|
} ${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
} ${
|
|
entry.tag.toString().padEnd(8)
|
|
}: `;
|
|
case LogcatFormat.Brief:
|
|
default:
|
|
// prettier-ignore
|
|
return `${
|
|
AndroidLogPriorityToCharacter[entry.priority]
|
|
}/${
|
|
entry.tag.padEnd(8)
|
|
}(${
|
|
formatUid(entry.uid, modifiers, ":")
|
|
}${
|
|
entry.pid.toString().padStart(5)
|
|
}): `;
|
|
}
|
|
}
|
|
|
|
function getFormatSuffix(entry: AndroidLogEntry, format: LogcatFormat) {
|
|
switch (format) {
|
|
case LogcatFormat.Process:
|
|
return ` (${entry.tag})`;
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatEntryWrapLine(
|
|
entry: AndroidLogEntry,
|
|
format: LogcatFormat,
|
|
modifiers: LogcatFormatModifiers,
|
|
) {
|
|
const prefix = getFormatPrefix(entry, format, modifiers);
|
|
const suffix = getFormatSuffix(entry, format);
|
|
return (
|
|
prefix + entry.message.replaceAll("\n", suffix + "\n" + prefix) + suffix
|
|
);
|
|
}
|
|
|
|
function AndroidLogEntryToString(
|
|
this: AndroidLogEntry,
|
|
format: LogcatFormat = LogcatFormat.ThreadTime,
|
|
modifiers: LogcatFormatModifiers = {},
|
|
) {
|
|
switch (format) {
|
|
case LogcatFormat.Long:
|
|
// prettier-ignore
|
|
return `[ ${
|
|
formatTime(this.seconds, this.nanoseconds, modifiers)
|
|
} ${
|
|
formatUid(this.uid, modifiers, ":")
|
|
}${
|
|
this.pid.toString().padStart(5)
|
|
}:${
|
|
this.tid.toString().padStart(5)
|
|
} ${
|
|
AndroidLogPriorityToCharacter[this.priority]
|
|
}/${
|
|
this.tag.padEnd(8)
|
|
} ]\n${
|
|
this.message
|
|
}\n`;
|
|
default:
|
|
return formatEntryWrapLine(this, format, modifiers);
|
|
}
|
|
}
|
|
|
|
function findTagEnd(payload: Uint8Array) {
|
|
for (const separator of [0, " ".charCodeAt(0), ":".charCodeAt(0)]) {
|
|
const index = payload.indexOf(separator);
|
|
if (index !== -1) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
const index = payload.findIndex((x) => x >= 0x7f);
|
|
if (index !== -1) {
|
|
return index;
|
|
}
|
|
|
|
return payload.length;
|
|
}
|
|
|
|
export async function deserializeAndroidLogEntry(
|
|
stream: AsyncExactReadable,
|
|
): Promise<AndroidLogEntry> {
|
|
const entry = (await LoggerEntry.deserialize(stream)) as AndroidLogEntry;
|
|
if (entry.headerSize !== LoggerEntry.size) {
|
|
// Skip unknown fields
|
|
await stream.readExactly(entry.headerSize - LoggerEntry.size);
|
|
}
|
|
|
|
let payload = await stream.readExactly(entry.payloadSize);
|
|
|
|
// https://cs.android.com/android/platform/superproject/+/master:system/logging/logcat/logcat.cpp;l=193-194;drc=bbe77d66e7bee8bd1f0bc7e5492b5376b0207ef6
|
|
// TODO: payload for some log IDs are in binary format.
|
|
entry.priority = payload[0] as AndroidLogPriority;
|
|
|
|
payload = payload.subarray(1);
|
|
const tagEnd = findTagEnd(payload);
|
|
entry.tag = decodeUtf8(payload.subarray(0, tagEnd));
|
|
entry.message =
|
|
tagEnd < payload.length - 1
|
|
? decodeUtf8(payload.subarray(tagEnd + 1))
|
|
: "";
|
|
entry.toString = AndroidLogEntryToString;
|
|
return entry;
|
|
}
|
|
|
|
export interface LogSize {
|
|
id: LogId;
|
|
size: number;
|
|
readable?: number;
|
|
consumed: number;
|
|
maxEntrySize: number;
|
|
maxPayloadSize: number;
|
|
}
|
|
|
|
export class Logcat extends AdbCommandBase {
|
|
static logIdToName(id: LogId): string {
|
|
return LogId[id];
|
|
}
|
|
|
|
static logNameToId(name: string): LogId {
|
|
const key = name[0]!.toUpperCase() + name.substring(1);
|
|
return LogId[key as keyof typeof LogId];
|
|
}
|
|
|
|
static joinLogId(ids: LogId[]): string {
|
|
return ids.map((id) => Logcat.logIdToName(id)).join(",");
|
|
}
|
|
|
|
static parseSize(value: number, multiplier: string): number {
|
|
const MULTIPLIERS = ["", "Ki", "Mi", "Gi"];
|
|
return value * 1024 ** (MULTIPLIERS.indexOf(multiplier) || 0);
|
|
}
|
|
|
|
// TODO: logcat: Support output format before Android 10
|
|
// ref https://android-review.googlesource.com/c/platform/system/core/+/748128
|
|
static readonly LOG_SIZE_REGEX_10: RegExp =
|
|
/(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed\), max entry is (.*) B, max payload is (.*) B/;
|
|
|
|
// Android 11 added `readable` part
|
|
// ref https://android-review.googlesource.com/c/platform/system/core/+/1390940
|
|
static readonly LOG_SIZE_REGEX_11: RegExp =
|
|
/(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed, (.*) (.*)B readable\), max entry is (.*) B, max payload is (.*) B/;
|
|
|
|
async getLogSize(ids?: LogId[]): Promise<LogSize[]> {
|
|
const { stdout } = await this.adb.subprocess.spawn([
|
|
"logcat",
|
|
"-g",
|
|
...(ids ? ["-b", Logcat.joinLogId(ids)] : []),
|
|
]);
|
|
|
|
const result: LogSize[] = [];
|
|
await stdout
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(new SplitStringStream("\n"))
|
|
.pipeTo(
|
|
new WritableStream({
|
|
write(chunk) {
|
|
let match = chunk.match(Logcat.LOG_SIZE_REGEX_11);
|
|
if (match) {
|
|
result.push({
|
|
id: Logcat.logNameToId(match[1]!),
|
|
size: Logcat.parseSize(
|
|
Number.parseInt(match[2]!, 10),
|
|
match[3]!,
|
|
),
|
|
readable: Logcat.parseSize(
|
|
Number.parseInt(match[6]!, 10),
|
|
match[7]!,
|
|
),
|
|
consumed: Logcat.parseSize(
|
|
Number.parseInt(match[4]!, 10),
|
|
match[5]!,
|
|
),
|
|
maxEntrySize: parseInt(match[8]!, 10),
|
|
maxPayloadSize: parseInt(match[9]!, 10),
|
|
});
|
|
return;
|
|
}
|
|
|
|
match = chunk.match(Logcat.LOG_SIZE_REGEX_10);
|
|
if (match) {
|
|
result.push({
|
|
id: Logcat.logNameToId(match[1]!),
|
|
size: Logcat.parseSize(
|
|
Number.parseInt(match[2]!, 10),
|
|
match[3]!,
|
|
),
|
|
consumed: Logcat.parseSize(
|
|
Number.parseInt(match[4]!, 10),
|
|
match[5]!,
|
|
),
|
|
maxEntrySize: parseInt(match[6]!, 10),
|
|
maxPayloadSize: parseInt(match[7]!, 10),
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
async clear(ids?: LogId[]): Promise<void> {
|
|
const args = ["logcat", "-c"];
|
|
if (ids && ids.length > 0) {
|
|
args.push("-b", Logcat.joinLogId(ids));
|
|
}
|
|
|
|
await this.adb.subprocess.spawnAndWait(args);
|
|
}
|
|
|
|
binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> {
|
|
return new WrapReadableStream(async () => {
|
|
const args = ["logcat", "-B"];
|
|
if (options?.dump) {
|
|
args.push("-d");
|
|
}
|
|
if (options?.pid) {
|
|
args.push("--pid", options.pid.toString());
|
|
}
|
|
if (options?.ids) {
|
|
args.push("-b", Logcat.joinLogId(options.ids));
|
|
}
|
|
|
|
// TODO: make `spawn` return synchronously with streams pending
|
|
// so it's easier to chain them.
|
|
const { stdout } = await this.adb.subprocess.spawn(args, {
|
|
// PERF: None protocol is 150% faster then Shell protocol
|
|
protocols: [AdbSubprocessNoneProtocol],
|
|
});
|
|
return stdout;
|
|
}).pipeThrough(
|
|
new BufferedTransformStream((stream) => {
|
|
return deserializeAndroidLogEntry(stream);
|
|
}),
|
|
);
|
|
}
|
|
}
|