@mcesystems/usbmuxd-instance-manager 1.0.72 → 1.0.73

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/README.md CHANGED
@@ -108,6 +108,19 @@ await service.stop();
108
108
  | `wslDistribution` | string | `usbmuxd-alpine` | Alpine WSL2 distribution name |
109
109
  | `verboseLogging` | boolean | true | Enable verbose output |
110
110
  | `appleVendorId` | string | '05AC' | Apple vendor ID (hex) |
111
+ | `lockdownWindowsPath` | string | `C:\ProgramData\mce\lockdown` | Windows folder for lockdown (pairing) files; synced to/from Alpine |
112
+ | `lockdownSyncEnabled` | boolean | true | Sync lockdown files so pairing can be skipped |
113
+
114
+ ### Lockdown sync
115
+
116
+ When enabled, the manager syncs lockdown (pairing) files between Windows and Alpine so devices that were already paired on Windows can be used without pairing again in WSL:
117
+
118
+ - **To Alpine**: When a device is assigned, that device’s plist (`{UDID}.plist`) is copied from `lockdownWindowsPath` to Alpine `/var/lib/lockdown`. Only the file for that device is copied.
119
+ - **From Alpine**: When the service stops, plists for all devices that were assigned this session (plus `SystemConfiguration.plist` if present) are copied from Alpine back to `lockdownWindowsPath` so they are available for the next run.
120
+
121
+ **Alpine permissions**: `/var/lib/lockdown` is often root-owned. The sync tries a normal copy first; if that fails (e.g. permission denied), it retries with `sudo cp`. Ensure the WSL user has passwordless sudo for these commands, or that the Alpine image has `/var/lib/lockdown` writable by the default user. If both fail, a warning is logged and device assignment continues (pairing can still be done manually).
122
+
123
+ Set `lockdownSyncEnabled: false` or leave `lockdownWindowsPath` empty to disable lockdown sync.
111
124
 
112
125
  ## CLI Options
113
126
 
@@ -118,7 +131,8 @@ Options:
118
131
  --batchSize <n> Number of devices per instance (default: 4)
119
132
  --basePort <port> Base TCP port for first instance (default: 27015)
120
133
  --maxInstances <n> Maximum number of instances (default: 20)
121
- --usbmuxdPath <path> Path to usbmuxd executable
134
+ --usbmuxdPath <path> Path to usbmuxd executable (WSL: default usbmuxd)
135
+ --wslDistribution <n> WSL distribution name (default: alpine-usbmuxd-build)
122
136
  --verbose <true|false> Enable verbose logging (default: true)
123
137
  --appleVid <vid> Apple Vendor ID in hex (default: 05AC)
124
138
  -h, --help Show this help message
@@ -162,6 +176,65 @@ When all devices disconnect from Instance 2:
162
176
  - Instance 2 automatically terminates
163
177
  - Frees resources
164
178
 
179
+ ## Flow: Working with usbipd and WSL
180
+
181
+ On Windows, the same physical USB device can be used by **either** Windows (e.g. iTunes, Apple Mobile Device) **or** WSL (usbmuxd), not both. usbipd-win is what moves the device between the two.
182
+
183
+ ### Device ownership
184
+
185
+ | State | Who has the device | iTunes / Windows | usbmuxd (WSL) |
186
+ |-------|--------------------|------------------|---------------|
187
+ | Not shared | Windows | ✓ | ✗ |
188
+ | Bound + attached to WSL | WSL | ✗ | ✓ |
189
+ | Bound but detached | Neither (held by usbipd) | ✗ | ✗ |
190
+
191
+ ### Giving the device to usbmuxd (instance manager)
192
+
193
+ 1. **WSL** must be running (e.g. the manager starts it, or run `wsl -d alpine-usbmuxd-build -- echo ok`).
194
+ 2. **Bind** the device (take it from Windows):
195
+ `usbipd bind --busid <BUSID> --force`
196
+ 3. **Attach** to WSL:
197
+ `usbipd attach --wsl=<distro> --busid=<BUSID>`
198
+
199
+ The instance manager does steps 2–3 automatically when it sees an iOS device. You can also use the script:
200
+
201
+ ```powershell
202
+ # From packages/usbmuxd-instance-manager
203
+ .\scripts\attach-device.ps1
204
+ ```
205
+
206
+ ### Giving the device back to Windows (e.g. for iTunes)
207
+
208
+ 1. **Detach** from WSL (stops forwarding to Linux):
209
+ `usbipd detach --busid <BUSID>`
210
+ Or detach all:
211
+ `usbipd detach -a`
212
+ 2. **Unbind** so Windows gets the device back:
213
+ `usbipd unbind --busid <BUSID>`
214
+
215
+ Until you unbind, the device stays “Shared (forced)” and Windows/iTunes will not see it.
216
+
217
+ ### Useful usbipd commands
218
+
219
+ ```powershell
220
+ $usbipd = "C:\Program Files\usbipd-win\usbipd.exe"
221
+
222
+ # List devices and their state (Shared / Not shared, Attached / Not attached)
223
+ & $usbipd list
224
+
225
+ # Detach from WSL (device still bound to usbipd)
226
+ & $usbipd detach --busid 2-5
227
+ & $usbipd detach -a
228
+
229
+ # Unbind so Windows can use the device again (e.g. iTunes)
230
+ & $usbipd unbind --busid 2-5
231
+ ```
232
+
233
+ ### Quick reference
234
+
235
+ - **Use device with usbmuxd-instance-manager** → bind + attach (manager or script does this when you connect a device).
236
+ - **Use device with iTunes on Windows** → detach then unbind for that bus ID; then iTunes will see it.
237
+
165
238
  ## Requirements
