From bbbc9debccf11b3e689b60e22b1eaa7d0496dca3 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 22 May 2024 18:11:49 +0200 Subject: [PATCH] Refactor to persistent model, add reconnect support + proper unit tests --- package.json | 2 +- src/index.ts | 107 +++++++++++++++++++++------------ test/integration-tests.spec.ts | 13 ++-- test/unit-tests.spec.ts | 91 +++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index e13c094..437e974 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ }, "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", @@ -43,6 +42,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@httptoolkit/util": "^0.1.2", "plist": "^3.1.0" } } diff --git a/src/index.ts b/src/index.ts index 52889ff..c166dab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,40 +1,13 @@ import * as net from 'net'; import * as plist from 'plist'; +import { delay, getDeferred } from '@httptoolkit/util'; + import { readBytes } from './stream-utils'; 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'); @@ -74,12 +47,66 @@ const readMessageFromStream = async (stream: net.Socket): Promise { + const conn = net.connect(options); + + await new Promise((resolve, reject) => { + conn.once('connect', resolve); + conn.once('error', reject); + }); + + return conn; +} + +export class UsbmuxClient { constructor( - private socket: net.Socket + private connectionOptions: net.NetConnectOpts = DEFAULT_ADDRESS ) { - this.listenToMessages(socket) + this.startListeningForDevices().catch(() => {}); + } + + deviceMonitorConnection: net.Socket | Promise | undefined; + + private async startListeningForDevices() { + if (this.deviceMonitorConnection instanceof net.Socket) return; + else if (this.deviceMonitorConnection?.then) return this.deviceMonitorConnection; + + const connectionDeferred = getDeferred(); + this.deviceMonitorConnection = connectionDeferred.promise; + + try { + const conn = await connectSocket(this.connectionOptions); + + // 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'); + }; + + this.listenToMessages(conn); + await delay(10); // Brief delay to make sure we get already-connected device updates + + connectionDeferred.resolve(conn); + this.deviceMonitorConnection = conn; + conn.on('close', () => { + this.deviceMonitorConnection = undefined; + this.deviceData = {}; + }); + } catch (e: any) { + connectionDeferred.reject(e); + throw e; + } } private deviceData: Record> = {}; @@ -102,15 +129,21 @@ class UsbmuxClient { } } - getDevices() { + async getDevices() { + await this.startListeningForDevices(); return this.deviceData; } close() { - this.socket.end(); + if (this.deviceMonitorConnection instanceof net.Socket) { + this.deviceMonitorConnection?.end(); + } else if (this.deviceMonitorConnection?.then) { + this.deviceMonitorConnection + .then((conn) => conn.destroy()) + .catch(() => {}); + } + this.deviceData = {}; } -} - -export type { UsbmuxClient }; \ No newline at end of file +} \ No newline at end of file diff --git a/test/integration-tests.spec.ts b/test/integration-tests.spec.ts index eb426dd..9e91b43 100644 --- a/test/integration-tests.spec.ts +++ b/test/integration-tests.spec.ts @@ -1,7 +1,6 @@ import { expect } from "chai"; -import { delay } from "@httptoolkit/util"; -import { UsbmuxClient, getUsbmuxClient } from "../src/index"; +import { UsbmuxClient } from "../src/index"; describe("Usbmux-client integration tests", () => { @@ -14,10 +13,9 @@ describe("Usbmux-client integration tests", () => { if (process.env.CI) { it("can query the connected devices (seeing no results)", async () => { - client = await getUsbmuxClient(); - await delay(10); // Not clear this is necessary, but not unhelpful + client = new UsbmuxClient(); - const devices = client.getDevices(); + const devices = await client.getDevices(); // Tests assume no devices in CI but this at least confirms we can connect // to Usbmux successfully. @@ -25,10 +23,9 @@ describe("Usbmux-client integration tests", () => { }); } else { it("can detect the connected device", async () => { - client = await getUsbmuxClient(); - await delay(10); // Not clear this is necessary, but not unhelpful + client = new UsbmuxClient(); - const devices = client.getDevices(); + const devices = await client.getDevices(); // Local integratiion testing assumes you'll test with a real device expect(devices).to.be.an('object'); diff --git a/test/unit-tests.spec.ts b/test/unit-tests.spec.ts index 18804c4..b6ab5d6 100644 --- a/test/unit-tests.spec.ts +++ b/test/unit-tests.spec.ts @@ -1,23 +1,53 @@ +import * as stream from 'stream'; import * as net from 'net'; import { expect } from 'chai'; import { makeDestroyable } from "destroyable-server"; -import { UsbmuxClient, getUsbmuxClient } from "../src/index"; +import { UsbmuxClient } from "../src/index"; +import { readBytes } from '../src/stream-utils'; import { delay } from '@httptoolkit/util'; -// Various Base64 encoded messages we expect or use a test data: +// Various Base64 encoded messages we expect or use as test data: const MESSAGES = { - LISTEN_REQUEST: Buffer.from("hwEAAAAAAAAIAAAAAQAAADw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KICA8ZGljdD4KICAgIDxrZXk+TWVzc2FnZVR5cGU8L2tleT4KICAgIDxzdHJpbmc+TGlzdGVuPC9zdHJpbmc+CiAgICA8a2V5PkNsaWVudFZlcnNpb25TdHJpbmc8L2tleT4KICAgIDxzdHJpbmc+dXNibXV4LWNsaWVudDwvc3RyaW5nPgogICAgPGtleT5Qcm9nTmFtZTwva2V5PgogICAgPHN0cmluZz51c2JtdXgtY2xpZW50PC9zdHJpbmc+CiAgPC9kaWN0Pgo8L3BsaXN0Pg==", "base64") + LISTEN_REQUEST: "hwEAAAAAAAAIAAAAAQAAADw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KICA8ZGljdD4KICAgIDxrZXk+TWVzc2FnZVR5cGU8L2tleT4KICAgIDxzdHJpbmc+TGlzdGVuPC9zdHJpbmc+CiAgICA8a2V5PkNsaWVudFZlcnNpb25TdHJpbmc8L2tleT4KICAgIDxzdHJpbmc+dXNibXV4LWNsaWVudDwvc3RyaW5nPgogICAgPGtleT5Qcm9nTmFtZTwva2V5PgogICAgPHN0cmluZz51c2JtdXgtY2xpZW50PC9zdHJpbmc+CiAgPC9kaWN0Pgo8L3BsaXN0Pg==", + OK_RESULT: "JgEAAAEAAAAIAAAAAQAAADw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KPGRpY3Q+Cgk8a2V5Pk1lc3NhZ2VUeXBlPC9rZXk+Cgk8c3RyaW5nPlJlc3VsdDwvc3RyaW5nPgoJPGtleT5OdW1iZXI8L2tleT4KCTxpbnRlZ2VyPjA8L2ludGVnZXI+CjwvZGljdD4KPC9wbGlzdD4K", // Result = 0 plist message + FAIL_RESULT: "JgEAAAEAAAAIAAAAAQAAADw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KPGRpY3Q+Cgk8a2V5Pk1lc3NhZ2VUeXBlPC9rZXk+Cgk8c3RyaW5nPlJlc3VsdDwvc3RyaW5nPgoJPGtleT5OdW1iZXI8L2tleT4KCTxpbnRlZ2VyPjE8L2ludGVnZXI+CjwvZGljdD4KPC9wbGlzdD4K", // Result = 1 plist message + DEVICE_ATTACHED_EVENT: "qQIAAAEAAAAIAAAAAAAAADw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+CjwhRE9DVFlQRSBwbGlzdCBQVUJMSUMgIi0vL0FwcGxlLy9EVEQgUExJU1QgMS4wLy9FTiIgImh0dHA6Ly93d3cuYXBwbGUuY29tL0RURHMvUHJvcGVydHlMaXN0LTEuMC5kdGQiPgo8cGxpc3QgdmVyc2lvbj0iMS4wIj4KPGRpY3Q+Cgk8a2V5Pk1lc3NhZ2VUeXBlPC9rZXk+Cgk8c3RyaW5nPkF0dGFjaGVkPC9zdHJpbmc+Cgk8a2V5PkRldmljZUlEPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgoJPGtleT5Qcm9wZXJ0aWVzPC9rZXk+Cgk8ZGljdD4KCQk8a2V5PkNvbm5lY3Rpb25TcGVlZDwva2V5PgoJCTxpbnRlZ2VyPjQ4MDAwMDAwMDwvaW50ZWdlcj4KCQk8a2V5PkNvbm5lY3Rpb25UeXBlPC9rZXk+CgkJPHN0cmluZz5VU0I8L3N0cmluZz4KCQk8a2V5PkRldmljZUlEPC9rZXk+CgkJPGludGVnZXI+MTwvaW50ZWdlcj4KCQk8a2V5PkxvY2F0aW9uSUQ8L2tleT4KCQk8aW50ZWdlcj45OTk5OTk8L2ludGVnZXI+CgkJPGtleT5Qcm9kdWN0SUQ8L2tleT4KCQk8aW50ZWdlcj40Nzc2PC9pbnRlZ2VyPgoJCTxrZXk+U2VyaWFsTnVtYmVyPC9rZXk+CgkJPHN0cmluZz5BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPC9zdHJpbmc+Cgk8L2RpY3Q+CjwvZGljdD4KPC9wbGlzdD4K", }; +async function expectMessage(input: stream.Readable, messageKey: keyof typeof MESSAGES) { + const expectedMessage = Buffer.from(MESSAGES[messageKey], 'base64'); + const bytesToRead = expectedMessage.byteLength; + + const data = await readBytes(input, bytesToRead); + expect(data).to.deep.equal(expectedMessage); +} + describe("Usbmux-client unit tests", () => { let serverSocket: net.Socket | undefined; + const waitForSocket = () => new Promise(async (resolve) => { + while (true) { + if (serverSocket) { + resolve(serverSocket); + return; + } + await delay(5); + } + }); + const mockServer = makeDestroyable(net.createServer((socket) => { if (serverSocket) serverSocket.destroy(); + serverSocket = socket; + + serverSocket.on('close', () => { + if (serverSocket === socket) { + serverSocket = undefined; + } + }) })); let mockServerPort: number | undefined; @@ -25,6 +55,11 @@ describe("Usbmux-client unit tests", () => { beforeEach(async () => { mockServer.listen(0); + await new Promise((resolve, reject) => { + mockServer.once('listening', resolve); + mockServer.once('error', reject); + }); + mockServerPort = (mockServer.address() as net.AddressInfo).port; }); @@ -36,13 +71,53 @@ describe("Usbmux-client unit tests", () => { serverSocket = undefined; }); - it.only("should send a hello message to start listening", async () => { - getUsbmuxClient({ port: mockServerPort! }); + it("should connect & report no connected devices initially", async () => { + client = new UsbmuxClient({ port: mockServerPort! }); + const socket = await waitForSocket(); + + await expectMessage(socket, 'LISTEN_REQUEST'); + socket.write(Buffer.from(MESSAGES.OK_RESULT, 'base64')); +; + const devices = await client.getDevices(); + expect(Object.keys(devices)).to.have.length(0); + }); + + it("should connect & report a connected device after one appears", async () => { + client = new UsbmuxClient({ port: mockServerPort! }); + const socket = await waitForSocket(); + + await expectMessage(socket, 'LISTEN_REQUEST'); + socket.write(Buffer.from(MESSAGES.OK_RESULT, 'base64')); + + expect(Object.keys(await client.getDevices())).to.have.length(0); + + socket.write(Buffer.from(MESSAGES.DEVICE_ATTACHED_EVENT, 'base64')); + await delay(10); + expect(Object.keys(await client.getDevices())).to.have.length(1); + }); + + it("should handle reconnecting after disconnection", async () => { + client = new UsbmuxClient({ port: mockServerPort! }); + let socket = await waitForSocket(); + + await expectMessage(socket, 'LISTEN_REQUEST'); + socket.write(Buffer.from(MESSAGES.OK_RESULT, 'base64')); + + expect(Object.keys(await client.getDevices())).to.have.length(0); + + socket.destroy(); + serverSocket = undefined; + await delay(10); + + const deviceQuery = client.getDevices(); + + socket = await waitForSocket(); + await expectMessage(socket, 'LISTEN_REQUEST'); + socket.write(Buffer.from(MESSAGES.OK_RESULT, 'base64')); + socket.write(Buffer.from(MESSAGES.DEVICE_ATTACHED_EVENT, 'base64')); await delay(10); - const receivedData = serverSocket!.read(); - - expect(receivedData).to.deep.equal(MESSAGES.LISTEN_REQUEST); + expect(Object.keys(await deviceQuery)).to.have.length(1); }); }); \ No newline at end of file