This commit is contained in:
libr
2026-03-03 10:12:22 +08:00
commit ae4c58e56d
223 changed files with 42635 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { BrowserUsbMuxClient } from "../core/browser-usbmux-client"
import {
decodeHeader,
encodeHeader,
USBMUX_HEADER_SIZE,
UsbMuxMessageType,
} from "../core/usbmux-protocol"
import { UsbMuxSession } from "../core/usbmux-session"
import { DataHandler, DisconnectHandler, UsbMuxTransport } from "../core/transport"
class MockTransport implements UsbMuxTransport {
isOpen = false
dataHandler: DataHandler | null = null
disconnectHandler: DisconnectHandler | null = null
sent: ArrayBuffer[] = []
async open(): Promise<void> {
this.isOpen = true
}
async close(): Promise<void> {
this.isOpen = false
}
async send(data: ArrayBuffer): Promise<void> {
this.sent.push(new Uint8Array(data).slice().buffer)
}
setDataHandler(handler: DataHandler | null): void {
this.dataHandler = handler
}
setDisconnectHandler(handler: DisconnectHandler | null): void {
this.disconnectHandler = handler
}
emit(data: Uint8Array): void {
this.dataHandler?.(data.slice().buffer)
}
}
const buildPacket = (tag: number, payloadText: string): Uint8Array => {
const payload = new Uint8Array(Buffer.from(payloadText, "utf8"))
const header = encodeHeader({
length: USBMUX_HEADER_SIZE + payload.byteLength,
version: 1,
message: UsbMuxMessageType.plist,
tag,
})
const packet = new Uint8Array(USBMUX_HEADER_SIZE + payload.byteLength)
packet.set(new Uint8Array(header), 0)
packet.set(payload, USBMUX_HEADER_SIZE)
return packet
}
test("UsbMuxSession handles fragmented packet", async () => {
const transport = new MockTransport()
const session = new UsbMuxSession(transport)
const packets: Array<{ tag: number; payload: string }> = []
session.onPacket((packet) => {
packets.push({
tag: packet.header.tag,
payload: Buffer.from(packet.payload).toString("utf8"),
})
})
await session.start()
const packet = buildPacket(7, "first")
transport.emit(packet.slice(0, 9))
expect(packets).toHaveLength(0)
transport.emit(packet.slice(9))
expect(packets).toEqual([{ tag: 7, payload: "first" }])
})
test("UsbMuxSession handles multiple packets in one read", async () => {
const transport = new MockTransport()
const session = new UsbMuxSession(transport)
const tags: number[] = []
session.onPacket((packet) => tags.push(packet.header.tag))
await session.start()
const first = buildPacket(1, "a")
const second = buildPacket(2, "b")
const merged = new Uint8Array(first.byteLength + second.byteLength)
merged.set(first, 0)
merged.set(second, first.byteLength)
transport.emit(merged)
expect(tags).toEqual([1, 2])
})
test("UsbMuxSession drops invalid header length and continues on next read", async () => {
const transport = new MockTransport()
const session = new UsbMuxSession(transport)
const tags: number[] = []
session.onPacket((packet) => tags.push(packet.header.tag))
await session.start()
const invalid = new Uint8Array(
encodeHeader({
length: 8,
version: 1,
message: UsbMuxMessageType.plist,
tag: 99,
}),
)
transport.emit(invalid)
expect(tags).toEqual([])
transport.emit(buildPacket(3, "ok"))
expect(tags).toEqual([3])
})
test("BrowserUsbMuxClient connect sends network-byte-order port", async () => {
const transport = new MockTransport()
const client = new BrowserUsbMuxClient(transport)
await client.start()
await client.connect(42, 62078)
expect(transport.sent).toHaveLength(1)
const sent = transport.sent[0]
const header = decodeHeader(sent.slice(0, USBMUX_HEADER_SIZE))
expect(header.message).toBe(UsbMuxMessageType.plist)
const payload = Buffer.from(sent.slice(USBMUX_HEADER_SIZE)).toString("utf8")
expect(payload).toContain("<key>DeviceID</key><integer>42</integer>")
expect(payload).toContain("<key>PortNumber</key><integer>32498</integer>")
expect(payload).toContain("<key>MessageType</key><string>Connect</string>")
})

View File

