@mcesystems/usbmuxd-instance-manager 1.0.72 → 1.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,137 +1,113 @@
1
1
  // src/UsbmuxdService.ts
2
2
  import { EventEmitter as EventEmitter2 } from "node:events";
3
- import { createLoggers as createLoggers2 } from "@mcesystems/tool-debug-g4";
3
+ import { createLoggers as createLoggers4 } from "@mcesystems/tool-debug-g4";
4
4
  import UsbDeviceListener from "@mcesystems/usb-device-listener";
5
5
 
6
6
  // src/InstanceManager.ts
7
- import { exec, spawn } from "node:child_process";
8
7
  import { EventEmitter } from "node:events";
8
+ import { createLoggers as createLoggers3 } from "@mcesystems/tool-debug-g4";
9
+
10
+ // src/usbipd.ts
11
+ import { exec } from "node:child_process";
9
12
  import { promisify } from "node:util";
10
13
  import { createLoggers } from "@mcesystems/tool-debug-g4";
11
- var { logInfo, logWarning } = createLoggers("usbmuxd-instance-manager");
14
+ var { logInfo, logWarning } = createLoggers("usbipd");
12
15
  var execAsync = promisify(exec);
13
- var USBIPD_PATH = '"C:\\Program Files\\usbipd-win\\usbipd.exe"';
14
- function parseUsbipdList(output) {
15
- const devices = [];
16
- const lines = output.split(/\r?\n/);
17
- for (const line of lines) {
18
- const match = line.match(/^(\d+-\d+)\s+([0-9a-f]{4}):([0-9a-f]{4})\s+(.+)$/i);
19
- if (match) {
20
- const rest = match[4].trim();
21
- const stateMatch = rest.match(/^(.+?)\s{2,}(\S.*)$/);
22
- const description = stateMatch ? stateMatch[1].trim() : rest;
23
- const state = stateMatch ? stateMatch[2].trim() : "Unknown";
24
- devices.push({
25
- busId: match[1],
26
- vid: match[2].toUpperCase(),
27
- pid: match[3].toUpperCase(),
28
- description,
29
- state
30
- });
16
+ var Usbipd = class {
17
+ constructor(usbipdPath) {
18
+ this.usbipdPath = usbipdPath;
19
+ }
20
+ async unbindAllDevicesFromWsl() {
21
+ try {
22
+ await execAsync(`"${this.usbipdPath}" unbind -a`);
23
+ logInfo("All devices unbound from WSL");
24
+ } catch (error) {
25
+ logWarning(`Failed to unbind all devices from WSL: ${error}`);
31
26
  }
32
27
  }
33
- return devices;
34
- }
35
- var DEFAULT_CONFIG = {
36
- batchSize: 4,
37
- basePort: 27015,
38
- maxInstances: 20,
39
- usbmuxdPath: "usbmuxd",
40
- // Path inside WSL2
41
- wslDistribution: "alpine-usbmuxd-build",
42
- // Alpine WSL2 distribution name
43
- verboseLogging: true,
44
- appleVendorId: "05AC"
45
- };
46
- var InstanceManager = class extends EventEmitter {
47
- config;
48
- instances = /* @__PURE__ */ new Map();
49
- deviceMappings = /* @__PURE__ */ new Map();
50
- processes = /* @__PURE__ */ new Map();
51
- nextInstanceId = 1;
52
- startedAt = null;
53
- isRunning = false;
54
- /** Tracks which devices have been attached to WSL */
55
- attachedDevices = /* @__PURE__ */ new Set();
56
- /** Cached WSL IP address for connecting from Windows */
57
- wslIpAddress = null;
58
- constructor(config = {}) {
59
- super();
60
- this.config = { ...DEFAULT_CONFIG, ...config };
28
+ async detachAllDevicesFromWsl() {
29
+ try {
30
+ await execAsync(`"${this.usbipdPath}" detach -a`);
31
+ logInfo("All devices detached from WSL");
32
+ } catch (error) {
33
+ logWarning(`Failed to detach all devices from WSL: ${error}`);
34
+ }
61
35
  }
62
36
  /**
63
- * Detect the WSL2 IP address for the configured distribution
64
- * This IP is needed to connect from Windows to services inside WSL
37
+ * Detach a device from WSL via usbipd
65
38
  */
66
- async detectWslIpAddress() {
67
- if (this.wslIpAddress) {
68
- return this.wslIpAddress;
69
- }
70
- const distro = this.config.wslDistribution || "alpine-usbmuxd-build";
39
+ async detachDeviceFromWsl(busId) {
71
40
  try {
72
- const { stdout } = await execAsync(`wsl -d ${distro} -- ip -4 addr show eth0`);
73
- const match = stdout.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
74
- if (match) {
75
- this.wslIpAddress = match[1];
76
- logInfo(`Detected WSL IP address: ${this.wslIpAddress}`);
77
- return this.wslIpAddress;
78
- }
41
+ await execAsync(`"${this.usbipdPath}" detach --busid=${busId}`);
42
+ logInfo(`Device ${busId} detached from WSL`);
79
43
  } catch (error) {
80
- logWarning(`Failed to detect WSL IP via ip addr: ${error}`);
81
- }
82
- try {
83
- const { stdout } = await execAsync(`wsl -d ${distro} -- hostname -I`);
84
- const ip = stdout.trim().split(/\s+/)[0];
85
- if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
86
- this.wslIpAddress = ip;
87
- logInfo(`Detected WSL IP address (hostname): ${this.wslIpAddress}`);
88
- return this.wslIpAddress;
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ if (message.includes("no device with busid") || message.includes("There is no device")) {
46
+ logInfo(`Device ${busId} already detached`);
47
+ } else {
48
+ logWarning(`Failed to detach device ${busId}: ${error}`);
89
49
  }
90
- } catch (error) {
91
- logWarning(`Failed to detect WSL IP via hostname: ${error}`);
92
50
  }
93
- logWarning("Could not detect WSL IP, falling back to localhost");
94
- this.wslIpAddress = "127.0.0.1";
95
- return this.wslIpAddress;
96
51
  }
97
52
  /**
98
- * Start the instance manager
53
+ * Attach a device to WSL via usbipd
54
+ * Note: This requires administrator privileges
99
55
  */
100
- start() {
101
- if (this.isRunning) {
102
- throw new Error("Instance manager is already running");
56
+ async attachDeviceToWsl(busId, wsl, distro) {
57
+ try {
58
+ await wsl.ensureWslRunning();
59
+ logInfo(`Binding device ${busId}...`);
60
+ await execAsync(`"${this.usbipdPath}" bind --busid ${busId} --force`);
61
+ if (distro) {
62
+ logInfo(`Attaching device ${busId} to WSL distribution ${distro}...`);
63
+ await execAsync(`"${this.usbipdPath}" attach --wsl=${distro} --busid=${busId}`);
64
+ } else {
65
+ logInfo(`Attaching device ${busId} to default WSL...`);
66
+ await execAsync(`"${this.usbipdPath}" attach --wsl --busid=${busId}`);
67
+ }
68
+ logInfo(`Device ${busId} attached to WSL successfully`);
69
+ return true;
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ if (message.includes("already attached to a client")) {
73
+ logInfo(`Device ${busId} is already attached to WSL, continuing`);
74
+ return true;
75
+ }
76
+ logWarning(`Failed to attach device ${busId} to WSL: ${error}`);
77
+ return false;
103
78
  }
104
- this.isRunning = true;
105
- this.startedAt = /* @__PURE__ */ new Date();
106
- this.emit("started");
107
79
  }
108
80
  /**
109
- * Stop the instance manager and all instances
81
+ * Parse usbipd list output to extract device information
110
82
  */
111
- async stop() {
112
- if (!this.isRunning) {
113
- return;
83
+ parseUsbipdList(output) {
84
+ const devices = [];
85
+ const lines = output.split(/\r?\n/);
86
+ for (const line of lines) {
87
+ const match = line.match(/^(\d+-\d+)\s+([0-9a-f]{4}):([0-9a-f]{4})\s+(.+)$/i);
88
+ if (match) {
89
+ const rest = match[4].trim();
90
+ const stateMatch = rest.match(/^(.+?)\s{2,}(\S.*)$/);
91
+ const description = stateMatch ? stateMatch[1].trim() : rest;
92
+ const state = stateMatch ? stateMatch[2].trim() : "Unknown";
93
+ devices.push({
94
+ busId: match[1],
95
+ vid: match[2].toUpperCase(),
96
+ pid: match[3].toUpperCase(),
97
+ description,
98
+ state
99
+ });
100
+ }
114
101
  }
115
- this.isRunning = false;
116
- const detachPromises = Array.from(this.attachedDevices).map(
117
- (busId) => this.detachDeviceFromWsl(busId)
118
- );
119
- await Promise.all(detachPromises);
120
- this.attachedDevices.clear();
121
- const stopPromises = Array.from(this.instances.keys()).map((id) => this.stopInstance(id));
122
- await Promise.all(stopPromises);
123
- this.instances.clear();
124
- this.deviceMappings.clear();
125
- this.processes.clear();
126
- this.emit("stopped");
102
+ return devices;
127
103
  }
128
104
  /**
129
105
  * Find the usbipd bus ID for a device by matching VID/PID
130
106
  */
131
107
  async findBusIdForDevice(device) {
132
108
  try {
133
- const { stdout } = await execAsync(`${USBIPD_PATH} list`);
134
- const usbipdDevices = parseUsbipdList(stdout);
109
+ const { stdout } = await execAsync(`"${this.usbipdPath}" list`);
110
+ const usbipdDevices = this.parseUsbipdList(stdout);
135
111
  const deviceVid = device.vid.toString(16).toUpperCase().padStart(4, "0");
136
112
  const devicePid = device.pid.toString(16).toUpperCase().padStart(4, "0");
137
113
  logInfo(`Looking for device with VID:PID ${deviceVid}:${devicePid}`);
@@ -143,100 +119,474 @@ var InstanceManager = class extends EventEmitter {
143
119
  );
144
120
  return match.busId;
145
121
  }
146
- for (const d of usbipdDevices) {
147
- logInfo(` usbipd device: ${d.busId} ${d.vid}:${d.pid} "${d.description}"`);
148
- }
149
- logWarning(
122
+ throw new Error(
150
123
  `Could not find usbipd bus ID for device ${device.deviceId} (${deviceVid}:${devicePid})`
151
124
  );
152
- return null;
153
125
  } catch (error) {
154
- logWarning(`Failed to run usbipd list: ${error}`);
155
- return null;
126
+ throw new Error(`Failed to run usbipd list: ${error}`);
127
+ }
128
+ }
129
+ };
130
+
131
+ // src/wsl.ts
132
+ import { exec as exec2, spawn } from "node:child_process";
133
+ import { existsSync, mkdirSync } from "node:fs";
134
+ import { join } from "node:path";
135
+ import { promisify as promisify2 } from "node:util";
136
+ import { createLoggers as createLoggers2 } from "@mcesystems/tool-debug-g4";
137
+ var { logInfo: logInfo2, logWarning: logWarning2, logDetail } = createLoggers2("wsl");
138
+ var execAsync2 = promisify2(exec2);
139
+ var Wsl2 = class {
140
+ constructor(wslDistribution) {
141
+ this.wslDistribution = wslDistribution;
142
+ }
143
+ ALPINE_LOCKDOWN_DIR = "/var/lib/lockdown";
144
+ SYSTEM_CONFIG_PLIST = "SystemConfiguration.plist";
145
+ wslIpAddress = null;
146
+ /**
147
+ * Detect the WSL2 IP address for the configured distribution
148
+ * This IP is needed to connect from Windows to services inside WSL
149
+ */
150
+ async detectWslIpAddress() {
151
+ if (this.wslIpAddress) {
152
+ return this.wslIpAddress;
153
+ }
154
+ const distro = this.wslDistribution || "alpine-usbmuxd-build";
155
+ try {
156
+ const { stdout } = await execAsync2(`wsl -d ${distro} -- ip -4 addr show eth0`);
157
+ const match = stdout.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
158
+ if (match) {
159
+ this.wslIpAddress = match[1];
160
+ logInfo2(`Detected WSL IP address: ${this.wslIpAddress}`);
161
+ return this.wslIpAddress;
162
+ }
163
+ } catch (error) {
164
+ logWarning2(`Failed to detect WSL IP via ip addr: ${error}`);
165
+ }
166
+ try {
167
+ const { stdout } = await execAsync2(`wsl -d ${distro} -- hostname -I`);
168
+ const ip = stdout.trim().split(/\s+/)[0];
169
+ if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
170
+ this.wslIpAddress = ip;
171
+ logInfo2(`Detected WSL IP address (hostname): ${this.wslIpAddress}`);
172
+ return this.wslIpAddress;
173
+ }
174
+ } catch (error) {
175
+ logWarning2(`Failed to detect WSL IP via hostname: ${error}`);
156
176
  }
177
+ logWarning2("Could not detect WSL IP, falling back to localhost");
178
+ this.wslIpAddress = "127.0.0.1";
179
+ return this.wslIpAddress;
157
180
  }
158
181
  /**
159
182
  * Ensure the WSL distribution is running
160
183
  */
161
184
  async ensureWslRunning() {
162
- const distro = this.config.wslDistribution;
185
+ const distro = this.wslDistribution;
163
186
  try {
164
187
  if (distro) {
165
- logInfo(`Starting WSL distribution: ${distro}...`);
166
- await execAsync(`wsl -d ${distro} -- echo "WSL started"`);
188
+ logInfo2(`Starting WSL distribution: ${distro}...`);
189
+ await execAsync2(`wsl -d ${distro} -- echo "WSL started"`);
167
190
  } else {
168
- logInfo("Starting default WSL distribution...");
169
- await execAsync(`wsl -- echo "WSL started"`);
191
+ logInfo2("Starting default WSL distribution...");
192
+ await execAsync2(`wsl -- echo "WSL started"`);
170
193
  }
171
194
  return true;
172
195
  } catch (error) {
173
- logWarning(`Failed to start WSL: ${error}`);
196
+ logWarning2(`Failed to start WSL: ${error}`);
174
197
  return false;
175
198
  }
176
199
  }
177
200
  /**
178
- * Attach a device to WSL via usbipd
179
- * Note: This requires administrator privileges
201
+ * Create a new usbmuxd instance
180
202
  */
181
- async attachDeviceToWsl(busId) {
182
- const distro = this.config.wslDistribution;
203
+ async createInstance({
204
+ id,
205
+ basePort,
206
+ verboseLogging,
207
+ usbmuxdPath,
208
+ onLog,
209
+ onExit
210
+ }) {
211
+ const port = basePort + id - 1;
212
+ logInfo2(`Creating instance ${id} on port ${port}`);
213
+ const host = await this.detectWslIpAddress();
214
+ const usbmuxdArgs = [
215
+ "-f",
216
+ // Foreground
217
+ "-v",
218
+ // Verbose (if enabled)
219
+ "-S",
220
+ `0.0.0.0:${port}`,
221
+ // Listen on all interfaces (for Windows → WSL2)
222
+ "--pidfile",
223
+ "NONE"
224
+ ];
225
+ if (!verboseLogging) {
226
+ usbmuxdArgs.splice(1, 1);
227
+ }
228
+ const wslArgs = [
229
+ "-d",
230
+ this.wslDistribution || "alpine-usbmuxd-build",
231
+ usbmuxdPath,
232
+ ...usbmuxdArgs
233
+ ];
234
+ const process2 = spawn("wsl", wslArgs, {
235
+ stdio: ["ignore", "pipe", "pipe"],
236
+ detached: true,
237
+ windowsHide: false
238
+ });
239
+ process2.stdout?.on("data", (data) => {
240
+ onLog(data.toString().trim());
241
+ });
242
+ process2.stderr?.on("data", (data) => {
243
+ onLog(data.toString().trim());
244
+ });
245
+ process2.on("exit", onExit);
246
+ const pid = process2.pid;
247
+ if (pid === void 0) {
248
+ process2.kill("SIGKILL");
249
+ throw new Error("Failed to get PID for usbmuxd instance");
250
+ }
251
+ const instance = {
252
+ id,
253
+ host,
254
+ port,
255
+ pid,
256
+ deviceUdids: [],
257
+ startedAt: /* @__PURE__ */ new Date()
258
+ };
259
+ return { instance, process: process2 };
260
+ }
261
+ /**
262
+ * Convert a Windows path to the equivalent path inside WSL (e.g. C:\foo\bar -> /mnt/c/foo/bar).
263
+ */
264
+ windowsPathToWsl(windowsPath) {
265
+ const normalized = windowsPath.replace(/\\/g, "/").trim();
266
+ const driveMatch = normalized.match(/^([a-zA-Z]):\/?(.*)$/);
267
+ if (driveMatch) {
268
+ const drive = driveMatch[1].toLowerCase();
269
+ const rest = driveMatch[2] || "";
270
+ return `/mnt/${drive}${rest ? `/${rest}` : ""}`;
271
+ }
272
+ return normalized;
273
+ }
274
+ /**
275
+ * Get the native Apple lockdown directory path for the current platform.
276
+ * This is where Apple/iTunes stores pairing records natively.
277
+ */
278
+ getAppleLockdownPath() {
279
+ if (process.platform === "win32") {
280
+ return join(process.env.ProgramData ?? "C:\\ProgramData", "Apple", "Lockdown");
281
+ }
282
+ if (process.platform === "darwin") {
283
+ return "/var/db/lockdown";
284
+ }
285
+ if (process.platform === "linux") {
286
+ return "/var/lib/lockdown";
287
+ }
288
+ return null;
289
+ }
290
+ /**
291
+ * Copy a single device's lockdown plist from the native Apple lockdown directory
292
+ * to Alpine so usbmuxd can use it (skip pairing).
293
+ *
294
+ * Uses the platform-specific Apple/iTunes lockdown directory
295
+ * (e.g. C:\ProgramData\Apple\Lockdown on Windows, /var/db/lockdown on macOS).
296
+ *
297
+ * No-op if the lockdown directory cannot be determined or files don't exist.
298
+ */
299
+ async syncToAlpine(udid) {
300
+ const lockdownDir = this.getAppleLockdownPath();
301
+ if (!lockdownDir) {
302
+ logWarning2("Lockdown sync to Alpine: unsupported platform, skipping");
303
+ return;
304
+ }
305
+ logDetail(`Lockdown sync to Alpine: source dir = ${lockdownDir}, udid = ${udid}`);
306
+ const distro = this.wslDistribution || "alpine-usbmuxd-build";
307
+ const wslDestDir = this.ALPINE_LOCKDOWN_DIR;
308
+ const devicePlistFile = `${udid}.plist`;
309
+ const devicePlistPath = join(lockdownDir, devicePlistFile);
310
+ if (existsSync(devicePlistPath)) {
311
+ await this.copyFileToAlpine(
312
+ this.windowsPathToWsl(devicePlistPath),
313
+ wslDestDir,
314
+ distro,
315
+ devicePlistFile
316
+ );
317
+ } else {
318
+ logDetail(
319
+ `Lockdown sync to Alpine: ${devicePlistFile} not found at ${devicePlistPath}, skipping`
320
+ );
321
+ }
322
+ const systemConfigPath = join(lockdownDir, this.SYSTEM_CONFIG_PLIST);
323
+ if (existsSync(systemConfigPath)) {
324
+ await this.copyFileToAlpine(
325
+ this.windowsPathToWsl(systemConfigPath),
326
+ wslDestDir,
327
+ distro,
328
+ this.SYSTEM_CONFIG_PLIST
329
+ );
330
+ } else {
331
+ logDetail(
332
+ `Lockdown sync to Alpine: ${this.SYSTEM_CONFIG_PLIST} not found at ${systemConfigPath}, skipping`
333
+ );
334
+ }
335
+ }
336
+ /**
337
+ * Copy a single file into the Alpine lockdown directory.
338
+ * Falls back to sudo if the initial copy fails.
339
+ */
340
+ async copyFileToAlpine(wslSource, wslDestDir, distro, fileName) {
183
341
  try {
184
- await this.ensureWslRunning();
185
- logInfo(`Binding device ${busId}...`);
186
- await execAsync(`${USBIPD_PATH} bind --busid ${busId} --force`);
187
- if (distro) {
188
- logInfo(`Attaching device ${busId} to WSL distribution ${distro}...`);
189
- await execAsync(`${USBIPD_PATH} attach --wsl=${distro} --busid=${busId}`);
190
- } else {
191
- logInfo(`Attaching device ${busId} to default WSL...`);
192
- await execAsync(`${USBIPD_PATH} attach --wsl --busid=${busId}`);
193
- }
194
- logInfo(`Device ${busId} attached to WSL successfully`);
195
- return true;
342
+ await execAsync2(
343
+ `wsl -d ${distro} -- sh -c "mkdir -p ${wslDestDir} && cp '${wslSource}' '${wslDestDir}/'"`
344
+ );
345
+ logInfo2(`Lockdown sync: copied ${fileName} to Alpine`);
196
346
  } catch (error) {
197
- logWarning(`Failed to attach device ${busId} to WSL: ${error}`);
198
- return false;
347
+ try {
348
+ await execAsync2(
349
+ `wsl -d ${distro} -- sh -c "mkdir -p ${wslDestDir} && sudo cp '${wslSource}' '${wslDestDir}/'"`
350
+ );
351
+ logInfo2(`Lockdown sync: copied ${fileName} to Alpine (via sudo)`);
352
+ } catch (sudoError) {
353
+ logWarning2(
354
+ `Lockdown sync to Alpine failed for ${fileName}: ${error}. Sudo fallback failed: ${sudoError}. Ensure /var/lib/lockdown is writable or use passwordless sudo.`
355
+ );
356
+ }
199
357
  }
200
358
  }
201
359
  /**
202
- * Detach a device from WSL via usbipd
360
+ * Copy lockdown plists from Alpine back to the native Apple lockdown directory
361
+ * (for devices that were assigned this session).
362
+ * Also copies SystemConfiguration.plist if present in Alpine.
363
+ *
364
+ * No-op if the lockdown directory cannot be determined.
203
365
  */
204
- async detachDeviceFromWsl(busId) {
366
+ async syncFromAlpine(udids) {
367
+ logInfo2(`Lockdown sync from Alpine: starting (${udids.length} device(s))`);
368
+ const lockdownDir = this.getAppleLockdownPath();
369
+ if (!lockdownDir) {
370
+ logWarning2("Lockdown sync: unsupported platform, skipping");
371
+ return;
372
+ }
373
+ logDetail(`Lockdown sync: target dir = ${lockdownDir}`);
205
374
  try {
206
- await execAsync(`${USBIPD_PATH} detach --busid=${busId}`);
207
- logInfo(`Device ${busId} detached from WSL`);
375
+ mkdirSync(lockdownDir, { recursive: true });
376
+ } catch (error) {
377
+ logWarning2(`Lockdown sync: could not create lockdown dir ${lockdownDir}: ${error}`);
378
+ return;
379
+ }
380
+ const distro = this.wslDistribution || "alpine-usbmuxd-build";
381
+ const wslDestDir = this.windowsPathToWsl(lockdownDir);
382
+ try {
383
+ const { stdout } = await execAsync2(`wsl -d ${distro} -- ls -la ${this.ALPINE_LOCKDOWN_DIR}/`);
384
+ logDetail(`Lockdown sync: Alpine ${this.ALPINE_LOCKDOWN_DIR} contents:
385
+ ${stdout}`);
208
386
  } catch (error) {
209
- logWarning(`Failed to detach device ${busId}: ${error}`);
387
+ logWarning2(`Lockdown sync: could not list Alpine lockdown dir: ${error}`);
388
+ }
389
+ const filesToSync = [...udids.map((udid) => `${udid}.plist`), this.SYSTEM_CONFIG_PLIST];
390
+ logDetail(`Lockdown sync: files to sync = [${filesToSync.join(", ")}]`);
391
+ for (const fileName of filesToSync) {
392
+ await this.copyFileFromAlpine(fileName, wslDestDir, distro, lockdownDir);
210
393
  }
394
+ logInfo2("Lockdown sync from Alpine: done");
211
395
  }
212
396
  /**
213
- * Handle device connection
214
- * Attaches device to WSL, then assigns to an existing instance or creates a new one
397
+ * Copy a single file from the Alpine lockdown directory back to Windows.
398
+ * Skips if the file does not exist in Alpine; logs a warning if the copy fails.
215
399
  */
216
- async onDeviceConnected(device) {
217
- if (this.deviceMappings.has(device.deviceId)) {
218
- logWarning(`Device ${device.deviceId} is already connected`);
400
+ async copyFileFromAlpine(fileName, wslDestDir, distro, lockdownDir) {
401
+ const alpinePath = `${this.ALPINE_LOCKDOWN_DIR}/${fileName}`;
402
+ try {
403
+ await execAsync2(`wsl -d ${distro} -- test -f '${alpinePath}'`);
404
+ } catch {
405
+ logDetail(`Lockdown sync: ${fileName} not found in Alpine (${alpinePath}), skipping`);
219
406
  return;
220
407
  }
221
- const busId = await this.findBusIdForDevice(device);
222
- if (!busId) {
223
- logWarning(`Cannot attach device ${device.deviceId} - bus ID not found`);
408
+ logDetail(`Lockdown sync: copying ${fileName} \u2192 ${wslDestDir}/`);
409
+ try {
410
+ await execAsync2(`wsl -d ${distro} -- cp -f '${alpinePath}' '${wslDestDir}/'`);
411
+ logInfo2(`Lockdown sync: copied ${fileName} from Alpine to ${lockdownDir}`);
412
+ } catch (error) {
413
+ logWarning2(`Lockdown sync: failed to copy ${fileName} from Alpine to ${lockdownDir}: ${error}`);
224
414
  }
225
- if (busId && !this.attachedDevices.has(busId)) {
226
- const attached = await this.attachDeviceToWsl(busId);
415
+ }
416
+ };
417
+
418
+ // src/InstanceManager.ts
419
+ var { logWarning: logWarning3, logDetail: logDetail2, logDataObject, logTask } = createLoggers3("instance-manager");
420
+ var DEFAULT_CONFIG = {
421
+ batchSize: 4,
422
+ basePort: 27015,
423
+ maxInstances: 5,
424
+ usbmuxdPath: "usbmuxd",
425
+ // Path inside WSL2
426
+ wslDistribution: "alpine-usbmuxd-build",
427
+ // Alpine WSL2 distribution name
428
+ verboseLogging: true,
429
+ appleVendorId: "05AC"
430
+ };
431
+ var InstanceManager = class extends EventEmitter {
432
+ /** Map of usbmuxd instance IDs to instances */
433
+ instances = /* @__PURE__ */ new Map();
434
+ /** Map of device IDs to device mappings host:port */
435
+ deviceMappings = /* @__PURE__ */ new Map();
436
+ /** Map of usbmuxd instance IDs to child processes running it on wsl2*/
437
+ processes = /* @__PURE__ */ new Map();
438
+ /** Tracks which devices have been attached to WSL */
439
+ attachedDevices = /* @__PURE__ */ new Set();
440
+ /** Device IDs currently in the attach flow
441
+ * (ignore disconnect until attach completes) */
442
+ pendingAttachDevices = /* @__PURE__ */ new Set();
443
+ config;
444
+ nextInstanceId = 1;
445
+ startedAt = null;
446
+ isRunning = false;
447
+ wsl;
448
+ usbipd;
449
+ constructor(config = {}) {
450
+ super();
451
+ this.config = { ...DEFAULT_CONFIG, ...config };
452
+ this.wsl = new Wsl2(this.config.wslDistribution);
453
+ this.usbipd = new Usbipd(process.env.USBIPD_PATH ?? "usbipd");
454
+ }
455
+ /**
456
+ * Start the instance manager
457
+ */
458
+ start() {
459
+ if (this.isRunning) {
460
+ throw new Error("Instance manager is already running");
461
+ }
462
+ this.isRunning = true;
463
+ this.startedAt = /* @__PURE__ */ new Date();
464
+ }
465
+ /**
466
+ * Stop the instance manager and all instances
467
+ */
468
+ async stop() {
469
+ if (!this.isRunning) {
470
+ return;
471
+ }
472
+ this.isRunning = false;
473
+ const udids = Array.from(this.deviceMappings.keys());
474
+ await this.wsl.syncFromAlpine(udids);
475
+ await this.usbipd.detachAllDevicesFromWsl();
476
+ await this.usbipd.unbindAllDevicesFromWsl();
477
+ this.attachedDevices.clear();
478
+ const stopPromises = Array.from(this.instances.keys()).map((id) => this.stopInstance(id));
479
+ await Promise.all(stopPromises);
480
+ this.instances.clear();
481
+ this.deviceMappings.clear();
482
+ this.processes.clear();
483
+ }
484
+ async attachToWsl({
485
+ busId,
486
+ deviceId
487
+ }) {
488
+ if (!this.attachedDevices.has(busId)) {
489
+ this.pendingAttachDevices.add(deviceId);
490
+ if (!this.config.wslDistribution) {
491
+ throw new Error("WSL distribution not configured");
492
+ }
493
+ const attached = await this.usbipd.attachDeviceToWsl(
494
+ busId,
495
+ this.wsl,
496
+ this.config.wslDistribution
497
+ );
227
498
  if (attached) {
228
499
  this.attachedDevices.add(busId);
229
- await new Promise((resolve) => setTimeout(resolve, 1e3));
230
500
  }
231
501
  }
502
+ }
503
+ async removeDevice(mapping) {
504
+ const instance = await this.detachFromWsl({ deviceId: mapping.udid });
505
+ if (!instance) {
506
+ throw new Error(`Instance ${mapping.instanceId} not found`);
507
+ }
508
+ if (mapping.busId) {
509
+ await this.usbipd.detachDeviceFromWsl(mapping.busId);
510
+ this.attachedDevices.delete(mapping.busId);
511
+ }
512
+ const deviceIndex = instance.deviceUdids.indexOf(mapping.udid);
513
+ if (deviceIndex > -1) {
514
+ instance.deviceUdids.splice(deviceIndex, 1);
515
+ }
516
+ this.deviceMappings.delete(mapping.udid);
517
+ return instance;
518
+ }
519
+ async onUsbmuxdInstanceEnd({
520
+ instanceId,
521
+ code,
522
+ signal
523
+ }) {
524
+ this.emit("instance-exited", {
525
+ instanceId,
526
+ code,
527
+ signal
528
+ });
529
+ for (const [_, mapping] of this.deviceMappings.entries()) {
530
+ if (mapping.instanceId === instanceId) {
531
+ this.removeDevice(mapping);
532
+ }
533
+ }
534
+ this.instances.delete(instanceId);
535
+ this.processes.get(instanceId)?.kill();
536
+ this.processes.delete(instanceId);
537
+ }
538
+ async onUsbmuxdInstanceStart({
539
+ instance,
540
+ process: process2
541
+ }) {
542
+ this.instances.set(instance.id, instance);
543
+ this.processes.set(instance.id, process2);
544
+ this.emit("instance-started", {
545
+ instanceId: instance.id
546
+ });
547
+ }
548
+ async getInstance() {
232
549
  let targetInstance = this.findInstanceWithCapacity();
233
550
  if (!targetInstance) {
234
551
  if (this.instances.size >= this.config.maxInstances) {
235
552
  throw new Error(`Maximum number of instances (${this.config.maxInstances}) reached`);
236
553
  }
237
- targetInstance = await this.createInstance();
554
+ const newInstanceId = this.nextInstanceId++;
555
+ let onExit = (code, signal) => {
556
+ this.onUsbmuxdInstanceEnd({ instanceId: newInstanceId, code, signal });
557
+ };
558
+ onExit = onExit.bind(this);
559
+ const { instance, process: process2 } = await this.wsl.createInstance({
560
+ id: newInstanceId,
561
+ basePort: this.config.basePort,
562
+ verboseLogging: this.config.verboseLogging,
563
+ usbmuxdPath: this.config.usbmuxdPath,
564
+ onLog: (message) => this.emit("instance-log", {
565
+ instanceId: newInstanceId,
566
+ level: "info",
567
+ message
568
+ }),
569
+ onExit
570
+ });
571
+ this.onUsbmuxdInstanceStart({ instance, process: process2 });
572
+ targetInstance = instance;
238
573
  }
239
- targetInstance.deviceUdids.push(device.deviceId);
574
+ return targetInstance;
575
+ }
576
+ /**
577
+ * Handle device connection only for not attached devices
578
+ * Attaches device to WSL, then assigns to an existing instance or creates a new one
579
+ */
580
+ async onDeviceConnected(device) {
581
+ const busId = await this.usbipd.findBusIdForDevice(device);
582
+ if (this.attachedDevices.has(busId)) {
583
+ logTask(`Device ${device.deviceId} is already attached. skipping onDeviceConnected...`);
584
+ return;
585
+ }
586
+ logDataObject("Device connected", { device });
587
+ await this.wsl.syncToAlpine(device.deviceId);
588
+ await this.attachToWsl({ busId, deviceId: device.deviceId });
589
+ const targetInstance = await this.getInstance();
240
590
  const mapping = {
241
591
  udid: device.deviceId,
242
592
  instanceId: targetInstance.id,
@@ -246,74 +596,52 @@ var InstanceManager = class extends EventEmitter {
246
596
  busId: busId ?? void 0
247
597
  };
248
598
  this.deviceMappings.set(device.deviceId, mapping);
599
+ targetInstance.deviceUdids.push(device.deviceId);
249
600
  this.emit("device-assigned", {
250
601
  device,
251
602
  instance: targetInstance,
252
603
  mapping
253
604
  });
605
+ this.pendingAttachDevices.delete(device.deviceId);
254
606
  }
255
- /**
256
- * Pair a device with the usbmuxd host.
257
- * This is required once per device before most commands will work.
258
- * The pairing record is stored in WSL and persists across restarts.
259
- *
260
- * @param udid Device UDID to pair
261
- * @param goIosPath Optional path to go-ios binary (defaults to "ios")
262
- * @returns true if pairing succeeded, false otherwise
263
- */
264
- async pairDevice(udid, goIosPath = "ios") {
265
- const mapping = this.deviceMappings.get(udid);
607
+ async detachFromWsl({ deviceId }) {
608
+ const mapping = this.deviceMappings.get(deviceId);
266
609
  if (!mapping) {
267
- logWarning(`Cannot pair device ${udid} - not found in mappings`);
268
- return false;
610
+ throw new Error(`Device ${deviceId} was not tracked`);
269
611
  }
270
- try {
271
- const socketAddress = `${mapping.host}:${mapping.port}`;
272
- logInfo(`Pairing device ${udid} via ${socketAddress}...`);
273
- const { stderr } = await execAsync(`"${goIosPath}" pair --udid=${udid}`, {
274
- env: { ...process.env, USBMUXD_SOCKET_ADDRESS: socketAddress }
275
- });
276
- if (stderr?.includes("error")) {
277
- logWarning(`Pairing warning for ${udid}: ${stderr}`);
278
- }
279
- logInfo(`Device ${udid} paired successfully`);
280
- this.emit("device-paired", { udid, mapping });
281
- return true;
282
- } catch (error) {
283
- logWarning(`Failed to pair device ${udid}: ${error}`);
284
- return false;
612
+ if (mapping.busId) {
613
+ await this.usbipd.detachDeviceFromWsl(mapping.busId);
614
+ this.attachedDevices.delete(mapping.busId);
285
615
  }
616
+ const instance = this.instances.get(mapping.instanceId);
617
+ if (!instance) {
618
+ logWarning3(`Instance ${mapping.instanceId} not found`);
619
+ this.deviceMappings.delete(deviceId);
620
+ throw new Error(`Instance ${mapping.instanceId} not found`);
621
+ }
622
+ return instance;
286
623
  }
287
624
  /**
288
- * Handle device disconnection
625
+ * Handle device disconnection only for attached devices
289
626
  * Detaches from WSL, removes device from instance, and stops instance if empty
290
627
  */
291
628
  async onDeviceDisconnected(device) {
292
- const mapping = this.deviceMappings.get(device.deviceId);
293
- if (!mapping) {
294
- logWarning(`Device ${device.deviceId} was not tracked`);
295
- return;
296
- }
297
- if (mapping.busId) {
298
- await this.detachDeviceFromWsl(mapping.busId);
299
- this.attachedDevices.delete(mapping.busId);
300
- }
301
- const instance = this.instances.get(mapping.instanceId);
302
- if (!instance) {
303
- logWarning(`Instance ${mapping.instanceId} not found`);
304
- this.deviceMappings.delete(device.deviceId);
629
+ if (!this.attachedDevices.has(device.deviceId)) {
630
+ logTask(`Device ${device.deviceId} is not attached. skipping onDeviceDisconnected...`);
305
631
  return;
306
632
  }
307
- const deviceIndex = instance.deviceUdids.indexOf(device.deviceId);
308
- if (deviceIndex > -1) {
309
- instance.deviceUdids.splice(deviceIndex, 1);
633
+ logDataObject("Device disconnected", { device });
634
+ const mapping = this.deviceMappings.get(device.deviceId);
635
+ if (!mapping) {
636
+ throw new Error(`Device ${device.deviceId} was not tracked`);
310
637
  }
311
- this.deviceMappings.delete(device.deviceId);
638
+ const instance = await this.removeDevice(mapping);
312
639
  this.emit("device-removed", {
313
640
  device,
314
641
  instance
315
642
  });
316
643
  if (instance.deviceUdids.length === 0) {
644
+ logDetail2(`Instance ${instance.id} is empty, stopping...`);
317
645
  await this.stopInstance(instance.id);
318
646
  }
319
647
  }
@@ -328,104 +656,18 @@ var InstanceManager = class extends EventEmitter {
328
656
  }
329
657
  return null;
330
658
  }
331
- /**
332
- * Create a new usbmuxd instance
333
- */
334
- async createInstance() {
335
- const instanceId = this.nextInstanceId++;
336
- const port = this.config.basePort + instanceId - 1;
337
- const host = await this.detectWslIpAddress();
338
- const usbmuxdArgs = [
339
- "-f",
340
- // Foreground
341
- "-v",
342
- // Verbose (if enabled)
343
- "-S",
344
- `0.0.0.0:${port}`,
345
- // Listen on all interfaces (for Windows → WSL2)
346
- "--pidfile",
347
- "NONE"
348
- ];
349
- if (!this.config.verboseLogging) {
350
- usbmuxdArgs.splice(1, 1);
351
- }
352
- const wslArgs = [
353
- "-d",
354
- this.config.wslDistribution || "alpine-usbmuxd-build",
355
- this.config.usbmuxdPath,
356
- ...usbmuxdArgs
357
- ];
358
- const process2 = spawn("wsl", wslArgs, {
359
- stdio: ["ignore", "pipe", "pipe"],
360
- windowsHide: false
361
- // Show console for debugging
362
- });
363
- process2.stdout?.on("data", (data) => {
364
- this.emit("instance-log", {
365
- instanceId,
366
- level: "info",
367
- message: data.toString().trim()
368
- });
369
- });
370
- process2.stderr?.on("data", (data) => {
371
- this.emit("instance-log", {
372
- instanceId,
373
- level: "error",
374
- message: data.toString().trim()
375
- });
376
- });
377
- process2.on("exit", (code, signal) => {
378
- this.emit("instance-exited", {
379
- instanceId,
380
- code,
381
- signal
382
- });
383
- if (this.instances.has(instanceId)) {
384
- this.instances.delete(instanceId);
385
- this.processes.delete(instanceId);
386
- }
387
- });
388
- const pid = process2.pid;
389
- if (pid === void 0) {
390
- process2.kill("SIGKILL");
391
- throw new Error("Failed to get PID for usbmuxd instance");
392
- }
393
- const instance = {
394
- id: instanceId,
395
- host,
396
- port,
397
- pid,
398
- deviceUdids: [],
399
- startedAt: /* @__PURE__ */ new Date()
400
- };
401
- this.instances.set(instanceId, instance);
402
- this.processes.set(instanceId, process2);
403
- this.emit("instance-started", instance);
404
- await new Promise((resolve) => setTimeout(resolve, 500));
405
- return instance;
406
- }
407
659
  /**
408
660
  * Stop a specific instance
409
661
  */
410
662
  async stopInstance(instanceId) {
411
663
  const instance = this.instances.get(instanceId);
412
- const process2 = this.processes.get(instanceId);
413
- if (!instance || !process2) {
664
+ const childProcess = this.processes.get(instanceId);
665
+ if (!instance || !childProcess) {
414
666
  return;
415
667
  }
416
- process2.kill("SIGTERM");
417
- await new Promise((resolve) => {
418
- const timeout = setTimeout(() => {
419
- if (!process2.killed) {
420
- process2.kill("SIGKILL");
421
- }
422
- resolve();
423
- }, 5e3);
424
- process2.once("exit", () => {
425
- clearTimeout(timeout);
426
- resolve();
427
- });
428
- });
668
+ for (const process2 of this.processes.values()) {
669
+ process2.kill("SIGKILL");
670
+ }
429
671
  this.instances.delete(instanceId);
430
672
  this.processes.delete(instanceId);
431
673
  for (const [udid, mapping] of this.deviceMappings.entries()) {
@@ -477,7 +719,7 @@ var InstanceManager = class extends EventEmitter {
477
719
  };
478
720
 
479
721
  // src/UsbmuxdService.ts
480
- var { logInfo: logInfo2, logError } = createLoggers2("usbmuxd-instance-manager");
722
+ var { logInfo: logInfo3, logError, logDataObject: logDataObject2 } = createLoggers4("usbmuxd-service");
481
723
  var UsbmuxdService = class extends EventEmitter2 {
482
724
  manager;
483
725
  usbListener;
@@ -501,6 +743,9 @@ var UsbmuxdService = class extends EventEmitter2 {
501
743
  this.manager.on(
502
744
  "device-assigned",
503
745
  (payload) => {
746
+ logInfo3(
747
+ `Device ${payload.device.deviceId} connected to usbmuxd instance at ${payload.mapping.host}:${payload.mapping.port}`
748
+ );
504
749
  this.emit("device-assigned", payload);
505
750
  }
506
751
  );
@@ -536,17 +781,12 @@ var UsbmuxdService = class extends EventEmitter2 {
536
781
  }
537
782
  const config = this.manager.getConfig();
538
783
  const appleVid = Number.parseInt(config.appleVendorId, 16);
539
- logInfo2("Starting service...");
540
- logInfo2(`Batch size: ${config.batchSize} devices per instance`);
541
- logInfo2(`Base port: ${config.basePort}`);
542
- logInfo2(`Max instances: ${config.maxInstances}`);
543
- logInfo2(`usbmuxd path: ${config.usbmuxdPath}`);
544
- logInfo2(`Monitoring Apple devices (VID: ${config.appleVendorId})`);
784
+ logDataObject2("Starting service...", { config });
545
785
  this.usbListener.onDeviceAdd(async (device) => {
546
786
  if (device.vid !== appleVid) {
547
787
  return;
548
788
  }
549
- logInfo2(`Apple device connected: ${device.deviceId} (${device.deviceName || "Unknown"})`);
789
+ logInfo3(`Apple device connected: ${device.deviceId} (${device.deviceName || "Unknown"})`);
550
790
  try {
551
791
  await this.manager.onDeviceConnected(device);
552
792
  } catch (error) {
@@ -557,7 +797,7 @@ var UsbmuxdService = class extends EventEmitter2 {
557
797
  if (device.vid !== appleVid) {
558
798
  return;
559
799
  }
560
- logInfo2(`Apple device disconnected: ${device.deviceId}`);
800
+ logInfo3(`Apple device disconnected: ${device.deviceId}`);
561
801
  try {
562
802
  await this.manager.onDeviceDisconnected(device);
563
803
  } catch (error) {
@@ -571,8 +811,8 @@ var UsbmuxdService = class extends EventEmitter2 {
571
811
  });
572
812
  this.isListening = true;
573
813
  this.manager.start();
574
- logInfo2("Service started successfully");
575
- logInfo2("Waiting for iOS devices...");
814
+ logInfo3("Service started successfully");
815
+ logInfo3("Waiting for iOS devices...");
576
816
  } catch (error) {
577
817
  logError("Failed to start USB listener:", error);
578
818
  throw error;
@@ -586,11 +826,11 @@ var UsbmuxdService = class extends EventEmitter2 {
586
826
  if (!this.isListening) {
587
827
  return;
588
828
  }
589
- logInfo2("Stopping service...");
829
+ logInfo3("Stopping service...");
590
830
  this.usbListener.stopListening();
591
831
  this.isListening = false;
592
832
  await this.manager.stop();
593
- logInfo2("Service stopped");
833
+ logInfo3("Service stopped");
594
834
  }
595
835
  /**
596
836
  * Get device port mapping
@@ -622,18 +862,6 @@ var UsbmuxdService = class extends EventEmitter2 {
622
862
  getConfig() {
623
863
  return this.manager.getConfig();
624
864
  }
625
- /**
626
- * Pair a device with the usbmuxd host.
627
- * This is required once per device before most commands will work.
628
- * The pairing record is stored in WSL and persists across restarts.
629
- *
630
- * @param udid Device UDID to pair
631
- * @param goIosPath Optional path to go-ios binary
632
- * @returns true if pairing succeeded, false otherwise
633
- */
634
- async pairDevice(udid, goIosPath) {
635
- return this.manager.pairDevice(udid, goIosPath);
636
- }
637
865
  };
638
866
  export {
639
867
  InstanceManager,