diff --git a/apps/demo/src/components/connect.tsx b/apps/demo/src/components/connect.tsx index 4093e4a0..e550b844 100644 --- a/apps/demo/src/components/connect.tsx +++ b/apps/demo/src/components/connect.tsx @@ -162,6 +162,9 @@ function _Connect(): JSX.Element | null { device = await Adb.authenticate({ readable, writable }, CredentialStore, undefined); device.disconnected.then(() => { globalState.setDevice(undefined, undefined); + }, (e) => { + globalState.showErrorDialog(e); + globalState.setDevice(undefined, undefined); }); globalState.setDevice(selectedBackend, device); } catch (e) { @@ -170,7 +173,7 @@ function _Connect(): JSX.Element | null { } } } catch (e: any) { - globalState.showErrorDialog(e.message); + globalState.showErrorDialog(e); } finally { setConnecting(false); } @@ -180,7 +183,7 @@ function _Connect(): JSX.Element | null { await globalState.device!.dispose(); globalState.setDevice(undefined, undefined); } catch (e: any) { - globalState.showErrorDialog(e.message); + globalState.showErrorDialog(e); } }, []); diff --git a/apps/demo/src/pages/file-manager.tsx b/apps/demo/src/pages/file-manager.tsx index be0f6ae5..74517e44 100644 --- a/apps/demo/src/pages/file-manager.tsx +++ b/apps/demo/src/pages/file-manager.tsx @@ -140,8 +140,8 @@ class FileManagerState { const itemPath = path.resolve(this.path, item.name); await sync.read(itemPath) .pipeTo(saveFile(item.name, Number(item.size))); - } catch (e) { - globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); + } catch (e: any) { + globalState.showErrorDialog(e); } finally { sync.dispose(); } @@ -169,8 +169,8 @@ class FileManagerState { return; } } - } catch (e) { - globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); + } catch (e: any) { + globalState.showErrorDialog(e); } finally { this.loadFiles(); } @@ -481,8 +481,8 @@ class FileManagerState { } finally { clearInterval(intervalId); } - } catch (e) { - globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); + } catch (e: any) { + globalState.showErrorDialog(e); } finally { sync.dispose(); this.loadFiles(); diff --git a/apps/demo/src/pages/framebuffer.tsx b/apps/demo/src/pages/framebuffer.tsx index 5c9a8b16..91a8b649 100644 --- a/apps/demo/src/pages/framebuffer.tsx +++ b/apps/demo/src/pages/framebuffer.tsx @@ -45,8 +45,8 @@ const FrameBuffer: NextPage = (): JSX.Element | null => { try { const framebuffer = await globalState.device.framebuffer(); state.setImage(framebuffer); - } catch (e) { - globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); + } catch (e: any) { + globalState.showErrorDialog(e); } }, []); diff --git a/apps/demo/src/pages/logcat.tsx b/apps/demo/src/pages/logcat.tsx new file mode 100644 index 00000000..00c1645a --- /dev/null +++ b/apps/demo/src/pages/logcat.tsx @@ -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 | 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 ( +
+ {item.timeString} +
+ ); + } + }, + { + width: 80, + title: 'PID', + CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => { + const item = this.list[rowIndex]; + + const classes = useClasses(); + + return ( +
+ {item.pid} +
+ ); + } + }, + { + width: 80, + title: 'TID', + CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => { + const item = this.list[rowIndex]; + + const classes = useClasses(); + + return ( +
+ {item.tid} +
+ ); + } + }, + { + width: 100, + title: 'Priority', + CellComponent: ({ rowIndex, columnIndex, className, ...rest }) => { + const item = this.list[rowIndex]; + + const classes = useClasses(); + + return ( +
+ {LogPriority[item.priority]} +
+ ); + } + }, + { + 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 ( +
+ {item.payloadString} +
+ ); + } + }, + ]; + }, +}, { + 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 ( +
+ {state.columns[columnIndex].title} +
+ ); +}); + +const Row = observer(function Row({ + className, + rowIndex, + ...rest +}: GridRowProps) { + const item = state.list[rowIndex]; + const classes = useClasses(); + + const handleClick = useCallbackRef(() => { + runInAction(() => { + }); + }); + + return ( +
+ ); +}); + +const LogcatPage: NextPage = () => { + const classes = useClasses(); + + return ( + + + Logcat - Android Web Toolbox + + + + + + + + + ); +}; + +export default observer(LogcatPage); diff --git a/apps/demo/src/pages/packet-log.tsx b/apps/demo/src/pages/packet-log.tsx index bbdf228a..b3204409 100644 --- a/apps/demo/src/pages/packet-log.tsx +++ b/apps/demo/src/pages/packet-log.tsx @@ -5,7 +5,6 @@ import { autorun, makeAutoObservable, observable, runInAction } from "mobx"; import { observer } from "mobx-react-lite"; import { NextPage } from "next"; import Head from "next/head"; -import { useMemo } from "react"; import { CommandBar, Grid, GridCellProps, GridColumn, GridHeaderProps, GridRowProps, HexViewer, toText } from "../components"; import { globalState, PacketLogItem } from "../state"; import { Icons, RouteStackProps, useCallbackRef, withDisplayName } from "../utils"; @@ -89,158 +88,160 @@ const useClasses = makeStyles({ }, }); -const PacketLog: NextPage = () => { - const classes = useClasses(); +const columns: Column[] = [ + { + title: 'Direction', + width: 100, + CellComponent: withDisplayName('Direction')(({ className, rowIndex, ...rest }: GridCellProps) => { + const item = globalState.logs[rowIndex]; - const columns: Column[] = useMemo(() => [ - { - key: 'direction', - title: 'Direction', - width: 100, - CellComponent: withDisplayName('Direction')(({ className, rowIndex, ...rest }: GridCellProps) => { - const item = globalState.logs[rowIndex]; - - return ( -
- {item.direction} -
- ); - }), - }, - { - 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 ( -
- {item.commandString} -
- ); - }), - }, - { - 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 ( -
- {item.arg0String} -
- ); - }), - }, - { - 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 ( -
- {item.arg1String} -
- ); - }), - }, - { - 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 ( -
- {item.payloadString} -
- ); - }), - }, - ], [classes.code]); - - const Header = useMemo( - () => withDisplayName('Header')(({ - className, - columnIndex, - ...rest - }: GridHeaderProps) => { - return ( -
- {columns[columnIndex].title} -
- ); - }), - [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]; - }); - }); + const classes = useClasses(); return (
+ > + {item.direction} +
); }), - [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 ( +
+ {item.commandString} +
+ ); + }), + }, + { + 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 ( +
+ {item.arg0String} +
+ ); + }), + }, + { + 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 ( +
+ {item.arg1String} +
+ ); + }), + }, + { + 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 ( +
+ {item.payloadString} +
+ ); + }), + }, +]; + +const Header = withDisplayName('Header')(({ + className, + columnIndex, + ...rest +}: GridHeaderProps) => { + const classes = useClasses(); + + return ( +
+ {columns[columnIndex].title} +
); +}); + +const Row = observer(function Row({ + className, + rowIndex, + ...rest +}: GridRowProps) { + const classes = useClasses(); + + const handleClick = useCallbackRef(() => { + runInAction(() => { + state.selectedPacket = globalState.logs[rowIndex]; + }); + }); + + return ( +
+ ); +}); + +const PacketLog: NextPage = () => { + const classes = useClasses(); return ( diff --git a/apps/demo/src/pages/scrcpy.tsx b/apps/demo/src/pages/scrcpy.tsx index d88b7b31..28a73569 100644 --- a/apps/demo/src/pages/scrcpy.tsx +++ b/apps/demo/src/pages/scrcpy.tsx @@ -519,7 +519,7 @@ class ScrcpyPageState { this.running = true; }); } catch (e: any) { - globalState.showErrorDialog(e.message); + globalState.showErrorDialog(e); } finally { runInAction(() => { this.connecting = false; diff --git a/apps/demo/src/pages/shell.tsx b/apps/demo/src/pages/shell.tsx index d94384a1..985400c9 100644 --- a/apps/demo/src/pages/shell.tsx +++ b/apps/demo/src/pages/shell.tsx @@ -51,8 +51,8 @@ const Shell: NextPage = (): JSX.Element | null => { connectingRef.current = true; const socket = await globalState.device.subprocess.shell(); terminal.socket = socket; - } catch (e) { - globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); + } catch (e: any) { + globalState.showErrorDialog(e); } finally { connectingRef.current = false; } diff --git a/apps/demo/src/state/global.ts b/apps/demo/src/state/global.ts index 9797f827..cb4bd5dc 100644 --- a/apps/demo/src/state/global.ts +++ b/apps/demo/src/state/global.ts @@ -34,9 +34,13 @@ export class GlobalState { this.device = device; } - showErrorDialog(message: string) { + showErrorDialog(message: Error | string) { this.errorDialogVisible = true; - this.errorDialogMessage = message; + if (message instanceof Error) { + this.errorDialogMessage = message.stack!; + } else { + this.errorDialogMessage = message; + } } hideErrorDialog() { diff --git a/apps/demo/src/utils/b-tree.ts b/apps/demo/src/utils/b-tree.ts new file mode 100644 index 00000000..68beac3d --- /dev/null +++ b/apps/demo/src/utils/b-tree.ts @@ -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); + } +} diff --git a/libraries/adb-credential-web/src/index.ts b/libraries/adb-credential-web/src/index.ts index 88b871f3..42f5ead9 100644 --- a/libraries/adb-credential-web/src/index.ts +++ b/libraries/adb-credential-web/src/index.ts @@ -1,17 +1,6 @@ // cspell: ignore RSASSA -import { calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, 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); -} +import { calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, decodeUtf8, encodeBase64, type AdbCredentialStore } from "@yume-chan/adb"; export default class AdbWebCredentialStore implements AdbCredentialStore { public readonly localStorageKey: string; diff --git a/libraries/adb/README.md b/libraries/adb/README.md index 443c61a0..78d7acb1 100644 --- a/libraries/adb/README.md +++ b/libraries/adb/README.md @@ -18,9 +18,10 @@ TypeScript implementation of Android Debug Bridge (ADB) protocol. - [AdbAuthenticator](#adbauthenticator) - [`authenticate`](#authenticate) - [Stream multiplex](#stream-multiplex) - - [Backend](#backend-1) - [Commands](#commands) - - [childProcess](#childprocess) + - [subprocess](#subprocess) + - [raw mode](#raw-mode) + - [pty mode](#pty-mode) - [usb](#usb) - [tcpip](#tcpip) - [sync](#sync) @@ -48,10 +49,9 @@ Each backend may have different requirements. | | Chrome | Edge | Firefox | Internet Explorer | Safari | Node.js | | ------------------------------- | ------ | ---- | ------- | ----------------- | ------ | ------------------- | | `@yume-chan/struct`1 | 67 | 79 | 68 | No | 14 | 8.32, 11 | -| [Streams][MDN_Streams] | 67 | 79 | No | No | 14.1 | 16.5 | | *Overall* | 67 | 79 | No | No | 14.1 | 16.5 | -1 `uint64` and `string` used. +1 `uint64` and `string` are used. 2 `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 | -[MDN_Streams]: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API - ## Connection This library doesn't tie to a specific transportation method. @@ -79,7 +77,7 @@ connect(): ValueOrPromise> 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 @@ -133,7 +131,7 @@ static async authenticate( 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 @@ -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. 4. Client/server sends a `CLSE` to close the stream. -The `Backend` is responsible for reading and writing data from underlying source. - -### Backend - ## 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` | | Implementation | `AdbNoneSubprocessProtocol` | `AdbShellSubprocessProtocol` | | Splitting stdout and stderr | 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 | -The `Adb#childProcess#shell` and `Adb#childProcess#spawn` methods accepts a list of implementations, and will use the first supported one. - -For simple command invocation, usually the `AdbNoneSubprocessProtocol` is enough. +Use `shell` method to create a subprocess in PTY mode. ### usb diff --git a/libraries/adb/src/commands/subprocess/protocols/shell.ts b/libraries/adb/src/commands/subprocess/protocols/shell.ts index 1e89ca1e..7d45d351 100644 --- a/libraries/adb/src/commands/subprocess/protocols/shell.ts +++ b/libraries/adb/src/commands/subprocess/protocols/shell.ts @@ -3,7 +3,7 @@ import Struct, { placeholder, type StructValueType } from "@yume-chan/struct"; import type { Adb } from "../../../adb.js"; import { AdbFeatures } from "../../../features.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 type { AdbSubprocessProtocol } from "./types.js"; @@ -144,7 +144,7 @@ export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol { transform: (chunk, controller) => { if (chunk.id === AdbShellProtocolId.Exit) { 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, // terminating child streams here is killing two birds with one stone. controller.terminate(); @@ -164,9 +164,10 @@ export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol { .pipeThrough(new StructSerializeStream(AdbShellProtocolPacket)) .pipeTo(socket.writable); - const { readable, writable } = new StdinSerializeStream(); - this._stdin = writable; - readable.pipeTo(multiplexer.createWriteable()); + this._stdin = pipeFrom( + multiplexer.createWriteable(), + new StdinSerializeStream() + ); this._socketWriter = multiplexer.createWriteable().getWriter(); } diff --git a/libraries/adb/src/commands/sync/sync.ts b/libraries/adb/src/commands/sync/sync.ts index d2096b43..41337881 100644 --- a/libraries/adb/src/commands/sync/sync.ts +++ b/libraries/adb/src/commands/sync/sync.ts @@ -151,7 +151,7 @@ export class AdbSync extends AutoDisposable { if (this.needPushMkdirWorkaround) { // It may fail if the path is already existed. // Ignore the result. - // TODO: test this + // TODO: sync: test this await this.adb.subprocess.spawnAndWait([ 'mkdir', '-p', diff --git a/libraries/adb/src/socket/dispatcher.ts b/libraries/adb/src/socket/dispatcher.ts index 89ef9530..a3d4b338 100644 --- a/libraries/adb/src/socket/dispatcher.ts +++ b/libraries/adb/src/socket/dispatcher.ts @@ -45,9 +45,6 @@ export class AdbPacketDispatcher extends AutoDisposable { private readonly incomingSocketEvent = this.addDisposable(new EventEmitter()); public get onIncomingSocket() { return this.incomingSocketEvent.event; } - private readonly errorEvent = this.addDisposable(new EventEmitter()); - public get onError() { return this.errorEvent.event; } - private _abortController = new AbortController(); public constructor( @@ -83,8 +80,6 @@ export class AdbPacketDispatcher extends AutoDisposable { return; } } catch (e) { - this.errorEvent.fire(e as Error); - // Throw error here will stop the pipe // But won't close `readable` because of `preventCancel: true` throw e; @@ -96,8 +91,8 @@ export class AdbPacketDispatcher extends AutoDisposable { }) .then(() => { this.dispose(); - }, () => { - // TODO: AdbPacketDispatcher: reject `_disconnected` when pipe errored? + }, (e) => { + this._disconnected.reject(e); this.dispose(); }); diff --git a/libraries/adb/src/stream/transform.ts b/libraries/adb/src/stream/transform.ts index 098cba12..71ba0f99 100644 --- a/libraries/adb/src/stream/transform.ts +++ b/libraries/adb/src/stream/transform.ts @@ -475,8 +475,8 @@ export class PushReadableStream extends ReadableStream { waterMarkLow?.resolve(); }, cancel: async (reason) => { - waterMarkLow?.reject(reason); canceled.abort(); + waterMarkLow?.reject(reason); }, }, strategy); } diff --git a/libraries/adb/src/utils/encoding.ts b/libraries/adb/src/utils/encoding.ts deleted file mode 100644 index 29093ffc..00000000 --- a/libraries/adb/src/utils/encoding.ts +++ /dev/null @@ -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); -} diff --git a/libraries/adb/src/utils/index.ts b/libraries/adb/src/utils/index.ts index 92f6cdbb..fc514ee2 100644 --- a/libraries/adb/src/utils/index.ts +++ b/libraries/adb/src/utils/index.ts @@ -1,3 +1,3 @@ +export { decodeUtf8, encodeUtf8 } from '@yume-chan/struct'; export * from './auto-reset-event.js'; export * from './base64.js'; -export * from './encoding.js'; diff --git a/libraries/android-bin/package.json b/libraries/android-bin/package.json index f37eb5a4..4810207a 100644 --- a/libraries/android-bin/package.json +++ b/libraries/android-bin/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@yume-chan/adb": "^0.0.12", + "@yume-chan/struct": "^0.0.12", "tslib": "^2.3.1" } } diff --git a/libraries/android-bin/src/index.ts b/libraries/android-bin/src/index.ts index c9c23c66..829476e8 100644 --- a/libraries/android-bin/src/index.ts +++ b/libraries/android-bin/src/index.ts @@ -1,3 +1,6 @@ +// cspell: ignore logcat + export * from './bug-report.js'; export * from './demo-mode.js'; +export * from './logcat.js'; export * from './settings.js'; diff --git a/libraries/android-bin/src/logcat.ts b/libraries/android-bin/src/logcat.ts new file mode 100644 index 00000000..dc0cb6c8 --- /dev/null +++ b/libraries/android-bin/src/logcat.ts @@ -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 { + 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 { + 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 { + 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(); + }, + }); + } +} diff --git a/libraries/scrcpy/src/utils.ts b/libraries/scrcpy/src/utils.ts index 09658664..4a698a96 100644 --- a/libraries/scrcpy/src/utils.ts +++ b/libraries/scrcpy/src/utils.ts @@ -3,14 +3,3 @@ export function delay(time: number): Promise { (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); -} diff --git a/libraries/struct/src/utils.ts b/libraries/struct/src/utils.ts index 410ae8b2..df77eafb 100644 --- a/libraries/struct/src/utils.ts +++ b/libraries/struct/src/utils.ts @@ -54,7 +54,7 @@ export function placeholder(): T { // Node.js 8.3 ships `TextEncoder` and `TextDecoder` in `util` module. // 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 const Utf8Encoder = new TextEncoder(); @@ -65,6 +65,6 @@ export function encodeUtf8(input: string): Uint8Array { return Utf8Encoder.encode(input); } -export function decodeUtf8(buffer: Uint8Array): string { +export function decodeUtf8(buffer: ArrayBufferView | ArrayBuffer): string { return Utf8Decoder.decode(buffer); }