From b276b1265dd9d97c8ab16d15159044004c392337 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 17 May 2024 18:11:06 +0200 Subject: [PATCH] Connect to usbmux and scan for devices --- package.json | 5 ++ src/index.ts | 133 +++++++++++++++++++++++++++++++++++++++++++++- test/test.spec.ts | 22 ++++++-- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 4108218..ad49e59 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,17 @@ }, "homepage": "https://github.com/httptoolkit/usbmux-client#readme", "devDependencies": { + "@httptoolkit/util": "^0.1.2", "@types/chai": "^4.3.16", "@types/mocha": "^10.0.6", + "@types/plist": "^3.0.5", "chai": "^4.4.1", "mocha": "^10.4.0", "rimraf": "^5.0.7", "ts-node": "^10.9.2", "typescript": "^5.4.5" + }, + "dependencies": { + "plist": "^3.1.0" } } diff --git a/src/index.ts b/src/index.ts index 26a534b..3b22a71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,132 @@ -export const PLACEHOLDER = true; \ No newline at end of file +import * as net from 'net'; +import * as plist from 'plist'; + +const DEFAULT_ADDRESS = process.platform === 'win32' + ? { port: 27015 } + : { path: '/var/run/usbmuxd' }; + +export async function getUsbmuxClient( + connectionOptions: net.NetConnectOpts = DEFAULT_ADDRESS +) { + const conn = net.connect(connectionOptions); + + await new Promise((resolve, reject) => { + conn.on('connect', resolve); + conn.on('error', reject); + }); + + // Start listening for connected devices: + conn.write(plistSerialize({ + MessageType: 'Listen', + ClientVersionString: 'usbmux-client', + ProgName: 'usbmux-client' + })); + + const response = await readMessageFromStream(conn); + if ( + response === null || + response.MessageType !== 'Result' || + response.Number !== 0 + ) { + throw new Error('Usbmux connection failed'); + }; + + return new UsbmuxClient(conn); +} + +function plistSerialize(value: any) { + const plistString = plist.build(value) + const plistBuffer = Buffer.from(plistString, 'utf8'); + + const length = 16 + plistBuffer.byteLength; // Header always 16 bytes + const version = 0; // Also called 'reserved'? Always 0 + const messageType = 8; // 8 is 'plist' message type + const tag = 1; // Echoed in responses, not used for now + + const messageHeader = Buffer.alloc(16); + messageHeader.writeUInt32LE(length, 0); + messageHeader.writeUInt32LE(version, 4); + messageHeader.writeUInt32LE(messageType, 8); + messageHeader.writeUInt32LE(tag, 12); + + return Buffer.concat([messageHeader, plistBuffer], length); +} + +type ResultMessage = { MessageType: 'Result', Number: number }; +type AttachedMessage = { MessageType: 'Attached', DeviceID: number, Properties: Record }; +type DetachedMessage = { MessageType: 'Detached', DeviceID: number }; + +type ResponseMessage = + | ResultMessage + | AttachedMessage + | DetachedMessage; + +const readMessageFromStream = async (stream: net.Socket): Promise => { + if (stream.closed) return null; + + const header = stream.read(16); + if (!header) { + await new Promise((resolve, reject) => { + stream.once('readable', resolve); + stream.once('close', resolve); + stream.once('error', reject); + }); + return readMessageFromStream(stream); + } + + const payloadLength = header.readUInt32LE(0) - 16; // Minus the header length + + const payload = stream.read(payloadLength); + if (!payload) { + stream.unshift(header); + await new Promise((resolve, reject) => { + stream.once('readable', resolve); + stream.once('close', resolve); + stream.once('error', reject); + }); + return readMessageFromStream(stream); + } + + return plist.parse(payload.toString('utf8')) as ResponseMessage; +} + +class UsbmuxClient { + + constructor( + private socket: net.Socket + ) { + this.listenToMessages(socket) + } + + private deviceData: Record> = {}; + + // Listen for events by using readMessageFromStream in an async iterator: + async listenToMessages(socket: net.Socket) { + while (true) { + const message = await readMessageFromStream(socket); + if (message === null) { + this.close(); + return; + } + + if (message.MessageType === 'Attached') { + this.deviceData[message.DeviceID] = message.Properties; + } else if (message.MessageType === 'Detached') { + delete this.deviceData[message.DeviceID]; + + } + } + } + + getDevices() { + return this.deviceData; + } + + close() { + this.socket.end(); + this.deviceData = {}; + } + +} + +export type { UsbmuxClient }; \ No newline at end of file diff --git a/test/test.spec.ts b/test/test.spec.ts index ac71a2d..f7f6c2d 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -1,11 +1,27 @@ import { expect } from "chai"; +import { delay } from "@httptoolkit/util"; -import { PLACEHOLDER } from "../src/index"; +import { UsbmuxClient, getUsbmuxClient } from "../src/index"; describe("Usbmux-client", () => { - it("placeholder test", () => { - expect(PLACEHOLDER).to.equal(true); + let client: UsbmuxClient | undefined; + + afterEach(() => { + client?.close(); + client = undefined; + }) + + it("can expose the connected devices ", async () => { + client = await getUsbmuxClient(); + await delay(10); // Not clear this is necessary, but not unhelpful + + const devices = client.getDevices(); + + // Tests assume no devices (since this will always be the state in + // the CI environment) but this at least confirms we can connect + // to Usbmux successfully. + expect(devices).to.deep.equal({}); }); }); \ No newline at end of file