Skip to content

Commit

Permalink
Refactor to persistent model, add reconnect support + proper unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pimterry committed May 22, 2024
1 parent 1964da2 commit bbbc9de
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 54 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -43,6 +42,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@httptoolkit/util": "^0.1.2",
"plist": "^3.1.0"
}
}
107 changes: 70 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -74,12 +47,66 @@ const readMessageFromStream = async (stream: net.Socket): Promise<ResponseMessag
return plist.parse(payload.toString('utf8')) as ResponseMessage;
}

class UsbmuxClient {
const connectSocket = async (options: net.NetConnectOpts) => {
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<net.Socket> | undefined;

private async startListeningForDevices() {
if (this.deviceMonitorConnection instanceof net.Socket) return;
else if (this.deviceMonitorConnection?.then) return this.deviceMonitorConnection;

const connectionDeferred = getDeferred<net.Socket>();
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<string, Record<string, string | number>> = {};
Expand All @@ -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 };
}
13 changes: 5 additions & 8 deletions test/integration-tests.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {

Expand All @@ -14,21 +13,19 @@ 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.
expect(devices).to.deep.equal({});
});
} 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');
Expand Down
91 changes: 83 additions & 8 deletions test/unit-tests.spec.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
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<net.Socket>(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;

let client: UsbmuxClient | undefined;

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

Expand All @@ -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);
});

});

0 comments on commit bbbc9de

Please sign in to comment.