@@ -0,0 +1,16 @@
import MobileDevice from '../webmuxd';
import {
BrowserPairingStore,
BrowserUsbMuxClient,
UsbMuxMessageType,
} from "../webmuxd"
test('Module defined', () => {
expect(MobileDevice).toBeDefined();
});
test("Core exports defined", () => {
expect(BrowserPairingStore).toBeDefined()
expect(BrowserUsbMuxClient).toBeDefined()
expect(UsbMuxMessageType.plist).toBe(8)
})

31
src/core/afc-client.ts Normal file
View File

@@ -0,0 +1,31 @@
import { LockdownClient } from "./lockdown-client"
export interface AfcEntry {
path: string
size?: number
type?: string
}
export class AfcClient {
private readonly lockdown: LockdownClient
constructor(lockdown: LockdownClient) {
this.lockdown = lockdown
}
async connect(): Promise<void> {
await this.lockdown.startService("com.apple.afc")
}
listDirectory(_path: string): Promise<AfcEntry[]> {
return Promise.reject(new Error("AFC listDirectory is not implemented yet"))
}
readFile(_path: string): Promise<Uint8Array> {
return Promise.reject(new Error("AFC readFile is not implemented yet"))
}
writeFile(_path: string, _data: Uint8Array): Promise<void> {
return Promise.reject(new Error("AFC writeFile is not implemented yet"))
}
}

View File

@@ -0,0 +1,60 @@
import { AfcClient } from "./afc-client"
import { LockdownClient } from "./lockdown-client"
import { BrowserPairingStore, PairingStore } from "./pairing-store"
import { UsbMuxSession } from "./usbmux-session"
import { UsbMuxTransport } from "./transport"
export interface BrowserUsbMuxClientOptions {
pairingStore?: PairingStore
}
export class BrowserUsbMuxClient {
readonly session: UsbMuxSession
readonly lockdown: LockdownClient
readonly afc: AfcClient
readonly pairingStore: PairingStore
constructor(
transport: UsbMuxTransport,
options: BrowserUsbMuxClientOptions = {},
) {
this.pairingStore = options.pairingStore ?? new BrowserPairingStore()
this.session = new UsbMuxSession(transport)
this.lockdown = new LockdownClient(this.session, this.pairingStore)
this.afc = new AfcClient(this.lockdown)
}
async start(): Promise<void> {
await this.session.start()
}
async stop(): Promise<void> {
await this.session.stop()
}
async listDevices(): Promise<number> {
return await this.session.sendPlistRequest({ messageType: "ListDevices" })
}
async connect(deviceId: number, port: number): Promise<number> {
const deviceIdKey = "DeviceID"
const portNumberKey = "PortNumber"
return await this.session.sendPlistRequest({
messageType: "Connect",
payload: {
[deviceIdKey]: deviceId,
[portNumberKey]: toNetworkByteOrderPort(port),
},
})
}
}
const toNetworkByteOrderPort = (port: number): number => {
if (!Number.isInteger(port) || port < 0 || port > 0xffff) {
throw new RangeError(`Invalid port number: ${String(port)}`)
}
const buffer = new ArrayBuffer(2)
const view = new DataView(buffer)
view.setUint16(0, port, true)
return view.getUint16(0, false)
}

File diff suppressed because it is too large Load Diff

10
src/core/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./afc-client"
export * from "./browser-usbmux-client"
export * from "./imobiledevice-client"
export * from "./lockdown-client"
export * from "./pairing-store"
export * from "./plist"
export * from "./transport"
export * from "./usbmux-protocol"
export * from "./usbmux-session"
export * from "./webusb-transport"

View File

@@ -0,0 +1,28 @@
import { UsbMuxSession } from "./usbmux-session"
import { PairingStore } from "./pairing-store"
export interface LockdownStartSessionResult {
sessionId: string
}
export class LockdownClient {
private readonly session: UsbMuxSession
private readonly pairingStore: PairingStore
constructor(session: UsbMuxSession, pairingStore: PairingStore) {
this.session = session
this.pairingStore = pairingStore
}
readBuid(): Promise<string> {
return this.pairingStore.getSystemBuid()
}
startSession(_udid: string): Promise<LockdownStartSessionResult> {
return Promise.reject(new Error("Lockdown startSession is not implemented yet"))
}
startService(_serviceName: string): Promise<number> {
return Promise.reject(new Error("Lockdown startService is not implemented yet"))
}
}

