@ebowwa/ios-devices 1.0.0

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/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@ebowwa/ios-devices",
3
+ "version": "1.0.0",
4
+ "description": "iOS device control library wrapping xcrun devicectl, libimobiledevice, and simctl",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "bun build ./src/index.ts --outdir ./dist --target node --sourcemap --external shell-quote",
20
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
21
+ "typecheck": "tsc --noEmit",
22
+ "clean": "rm -rf dist",
23
+ "prepublishOnly": "bun run build && bun run build:types"
24
+ },
25
+ "keywords": [
26
+ "ios",
27
+ "iphone",
28
+ "ipad",
29
+ "device",
30
+ "devicectl",
31
+ "simctl",
32
+ "libimobiledevice",
33
+ "xcode"
34
+ ],
35
+ "author": "ebowwa",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/ebowwa/codespaces"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "^5.7.0"
44
+ },
45
+ "dependencies": {
46
+ "shell-quote": "^1.8.0",
47
+ "zod": "^3.24.0"
48
+ }
49
+ }
@@ -0,0 +1,547 @@
1
+ /**
2
+ * DeviceCtl - Wrapper for xcrun devicectl (iOS 17+)
3
+ * Primary interface for modern iOS device control
4
+ */
5
+
6
+ import {
7
+ execDeviceCtl,
8
+ exec,
9
+ buildDeviceArg,
10
+ type ExecOptions,
11
+ } from './utils.js';
12
+ import {
13
+ type IOSDevice,
14
+ type DeviceDetails,
15
+ type ProcessInfo,
16
+ type InstalledApp,
17
+ type FileInfo,
18
+ type DisplayInfo,
19
+ type CommandResult,
20
+ type ProcessSignal,
21
+ type DarwinNotification,
22
+ } from './types.js';
23
+
24
+ export interface DeviceCtlOptions {
25
+ timeout?: number;
26
+ }
27
+
28
+ export class DeviceCtl {
29
+ private options: DeviceCtlOptions;
30
+
31
+ constructor(options: DeviceCtlOptions = {}) {
32
+ this.options = { timeout: 60000, ...options };
33
+ }
34
+
35
+ private execOpts(): ExecOptions {
36
+ return { timeout: this.options.timeout };
37
+ }
38
+
39
+ // ==========================================================================
40
+ // Device Listing & Management
41
+ // ==========================================================================
42
+
43
+ /**
44
+ * List all connected devices
45
+ */
46
+ async listDevices(): Promise<IOSDevice[]> {
47
+ const result = await execDeviceCtl(['list', 'devices'], this.execOpts());
48
+
49
+ if (!result.success || !result.json) {
50
+ return this.parseDevicesFromText(result.stdout);
51
+ }
52
+
53
+ const data = result.json as { devices?: Array<Record<string, unknown>> };
54
+ return (data.devices || []).map(this.parseDeviceFromJson);
55
+ }
56
+
57
+ private parseDeviceFromJson(device: Record<string, unknown>): IOSDevice {
58
+ return {
59
+ identifier: device.udid as string || device.identifier as string,
60
+ name: device.name as string || device.deviceName as string || 'Unknown',
61
+ model: device.model as string || '',
62
+ modelCode: device.modelCode as string || '',
63
+ productType: device.productType as string || '',
64
+ osVersion: device.osVersion as string || device.productVersion as string || '',
65
+ osBuildVersion: device.buildVersion as string || '',
66
+ architecture: device.architecture as string || 'arm64e',
67
+ platform: this.detectPlatform(device.productType as string),
68
+ connectionType: this.detectConnectionType(device),
69
+ isTrusted: device.trusted as boolean ?? true,
70
+ isAvailable: device.available as boolean ?? true,
71
+ };
72
+ }
73
+
74
+ private parseDevicesFromText(text: string): IOSDevice[] {
75
+ const lines = text.split('\n');
76
+ const devices: IOSDevice[] = [];
77
+
78
+ for (const line of lines) {
79
+ // Parse format: "Device Name (iOS 18.1) [UDID]"
80
+ const match = line.match(/^(.+?)\s*\((.+?)\)\s*\[([A-F0-9-]+)\]/i);
81
+ if (match) {
82
+ devices.push({
83
+ identifier: match[3],
84
+ name: match[1].trim(),
85
+ model: '',
86
+ modelCode: '',
87
+ productType: '',
88
+ osVersion: match[2],
89
+ osBuildVersion: '',
90
+ architecture: 'arm64e',
91
+ platform: 'iOS',
92
+ connectionType: 'USB',
93
+ isTrusted: true,
94
+ isAvailable: true,
95
+ });
96
+ }
97
+ }
98
+
99
+ return devices;
100
+ }
101
+
102
+ private detectPlatform(productType?: string): 'iOS' | 'iPadOS' | 'tvOS' | 'watchOS' | 'visionOS' {
103
+ if (!productType) return 'iOS';
104
+ if (productType.includes('iPad')) return 'iPadOS';
105
+ if (productType.includes('AppleTV') || productType.includes('TV')) return 'tvOS';
106
+ if (productType.includes('Watch')) return 'watchOS';
107
+ if (productType.includes('Vision') || productType.includes('Reality')) return 'visionOS';
108
+ return 'iOS';
109
+ }
110
+
111
+ private detectConnectionType(device: Record<string, unknown>): 'USB' | 'Network' | 'Wireless' {
112
+ if (device.connectionType) return device.connectionType as 'USB' | 'Network' | 'Wireless';
113
+ if (device.networkDevice === true) return 'Network';
114
+ if (device.wirelessEnabled === true) return 'Wireless';
115
+ return 'USB';
116
+ }
117
+
118
+ /**
119
+ * Get detailed device information
120
+ */
121
+ async getDeviceInfo(deviceId: string): Promise<DeviceDetails | null> {
122
+ const result = await execDeviceCtl(
123
+ ['device', 'info', 'details', ...buildDeviceArg(deviceId)],
124
+ this.execOpts()
125
+ );
126
+
127
+ if (!result.success) return null;
128
+
129
+ if (result.json) {
130
+ const d = result.json as Record<string, unknown>;
131
+ return {
132
+ udid: d.udid as string || deviceId,
133
+ name: d.name as string || d.deviceName as string || '',
134
+ model: d.model as string || '',
135
+ productType: d.productType as string || '',
136
+ productVersion: d.productVersion as string || d.osVersion as string || '',
137
+ buildVersion: d.buildVersion as string || '',
138
+ serialNumber: d.serialNumber as string,
139
+ cpuArchitecture: d.architecture as string || 'arm64e',
140
+ deviceName: d.deviceName as string || d.name as string || '',
141
+ timeZone: d.timeZone as string,
142
+ locale: d.locale as string,
143
+ };
144
+ }
145
+
146
+ return this.parseDeviceDetailsFromText(result.stdout);
147
+ }
148
+
149
+ private parseDeviceDetailsFromText(text: string): DeviceDetails | null {
150
+ const lines = text.split('\n');
151
+ const info: Record<string, string> = {};
152
+
153
+ for (const line of lines) {
154
+ const match = line.match(/^([^:]+):\s*(.+)$/);
155
+ if (match) {
156
+ info[match[1].trim().toLowerCase().replace(/\s+/g, '_')] = match[2].trim();
157
+ }
158
+ }
159
+
160
+ return {
161
+ udid: info.udid || info.identifier || '',
162
+ name: info.name || info.device_name || '',
163
+ model: info.model || '',
164
+ productType: info.product_type || '',
165
+ productVersion: info.product_version || info.os_version || '',
166
+ buildVersion: info.build_version || '',
167
+ serialNumber: info.serial_number,
168
+ cpuArchitecture: info.architecture || info.cpu_architecture || 'arm64e',
169
+ deviceName: info.device_name || info.name || '',
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Get device lock state
175
+ */
176
+ async getLockState(deviceId: string): Promise<'locked' | 'unlocked'> {
177
+ const result = await execDeviceCtl(
178
+ ['device', 'info', 'lockState', ...buildDeviceArg(deviceId)],
179
+ this.execOpts()
180
+ );
181
+
182
+ if (result.stdout.toLowerCase().includes('unlocked')) return 'unlocked';
183
+ return 'locked';
184
+ }
185
+
186
+ /**
187
+ * Get display information
188
+ */
189
+ async getDisplays(deviceId: string): Promise<DisplayInfo[]> {
190
+ const result = await execDeviceCtl(
191
+ ['device', 'info', 'displays', ...buildDeviceArg(deviceId)],
192
+ this.execOpts()
193
+ );
194
+
195
+ if (!result.success || !result.json) return [];
196
+
197
+ const displays = (result.json as { displays?: unknown[] }).displays || [];
198
+ return displays.map((d: unknown) => {
199
+ const display = d as Record<string, unknown>;
200
+ return {
201
+ id: display.id as number || 0,
202
+ width: display.width as number || display.resolutionWidth as number || 0,
203
+ height: display.height as number || display.resolutionHeight as number || 0,
204
+ scale: display.scale as number || 1,
205
+ refreshRate: display.refreshRate as number,
206
+ colorSpace: display.colorSpace as string,
207
+ };
208
+ });
209
+ }
210
+
211
+ // ==========================================================================
212
+ // Pairing
213
+ // ==========================================================================
214
+
215
+ /**
216
+ * Pair with a device
217
+ */
218
+ async pair(deviceId: string): Promise<CommandResult> {
219
+ return execDeviceCtl(['manage', 'pair', ...buildDeviceArg(deviceId)], this.execOpts());
220
+ }
221
+
222
+ /**
223
+ * Unpair from a device
224
+ */
225
+ async unpair(deviceId: string): Promise<CommandResult> {
226
+ return execDeviceCtl(['manage', 'unpair', ...buildDeviceArg(deviceId)], this.execOpts());
227
+ }
228
+
229
+ // ==========================================================================
230
+ // Device Control
231
+ // ==========================================================================
232
+
233
+ /**
234
+ * Reboot a device
235
+ */
236
+ async reboot(deviceId: string): Promise<CommandResult> {
237
+ return execDeviceCtl(['device', 'reboot', ...buildDeviceArg(deviceId)], this.execOpts());
238
+ }
239
+
240
+ /**
241
+ * Set device orientation
242
+ */
243
+ async setOrientation(deviceId: string, orientation: 'portrait' | 'landscape' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right'): Promise<CommandResult> {
244
+ return execDeviceCtl(
245
+ ['device', 'orientation', '--orientation', orientation, ...buildDeviceArg(deviceId)],
246
+ this.execOpts()
247
+ );
248
+ }
249
+
250
+ // ==========================================================================
251
+ // Process Management
252
+ // ==========================================================================
253
+
254
+ /**
255
+ * List running processes
256
+ */
257
+ async listProcesses(deviceId: string): Promise<ProcessInfo[]> {
258
+ const result = await execDeviceCtl(
259
+ ['device', 'info', 'processes', ...buildDeviceArg(deviceId)],
260
+ this.execOpts()
261
+ );
262
+
263
+ if (!result.success) return [];
264
+
265
+ return this.parseProcessesFromText(result.stdout);
266
+ }
267
+
268
+ private parseProcessesFromText(text: string): ProcessInfo[] {
269
+ const lines = text.split('\n');
270
+ const processes: ProcessInfo[] = [];
271
+
272
+ for (const line of lines) {
273
+ // Try to parse PID and process name
274
+ const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
275
+ if (match) {
276
+ processes.push({
277
+ pid: parseInt(match[1], 10),
278
+ name: match[2].trim(),
279
+ });
280
+ }
281
+ }
282
+
283
+ return processes;
284
+ }
285
+
286
+ /**
287
+ * Launch an app
288
+ */
289
+ async launchApp(deviceId: string, bundleId: string, options: { arguments?: string[]; environment?: Record<string, string> } = {}): Promise<CommandResult> {
290
+ const args = ['device', 'process', 'launch', ...buildDeviceArg(deviceId), bundleId];
291
+
292
+ if (options.arguments?.length) {
293
+ args.push('--arguments', ...options.arguments);
294
+ }
295
+
296
+ if (options.environment) {
297
+ for (const [key, value] of Object.entries(options.environment)) {
298
+ args.push('--environment', `${key}=${value}`);
299
+ }
300
+ }
301
+
302
+ return execDeviceCtl(args, this.execOpts());
303
+ }
304
+
305
+ /**
306
+ * Terminate a process
307
+ */
308
+ async terminateProcess(deviceId: string, processNameOrPid: string | number): Promise<CommandResult> {
309
+ const args = ['device', 'process', 'terminate', ...buildDeviceArg(deviceId)];
310
+
311
+ if (typeof processNameOrPid === 'number') {
312
+ args.push('--pid', String(processNameOrPid));
313
+ } else {
314
+ args.push('--name', processNameOrPid);
315
+ }
316
+
317
+ return execDeviceCtl(args, this.execOpts());
318
+ }
319
+
320
+ /**
321
+ * Send signal to a process
322
+ */
323
+ async signalProcess(deviceId: string, pid: number, signal: ProcessSignal): Promise<CommandResult> {
324
+ return execDeviceCtl(
325
+ ['device', 'process', 'signal', '--pid', String(pid), '--signal', signal, ...buildDeviceArg(deviceId)],
326
+ this.execOpts()
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Suspend a process
332
+ */
333
+ async suspendProcess(deviceId: string, pid: number): Promise<CommandResult> {
334
+ return execDeviceCtl(
335
+ ['device', 'process', 'suspend', '--pid', String(pid), ...buildDeviceArg(deviceId)],
336
+ this.execOpts()
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Resume a process
342
+ */
343
+ async resumeProcess(deviceId: string, pid: number): Promise<CommandResult> {
344
+ return execDeviceCtl(
345
+ ['device', 'process', 'resume', '--pid', String(pid), ...buildDeviceArg(deviceId)],
346
+ this.execOpts()
347
+ );
348
+ }
349
+
350
+ /**
351
+ * Send memory warning to a process
352
+ */
353
+ async sendMemoryWarning(deviceId: string, pid: number): Promise<CommandResult> {
354
+ return execDeviceCtl(
355
+ ['device', 'process', 'sendMemoryWarning', '--pid', String(pid), ...buildDeviceArg(deviceId)],
356
+ this.execOpts()
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Respring the device (restart SpringBoard)
362
+ */
363
+ async respring(deviceId: string): Promise<CommandResult> {
364
+ // Find SpringBoard PID and send SIGHUP
365
+ const processes = await this.listProcesses(deviceId);
366
+ const springBoard = processes.find((p) => p.name === 'SpringBoard' || p.name.includes('SpringBoard'));
367
+
368
+ if (!springBoard) {
369
+ return { success: false, stdout: '', stderr: 'SpringBoard process not found', exitCode: 1 };
370
+ }
371
+
372
+ return this.signalProcess(deviceId, springBoard.pid, 'SIGHUP');
373
+ }
374
+
375
+ // ==========================================================================
376
+ // App Management
377
+ // ==========================================================================
378
+
379
+ /**
380
+ * List installed apps
381
+ */
382
+ async listApps(deviceId: string): Promise<InstalledApp[]> {
383
+ const result = await execDeviceCtl(
384
+ ['device', 'info', 'apps', ...buildDeviceArg(deviceId)],
385
+ this.execOpts()
386
+ );
387
+
388
+ if (!result.success) return [];
389
+
390
+ return this.parseAppsFromText(result.stdout);
391
+ }
392
+
393
+ private parseAppsFromText(text: string): InstalledApp[] {
394
+ const lines = text.split('\n');
395
+ const apps: InstalledApp[] = [];
396
+
397
+ for (const line of lines) {
398
+ // Try parsing bundle identifier and name
399
+ const trimmed = line.trim();
400
+ if (trimmed && !trimmed.startsWith('[') && trimmed.includes('.')) {
401
+ const parts = trimmed.split(/\s+/);
402
+ if (parts.length >= 1) {
403
+ apps.push({
404
+ bundleIdentifier: parts[0],
405
+ bundleName: parts[1] || parts[0].split('.').pop() || '',
406
+ bundleVersion: '',
407
+ installPath: '',
408
+ platform: 'iOS',
409
+ type: 'User',
410
+ });
411
+ }
412
+ }
413
+ }
414
+
415
+ return apps;
416
+ }
417
+
418
+ /**
419
+ * Install an app
420
+ */
421
+ async installApp(deviceId: string, appPath: string): Promise<CommandResult> {
422
+ return execDeviceCtl(
423
+ ['device', 'install', 'app', ...buildDeviceArg(deviceId), appPath],
424
+ this.execOpts()
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Uninstall an app
430
+ */
431
+ async uninstallApp(deviceId: string, bundleId: string): Promise<CommandResult> {
432
+ return execDeviceCtl(
433
+ ['device', 'uninstall', ...buildDeviceArg(deviceId), bundleId],
434
+ this.execOpts()
435
+ );
436
+ }
437
+
438
+ /**
439
+ * Get app icon
440
+ */
441
+ async getAppIcon(deviceId: string, bundleId: string, outputPath?: string): Promise<CommandResult> {
442
+ const args = ['device', 'info', 'appIcon', ...buildDeviceArg(deviceId), bundleId];
443
+ if (outputPath) {
444
+ args.push('--output', outputPath);
445
+ }
446
+ return execDeviceCtl(args, this.execOpts());
447
+ }
448
+
449
+ // ==========================================================================
450
+ // File System
451
+ // ==========================================================================
452
+
453
+ /**
454
+ * List files in a directory
455
+ */
456
+ async listFiles(deviceId: string, path: string): Promise<FileInfo[]> {
457
+ const result = await execDeviceCtl(
458
+ ['device', 'info', 'files', ...buildDeviceArg(deviceId), '--path', path],
459
+ this.execOpts()
460
+ );
461
+
462
+ if (!result.success) return [];
463
+
464
+ return this.parseFilesFromText(result.stdout);
465
+ }
466
+
467
+ private parseFilesFromText(text: string): FileInfo[] {
468
+ const lines = text.split('\n');
469
+ const files: FileInfo[] = [];
470
+
471
+ for (const line of lines) {
472
+ const trimmed = line.trim();
473
+ if (!trimmed) continue;
474
+
475
+ const isDirectory = trimmed.endsWith('/');
476
+ const name = isDirectory ? trimmed.slice(0, -1) : trimmed;
477
+
478
+ if (name && name !== '.' && name !== '..') {
479
+ files.push({
480
+ path: name,
481
+ name: name.split('/').pop() || name,
482
+ isDirectory,
483
+ });
484
+ }
485
+ }
486
+
487
+ return files;
488
+ }
489
+
490
+ /**
491
+ * Copy file to device
492
+ */
493
+ async copyToDevice(deviceId: string, source: string, destination: string): Promise<CommandResult> {
494
+ return execDeviceCtl(
495
+ ['device', 'copy', 'to-device', ...buildDeviceArg(deviceId), '--source', source, '--destination', destination],
496
+ this.execOpts()
497
+ );
498
+ }
499
+
500
+ /**
501
+ * Copy file from device
502
+ */
503
+ async copyFromDevice(deviceId: string, source: string, destination: string): Promise<CommandResult> {
504
+ return execDeviceCtl(
505
+ ['device', 'copy', 'from-device', ...buildDeviceArg(deviceId), '--source', source, '--destination', destination],
506
+ this.execOpts()
507
+ );
508
+ }
509
+
510
+ // ==========================================================================
511
+ // Notifications
512
+ // ==========================================================================
513
+
514
+ /**
515
+ * Post a Darwin notification
516
+ */
517
+ async postNotification(deviceId: string, name: string, userInfo?: Record<string, unknown>): Promise<CommandResult> {
518
+ const args = ['device', 'notification', 'post', ...buildDeviceArg(deviceId), '--name', name];
519
+
520
+ if (userInfo) {
521
+ args.push('--user-info', JSON.stringify(userInfo));
522
+ }
523
+
524
+ return execDeviceCtl(args, this.execOpts());
525
+ }
526
+
527
+ // ==========================================================================
528
+ // Diagnostics
529
+ // ==========================================================================
530
+
531
+ /**
532
+ * Collect diagnostics
533
+ */
534
+ async collectDiagnostics(options: { devices?: string; archiveDestination?: string } = {}): Promise<CommandResult> {
535
+ const args = ['diagnose'];
536
+
537
+ if (options.devices) {
538
+ args.push('--devices', options.devices);
539
+ }
540
+
541
+ if (options.archiveDestination) {
542
+ args.push('--archive-destination', options.archiveDestination);
543
+ }
544
+
545
+ return execDeviceCtl(args, { ...this.execOpts(), timeout: 300000 }); // 5 min timeout
546
+ }
547
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @ebowwa/ios-devices
3
+ * iOS device control library wrapping xcrun devicectl, libimobiledevice, and simctl
4
+ */
5
+
6
+ export { IOSDevices, DeviceCtl, LibIMobileDevice, SimCtl, checkAvailableTools } from './unified-api.js';
7
+
8
+ export * from './types.js';