mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
init
This commit is contained in:
131
src/__tests__/core-usbmux-session.test.ts
Normal file
131
src/__tests__/core-usbmux-session.test.ts
Normal 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>")
|
||||
})
|
||||
16
src/__tests__/webmuxd.test.ts
Normal file
16
src/__tests__/webmuxd.test.ts
Normal 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
31
src/core/afc-client.ts
Normal 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"))
|
||||
}
|
||||
}
|
||||
60
src/core/browser-usbmux-client.ts
Normal file
60
src/core/browser-usbmux-client.ts
Normal 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)
|
||||
}
|
||||
2271
src/core/imobiledevice-client.ts
Normal file
2271
src/core/imobiledevice-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
10
src/core/index.ts
Normal file
10
src/core/index.ts
Normal 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"
|
||||
28
src/core/lockdown-client.ts
Normal file
28
src/core/lockdown-client.ts
Normal 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
89
src/core/pairing-store.ts
Normal 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
89
src/core/plist.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
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
11
src/core/transport.ts
Normal 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
|
||||
}
|
||||
44
src/core/usbmux-protocol.ts
Normal file
44
src/core/usbmux-protocol.ts
Normal 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
135
src/core/usbmux-session.ts
Normal 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
|
||||
}
|
||||
175
src/core/webusb-transport.ts
Normal file
175
src/core/webusb-transport.ts
Normal 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
225
src/webmuxd.ts
Normal 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"
|
||||
Reference in New Issue
Block a user