89
src/core/pairing-store.ts Normal file
View File

@@ -0,0 +1,89 @@
export interface PairRecordEntry {
udid: string
data: string
}
export interface PairingStore {
getSystemBuid(): Promise<string>
getPairRecord(udid: string): Promise<PairRecordEntry | null>
savePairRecord(record: PairRecordEntry): Promise<void>
deletePairRecord(udid: string): Promise<void>
}
const BUID_KEY = "webmuxd:buid"
const PAIR_PREFIX = "webmuxd:pair:"
export class BrowserPairingStore implements PairingStore {
private readonly inMemory = new Map<string, string>()
getSystemBuid(): Promise<string> {
const existing = this.get(BUID_KEY)
if (existing) {
return Promise.resolve(existing)
}
const created = this.generateBuid()
this.set(BUID_KEY, created)
return Promise.resolve(created)
}
getPairRecord(udid: string): Promise<PairRecordEntry | null> {
const value = this.get(this.toPairKey(udid))
if (!value) {
return Promise.resolve(null)
}
return Promise.resolve({ udid, data: value })
}
savePairRecord(record: PairRecordEntry): Promise<void> {
this.set(this.toPairKey(record.udid), record.data)
return Promise.resolve()
}
deletePairRecord(udid: string): Promise<void> {
this.remove(this.toPairKey(udid))
return Promise.resolve()
}
private toPairKey(udid: string): string {
return `${PAIR_PREFIX}${udid}`
}
private get(key: string): string | null {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage.getItem(key)
}
return this.inMemory.get(key) ?? null
}
private set(key: string, value: string): void {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.setItem(key, value)
return
}
this.inMemory.set(key, value)
}
private remove(key: string): void {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem(key)
return
}
this.inMemory.delete(key)
}
private generateBuid(): string {
const bytes = new Uint8Array(16)
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(bytes)
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
}
let out = ""
for (const byte of bytes) {
out += byte.toString(16).padStart(2, "0")
}
return out.toUpperCase()
}
}

89
src/core/plist.ts Normal file
View File

@@ -0,0 +1,89 @@
export type PlistValue =
| string
| number
| boolean
| Uint8Array
| ArrayBuffer
| PlistValue[]
| { [key: string]: PlistValue }
const XML_HEADER =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' +
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
export const encodePlistXml = (value: { [key: string]: PlistValue }): Uint8Array => {
const xml = `${XML_HEADER}<plist version="1.0">${encodeValue(value)}</plist>`
return encodeUtf8(xml)
}
const encodeValue = (value: PlistValue): string => {
if (typeof value === "string") {
return `<string>${escapeXml(value)}</string>`
}
if (typeof value === "number") {
if (Number.isInteger(value)) {
return `<integer>${String(value)}</integer>`
}
return `<real>${String(value)}</real>`
}
if (typeof value === "boolean") {
return value ? "<true/>" : "<false/>"
}
if (value instanceof Uint8Array) {
return `<data>${encodeBase64(value)}</data>`
}
if (value instanceof ArrayBuffer) {
return `<data>${encodeBase64(new Uint8Array(value))}</data>`
}
if (Array.isArray(value)) {
let out = "<array>"
for (const item of value) {
out += encodeValue(item)
}
out += "</array>"
return out
}
if (value && typeof value === "object") {
let out = "<dict>"
for (const [key, objectValue] of Object.entries(value)) {
out += `<key>${escapeXml(key)}</key>${encodeValue(objectValue)}`
}
out += "</dict>"
return out
}
throw new Error(`Unsupported plist value type: ${String(value)}`)
}
const escapeXml = (input: string): string => {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}
const encodeBase64 = (value: Uint8Array): string => {
if (typeof btoa === "function") {
let binary = ""
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
if (typeof Buffer !== "undefined") {
return Buffer.from(value).toString("base64")
}
throw new Error("No base64 implementation is available in this environment")
}
const encodeUtf8 = (value: string): Uint8Array => {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(value)
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(value, "utf8"))
}
throw new Error("No UTF-8 encoder is available in this environment")
}

11
src/core/transport.ts Normal file
View File

