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