feat(bin): logcat binary format parsing

ref #359
This commit is contained in:
Simon Chan 2022-04-24 21:09:46 +08:00
parent f46ff36830
commit c3a61f9b71
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
22 changed files with 946 additions and 231 deletions

View file

@ -162,6 +162,9 @@ function _Connect(): JSX.Element | null {
device = await Adb.authenticate({ readable, writable }, CredentialStore, undefined); device = await Adb.authenticate({ readable, writable }, CredentialStore, undefined);
device.disconnected.then(() => { device.disconnected.then(() => {
globalState.setDevice(undefined, undefined); globalState.setDevice(undefined, undefined);
}, (e) => {
globalState.showErrorDialog(e);
globalState.setDevice(undefined, undefined);
}); });
globalState.setDevice(selectedBackend, device); globalState.setDevice(selectedBackend, device);
} catch (e) { } catch (e) {
@ -170,7 +173,7 @@ function _Connect(): JSX.Element | null {
} }
} }
} catch (e: any) { } catch (e: any) {
globalState.showErrorDialog(e.message); globalState.showErrorDialog(e);
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
@ -180,7 +183,7 @@ function _Connect(): JSX.Element | null {
await globalState.device!.dispose(); await globalState.device!.dispose();
globalState.setDevice(undefined, undefined); globalState.setDevice(undefined, undefined);
} catch (e: any) { } catch (e: any) {
globalState.showErrorDialog(e.message); globalState.showErrorDialog(e);
} }
}, []); }, []);

View file

@ -140,8 +140,8 @@ class FileManagerState {
const itemPath = path.resolve(this.path, item.name); const itemPath = path.resolve(this.path, item.name);
await sync.read(itemPath) await sync.read(itemPath)
.pipeTo(saveFile(item.name, Number(item.size))); .pipeTo(saveFile(item.name, Number(item.size)));
} catch (e) { } catch (e: any) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e);
} finally { } finally {
sync.dispose(); sync.dispose();
} }
@ -169,8 +169,8 @@ class FileManagerState {
return; return;
} }
} }
} catch (e) { } catch (e: any) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e);
} finally { } finally {
this.loadFiles(); this.loadFiles();
} }
@ -481,8 +481,8 @@ class FileManagerState {
} finally { } finally {
clearInterval(intervalId); clearInterval(intervalId);
} }
} catch (e) { } catch (e: any) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e);
} finally { } finally {
sync.dispose(); sync.dispose();
this.loadFiles(); this.loadFiles();

View file

@ -45,8 +45,8 @@ const FrameBuffer: NextPage = (): JSX.Element | null => {
try { try {
const framebuffer = await globalState.device.framebuffer(); const framebuffer = await globalState.device.framebuffer();
state.setImage(framebuffer); state.setImage(framebuffer);
} catch (e) { } catch (e: any) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e);
} }
}, []); }, []);

View file