@@ -0,0 +1,11 @@
export type DataHandler = (data: ArrayBuffer) => void
export type DisconnectHandler = (reason?: unknown) => void
export interface UsbMuxTransport {
open(): Promise<void>
close(): Promise<void>
send(data: ArrayBuffer): Promise<void>
setDataHandler(handler: DataHandler | null): void
setDisconnectHandler(handler: DisconnectHandler | null): void
readonly isOpen: boolean
}

View File

@@ -0,0 +1,44 @@
/* eslint-disable no-shadow */
export enum UsbMuxMessageType {
result = 1,
connect = 2,
listen = 3,
deviceAdd = 4,
deviceRemove = 5,
devicePaired = 6,
plist = 8,
}
export interface UsbMuxHeader {
length: number
version: number
message: UsbMuxMessageType
tag: number
}
export interface UsbMuxPacket {
header: UsbMuxHeader
payload: ArrayBuffer
}
export const USBMUX_HEADER_SIZE = 16
export const encodeHeader = (header: UsbMuxHeader): ArrayBuffer => {
const buffer = new ArrayBuffer(USBMUX_HEADER_SIZE)
const view = new DataView(buffer)
view.setUint32(0, header.length, true)
view.setUint32(4, header.version, true)
view.setUint32(8, header.message, true)
view.setUint32(12, header.tag, true)
return buffer
}
export const decodeHeader = (data: ArrayBuffer): UsbMuxHeader => {
const view = new DataView(data)
return {
length: view.getUint32(0, true),
version: view.getUint32(4, true),
message: view.getUint32(8, true) as UsbMuxMessageType,
tag: view.getUint32(12, true),
}
}

135
src/core/usbmux-session.ts Normal file
View File

@@ -0,0 +1,135 @@
import {
decodeHeader,
encodeHeader,
USBMUX_HEADER_SIZE,
UsbMuxHeader,
UsbMuxMessageType,
UsbMuxPacket,
} from "./usbmux-protocol"
import { encodePlistXml, PlistValue } from "./plist"
import { UsbMuxTransport } from "./transport"
export interface UsbMuxPlistRequest {
messageType: string
payload?: Record<string, PlistValue>
}
const plistMessageTypeKey = "MessageType"
const USBMUX_MAX_PACKET_SIZE = 0x10000
export class UsbMuxSession {
private readonly transport: UsbMuxTransport
private tagCounter = 1
private onPacketHandler: ((packet: UsbMuxPacket) => void) | null = null
private started = false
private readBuffer = new Uint8Array(0)
constructor(transport: UsbMuxTransport) {
this.transport = transport
}
async start(): Promise<void> {
if (this.started) {
return
}
this.transport.setDataHandler((data) => this.onRawData(data))
await this.transport.open()
this.started = true
}
async stop(): Promise<void> {
if (!this.started) {
return
}
await this.transport.close()
this.started = false
this.readBuffer = new Uint8Array(0)
}
onPacket(handler: ((packet: UsbMuxPacket) => void) | null): void {
this.onPacketHandler = handler
}
async sendPlistRequest(request: UsbMuxPlistRequest): Promise<number> {
const body = encodePlistXml({
...(request.payload ?? {}),
[plistMessageTypeKey]: request.messageType,
})
const tag = this.nextTag()
const header: UsbMuxHeader = {
length: USBMUX_HEADER_SIZE + body.byteLength,
version: 1,
message: UsbMuxMessageType.plist,
tag,
}
const headerBytes = new Uint8Array(encodeHeader(header))
const packet = new Uint8Array(header.length)
packet.set(headerBytes, 0)
packet.set(body, USBMUX_HEADER_SIZE)
await this.transport.send(packet.buffer)
return tag
}
private onRawData(data: ArrayBuffer): void {
const incoming = new Uint8Array(data)
if (incoming.byteLength === 0) {
return
}
this.readBuffer = appendBytes(this.readBuffer, incoming)
this.drainReadBuffer()
}
private nextTag(): number {
const current = this.tagCounter
this.tagCounter += 1
return current
}
private drainReadBuffer(): void {
let offset = 0
while (this.readBuffer.byteLength - offset >= USBMUX_HEADER_SIZE) {
const headerChunk = this.readBuffer.subarray(
offset,
offset + USBMUX_HEADER_SIZE,
)
const headerBytes = headerChunk.buffer.slice(
headerChunk.byteOffset,
headerChunk.byteOffset + USBMUX_HEADER_SIZE,
)
const header = decodeHeader(headerBytes)
if (
header.length < USBMUX_HEADER_SIZE ||
header.length > USBMUX_MAX_PACKET_SIZE
) {
this.readBuffer = new Uint8Array(0)
return
}
if (this.readBuffer.byteLength - offset < header.length) {
break
}
const payloadStart = offset + USBMUX_HEADER_SIZE
const payloadEnd = offset + header.length
const payload = this.readBuffer.slice(payloadStart, payloadEnd)
this.onPacketHandler?.({ header, payload: payload.buffer })
offset = payloadEnd
}
if (offset === 0) {
return
}
if (offset >= this.readBuffer.byteLength) {
this.readBuffer = new Uint8Array(0)
return
}
this.readBuffer = this.readBuffer.slice(offset)
}
}
const appendBytes = (left: Uint8Array, right: Uint8Array): Uint8Array => {
if (left.byteLength === 0) {
return right.slice()
}
const merged = new Uint8Array(left.byteLength + right.byteLength)
merged.set(left, 0)
merged.set(right, left.byteLength)
return merged
}

