feat(demo): wip: add new packet log

ref #397
This commit is contained in:
Simon Chan 2022-04-11 01:50:43 +08:00
parent 4cec3ec55c
commit bb5e292a80
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
69 changed files with 1640 additions and 618 deletions

View file

@ -1,171 +0,0 @@
import { IconButton, IListProps, List, mergeStyles, mergeStyleSets, Stack } from '@fluentui/react';
import { AdbCommand, AdbPacketCore, decodeUtf8 } from '@yume-chan/adb';
import { observer } from "mobx-react-lite";
import { PropsWithChildren, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { globalState } from "../state";
import { Icons, withDisplayName } from '../utils';
import { CommandBar } from './command-bar';
const classNames = mergeStyleSets({
'logger-container': {
width: 300,
},
grow: {
flexGrow: 1,
height: 0,
padding: '0 8px',
overflowX: 'hidden',
overflowY: 'auto',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
},
});
const ADB_COMMAND_NAME = {
[AdbCommand.Auth]: 'AUTH',
[AdbCommand.Close]: 'CLSE',
[AdbCommand.Connect]: 'CNXN',
[AdbCommand.OK]: 'OKAY',
[AdbCommand.Open]: 'OPEN',
[AdbCommand.Write]: 'WRTE',
};
function serializePacket(packet: AdbPacketCore) {
const command =
ADB_COMMAND_NAME[packet.command as AdbCommand] ??
decodeUtf8(new Uint32Array([packet.command]));
const parts = [
command,
packet.arg0.toString(16).padStart(8, '0'),
packet.arg1.toString(16).padStart(8, '0'),
];
if (packet.payload) {
parts.push(
Array.from(
packet.payload,
byte => byte.toString(16).padStart(2, '0')
).join(' ')
);
}
return parts.join(' ');
}
const LogLine = withDisplayName('LoggerLine')(({ packet }: { packet: [string, AdbPacketCore]; }) => {
const string = useMemo(() => serializePacket(packet[1]), [packet]);
return (
<>
{packet[0]}{' '}{string}
</>
);
});
export const ToggleLogView = observer(() => {
return (
<IconButton
checked={globalState.logVisible}
iconProps={{ iconName: Icons.TextGrammarError }}
title="Toggle Log"
onClick={globalState.toggleLog}
/>
);
});
export interface LoggerProps {
className?: string;
}
function shouldVirtualize(props: IListProps<[string, AdbPacketCore]>) {
return !!props.items && props.items.length > 100;
}
function renderCell(item?: [string, AdbPacketCore]) {
if (!item) {
return null;
}
return (
<LogLine packet={item} />
);
}
export const LogView = observer(({
className,
}: LoggerProps) => {
const scrollerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const scroller = scrollerRef.current;
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
});
const commandBarItems = useMemo(() => [
{
key: 'Copy',
text: 'Copy',
iconProps: { iconName: Icons.Copy },
onClick: () => {
window.navigator.clipboard.writeText(
globalState.logs
.map(
([direction, packet]) => `${direction}${serializePacket((packet))}`
)
.join('\n'));
},
},
{
key: 'Clear',
text: 'Clear',
iconProps: { iconName: Icons.Delete },
onClick: () => {
globalState.clearLog();
},
},
], []);
const mergedClassName = useMemo(() => mergeStyles(
className,
classNames['logger-container'],
), [className]);
if (!globalState.logVisible) {
return null;
}
return (
<Stack
className={mergedClassName}
verticalFill
>
<CommandBar items={commandBarItems} />
<div ref={scrollerRef} className={classNames.grow}>
<List
items={globalState.logs}
onShouldVirtualize={shouldVirtualize}
onRenderCell={renderCell}
/>
</div>
</Stack>
);
});
export function NoSsr({ children }: PropsWithChildren<{}>) {
const [showChild, setShowChild] = useState(false);
// Wait until after client-side hydration to show
useEffect(() => {
setShowChild(true);
}, []);
if (!showChild) {
return null;
}
return <>{children}</>;
}

View file

@ -31,6 +31,7 @@
"next": "12.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-window": "1.8.6",
"streamsaver": "^2.0.5",
"xterm": "^4.17.0",
"xterm-addon-fit": "^0.5.0",
@ -41,8 +42,9 @@
"@mdx-js/loader": "^1.6.22",
"@next/mdx": "^11.1.2",
"@types/react": "17.0.27",
"@types/react-window": "^1.8.5",
"eslint": "8.8.0",
"eslint-config-next": "12.1.3",
"typescript": "next"
"typescript": "4.7.0-beta"
}
}

View file