@ -0,0 +1,301 @@
// cspell: ignore logcat
import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import { AbortController, decodeUtf8, ReadableStream, WritableStream } from '@yume-chan/adb';
import { Logcat, LogMessage, LogPriority } from '@yume-chan/android-bin';
import { autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { CommandBar, Grid, GridColumn, GridHeaderProps, GridRowProps } from "../components";
import { globalState } from "../state";
import { Icons, RouteStackProps, useCallbackRef } from "../utils";
const LINE_HEIGHT = 32;
const useClasses = makeStyles({
grid: {
height: '100%',
marginLeft: '-16px',
marginRight: '-16px',
},
header: {
textAlign: 'center',
lineHeight: `${LINE_HEIGHT}px`,
},
row: {
'&:hover': {
backgroundColor: '#f3f2f1',
},
},
selected: {
backgroundColor: '#edebe9',
},
code: {
fontFamily: 'monospace',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: LINE_HEIGHT + 'px',
cursor: 'default',
...shorthands.overflow('hidden'),
},
});
export interface Column extends GridColumn {
title: string;
}
export interface LogRow extends LogMessage {
timeString?: string;
payloadString?: string;
}
const state = makeAutoObservable({
logcat: undefined as Logcat | undefined,
running: false,
list: [] as LogRow[],
stream: undefined as ReadableStream<LogMessage> | undefined,
stopSignal: undefined as AbortController | undefined,
selectedCount: 0,
start() {
if (this.running) {
return;
}
this.running = true;
this.stream = this.logcat!.binary();
this.stopSignal = new AbortController();
this.stream
.pipeTo(
new WritableStream({
write: (chunk) => {
runInAction(() => {
this.list.push(chunk);
});
},
}),
{ signal: this.stopSignal.signal }
)
.catch(() => { });
},
stop() {
this.running = false;
this.stopSignal!.abort();
},
clear() {
this.list = [];
this.selectedCount = 0;
},
get empty() {
return this.list.length === 0;
},
get commandBar(): ICommandBarItemProps[] {
return [
this.running ? {
key: "stop",
text: "Stop",
iconProps: { iconName: Icons.Stop },
onClick: () => this.stop(),
} : {
key: "start",
text: "Start",
disabled: this.logcat === undefined,
iconProps: { iconName: Icons.Play },
onClick: () => this.start(),
},
{
key: 'clear',
text: 'Clear',
disabled: this.empty,
iconProps: { iconName: Icons.Delete },
onClick: () => this.clear(),
},
{
key: 'copyAll',
text: 'Copy Rows',
disabled: this.selectedCount === 0,
iconProps: { iconName: Icons.Copy },
onClick: () => {
}
},
{
key: 'copyText',
text: 'Copy Messages',
disabled: this.selectedCount === 0,
iconProps: { iconName: Icons.Copy },
onClick: () => {
}
}
];
},
get columns(): Column[] {
return [
{
width: 200,
title: 'Time',
CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => {
const item = this.list[rowIndex];
if (!item.timeString) {
item.timeString = new Date(item.second * 1000).toISOString();
}
const classes = useClasses();
return (
<div className={mergeClasses(classes.code, className)} {...rest}>
{item.timeString}
</div>
);
}
},
{
width: 80,
title: 'PID',
CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div className={mergeClasses(classes.code, className)} {...rest}>
{item.pid}
</div>
);
}
},
{
width: 80,
title: 'TID',
CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div className={mergeClasses(classes.code, className)} {...rest}>
{item.tid}
</div>
);
}
},
{
width: 100,
title: 'Priority',
CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div className={mergeClasses(classes.code, className)} {...rest}>
{LogPriority[item.priority]}
</div>
);
}
},
{
width: 300,
flexGrow: 1,
title: 'Payload',
CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => {
const item = this.list[rowIndex];
if (!item.payloadString) {
item.payloadString = decodeUtf8(item.payload);
}
const classes = useClasses();
return (
<div className={mergeClasses(classes.code, className)} {...rest}>
{item.payloadString}
</div>
);
}
},
];
},
}, {
list: observable.shallow,
});
console.log(state);
autorun(() => {
if (globalState.device) {
state.logcat = new Logcat(globalState.device);
} else {
state.logcat = undefined;
if (state.running) {
state.stop();
}
}
});
const Header = observer(function Header({
className,
columnIndex,
...rest
}: GridHeaderProps) {
const classes = useClasses();
return (
<div className={mergeClasses(className, classes.header)} {...rest}>
{state.columns[columnIndex].title}
</div>
);
});
const Row = observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
const item = state.list[rowIndex];
const classes = useClasses();
const handleClick = useCallbackRef(() => {
runInAction(() => {
});
});
return (
<div
className={mergeClasses(
className,
classes.row,
)}
onClick={handleClick}
{...rest}
/>
);
});
const LogcatPage: NextPage = () => {
const classes = useClasses();
return (
<Stack {...RouteStackProps}>
<Head>
<title>Logcat - Android Web Toolbox</title>
</Head>
<CommandBar items={state.commandBar} />
<StackItem grow>
<Grid
className={classes.grid}
rowCount={state.list.length}
rowHeight={LINE_HEIGHT}
columns={state.columns}
HeaderComponent={Header}
RowComponent={Row}
/>
</StackItem>
</Stack>
);
};
export default observer(LogcatPage);

View file