View File

@@ -0,0 +1,175 @@
import { CONSOLE_LOGGER, Logger, NULL_LOGGER } from "../webmuxd"
import { DataHandler, DisconnectHandler, UsbMuxTransport } from "./transport"
const USBMUX_CLASS = 255
const USBMUX_SUBCLASS = 254
const USBMUX_PROTOCOL = 2
export interface WebUsbTransportOptions {
logger?: Logger
transferSize?: number
}
export class WebUsbTransport implements UsbMuxTransport {
private readonly usbDevice: USBDevice
private readonly transferSize: number
private readonly logger: Logger
private usbInterface: USBInterface | null = null
private usbConfigurationValue: number | null = null
private inputEndpoint: USBEndpoint | null = null
private outputEndpoint: USBEndpoint | null = null
private reading = false
private closing = false
private dataHandler: DataHandler | null = null
private disconnectHandler: DisconnectHandler | null = null
constructor(device: USBDevice, options?: WebUsbTransportOptions) {
this.usbDevice = device
this.transferSize = options?.transferSize ?? 16384
this.logger = options?.logger ?? NULL_LOGGER
}
get isOpen(): boolean {
return this.usbDevice.opened
}
static supported(): boolean {
return "usb" in window.navigator
}
static async requestAppleDevice(
logger: Logger = CONSOLE_LOGGER,
): Promise<WebUsbTransport> {
const device = await navigator.usb.requestDevice({
filters: [{ vendorId: 0x05ac }],
})
logger.log("info", `Selected device ${device.productName ?? "unknown"}`)
return new WebUsbTransport(device, { logger })
}
setDataHandler(handler: DataHandler | null): void {
this.dataHandler = handler
}
setDisconnectHandler(handler: DisconnectHandler | null): void {
this.disconnectHandler = handler
}
async open(): Promise<void> {
this.resolveInterface()
if (!this.usbInterface) {
throw new Error("No usbmux interface found")
}
if (!this.usbDevice.opened) {
await this.usbDevice.open()
}
const selectedConfig = this.usbDevice.configuration?.configurationValue ?? null
if (
this.usbConfigurationValue !== null &&
this.usbConfigurationValue !== selectedConfig
) {
await this.usbDevice.selectConfiguration(this.usbConfigurationValue)
}
if (!this.usbInterface.claimed) {
await this.usbDevice.claimInterface(this.usbInterface.interfaceNumber)
}
this.resolveEndpoints()
if (!this.inputEndpoint || !this.outputEndpoint) {
throw new Error("Failed to resolve usbmux endpoints")
}
this.reading = true
this.closing = false
this.readLoop()
}
async close(): Promise<void> {
if (this.closing) {
return
}
this.closing = true
this.reading = false
if (this.usbInterface?.claimed && this.usbDevice.opened) {
await this.usbDevice.releaseInterface(this.usbInterface.interfaceNumber)
}
if (this.usbDevice.opened) {
await this.usbDevice.close()
}
}
async send(data: ArrayBuffer): Promise<void> {
if (!this.outputEndpoint) {
throw new Error("Output endpoint is not ready")
}
const result = await this.usbDevice.transferOut(
this.outputEndpoint.endpointNumber,
data,
)
if (result.status !== "ok") {
throw new Error(`USB transferOut failed with status ${result.status}`)
}
}
private resolveInterface(): void {
for (const configuration of this.usbDevice.configurations) {
for (const usbInterface of configuration.interfaces) {
for (const alternate of usbInterface.alternates) {
if (
alternate.interfaceClass === USBMUX_CLASS &&
alternate.interfaceSubclass === USBMUX_SUBCLASS &&
alternate.interfaceProtocol === USBMUX_PROTOCOL
) {
this.usbInterface = usbInterface
this.usbConfigurationValue = configuration.configurationValue
return
}
}
}
}
}
private resolveEndpoints(): void {
if (!this.usbInterface) {
return
}
for (const endpoint of this.usbInterface.alternates[0].endpoints) {
if (endpoint.direction === "in") {
this.inputEndpoint = endpoint
} else if (endpoint.direction === "out") {
this.outputEndpoint = endpoint
}
}
}
private readLoop(): void {
if (!this.reading || this.closing || !this.inputEndpoint) {
return
}
this.usbDevice
.transferIn(this.inputEndpoint.endpointNumber, this.transferSize)
.then((result) => {
if (this.closing) {
return
}
if (result.status === "ok" && result.data) {
const bytes = new Uint8Array(
result.data.buffer,
result.data.byteOffset,
result.data.byteLength,
)
this.dataHandler?.(bytes.slice().buffer)
}
this.readLoop()
})
.catch((error) => {
this.logger.log("error", `USB read loop stopped: ${String(error)}`)
this.disconnectHandler?.(error)
})
}
}

