diff --git a/.vscode/settings.json b/.vscode/settings.json index 26b092dd..4c5539fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "addrs", "fluentui", "getprop", + "killforward", "lapo", "lstat", "mitm", diff --git a/packages/adb-backend-web/src/index.ts b/packages/adb-backend-web/src/index.ts index dc7491de..e4f23fdf 100644 --- a/packages/adb-backend-web/src/index.ts +++ b/packages/adb-backend-web/src/index.ts @@ -1,6 +1,8 @@ import { AdbBackend, decodeBase64, encodeBase64 } from '@yume-chan/adb'; import { EventEmitter } from '@yume-chan/event'; +export * from './watcher'; + export const WebUsbDeviceFilter: USBDeviceFilter = { classCode: 0xFF, subclassCode: 0x42, @@ -21,58 +23,15 @@ export function decodeUtf8(buffer: ArrayBuffer): string { } export default class AdbWebBackend implements AdbBackend { - public static async fromDevice(device: USBDevice): Promise { - await device.open(); - - 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) { - 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 getDevices(): Promise { + const devices = await window.navigator.usb.getDevices(); + return devices.map(device => new AdbWebBackend(device)); } - public static async pickDevice(): Promise { + public static async requestDevice(): Promise { try { const device = await navigator.usb.requestDevice({ filters: [WebUsbDeviceFilter] }); - return AdbWebBackend.fromDevice(device); + return new AdbWebBackend(device); } catch (e) { switch (e.name) { case 'NotFoundError': @@ -85,18 +44,72 @@ export default class AdbWebBackend implements AdbBackend { private _device: USBDevice; - public get name() { return this._device.productName; } + public get serial(): string { return this._device.serialNumber!; } - private readonly onDisconnectedEvent = new EventEmitter(); - public readonly onDisconnected = this.onDisconnectedEvent.event; + public get name(): string { return this._device.productName!; } + + private readonly disconnectEvent = new EventEmitter(); + public readonly onDisconnected = this.disconnectEvent.event; private _inEndpointNumber!: number; private _outEndpointNumber!: number; - private constructor(device: USBDevice, inEndPointNumber: number, outEndPointNumber: number) { + public constructor(device: USBDevice) { this._device = device; - this._inEndpointNumber = inEndPointNumber; - this._outEndpointNumber = outEndPointNumber; + window.navigator.usb.addEventListener('disconnect', this.handleDisconnect); + } + + private handleDisconnect = (e: USBConnectionEvent) => { + if (e.device === this._device) { + this.disconnectEvent.fire(); + } + }; + + public async connect(): Promise { + 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 { @@ -133,38 +146,23 @@ export default class AdbWebBackend implements AdbBackend { } public async write(buffer: ArrayBuffer): Promise { - try { - await this._device.transferOut(this._outEndpointNumber, buffer); - } catch (e) { - if (e instanceof Error && e.name === 'NotFoundError') { - this.onDisconnectedEvent.fire(); - } - - throw e; - } + await this._device.transferOut(this._outEndpointNumber, buffer); } public async read(length: number): Promise { - try { - const result = await this._device.transferIn(this._inEndpointNumber, length); + const result = await this._device.transferIn(this._inEndpointNumber, length); - if (result.status === 'stall') { - await this._device.clearHalt('in', this._inEndpointNumber); - } - - const { buffer } = result.data!; - return buffer; - } catch (e) { - if (e instanceof Error && e.name === 'NotFoundError') { - this.onDisconnectedEvent.fire(); - } - - throw e; + if (result.status === 'stall') { + await this._device.clearHalt('in', this._inEndpointNumber); } + + const { buffer } = result.data!; + return buffer; } public async dispose() { - this.onDisconnectedEvent.dispose(); + window.navigator.usb.removeEventListener('disconnect', this.handleDisconnect); + this.disconnectEvent.dispose(); await this._device.close(); } } diff --git a/packages/adb-backend-web/src/watcher.ts b/packages/adb-backend-web/src/watcher.ts new file mode 100644 index 00000000..3ef4b7b8 --- /dev/null +++ b/packages/adb-backend-web/src/watcher.ts @@ -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); + } +} diff --git a/packages/adb/src/adb.ts b/packages/adb/src/adb.ts index 6c554cba..7eafe1ab 100644 --- a/packages/adb/src/adb.ts +++ b/packages/adb/src/adb.ts @@ -2,6 +2,7 @@ import { PromiseResolver } from '@yume-chan/async-operation-manager'; import { DisposableList } from '@yume-chan/event'; import { AdbAuthenticationHandler, AdbDefaultAuthenticators } from './auth'; import { AdbBackend } from './backend'; +import { AdbReverseCommand, AdbTcpIpCommand } from './commands'; import { AdbFeatures } from './features'; import { FrameBuffer } from './framebuffer'; import { AdbCommand } from './packet'; @@ -16,6 +17,8 @@ export enum AdbPropKey { } export class Adb { + private packetDispatcher: AdbPacketDispatcher; + private backend: AdbBackend; public get onDisconnected() { return this.backend.onDisconnected; } @@ -36,29 +39,38 @@ export class Adb { private _features: AdbFeatures[] | undefined; public get features() { return this._features; } - private packetDispatcher: AdbPacketDispatcher; + public readonly tcpip: AdbTcpIpCommand; + + public readonly reverse: AdbReverseCommand; public constructor(backend: AdbBackend) { this.backend = backend; + this.packetDispatcher = new AdbPacketDispatcher(backend); + this.tcpip = new AdbTcpIpCommand(this); + this.reverse = new AdbReverseCommand(this.packetDispatcher); + backend.onDisconnected(this.dispose, this); } public async connect(authenticators = AdbDefaultAuthenticators) { + await this.backend.connect?.(); + this.packetDispatcher.start(); + const version = 0x01000001; const features = [ - 'shell_v2', - 'cmd', - AdbFeatures.StatV2, + 'shell_v2', // 9 + 'cmd', // 7 + AdbFeatures.StatV2, // 5 'ls_v2', - 'fixed_push_mkdir', - 'apex', - 'abb', - 'fixed_push_symlink_timestamp', - 'abb_exec', - 'remount_shell', + 'fixed_push_mkdir', // 4 + 'apex', // 2 + 'abb', // 8 + 'fixed_push_symlink_timestamp', // 1 + 'abb_exec', // 6 + 'remount_shell', // 3 'track_app', 'sendrecv_v2', 'sendrecv_v2_brotli', @@ -163,33 +175,6 @@ export class Adb { } } - public async getDaemonTcpAddresses(): Promise { - 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 { - return this.createStreamAndReadAll(`tcpip:${port}`); - } - - public disableDaemonTcp(): Promise { - return this.createStreamAndReadAll('usb:'); - } - public async sync(): Promise { const stream = await this.createStream('sync:'); return new AdbSync(stream, this); @@ -198,11 +183,7 @@ export class Adb { public async framebuffer(): Promise { const stream = await this.createStream('framebuffer:'); const buffered = new AdbBufferedStream(stream); - return await FrameBuffer.deserialize({ - read: buffered.read.bind(buffered), - encodeUtf8: this.backend.encodeUtf8.bind(this.backend), - decodeUtf8: this.backend.decodeUtf8.bind(this.backend), - }); + return FrameBuffer.deserialize(buffered); } public async createStream(service: string): Promise { diff --git a/packages/adb/src/backend.ts b/packages/adb/src/backend.ts index 83dc16db..8f4c5a94 100644 --- a/packages/adb/src/backend.ts +++ b/packages/adb/src/backend.ts @@ -3,10 +3,14 @@ import { Event } from '@yume-chan/event'; export type AdbKeyIterator = Iterator | AsyncIterator; export interface AdbBackend { + readonly serial: string; + readonly name: string | undefined; readonly onDisconnected: Event; + connect?(): void | Promise; + iterateKeys(): AdbKeyIterator; generateKey(): ArrayBuffer | Promise; diff --git a/packages/adb/src/commands/base.ts b/packages/adb/src/commands/base.ts new file mode 100644 index 00000000..27c6e1d9 --- /dev/null +++ b/packages/adb/src/commands/base.ts @@ -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; + } +} diff --git a/packages/adb/src/commands/index.ts b/packages/adb/src/commands/index.ts new file mode 100644 index 00000000..3bd2b078 --- /dev/null +++ b/packages/adb/src/commands/index.ts @@ -0,0 +1,3 @@ +export * from './base'; +export * from './tcpip'; +export * from './reverse'; diff --git a/packages/adb/src/commands/reverse.ts b/packages/adb/src/commands/reverse.ts new file mode 100644 index 00000000..bbc05f85 --- /dev/null +++ b/packages/adb/src/commands/reverse.ts @@ -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(); + + private devicePortToPortMap = new Map(); + + private dispatcher: AdbPacketDispatcher; + + private listening = false; + + public constructor(dispatcher: AdbPacketDispatcher) { + super(); + this.dispatcher = dispatcher; + } + + public async list(): Promise { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/packages/adb/src/commands/tcpip.ts b/packages/adb/src/commands/tcpip.ts new file mode 100644 index 00000000..567bdd7d --- /dev/null +++ b/packages/adb/src/commands/tcpip.ts @@ -0,0 +1,45 @@ +import { AdbCommandBase } from './base'; + +export class AdbTcpIpCommand extends AdbCommandBase { + private async getProp(key: string): Promise { + const output = await this.adb.shell('getprop', key); + return output.trim(); + } + + public async getAddresses(): Promise { + 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 { + 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 { + const output = await this.adb.createStreamAndReadAll('usb:'); + if (output !== 'restarting in USB mode\n') { + throw new Error('Invalid response'); + } + } +} diff --git a/packages/adb/src/stream/buffered-stream.ts b/packages/adb/src/stream/buffered-stream.ts index ce64ca83..c9ee76e7 100644 --- a/packages/adb/src/stream/buffered-stream.ts +++ b/packages/adb/src/stream/buffered-stream.ts @@ -1,3 +1,4 @@ +import { StructDeserializationContext } from '@yume-chan/struct'; import { AdbStreamBase } from './controller'; import { AdbReadableStream } from './readable-stream'; import { AdbStream } from './stream'; @@ -60,7 +61,9 @@ export class BufferedStream { } } -export class AdbBufferedStream extends BufferedStream implements AdbStreamBase { +export class AdbBufferedStream + extends BufferedStream + implements AdbStreamBase, StructDeserializationContext { public get backend() { return this.stream.backend; } public get localId() { return this.stream.localId; } @@ -74,4 +77,12 @@ export class AdbBufferedStream extends BufferedStream impleme public write(data: ArrayBuffer): Promise { 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); + } } diff --git a/packages/adb/src/stream/dispatcher.ts b/packages/adb/src/stream/dispatcher.ts index afb283c9..fcb012c7 100644 --- a/packages/adb/src/stream/dispatcher.ts +++ b/packages/adb/src/stream/dispatcher.ts @@ -36,14 +36,13 @@ export class AdbPacketDispatcher extends AutoDisposable { private readonly errorEvent = this.addDisposable(new EventEmitter()); public get onError() { return this.errorEvent.event; } - private _running = true; + private _running = false; public get running() { return this._running; } public constructor(backend: AdbBackend) { super(); this.backend = backend; - this.receiveLoop(); } private async receiveLoop() { @@ -129,7 +128,8 @@ export class AdbPacketDispatcher extends AutoDisposable { const [localId] = this.initializers.add(); 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 args: AdbStreamCreatedEventArgs = { @@ -141,12 +141,17 @@ export class AdbPacketDispatcher extends AutoDisposable { if (args.handled) { this.streams.set(localId, controller); - await this.sendPacket(AdbCommand.OK, localId, packet.arg0); + await this.sendPacket(AdbCommand.OK, localId, remoteId); } 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 { const [localId, initializer] = this.initializers.add(); await this.sendPacket(AdbCommand.Open, localId, 0, service); diff --git a/packages/demo/src/connect.tsx b/packages/demo/src/connect.tsx index 58330008..e900b3bc 100644 --- a/packages/demo/src/connect.tsx +++ b/packages/demo/src/connect.tsx @@ -1,10 +1,12 @@ -import { DefaultButton, Dialog, PrimaryButton, ProgressIndicator, Stack, StackItem } from '@fluentui/react'; -import { Adb } from '@yume-chan/adb'; -import AdbWebBackend from '@yume-chan/adb-backend-web'; +import { DefaultButton, Dialog, Dropdown, IDropdownOption, PrimaryButton, ProgressIndicator, Stack, TooltipHost } from '@fluentui/react'; +import { Adb, AdbBackend } from '@yume-chan/adb'; +import AdbWebBackend, { AdbWebBackendWatcher } from '@yume-chan/adb-backend-web'; import React, { useCallback, useContext, useEffect, useState } from 'react'; import { ErrorDialogContext } from './error-dialog'; import { withDisplayName } from './utils'; +const DropdownStyles = { dropdown: { width: 300 } }; + interface ConnectProps { device: Adb | undefined; @@ -17,12 +19,66 @@ export default withDisplayName('Connect', ({ }: ConnectProps): JSX.Element | null => { const { show: showErrorDialog } = useContext(ErrorDialogContext); + const [backendOptions, setBackendOptions] = useState([]); + const [selectedBackend, setSelectedBackend] = useState(); + 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, + 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 connect = useCallback(async () => { try { - const backend = await AdbWebBackend.pickDevice(); - if (backend) { - const device = new Adb(backend); + if (selectedBackend) { + const device = new Adb(selectedBackend); try { setConnecting(true); await device.connect(); @@ -37,7 +93,7 @@ export default withDisplayName('Connect', ({ } finally { setConnecting(false); } - }, [onDeviceChange]); + }, [selectedBackend, onDeviceChange]); const disconnect = useCallback(async () => { try { await device!.dispose(); @@ -53,18 +109,42 @@ export default withDisplayName('Connect', ({ }, [device, onDeviceChange]); return ( - <> - - {!device && - - } - {device && + + + + {!device ? ( + <> + + + + + + ) : ( - } - - {device && `Connected to ${device.name}`} - - + )} - + ); }); diff --git a/packages/demo/src/index.tsx b/packages/demo/src/index.tsx index 7aa3b6c0..d7df03be 100644 --- a/packages/demo/src/index.tsx +++ b/packages/demo/src/index.tsx @@ -12,7 +12,8 @@ import FileManager from './routes/file-manager'; import FrameBuffer from './routes/framebuffer'; import Intro from './routes/intro'; import Shell from './routes/shell'; -import TcpIp from './routes/tcp-ip'; +import TcpIp from './routes/tcpip'; +import { CommonStackTokens } from './styles'; initializeIcons(); @@ -105,7 +106,7 @@ function App(): JSX.Element | null { - +