refactor directory

This commit is contained in:
LiBr
2026-04-10 08:53:51 +08:00
parent 41d3f700b9
commit 8d31144741
215 changed files with 938 additions and 1836 deletions

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

11
dependencies/webmuxd/src/core/index.ts vendored Normal file
View 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"

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"))
}
}

View 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,
)
}

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
dependencies/webmuxd/src/core/plist.ts vendored 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")
}

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),
}
}

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: 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
}

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)
})
}
}