166
239
 
167
240
  ### Runtime
package/dist/cli.js CHANGED
@@ -24,20 +24,104 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
- var import_tool_debug_g43 = require("@mcesystems/tool-debug-g4");
27
+ var import_tool_debug_g44 = require("@mcesystems/tool-debug-g4");
28
28
 
29
29
  // src/UsbmuxdService.ts
30
30
  var import_node_events2 = require("node:events");
31
- var import_tool_debug_g42 = require("@mcesystems/tool-debug-g4");
31
+ var import_tool_debug_g43 = require("@mcesystems/tool-debug-g4");
32
32
  var import_usb_device_listener = __toESM(require("@mcesystems/usb-device-listener"));
33
33
 
34
34
  // src/InstanceManager.ts
35
- var import_node_child_process = require("node:child_process");
35
+ var import_node_child_process2 = require("node:child_process");
36
36
  var import_node_events = require("node:events");
37
+ var import_node_util2 = require("node:util");
38
+ var import_tool_debug_g42 = require("@mcesystems/tool-debug-g4");
39
+
40
+ // src/LockdownSync.ts
41
+ var import_node_child_process = require("node:child_process");
42
+ var import_node_fs = require("node:fs");
43
+ var import_node_path = require("node:path");
37
44
  var import_node_util = require("node:util");
38
45
  var import_tool_debug_g4 = require("@mcesystems/tool-debug-g4");
39
46
  var { logInfo, logWarning } = (0, import_tool_debug_g4.createLoggers)("usbmuxd-instance-manager");
40
47
  var execAsync = (0, import_node_util.promisify)(import_node_child_process.exec);