@ -5,7 +5,6 @@ import { autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { NextPage } from "next"; import { NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { useMemo } from "react";
import { CommandBar, Grid, GridCellProps, GridColumn, GridHeaderProps, GridRowProps, HexViewer, toText } from "../components"; import { CommandBar, Grid, GridCellProps, GridColumn, GridHeaderProps, GridRowProps, HexViewer, toText } from "../components";
import { globalState, PacketLogItem } from "../state"; import { globalState, PacketLogItem } from "../state";
import { Icons, RouteStackProps, useCallbackRef, withDisplayName } from "../utils"; import { Icons, RouteStackProps, useCallbackRef, withDisplayName } from "../utils";
@ -89,158 +88,160 @@ const useClasses = makeStyles({
}, },
}); });
const PacketLog: NextPage = () => { const columns: Column[] = [
const classes = useClasses(); {
title: 'Direction',
width: 100,
CellComponent: withDisplayName('Direction')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
const columns: Column[] = useMemo(() => [ const classes = useClasses();
{
key: 'direction',
title: 'Direction',
width: 100,
CellComponent: withDisplayName('Direction')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.direction}
</div>
);
}),
},
{
key: 'command',
title: 'Command',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.commandString) {
item.commandString =
ADB_COMMAND_NAME[item.command as AdbCommand] ??
decodeUtf8(new Uint32Array([item.command]));
}
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.commandString}
</div>
);
}),
},
{
key: 'arg0',
title: 'Arg0',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.arg0String) {
item.arg0String = item.arg0.toString(16).padStart(8, '0');
}
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg0String}
</div>
);
}),
},
{
key: 'arg1',
title: 'Arg1',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.arg1String) {
item.arg1String = item.arg0.toString(16).padStart(8, '0');
}
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg1String}
</div>
);
}),
},
{
key: 'payload',
title: 'Payload',
width: 200,
flexGrow: 1,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.payloadString) {
item.payloadString = toText(item.payload.subarray(0, 100));
}
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.payloadString}
</div>
);
}),
},
], [classes.code]);
const Header = useMemo(
() => withDisplayName('Header')(({
className,
columnIndex,
...rest
}: GridHeaderProps) => {
return (
<div className={mergeClasses(className, classes.header)} {...rest}>
{columns[columnIndex].title}
</div>
);
}),
[classes.header, columns]
);
const Row = useMemo(
() => observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
/* eslint-disable-next-line */
const handleClick = useCallbackRef(() => {
runInAction(() => {
state.selectedPacket = globalState.logs[rowIndex];
});
});
return ( return (
<div <div
className={mergeClasses( className={mergeClasses(className, classes.code)}
className,
classes.row,
state.selectedPacket === globalState.logs[rowIndex] && classes.selected
)}
onClick={handleClick}
{...rest} {...rest}
/> >
{item.direction}
</div>
); );
}), }),
[classes] },
{
title: 'Command',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.commandString) {
item.commandString =
ADB_COMMAND_NAME[item.command as AdbCommand] ??
decodeUtf8(new Uint32Array([item.command]));
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.commandString}
</div>
);
}),
},
{
title: 'Arg0',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.arg0String) {
item.arg0String = item.arg0.toString(16).padStart(8, '0');
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg0String}
</div>
);
}),
},
{
title: 'Arg1',
width: 100,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.arg1String) {
item.arg1String = item.arg0.toString(16).padStart(8, '0');
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg1String}
</div>
);
}),
},
{
title: 'Payload',
width: 200,
flexGrow: 1,
CellComponent: withDisplayName('Command')(({ className, rowIndex, ...rest }: GridCellProps) => {
const item = globalState.logs[rowIndex];
if (!item.payloadString) {
item.payloadString = toText(item.payload.subarray(0, 100));
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.payloadString}
</div>
);
}),
},
];
const Header = withDisplayName('Header')(({
className,
columnIndex,
...rest
}: GridHeaderProps) => {
const classes = useClasses();
return (
<div className={mergeClasses(className, classes.header)} {...rest}>
{columns[columnIndex].title}
</div>
); );
});
const Row = observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
const classes = useClasses();
const handleClick = useCallbackRef(() => {
runInAction(() => {
state.selectedPacket = globalState.logs[rowIndex];
});
});
return (
<div
className={mergeClasses(
className,
classes.row,
state.selectedPacket === globalState.logs[rowIndex] && classes.selected
)}
onClick={handleClick}
{...rest}
/>
);
});
const PacketLog: NextPage = () => {
const classes = useClasses();
return ( return (
<Stack {...RouteStackProps} tokens={{}}> <Stack {...RouteStackProps} tokens={{}}>

View file

@ -519,7 +519,7 @@ class ScrcpyPageState {
this.running = true; this.running = true;
}); });
} catch (e: any) { } catch (e: any) {
globalState.showErrorDialog(e.message); globalState.showErrorDialog(e);
} finally { } finally {
runInAction(() => { runInAction(() => {
this.connecting = false; this.connecting = false;

View file

@ -51,8 +51,8 @@ const Shell: NextPage = (): JSX.Element | null => {
connectingRef.current = true; connectingRef.current = true;
const socket = await globalState.device.subprocess.shell(); const socket = await globalState.device.subprocess.shell();
terminal.socket = socket; terminal.socket = socket;
} catch (e) { } catch (e: any) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e);
} finally { } finally {
connectingRef.current = false; connectingRef.current = false;
} }

View file

@ -34,9 +34,13 @@ export class GlobalState {
this.device = device; this.device = device;
} }
showErrorDialog(message: string) { showErrorDialog(message: Error | string) {
this.errorDialogVisible = true; this.errorDialogVisible = true;
this.errorDialogMessage = message; if (message instanceof Error) {
this.errorDialogMessage = message.stack!;
} else {
this.errorDialogMessage = message;
}
} }
hideErrorDialog() { hideErrorDialog() {

View file

@ -0,0 +1,247 @@
export function binarySearch(
array: Uint8Array,
start: number,
end: number,
value: number,
) {
while (start <= end) {
const mid = (start + end) >> 1;
if (array[mid] === value) {
return mid;
} else if (array[mid] < value) {
start = mid + 1;
} else {
end = mid - 1;
}
}
return -(start + 1);
}
interface BTreeInsertionResult {
keys: Uint8Array;
keyCount: number;
children: BTreeNode[];
}
export class BTreeNode {
order: number;
keys: Uint8Array;
keyCount: number;
children: BTreeNode[];
isLeaf = true;
public constructor(
order: number,
keys: Uint8Array,
keyCount: number,
children: BTreeNode[]
) {
this.order = order;
this.keys = keys;
this.keyCount = keyCount;
this.children = children;
}
protected split(value: number, index: number, split?: BTreeInsertionResult): BTreeInsertionResult {
const keys = new Uint8Array(this.order);
let children: BTreeNode[];
const mid = this.keyCount >> 1;
if (index < mid) {
keys.set(this.keys.subarray(mid - 1), 0);
this.keys.set(this.keys.subarray(index, mid - 1), index + 1);
this.keys[index] = value;
if (split) {
children = this.children.splice(mid - 1, this.order - mid + 1);
// TODO: this may cause the underlying array to grow (re-alloc and copy)
// investigate if this is a problem.
this.children.splice(
index,
0,
new BTreeNode(
this.order,
split.keys,
split.keyCount,
split.children
),
);
} else {
children = new Array(this.order);
}
} else {
if (index !== mid) {
keys.set(this.keys.subarray(mid, index), 0);
}
keys[index - mid] = value;
keys.set(this.keys.subarray(index), index - mid + 1);
if (split) {
children = this.children.splice(mid, this.order - mid);
children.splice(
index - mid,
0,
new BTreeNode(
this.order,
split.keys.subarray(1),
split.keyCount,
split.children
),
);
} else {
children = new Array(this.order);
}
}
this.keyCount = mid;
return {
keys,
keyCount: this.order - 1 - mid,
children
};
}
public insert(value: number): BTreeInsertionResult | undefined {
let index = binarySearch(this.keys, 0, this.keyCount - 1, value);
if (index >= 0) {
return;
}
index = -index - 1;
if (!this.isLeaf) {
let child = this.children[index];
if (!child) {
child = new BTreeNode(
this.order,
new Uint8Array(this.order - 1),
0,
new Array(this.order)
);
child.keys[0] = value;
child.keyCount = 1;
this.children[index] = child;
return;
}
const split = child.insert(value);
if (split) {
if (this.keyCount === this.order - 1) {
value = split.keys[0];
split.keys = split.keys.subarray(1);
return this.split(value, index, split);
} else {
this.keys.set(this.keys.subarray(index, this.keyCount), index + 1);
this.keys[index] = split.keys[0];
this.keyCount += 1;
this.children.splice(
index,
0,
new BTreeNode(
this.order,
split.keys.subarray(1),
split.keyCount,
split.children
),
);
}
}
return;
}
if (this.keyCount === this.order - 1) {
return this.split(value, index);
}
if (index !== this.keyCount) {
this.keys.set(this.keys.subarray(index, this.keyCount), index + 1);
}
this.keys[index] = value;
this.keyCount += 1;
return;
}
log(): any[] {
const result = [];
for (let i = 0; i < this.keyCount; i += 1) {
result.push(this.children[i]?.log());
result.push(this.keys[i]);
}
result.push(this.children[this.keyCount]?.log());
return result;
}
}
export class BTreeRoot extends BTreeNode {
public constructor(order: number) {
super(
order,
new Uint8Array(order - 1),
0,
new Array(order)
);
}
protected override split(value: number, index: number, split?: BTreeInsertionResult | undefined): BTreeInsertionResult {
split = super.split(value, index, split);
this.children[0] = new BTreeNode(
this.order,
this.keys,
this.keyCount,
this.children.slice(),
);
this.children[0].isLeaf = this.isLeaf;
this.children[1] = new BTreeNode(
this.order,
split.keys.subarray(1),
split.keyCount,
split.children,
);
this.children[1].isLeaf = this.isLeaf;
this.keys = new Uint8Array(this.order);
this.keys[0] = split.keys[0];
this.keyCount = 1;
this.isLeaf = false;
return split;
}
}
export class BTree {
order: number;
root: BTreeNode;
public constructor(order: number) {
this.order = order;
this.root = new BTreeRoot(order);
}
public has(value: number) {
let node = this.root;
while (true) {
const index = binarySearch(node.keys, 0, node.keyCount - 1, value);
if (index >= 0) {
return true;
}
node = node.children[-index - 1];
if (!node) {
return false;
}
}
}
public insert(value: number) {
this.root.insert(value);
}
}

View file

@ -1,17 +1,6 @@
// cspell: ignore RSASSA // cspell: ignore RSASSA
import { calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, encodeBase64, type AdbCredentialStore } from "@yume-chan/adb"; import { calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, decodeUtf8, encodeBase64, type AdbCredentialStore } from "@yume-chan/adb";
const Utf8Encoder = new TextEncoder();
const Utf8Decoder = new TextDecoder();
export function encodeUtf8(input: string): Uint8Array {
return Utf8Encoder.encode(input);
}
export function decodeUtf8(array: Uint8Array): string {
return Utf8Decoder.decode(array);
}
export default class AdbWebCredentialStore implements AdbCredentialStore { export default class AdbWebCredentialStore implements AdbCredentialStore {
public readonly localStorageKey: string; public readonly localStorageKey: string;

View file

@ -18,9 +18,10 @@ TypeScript implementation of Android Debug Bridge (ADB) protocol.
- [AdbAuthenticator](#adbauthenticator) - [AdbAuthenticator](#adbauthenticator)
- [`authenticate`](#authenticate) - [`authenticate`](#authenticate)
- [Stream multiplex](#stream-multiplex) - [Stream multiplex](#stream-multiplex)
- [Backend](#backend-1)
- [Commands](#commands) - [Commands](#commands)
- [childProcess](#childprocess) - [subprocess](#subprocess)
- [raw mode](#raw-mode)
- [pty mode](#pty-mode)
- [usb](#usb) - [usb](#usb)
- [tcpip](#tcpip) - [tcpip](#tcpip)
- [sync](#sync) - [sync](#sync)
@ -48,10 +49,9 @@ Each backend may have different requirements.
| | Chrome | Edge | Firefox | Internet Explorer | Safari | Node.js | | | Chrome | Edge | Firefox | Internet Explorer | Safari | Node.js |
| ------------------------------- | ------ | ---- | ------- | ----------------- | ------ | ------------------- | | ------------------------------- | ------ | ---- | ------- | ----------------- | ------ | ------------------- |
| `@yume-chan/struct`<sup>1</sup> | 67 | 79 | 68 | No | 14 | 8.3<sup>2</sup>, 11 | | `@yume-chan/struct`<sup>1</sup> | 67 | 79 | 68 | No | 14 | 8.3<sup>2</sup>, 11 |
| [Streams][MDN_Streams] | 67 | 79 | No | No | 14.1 | 16.5 |
| *Overall* | 67 | 79 | No | No | 14.1 | 16.5 | | *Overall* | 67 | 79 | No | No | 14.1 | 16.5 |
<sup>1</sup> `uint64` and `string` used. <sup>1</sup> `uint64` and `string` are used.
<sup>2</sup> `TextEncoder` and `TextDecoder` are only available in `util` module. Need to be assigned to `globalThis`. <sup>2</sup> `TextEncoder` and `TextDecoder` are only available in `util` module. Need to be assigned to `globalThis`.
@ -61,8 +61,6 @@ Each backend may have different requirements.
| --------------- | ------ | ---- | ------- | ----------------- | ------ | ------- | | --------------- | ------ | ---- | ------- | ----------------- | ------ | ------- |
| Top-level await | 89 | 89 | 89 | No | 15 | 14.8 | | Top-level await | 89 | 89 | 89 | No | 15 | 14.8 |
[MDN_Streams]: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API
## Connection ## Connection
This library doesn't tie to a specific transportation method. This library doesn't tie to a specific transportation method.
@ -79,7 +77,7 @@ connect(): ValueOrPromise<ReadableWritablePair<AdbPacketCore, AdbPacketInit>>
Connect to a device and create a pair of `AdbPacket` streams. Connect to a device and create a pair of `AdbPacket` streams.
The backend is responsible for serializing and deserializing the packets, because it's extreme slow for WebUSB backend (`@yume-chan/adb-backend-webusb`) to read packets with unknown size. The backend, instead of the core library, is responsible for serializing and deserializing the packets. Because it's extreme slow for WebUSB backend (`@yume-chan/adb-backend-webusb`) to read packets with unknown size.
## Authentication ## Authentication
@ -133,7 +131,7 @@ static async authenticate(
Call this method to authenticate the connection and create an `Adb` instance. Call this method to authenticate the connection and create an `Adb` instance.
It's possible to call `authenticate` multiple times on a single connection, every time the device receives a `CNXN` packet, it resets its internal state, and starts a new authentication process. If an authentication process failed, it's possible to call `authenticate` again on the same connection (`AdbPacket` stream pair). Every time the device receives a `CNXN` packet, it resets all internal state, and starts a new authentication process.
## Stream multiplex ## Stream multiplex
@ -144,27 +142,36 @@ ADB commands are all based on streams. Multiple streams can send and receive at
3. Client and server read/write on the stream. 3. Client and server read/write on the stream.
4. Client/server sends a `CLSE` to close the stream. 4. Client/server sends a `CLSE` to close the stream.
The `Backend` is responsible for reading and writing data from underlying source.
### Backend
## Commands ## Commands
### childProcess ### subprocess
Spawns child process on server. ADB has two shell modes: ADB has two subprocess invocation modes and two data protocols (4 combinations).
| | Legacy mode | Shell Protocol | #### raw mode
In raw mode, Shell protocol transfers `stdout` and `stderr` separately. It also supports returning exit code.
| | Legacy protocol | Shell Protocol |
| --------------------------- | --------------------------- | ---------------------------- | | --------------------------- | --------------------------- | ---------------------------- |
| Feature flag | - | `shell_v2` | | Feature flag | - | `shell_v2` |
| Implementation | `AdbNoneSubprocessProtocol` | `AdbShellSubprocessProtocol` | | Implementation | `AdbNoneSubprocessProtocol` | `AdbShellSubprocessProtocol` |
| Splitting stdout and stderr | No | Yes | | Splitting stdout and stderr | No | Yes |
| Returning exit code | No | Yes | | Returning exit code | No | Yes |
Use `spawn` method to create a subprocess in raw mode.
#### pty mode
In PTY mode, the subprocess has a pseudo-terminal, so it can send special control sequences like clear screen and set cursor position. The two protocols both send data in `stdout`, but Shell Protocol also supports resizing the terminal from client.
| | Legacy protocol | Shell Protocol |
| --------------------------- | --------------------------- | ---------------------------- |
| Feature flag | - | `shell_v2` |
| Implementation | `AdbNoneSubprocessProtocol` | `AdbShellSubprocessProtocol` |
| Resizing window | No | Yes | | Resizing window | No | Yes |
The `Adb#childProcess#shell` and `Adb#childProcess#spawn` methods accepts a list of implementations, and will use the first supported one. Use `shell` method to create a subprocess in PTY mode.
For simple command invocation, usually the `AdbNoneSubprocessProtocol` is enough.
### usb ### usb

View file

@ -3,7 +3,7 @@ import Struct, { placeholder, type StructValueType } from "@yume-chan/struct";
import type { Adb } from "../../../adb.js"; import type { Adb } from "../../../adb.js";
import { AdbFeatures } from "../../../features.js"; import { AdbFeatures } from "../../../features.js";
import type { AdbSocket } from "../../../socket/index.js"; import type { AdbSocket } from "../../../socket/index.js";
import { PushReadableStream, ReadableStream, StructDeserializeStream, StructSerializeStream, TransformStream, WritableStream, WritableStreamDefaultWriter, type PushReadableStreamController } from "../../../stream/index.js"; import { pipeFrom, PushReadableStream, ReadableStream, StructDeserializeStream, StructSerializeStream, TransformStream, WritableStream, WritableStreamDefaultWriter, type PushReadableStreamController } from "../../../stream/index.js";
import { encodeUtf8 } from "../../../utils/index.js"; import { encodeUtf8 } from "../../../utils/index.js";
import type { AdbSubprocessProtocol } from "./types.js"; import type { AdbSubprocessProtocol } from "./types.js";
@ -144,7 +144,7 @@ export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol {
transform: (chunk, controller) => { transform: (chunk, controller) => {
if (chunk.id === AdbShellProtocolId.Exit) { if (chunk.id === AdbShellProtocolId.Exit) {
this._exit.resolve(new Uint8Array(chunk.data)[0]!); this._exit.resolve(new Uint8Array(chunk.data)[0]!);
// We can let `StdoutTransformStream` to process `AdbShellProtocolId.Exit`, // We can let `StdoutDeserializeStream` to process `AdbShellProtocolId.Exit`,
// but since we need this `TransformStream` to capture the exit code anyway, // but since we need this `TransformStream` to capture the exit code anyway,
// terminating child streams here is killing two birds with one stone. // terminating child streams here is killing two birds with one stone.
controller.terminate(); controller.terminate();
@ -164,9 +164,10 @@ export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol {
.pipeThrough(new StructSerializeStream(AdbShellProtocolPacket)) .pipeThrough(new StructSerializeStream(AdbShellProtocolPacket))
.pipeTo(socket.writable); .pipeTo(socket.writable);
const { readable, writable } = new StdinSerializeStream(); this._stdin = pipeFrom(
this._stdin = writable; multiplexer.createWriteable(),
readable.pipeTo(multiplexer.createWriteable()); new StdinSerializeStream()
);
this._socketWriter = multiplexer.createWriteable().getWriter(); this._socketWriter = multiplexer.createWriteable().getWriter();
} }

View file

@ -151,7 +151,7 @@ export class AdbSync extends AutoDisposable {
if (this.needPushMkdirWorkaround) { if (this.needPushMkdirWorkaround) {
// It may fail if the path is already existed. // It may fail if the path is already existed.
// Ignore the result. // Ignore the result.
// TODO: test this // TODO: sync: test this
await this.adb.subprocess.spawnAndWait([ await this.adb.subprocess.spawnAndWait([
'mkdir', 'mkdir',
'-p', '-p',

View file

@ -45,9 +45,6 @@ export class AdbPacketDispatcher extends AutoDisposable {
private readonly incomingSocketEvent = this.addDisposable(new EventEmitter<AdbIncomingSocketEventArgs>()); private readonly incomingSocketEvent = this.addDisposable(new EventEmitter<AdbIncomingSocketEventArgs>());
public get onIncomingSocket() { return this.incomingSocketEvent.event; } public get onIncomingSocket() { return this.incomingSocketEvent.event; }
private readonly errorEvent = this.addDisposable(new EventEmitter<Error>());
public get onError() { return this.errorEvent.event; }
private _abortController = new AbortController(); private _abortController = new AbortController();
public constructor( public constructor(
@ -83,8 +80,6 @@ export class AdbPacketDispatcher extends AutoDisposable {
return; return;
} }
} catch (e) { } catch (e) {
this.errorEvent.fire(e as Error);
// Throw error here will stop the pipe // Throw error here will stop the pipe
// But won't close `readable` because of `preventCancel: true` // But won't close `readable` because of `preventCancel: true`
throw e; throw e;
@ -96,8 +91,8 @@ export class AdbPacketDispatcher extends AutoDisposable {
}) })
.then(() => { .then(() => {
this.dispose(); this.dispose();
}, () => { }, (e) => {
// TODO: AdbPacketDispatcher: reject `_disconnected` when pipe errored? this._disconnected.reject(e);
this.dispose(); this.dispose();
}); });

View file

@ -475,8 +475,8 @@ export class PushReadableStream<T> extends ReadableStream<T> {
waterMarkLow?.resolve(); waterMarkLow?.resolve();
}, },
cancel: async (reason) => { cancel: async (reason) => {
waterMarkLow?.reject(reason);
canceled.abort(); canceled.abort();
waterMarkLow?.reject(reason);
}, },
}, strategy); }, strategy);
} }

View file

@ -1,13 +0,0 @@
// @ts-expect-error @types/node missing `TextEncoder`
const Utf8Encoder = new TextEncoder();
// @ts-expect-error @types/node missing `TextDecoder`
const Utf8Decoder = new TextDecoder();
export function encodeUtf8(input: string): Uint8Array {
return Utf8Encoder.encode(input);
}
export function decodeUtf8(buffer: ArrayBufferView | ArrayBuffer): string {
return Utf8Decoder.decode(buffer);
}

View file

@ -1,3 +1,3 @@
export { decodeUtf8, encodeUtf8 } from '@yume-chan/struct';
export * from './auto-reset-event.js'; export * from './auto-reset-event.js';
export * from './base64.js'; export * from './base64.js';
export * from './encoding.js';

View file

@ -35,6 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@yume-chan/adb": "^0.0.12", "@yume-chan/adb": "^0.0.12",
"@yume-chan/struct": "^0.0.12",
"tslib": "^2.3.1" "tslib": "^2.3.1"
} }
} }

View file

@ -1,3 +1,6 @@
// cspell: ignore logcat
export * from './bug-report.js'; export * from './bug-report.js';
export * from './demo-mode.js'; export * from './demo-mode.js';
export * from './logcat.js';
export * from './settings.js'; export * from './settings.js';

View file

@ -0,0 +1,187 @@
// cspell: ignore logcat
import { AdbCommandBase, BufferedStream, BufferedStreamEndedError, DecodeUtf8Stream, ReadableStream, SplitLineStream, WritableStream } from "@yume-chan/adb";
import Struct, { StructAsyncDeserializeStream } 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,
}
export enum LogPriority {
Unknown,
Default,
Verbose,
Info,
Warn,
Error,
Fatal,
Silent,
}
export interface LogcatOptions {
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('second')
.uint32('nanoseconds')
.uint32('logId')
.uint32('uid')
.extra({
get timestamp() {
return BigInt(this.second) * NANOSECONDS_PER_SECOND + BigInt(this.nanoseconds);
},
});
export type LoggerEntry = typeof LoggerEntry['TDeserializeResult'];
export interface LogMessage extends LoggerEntry {
priority: LogPriority;
payload: Uint8Array;
}
export async function deserializeLogMessage(stream: StructAsyncDeserializeStream): Promise<LogMessage> {
const entry = await LoggerEntry.deserialize(stream);
await stream.read(entry.headerSize - LoggerEntry.size);
const priority = (await stream.read(1))[0] as LogPriority;
const payload = await stream.read(entry.payloadSize - 1);
return { ...entry, priority, payload };
}
export interface LogSize {
id: LogId;
size: number;
readable?: number;
consumed: number;
maxEntrySize: number;
maxPayloadSize: number;
}
export class Logcat extends AdbCommandBase {
public static logIdToName(id: LogId): string {
return LogId[id]!;
}
public static logNameToId(name: string): LogId {
const key = name[0]!.toUpperCase() + name.substring(1);
return (LogId as any)[key];
}
public static joinLogId(ids: LogId[]): string {
return ids.map(id => Logcat.logIdToName(id)).join(',');
}
public 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
public static readonly LOG_SIZE_REGEX_10 = /(.*): 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
public static readonly LOG_SIZE_REGEX_11 = /(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed, (.*) (.*)B readable\), max entry is (.*) B, max payload is (.*) B/;
public 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 DecodeUtf8Stream())
.pipeThrough(new SplitLineStream())
.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),
});
}
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;
}
public async clear(ids?: LogId[]) {
await this.adb.subprocess.spawnAndWait([
'logcat',
'-c',
...(ids ? ['-b', Logcat.joinLogId(ids)] : []),
]);
}
public binary(options?: LogcatOptions): ReadableStream<LogMessage> {
let bufferedStream: BufferedStream;
return new ReadableStream({
start: async () => {
const { stdout } = await this.adb.subprocess.spawn([
'logcat',
'-B',
...(options?.pid ? ['--pid', options.pid.toString()] : []),
...(options?.ids ? ['-b', Logcat.joinLogId(options.ids)] : [])
]);
bufferedStream = new BufferedStream(stdout);
},
async pull(controller) {
try {
const entry = await deserializeLogMessage(bufferedStream);
controller.enqueue(entry);
} catch (e) {
if (e instanceof BufferedStreamEndedError) {
controller.close();
return;
}
throw e;
}
},
cancel() {
bufferedStream.close();
},
});
}
}

View file

@ -3,14 +3,3 @@ export function delay(time: number): Promise<void> {
(globalThis as any).setTimeout(resolve, time); (globalThis as any).setTimeout(resolve, time);
}); });
} }
const Utf8Encoder = new TextEncoder();
const Utf8Decoder = new TextDecoder();
export function encodeUtf8(input: string): ArrayBuffer {
return Utf8Encoder.encode(input).buffer;
}
export function decodeUtf8(buffer: ArrayBuffer): string {
return Utf8Decoder.decode(buffer);
}

View file

@ -54,7 +54,7 @@ export function placeholder<T>(): T {
// Node.js 8.3 ships `TextEncoder` and `TextDecoder` in `util` module. // Node.js 8.3 ships `TextEncoder` and `TextDecoder` in `util` module.
// But using top level await to load them requires Node.js 14.1. // But using top level await to load them requires Node.js 14.1.
// So there is no point to do that. Let's just assume they exists in global. // So there is no point to do that. Let's just assume they exist in global.
// @ts-expect-error // @ts-expect-error
const Utf8Encoder = new TextEncoder(); const Utf8Encoder = new TextEncoder();
@ -65,6 +65,6 @@ export function encodeUtf8(input: string): Uint8Array {
return Utf8Encoder.encode(input); return Utf8Encoder.encode(input);
} }
export function decodeUtf8(buffer: Uint8Array): string { export function decodeUtf8(buffer: ArrayBufferView | ArrayBuffer): string {
return Utf8Decoder.decode(buffer); return Utf8Decoder.decode(buffer);
} }