appium-ios-remotexpc 0.19.0 → 0.21.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.
@@ -0,0 +1,157 @@
1
+ import { getLogger } from '../../../../lib/logger.js';
2
+ import { BaseInstrument } from './base-instrument.js';
3
+ const log = getLogger('NetworkMonitor');
4
+ /**
5
+ * Message types for network monitoring events
6
+ */
7
+ export const NetworkMessageType = {
8
+ INTERFACE_DETECTION: 0,
9
+ CONNECTION_DETECTION: 1,
10
+ CONNECTION_UPDATE: 2,
11
+ };
12
+ /**
13
+ * NetworkMonitor provides real-time network activity monitoring on iOS devices.
14
+ *
15
+ * This instrument captures:
16
+ * - Interface detection events (network interfaces coming up)
17
+ * - Connection detection events (new TCP/UDP connections)
18
+ * - Connection update events (traffic statistics updates)
19
+ */
20
+ export class NetworkMonitor extends BaseInstrument {
21
+ static IDENTIFIER = 'com.apple.instruments.server.services.networking';
22
+ async start() {
23
+ await this.initialize();
24
+ await this.channel.call('startMonitoring')(undefined, false);
25
+ }
26
+ async stop() {
27
+ if (this.channel) {
28
+ await this.channel.call('stopMonitoring')();
29
+ }
30
+ }
31
+ /**
32
+ * Async generator that yields network events as they occur.
33
+ *
34
+ * The generator automatically starts monitoring when iteration begins
35
+ * and stops when the iteration is terminated (via break, return, or error).
36
+ *
37
+ * @yields NetworkEvent - Interface detection, connection detection, or connection update events
38
+ */
39
+ async *events() {
40
+ await this.start();
41
+ log.debug('network monitoring started');
42
+ try {
43
+ while (true) {
44
+ const message = await this.channel.receivePlist();
45
+ if (message === null) {
46
+ continue;
47
+ }
48
+ const event = this.parseMessage(message);
49
+ if (event) {
50
+ yield event;
51
+ }
52
+ }
53
+ }
54
+ finally {
55
+ log.debug('network monitoring stopped');
56
+ await this.stop();
57
+ }
58
+ }
59
+ /**
60
+ * Parse a raw message into a typed NetworkEvent
61
+ */
62
+ parseMessage(message) {
63
+ if (!Array.isArray(message) || message.length < 2) {
64
+ return null;
65
+ }
66
+ const [messageType, data] = message;
67
+ switch (messageType) {
68
+ case NetworkMessageType.INTERFACE_DETECTION:
69
+ return this.parseInterfaceDetection(data);
70
+ case NetworkMessageType.CONNECTION_DETECTION:
71
+ return this.parseConnectionDetection(data);
72
+ case NetworkMessageType.CONNECTION_UPDATE:
73
+ return this.parseConnectionUpdate(data);
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * Parse interface detection event data
80
+ */
81
+ parseInterfaceDetection(data) {
82
+ const [interfaceIndex, name] = data;
83
+ return {
84
+ type: NetworkMessageType.INTERFACE_DETECTION,
85
+ interfaceIndex,
86
+ name,
87
+ };
88
+ }
89
+ /**
90
+ * Parse connection detection event data
91
+ */
92
+ parseConnectionDetection(data) {
93
+ const [localAddressRaw, remoteAddressRaw, interfaceIndex, pid, recvBufferSize, recvBufferUsed, serialNumber, kind,] = data;
94
+ return {
95
+ type: NetworkMessageType.CONNECTION_DETECTION,
96
+ localAddress: this.parseAddress(localAddressRaw),
97
+ remoteAddress: this.parseAddress(remoteAddressRaw),
98
+ interfaceIndex,
99
+ pid,
100
+ recvBufferSize,
101
+ recvBufferUsed,
102
+ serialNumber,
103
+ kind,
104
+ };
105
+ }
106
+ /**
107
+ * Parse connection update event data
108
+ */
109
+ parseConnectionUpdate(data) {
110
+ const [rxPackets, rxBytes, txPackets, txBytes, rxDups, rx000, txRetx, minRtt, avgRtt, connectionSerial, time,] = data;
111
+ return {
112
+ type: NetworkMessageType.CONNECTION_UPDATE,
113
+ rxPackets,
114
+ rxBytes,
115
+ txPackets,
116
+ txBytes,
117
+ rxDups,
118
+ rx000,
119
+ txRetx,
120
+ minRtt,
121
+ avgRtt,
122
+ connectionSerial,
123
+ time,
124
+ };
125
+ }
126
+ /**
127
+ * Parse a raw address buffer into a NetworkAddress structure
128
+ *
129
+ * Address structure format (sockaddr):
130
+ * - Byte 0: Length (0x10 for IPv4, 0x1C for IPv6)
131
+ * - Byte 1: Address family (2 = AF_INET, 30 = AF_INET6)
132
+ * - Bytes 2-3: Port (big-endian)
133
+ * - For IPv4 (len=0x10): Bytes 4-7 are the IP address
134
+ * - For IPv6 (len=0x1C): Bytes 4-7 flow info, 8-23 address, 24-27 scope ID
135
+ */
136
+ parseAddress(raw) {
137
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
138
+ const len = buf[0];
139
+ const family = buf[1];
140
+ const port = buf.readUInt16BE(2);
141
+ const result = { len, family, port, address: '0.0.0.0' };
142
+ if (len === 0x1c) {
143
+ // IPv6: 8 groups of 16-bit hex values
144
+ result.flowInfo = buf.readUInt32LE(4);
145
+ result.address = Array.from({ length: 8 }, (_, i) => buf.readUInt16BE(8 + i * 2).toString(16)).join(':');
146
+ result.scopeId = buf.readUInt32LE(24);
147
+ }
148
+ else if (len === 0x10) {
149
+ // IPv4: 4 octets as decimal
150
+ result.address = Array.from(buf.subarray(4, 8)).join('.');
151
+ }
152
+ else {
153
+ log.warn(`Unknown address length: ${len}`);
154
+ }
155
+ return result;
156
+ }
157
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/services.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAGhF,OAAO,KAAK,EACV,wBAAwB,EACxB,gCAAgC,EAChC,6BAA6B,EAC7B,iCAAiC,EACjC,uCAAuC,EACvC,sCAAsC,EACtC,mCAAmC,EACnC,gCAAgC,EAChC,aAAa,IAAI,iBAAiB,EAClC,iCAAiC,EAClC,MAAM,gBAAgB,CAAC;AACxB,OAAO,UAAU,MAAM,6BAA6B,CAAC;AAsBrD,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,sCAAsC,CAAC,CAYjD;AAED,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iCAAiC,CAAC,CAY5C;AAED,wBAAsB,8BAA8B,CAClD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,uCAAuC,CAAC,CAYlD;AAED,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,6BAA6B,CAAC,CAYxC;AAED,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,mCAAmC,CAAC,CAY9C;AAED,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAG5B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAOvE;AAED,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iCAAiC,CAAC,CAY5C;AAED,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,wBAAwB,CAAC,CAmCnC;AAED,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM;;;;;;;;GAO3D"}
1
+ {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/services.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAGhF,OAAO,KAAK,EACV,wBAAwB,EACxB,gCAAgC,EAChC,6BAA6B,EAC7B,iCAAiC,EACjC,uCAAuC,EACvC,sCAAsC,EACtC,mCAAmC,EACnC,gCAAgC,EAChC,aAAa,IAAI,iBAAiB,EAClC,iCAAiC,EAClC,MAAM,gBAAgB,CAAC;AACxB,OAAO,UAAU,MAAM,6BAA6B,CAAC;AAuBrD,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,sCAAsC,CAAC,CAYjD;AAED,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iCAAiC,CAAC,CAY5C;AAED,wBAAsB,8BAA8B,CAClD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,uCAAuC,CAAC,CAYlD;AAED,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,6BAA6B,CAAC,CAYxC;AAED,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,mCAAmC,CAAC,CAY9C;AAED,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAG5B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAOvE;AAED,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iCAAiC,CAAC,CAY5C;AAED,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,wBAAwB,CAAC,CAqCnC;AAED,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM;;;;;;;;GAO3D"}
@@ -10,6 +10,7 @@ import { ConditionInducer } from './services/ios/dvt/instruments/condition-induc
10
10
  import { DeviceInfo } from './services/ios/dvt/instruments/device-info.js';
11
11
  import { Graphics } from './services/ios/dvt/instruments/graphics.js';
12
12
  import { LocationSimulation } from './services/ios/dvt/instruments/location-simulation.js';
13
+ import { NetworkMonitor } from './services/ios/dvt/instruments/network-monitor.js';
13
14
  import { Notifications } from './services/ios/dvt/instruments/notifications.js';
14
15
  import { Screenshot } from './services/ios/dvt/instruments/screenshot.js';
15
16
  import { MisagentService } from './services/ios/misagent/index.js';
@@ -144,6 +145,7 @@ export async function startDVTService(udid) {
144
145
  const graphics = new Graphics(dvtService);
145
146
  const deviceInfo = new DeviceInfo(dvtService);
146
147
  const notification = new Notifications(dvtService);
148
+ const networkMonitor = new NetworkMonitor(dvtService);
147
149
  return {
148
150
  remoteXPC: remoteXPC,
149
151
  dvtService,
@@ -154,6 +156,7 @@ export async function startDVTService(udid) {
154
156
  graphics,
155
157
  deviceInfo,
156
158
  notification,
159
+ networkMonitor,
157
160
  };
158
161
  }
159
162
  export async function createRemoteXPCConnection(udid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "main": "build/src/index.js",
5
5
  "types": "build/src/index.d.ts",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "test:dvt:device-info": "mocha test/integration/dvt_instruments/device-info-test.ts --exit --timeout 1m",
48
48
  "test:dvt:applist": "mocha test/integration/dvt_instruments/app-listing-test.ts --exit --timeout 1m",
49
49
  "test:dvt:notification": "mocha test/integration/dvt_instruments/notifications-test.ts --exit --timeout 1m",
50
+ "test:dvt:network-monitor": "mocha test/integration/dvt_instruments/network-monitor-test.ts --exit --timeout 1m",
50
51
  "test:tunnel-creation": "sudo tsx scripts/test-tunnel-creation.ts",
51
52
  "test:tunnel-creation:lsof": "sudo tsx scripts/test-tunnel-creation.ts --keep-open"
52
53
  },
@@ -91,6 +92,7 @@
91
92
  "@xmldom/xmldom": "^0.9.8",
92
93
  "appium-ios-tuntap": "^0.x",
93
94
  "axios": "^1.12.0",
95
+ "minimatch": "^10.1.1",
94
96
  "npm-run-all2": "^8.0.4"
95
97
  },
96
98
  "files": [
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export type {
30
30
  ScreenshotService,
31
31
  GraphicsService,
32
32
  DeviceInfoService,
33
+ NetworkMonitorService,
33
34
  ProcessInfo,
34
35
  ConditionProfile,
35
36
  ConditionGroup,
@@ -46,8 +47,14 @@ export type {
46
47
  WebInspectorServiceWithConnection,
47
48
  MisagentServiceWithConnection,
48
49
  DVTServiceWithConnection,
50
+ NetworkAddress,
51
+ InterfaceDetectionEvent,
52
+ ConnectionDetectionEvent,
53
+ ConnectionUpdateEvent,
54
+ NetworkEvent,
49
55
  } from './lib/types.js';
50
56
  export { PowerAssertionType } from './lib/types.js';
57
+ export { NetworkMessageType } from './services/ios/dvt/instruments/network-monitor.js';
51
58
  export {
52
59
  STRONGBOX_CONTAINER_NAME,
53
60
  createUsbmux,
package/src/lib/types.ts CHANGED
@@ -575,6 +575,140 @@ export interface GraphicsService {
575
575
  messages(): AsyncGenerator<unknown, void, unknown>;
576
576
  }
577
577
 
578
+ /**
579
+ * Network address information
580
+ */
581
+ export interface NetworkAddress {
582
+ /** Length of the address structure */
583
+ len: number;
584
+ /** Address family (2 = IPv4, 30 = IPv6) */
585
+ family: number;
586
+ /** Port number */
587
+ port: number;
588
+ /** Parsed IP address string */
589
+ address: string;
590
+ /** Flow info (IPv6 only) */
591
+ flowInfo?: number;
592
+ /** Scope ID (IPv6 only) */
593
+ scopeId?: number;
594
+ }
595
+
596
+ /**
597
+ * Event emitted when a network interface is detected
598
+ */
599
+ export interface InterfaceDetectionEvent {
600
+ type: 0;
601
+ /** Interface index */
602
+ interfaceIndex: number;
603
+ /** Interface name (e.g., 'en0', 'lo0') */
604
+ name: string;
605
+ }
606
+
607
+ /**
608
+ * Event emitted when a network connection is detected
609
+ */
610
+ export interface ConnectionDetectionEvent {
611
+ type: 1;
612
+ /** Local address information */
613
+ localAddress: NetworkAddress;
614
+ /** Remote address information */
615
+ remoteAddress: NetworkAddress;
616
+ /** Interface index */
617
+ interfaceIndex: number;
618
+ /** Process ID owning the connection */
619
+ pid: number;
620
+ /** Receive buffer size */
621
+ recvBufferSize: number;
622
+ /** Receive buffer used */
623
+ recvBufferUsed: number;
624
+ /** Connection serial number */
625
+ serialNumber: number;
626
+ /** Connection kind/type */
627
+ kind: number;
628
+ }
629
+
630
+ /**
631
+ * Event emitted when connection statistics are updated
632
+ */
633
+ export interface ConnectionUpdateEvent {
634
+ type: 2;
635
+ /** Received packets count */
636
+ rxPackets: number;
637
+ /** Received bytes count */
638
+ rxBytes: number;
639
+ /** Transmitted packets count */
640
+ txPackets: number;
641
+ /** Transmitted bytes count */
642
+ txBytes: number;
643
+ /** Duplicate received packets */
644
+ rxDups: number;
645
+ /** Reserved field */
646
+ rx000: number;
647
+ /** Retransmitted packets */
648
+ txRetx: number;
649
+ /** Minimum round-trip time */
650
+ minRtt: number;
651
+ /** Average round-trip time */
652
+ avgRtt: number;
653
+ /** Connection serial number (links to ConnectionDetectionEvent) */
654
+ connectionSerial: number;
655
+ /** Timestamp */
656
+ time: number;
657
+ }
658
+
659
+ /**
660
+ * Union type for all network monitoring events
661
+ */
662
+ export type NetworkEvent =
663
+ | InterfaceDetectionEvent
664
+ | ConnectionDetectionEvent
665
+ | ConnectionUpdateEvent;
666
+
667
+ /**
668
+ * Network monitor service interface for real-time network activity monitoring
669
+ */
670
+ export interface NetworkMonitorService {
671
+ /**
672
+ * Async iterator for network events.
673
+ * Yields interface detection, connection detection, and connection update events.
674
+ *
675
+ * @example
676
+ * const networkMonitor = device.networkMonitor();
677
+ * for await (const event of networkMonitor.events()) {
678
+ * console.log(event);
679
+ * }
680
+ *
681
+ * // Example output:
682
+ * // { type: 0, interfaceIndex: 25, name: 'utun5' }
683
+ * // {
684
+ * // type: 1,
685
+ * // localAddress: {
686
+ * // len: 28,
687
+ * // family: 30,
688
+ * // port: 50063,
689
+ * // address: 'fdc2:1118:d2ac:0:0:0:0:1',
690
+ * // flowInfo: 0,
691
+ * // scopeId: 0
692
+ * // },
693
+ * // remoteAddress: {
694
+ * // len: 28,
695
+ * // family: 30,
696
+ * // port: 65334,
697
+ * // address: 'fdc2:1118:d2ac:0:0:0:0:2',
698
+ * // flowInfo: 0,
699
+ * // scopeId: 0
700
+ * // },
701
+ * // interfaceIndex: 25,
702
+ * // pid: -2,
703
+ * // recvBufferSize: 397120,
704
+ * // recvBufferUsed: 0,
705
+ * // serialNumber: 0,
706
+ * // kind: 1
707
+ * // }
708
+ */
709
+ events(): AsyncGenerator<NetworkEvent, void, unknown>;
710
+ }
711
+
578
712
  /**
579
713
  * Process information
580
714
  */
@@ -862,6 +996,8 @@ export interface DVTServiceWithConnection {
862
996
  deviceInfo: DeviceInfoService;
863
997
  /** The Notifications service instance */
864
998
  notification: NotificationService;
999
+ /** The NetworkMonitor service instance */
1000
+ networkMonitor: NetworkMonitorService;
865
1001
  /** The RemoteXPC connection that can be used to close the connection */
866
1002
  remoteXPC: RemoteXpcConnection;
867
1003
  }
@@ -1,4 +1,6 @@
1
+ import { minimatch } from 'minimatch';
1
2
  import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
2
4
  import net from 'node:net';
3
5
  import path from 'node:path';
4
6
  import { Readable, Writable } from 'node:stream';
@@ -8,6 +10,7 @@ import { getLogger } from '../../../lib/logger.js';
8
10
  import {
9
11
  buildClosePayload,
10
12
  buildFopenPayload,
13
+ buildMkdirPayload,
11
14
  buildReadPayload,
12
15
  buildRemovePayload,
13
16
  buildRenamePayload,
@@ -29,6 +32,38 @@ const log = getLogger('AfcService');
29
32
 
30
33
  const NON_LISTABLE_ENTRIES = ['', '.', '..'];
31
34
 
35
+ /**
36
+ * Callback invoked for each file successfully pulled from the device.
37
+ *
38
+ * @param remotePath - The remote file path on the device
39
+ * @param localPath - The local file path where it was saved
40
+ *
41
+ * @remarks
42
+ * If the callback throws an error, the pull operation will be aborted immediately.
43
+ */
44
+ export type PullRecursiveCallback = (
45
+ remotePath: string,
46
+ localPath: string,
47
+ ) => unknown | Promise<unknown>;
48
+
49
+ /** Options for the pull method. */
50
+ export interface PullOptions {
51
+ /**
52
+ * If true, recursively pull directories.
53
+ * @default false
54
+ */
55
+ recursive?: boolean;
56
+ /** Glob pattern to filter files (e.g., '*.txt', '**\/*.log'). */
57
+ match?: string;
58
+ /**
59
+ * If false, throws error when local file exists.
60
+ * @default true
61
+ */
62
+ overwrite?: boolean;
63
+ /** Callback invoked for each pulled file. */
64
+ callback?: PullRecursiveCallback;
65
+ }
66
+
32
67
  export interface StatInfo {
33
68
  st_ifmt: AfcFileMode;
34
69
  st_size: bigint;
@@ -288,12 +323,97 @@ export class AfcService {
288
323
  }
289
324
  }
290
325
 
291
- async pull(remoteSrc: string, localDst: string): Promise<void> {
292
- log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
293
- const stream = await this.readToStream(remoteSrc);
294
- const writeStream = fs.createWriteStream(localDst);
295
- await pipeline(stream, writeStream);
296
- log.debug(`Successfully pulled file to '${localDst}'`);
326
+ /**
327
+ * Pull file(s) or directory from the device to the local filesystem.
328
+ *
329
+ * @param remoteSrc - Remote path on the device (file or directory)
330
+ * @param localDst - Local destination path
331
+ * @param options - Optional configuration
332
+ *
333
+ * @throws {Error} If the remote source path does not exist
334
+ * @throws {Error} If overwrite is false and local file already exists
335
+ *
336
+ * @remarks
337
+ * When pulling a directory with `recursive: true`, the directory itself will be created
338
+ * inside the destination. For example, pulling `/Downloads` to `/tmp` will create `/tmp/Downloads`.
339
+ */
340
+ async pull(
341
+ remoteSrc: string,
342
+ localDst: string,
343
+ options?: PullOptions,
344
+ ): Promise<void> {
345
+ const {
346
+ recursive = false,
347
+ match,
348
+ overwrite = true,
349
+ callback,
350
+ } = options ?? {};
351
+
352
+ if (!(await this.exists(remoteSrc))) {
353
+ throw new Error(`Remote path does not exist: ${remoteSrc}`);
354
+ }
355
+
356
+ const pullSingleFile = async (
357
+ remoteFilePath: string,
358
+ localFilePath: string,
359
+ ): Promise<void> => {
360
+ log.debug(`Pulling file from '${remoteFilePath}' to '${localFilePath}'`);
361
+
362
+ if (!overwrite && (await this._localPathExists(localFilePath))) {
363
+ throw new Error(`Local file already exists: ${localFilePath}`);
364
+ }
365
+
366
+ await this._pullFile(remoteFilePath, localFilePath);
367
+
368
+ if (callback) {
369
+ await callback(remoteFilePath, localFilePath);
370
+ }
371
+ };
372
+
373
+ const isDir = await this.isdir(remoteSrc);
374
+
375
+ if (!isDir) {
376
+ const baseName = path.posix.basename(remoteSrc);
377
+
378
+ if (match && !minimatch(baseName, match)) {
379
+ return;
380
+ }
381
+
382
+ const localDstIsDirectory = await this._isLocalDirectory(localDst);
383
+ const targetPath = localDstIsDirectory
384
+ ? path.join(localDst, baseName)
385
+ : localDst;
386
+
387
+ await pullSingleFile(remoteSrc, targetPath);
388
+ return;
389
+ }
390
+
391
+ // Source is a directory, recursive option required
392
+ if (!recursive) {
393
+ throw new Error(
394
+ `Cannot pull directory '${remoteSrc}' without recursive option. Set recursive: true to pull directories.`,
395
+ );
396
+ }
397
+
398
+ log.debug(`Starting recursive pull from '${remoteSrc}' to '${localDst}'`);
399
+ await this._pullRecursiveInternal(remoteSrc, localDst, {
400
+ match,
401
+ overwrite,
402
+ callback,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Create a directory on the device.
408
+ *
409
+ * Creates parent directories automatically and is idempotent (no error if the directory exists).
410
+ *
411
+ * @param dirPath - Path of the directory to create.
412
+ * @returns A promise that resolves when the directory has been created.
413
+ */
414
+ async mkdir(dirPath: string): Promise<void> {
415
+ await this._doOperation(AfcOpcode.MAKE_DIR, buildMkdirPayload(dirPath));
416
+ log.debug(`Successfully created directory: ${dirPath}`);
297
417
  }
298
418
 
299
419
  async rmSingle(filePath: string, force = false): Promise<boolean> {
@@ -418,6 +538,137 @@ export class AfcService {
418
538
  this.socket = null;
419
539
  }
420
540
 
541
+ /**
542
+ * Private primitive to pull a single file from device to local filesystem.
543
+ *
544
+ * @param remoteSrc - Remote file path on the device (must be a file)
545
+ * @param localDst - Local destination file path
546
+ */
547
+ private async _pullFile(remoteSrc: string, localDst: string): Promise<void> {
548
+ log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
549
+
550
+ const resolved = await this._resolvePath(remoteSrc);
551
+ const st = await this.stat(resolved);
552
+
553
+ if (st.st_ifmt !== AfcFileMode.S_IFREG) {
554
+ throw new Error(`'${resolved}' isn't a regular file`);
555
+ }
556
+
557
+ const handle = await this.fopen(resolved, 'r');
558
+ try {
559
+ const stream = this.createReadStream(handle, st.st_size);
560
+ const writeStream = fs.createWriteStream(localDst);
561
+ await pipeline(stream, writeStream);
562
+ log.debug(
563
+ `Successfully pulled file to '${localDst}' (${st.st_size} bytes)`,
564
+ );
565
+ } finally {
566
+ await this.fclose(handle);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Recursively pull directory contents from device to local filesystem.
572
+ *
573
+ * @remarks
574
+ * This method is intended for directories only. Caller must validate that remoteSrcDir
575
+ * is a directory before invoking.
576
+ */
577
+ private async _pullRecursiveInternal(
578
+ remoteSrcDir: string,
579
+ localDstDir: string,
580
+ options?: Omit<PullOptions, 'recursive'>,
581
+ relativePath = '',
582
+ ): Promise<void> {
583
+ const { match, overwrite = true, callback } = options ?? {};
584
+
585
+ let localDirPath: string;
586
+ if (!relativePath) {
587
+ const localDstIsDirectory = await this._isLocalDirectory(localDstDir);
588
+
589
+ if (!localDstIsDirectory) {
590
+ const stat = await fsp.stat(localDstDir).catch((err) => {
591
+ if (err.code === 'ENOENT') {
592
+ return null;
593
+ }
594
+ throw err;
595
+ });
596
+ if (stat?.isFile()) {
597
+ throw new Error(
598
+ `Local destination exists and is a file, not a directory: ${localDstDir}`,
599
+ );
600
+ }
601
+ }
602
+
603
+ const baseName = path.posix.basename(remoteSrcDir);
604
+ localDirPath = localDstIsDirectory
605
+ ? path.join(localDstDir, baseName)
606
+ : localDstDir;
607
+ } else {
608
+ localDirPath = localDstDir;
609
+ }
610
+
611
+ await fsp.mkdir(localDirPath, { recursive: true });
612
+
613
+ for (const entry of await this.listdir(remoteSrcDir)) {
614
+ const entryPath = path.posix.join(remoteSrcDir, entry);
615
+ const entryRelativePath = relativePath
616
+ ? path.posix.join(relativePath, entry)
617
+ : entry;
618
+
619
+ if (await this.isdir(entryPath)) {
620
+ await this._pullRecursiveInternal(
621
+ entryPath,
622
+ path.join(localDirPath, entry),
623
+ options,
624
+ entryRelativePath,
625
+ );
626
+ } else {
627
+ if (match && !minimatch(entryRelativePath, match)) {
628
+ continue;
629
+ }
630
+
631
+ const targetPath = path.join(localDirPath, entry);
632
+ if (!overwrite && (await this._localPathExists(targetPath))) {
633
+ throw new Error(`Local file already exists: ${targetPath}`);
634
+ }
635
+
636
+ await this._pullFile(entryPath, targetPath);
637
+
638
+ if (callback) {
639
+ await callback(entryPath, targetPath);
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Helper to check if a local filesystem path exists and is a directory.
647
+ */
648
+ private async _isLocalDirectory(localPath: string): Promise<boolean> {
649
+ try {
650
+ const stats = await fsp.stat(localPath);
651
+ return stats.isDirectory();
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Helper to check if a local path (file or directory) exists.
659
+ */
660
+ private async _localPathExists(localPath: string): Promise<boolean> {
661
+ try {
662
+ await fsp.access(localPath, fsp.constants.F_OK);
663
+ return true;
664
+ } catch (err: any) {
665
+ if (err.code === 'ENOENT') {
666
+ return false;
667
+ }
668
+ throw err;
669
+ }
670
+ }
671
+
421
672
  /**
422
673
  * Connect to RSD port and perform RSDCheckin.
423
674
  * Keeps the underlying socket for raw AFC I/O.