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

196
dependencies/webmuxd/.eslintrc vendored Normal file
View File

@@ -0,0 +1,196 @@
{
"env": {
"browser": true
},
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier",
"prettier/@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"ignorePatterns": ["src/__tests__/**/*"],
"plugins": [
"eslint-plugin-jsdoc",
"eslint-plugin-prefer-arrow",
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/adjacent-overload-signatures": "error",
"@typescript-eslint/array-type": [
"error",
{
"default": "array"
}
],
"@typescript-eslint/ban-types": [
"error",
{
"types": {
"Object": {
"message": "Avoid using the `Object` type. Did you mean `object`?"
},
"Function": {
"message": "Avoid using the `Function` type. Prefer a specific function type, like `() => void`."
},
"Boolean": {
"message": "Avoid using the `Boolean` type. Did you mean `boolean`?"
},
"Number": {
"message": "Avoid using the `Number` type. Did you mean `number`?"
},
"String": {
"message": "Avoid using the `String` type. Did you mean `string`?"
},
"Symbol": {
"message": "Avoid using the `Symbol` type. Did you mean `symbol`?"
}
}
}
],
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/indent": "off",
"@typescript-eslint/member-delimiter-style": [
"off",
{
"multiline": {
"delimiter": "none",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/naming-convention": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-unused-expressions": "error",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": "off",
"@typescript-eslint/semi": [
"off",
null
],
"@typescript-eslint/triple-slash-reference": [
"error",
{
"path": "always",
"types": "prefer-import",
"lib": "always"
}
],
"@typescript-eslint/type-annotation-spacing": "off",
"@typescript-eslint/unified-signatures": "error",
"arrow-parens": [
"off",
"always"
],
"brace-style": [
"off",
"off"
],
"comma-dangle": "off",
"complexity": "off",
"constructor-super": "error",
"eol-last": "off",
"eqeqeq": [
"error",
"smart"
],
"guard-for-in": "error",
"id-blacklist": [
"error",
"any",
"Number",
"number",
"String",
"string",
"Boolean",
"boolean",
"Undefined",
"undefined"
],
"id-match": "error",
"jsdoc/check-alignment": "error",
"jsdoc/check-indentation": "error",
"jsdoc/newline-after-description": "error",
"linebreak-style": "off",
"max-classes-per-file": [
"error",
1
],
"max-len": "off",
"new-parens": "off",
"newline-per-chained-call": "off",
"no-bitwise": "error",
"no-caller": "error",
"no-cond-assign": "error",
"no-console": "error",
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-extra-semi": "off",
"no-fallthrough": "off",
"no-invalid-this": "off",
"no-irregular-whitespace": "off",
"no-multiple-empty-lines": "off",
"no-new-wrappers": "error",
"no-shadow": [
"error",
{
"hoist": "all"
}
],
"no-throw-literal": "error",
"no-trailing-spaces": "off",
"no-undef-init": "error",
"no-underscore-dangle": "error",
"no-unsafe-finally": "error",
"no-unused-labels": "error",
"no-var": "error",
"object-shorthand": "error",
"one-var": [
"error",
"never"
],
"prefer-arrow/prefer-arrow-functions": "error",
"prefer-const": "error",
"quote-props": "off",
"radix": "error",
"react/jsx-curly-spacing": "off",
"react/jsx-equals-spacing": "off",
"react/jsx-wrap-multilines": "off",
"space-before-function-paren": "off",
"space-in-parens": [
"off",
"never"
],
"spaced-comment": [
"error",
"always",
{
"markers": [
"/"
]
}
],
"use-isnan": "error",
"valid-typeof": "off"
}
}

21
dependencies/webmuxd/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Rick Mark
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

30
dependencies/webmuxd/README.md vendored Normal file
View File

@@ -0,0 +1,30 @@
# WebMuxD
`webmuxd` is a WebUSB implementation of Apple's `usbmuxd` protocol, compatible with [libimobiledevice/usbmuxd](https://github.com/libimobiledevice/usbmuxd).
## Usage
```ts
import { DirectUsbMuxClient, installIpaViaInstProxy } from "webmuxd"
```
This package includes:
- `DirectUsbMuxClient`: usbmux + lockdownd + AFC + installation_proxy lifecycle
- `installIpaViaInstProxy`: stage and install an IPA via AFC and InstProxy
- Pairing helpers: `createHostId`, `createSystemBuid`, pair record encode/decode helpers
- OpenSSL WASM helpers: `createOpenSslWasmTlsFactory`, `generatePairRecordWithOpenSslWasm`
## Build
From the workspace root:
```bash
bun run build
```
From this package directory:
```bash
bun run build
```

12
dependencies/webmuxd/jestconfig.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"globals": {
"ts-jest": {
"tsconfig": "tsconfig.test.json"
}
},
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
}

58
dependencies/webmuxd/package.json vendored Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "webmuxd",
"version": "0.8.1",
"description": "WebUSB implementation of Apple Mobile Device usbmuxd",
"packageManager": "bun@1.3.11",
"homepage": "https://github.com/webmuxd/webmuxd#readme",
"repository": {
"type": "git",
"url": "https://github.com/webmuxd/webmuxd"
},
"bugs": {
"url": "https://github.com/webmuxd/webmuxd/issues"
},
"main": "lib/webmuxd.js",
"types": "lib/webmuxd.d.ts",
"exports": {
".": {
"types": "./lib/webmuxd.d.ts",
"default": "./lib/webmuxd.js"
}
},
"files": [
"lib/**/*",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc && bun run copy:openssl-wasm",
"copy:openssl-wasm": "bun scripts/copy-openssl-wasm.mjs",
"prepare": "bun run build",
"prepublishOnly": "bun run test && bun run lint",
"preversion": "bun run lint",
"version": "bun run format && git add -A src",
"postversion": "git push && git push --tags",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint --ext .ts src/",
"test": "jest --config jestconfig.json"
},
"author": "Rick Mark",
"license": "MIT",
"devDependencies": {
"@types/jest": "^26.0.15",
"@types/node": "^25.5.2",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"eslint": "^8.4.1",
"eslint-config-prettier": "^6.14.0",
"eslint-plugin-jsdoc": "^37.2.1",
"eslint-plugin-prefer-arrow": "^1.2.2",
"jest": "^26.6.0",
"prettier": "^2.1.2",
"ts-jest": "^26.4.1",
"typescript": "~5.9.3"
},
"dependencies": {
"@types/w3c-web-usb": "^1.0.4"
}
}

View File

@@ -0,0 +1,22 @@
import { mkdir, copyFile } from "node:fs/promises"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageDir = resolve(scriptDir, "..")
const workspaceRootDir = resolve(packageDir, "../..")
const sourceDir = resolve(workspaceRootDir, "wasm/openssl/pkg")
const targetDir = resolve(packageDir, "lib/openssl-wasm")
const filesToCopy = [
"openssl_wasm.js",
"openssl_wasm.d.ts",
"openssl_wasm_bg.wasm",
"openssl_wasm_bg.wasm.d.ts",
]
await mkdir(targetDir, { recursive: true })
for (const fileName of filesToCopy) {
await copyFile(resolve(sourceDir, fileName), resolve(targetDir, fileName))
}

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

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

225
dependencies/webmuxd/src/webmuxd.ts vendored 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 as ArrayBuffer);
}
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"

13
dependencies/webmuxd/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./lib",
"strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2018"],
"types": ["node", "w3c-web-usb"]
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node", "w3c-web-usb"]
},
"include": ["src"]
}