48
+ var ALPINE_LOCKDOWN_DIR = "/var/lib/lockdown";
49
+ var SYSTEM_CONFIG_PLIST = "SystemConfiguration.plist";
50
+ function windowsPathToWsl(windowsPath) {
51
+ const normalized = windowsPath.replace(/\\/g, "/").trim();
52
+ const driveMatch = normalized.match(/^([a-zA-Z]):\/?(.*)$/);
53
+ if (driveMatch) {
54
+ const drive = driveMatch[1].toLowerCase();
55
+ const rest = driveMatch[2] || "";
56
+ return `/mnt/${drive}${rest ? `/${rest}` : ""}`;
57
+ }
58
+ return normalized;
59
+ }
60
+ async function syncToAlpine(udid, config2) {
61
+ if (!config2.lockdownSyncEnabled || !config2.lockdownWindowsPath?.trim()) {
62
+ return;
63
+ }
64
+ const windowsDir = config2.lockdownWindowsPath.trim();
65
+ const windowsFile = (0, import_node_path.join)(windowsDir, `${udid}.plist`);
66
+ if (!(0, import_node_fs.existsSync)(windowsFile)) {
67
+ return;
68
+ }
69
+ const distro = config2.wslDistribution || "alpine-usbmuxd-build";
70
+ const wslSource = windowsPathToWsl(windowsFile);
71
+ const wslDestDir = ALPINE_LOCKDOWN_DIR;
72
+ try {
73
+ await execAsync(
74
+ `wsl -d ${distro} -- sh -c "mkdir -p ${wslDestDir} && cp '${wslSource}' '${wslDestDir}/'"`
75
+ );
76
+ logInfo(`Lockdown sync: copied ${udid}.plist to Alpine`);
77
+ } catch (error) {
78
+ try {
79
+ await execAsync(
80
+ `wsl -d ${distro} -- sh -c "mkdir -p ${wslDestDir} && sudo cp '${wslSource}' '${wslDestDir}/'"`
81
+ );
82
+ logInfo(`Lockdown sync: copied ${udid}.plist to Alpine (via sudo)`);
83
+ } catch (sudoError) {
84
+ logWarning(
85
+ `Lockdown sync to Alpine failed for ${udid}: ${error}. Sudo fallback failed: ${sudoError}. Ensure /var/lib/lockdown is writable or use passwordless sudo.`
86
+ );
87
+ }
88
+ }
89
+ }
90
+ async function syncFromAlpine(udids, config2) {
91
+ if (!config2.lockdownSyncEnabled || !config2.lockdownWindowsPath?.trim()) {
92
+ return;
93
+ }
94
+ const windowsDir = config2.lockdownWindowsPath.trim();
95
+ try {
96
+ (0, import_node_fs.mkdirSync)(windowsDir, { recursive: true });
97
+ } catch (error) {
98
+ logWarning(`Lockdown sync: could not create Windows dir ${windowsDir}: ${error}`);
99
+ return;
100
+ }
101
+ const distro = config2.wslDistribution || "alpine-usbmuxd-build";
102
+ const wslDestDir = windowsPathToWsl(windowsDir);
103
+ for (const udid of udids) {
104
+ const plist = `${udid}.plist`;
105
+ try {
106
+ await execAsync(
107
+ `wsl -d ${distro} -- sh -c "test -f '${ALPINE_LOCKDOWN_DIR}/${plist}' && cp '${ALPINE_LOCKDOWN_DIR}/${plist}' '${wslDestDir}/'"`
108
+ );
109
+ logInfo(`Lockdown sync: copied ${plist} from Alpine to Windows`);
110
+ } catch {
111
+ }
112
+ }
113
+ try {
114
+ await execAsync(
115
+ `wsl -d ${distro} -- sh -c "test -f '${ALPINE_LOCKDOWN_DIR}/${SYSTEM_CONFIG_PLIST}' && cp '${ALPINE_LOCKDOWN_DIR}/${SYSTEM_CONFIG_PLIST}' '${wslDestDir}/'"`
116
+ );
117
+ logInfo(`Lockdown sync: copied ${SYSTEM_CONFIG_PLIST} from Alpine to Windows`);
118
+ } catch {
119
+ }
120
+ }
121
+
122
+ // src/InstanceManager.ts
123
+ var { logInfo: logInfo2, logWarning: logWarning2 } = (0, import_tool_debug_g42.createLoggers)("usbmuxd-instance-manager");
124
+ var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.exec);
41
125
  var USBIPD_PATH = '"C:\\Program Files\\usbipd-win\\usbipd.exe"';
