mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
refactor directory
This commit is contained in:
196
dependencies/webmuxd/.eslintrc
vendored
Normal file
196
dependencies/webmuxd/.eslintrc
vendored
Normal 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
21
dependencies/webmuxd/LICENSE
vendored
Normal 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
30
dependencies/webmuxd/README.md
vendored
Normal 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
12
dependencies/webmuxd/jestconfig.json
vendored
Normal 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
58
dependencies/webmuxd/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
22
dependencies/webmuxd/scripts/copy-openssl-wasm.mjs
vendored
Normal file
22
dependencies/webmuxd/scripts/copy-openssl-wasm.mjs
vendored
Normal 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))
|
||||
}
|
||||
131
dependencies/webmuxd/src/__tests__/core-usbmux-session.test.ts
vendored
Normal file
131
dependencies/webmuxd/src/__tests__/core-usbmux-session.test.ts
vendored
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
dependencies/webmuxd/src/__tests__/webmuxd.test.ts
vendored
Normal file
16
dependencies/webmuxd/src/__tests__/webmuxd.test.ts
vendored
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
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
225
dependencies/webmuxd/src/webmuxd.ts
vendored
Normal file
225
dependencies/webmuxd/src/webmuxd.ts
vendored
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 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
13
dependencies/webmuxd/tsconfig.json
vendored
Normal 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__/*"]
|
||||
}
|
||||
7
dependencies/webmuxd/tsconfig.test.json
vendored
Normal file
7
dependencies/webmuxd/tsconfig.test.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node", "w3c-web-usb"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user