mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
refactor directory
This commit is contained in:
31
dependencies/webmuxd/src/core/afc-client.ts
vendored
Normal file
31
dependencies/webmuxd/src/core/afc-client.ts
vendored
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
dependencies/webmuxd/src/core/browser-usbmux-client.ts
vendored
Normal file
60
dependencies/webmuxd/src/core/browser-usbmux-client.ts
vendored
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)
|
||||
}
|
||||
2274
dependencies/webmuxd/src/core/imobiledevice-client.ts
vendored
Normal file
2274
dependencies/webmuxd/src/core/imobiledevice-client.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
11
dependencies/webmuxd/src/core/index.ts
vendored
Normal file
11
dependencies/webmuxd/src/core/index.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from "./afc-client"
|
||||
export * from "./browser-usbmux-client"
|
||||
export * from "./imobiledevice-client"
|
||||
export * from "./lockdown-client"
|
||||
export * from "./openssl-wasm"
|
||||
export * from "./pairing-store"
|
||||
export * from "./plist"
|
||||
export * from "./transport"
|
||||
export * from "./usbmux-protocol"
|
||||
export * from "./usbmux-session"
|
||||
export * from "./webusb-transport"
|
||||
28
dependencies/webmuxd/src/core/lockdown-client.ts
vendored
Normal file
28
dependencies/webmuxd/src/core/lockdown-client.ts
vendored
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"))
|
||||
}
|
||||
}
|
||||
132
dependencies/webmuxd/src/core/openssl-wasm.ts
vendored
Normal file
132
dependencies/webmuxd/src/core/openssl-wasm.ts
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { TlsConnection, TlsConnectionFactory } from "./imobiledevice-client"
|
||||
|
||||
export interface OpenSslWasmConnectionRequest {
|
||||
serverName: string
|
||||
caCertificatePem: string
|
||||
certificatePem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
export interface OpenSslWasmPairRecordRequest {
|
||||
devicePublicKey: Uint8Array
|
||||
hostId: string
|
||||
systemBuid: string
|
||||
}
|
||||
|
||||
interface OpenSslWasmModule {
|
||||
default(input?: unknown): Promise<unknown>
|
||||
opensslClientConstructor: new (
|
||||
serverName: string,
|
||||
caCertificatePem: string,
|
||||
certificatePem: string,
|
||||
privateKeyPem: string,
|
||||
) => TlsConnection
|
||||
generatePairRecord(
|
||||
devicePublicKey: Uint8Array,
|
||||
hostId: string,
|
||||
systemBuid: string,
|
||||
): string
|
||||
}
|
||||
|
||||
const OPENSSL_WASM_MODULE_SPECIFIER = "../openssl-wasm/openssl_wasm.js"
|
||||
|
||||
/**
|
||||
* Keep native `import()` intact in the CommonJS build so bundlers can defer the
|
||||
* large wasm glue file until TLS or pairing is actually requested.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const dynamicImport = new Function("specifier", "return import(specifier)") as (
|
||||
specifier: string,
|
||||
) => Promise<unknown>
|
||||
|
||||
let opensslWasmModule: OpenSslWasmModule | null = null
|
||||
let opensslWasmModulePromise: Promise<OpenSslWasmModule> | null = null
|
||||
let opensslWasmInitPromise: Promise<void> | null = null
|
||||
|
||||
const toOpenSslWasmModule = (moduleValue: unknown): OpenSslWasmModule => {
|
||||
if (!moduleValue || typeof moduleValue !== "object") {
|
||||
throw new Error("OpenSSL wasm module did not return an object")
|
||||
}
|
||||
|
||||
const candidate = moduleValue as Record<string, unknown>
|
||||
if (typeof candidate.default !== "function") {
|
||||
throw new Error("OpenSSL wasm module is missing its default initializer")
|
||||
}
|
||||
if (typeof candidate.OpensslClient !== "function") {
|
||||
throw new Error("OpenSSL wasm module is missing OpensslClient")
|
||||
}
|
||||
if (typeof candidate.libimobiledevice_generate_pair_record !== "function") {
|
||||
throw new Error("OpenSSL wasm module is missing pair record generation")
|
||||
}
|
||||
|
||||
return {
|
||||
default: candidate.default as OpenSslWasmModule["default"],
|
||||
opensslClientConstructor:
|
||||
candidate.OpensslClient as OpenSslWasmModule["opensslClientConstructor"],
|
||||
generatePairRecord:
|
||||
candidate.libimobiledevice_generate_pair_record as OpenSslWasmModule["generatePairRecord"],
|
||||
}
|
||||
}
|
||||
|
||||
const loadOpenSslWasmModule = async (): Promise<OpenSslWasmModule> => {
|
||||
if (!opensslWasmModulePromise) {
|
||||
opensslWasmModulePromise = dynamicImport(OPENSSL_WASM_MODULE_SPECIFIER).then(
|
||||
(moduleValue) => {
|
||||
const loadedModule = toOpenSslWasmModule(moduleValue)
|
||||
opensslWasmModule = loadedModule
|
||||
return loadedModule
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return await opensslWasmModulePromise
|
||||
}
|
||||
|
||||
const requireOpenSslWasmModule = (): OpenSslWasmModule => {
|
||||
if (!opensslWasmModule) {
|
||||
throw new Error("OpenSSL wasm is not ready. Call ensureOpenSslWasmReady() first.")
|
||||
}
|
||||
|
||||
return opensslWasmModule
|
||||
}
|
||||
|
||||
export const ensureOpenSslWasmReady = async (): Promise<void> => {
|
||||
if (!opensslWasmInitPromise) {
|
||||
opensslWasmInitPromise = loadOpenSslWasmModule().then(async (moduleValue) => {
|
||||
await moduleValue.default()
|
||||
})
|
||||
}
|
||||
|
||||
await opensslWasmInitPromise
|
||||
}
|
||||
|
||||
export const createOpenSslWasmConnection = (
|
||||
request: OpenSslWasmConnectionRequest,
|
||||
): TlsConnection => {
|
||||
const moduleValue = requireOpenSslWasmModule()
|
||||
return new moduleValue.opensslClientConstructor(
|
||||
request.serverName,
|
||||
request.caCertificatePem,
|
||||
request.certificatePem,
|
||||
request.privateKeyPem,
|
||||
)
|
||||
}
|
||||
|
||||
export const createOpenSslWasmTlsFactory = (): TlsConnectionFactory => {
|
||||
return {
|
||||
ensureReady: ensureOpenSslWasmReady,
|
||||
createConnection: createOpenSslWasmConnection,
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePairRecordWithOpenSslWasm = async (
|
||||
request: OpenSslWasmPairRecordRequest,
|
||||
): Promise<string> => {
|
||||
await ensureOpenSslWasmReady()
|
||||
const moduleValue = requireOpenSslWasmModule()
|
||||
return moduleValue.generatePairRecord(
|
||||
new Uint8Array(request.devicePublicKey),
|
||||
request.hostId,
|
||||
request.systemBuid,
|
||||
)
|
||||
}
|
||||
89
dependencies/webmuxd/src/core/pairing-store.ts
vendored
Normal file
89
dependencies/webmuxd/src/core/pairing-store.ts
vendored
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
dependencies/webmuxd/src/core/plist.ts
vendored
Normal file
89
dependencies/webmuxd/src/core/plist.ts
vendored
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
dependencies/webmuxd/src/core/transport.ts
vendored
Normal file
11
dependencies/webmuxd/src/core/transport.ts
vendored
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
dependencies/webmuxd/src/core/usbmux-protocol.ts
vendored
Normal file
44
dependencies/webmuxd/src/core/usbmux-protocol.ts
vendored
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
dependencies/webmuxd/src/core/usbmux-session.ts
vendored
Normal file
135
dependencies/webmuxd/src/core/usbmux-session.ts
vendored
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: Uint8Array = 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,
|
||||
) as ArrayBuffer
|
||||
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
dependencies/webmuxd/src/core/webusb-transport.ts
vendored
Normal file
175
dependencies/webmuxd/src/core/webusb-transport.ts
vendored
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user