42
126
  function parseUsbipdList(output) {
43
127
  const devices = [];
@@ -69,7 +153,9 @@ var DEFAULT_CONFIG = {
69
153
  wslDistribution: "alpine-usbmuxd-build",
70
154
  // Alpine WSL2 distribution name
71
155
  verboseLogging: true,
72
- appleVendorId: "05AC"
156
+ appleVendorId: "05AC",
157
+ lockdownWindowsPath: "C:\\ProgramData\\mce\\lockdown",
158
+ lockdownSyncEnabled: true
73
159
  };
74
160
  var InstanceManager = class extends import_node_events.EventEmitter {
75
161
  config;
@@ -81,6 +167,8 @@ var InstanceManager = class extends import_node_events.EventEmitter {
81
167
  isRunning = false;
82
168
  /** Tracks which devices have been attached to WSL */
83
169
  attachedDevices = /* @__PURE__ */ new Set();
170
+ /** Device IDs currently in the attach flow (ignore disconnect until attach completes) */
171
+ pendingAttachDevices = /* @__PURE__ */ new Set();
84
172
  /** Cached WSL IP address for connecting from Windows */
85
173
  wslIpAddress = null;
86
174
  constructor(config2 = {}) {
@@ -97,28 +185,28 @@ var InstanceManager = class extends import_node_events.EventEmitter {
97
185
  }
98
186
  const distro = this.config.wslDistribution || "alpine-usbmuxd-build";
99
187
  try {
100
- const { stdout } = await execAsync(`wsl -d ${distro} -- ip -4 addr show eth0`);
188
+ const { stdout } = await execAsync2(`wsl -d ${distro} -- ip -4 addr show eth0`);
101
189
  const match = stdout.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
102
190
  if (match) {
103
191
  this.wslIpAddress = match[1];
104
- logInfo(`Detected WSL IP address: ${this.wslIpAddress}`);
192
+ logInfo2(`Detected WSL IP address: ${this.wslIpAddress}`);
105
193
  return this.wslIpAddress;
106
194
  }
107
195
  } catch (error) {
108
- logWarning(`Failed to detect WSL IP via ip addr: ${error}`);
196
+ logWarning2(`Failed to detect WSL IP via ip addr: ${error}`);
109
197
  }
110
198
  try {
111
- const { stdout } = await execAsync(`wsl -d ${distro} -- hostname -I`);
199
+ const { stdout } = await execAsync2(`wsl -d ${distro} -- hostname -I`);
112
200
  const ip = stdout.trim().split(/\s+/)[0];
113
201
  if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
114
202
  this.wslIpAddress = ip;
115
- logInfo(`Detected WSL IP address (hostname): ${this.wslIpAddress}`);
203
+ logInfo2(`Detected WSL IP address (hostname): ${this.wslIpAddress}`);
116
204
  return this.wslIpAddress;
117
205
  }
118
206
  } catch (error) {
119
- logWarning(`Failed to detect WSL IP via hostname: ${error}`);
207
+ logWarning2(`Failed to detect WSL IP via hostname: ${error}`);
120
208
  }
121
- logWarning("Could not detect WSL IP, falling back to localhost");
209
+ logWarning2("Could not detect WSL IP, falling back to localhost");
122
210
  this.wslIpAddress = "127.0.0.1";
123
211
  return this.wslIpAddress;
124
212
  }
@@ -141,6 +229,8 @@ var InstanceManager = class extends import_node_events.EventEmitter {
141
229
  return;
142
230
  }
143
231
  this.isRunning = false;
232
+ const udids = Array.from(this.deviceMappings.keys());
233
+ await syncFromAlpine(udids, this.config);
144
234
  const detachPromises = Array.from(this.attachedDevices).map(
145
235
  (busId) => this.detachDeviceFromWsl(busId)
146
236
  );
@@ -158,28 +248,28 @@ var InstanceManager = class extends import_node_events.EventEmitter {
158
248
  */
159
249
  async findBusIdForDevice(device) {
160
250
  try {
161
- const { stdout } = await execAsync(`${USBIPD_PATH} list`);
251
+ const { stdout } = await execAsync2(`${USBIPD_PATH} list`);
162
252
  const usbipdDevices = parseUsbipdList(stdout);
163
253
  const deviceVid = device.vid.toString(16).toUpperCase().padStart(4, "0");
164
254
  const devicePid = device.pid.toString(16).toUpperCase().padStart(4, "0");
165
- logInfo(`Looking for device with VID:PID ${deviceVid}:${devicePid}`);
166
- logInfo(`Found ${usbipdDevices.length} devices from usbipd list`);
255
+ logInfo2(`Looking for device with VID:PID ${deviceVid}:${devicePid}`);
256
+ logInfo2(`Found ${usbipdDevices.length} devices from usbipd list`);
167
257
  const match = usbipdDevices.find((d) => d.vid === deviceVid && d.pid === devicePid);
168
258
  if (match) {
169
- logInfo(
259
+ logInfo2(
170
260
  `Found usbipd bus ID ${match.busId} for device ${device.deviceId} (${deviceVid}:${devicePid})`
171
261
  );
172
262
  return match.busId;
173
263
  }
174
264
  for (const d of usbipdDevices) {
175
- logInfo(` usbipd device: ${d.busId} ${d.vid}:${d.pid} "${d.description}"`);
265
+ logInfo2(` usbipd device: ${d.busId} ${d.vid}:${d.pid} "${d.description}"`);
176
266
  }
177
- logWarning(
267
+ logWarning2(
178
268
  `Could not find usbipd bus ID for device ${device.deviceId} (${deviceVid}:${devicePid})`
179
269
  );
180
270
  return null;
181
271
  } catch (error) {
182
- logWarning(`Failed to run usbipd list: ${error}`);
272
+ logWarning2(`Failed to run usbipd list: ${error}`);
183
273
  return null;
184
274
  }
185
275
  }
@@ -190,15 +280,15 @@ var InstanceManager = class extends import_node_events.EventEmitter {
190
280
  const distro = this.config.wslDistribution;
191
281
  try {
192
282
  if (distro) {
193
- logInfo(`Starting WSL distribution: ${distro}...`);
194
- await execAsync(`wsl -d ${distro} -- echo "WSL started"`);
283
+ logInfo2(`Starting WSL distribution: ${distro}...`);
284
+ await execAsync2(`wsl -d ${distro} -- echo "WSL started"`);
195
285
  } else {
196
- logInfo("Starting default WSL distribution...");
197
- await execAsync(`wsl -- echo "WSL started"`);
286
+ logInfo2("Starting default WSL distribution...");
287
+ await execAsync2(`wsl -- echo "WSL started"`);
198
288
  }
199
289
  return true;
200
290
  } catch (error) {
201
- logWarning(`Failed to start WSL: ${error}`);
291
+ logWarning2(`Failed to start WSL: ${error}`);
202
292
  return false;
203
293
  }
204
294
  }
@@ -210,19 +300,24 @@ var InstanceManager = class extends import_node_events.EventEmitter {
210
300
  const distro = this.config.wslDistribution;
211
301
  try {
212
302
  await this.ensureWslRunning();
213
- logInfo(`Binding device ${busId}...`);
214
- await execAsync(`${USBIPD_PATH} bind --busid ${busId} --force`);
303
+ logInfo2(`Binding device ${busId}...`);
304
+ await execAsync2(`${USBIPD_PATH} bind --busid ${busId} --force`);
215
305
  if (distro) {
216
- logInfo(`Attaching device ${busId} to WSL distribution ${distro}...`);
217
- await execAsync(`${USBIPD_PATH} attach --wsl=${distro} --busid=${busId}`);
306
+ logInfo2(`Attaching device ${busId} to WSL distribution ${distro}...`);
307
+ await execAsync2(`${USBIPD_PATH} attach --wsl=${distro} --busid=${busId}`);
218
308
  } else {
219
- logInfo(`Attaching device ${busId} to default WSL...`);
220
- await execAsync(`${USBIPD_PATH} attach --wsl --busid=${busId}`);
309
+ logInfo2(`Attaching device ${busId} to default WSL...`);
310
+ await execAsync2(`${USBIPD_PATH} attach --wsl --busid=${busId}`);
221
311
  }
222
- logInfo(`Device ${busId} attached to WSL successfully`);
312
+ logInfo2(`Device ${busId} attached to WSL successfully`);
223
313
  return true;
224
314
  } catch (error) {
225
- logWarning(`Failed to attach device ${busId} to WSL: ${error}`);
315
+ const message = error instanceof Error ? error.message : String(error);
316
+ if (message.includes("already attached to a client")) {
317
+ logInfo2(`Device ${busId} is already attached to WSL, continuing`);
318
+ return true;
319
+ }
320
+ logWarning2(`Failed to attach device ${busId} to WSL: ${error}`);
226
321
  return false;
227
322
  }
228
323
  }
@@ -231,10 +326,15 @@ var InstanceManager = class extends import_node_events.EventEmitter {
231
326
  */
232
327
  async detachDeviceFromWsl(busId) {
233
328
  try {
234
- await execAsync(`${USBIPD_PATH} detach --busid=${busId}`);
235
- logInfo(`Device ${busId} detached from WSL`);
329
+ await execAsync2(`${USBIPD_PATH} detach --busid=${busId}`);
330
+ logInfo2(`Device ${busId} detached from WSL`);
236
331
  } catch (error) {
237
- logWarning(`Failed to detach device ${busId}: ${error}`);
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ if (message.includes("no device with busid") || message.includes("There is no device")) {
334
+ logInfo2(`Device ${busId} already detached`);
335
+ } else {
336
+ logWarning2(`Failed to detach device ${busId}: ${error}`);
337
+ }
238
338
  }
239
339
  }
240
340
  /**
@@ -243,18 +343,24 @@ var InstanceManager = class extends import_node_events.EventEmitter {
243
343
  */
244
344
  async onDeviceConnected(device) {
245
345
  if (this.deviceMappings.has(device.deviceId)) {
246
- logWarning(`Device ${device.deviceId} is already connected`);
346
+ logWarning2(`Device ${device.deviceId} is already connected`);
247
347
  return;
248
348
  }
349
+ await syncToAlpine(device.deviceId, this.config);
249
350
  const busId = await this.findBusIdForDevice(device);
250
351
  if (!busId) {
251
- logWarning(`Cannot attach device ${device.deviceId} - bus ID not found`);
352
+ logWarning2(`Cannot attach device ${device.deviceId} - bus ID not found`);
252
353
  }
253
354
  if (busId && !this.attachedDevices.has(busId)) {
254
- const attached = await this.attachDeviceToWsl(busId);
255
- if (attached) {
256
- this.attachedDevices.add(busId);
257
- await new Promise((resolve) => setTimeout(resolve, 1e3));
355
+ this.pendingAttachDevices.add(device.deviceId);
356
+ try {
357
+ const attached = await this.attachDeviceToWsl(busId);
358
+ if (attached) {
359
+ this.attachedDevices.add(busId);
360
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
361
+ }
362
+ } finally {
363
+ this.pendingAttachDevices.delete(device.deviceId);
258
364
  }
259
365
  }
260
366
  let targetInstance = this.findInstanceWithCapacity();
@@ -292,23 +398,23 @@ var InstanceManager = class extends import_node_events.EventEmitter {
292
398
  async pairDevice(udid, goIosPath = "ios") {
293
399
  const mapping = this.deviceMappings.get(udid);
294
400
  if (!mapping) {
295
- logWarning(`Cannot pair device ${udid} - not found in mappings`);
401
+ logWarning2(`Cannot pair device ${udid} - not found in mappings`);
296
402
  return false;
297
403
  }
298
404
  try {
299
405
  const socketAddress = `${mapping.host}:${mapping.port}`;
300
- logInfo(`Pairing device ${udid} via ${socketAddress}...`);
301
- const { stderr } = await execAsync(`"${goIosPath}" pair --udid=${udid}`, {
406
+ logInfo2(`Pairing device ${udid} via ${socketAddress}...`);
407
+ const { stderr } = await execAsync2(`"${goIosPath}" pair --udid=${udid}`, {
302
408
  env: { ...process.env, USBMUXD_SOCKET_ADDRESS: socketAddress }
303
409
  });
304
410
  if (stderr?.includes("error")) {
305
- logWarning(`Pairing warning for ${udid}: ${stderr}`);
411
+ logWarning2(`Pairing warning for ${udid}: ${stderr}`);
306
412
  }
307
- logInfo(`Device ${udid} paired successfully`);
413
+ logInfo2(`Device ${udid} paired successfully`);
308
414
  this.emit("device-paired", { udid, mapping });
309
415
  return true;
310
416
  } catch (error) {
311
- logWarning(`Failed to pair device ${udid}: ${error}`);
417
+ logWarning2(`Failed to pair device ${udid}: ${error}`);
312
418
  return false;
313
419
  }
314
420
  }
@@ -317,9 +423,12 @@ var InstanceManager = class extends import_node_events.EventEmitter {
317
423
  * Detaches from WSL, removes device from instance, and stops instance if empty
318
424
  */
319
425
  async onDeviceDisconnected(device) {
426
+ if (this.pendingAttachDevices.has(device.deviceId)) {
427
+ return;
428
+ }
320
429
  const mapping = this.deviceMappings.get(device.deviceId);
321
430
  if (!mapping) {
322
- logWarning(`Device ${device.deviceId} was not tracked`);
431
+ logWarning2(`Device ${device.deviceId} was not tracked`);
323
432
  return;
324
433
  }
325
434
  if (mapping.busId) {
@@ -328,7 +437,7 @@ var InstanceManager = class extends import_node_events.EventEmitter {
328
437
  }
329
438
  const instance = this.instances.get(mapping.instanceId);
330
439
  if (!instance) {
331
- logWarning(`Instance ${mapping.instanceId} not found`);
440
+ logWarning2(`Instance ${mapping.instanceId} not found`);
332
441
  this.deviceMappings.delete(device.deviceId);
333
442
  return;
334
443
  }
@@ -383,7 +492,7 @@ var InstanceManager = class extends import_node_events.EventEmitter {
383
492
  this.config.usbmuxdPath,
384
493
  ...usbmuxdArgs
385
494
  ];
386
- const process2 = (0, import_node_child_process.spawn)("wsl", wslArgs, {
495
+ const process2 = (0, import_node_child_process2.spawn)("wsl", wslArgs, {
387
496
  stdio: ["ignore", "pipe", "pipe"],
388
497
  windowsHide: false
389
498
  // Show console for debugging
@@ -505,7 +614,7 @@ var InstanceManager = class extends import_node_events.EventEmitter {
505
614
  };
506
615
 
507
616
  // src/UsbmuxdService.ts
508
- var { logInfo: logInfo2, logError } = (0, import_tool_debug_g42.createLoggers)("usbmuxd-instance-manager");
617
+ var { logInfo: logInfo3, logError } = (0, import_tool_debug_g43.createLoggers)("usbmuxd-instance-manager");
509
618
  var UsbmuxdService = class extends import_node_events2.EventEmitter {
510
619
  manager;
511
620
  usbListener;
@@ -564,17 +673,17 @@ var UsbmuxdService = class extends import_node_events2.EventEmitter {
564
673
  }
565
674
  const config2 = this.manager.getConfig();
566
675
  const appleVid = Number.parseInt(config2.appleVendorId, 16);
567
- logInfo2("Starting service...");
568
- logInfo2(`Batch size: ${config2.batchSize} devices per instance`);
569
- logInfo2(`Base port: ${config2.basePort}`);
570
- logInfo2(`Max instances: ${config2.maxInstances}`);
571
- logInfo2(`usbmuxd path: ${config2.usbmuxdPath}`);
572
- logInfo2(`Monitoring Apple devices (VID: ${config2.appleVendorId})`);
676
+ logInfo3("Starting service...");
677
+ logInfo3(`Batch size: ${config2.batchSize} devices per instance`);
678
+ logInfo3(`Base port: ${config2.basePort}`);
679
+ logInfo3(`Max instances: ${config2.maxInstances}`);
680
+ logInfo3(`usbmuxd path: ${config2.usbmuxdPath}`);
681
+ logInfo3(`Monitoring Apple devices (VID: ${config2.appleVendorId})`);
573
682
  this.usbListener.onDeviceAdd(async (device) => {
574
683
  if (device.vid !== appleVid) {
575
684
  return;
576
685
  }
577
- logInfo2(`Apple device connected: ${device.deviceId} (${device.deviceName || "Unknown"})`);
686
+ logInfo3(`Apple device connected: ${device.deviceId} (${device.deviceName || "Unknown"})`);
578
687
  try {
579
688
  await this.manager.onDeviceConnected(device);
580
689
  } catch (error) {
@@ -585,7 +694,7 @@ var UsbmuxdService = class extends import_node_events2.EventEmitter {
585
694
  if (device.vid !== appleVid) {
586
695
  return;
587
696
  }
588
- logInfo2(`Apple device disconnected: ${device.deviceId}`);
697
+ logInfo3(`Apple device disconnected: ${device.deviceId}`);
589
698
  try {
590
699
  await this.manager.onDeviceDisconnected(device);
591
700
  } catch (error) {
@@ -599,8 +708,8 @@ var UsbmuxdService = class extends import_node_events2.EventEmitter {
599
708
  });
600
709
  this.isListening = true;
601
710
  this.manager.start();
602
- logInfo2("Service started successfully");
603
- logInfo2("Waiting for iOS devices...");
711
+ logInfo3("Service started successfully");
712
+ logInfo3("Waiting for iOS devices...");
604
713
  } catch (error) {
605
714
  logError("Failed to start USB listener:", error);
606
715
  throw error;
@@ -614,11 +723,11 @@ var UsbmuxdService = class extends import_node_events2.EventEmitter {
614
723
  if (!this.isListening) {
615
724
  return;
616
725
  }
617
- logInfo2("Stopping service...");
726
+ logInfo3("Stopping service...");
618
727
  this.usbListener.stopListening();
619
728
  this.isListening = false;
620
729
  await this.manager.stop();
621
- logInfo2("Service stopped");
730
+ logInfo3("Service stopped");
622
731
  }
623
732
  /**
624
733
  * Get device port mapping
@@ -665,7 +774,7 @@ var UsbmuxdService = class extends import_node_events2.EventEmitter {
665
774
  };
666
775
 
667
776
  // src/cli.ts
668
- var { logInfo: logInfo3, logError: logError2, logHeader, logDataObject } = (0, import_tool_debug_g43.createLoggers)("usbmuxd-cli");
777
+ var { logInfo: logInfo4, logError: logError2, logHeader, logDataObject } = (0, import_tool_debug_g44.createLoggers)("usbmuxd-cli");
669
778
  var args = process.argv.slice(2);
670
779
  var options = {};
671
780
  for (let i = 0; i < args.length; i++) {
@@ -682,7 +791,7 @@ var config = {
682
791
  batchSize: options.batchSize ? Number.parseInt(options.batchSize, 10) : 4,
683
792
  basePort: options.basePort ? Number.parseInt(options.basePort, 10) : 27015,
684
793
  maxInstances: options.maxInstances ? Number.parseInt(options.maxInstances, 10) : 20,
685
- usbmuxdPath: options.usbmuxdPath || "C:\\Program Files\\usbmuxd-win-mce\\usbmuxd.exe",
794
+ usbmuxdPath: options.usbmuxdPath || "usbmuxd",
686
795
  verboseLogging: options.verbose !== "false",
687
796
  appleVendorId: options.appleVid || "05AC"
688
797
  };
@@ -690,11 +799,11 @@ logHeader("usbmuxd Instance Manager");
690
799
  logDataObject("Multi-instance manager for iOS device connections", { config });
691
800
  var service = new UsbmuxdService(config);
692
801
  process.on("SIGINT", async () => {
693
- logInfo3("");
694
- logInfo3("Received SIGINT, shutting down gracefully...");
802
+ logInfo4("");
803
+ logInfo4("Received SIGINT, shutting down gracefully...");
695
804
  try {
696
805
  await service.stop();
697
- logInfo3("Shutdown complete");
806
+ logInfo4("Shutdown complete");
698
807
  process.exit(0);
699
808
  } catch (error) {
700
809
  logError2("Error during shutdown:", error);
@@ -702,11 +811,11 @@ process.on("SIGINT", async () => {
702
811
  }
703
812
  });
704
813
  process.on("SIGTERM", async () => {
705
- logInfo3("");
706
- logInfo3("Received SIGTERM, shutting down gracefully...");
814
+ logInfo4("");
815
+ logInfo4("Received SIGTERM, shutting down gracefully...");
707
816
  try {
708
817
  await service.stop();
709
- logInfo3("Shutdown complete");
818
+ logInfo4("Shutdown complete");
710
819
  process.exit(0);
711
820
  } catch (error) {
712
821
  logError2("Error during shutdown:", error);
@@ -724,21 +833,21 @@ setInterval(() => {
724
833
  }
725
834
  }, 3e4);
726
835
  if (args.includes("--help") || args.includes("-h")) {
727
- logInfo3("Usage: usbmuxd-instance-manager [OPTIONS]");
728
- logInfo3("");
729
- logInfo3("Options:");
730
- logInfo3(" --batchSize <n> Number of devices per instance (default: 4)");
731
- logInfo3(" --basePort <port> Base TCP port for first instance (default: 27015)");
732
- logInfo3(" --maxInstances <n> Maximum number of instances (default: 20)");
733
- logInfo3(" --usbmuxdPath <path> Path to usbmuxd executable");
734
- logInfo3(" --verbose <true|false> Enable verbose logging (default: true)");
735
- logInfo3(" --appleVid <vid> Apple Vendor ID in hex (default: 05AC)");
736
- logInfo3(" -h, --help Show this help message");
737
- logInfo3("");
738
- logInfo3("Examples:");
739
- logInfo3(" usbmuxd-instance-manager");
740
- logInfo3(" usbmuxd-instance-manager --batchSize 6 --basePort 27020");
741
- logInfo3("");
836
+ logInfo4("Usage: usbmuxd-instance-manager [OPTIONS]");
837
+ logInfo4("");
838
+ logInfo4("Options:");
839
+ logInfo4(" --batchSize <n> Number of devices per instance (default: 4)");
840
+ logInfo4(" --basePort <port> Base TCP port for first instance (default: 27015)");
841
+ logInfo4(" --maxInstances <n> Maximum number of instances (default: 20)");
842
+ logInfo4(" --usbmuxdPath <path> Path to usbmuxd executable");
843
+ logInfo4(" --verbose <true|false> Enable verbose logging (default: true)");
844
+ logInfo4(" --appleVid <vid> Apple Vendor ID in hex (default: 05AC)");
845
+ logInfo4(" -h, --help Show this help message");
846
+ logInfo4("");
847
+ logInfo4("Examples:");
848
+ logInfo4(" usbmuxd-instance-manager");
849
+ logInfo4(" usbmuxd-instance-manager --batchSize 6 --basePort 27020");
850
+ logInfo4("");
742
851
  process.exit(0);
743
852
  }
744
853
  try {