225
src/webmuxd.ts Normal file
View File

@@ -0,0 +1,225 @@
const USBMUX_USB_FILTER = [{ vendorId: 0x5ac, productId: 0x12a8 }];
const USBMUX_CLASS = 255;
const USBMUX_SUBCLASS = 254;
const USBMUX_PROTOCOL = 2;
type LogLevel = "debug" | "info" | "warn" | "error"
export interface Logger {
log(level: LogLevel, message: string): void
}
export const NULL_LOGGER = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
log: (level: LogLevel, message: string): void => {
return
}
}
export const CONSOLE_LOGGER = {
log: (level: LogLevel, message: string): void => {
switch (level) {
case "info":
// eslint-disable-next-line no-console
console.log(message)
break
case "warn":
// eslint-disable-next-line no-console
console.warn(message)
break
case "error":
// eslint-disable-next-line no-console
console.error(message)
break
case "debug":
// eslint-disable-next-line no-console
console.debug(message)
break
default:
// eslint-disable-next-line no-console
console.error(`Unknown log level ${level}: ${message}`)
}
}
}
export default class MobileDevice {
static logger: Logger = NULL_LOGGER
usbDevice: USBDevice;
usbConfiguration: USBConfiguration | null = null
usbInterface: USBInterface | null = null
private closing = false
private readInterval: number | null = null
usbInputEndpoint: USBEndpoint | null = null
usbOutputEndpoint: USBEndpoint | null = null
inputTransfer: Promise<USBInTransferResult> | null = null
public handleData: ((data: ArrayBuffer) => void) | null = null
constructor(device: USBDevice) {
this.usbDevice = device;
}
static supported(): boolean {
return 'usb' in window.navigator;
}
static async selectDevice(): Promise<MobileDevice> {
const device = await navigator.usb.requestDevice({ filters: USBMUX_USB_FILTER });
return new MobileDevice(device);
}
static async getDevices(): Promise<MobileDevice[]> {
const devices = await navigator.usb.getDevices();
return devices.map((device) => {
return new MobileDevice(device);
});
}
get name(): string {
return this.usbDevice.productName as string;
}
get serialNumber(): string {
return this.usbDevice.serialNumber as string;
}
async close(): Promise<void> {
if (!this.closing && this.readInterval) {
window.clearInterval(this.readInterval);
}
this.closing = true;
if (this.usbDevice && this.usbDevice.opened) {
if (this.usbInterface && this.usbInterface.claimed) {
MobileDevice.logger.log("info", `Closing interface ${this.usbInterface.interfaceNumber} for ${this.usbDevice.serialNumber}`);
await this.usbDevice.releaseInterface(this.usbInterface.interfaceNumber);
}
await this.usbDevice.selectConfiguration(1);
try {
MobileDevice.logger.log("info", `Resetting device ${this.usbDevice.serialNumber}`);
await this.usbDevice.reset();
} finally {
MobileDevice.logger.log("info", `Closing device ${this.usbDevice.serialNumber}`);
await this.usbDevice.close();
MobileDevice.logger.log("info", `Closed ${this.serialNumber}`);
}
}
}
async open(): Promise<void> {
try {
for (const configuration of this.usbDevice.configurations) {
for (const usbInterface of configuration.interfaces) {
MobileDevice.logger.log("debug", `Interface ${usbInterface.interfaceNumber} (Claimed: ${usbInterface.claimed})`)
for (const alternate of usbInterface.alternates) {
MobileDevice.logger.log("debug", `\tAlternate ${alternate.alternateSetting} ${alternate.interfaceName} Class ${alternate.interfaceClass} Subclass ${alternate.interfaceSubclass} Protocol ${alternate.interfaceProtocol}`)
if (
alternate.interfaceClass === USBMUX_CLASS &&
alternate.interfaceSubclass === USBMUX_SUBCLASS &&
alternate.interfaceProtocol === USBMUX_PROTOCOL
) {
this.usbInterface = usbInterface;
this.usbConfiguration = configuration;
}
}
}
}
if (this.usbConfiguration && this.usbInterface) {
MobileDevice.logger.log("info", `Opening device ${this.usbDevice.serialNumber}`);
await this.usbDevice.open();
if (this.usbDevice.configuration?.configurationValue !== this.usbConfiguration.configurationValue) {
MobileDevice.logger.log("info",
`Selecting Configuration ${this.usbConfiguration.configurationValue} from ${this.usbDevice.configuration?.configurationValue}`,
);
await this.usbDevice.selectConfiguration(this.usbConfiguration.configurationValue);
}
MobileDevice.logger.log("info", `Claiming Interface ${this.usbInterface.interfaceNumber}`);
await this.usbDevice.claimInterface(this.usbInterface.interfaceNumber);
for (const endpoint of this.usbInterface.alternates[0].endpoints) {
MobileDevice.logger.log("info", `Endpoint ${endpoint.endpointNumber} ${endpoint.direction}`);
if (endpoint.direction === 'in') {
this.usbInputEndpoint = endpoint;
}
if (endpoint.direction === 'out') {
this.usbOutputEndpoint = endpoint;
}
}
} else {
MobileDevice.logger.log("error", `No configuration ${this.usbConfiguration} or interface ${this.usbInterface}`);
}
this.readInterval = window.setInterval(() => {
this.deviceReader();
}, 1000);
} catch (e) {
if (typeof e === 'string') {
MobileDevice.logger.log("error", e);
}
else if (e instanceof Error) {
MobileDevice.logger.log("error", e.message)
}
}
}
private deviceReader() {
if (!this || !this.usbDevice || !this.usbDevice.opened || !this.usbInterface) {
MobileDevice.logger.log("info", 'deviceReader not in ready state');
return;
}
if (this.inputTransfer && !this.closing) {
return;
}
MobileDevice.logger.log("info", 'MobileDevice deviceReader loop');
if (this.usbInputEndpoint === null) {
throw new Error('No input endpoint');
}
const inputEndpoint = this.usbInputEndpoint.endpointNumber;
this.inputTransfer = this.usbDevice.transferIn(inputEndpoint, 4096);
this.inputTransfer
.then((result) => {
MobileDevice.logger.log("info", `Received USB data ${result.data?.byteLength} status ${result.status}`);
if (this.handleData && result.data) {
this.handleData(result.data.buffer);
}
this.inputTransfer = null;
this.deviceReader();
})
.catch((reason) => {
MobileDevice.logger.log("error", `InputTransfer exception: ${reason}`);
});
}
async sendData(data: ArrayBuffer): Promise<USBOutTransferResult | null> {
const outputEndpoint = this.usbOutputEndpoint?.endpointNumber;
if (outputEndpoint !== undefined) {
MobileDevice.logger.log("info", `Outputting Data to Device on ${outputEndpoint}`);
return await this.usbDevice.transferOut(outputEndpoint, data);
} else {
MobileDevice.logger.log("info", `Undefined output interface ${outputEndpoint}`);
}
return null;
}
}
export * from "./core"