@ -150,13 +150,13 @@ function _Connect(): JSX.Element | null {
const readable = streams.readable
.pipeThrough(
new InspectStream(packet => {
globalState.appendLog('Incoming', packet);
globalState.appendLog('in', packet);
})
);
const writable = pipeFrom(
streams.writable,
new InspectStream(packet => {
globalState.appendLog('Outgoing', packet);
globalState.appendLog('out', packet);
})
);
device = await Adb.authenticate({ readable, writable }, CredentialStore, undefined);

View file

@ -1,6 +1,6 @@
import { StackItem } from '@fluentui/react';
import { mergeStyleSets, StackItem } from '@fluentui/react';
import { ReactNode, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { ResizeObserver, Size } from '../utils/resize-observer';
import { ResizeObserver, Size } from './resize-observer';
import { forwardRef } from '../utils/with-display-name';
export interface DeviceViewProps {
@ -23,6 +23,21 @@ export const DeviceView = forwardRef<DeviceViewRef>('DeviceView')(({
bottomElement,
children,
}: DeviceViewProps, ref) => {
const styles = mergeStyleSets({
outer: {
width: '100%',
height: '100%',
backgroundColor: 'black',
},
inner: {
position: 'absolute',
transformOrigin: 'top left',
},
bottom: {
position: 'absolute',
},
});
const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 });
const [bottomSize, setBottomSize] = useState<Size>({ width: 0, height: 0 });
@ -82,6 +97,7 @@ export const DeviceView = forwardRef<DeviceViewRef>('DeviceView')(({
return (
<StackItem grow>
<ResizeObserver
className={styles.outer}
ref={containerRef}
style={{
position: 'relative',
@ -92,22 +108,21 @@ export const DeviceView = forwardRef<DeviceViewRef>('DeviceView')(({
onResize={setContainerSize}
>
<div
className={styles.inner}
style={{
position: 'absolute',
top: childrenStyle.top,
left: childrenStyle.left,
width,
height,
transform: `scale(${childrenStyle.scale})`,
transformOrigin: 'top left',
}}
>
{children}
</div>
<ResizeObserver
className={styles.bottom}
style={{
position: 'absolute',
top: childrenStyle.top + childrenStyle.height,
left: childrenStyle.left,
width: childrenStyle.width,

View file

@ -5,3 +5,4 @@ export * from './device-view';
export * from './error-dialog';
export * from './external-link';
export * from './log-view';
export * from './resize-observer';

View file

@ -0,0 +1,16 @@
import { PropsWithChildren, useEffect, useState } from 'react';
export function NoSsr({ children }: PropsWithChildren<{}>) {
const [showChild, setShowChild] = useState(false);
// Wait until after client-side hydration to show
useEffect(() => {
setShowChild(true);
}, []);
if (!showChild) {
return null;
}
return <>{children}</>;
}

View file

@ -1,6 +1,6 @@
import { createMergedRef } from '@fluentui/react';
import { CSSProperties, HTMLAttributes, PropsWithChildren, useCallback, useMemo, useRef } from 'react';
import { forwardRef } from './with-display-name';
import { CSSProperties, HTMLAttributes, PropsWithChildren, useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import { forwardRef, useCallbackRef } from '../utils';
export interface Size {
width: number;
@ -12,17 +12,6 @@ export interface ResizeObserverProps extends HTMLAttributes<HTMLDivElement>, Pro
onResize: (size: Size) => void;
}
export function useCallbackRef<TArgs extends any[], R>(callback: (...args: TArgs) => R): (...args: TArgs) => R {
const ref = useRef<(...args: TArgs) => R>(callback);
ref.current = callback;
const wrapper = useCallback((...args: TArgs) => {
return ref.current.apply(undefined, args);
}, []);
return wrapper;
}
const iframeStyle: CSSProperties = {
position: 'absolute',
top: 0,
@ -46,6 +35,10 @@ export const ResizeObserver = forwardRef<HTMLDivElement>('ResizeObserver')(({
onResize({ width, height });
});
useLayoutEffect(() => {
handleResize();
}, []);
const handleIframeRef = useCallback((element: HTMLIFrameElement | null) => {
if (element) {
element.contentWindow!.addEventListener('resize', handleResize);

View file

@ -3,8 +3,8 @@ import type { AppProps } from 'next/app';
import Head from "next/head";
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from "react";
import { Connect, ErrorDialogProvider, LogView, NoSsr, ToggleLogView } from "../components";
import { useCallback, useEffect, useState } from "react";
import { Connect, ErrorDialogProvider } from "../components";
import '../styles/globals.css';
import { Icons } from "../utils";
import { register as registerIcons } from '../utils/icons';
@ -62,6 +62,11 @@ const ROUTES = [
icon: Icons.Bug,
name: 'Bug Report',
},
{
url: '/packet-log',
icon: Icons.TextGrammarError,
name: 'Packet Log',
},
];
function NavLink({ link, defaultRender: DefaultRender, ...props }: IComponentAsProps<INavButtonProps>) {
@ -123,7 +128,7 @@ function App({ Component, pageProps }: AppProps) {
/>
<StackItem grow>
<div className={classNames.title}>WebADB Demo</div>
<div className={classNames.title}>Android Web Toolbox</div>
</StackItem>
<IconButton
@ -133,8 +138,6 @@ function App({ Component, pageProps }: AppProps) {
href="https://github.com/yume-chan/ya-webadb/issues/new"
target="_blank"
/>
<ToggleLogView />
</Stack>
<Stack grow horizontal verticalFill disableShrink styles={{ root: { minHeight: 0, overflow: 'hidden', lineHeight: '1.5' } }}>
@ -156,10 +159,6 @@ function App({ Component, pageProps }: AppProps) {
<StackItem grow styles={{ root: { width: 0 } }}>
<Component {...pageProps} />
</StackItem>
<NoSsr>
<LogView className={classNames['right-column']} />
</NoSsr>
</Stack>
</Stack>
</ErrorDialogProvider >

View file

@ -95,7 +95,7 @@ const BugReportPage: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>BugReport - WebADB</title>
<title>BugReport - Android Web Toolbox</title>
</Head>
<MessageBar messageBarType={MessageBarType.info}>This is the `bugreport`/`bugreportz` tool in Android</MessageBar>

View file

@ -587,7 +587,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>File Manager - WebADB</title>
<title>File Manager - Android Web Toolbox</title>
</Head>
<CommandBar items={state.menuItems} />

View file

@ -98,7 +98,7 @@ const Install: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Install APK - WebADB</title>
<title>Install APK - Android Web Toolbox</title>
</Head>
<Stack horizontal>

View file

@ -0,0 +1,283 @@
import { mergeStyleSets, Stack, StackItem } from "@fluentui/react";
import { AdbCommand, decodeUtf8 } from "@yume-chan/adb";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { Children, CSSProperties, forwardRef, isValidElement, ReactChildren, ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { VariableSizeGrid } from 'react-window';
import { ResizeObserver, Size } from "../components";
import { globalState, PacketLogItem } from "../state";
import { RouteStackProps, useCallbackRef } from "../utils";
const ADB_COMMAND_NAME = {
[AdbCommand.Auth]: 'AUTH',
[AdbCommand.Close]: 'CLSE',
[AdbCommand.Connect]: 'CNXN',
[AdbCommand.OK]: 'OKAY',
[AdbCommand.Open]: 'OPEN',
[AdbCommand.Write]: 'WRTE',
};
interface Column {
key: string;
title: string;
width?: number;
flexGrow?: number;
render: (value: PacketLogItem, style: CSSProperties) => JSX.Element;
}
const LINE_HEIGHT = 32;
const PacketLog: NextPage = () => {
const styles = mergeStyleSets({
tableContainer: {
width: '100%',
height: '100%',
},
table: {
position: 'absolute !important',
top: 0,
left: 0,
},
header: {
position: 'sticky',
textAlign: 'center',
fontWeight: 'bold',
lineHeight: LINE_HEIGHT + 'px',
},
code: {
fontFamily: 'monospace',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
lineHeight: LINE_HEIGHT + 'px',
}
});
const columns: Column[] = [
{
key: 'direction',
title: 'Direction',
width: 100,
render(item, style) {
return (
<div
className={styles.code}
style={style}
>
{item.direction}
</div>
);
},
},
{
key: 'command',
title: 'Command',
width: 100,
render(item, style) {
if (!item.commandString) {
item.commandString =
ADB_COMMAND_NAME[item.command as AdbCommand] ??
decodeUtf8(new Uint32Array([item.command]));
}
return (
<div
className={styles.code}
style={style}
>
{item.commandString}
</div>
);
}
},
{
key: 'arg0',
title: 'Arg0',
width: 100,
render(item, style) {
if (!item.arg0String) {
item.arg0String = item.arg0.toString(16).padStart(8, '0');
}
return (
<div
className={styles.code}
style={style}
>
{item.arg0String}
</div>
);
}
},
{
key: 'arg1',
title: 'Arg1',
width: 100,
render(item, style) {
if (!item.arg1String) {
item.arg1String = item.arg1.toString(16).padStart(8, '0');
}
return (
<div
className={styles.code}
style={style}
>
{item.arg1String}
</div>
);
}
},
{
key: 'payload',
title: 'Payload',
render(item, style) {
if (!item.payloadString) {
item.payloadString = decodeUtf8(item.payload);
}
return (
<div
className={styles.code}
style={style}
>
{item.payloadString}
</div>
);
}
}
];
const [tableSize, setTableSize] = useState<Size>({ width: 0, height: 0 });
const columnWidths = useMemo(() => {
let distributableWidth = tableSize.width;
let distributedSlices = 0;
for (const column of columns) {
if (column.width) {
distributableWidth -= column.width;
} else {
distributedSlices += column.flexGrow ?? 1;
}
}
const widthPerSlice = distributableWidth / distributedSlices;
return columns.map(column => {
if (column.width) {
return column.width;
} else {
return widthPerSlice * (column.flexGrow ?? 1);
}
});
}, [tableSize.width]);
const columnWidth = useCallbackRef((index: number) => columnWidths[index]);
const tableRef = useRef<VariableSizeGrid | null>(null);
useEffect(() => {
tableRef.current?.resetAfterColumnIndex(
columns.findIndex(column => !column.width),
true
);
}, [columnWidths]);
const innerElementType = useMemo(() =>
forwardRef<HTMLDivElement, any>(({ children, ...rest }: { children: ReactNode; }, ref) => {
let left = 0;
const { minColumn, maxColumn } = Children.toArray(children).reduce<{ minColumn: number; maxColumn: number; }>(
({ minColumn, maxColumn }, child) => {
if (!isValidElement(child)) {
return { minColumn, maxColumn };
}
const columnIndex = child.props.columnIndex as number;
return {
minColumn: Math.min(minColumn, columnIndex),
maxColumn: Math.max(maxColumn, columnIndex),
};
},
{
minColumn: Infinity,
maxColumn: -Infinity
}
);
const headers = [];
if (minColumn !== Infinity && maxColumn !== -Infinity) {
for (let i = 0; i < minColumn; i += 1) {
left += columnWidth(i);
}
const height = LINE_HEIGHT;
for (let i = minColumn; i <= maxColumn; i++) {
const width = columnWidth(i);
headers.push(
<div
key={columns[i].key}
style={{
position: 'absolute',
top: 0,
left,
width,
height,
}}
>
{columns[i].title}
</div>
);
left += width;
}
}
return (
<div ref={ref} {...rest}>
<div className={styles.header}>
{headers}
</div>
{children}
</div>
);
}),
[]
);
return (
<Stack {...RouteStackProps} tokens={{}}>
<Head>
<title>Packet Log - Android Web Toolbox</title>
</Head>
<StackItem grow shrink>
<ResizeObserver
className={styles.tableContainer}
onResize={setTableSize}
>
<VariableSizeGrid
ref={tableRef}
className={styles.table}
width={tableSize.width}
height={tableSize.height}
columnCount={columns.length}
columnWidth={columnWidth}
rowCount={globalState.logs.length + 1}
rowHeight={() => LINE_HEIGHT}
estimatedRowHeight={LINE_HEIGHT}
innerElementType={innerElementType}
>
{({ columnIndex, rowIndex, style }) => {
if (rowIndex === 0) {
return null;
}
const item = globalState.logs[rowIndex - 1];
return columns[columnIndex].render(item, style);
}}
</VariableSizeGrid>
</ResizeObserver>
</StackItem>
</Stack>
);
};
export default observer(PacketLog);

View file

@ -824,7 +824,7 @@ const Scrcpy: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Scrcpy - WebADB</title>
<title>Scrcpy - Android Web Toolbox</title>
</Head>
<CommandBar items={state.commandBarItems} farItems={state.commandBarFarItems} />

View file

@ -5,8 +5,9 @@ import { NextPage } from "next";
import Head from "next/head";
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import 'xterm/css/xterm.css';
import { ResizeObserver } from '../components';
import { globalState } from "../state";
import { Icons, ResizeObserver, RouteStackProps } from '../utils';
import { Icons, RouteStackProps } from '../utils';
let terminal: import('../components/terminal').AdbTerminal;
if (typeof window !== 'undefined') {
@ -80,7 +81,7 @@ const Shell: NextPage = (): JSX.Element | null => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Interactive Shell - WebADB</title>
<title>Interactive Shell - Android Web Toolbox</title>
</Head>
<StackItem>

View file

@ -141,7 +141,7 @@ const TcpIp: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>ADB over WiFi - WebADB</title>
<title>ADB over WiFi - Android Web Toolbox</title>
</Head>
<CommandBar items={state.commandBarItems} />
@ -149,7 +149,7 @@ const TcpIp: NextPage = () => {
<StackItem>
<MessageBar>
<Text>
For WebADB to wirelessly connect to your phone,
For Android Web Toolbox to wirelessly connect to your phone,
<ExternalLink href="https://github.com/yume-chan/ya-webadb/discussions/245#discussioncomment-384030" spaceBefore spaceAfter>extra software</ExternalLink>
is required.
</Text>

View file

@ -1,6 +1,17 @@
import { Adb, AdbBackend, AdbPacketCore } from "@yume-chan/adb";
import { Adb, AdbBackend, AdbPacketData } from "@yume-chan/adb";
import { action, makeAutoObservable, observable } from 'mobx';
export type PacketLogItemDirection = 'in' | 'out';
export interface PacketLogItem extends AdbPacketData {
direction: PacketLogItemDirection;
commandString?: string;
arg0String?: string;
arg1String?: string;
payloadString?: string;
}
export class GlobalState {
backend: AdbBackend | undefined = undefined;
@ -9,13 +20,11 @@ export class GlobalState {
errorDialogVisible = false;
errorDialogMessage = '';
logVisible = false;
logs: [string, AdbPacketCore][] = [];
logs: PacketLogItem[] = [];
constructor() {
makeAutoObservable(this, {
hideErrorDialog: action.bound,
toggleLog: action.bound,
logs: observable.shallow,
});
}
@ -34,12 +43,9 @@ export class GlobalState {
this.errorDialogVisible = false;
}
toggleLog() {
this.logVisible = !this.logVisible;
}
appendLog(direction: string, packet: AdbPacketCore) {
this.logs.push([direction, packet]);
appendLog(direction: PacketLogItemDirection, packet: AdbPacketData) {
(packet as PacketLogItem).direction = direction;
this.logs.push((packet as PacketLogItem));
}
clearLog() {

View file

@ -1,5 +1,5 @@
import { registerIcons } from "@fluentui/react";
import { AddCircleRegular, ArrowClockwiseRegular, ArrowSortDownRegular, ArrowSortUpRegular, BookmarkRegular, BoxRegular, BugRegular, CameraRegular, CheckmarkRegular, ChevronDownRegular, ChevronRightRegular, ChevronUpRegular, CircleRegular, CloudArrowDownRegular, CloudArrowUpRegular, CopyRegular, DeleteRegular, DocumentRegular, FolderRegular, FullScreenMaximizeRegular, InfoRegular, MoreHorizontalRegular, NavigationRegular, PanelBottomRegular, PersonFeedbackRegular, PhoneLaptopRegular, PhoneRegular, PlayRegular, PlugConnectedRegular, PlugDisconnectedRegular, PowerRegular, SaveRegular, SearchRegular, SettingsRegular, StopRegular, TextGrammarErrorRegular, WandRegular, WarningRegular, WifiSettingsRegular, WindowConsoleRegular } from '@fluentui/react-icons';
import { FilterRegular, AddCircleRegular, ArrowClockwiseRegular, ArrowSortDownRegular, ArrowSortUpRegular, BookmarkRegular, BoxRegular, BugRegular, CameraRegular, CheckmarkRegular, ChevronDownRegular, ChevronRightRegular, ChevronUpRegular, CircleRegular, CloudArrowDownRegular, CloudArrowUpRegular, CopyRegular, DeleteRegular, DocumentRegular, FolderRegular, FullScreenMaximizeRegular, InfoRegular, MoreHorizontalRegular, NavigationRegular, PanelBottomRegular, PersonFeedbackRegular, PhoneLaptopRegular, PhoneRegular, PlayRegular, PlugConnectedRegular, PlugDisconnectedRegular, PowerRegular, SaveRegular, SearchRegular, SettingsRegular, StopRegular, TextGrammarErrorRegular, WandRegular, WarningRegular, WifiSettingsRegular, WindowConsoleRegular } from '@fluentui/react-icons';
const STYLE = {};
@ -52,6 +52,7 @@ export function register() {
SortUp: <ArrowSortUpRegular style={STYLE} />,
SortDown: <ArrowSortDownRegular style={STYLE} />,
Search: <SearchRegular style={STYLE} />,
Filter: <FilterRegular style={STYLE} />,
// Required by file manager page
Document20: <DocumentRegular style={{ fontSize: 20, verticalAlign: 'middle' }} />

View file

@ -2,6 +2,5 @@ export * from './async-effect';
export * from './file';
export * from './file-size';
export { default as Icons } from './icons';
export * from './resize-observer';
export * from './styles';
export * from './with-display-name';

View file

@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useCallback, useRef } from 'react';
export function withDisplayName(name: string) {
return <P extends object>(Component: React.FunctionComponent<P>) => {
@ -12,3 +12,14 @@ export function forwardRef<T>(name: string) {
return withDisplayName(name)(React.forwardRef(Component));
};
}
export function useCallbackRef<TArgs extends any[], R>(callback: (...args: TArgs) => R): (...args: TArgs) => R {
const ref = useRef<(...args: TArgs) => R>(callback);
ref.current = callback;
const wrapper = useCallback((...args: TArgs) => {
return ref.current.apply(undefined, args);
}, []);
return wrapper;
}

File diff suppressed because it is too large Load diff

View file

@ -31,7 +31,7 @@
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "next",
"typescript": "4.7.0-beta",
"@types/jest": "^27.4.1",
"@yume-chan/ts-package-builder": "^1.0.0"
},

View file

@ -38,7 +38,7 @@
},
"devDependencies": {
"jest": "^27.5.1",
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
}
}

View file

@ -1,4 +1,4 @@
import { AdbPacketHeader, AdbPacketSerializeStream, DuplexStreamFactory, pipeFrom, ReadableStream, type AdbBackend, type AdbPacketCore, type AdbPacketInit, type ReadableWritablePair, type WritableStream } from '@yume-chan/adb';
import { AdbPacketHeader, AdbPacketSerializeStream, DuplexStreamFactory, pipeFrom, ReadableStream, type AdbBackend, type AdbPacketData, type AdbPacketInit, type ReadableWritablePair, type WritableStream } from '@yume-chan/adb';
import type { StructDeserializeStream } from "@yume-chan/struct";
export const ADB_DEVICE_FILTER: USBDeviceFilter = {
@ -24,21 +24,21 @@ class Uint8ArrayStructDeserializeStream implements StructDeserializeStream {
}
}
export class AdbWebUsbBackendStream implements ReadableWritablePair<AdbPacketCore, AdbPacketInit>{
private _readable: ReadableStream<AdbPacketCore>;
export class AdbWebUsbBackendStream implements ReadableWritablePair<AdbPacketData, AdbPacketInit>{
private _readable: ReadableStream<AdbPacketData>;
public get readable() { return this._readable; }
private _writable: WritableStream<AdbPacketInit>;
public get writable() { return this._writable; }
public constructor(device: USBDevice, inEndpoint: USBEndpoint, outEndpoint: USBEndpoint) {
const factory = new DuplexStreamFactory<AdbPacketCore, Uint8Array>({
const factory = new DuplexStreamFactory<AdbPacketData, Uint8Array>({
close: async () => {
navigator.usb.removeEventListener('disconnect', handleUsbDisconnect);
try {
await device.close();
} catch {
// device may already disconnected
// device may have already disconnected
}
},
});
@ -51,36 +51,43 @@ export class AdbWebUsbBackendStream implements ReadableWritablePair<AdbPacketCor
navigator.usb.addEventListener('disconnect', handleUsbDisconnect);
this._readable = factory.createWrapReadable(new ReadableStream<AdbPacketCore>({
this._readable = factory.createWrapReadable(new ReadableStream<AdbPacketData>({
async pull(controller) {
// The `length` argument in `transferIn` must not be smaller than what the device sent,
// otherwise it will return `babble` status without any data.
// But using `inEndpoint.packetSize` as `length` (ensures it can read packets in any size)
// leads to poor performance due to unnecessarily large allocations and corresponding GCs.
// So we read exactly 24 bytes (packet header) followed by exactly `payloadLength`.
// Here we read exactly 24 bytes (packet header) followed by exactly `payloadLength`.
const result = await device.transferIn(inEndpoint.endpointNumber, 24);
// TODO: webusb-backend: handle `babble` by discarding the data and receive again
// From spec, the `result.data` always covers the whole `buffer`.
const buffer = new Uint8Array(result.data!.buffer);
const stream = new Uint8ArrayStructDeserializeStream(buffer);
const packet = AdbPacketHeader.deserialize(stream);
// Add `payload` field to its type, because we will assign `payload` in next step.
const packet = AdbPacketHeader.deserialize(stream) as AdbPacketHeader & { payload: Uint8Array; };
if (packet.payloadLength !== 0) {
const payload = await device.transferIn(inEndpoint.endpointNumber, packet.payloadLength!);
// Use the cast to avoid allocate another object.
(packet as unknown as AdbPacketCore).payload = new Uint8Array(payload.data!.buffer);
const result = await device.transferIn(inEndpoint.endpointNumber, packet.payloadLength);
packet.payload = new Uint8Array(result.data!.buffer);
} else {
packet.payload = new Uint8Array(0);
}
controller.enqueue(packet as unknown as AdbPacketCore);
controller.enqueue(packet);
},
}));
this._writable = pipeFrom(factory.createWritable({
this._writable = pipeFrom(
factory.createWritable({
write: async (chunk) => {
await device.transferOut(outEndpoint.endpointNumber, chunk);
},
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
}), new AdbPacketSerializeStream());
}),
new AdbPacketSerializeStream()
);
}
}
@ -130,7 +137,7 @@ export class AdbWebUsbBackend implements AdbBackend {
alternate.interfaceClass === ADB_DEVICE_FILTER.classCode &&
alternate.interfaceSubclass === ADB_DEVICE_FILTER.subclassCode) {
if (this._device.configuration?.configurationValue !== configuration.configurationValue) {
// Note: It's not possible to switch configuration on Windows,
// Note: Switching configuration is not supported on Windows,
// but Android devices should always expose ADB function at the first (default) configuration.
await this._device.selectConfiguration(configuration.configurationValue);
}

View file

@ -31,7 +31,7 @@
},
"devDependencies": {
"jest": "^27.5.1",
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
},
"dependencies": {

View file

@ -29,7 +29,7 @@
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
},
"dependencies": {

View file

@ -45,6 +45,6 @@
"cross-env": "^7.0.3",
"jest": "^27.5.1",
"ts-jest": "^27.1.3",
"typescript": "next"
"typescript": "4.7.0-beta"
}
}

View file

@ -1,9 +1,11 @@
// cspell: ignore libusb
import { PromiseResolver } from '@yume-chan/async';
import { AdbAuthenticationHandler, AdbDefaultAuthenticators, type AdbCredentialStore } from './auth.js';
import { AdbAuthenticationProcessor, ADB_DEFAULT_AUTHENTICATORS, type AdbCredentialStore } from './auth.js';
import { AdbPower, AdbReverseCommand, AdbSubprocess, AdbSync, AdbTcpIpCommand, escapeArg, framebuffer, install, type AdbFrameBuffer } from './commands/index.js';
import { AdbFeatures } from './features.js';
import { AdbCommand, AdbPacket, calculateChecksum, type AdbPacketCore, type AdbPacketInit } from './packet.js';
import { AdbPacketDispatcher, AdbSocket } from './socket/index.js';
import { AdbCommand, calculateChecksum, type AdbPacketData, type AdbPacketInit } from './packet.js';
import { AdbPacketDispatcher, type AdbSocket } from './socket/index.js';
import { AbortController, DecodeUtf8Stream, GatherStringStream, WritableStream, type ReadableWritablePair } from "./stream/index.js";
import { decodeUtf8, encodeUtf8 } from "./utils/index.js";
@ -23,13 +25,60 @@ export class Adb {
* and starts a new authentication process.
*/
public static async authenticate(
connection: ReadableWritablePair<AdbPacketCore, AdbPacketCore>,
connection: ReadableWritablePair<AdbPacketData, AdbPacketInit>,
credentialStore: AdbCredentialStore,
authenticators = AdbDefaultAuthenticators,
authenticators = ADB_DEFAULT_AUTHENTICATORS,
): Promise<Adb> {
// Initially, set to highest-supported version and payload size.
let version = 0x01000001;
let maxPayloadSize = 0x100000;
const resolver = new PromiseResolver<string>();
const authProcessor = new AdbAuthenticationProcessor(authenticators, credentialStore);
// Here is similar to `AdbPacketDispatcher`,
// But the received packet types and send packet processing are different.
const abortController = new AbortController();
const pipe = connection.readable
.pipeTo(new WritableStream({
async write(packet) {
switch (packet.command) {
case AdbCommand.Connect:
version = Math.min(version, packet.arg0);
maxPayloadSize = Math.min(maxPayloadSize, packet.arg1);
resolver.resolve(decodeUtf8(packet.payload));
break;
case AdbCommand.Auth:
const response = await authProcessor.process(packet);
await sendPacket(response);
break;
default:
// Maybe the previous ADB session exited without reading all packets,
// so they are still waiting in OS internal buffer.
// Just ignore them.
// Because a `Connect` packet will reset the device,
// Eventually there will be `Connect` and `Auth` response packets.
break;
}
}
}), {
preventCancel: true,
signal: abortController.signal,
})
.catch((e) => {
resolver.reject(e);
});
const writer = connection.writable.getWriter();
async function sendPacket(init: AdbPacketData) {
// Always send checksum in auth steps
// Because we don't know if the device needs it or not.
await writer.write(calculateChecksum(init));
}
try {
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252
// There are more feature constants, but some of them are only used by ADB server, not devices.
const features = [
'shell_v2',
'cmd',
@ -49,44 +98,6 @@ export class Adb {
'sendrecv_v2_dry_run_send',
].join(',');
const resolver = new PromiseResolver<string>();
const authHandler = new AdbAuthenticationHandler(authenticators, credentialStore);
const abortController = new AbortController();
const pipe = connection.readable
.pipeTo(new WritableStream({
async write(packet: AdbPacket) {
switch (packet.command) {
case AdbCommand.Connect:
version = Math.min(version, packet.arg0);
maxPayloadSize = Math.min(maxPayloadSize, packet.arg1);
resolver.resolve(decodeUtf8(packet.payload));
break;
case AdbCommand.Auth:
const response = await authHandler.handle(packet);
await sendPacket(response);
break;
case AdbCommand.Close:
// Last connection was interrupted
// Ignore this packet, device will recover
break;
default:
throw new Error('Device not in correct state. Reconnect your device and try again');
}
}
}), {
preventCancel: true,
signal: abortController.signal,
})
.catch((e) => { resolver.reject(e); });
const writer = connection.writable.getWriter();
async function sendPacket(init: AdbPacketCore) {
// Always send checksum in auth steps
// Because we don't know if the device will ignore it yet.
await writer.write(calculateChecksum(init));
}
await sendPacket({
command: AdbCommand.Connect,
arg0: version,
@ -96,25 +107,26 @@ export class Adb {
payload: encodeUtf8(`host::features=${features};`),
});
try {
const banner = await resolver.promise;
// Stop piping before creating Adb object
// Because AdbPacketDispatcher will try to lock the streams when initializing
// Stop piping before creating `Adb` object
// Because `AdbPacketDispatcher` will lock the streams when initializing
abortController.abort();
await pipe;
writer.releaseLock();
// Wait until pipe stops (`ReadableStream` lock released)
await pipe;
return new Adb(
connection,
version,
maxPayloadSize,
banner,
);
} finally {
} catch (e) {
abortController.abort();
writer.releaseLock();
throw e;
}
}
@ -143,22 +155,33 @@ export class Adb {
public readonly tcpip: AdbTcpIpCommand;
public constructor(
connection: ReadableWritablePair<AdbPacketCore, AdbPacketInit>,
connection: ReadableWritablePair<AdbPacketData, AdbPacketInit>,
version: number,
maxPayloadSize: number,
banner: string,
) {
this.parseBanner(banner);
this.packetDispatcher = new AdbPacketDispatcher(connection);
let calculateChecksum: boolean;
let appendNullToServiceString: boolean;
if (version >= VERSION_OMIT_CHECKSUM) {
calculateChecksum = false;
appendNullToServiceString = false;
} else {
calculateChecksum = true;
appendNullToServiceString = true;
}
this.packetDispatcher = new AdbPacketDispatcher(
connection,
{
calculateChecksum,
appendNullToServiceString,
maxPayloadSize,
}
);
this._protocolVersion = version;
if (version >= VERSION_OMIT_CHECKSUM) {
this.packetDispatcher.calculateChecksum = false;
// Android prior to 9.0.0 uses char* to parse service string
// thus requires an extra null character
this.packetDispatcher.appendNullToServiceString = false;
}
this.packetDispatcher.maxPayloadSize = maxPayloadSize;
this.subprocess = new AdbSubprocess(this);
this.power = new AdbPower(this);
@ -201,6 +224,19 @@ export class Adb {
}
}
public async createSocket(service: string): Promise<AdbSocket> {
return this.packetDispatcher.createSocket(service);
}
public async createSocketAndWait(service: string): Promise<string> {
const socket = await this.createSocket(service);
const gatherStream = new GatherStringStream();
await socket.readable
.pipeThrough(new DecodeUtf8Stream())
.pipeTo(gatherStream);
return gatherStream.result;
}
public async getProp(key: string): Promise<string> {
const stdout = await this.subprocess.spawnAndWaitLegacy(
['getprop', key]
@ -228,19 +264,6 @@ export class Adb {
return framebuffer(this);
}
public async createSocket(service: string): Promise<AdbSocket> {
return this.packetDispatcher.createSocket(service);
}
public async createSocketAndWait(service: string): Promise<string> {
const socket = await this.createSocket(service);
const gatherStream = new GatherStringStream();
await socket.readable
.pipeThrough(new DecodeUtf8Stream())
.pipeTo(gatherStream);
return gatherStream.result;
}
public async dispose(): Promise<void> {
this.packetDispatcher.dispose();
}

View file

@ -2,7 +2,7 @@ import { PromiseResolver } from '@yume-chan/async';
import type { Disposable } from '@yume-chan/event';
import type { ValueOrPromise } from '@yume-chan/struct';
import { calculatePublicKey, calculatePublicKeyLength, sign } from './crypto.js';
import { AdbCommand, AdbPacket, type AdbPacketCore } from './packet.js';
import { AdbCommand, type AdbPacketData } from './packet.js';
import { calculateBase64EncodedLength, encodeBase64 } from './utils/index.js';
export type AdbKeyIterable = Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
@ -43,14 +43,14 @@ export interface AdbAuthenticator {
*/
(
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacket>
): AsyncIterable<AdbPacketCore>;
getNextRequest: () => Promise<AdbPacketData>
): AsyncIterable<AdbPacketData>;
}
export const AdbSignatureAuthenticator: AdbAuthenticator = async function* (
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacket>,
): AsyncIterable<AdbPacketCore> {
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData> {
for await (const key of credentialStore.iterateKeys()) {
const packet = await getNextRequest();
@ -70,8 +70,8 @@ export const AdbSignatureAuthenticator: AdbAuthenticator = async function* (
export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
credentialStore: AdbCredentialStore,
getNextRequest: () => Promise<AdbPacket>,
): AsyncIterable<AdbPacketCore> {
getNextRequest: () => Promise<AdbPacketData>,
): AsyncIterable<AdbPacketData> {
const packet = await getNextRequest();
if (packet.arg0 !== AdbAuthType.Token) {
@ -110,19 +110,19 @@ export const AdbPublicKeyAuthenticator: AdbAuthenticator = async function* (
};
};
export const AdbDefaultAuthenticators: AdbAuthenticator[] = [
export const ADB_DEFAULT_AUTHENTICATORS: AdbAuthenticator[] = [
AdbSignatureAuthenticator,
AdbPublicKeyAuthenticator,
];
export class AdbAuthenticationHandler implements Disposable {
export class AdbAuthenticationProcessor implements Disposable {
public readonly authenticators: readonly AdbAuthenticator[];
private readonly credentialStore: AdbCredentialStore;
private pendingRequest = new PromiseResolver<AdbPacket>();
private pendingRequest = new PromiseResolver<AdbPacketData>();
private iterator: AsyncIterator<AdbPacketCore> | undefined;
private iterator: AsyncIterator<AdbPacketData, void, void> | undefined;
public constructor(
authenticators: readonly AdbAuthenticator[],
@ -132,16 +132,16 @@ export class AdbAuthenticationHandler implements Disposable {
this.credentialStore = credentialStore;
}
private getNextRequest = (): Promise<AdbPacket> => {
private getNextRequest = (): Promise<AdbPacketData> => {
return this.pendingRequest.promise;
};
private async* runAuthenticator(): AsyncGenerator<AdbPacketCore> {
private async* invokeAuthenticator(): AsyncGenerator<AdbPacketData, void, void> {
for (const authenticator of this.authenticators) {
for await (const packet of authenticator(this.credentialStore, this.getNextRequest)) {
// If the authenticator yielded a response
// Prepare `nextRequest` for next authentication request
this.pendingRequest = new PromiseResolver<AdbPacket>();
this.pendingRequest = new PromiseResolver();
// Yield the response to outer layer
yield packet;
@ -150,17 +150,20 @@ export class AdbAuthenticationHandler implements Disposable {
// If the authenticator returned,
// Next authenticator will be given the same `pendingRequest`
}
throw new Error('Cannot authenticate with device');
}
public async handle(packet: AdbPacket): Promise<AdbPacketCore> {
public async process(packet: AdbPacketData): Promise<AdbPacketData> {
if (!this.iterator) {
this.iterator = this.runAuthenticator();
this.iterator = this.invokeAuthenticator();
}
this.pendingRequest.resolve(packet);
const result = await this.iterator.next();
if (result.done) {
throw new Error('Cannot authenticate with device');
}
return result.value;
}

View file

@ -1,5 +1,5 @@
import type { ValueOrPromise } from '@yume-chan/struct';
import type { AdbPacketCore, AdbPacketInit } from "./packet.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import type { ReadableWritablePair } from "./stream/index.js";
export interface AdbBackend {
@ -7,5 +7,5 @@ export interface AdbBackend {
readonly name: string | undefined;
connect(): ValueOrPromise<ReadableWritablePair<AdbPacketCore, AdbPacketInit>>;
connect(): ValueOrPromise<ReadableWritablePair<AdbPacketData, AdbPacketInit>>;
}

View file

@ -11,6 +11,8 @@ export function install(
let sync!: AdbSync;
return new WrapWritableStream<Uint8Array>({
async start() {
// TODO: install: support other install apk methods (streaming, etc.)
// Upload apk file to tmp folder
sync = await adb.sync();
return sync.write(filename, undefined, undefined);

View file

@ -2,13 +2,13 @@
import { AutoDisposable } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import type { AdbPacketCore } from '../packet.js';
import type { AdbPacketData } from '../packet.js';
import type { AdbIncomingSocketEventArgs, AdbPacketDispatcher, AdbSocket } from '../socket/index.js';
import { AdbBufferedStream } from '../stream/index.js';
import { decodeUtf8 } from "../utils/index.js";
export interface AdbReverseHandler {
onSocket(packet: AdbPacketCore, socket: AdbSocket): void;
onSocket(packet: AdbPacketData, socket: AdbSocket): void;
}
export interface AdbForwardListener {

View file

@ -1,34 +1,30 @@
import type { Adb } from '../../adb.js';
import { DecodeUtf8Stream, GatherStringStream } from "../../stream/index.js";
import { AdbNoneSubprocessProtocol } from './legacy.js';
import { AdbShellSubprocessProtocol } from './protocol.js';
import type { AdbSubprocessProtocol, AdbSubprocessProtocolConstructor } from './types.js';
import { AdbSubprocessNoneProtocol, AdbSubprocessShellProtocol, type AdbSubprocessProtocol, type AdbSubprocessProtocolConstructor } from './protocols/index.js';
export * from './legacy.js';
export * from './protocol.js';
export * from './types.js';
export * from './protocols/index.js';
export * from './utils.js';
export interface AdbSubprocessOptions {
/**
* A list of `AdbShellConstructor`s to be used.
* A list of `AdbSubprocessProtocolConstructor`s to be used.
*
* Different `AdbShell` has different capabilities, thus requires specific adaptations.
* Check each `AdbShell`'s documentation for details.
* Different `AdbSubprocessProtocol` has different capabilities, thus requires specific adaptations.
* Check their documentations for details.
*
* The first one whose `isSupported` returns `true` will be used.
* If no `AdbShell` is supported, an error will be thrown.
* The first protocol whose `isSupported` returns `true` will be used.
* If no `AdbSubprocessProtocol` is supported, an error will be thrown.
*
* The default value is `[AdbShellProtocol, AdbLegacyShell]`.
* @default [AdbSubprocessShellProtocol, AdbSubprocessNoneProtocol]
*/
protocols: AdbSubprocessProtocolConstructor[];
}
const DefaultOptions: AdbSubprocessOptions = {
protocols: [AdbShellSubprocessProtocol, AdbNoneSubprocessProtocol],
const DEFAULT_OPTIONS: AdbSubprocessOptions = {
protocols: [AdbSubprocessShellProtocol, AdbSubprocessNoneProtocol],
};
export interface SubprocessResult {
export interface AdbSubprocessWaitResult {
stdout: string;
stderr: string;
exitCode: number;
@ -41,11 +37,16 @@ export class AdbSubprocess {
this.adb = adb;
}
private async createProtocol(mode: 'pty' | 'raw', command?: string | string[], options?: Partial<AdbSubprocessOptions>): Promise<AdbSubprocessProtocol> {
let { protocols } = { ...DefaultOptions, ...options };
private async createProtocol(
mode: 'pty' | 'raw',
command?: string | string[],
options?: Partial<AdbSubprocessOptions>
): Promise<AdbSubprocessProtocol> {
const { protocols } = { ...DEFAULT_OPTIONS, ...options };
let Constructor: AdbSubprocessProtocolConstructor | undefined;
for (const item of protocols) {
// It's async so can't use `Array#find`
if (await item.isSupported(this.adb)) {
Constructor = item;
break;
@ -59,40 +60,48 @@ export class AdbSubprocess {
if (Array.isArray(command)) {
command = command.join(' ');
} else if (command === undefined) {
// spawn the default shell
command = '';
}
return await Constructor[mode](this.adb, command);
}
/**
* Spawns the default shell in interactive mode.
* @param options The options for creating the `AdbShell`
* @returns A new `AdbShell` instance connecting to the spawned shell process.
* Spawns an executable in PTY (interactive) mode.
* @param command The command to run. If omitted, the default shell will be spawned.
* @param options The options for creating the `AdbSubprocessProtocol`
* @returns A new `AdbSubprocessProtocol` instance connecting to the spawned process.
*/
public shell(command?: string | string[], options?: Partial<AdbSubprocessOptions>): Promise<AdbSubprocessProtocol> {
public shell(
command?: string | string[],
options?: Partial<AdbSubprocessOptions>
): Promise<AdbSubprocessProtocol> {
return this.createProtocol('pty', command, options);
}
/**
* Spawns a new process using the given `command`.
* Spawns an executable and pipe the output.
* @param command The command to run, or an array of strings containing both command and args.
* @param options The options for creating the `AdbShell`
* @returns A new `AdbShell` instance connecting to the spawned process.
* @param options The options for creating the `AdbSubprocessProtocol`
* @returns A new `AdbSubprocessProtocol` instance connecting to the spawned process.
*/
public spawn(command: string | string[], options?: Partial<AdbSubprocessOptions>): Promise<AdbSubprocessProtocol> {
public spawn(
command: string | string[],
options?: Partial<AdbSubprocessOptions>
): Promise<AdbSubprocessProtocol> {
return this.createProtocol('raw', command, options);
}
/**
* Spawns a new process, waits until it exits, and returns the entire output.
* @param command The command to run
* @param options The options for creating the `AdbShell`
* @param options The options for creating the `AdbSubprocessProtocol`
* @returns The entire output of the command
*/
public async spawnAndWait(
command: string | string[],
options?: Partial<AdbSubprocessOptions>
): Promise<SubprocessResult> {
): Promise<AdbSubprocessWaitResult> {
const shell = await this.spawn(command, options);
const stdout = new GatherStringStream();
@ -121,7 +130,10 @@ export class AdbSubprocess {
* @returns The entire output of the command
*/
public async spawnAndWaitLegacy(command: string | string[]): Promise<string> {
const { stdout } = await this.spawnAndWait(command, { protocols: [AdbNoneSubprocessProtocol] });
const { stdout } = await this.spawnAndWait(
command,
{ protocols: [AdbSubprocessNoneProtocol] }
);
return stdout;
}
}

View file

@ -0,0 +1,3 @@
export * from './none.js';
export * from './shell.js';
export * from './types.js';

View file

@ -1,6 +1,6 @@
import type { Adb } from "../../adb.js";
import type { AdbSocket } from "../../socket/index.js";
import { DuplexStreamFactory, type ReadableStream } from "../../stream/index.js";
import type { Adb } from "../../../adb.js";
import type { AdbSocket } from "../../../socket/index.js";
import { DuplexStreamFactory, type ReadableStream } from "../../../stream/index.js";
import type { AdbSubprocessProtocol } from "./types.js";
/**
@ -11,15 +11,17 @@ import type { AdbSubprocessProtocol } from "./types.js";
* * `exit` exit code: No
* * `resize`: No
*/
export class AdbNoneSubprocessProtocol implements AdbSubprocessProtocol {
export class AdbSubprocessNoneProtocol implements AdbSubprocessProtocol {
public static isSupported() { return true; }
public static async pty(adb: Adb, command: string) {
return new AdbNoneSubprocessProtocol(await adb.createSocket(`shell:${command}`));
return new AdbSubprocessNoneProtocol(await adb.createSocket(`shell:${command}`));
}
public static async raw(adb: Adb, command: string) {
return new AdbNoneSubprocessProtocol(await adb.createSocket(`shell,raw:${command}`));
// Native ADB client doesn't allow none protocol + raw mode,
// But ADB daemon supports it.
return new AdbSubprocessNoneProtocol(await adb.createSocket(`shell,raw:${command}`));
}
private readonly socket: AdbSocket;
@ -28,11 +30,15 @@ export class AdbNoneSubprocessProtocol implements AdbSubprocessProtocol {
public get stdin() { return this.socket.writable; }
private _stdout: ReadableStream<Uint8Array>;
// Legacy shell doesn't support splitting output streams.
/**
* Legacy shell mixes stdout and stderr.
*/
public get stdout() { return this._stdout; }
// `stderr` of Legacy shell is always empty.
private _stderr: ReadableStream<Uint8Array>;
/**
* `stderr` will always be empty.
*/
public get stderr() { return this._stderr; }
private _exit: Promise<number>;

View file

@ -1,10 +1,10 @@
import { PromiseResolver } from "@yume-chan/async";
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 { encodeUtf8 } from "../../utils/index.js";
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 { encodeUtf8 } from "../../../utils/index.js";
import type { AdbSubprocessProtocol } from "./types.js";
export enum AdbShellProtocolId {
@ -17,7 +17,8 @@ export enum AdbShellProtocolId {
}
// This packet format is used in both direction.
const AdbShellProtocolPacket = new Struct({ littleEndian: true })
const AdbShellProtocolPacket =
new Struct({ littleEndian: true })
.uint8('id', placeholder<AdbShellProtocolId>())
.uint32('length')
.uint8Array('data', { lengthField: 'length' });
@ -99,18 +100,18 @@ class MultiplexStream<T>{
* * `exit` exit code: Yes
* * `resize`: Yes
*/
export class AdbShellSubprocessProtocol implements AdbSubprocessProtocol {
export class AdbSubprocessShellProtocol implements AdbSubprocessProtocol {
public static isSupported(adb: Adb) {
return adb.features!.includes(AdbFeatures.ShellV2);
}
public static async pty(adb: Adb, command: string) {
// TODO: AdbShellSubprocessProtocol: Support setting `XTERM` environment variable
return new AdbShellSubprocessProtocol(await adb.createSocket(`shell,v2,pty:${command}`));
return new AdbSubprocessShellProtocol(await adb.createSocket(`shell,v2,pty:${command}`));
}
public static async raw(adb: Adb, command: string) {
return new AdbShellSubprocessProtocol(await adb.createSocket(`shell,v2,raw:${command}`));
return new AdbSubprocessShellProtocol(await adb.createSocket(`shell,v2,raw:${command}`));
}
private readonly _socket: AdbSocket;

View file

@ -1,7 +1,7 @@
import type { ValueOrPromise } from "@yume-chan/struct";
import type { Adb } from "../../adb.js";
import type { AdbSocket } from "../../socket/index.js";
import type { ReadableStream, WritableStream } from "../../stream/index.js";
import type { Adb } from "../../../adb.js";
import type { AdbSocket } from "../../../socket/index.js";
import type { ReadableStream, WritableStream } from "../../../stream/index.js";
export interface AdbSubprocessProtocol {
/**
@ -17,7 +17,7 @@ export interface AdbSubprocessProtocol {
/**
* The `stderr` pipe of the process.
*
* Note: Some `AdbShell` doesn't separate `stdout` and `stderr`,
* Note: Some `AdbSubprocessProtocol` doesn't separate `stdout` and `stderr`,
* All output will be sent to `stdout`.
*/
readonly stderr: ReadableStream<Uint8Array>;
@ -25,15 +25,16 @@ export interface AdbSubprocessProtocol {
/**
* A `Promise` that resolves to the exit code of the process.
*
* Note: Some `AdbShell` doesn't support exit code,
* They will always resolve with `0`.
* Note: Some `AdbSubprocessProtocol` doesn't support exit code,
* They will always resolve it with `0`.
*/
readonly exit: Promise<number>;
/**
* Resizes the current shell.
*
* Some `AdbShell`s may not support resizing and will always ignore calls to this method.
* Some `AdbSubprocessProtocol`s may not support resizing
* and will ignore calls to this method.
*/
resize(rows: number, cols: number): ValueOrPromise<void>;
@ -47,9 +48,10 @@ export interface AdbSubprocessProtocolConstructor {
/** Returns `true` if the `adb` instance supports this shell */
isSupported(adb: Adb): ValueOrPromise<boolean>;
/** Creates a new `AdbShell` using the specified `Adb` and `command` */
/** Spawns an executable in PTY (interactive) mode. */
pty(adb: Adb, command: string): ValueOrPromise<AdbSubprocessProtocol>;
/** Spawns an executable and pipe the output. */
raw(adb: Adb, command: string): ValueOrPromise<AdbSubprocessProtocol>;
/** Creates a new `AdbShell` by attaching to an exist `AdbSocket` */

View file

@ -25,6 +25,9 @@ export function adbSyncPush(
return pipeFrom(
new WritableStream<Uint8Array>({
async start() {
// TODO: AdbSyncPush: support create directory using `mkdir`
// if device doesn't support `fixed_push_mkdir` feature.
const pathAndMode = `${filename},${mode.toString()}`;
await adbSyncWriteRequest(writer, AdbSyncRequestId.Send, pathAndMode);
},

View file

@ -5,48 +5,60 @@ const BigInt1 = BigInt(1);
const BigInt2 = BigInt(2);
const BigInt64 = BigInt(64);
export function getBig(
array: Uint8Array,
offset = 0,
length = array.byteLength - offset
): bigint {
const view = new DataView(array.buffer, array.byteOffset, array.byteLength);
/**
* Gets the `BigInt` value at the specified byte offset and length from the start of the view. There is
* no alignment constraint; multi-byte values may be fetched from any offset.
*
* Only supports Big-Endian, because that's what ADB uses.
* @param byteOffset The place in the buffer at which the value should be retrieved.
*/
export function getBigUint(dataView: DataView, byteOffset: number, length: number): bigint {
let result = BigInt0;
// Currently `length` must be a multiplication of 8
// Support for arbitrary length can be easily added
for (let i = offset; i < offset + length; i += 8) {
for (let i = byteOffset; i < byteOffset + length; i += 8) {
result <<= BigInt64;
const value = getBigUint64(view, i, false);
const value = getBigUint64(dataView, i, false);
result += value;
}
return result;
}
export function setBig(buffer: ArrayBuffer, value: bigint, offset: number = 0) {
/**
* Stores an arbitrary-precision positive `BigInt` value at the specified byte offset from the start of the view.
* @param byteOffset The place in the buffer at which the value should be set.
* @param value The value to set.
* @param littleEndian If `false` or `undefined`, a big-endian value should be written,
* otherwise a little-endian value should be written.
*/
export function setBigUint(dataView: DataView, byteOffset: number, value: bigint, littleEndian?: boolean) {
const start = byteOffset;
if (littleEndian) {
while (value > BigInt0) {
setBigUint64(dataView, byteOffset, value, true);
byteOffset += 8;
value >>= BigInt64;
}
} else {
// Because we don't know how long (in bits) the `value` is,
// Convert it to an array of `uint64` first.
const uint64Array: bigint[] = [];
while (value > BigInt0) {
uint64Array.push(BigInt.asUintN(64, value));
value >>= BigInt64;
}
const view = new DataView(buffer);
for (let i = uint64Array.length - 1; i >= 0; i -= 1) {
setBigUint64(view, offset, uint64Array[i]!, false);
offset += 8;
setBigUint64(dataView, byteOffset, uint64Array[i]!, false);
byteOffset += 8;
}
}
export function setBigLE(array: Uint8Array, value: bigint, offset = 0) {
const view = new DataView(array.buffer, array.byteOffset, array.byteLength);
while (value > BigInt0) {
setBigUint64(view, offset, value, true);
offset += 8;
value >>= BigInt64;
}
return byteOffset - start;
}
// These values are correct only if
@ -76,9 +88,9 @@ const RsaPrivateKeyDOffset = 303;
const RsaPrivateKeyDLength = 2048 / 8;
export function parsePrivateKey(key: Uint8Array): [n: bigint, d: bigint] {
let n = getBig(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
let d = getBig(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
const n = getBigUint(view, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
const d = getBigUint(view, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
return [n, d];
}
@ -132,7 +144,7 @@ export function calculatePublicKey(
// [
// modulusLengthInWords, // 32-bit integer, a "word" is 32-bit so it must be 2048 / 8 / 4
// // Actually the comment in Android source code was wrong
// n0inv, // 32-bit integer, the modular inverse of (lower 32 bits of) n
// n0inv, // 32-bit integer, the modular inverse of (low 32 bits of n)
// modulus, // n
// rr, // Montgomery parameter R^2
// exponent, // 32-bit integer, must be 65537
@ -172,13 +184,12 @@ export function calculatePublicKey(
outputOffset += 4;
// Write n
setBigLE(output, n, outputOffset);
setBigUint(outputView, outputOffset, n, true);
outputOffset += 256;
// Calculate rr = (2^(rsa_size)) ^ 2 mod n
let rr = BigInt(2) ** BigInt(4096) % n;
setBigLE(output, rr, outputOffset);
outputOffset += 256;
outputOffset += setBigUint(outputView, outputOffset, rr, true);
// exponent
outputView.setUint32(outputOffset, 65537, true);
@ -218,21 +229,21 @@ export function powMod(base: bigint, exponent: bigint, modulus: bigint): bigint
return r;
}
export const Sha1DigestLength = 20;
export const SHA1_DIGEST_LENGTH = 20;
export const Asn1Sequence = 0x30;
export const Asn1OctetString = 0x04;
export const Asn1Null = 0x05;
export const Asn1Oid = 0x06;
export const ASN1_SEQUENCE = 0x30;
export const ASN1_OCTET_STRING = 0x04;
export const ASN1_NULL = 0x05;
export const ASN1_OID = 0x06;
// PKCS#1 SHA-1 hash digest info
export const Sha1DigestInfo = new Uint8Array([
Asn1Sequence, 0x0d + Sha1DigestLength,
Asn1Sequence, 0x09,
export const SHA1_DIGEST_INFO = new Uint8Array([
ASN1_SEQUENCE, 0x0d + SHA1_DIGEST_LENGTH,
ASN1_SEQUENCE, 0x09,
// SHA-1 (1 3 14 3 2 26)
Asn1Oid, 0x05, 1 * 40 + 3, 14, 3, 2, 26,
Asn1Null, 0x00,
Asn1OctetString, Sha1DigestLength
ASN1_OID, 0x05, 1 * 40 + 3, 14, 3, 2, 26,
ASN1_NULL, 0x00,
ASN1_OCTET_STRING, SHA1_DIGEST_LENGTH
]);
// SubtleCrypto.sign() will hash the given data and sign the hash
@ -241,7 +252,7 @@ export const Sha1DigestInfo = new Uint8Array([
// encrypt the given data with its private key)
// However SubtileCrypto.encrypt() doesn't accept 'RSASSA-PKCS1-v1_5' algorithm
// So we need to implement the encryption by ourself
export function sign(privateKey: Uint8Array, data: Uint8Array): ArrayBuffer {
export function sign(privateKey: Uint8Array, data: Uint8Array): Uint8Array {
const [n, d] = parsePrivateKey(privateKey);
// PKCS#1 padding
@ -254,7 +265,7 @@ export function sign(privateKey: Uint8Array, data: Uint8Array): ArrayBuffer {
padded[index] = 1;
index += 1;
const fillLength = padded.length - Sha1DigestInfo.length - data.length - 1;
const fillLength = padded.length - SHA1_DIGEST_INFO.length - data.length - 1;
while (index < fillLength) {
padded[index] = 0xff;
index += 1;
@ -263,18 +274,23 @@ export function sign(privateKey: Uint8Array, data: Uint8Array): ArrayBuffer {
padded[index] = 0;
index += 1;
padded.set(Sha1DigestInfo, index);
index += Sha1DigestInfo.length;
padded.set(SHA1_DIGEST_INFO, index);
index += SHA1_DIGEST_INFO.length;
padded.set(data, index);
// Encryption
// signature = padded ** d % n
let signature = powMod(getBig(padded), d, n);
const view = new DataView(padded.buffer);
const signature = powMod(
getBigUint(view, 0, view.byteLength),
d,
n
);
// Put into an ArrayBuffer
const result = new ArrayBuffer(256);
setBig(result, signature);
// `padded` is not used anymore,
// re-use the buffer to store the result
setBigUint(view, 0, signature, false);
return result;
return padded;
}

View file

@ -30,15 +30,24 @@ export const AdbPacket =
export type AdbPacket = typeof AdbPacket['TDeserializeResult'];
// All the useful fields
export type AdbPacketCore = Omit<typeof AdbPacket['TInit'], 'checksum' | 'magic'>;
/**
* `AdbPacketData` contains all the useful fields of `AdbPacket`.
*
* `AdbBackend#connect` will return a `ReadableStream<AdbPacketData>`,
* so each backend can encode `AdbPacket` in different ways.
*
* `AdbBackend#connect` will return a `WritableStream<AdbPacketInit>`,
* however, `AdbPacketDispatcher` will transform `AdbPacketData` to `AdbPacketInit` for you,
* so `AdbSocket#writable#write` only needs `AdbPacketData`.
*/
export type AdbPacketData = Omit<typeof AdbPacket['TInit'], 'checksum' | 'magic'>;
// All fields except `magic`, which can be calculated in `AdbPacketSerializeStream`
export type AdbPacketInit = Omit<typeof AdbPacket['TInit'], 'magic'>;
export function calculateChecksum(payload: Uint8Array): number;
export function calculateChecksum(init: AdbPacketCore): AdbPacketInit;
export function calculateChecksum(payload: Uint8Array | AdbPacketCore): number | AdbPacketInit {
export function calculateChecksum(init: AdbPacketData): AdbPacketInit;
export function calculateChecksum(payload: Uint8Array | AdbPacketData): number | AdbPacketInit {
if (payload instanceof Uint8Array) {
return payload.reduce((result, item) => result + item, 0);
} else {
@ -51,7 +60,7 @@ export class AdbPacketSerializeStream extends TransformStream<AdbPacketInit, Uin
public constructor() {
super({
transform: async (init, controller) => {
// This syntax is ugly, but I don't want to create an new object.
// This syntax is ugly, but I don't want to create a new object.
(init as unknown as AdbPacketHeaderInit).magic = init.command ^ 0xFFFFFFFF;
(init as unknown as AdbPacketHeaderInit).payloadLength = init.payload.byteLength;

View file

@ -1,14 +1,14 @@
import { AsyncOperationManager, PromiseResolver } from '@yume-chan/async';
import { AutoDisposable, EventEmitter } from '@yume-chan/event';
import { AdbCommand, calculateChecksum, type AdbPacketCore, type AdbPacketInit } from '../packet.js';
import { AdbCommand, calculateChecksum, type AdbPacketData, type AdbPacketInit } from '../packet.js';
import { AbortController, WritableStream, WritableStreamDefaultWriter, type ReadableWritablePair } from '../stream/index.js';
import { decodeUtf8, encodeUtf8 } from '../utils/index.js';
import { AdbSocket } from './socket.js';
import { AdbSocket, AdbSocketController } from './socket.js';
export interface AdbIncomingSocketEventArgs {
handled: boolean;
packet: AdbPacketCore;
packet: AdbPacketData;
serviceString: string;
@ -17,17 +17,27 @@ export interface AdbIncomingSocketEventArgs {
const EmptyUint8Array = new Uint8Array(0);
export interface AdbPacketDispatcherOptions {
calculateChecksum: boolean;
/**
* Before Android 9.0, ADB uses `char*` to parse service string,
* thus requires a null character to terminate.
*
* Usually it should have the same value as `calculateChecksum`.
*/
appendNullToServiceString: boolean;
maxPayloadSize: number;
}
export class AdbPacketDispatcher extends AutoDisposable {
// ADB socket id starts from 1
// (0 means open failed)
private readonly initializers = new AsyncOperationManager(1);
private readonly sockets = new Map<number, AdbSocket>();
private readonly sockets = new Map<number, AdbSocketController>();
private _writer!: WritableStreamDefaultWriter<AdbPacketInit>;
public maxPayloadSize = 0;
public calculateChecksum = true;
public appendNullToServiceString = true;
public readonly options: AdbPacketDispatcherOptions;
private _disconnected = new PromiseResolver<void>();
public get disconnected() { return this._disconnected.promise; }
@ -41,10 +51,13 @@ export class AdbPacketDispatcher extends AutoDisposable {
private _abortController = new AbortController();
public constructor(
connection: ReadableWritablePair<AdbPacketCore, AdbPacketInit>,
connection: ReadableWritablePair<AdbPacketData, AdbPacketInit>,
options: AdbPacketDispatcherOptions
) {
super();
this.options = options;
connection.readable
.pipeTo(new WritableStream({
write: async (packet) => {
@ -91,7 +104,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
this._writer = connection.writable.getWriter();
}
private handleOk(packet: AdbPacketCore) {
private handleOk(packet: AdbPacketData) {
if (this.initializers.resolve(packet.arg1, packet.arg0)) {
// Device successfully created the socket
return;
@ -109,7 +122,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
this.sendPacket(AdbCommand.Close, packet.arg1, packet.arg0);
}
private async handleClose(packet: AdbPacketCore) {
private async handleClose(packet: AdbPacketData) {
// From https://android.googlesource.com/platform/packages/modules/adb/+/65d18e2c1cc48b585811954892311b28a4c3d188/adb.cpp#459
/* According to protocol.txt, p->msg.arg0 might be 0 to indicate
* a failed OPEN only. However, due to a bug in previous ADB
@ -139,7 +152,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
// Just ignore it
}
private async handleOpen(packet: AdbPacketCore) {
private async handleOpen(packet: AdbPacketData) {
// AsyncOperationManager doesn't support get and skip an ID
// Use `add` + `resolve` to simulate this behavior
const [localId] = this.initializers.add<number>();
@ -148,7 +161,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
const remoteId = packet.arg0;
const serviceString = decodeUtf8(packet.payload);
const socket = new AdbSocket({
const controller = new AdbSocketController({
dispatcher: this,
localId,
remoteId,
@ -160,12 +173,12 @@ export class AdbPacketDispatcher extends AutoDisposable {
handled: false,
packet,
serviceString,
socket,
socket: controller.socket,
};
this.incomingSocketEvent.fire(args);
if (args.handled) {
this.sockets.set(localId, socket);
this.sockets.set(localId, controller);
await this.sendPacket(AdbCommand.OK, localId, remoteId);
} else {
await this.sendPacket(AdbCommand.Close, 0, remoteId);
@ -173,25 +186,30 @@ export class AdbPacketDispatcher extends AutoDisposable {
}
public async createSocket(serviceString: string): Promise<AdbSocket> {
if (this.appendNullToServiceString) {
if (this.options.appendNullToServiceString) {
serviceString += '\0';
}
const [localId, initializer] = this.initializers.add<number>();
await this.sendPacket(AdbCommand.Open, localId, 0, serviceString);
await this.sendPacket(
AdbCommand.Open,
localId,
0,
serviceString
);
// Fulfilled by `handleOk`
const remoteId = await initializer;
const socket = new AdbSocket({
const controller = new AdbSocketController({
dispatcher: this,
localId,
remoteId,
localCreated: true,
serviceString,
});
this.sockets.set(localId, socket);
this.sockets.set(localId, controller);
return socket;
return controller.socket;
}
public sendPacket(packet: AdbPacketInit): Promise<void>;
@ -207,7 +225,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
arg1?: number,
payload: string | Uint8Array = EmptyUint8Array,
): Promise<void> {
let init: AdbPacketCore;
let init: AdbPacketData;
if (arg0 === undefined) {
init = packetOrCommand as AdbPacketInit;
} else {
@ -224,11 +242,11 @@ export class AdbPacketDispatcher extends AutoDisposable {
}
if (init.payload &&
init.payload.byteLength > this.maxPayloadSize) {
init.payload.byteLength > this.options.maxPayloadSize) {
throw new Error('payload too large');
}
if (this.calculateChecksum) {
if (this.options.calculateChecksum) {
calculateChecksum(init);
} else {
(init as AdbPacketInit).checksum = 0;

View file

@ -1,6 +1,6 @@
import { PromiseResolver } from "@yume-chan/async";
import { AdbCommand } from '../packet.js';
import { ChunkStream, DuplexStreamFactory, pipeFrom, ReadableStream, WritableStream, type PushReadableStreamController } from '../stream/index.js';
import { ChunkStream, DuplexStreamFactory, pipeFrom, type PushReadableStreamController, type ReadableStream, type ReadableWritablePair, type WritableStream } from '../stream/index.js';
import type { AdbPacketDispatcher } from './dispatcher.js';
export interface AdbSocketInfo {
@ -8,25 +8,16 @@ export interface AdbSocketInfo {
remoteId: number;
localCreated: boolean;
serviceString: string;
}
export interface AdbSocketConstructionOptions {
export interface AdbSocketConstructionOptions extends AdbSocketInfo {
dispatcher: AdbPacketDispatcher;
localId: number;
remoteId: number;
localCreated: boolean;
serviceString: string;
highWaterMark?: number | undefined;
}
export class AdbSocket implements AdbSocketInfo {
export class AdbSocketController implements AdbSocketInfo, ReadableWritablePair<Uint8Array, Uint8Array> {
private readonly dispatcher!: AdbPacketDispatcher;
public readonly localId!: number;
@ -46,6 +37,9 @@ export class AdbSocket implements AdbSocketInfo {
private _closed = false;
public get closed() { return this._closed; }
private _socket: AdbSocket;
public get socket() { return this._socket; }
public constructor(options: AdbSocketConstructionOptions) {
Object.assign(this, options);
@ -80,20 +74,16 @@ export class AdbSocket implements AdbSocketInfo {
await this._writePromise.promise;
},
}),
new ChunkStream(this.dispatcher.maxPayloadSize)
new ChunkStream(this.dispatcher.options.maxPayloadSize)
);
this._socket = new AdbSocket(this);
}
/**
* @internal
*/
public async enqueue(packet: Uint8Array) {
await this._readableController.enqueue(packet);
}
/**
* @internal
*/
public ack() {
this._writePromise?.resolve();
}
@ -115,9 +105,6 @@ export class AdbSocket implements AdbSocketInfo {
}
}
/**
* @internal
*/
public dispose() {
this._closed = true;
@ -127,3 +114,23 @@ export class AdbSocket implements AdbSocketInfo {
this.close();
}
}
export class AdbSocket implements AdbSocketInfo, ReadableWritablePair<Uint8Array, Uint8Array>{
private _controller: AdbSocketController;
public get localId(): number { return this._controller.localId; }
public get remoteId(): number { return this._controller.remoteId; }
public get localCreated(): boolean { return this._controller.localCreated; }
public get serviceString(): string { return this._controller.serviceString; }
public get readable(): ReadableStream<Uint8Array> { return this._controller.readable; }
public get writable(): WritableStream<Uint8Array> { return this._controller.writable; }
public constructor(controller: AdbSocketController) {
this._controller = controller;
}
public close() {
return this._controller.close();
}
}

View file

@ -1 +1,5 @@
// cspell: ignore vercel
// Always use polyfilled version because
// Vercel doesn't support Node.js 16 (`streams/web` module) yet
export * from './detect.polyfill.js';

View file

@ -11,6 +11,12 @@ export interface DuplexStreamFactoryOptions {
close?: (() => void | Promise<void>) | undefined;
}
/**
* A factory for creating a duplex stream.
*
* It can create multiple `ReadableStream`s and `WritableStream`s,
* when any of them is closed, all other streams will be closed as well.
*/
export class DuplexStreamFactory<R, W> {
private readableControllers: ReadableStreamDefaultController<R>[] = [];
private pushReadableControllers: PushReadableStreamController<R>[] = [];
@ -42,6 +48,8 @@ export class DuplexStreamFactory<R, W> {
await controller.enqueue(chunk);
},
close: async () => {
// The source signals stream ended,
// usually means the other end closed the connection first.
controller.close();
this._closeRequestedByReadable = true;
await this.close();
@ -58,15 +66,7 @@ export class DuplexStreamFactory<R, W> {
public createWrapReadable(wrapper: ReadableStream<R> | WrapReadableStreamStart<R> | ReadableStreamWrapper<R>): WrapReadableStream<R> {
return new WrapReadableStream<R>({
async start() {
if ('start' in wrapper) {
return await wrapper.start();
} else if (typeof wrapper === 'function') {
return await wrapper();
} else {
// Can't use `wrapper instanceof ReadableStream`
// Because we want to be compatible with any ReadableStream-like objects
return wrapper;
}
return getWrappedReadableStream(wrapper);
},
close: async () => {
if ('close' in wrapper) {
@ -169,7 +169,7 @@ export class GatherStringStream extends WritableStream<string>{
}
}
// TODO: Find other ways to implement `StructTransformStream`
// TODO: StructTransformStream: Looking for better implementation
export class StructDeserializeStream<T extends Struct<any, any, any, any>>
implements ReadableWritablePair<Uint8Array, StructValueType<T>>{
private _readable: ReadableStream<StructValueType<T>>;
@ -234,6 +234,20 @@ export interface WritableStreamWrapper<T> {
close?(): Promise<void>;
}
async function getWrappedWritableStream<T>(
wrapper: WritableStream<T> | WrapWritableStreamStart<T> | WritableStreamWrapper<T>
) {
if ('start' in wrapper) {
return await wrapper.start();
} else if (typeof wrapper === 'function') {
return await wrapper();
} else {
// Can't use `wrapper instanceof WritableStream`
// Because we want to be compatible with any WritableStream-like objects
return wrapper;
}
}
export class WrapWritableStream<T> extends WritableStream<T> {
public writable!: WritableStream<T>;
@ -242,21 +256,13 @@ export class WrapWritableStream<T> extends WritableStream<T> {
public constructor(wrapper: WritableStream<T> | WrapWritableStreamStart<T> | WritableStreamWrapper<T>) {
super({
start: async () => {
// `start` is invoked before `WritableStream`'s constructor finish,
// `start` is invoked before `ReadableStream`'s constructor finish,
// so using `this` synchronously causes
// "Must call super constructor in derived class before accessing 'this' or returning from derived constructor".
// Queue a microtask to avoid this.
await Promise.resolve();
if ('start' in wrapper) {
this.writable = await wrapper.start();
} else if (typeof wrapper === 'function') {
this.writable = await wrapper();
} else {
// Can't use `wrapper instanceof WritableStream`
// Because we want to be compatible with any WritableStream-like objects
this.writable = wrapper;
}
this.writable = await getWrappedWritableStream(wrapper);
this.writer = this.writable.getWriter();
},
write: async (chunk) => {
@ -291,6 +297,20 @@ export interface ReadableStreamWrapper<T> {
close?(): Promise<void>;
}
function getWrappedReadableStream<T>(
wrapper: ReadableStream<T> | WrapReadableStreamStart<T> | ReadableStreamWrapper<T>
) {
if ('start' in wrapper) {
return wrapper.start();
} else if (typeof wrapper === 'function') {
return wrapper();
} else {
// Can't use `wrapper instanceof ReadableStream`
// Because we want to be compatible with any ReadableStream-like objects
return wrapper;
}
}
export class WrapReadableStream<T> extends ReadableStream<T>{
public readable!: ReadableStream<T>;
@ -305,15 +325,7 @@ export class WrapReadableStream<T> extends ReadableStream<T>{
// Queue a microtask to avoid this.
await Promise.resolve();
if ('start' in wrapper) {
this.readable = await wrapper.start();
} else if (typeof wrapper === 'function') {
this.readable = await wrapper();
} else {
// Can't use `wrapper instanceof ReadableStream`
// Because we want to be compatible with any ReadableStream-like objects
this.readable = wrapper;
}
this.readable = await getWrappedReadableStream(wrapper);
this.reader = this.readable.getReader();
},
cancel: async (reason) => {

View file

@ -30,7 +30,7 @@
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
},
"dependencies": {

View file

@ -0,0 +1,22 @@
// cspell: ignore apks
// cspell: ignore obbs
import { AdbCommandBase } from "@yume-chan/adb";
export interface AdbBackupOptions {
apps: string[] | 'all' | 'all-including-system';
apks: boolean;
obbs: boolean;
shared: boolean;
widgets: boolean;
compress: boolean;
user: number;
}
export class AdbBackup extends AdbCommandBase {
async backup(options: AdbBackupOptions): Promise<void> {
}
async restore(options: AdbBackupOptions): Promise<void> {
}
}

View file

@ -1,7 +1,7 @@
// cspell: ignore bugreport
// cspell: ignore bugreportz
import { AdbCommandBase, AdbShellSubprocessProtocol, DecodeUtf8Stream, PushReadableStream, ReadableStream, SplitLineStream, WrapReadableStream, WritableStream } from "@yume-chan/adb";
import { AdbCommandBase, AdbSubprocessShellProtocol, DecodeUtf8Stream, PushReadableStream, ReadableStream, SplitLineStream, WrapReadableStream, WritableStream } from "@yume-chan/adb";
export interface BugReportZVersion {
major: number;
@ -29,7 +29,7 @@ export class BugReportZ extends AdbCommandBase {
*/
public async version(): Promise<BugReportZVersion | undefined> {
// bugreportz requires shell protocol
if (!AdbShellSubprocessProtocol.isSupported(this.adb)) {
if (!AdbSubprocessShellProtocol.isSupported(this.adb)) {
return undefined;
}
@ -61,9 +61,10 @@ export class BugReportZ extends AdbCommandBase {
/**
* Create a zipped bugreport file.
*
* Compare to `stream`, this method will write the output on device. You can pull it using sync protocol.
* Compare to `stream`, this method will write the output to a file on device.
* You can pull it later using sync protocol.
*
* @param onProgress Progress callback. Only specify this if `supportsProgress()` returns `true`.
* @param onProgress Progress callback. Only specify this if `supportsProgress` is `true`.
* @returns The path of the bugreport file.
*/
public async generate(onProgress?: (progress: string, total: string) => void): Promise<string> {

View file

@ -39,7 +39,7 @@
},
"devDependencies": {
"jest": "^27.5.1",
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
}
}

View file

@ -37,7 +37,7 @@
},
"devDependencies": {
"jest": "^27.5.1",
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0"
}
}

View file

@ -48,7 +48,7 @@
"gh-release-fetch": "^2.0.4",
"jest": "^27.5.1",
"tinyh264": "^0.0.7",
"typescript": "next",
"typescript": "4.7.0-beta",
"yuv-buffer": "^1.0.0",
"yuv-canvas": "^1.2.7"
},

View file

@ -1,4 +1,4 @@
import { AdbBufferedStream, AdbNoneSubprocessProtocol, DecodeUtf8Stream, InspectStream, TransformStream, WritableStream, type Adb, type AdbSocket, type AdbSubprocessProtocol, type ReadableStream, type WritableStreamDefaultWriter } from '@yume-chan/adb';
import { AdbBufferedStream, AdbSubprocessNoneProtocol, DecodeUtf8Stream, InspectStream, TransformStream, WritableStream, type Adb, type AdbSocket, type AdbSubprocessProtocol, type ReadableStream, type WritableStreamDefaultWriter } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, type AndroidKeyEventAction } from './message.js';
@ -81,7 +81,7 @@ export class ScrcpyClient {
{
// Scrcpy server doesn't split stdout and stderr,
// so disable Shell Protocol to simplify processing
protocols: [AdbNoneSubprocessProtocol],
protocols: [AdbSubprocessNoneProtocol],
}
);

View file

@ -11,6 +11,9 @@
]
},
"references": [
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../event/tsconfig.json"
},

View file

@ -39,7 +39,7 @@
},
"devDependencies": {
"jest": "^27.5.1",
"typescript": "next",
"typescript": "4.7.0-beta",
"@yume-chan/ts-package-builder": "^1.0.0",
"@types/jest": "^27.4.1",
"@types/bluebird": "^3.5.36"

View file

@ -118,7 +118,7 @@ export class VariableLengthBufferLikeFieldLengthValue
extends StructFieldValue {
protected originalField: StructFieldValue;
protected arrayBufferField: VariableLengthBufferLikeFieldValueLike;
protected bufferField: VariableLengthBufferLikeFieldValueLike;
public constructor(
originalField: StructFieldValue,
@ -126,7 +126,7 @@ export class VariableLengthBufferLikeFieldLengthValue
) {
super(originalField.definition, originalField.options, originalField.struct, 0);
this.originalField = originalField;
this.arrayBufferField = arrayBufferField;
this.bufferField = arrayBufferField;
}
public override getSize() {
@ -134,11 +134,11 @@ export class VariableLengthBufferLikeFieldLengthValue
}
public override get() {
let value: string | number = this.arrayBufferField.getSize();
let value: string | number = this.bufferField.getSize();
const originalValue = this.originalField.get();
if (typeof originalValue === 'string') {
value = value.toString(this.arrayBufferField.definition.options.lengthFieldRadix ?? 10);
value = value.toString(this.bufferField.definition.options.lengthFieldRadix ?? 10);
}
return value;