feat(adb): group sub commands to separate objects

This commit is contained in:
Simon Chan 2020-09-27 17:10:12 +08:00
parent 7836343829
commit ba2be7172d
19 changed files with 453 additions and 167 deletions

View file

@ -10,6 +10,7 @@
"addrs", "addrs",
"fluentui", "fluentui",
"getprop", "getprop",
"killforward",
"lapo", "lapo",
"lstat", "lstat",
"mitm", "mitm",

View file

@ -1,6 +1,8 @@
import { AdbBackend, decodeBase64, encodeBase64 } from '@yume-chan/adb'; import { AdbBackend, decodeBase64, encodeBase64 } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event'; import { EventEmitter } from '@yume-chan/event';
export * from './watcher';
export const WebUsbDeviceFilter: USBDeviceFilter = { export const WebUsbDeviceFilter: USBDeviceFilter = {
classCode: 0xFF, classCode: 0xFF,
subclassCode: 0x42, subclassCode: 0x42,
@ -21,58 +23,15 @@ export function decodeUtf8(buffer: ArrayBuffer): string {
} }
export default class AdbWebBackend implements AdbBackend { export default class AdbWebBackend implements AdbBackend {
public static async fromDevice(device: USBDevice): Promise<AdbWebBackend> { public static async getDevices(): Promise<AdbWebBackend[]> {
await device.open(); const devices = await window.navigator.usb.getDevices();
return devices.map(device => new AdbWebBackend(device));
for (const configuration of device.configurations) {
for (const interface_ of configuration.interfaces) {
for (const alternate of interface_.alternates) {
if (alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode &&
alternate.interfaceClass === WebUsbDeviceFilter.classCode &&
alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode) {
if (device.configuration?.configurationValue !== configuration.configurationValue) {
await device.selectConfiguration(configuration.configurationValue);
} }
if (!interface_.claimed) { public static async requestDevice(): Promise<AdbWebBackend | undefined> {
await device.claimInterface(interface_.interfaceNumber);
}
if (interface_.alternate.alternateSetting !== alternate.alternateSetting) {
await device.selectAlternateInterface(interface_.interfaceNumber, alternate.alternateSetting);
}
let inEndpointNumber: number | undefined;
let outEndpointNumber: number | undefined;
for (const endpoint of alternate.endpoints) {
switch (endpoint.direction) {
case 'in':
inEndpointNumber = endpoint.endpointNumber;
if (outEndpointNumber !== undefined) {
return new AdbWebBackend(device, inEndpointNumber, outEndpointNumber);
}
break;
case 'out':
outEndpointNumber = endpoint.endpointNumber;
if (inEndpointNumber !== undefined) {
return new AdbWebBackend(device, inEndpointNumber, outEndpointNumber);
}
break;
}
}
}
}
}
}
throw new Error('Unknown error');
}
public static async pickDevice(): Promise<AdbWebBackend | undefined> {
try { try {
const device = await navigator.usb.requestDevice({ filters: [WebUsbDeviceFilter] }); const device = await navigator.usb.requestDevice({ filters: [WebUsbDeviceFilter] });
return AdbWebBackend.fromDevice(device); return new AdbWebBackend(device);
} catch (e) { } catch (e) {
switch (e.name) { switch (e.name) {
case 'NotFoundError': case 'NotFoundError':
@ -85,18 +44,72 @@ export default class AdbWebBackend implements AdbBackend {
private _device: USBDevice; private _device: USBDevice;
public get name() { return this._device.productName; } public get serial(): string { return this._device.serialNumber!; }
private readonly onDisconnectedEvent = new EventEmitter<void>(); public get name(): string { return this._device.productName!; }
public readonly onDisconnected = this.onDisconnectedEvent.event;
private readonly disconnectEvent = new EventEmitter<void>();
public readonly onDisconnected = this.disconnectEvent.event;
private _inEndpointNumber!: number; private _inEndpointNumber!: number;
private _outEndpointNumber!: number; private _outEndpointNumber!: number;
private constructor(device: USBDevice, inEndPointNumber: number, outEndPointNumber: number) { public constructor(device: USBDevice) {
this._device = device; this._device = device;
this._inEndpointNumber = inEndPointNumber; window.navigator.usb.addEventListener('disconnect', this.handleDisconnect);
this._outEndpointNumber = outEndPointNumber; }
private handleDisconnect = (e: USBConnectionEvent) => {
if (e.device === this._device) {
this.disconnectEvent.fire();
}
};
public async connect(): Promise<void> {
if (!this._device.opened) {
await this._device.open();
}
for (const configuration of this._device.configurations) {
for (const interface_ of configuration.interfaces) {
for (const alternate of interface_.alternates) {
if (alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode &&
alternate.interfaceClass === WebUsbDeviceFilter.classCode &&
alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode) {
if (this._device.configuration?.configurationValue !== configuration.configurationValue) {
await this._device.selectConfiguration(configuration.configurationValue);
}
if (!interface_.claimed) {
await this._device.claimInterface(interface_.interfaceNumber);
}
if (interface_.alternate.alternateSetting !== alternate.alternateSetting) {
await this._device.selectAlternateInterface(interface_.interfaceNumber, alternate.alternateSetting);
}
for (const endpoint of alternate.endpoints) {
switch (endpoint.direction) {
case 'in':
this._inEndpointNumber = endpoint.endpointNumber;
if (this._outEndpointNumber !== undefined) {
return;
}
break;
case 'out':
this._outEndpointNumber = endpoint.endpointNumber;
if (this._inEndpointNumber !== undefined) {
return;
}
break;
}
}
}
}
}
}
throw new Error('Unknown error');
} }
public *iterateKeys(): Generator<ArrayBuffer, void, void> { public *iterateKeys(): Generator<ArrayBuffer, void, void> {
@ -133,19 +146,10 @@ export default class AdbWebBackend implements AdbBackend {
} }
public async write(buffer: ArrayBuffer): Promise<void> { public async write(buffer: ArrayBuffer): Promise<void> {
try {
await this._device.transferOut(this._outEndpointNumber, buffer); await this._device.transferOut(this._outEndpointNumber, buffer);
} catch (e) {
if (e instanceof Error && e.name === 'NotFoundError') {
this.onDisconnectedEvent.fire();
}
throw e;
}
} }
public async read(length: number): Promise<ArrayBuffer> { public async read(length: number): Promise<ArrayBuffer> {
try {
const result = await this._device.transferIn(this._inEndpointNumber, length); const result = await this._device.transferIn(this._inEndpointNumber, length);
if (result.status === 'stall') { if (result.status === 'stall') {
@ -154,17 +158,11 @@ export default class AdbWebBackend implements AdbBackend {
const { buffer } = result.data!; const { buffer } = result.data!;
return buffer; return buffer;
} catch (e) {
if (e instanceof Error && e.name === 'NotFoundError') {
this.onDisconnectedEvent.fire();
}
throw e;
}
} }
public async dispose() { public async dispose() {
this.onDisconnectedEvent.dispose(); window.navigator.usb.removeEventListener('disconnect', this.handleDisconnect);
this.disconnectEvent.dispose();
await this._device.close(); await this._device.close();
} }
} }

View file

@ -0,0 +1,15 @@
export class AdbWebBackendWatcher {
private callback: () => void;
public constructor(callback: () => void) {
this.callback = callback;
window.navigator.usb.addEventListener('connect', callback);
window.navigator.usb.addEventListener('disconnect', callback);
}
public dispose(): void {
window.navigator.usb.removeEventListener('connect', this.callback);
window.navigator.usb.removeEventListener('disconnect', this.callback);
}
}

View file

@ -2,6 +2,7 @@ import { PromiseResolver } from '@yume-chan/async-operation-manager';
import { DisposableList } from '@yume-chan/event'; import { DisposableList } from '@yume-chan/event';
import { AdbAuthenticationHandler, AdbDefaultAuthenticators } from './auth'; import { AdbAuthenticationHandler, AdbDefaultAuthenticators } from './auth';
import { AdbBackend } from './backend'; import { AdbBackend } from './backend';
import { AdbReverseCommand, AdbTcpIpCommand } from './commands';
import { AdbFeatures } from './features'; import { AdbFeatures } from './features';
import { FrameBuffer } from './framebuffer'; import { FrameBuffer } from './framebuffer';
import { AdbCommand } from './packet'; import { AdbCommand } from './packet';
@ -16,6 +17,8 @@ export enum AdbPropKey {
} }
export class Adb { export class Adb {
private packetDispatcher: AdbPacketDispatcher;
private backend: AdbBackend; private backend: AdbBackend;
public get onDisconnected() { return this.backend.onDisconnected; } public get onDisconnected() { return this.backend.onDisconnected; }
@ -36,29 +39,38 @@ export class Adb {
private _features: AdbFeatures[] | undefined; private _features: AdbFeatures[] | undefined;
public get features() { return this._features; } public get features() { return this._features; }
private packetDispatcher: AdbPacketDispatcher; public readonly tcpip: AdbTcpIpCommand;
public readonly reverse: AdbReverseCommand;
public constructor(backend: AdbBackend) { public constructor(backend: AdbBackend) {
this.backend = backend; this.backend = backend;
this.packetDispatcher = new AdbPacketDispatcher(backend); this.packetDispatcher = new AdbPacketDispatcher(backend);
this.tcpip = new AdbTcpIpCommand(this);
this.reverse = new AdbReverseCommand(this.packetDispatcher);
backend.onDisconnected(this.dispose, this); backend.onDisconnected(this.dispose, this);
} }
public async connect(authenticators = AdbDefaultAuthenticators) { public async connect(authenticators = AdbDefaultAuthenticators) {
await this.backend.connect?.();
this.packetDispatcher.start();
const version = 0x01000001; const version = 0x01000001;
const features = [ const features = [
'shell_v2', 'shell_v2', // 9
'cmd', 'cmd', // 7
AdbFeatures.StatV2, AdbFeatures.StatV2, // 5
'ls_v2', 'ls_v2',
'fixed_push_mkdir', 'fixed_push_mkdir', // 4
'apex', 'apex', // 2
'abb', 'abb', // 8
'fixed_push_symlink_timestamp', 'fixed_push_symlink_timestamp', // 1
'abb_exec', 'abb_exec', // 6
'remount_shell', 'remount_shell', // 3
'track_app', 'track_app',
'sendrecv_v2', 'sendrecv_v2',
'sendrecv_v2_brotli', 'sendrecv_v2_brotli',
@ -163,33 +175,6 @@ export class Adb {
} }
} }
public async getDaemonTcpAddresses(): Promise<string[]> {
const propAddr = (await this.shell('getprop', 'service.adb.listen_addrs')).trim();
if (propAddr) {
return propAddr.split(',');
}
let port = (await this.shell('getprop', 'service.adb.tcp.port')).trim();
if (port) {
return [`0.0.0.0:${port}`];
}
port = (await this.shell('getprop', 'persist.adb.tcp.port')).trim();
if (port) {
return [`0.0.0.0:${port}`];
}
return [];
}
public setDaemonTcpPort(port = 5555): Promise<string> {
return this.createStreamAndReadAll(`tcpip:${port}`);
}
public disableDaemonTcp(): Promise<string> {
return this.createStreamAndReadAll('usb:');
}
public async sync(): Promise<AdbSync> { public async sync(): Promise<AdbSync> {
const stream = await this.createStream('sync:'); const stream = await this.createStream('sync:');
return new AdbSync(stream, this); return new AdbSync(stream, this);
@ -198,11 +183,7 @@ export class Adb {
public async framebuffer(): Promise<FrameBuffer> { public async framebuffer(): Promise<FrameBuffer> {
const stream = await this.createStream('framebuffer:'); const stream = await this.createStream('framebuffer:');
const buffered = new AdbBufferedStream(stream); const buffered = new AdbBufferedStream(stream);
return await FrameBuffer.deserialize({ return FrameBuffer.deserialize(buffered);
read: buffered.read.bind(buffered),
encodeUtf8: this.backend.encodeUtf8.bind(this.backend),
decodeUtf8: this.backend.decodeUtf8.bind(this.backend),
});
} }
public async createStream(service: string): Promise<AdbStream> { public async createStream(service: string): Promise<AdbStream> {

View file

@ -3,10 +3,14 @@ import { Event } from '@yume-chan/event';
export type AdbKeyIterator = Iterator<ArrayBuffer> | AsyncIterator<ArrayBuffer>; export type AdbKeyIterator = Iterator<ArrayBuffer> | AsyncIterator<ArrayBuffer>;
export interface AdbBackend { export interface AdbBackend {
readonly serial: string;
readonly name: string | undefined; readonly name: string | undefined;
readonly onDisconnected: Event<void>; readonly onDisconnected: Event<void>;
connect?(): void | Promise<void>;
iterateKeys(): AdbKeyIterator; iterateKeys(): AdbKeyIterator;
generateKey(): ArrayBuffer | Promise<ArrayBuffer>; generateKey(): ArrayBuffer | Promise<ArrayBuffer>;

View file

@ -0,0 +1,11 @@
import { AutoDisposable } from '@yume-chan/event';
import { Adb } from '../adb';
export class AdbCommandBase extends AutoDisposable {
protected adb: Adb;
public constructor(adb: Adb) {
super();
this.adb = adb;
}
}

View file

@ -0,0 +1,3 @@
export * from './base';
export * from './tcpip';
export * from './reverse';

View file

@ -0,0 +1,122 @@
import { AutoDisposable } from '@yume-chan/event';
import { Struct } from '@yume-chan/struct';
import { Adb } from '../adb';
import { AdbPacket } from '../packet';
import { AdbBufferedStream, AdbPacketDispatcher, AdbStream } from '../stream';
import { AdbCommandBase } from './base';
export interface AdbReverseHandler {
onStream(packet: AdbPacket, stream: AdbStream): void;
}
export interface AdbForwardListener {
deviceSerial: string;
localName: string;
remoteName: string;
}
const AdbReverseStringResponse =
new Struct({ littleEndian: true })
.string('length', { length: 4 })
.string('content', { lengthField: 'length' });
const AdbReverseErrorResponse =
AdbReverseStringResponse
.afterParsed((value) => {
throw new Error(value.content);
});
export class AdbReverseCommand extends AutoDisposable {
private portToHandlerMap = new Map<number, AdbReverseHandler>();
private devicePortToPortMap = new Map<number, number>();
private dispatcher: AdbPacketDispatcher;
private listening = false;
public constructor(dispatcher: AdbPacketDispatcher) {
super();
this.dispatcher = dispatcher;
}
public async list(): Promise<AdbForwardListener[]> {
const stream = await this.dispatcher.createStream('reverse:list-forward');
const buffered = new AdbBufferedStream(stream);
const response = await AdbReverseStringResponse.deserialize(buffered);
return response.content!.split('\n').map(line => {
const [deviceSerial, localName, remoteName] = line.split(' ');
return { deviceSerial, localName, remoteName };
});
}
public async add(
port: number,
handler: AdbReverseHandler,
devicePort: number = 0,
): Promise<number> {
if (!this.listening) {
this.addDisposable(this.dispatcher.onStream(e => {
if (e.handled) {
return;
}
const address = this.dispatcher.backend.decodeUtf8(e.packet.payload!);
const port = Number.parseInt(address.substring(4));
if (this.portToHandlerMap.has(port)) {
this.portToHandlerMap.get(port)!.onStream(e.packet, e.stream);
e.handled = true;
}
}));
}
const stream = await this.dispatcher.createStream(`reverse:forward:tcp:${devicePort};tcp:${port}`);
const buffered = new AdbBufferedStream(stream);
const success = this.dispatcher.backend.decodeUtf8(await buffered.read(4)) === 'OKAY';
if (success) {
const response = await AdbReverseStringResponse.deserialize(buffered);
devicePort = Number.parseInt(response.content!, 10);
this.portToHandlerMap.set(port, handler);
this.devicePortToPortMap.set(devicePort, port);
return devicePort;
} else {
return await AdbReverseErrorResponse.deserialize(buffered);
}
}
public async remove(devicePort: number): Promise<void> {
const stream = await this.dispatcher.createStream(`reverse:killforward:tcp:${devicePort}`);
const buffered = new AdbBufferedStream(stream);
const success = this.dispatcher.backend.decodeUtf8(await buffered.read(4)) === 'OKAY';
if (success) {
if (this.devicePortToPortMap.has(devicePort)) {
this.portToHandlerMap.delete(this.devicePortToPortMap.get(devicePort)!);
this.devicePortToPortMap.delete(devicePort);
}
} else {
await AdbReverseErrorResponse.deserialize(buffered);
}
}
public async removeAll(): Promise<void> {
const stream = await this.dispatcher.createStream(`reverse:killforward-all`);
const buffered = new AdbBufferedStream(stream);
const success = this.dispatcher.backend.decodeUtf8(await buffered.read(4)) === 'OKAY';
if (success) {
this.devicePortToPortMap.clear();
this.portToHandlerMap.clear();
} else {
await AdbReverseErrorResponse.deserialize(buffered);
}
}
}

View file

@ -0,0 +1,45 @@
import { AdbCommandBase } from './base';
export class AdbTcpIpCommand extends AdbCommandBase {
private async getProp(key: string): Promise<string> {
const output = await this.adb.shell('getprop', key);
return output.trim();
}
public async getAddresses(): Promise<string[]> {
const propAddr = await this.getProp('service.adb.listen_addrs');
if (propAddr) {
return propAddr.split(',');
}
let port = await this.getProp('service.adb.tcp.port');
if (port) {
return [`0.0.0.0:${port}`];
}
port = await this.getProp('persist.adb.tcp.port');
if (port) {
return [`0.0.0.0:${port}`];
}
return [];
}
public async setPort(port: number): Promise<void> {
if (port <= 0) {
throw new Error(`Invalid port ${port}`);
}
const output = await this.adb.createStreamAndReadAll(`tcpip:${port}`);
if (output !== `restarting in TCP mode port: ${port}\n`) {
throw new Error('Invalid response');
}
}
public async disable(): Promise<void> {
const output = await this.adb.createStreamAndReadAll('usb:');
if (output !== 'restarting in USB mode\n') {
throw new Error('Invalid response');
}
}
}

View file

@ -1,3 +1,4 @@
import { StructDeserializationContext } from '@yume-chan/struct';
import { AdbStreamBase } from './controller'; import { AdbStreamBase } from './controller';
import { AdbReadableStream } from './readable-stream'; import { AdbReadableStream } from './readable-stream';
import { AdbStream } from './stream'; import { AdbStream } from './stream';
@ -60,7 +61,9 @@ export class BufferedStream<T extends Stream> {
} }
} }
export class AdbBufferedStream extends BufferedStream<AdbReadableStream> implements AdbStreamBase { export class AdbBufferedStream
extends BufferedStream<AdbReadableStream>
implements AdbStreamBase, StructDeserializationContext {
public get backend() { return this.stream.backend; } public get backend() { return this.stream.backend; }
public get localId() { return this.stream.localId; } public get localId() { return this.stream.localId; }
@ -74,4 +77,12 @@ export class AdbBufferedStream extends BufferedStream<AdbReadableStream> impleme
public write(data: ArrayBuffer): Promise<void> { public write(data: ArrayBuffer): Promise<void> {
return this.stream.write(data); return this.stream.write(data);
} }
public decodeUtf8(buffer: ArrayBuffer): string {
return this.backend.decodeUtf8(buffer);
}
public encodeUtf8(input: string): ArrayBuffer {
return this.backend.encodeUtf8(input);
}
} }

View file

@ -36,14 +36,13 @@ export class AdbPacketDispatcher extends AutoDisposable {
private readonly errorEvent = this.addDisposable(new EventEmitter<Error>()); private readonly errorEvent = this.addDisposable(new EventEmitter<Error>());
public get onError() { return this.errorEvent.event; } public get onError() { return this.errorEvent.event; }
private _running = true; private _running = false;
public get running() { return this._running; } public get running() { return this._running; }
public constructor(backend: AdbBackend) { public constructor(backend: AdbBackend) {
super(); super();
this.backend = backend; this.backend = backend;
this.receiveLoop();
} }
private async receiveLoop() { private async receiveLoop() {
@ -129,7 +128,8 @@ export class AdbPacketDispatcher extends AutoDisposable {
const [localId] = this.initializers.add<number>(); const [localId] = this.initializers.add<number>();
this.initializers.resolve(localId, undefined); this.initializers.resolve(localId, undefined);
const controller = new AdbStreamController(localId, packet.arg0, this); const remoteId = packet.arg0;
const controller = new AdbStreamController(localId, remoteId, this);
const stream = new AdbStream(controller); const stream = new AdbStream(controller);
const args: AdbStreamCreatedEventArgs = { const args: AdbStreamCreatedEventArgs = {
@ -141,12 +141,17 @@ export class AdbPacketDispatcher extends AutoDisposable {
if (args.handled) { if (args.handled) {
this.streams.set(localId, controller); this.streams.set(localId, controller);
await this.sendPacket(AdbCommand.OK, localId, packet.arg0); await this.sendPacket(AdbCommand.OK, localId, remoteId);
} else { } else {
await this.sendPacket(AdbCommand.Close, 0, packet.arg0); await this.sendPacket(AdbCommand.Close, 0, remoteId);
} }
} }
public start() {
this._running = true;
this.receiveLoop();
}
public async createStream(service: string): Promise<AdbStream> { public async createStream(service: string): Promise<AdbStream> {
const [localId, initializer] = this.initializers.add<number>(); const [localId, initializer] = this.initializers.add<number>();
await this.sendPacket(AdbCommand.Open, localId, 0, service); await this.sendPacket(AdbCommand.Open, localId, 0, service);

View file

@ -1,10 +1,12 @@
import { DefaultButton, Dialog, PrimaryButton, ProgressIndicator, Stack, StackItem } from '@fluentui/react'; import { DefaultButton, Dialog, Dropdown, IDropdownOption, PrimaryButton, ProgressIndicator, Stack, TooltipHost } from '@fluentui/react';
import { Adb } from '@yume-chan/adb'; import { Adb, AdbBackend } from '@yume-chan/adb';
import AdbWebBackend from '@yume-chan/adb-backend-web'; import AdbWebBackend, { AdbWebBackendWatcher } from '@yume-chan/adb-backend-web';
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
import { ErrorDialogContext } from './error-dialog'; import { ErrorDialogContext } from './error-dialog';
import { withDisplayName } from './utils'; import { withDisplayName } from './utils';
const DropdownStyles = { dropdown: { width: 300 } };
interface ConnectProps { interface ConnectProps {
device: Adb | undefined; device: Adb | undefined;
@ -17,12 +19,66 @@ export default withDisplayName('Connect', ({
}: ConnectProps): JSX.Element | null => { }: ConnectProps): JSX.Element | null => {
const { show: showErrorDialog } = useContext(ErrorDialogContext); const { show: showErrorDialog } = useContext(ErrorDialogContext);
const [backendOptions, setBackendOptions] = useState<IDropdownOption[]>([]);
const [selectedBackend, setSelectedBackend] = useState<AdbBackend | undefined>();
useEffect(() => {
async function refresh() {
const backendList = await AdbWebBackend.getDevices();
const options = backendList.map(item => ({
key: item.serial,
text: `${item.serial} ${item.name ? `(${item.name})` : ''}`,
data: item,
}));
setBackendOptions(options);
setSelectedBackend(old => {
if (old && backendList.some(item => item.serial === old.serial)) {
return old;
}
return backendList[0];
});
};
refresh();
const watcher = new AdbWebBackendWatcher(refresh);
return () => watcher.dispose();
}, []);
const handleSelectedBackendChange = (
_e: React.FormEvent<HTMLDivElement>,
option?: IDropdownOption,
) => {
setSelectedBackend(option?.data as AdbBackend);
};
const requestAccess = useCallback(async () => {
const backend = await AdbWebBackend.requestDevice();
if (backend) {
setBackendOptions(list => {
for (const item of list) {
if (item.key === backend.serial) {
setSelectedBackend(item.data);
return list;
}
}
setSelectedBackend(backend);
return [...list, {
key: backend.serial,
text: `${backend.serial} ${backend.name ? `(${backend.name})` : ''}`,
data: backend,
}];
});
}
}, []);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const connect = useCallback(async () => { const connect = useCallback(async () => {
try { try {
const backend = await AdbWebBackend.pickDevice(); if (selectedBackend) {
if (backend) { const device = new Adb(selectedBackend);
const device = new Adb(backend);
try { try {
setConnecting(true); setConnecting(true);
await device.connect(); await device.connect();
@ -37,7 +93,7 @@ export default withDisplayName('Connect', ({
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
}, [onDeviceChange]); }, [selectedBackend, onDeviceChange]);
const disconnect = useCallback(async () => { const disconnect = useCallback(async () => {
try { try {
await device!.dispose(); await device!.dispose();
@ -53,18 +109,42 @@ export default withDisplayName('Connect', ({
}, [device, onDeviceChange]); }, [device, onDeviceChange]);
return ( return (
<Stack
horizontal
verticalAlign="end"
tokens={{ childrenGap: 8, padding: 8 }}
>
<Dropdown
disabled={!!device || backendOptions.length === 0}
label="Available devices"
placeholder="No available devices"
options={backendOptions}
styles={DropdownStyles}
selectedKey={selectedBackend?.serial}
onChange={handleSelectedBackendChange}
/>
{!device ? (
<> <>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8, padding: 8 }}> <PrimaryButton
{!device && <StackItem> text="Connect"
<PrimaryButton text="Connect" onClick={connect} /> disabled={!selectedBackend}
</StackItem>} primary={!!selectedBackend}
{device && <StackItem> onClick={connect}
/>
<TooltipHost
content="WebADB can't connect to anything without your explicit permission."
>
<DefaultButton
text="Add new device"
primary={!selectedBackend}
onClick={requestAccess}
/>
</TooltipHost>
</>
) : (
<DefaultButton text="Disconnect" onClick={disconnect} /> <DefaultButton text="Disconnect" onClick={disconnect} />
</StackItem>} )}
<StackItem>
{device && `Connected to ${device.name}`}
</StackItem>
</Stack>
<Dialog <Dialog
hidden={!connecting} hidden={!connecting}
@ -75,6 +155,6 @@ export default withDisplayName('Connect', ({
> >
<ProgressIndicator /> <ProgressIndicator />
</Dialog> </Dialog>
</> </Stack>
); );
}); });

View file

@ -12,7 +12,8 @@ import FileManager from './routes/file-manager';
import FrameBuffer from './routes/framebuffer'; import FrameBuffer from './routes/framebuffer';
import Intro from './routes/intro'; import Intro from './routes/intro';
import Shell from './routes/shell'; import Shell from './routes/shell';
import TcpIp from './routes/tcp-ip'; import TcpIp from './routes/tcpip';
import { CommonStackTokens } from './styles';
initializeIcons(); initializeIcons();
@ -105,7 +106,7 @@ function App(): JSX.Element | null {
<Separator /> <Separator />
</StackItem> </StackItem>
<StackItem grow styles={{ root: { minHeight: 0 } }}> <StackItem grow styles={{ root: { minHeight: 0 } }}>
<Stack horizontal verticalFill tokens={{ childrenGap: 8 }}> <Stack horizontal verticalFill tokens={CommonStackTokens}>
<StackItem> <StackItem>
<Nav <Nav
styles={{ root: { width: 250 } }} styles={{ root: { width: 250 } }}

View file

@ -9,6 +9,8 @@ const classNames = mergeStyleSets({
}, },
}); });
const BoldTextStyles = { root: { fontWeight: '600' } };
interface CopyLinkProps { interface CopyLinkProps {
href: string; href: string;
} }
@ -56,7 +58,7 @@ export default withDisplayName('Intro', () => {
The latest version of Google Chrome (or Microsoft Edge) is recommended for best compatibility. The latest version of Google Chrome (or Microsoft Edge) is recommended for best compatibility.
</Text> </Text>
<Text block styles={{ root: { fontWeight: '600' } }}> <Text block styles={BoldTextStyles}>
Windows user? Windows user?
</Text> </Text>
<Text block> <Text block>
@ -65,7 +67,7 @@ export default withDisplayName('Intro', () => {
. .
</Text> </Text>
<Text block styles={{ root: { fontWeight: '600' } }}> <Text block styles={BoldTextStyles}>
Got "Unable to claim interface" error? Got "Unable to claim interface" error?
</Text> </Text>
<Text block> <Text block>
@ -73,7 +75,7 @@ export default withDisplayName('Intro', () => {
1. Make sure ADB server is not running (run `adb kill-server` to stop it).<br /> 1. Make sure ADB server is not running (run `adb kill-server` to stop it).<br />
2. Make sure no other Android management tools are running 2. Make sure no other Android management tools are running
</Text> </Text>
<Text block styles={{ root: { fontWeight: '600' } }}> <Text block styles={BoldTextStyles}>
Got "Access denied" error? Got "Access denied" error?
</Text> </Text>
<Text block> <Text block>
@ -82,7 +84,7 @@ export default withDisplayName('Intro', () => {
https://bugs.chromium.org/p/chromium/issues/detail?id=1127206 https://bugs.chromium.org/p/chromium/issues/detail?id=1127206
</Link> </Link>
</Text> </Text>
<Text block styles={{ root: { fontWeight: '600' } }}> <Text block styles={BoldTextStyles}>
Can I connect my device wirelessly (ADB over WiFi)? Can I connect my device wirelessly (ADB over WiFi)?
</Text> </Text>
<Text block> <Text block>

View file

@ -8,11 +8,14 @@ import 'xterm/css/xterm.css';
import { ResizeObserver, withDisplayName } from '../utils'; import { ResizeObserver, withDisplayName } from '../utils';
import { RouteProps } from './type'; import { RouteProps } from './type';
const containerStyle: CSSProperties = { const ResizeObserverStyle: CSSProperties = {
width: '100%', width: '100%',
height: '100%', height: '100%',
}; };
const UpIconProps = { iconName: 'ChevronUp' };
const DownIconProps = { iconName: 'ChevronDown' };
export default withDisplayName('Shell', ({ export default withDisplayName('Shell', ({
device, device,
}: RouteProps): JSX.Element | null => { }: RouteProps): JSX.Element | null => {
@ -99,21 +102,21 @@ export default withDisplayName('Shell', ({
<StackItem> <StackItem>
<IconButton <IconButton
disabled={!findKeyword} disabled={!findKeyword}
iconProps={{ iconName: 'ChevronUp' }} iconProps={UpIconProps}
onClick={findPrevious} onClick={findPrevious}
/> />
</StackItem> </StackItem>
<StackItem> <StackItem>
<IconButton <IconButton
disabled={!findKeyword} disabled={!findKeyword}
iconProps={{ iconName: 'ChevronDown' }} iconProps={DownIconProps}
onClick={findNext} onClick={findNext}
/> />
</StackItem> </StackItem>
</Stack> </Stack>
</StackItem> </StackItem>
<StackItem grow styles={{ root: { minHeight: 0 } }}> <StackItem grow styles={{ root: { minHeight: 0 } }}>
<ResizeObserver style={containerStyle} onResize={handleResize}> <ResizeObserver style={ResizeObserverStyle} onResize={handleResize}>
<div ref={handleContainerRef} style={{ height: '100%' }} /> <div ref={handleContainerRef} style={{ height: '100%' }} />
</ResizeObserver> </ResizeObserver>
</StackItem> </StackItem>

View file

@ -1,6 +1,7 @@
import { Label, MessageBar, PrimaryButton, Stack, StackItem, Text, TextField } from '@fluentui/react'; import { Label, MessageBar, PrimaryButton, Stack, StackItem, Text, TextField } from '@fluentui/react';
import { useId } from '@uifabric/react-hooks'; import { useId } from '@uifabric/react-hooks';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { CommonStackTokens } from '../styles';
import { withDisplayName } from '../utils'; import { withDisplayName } from '../utils';
import { RouteProps } from './type'; import { RouteProps } from './type';
@ -19,7 +20,7 @@ export default withDisplayName('TcpIp', ({
return; return;
} }
const result = await device.getDaemonTcpAddresses(); const result = await device.tcpip.getAddresses();
setTcpAddresses(result); setTcpAddresses(result);
}, [device]); }, [device]);
@ -30,8 +31,7 @@ export default withDisplayName('TcpIp', ({
return; return;
} }
const result = await device.setDaemonTcpPort(Number.parseInt(tcpPortValue, 10)); await device.tcpip.setPort(Number.parseInt(tcpPortValue, 10));
console.log(result);
}, [device, tcpPortValue]); }, [device, tcpPortValue]);
const disableTcp = useCallback(async () => { const disableTcp = useCallback(async () => {
@ -39,8 +39,7 @@ export default withDisplayName('TcpIp', ({
return; return;
} }
const result = await device.disableDaemonTcp(); await device.tcpip.disable();
console.log(result);
}, [device]); }, [device]);
return ( return (
@ -55,7 +54,7 @@ export default withDisplayName('TcpIp', ({
<Text>Your device will disconnect after changing ADB over WiFi config.</Text> <Text>Your device will disconnect after changing ADB over WiFi config.</Text>
</MessageBar> </MessageBar>
</StackItem> </StackItem>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8 }}> <Stack horizontal verticalAlign="center" tokens={CommonStackTokens}>
<StackItem> <StackItem>
<PrimaryButton text="Update Status" disabled={!device} onClick={queryTcpAddress} /> <PrimaryButton text="Update Status" disabled={!device} onClick={queryTcpAddress} />
</StackItem> </StackItem>
@ -66,7 +65,7 @@ export default withDisplayName('TcpIp', ({
: 'Disabled')} : 'Disabled')}
</StackItem> </StackItem>
</Stack> </Stack>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8 }}> <Stack horizontal verticalAlign="center" tokens={CommonStackTokens}>
<StackItem> <StackItem>
<Label htmlFor={tcpPortInputId}>Port: </Label> <Label htmlFor={tcpPortInputId}>Port: </Label>
</StackItem> </StackItem>

View file

@ -0,0 +1 @@
export const CommonStackTokens = { childrenGap: 8 };

View file

@ -34,7 +34,7 @@ export namespace VariableLengthArray {
export interface Options< export interface Options<
TInit = object, TInit = object,
TLengthField extends KeyOfType<TInit, number> = any, TLengthField extends KeyOfType<TInit, number | string> = any,
TEmptyBehavior extends EmptyBehavior = EmptyBehavior TEmptyBehavior extends EmptyBehavior = EmptyBehavior
> extends FieldDescriptorBaseOptions { > extends FieldDescriptorBaseOptions {
lengthField: TLengthField; lengthField: TLengthField;
@ -131,7 +131,7 @@ export interface VariableLengthArray<
TName extends string = string, TName extends string = string,
TType extends Array.SubType = Array.SubType, TType extends Array.SubType = Array.SubType,
TInit = object, TInit = object,
TLengthField extends VariableLengthArray.KeyOfType<TInit, number> = any, TLengthField extends VariableLengthArray.KeyOfType<TInit, number | string> = any,
TEmptyBehavior extends VariableLengthArray.EmptyBehavior = VariableLengthArray.EmptyBehavior, TEmptyBehavior extends VariableLengthArray.EmptyBehavior = VariableLengthArray.EmptyBehavior,
TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior>, TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior>,
TOptions extends VariableLengthArray.Options<TInit, TLengthField, TEmptyBehavior> = VariableLengthArray.Options<TInit, TLengthField, TEmptyBehavior> TOptions extends VariableLengthArray.Options<TInit, TLengthField, TEmptyBehavior> = VariableLengthArray.Options<TInit, TLengthField, TEmptyBehavior>
@ -156,7 +156,11 @@ registerFieldTypeDefinition(
async deserialize( async deserialize(
{ context, field, object } { context, field, object }
): Promise<{ value: string | ArrayBuffer | undefined, extra?: ArrayBuffer; }> { ): Promise<{ value: string | ArrayBuffer | undefined, extra?: ArrayBuffer; }> {
const length = object[field.options.lengthField]; let length = object[field.options.lengthField];
if (typeof length === 'string') {
length = Number.parseInt(length, 10);
}
if (length === 0) { if (length === 0) {
if (field.options.emptyBehavior === VariableLengthArray.EmptyBehavior.Empty) { if (field.options.emptyBehavior === VariableLengthArray.EmptyBehavior.Empty) {
switch (field.subType) { switch (field.subType) {

View file

@ -39,7 +39,7 @@ interface AddArrayFieldDescriptor<
< <
TName extends string, TName extends string,
TType extends Array.SubType, TType extends Array.SubType,
TLengthField extends VariableLengthArray.KeyOfType<TInit, number>, TLengthField extends VariableLengthArray.KeyOfType<TInit, number | string>,
TEmptyBehavior extends VariableLengthArray.EmptyBehavior, TEmptyBehavior extends VariableLengthArray.EmptyBehavior,
TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior> TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior>
>( >(
@ -91,7 +91,7 @@ interface AddArraySubTypeFieldDescriptor<
< <
TName extends string, TName extends string,
TLengthField extends VariableLengthArray.KeyOfType<TInit, number>, TLengthField extends VariableLengthArray.KeyOfType<TInit, number | string>,
TEmptyBehavior extends VariableLengthArray.EmptyBehavior, TEmptyBehavior extends VariableLengthArray.EmptyBehavior,
TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior> TTypeScriptType = VariableLengthArray.TypeScriptType<TType, TEmptyBehavior>
>( >(