appium-xcuitest-driver 10.14.12 → 10.15.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/CHANGELOG.md +12 -0
- package/build/lib/commands/file-movement.d.ts.map +1 -1
- package/build/lib/commands/file-movement.js +73 -45
- package/build/lib/commands/file-movement.js.map +1 -1
- package/build/lib/device/afc-client.d.ts +135 -0
- package/build/lib/device/afc-client.d.ts.map +1 -0
- package/build/lib/device/afc-client.js +422 -0
- package/build/lib/device/afc-client.js.map +1 -0
- package/build/lib/device/real-device-management.d.ts +13 -14
- package/build/lib/device/real-device-management.d.ts.map +1 -1
- package/build/lib/device/real-device-management.js +50 -162
- package/build/lib/device/real-device-management.js.map +1 -1
- package/build/scripts/build-wda.d.mts +2 -0
- package/build/scripts/build-wda.d.mts.map +1 -0
- package/build/scripts/build-wda.mjs +36 -0
- package/build/scripts/build-wda.mjs.map +1 -0
- package/build/scripts/download-wda-sim.d.mts +2 -0
- package/build/scripts/download-wda-sim.d.mts.map +1 -0
- package/build/scripts/download-wda-sim.mjs +62 -0
- package/build/scripts/download-wda-sim.mjs.map +1 -0
- package/build/scripts/image-mounter.d.mts +3 -0
- package/build/scripts/image-mounter.d.mts.map +1 -0
- package/build/scripts/image-mounter.mjs +189 -0
- package/build/scripts/image-mounter.mjs.map +1 -0
- package/build/scripts/open-wda.d.mts +2 -0
- package/build/scripts/open-wda.d.mts.map +1 -0
- package/build/scripts/open-wda.mjs +13 -0
- package/build/scripts/open-wda.mjs.map +1 -0
- package/build/scripts/tunnel-creation.d.mts +3 -0
- package/build/scripts/tunnel-creation.d.mts.map +1 -0
- package/build/scripts/tunnel-creation.mjs +297 -0
- package/build/scripts/tunnel-creation.mjs.map +1 -0
- package/build/scripts/utils.d.ts +8 -0
- package/build/scripts/utils.d.ts.map +1 -0
- package/build/scripts/utils.js +20 -0
- package/build/scripts/utils.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/lib/commands/file-movement.ts +100 -56
- package/lib/device/afc-client.ts +503 -0
- package/lib/device/real-device-management.ts +73 -166
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
- package/scripts/build-wda.mjs +7 -0
- package/scripts/download-wda-sim.mjs +7 -3
- package/scripts/tunnel-creation.mjs +7 -2
- package/scripts/build-docs.js +0 -56
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import type {Readable} from 'stream';
|
|
2
|
+
import {Readable as ReadableStream} from 'stream';
|
|
3
|
+
import {pipeline} from 'stream/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import _ from 'lodash';
|
|
6
|
+
import B from 'bluebird';
|
|
7
|
+
import {fs, mkdirp} from 'appium/support';
|
|
8
|
+
import {services} from 'appium-ios-device';
|
|
9
|
+
import type {AfcService as IOSDeviceAfcService} from 'appium-ios-device';
|
|
10
|
+
import {getRemoteXPCServices} from './remotexpc-utils';
|
|
11
|
+
import {log} from '../logger';
|
|
12
|
+
import type {
|
|
13
|
+
AfcService as RemoteXPCAfcService,
|
|
14
|
+
RemoteXpcConnection,
|
|
15
|
+
} from 'appium-ios-remotexpc';
|
|
16
|
+
import {IO_TIMEOUT_MS, MAX_IO_CHUNK_SIZE} from './real-device-management';
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options for pulling files/folders
|
|
21
|
+
*/
|
|
22
|
+
export interface AfcPullOptions {
|
|
23
|
+
recursive?: boolean;
|
|
24
|
+
overwrite?: boolean;
|
|
25
|
+
onEntry?: (remotePath: string, localPath: string, isDirectory: boolean) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options for creating an AFC client for app container access
|
|
30
|
+
*/
|
|
31
|
+
export interface CreateForAppOptions {
|
|
32
|
+
containerType?: string | null;
|
|
33
|
+
skipDocumentsCheck?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unified AFC Client
|
|
38
|
+
*
|
|
39
|
+
* Provides a unified interface for file operations on iOS devices,
|
|
40
|
+
* automatically handling the differences between iOS < 18 (appium-ios-device)
|
|
41
|
+
* and iOS 18 and above (appium-ios-remotexpc).
|
|
42
|
+
*/
|
|
43
|
+
export class AfcClient {
|
|
44
|
+
private readonly service: RemoteXPCAfcService | IOSDeviceAfcService;
|
|
45
|
+
private readonly remoteXPCConnection?: RemoteXpcConnection;
|
|
46
|
+
|
|
47
|
+
private constructor(
|
|
48
|
+
service: RemoteXPCAfcService | IOSDeviceAfcService,
|
|
49
|
+
remoteXPCConnection?: RemoteXpcConnection
|
|
50
|
+
) {
|
|
51
|
+
this.service = service;
|
|
52
|
+
this.remoteXPCConnection = remoteXPCConnection;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#region Public Methods
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create an AFC client for device
|
|
59
|
+
*
|
|
60
|
+
* @param udid - Device UDID
|
|
61
|
+
* @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
|
|
62
|
+
* @returns AFC client instance
|
|
63
|
+
*/
|
|
64
|
+
static async createForDevice(udid: string, useRemoteXPC: boolean): Promise<AfcClient> {
|
|
65
|
+
if (useRemoteXPC) {
|
|
66
|
+
const client = await AfcClient.withRemoteXpcConnection(async () => {
|
|
67
|
+
const Services = await getRemoteXPCServices();
|
|
68
|
+
const connectionResult = await Services.createRemoteXPCConnection(udid);
|
|
69
|
+
const afcService = await Services.startAfcService(udid);
|
|
70
|
+
return {
|
|
71
|
+
service: afcService,
|
|
72
|
+
connection: connectionResult.remoteXPC,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
if (client) {
|
|
76
|
+
return client;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const afcService = await services.startAfcService(udid);
|
|
81
|
+
return new AfcClient(afcService);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create an AFC client for app container access
|
|
86
|
+
*
|
|
87
|
+
* @param udid - Device UDID
|
|
88
|
+
* @param bundleId - App bundle identifier
|
|
89
|
+
* @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
|
|
90
|
+
* @param options - Optional configuration for container access
|
|
91
|
+
* @returns AFC client instance
|
|
92
|
+
*/
|
|
93
|
+
static async createForApp(
|
|
94
|
+
udid: string,
|
|
95
|
+
bundleId: string,
|
|
96
|
+
useRemoteXPC: boolean,
|
|
97
|
+
options?: CreateForAppOptions
|
|
98
|
+
): Promise<AfcClient> {
|
|
99
|
+
const {containerType = null, skipDocumentsCheck = false} = options ?? {};
|
|
100
|
+
const isDocuments = !skipDocumentsCheck && containerType?.toLowerCase() === 'documents';
|
|
101
|
+
|
|
102
|
+
if (useRemoteXPC) {
|
|
103
|
+
const client = await AfcClient.withRemoteXpcConnection(async () => {
|
|
104
|
+
const Services = await getRemoteXPCServices();
|
|
105
|
+
const connectionResult = await Services.createRemoteXPCConnection(udid);
|
|
106
|
+
const {houseArrestService, remoteXPC: houseArrestRemoteXPC} = await Services.startHouseArrestService(udid);
|
|
107
|
+
const afcService = isDocuments
|
|
108
|
+
? await houseArrestService.vendDocuments(bundleId)
|
|
109
|
+
: await houseArrestService.vendContainer(bundleId);
|
|
110
|
+
// Use the remoteXPC from house arrest service if available, otherwise use the one from connection
|
|
111
|
+
const connection = houseArrestRemoteXPC ?? connectionResult.remoteXPC;
|
|
112
|
+
return {
|
|
113
|
+
service: afcService,
|
|
114
|
+
connection,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
if (client) {
|
|
118
|
+
return client;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const houseArrestService = await services.startHouseArrestService(udid);
|
|
123
|
+
const afcService = isDocuments
|
|
124
|
+
? await houseArrestService.vendDocuments(bundleId)
|
|
125
|
+
: await houseArrestService.vendContainer(bundleId);
|
|
126
|
+
return new AfcClient(afcService);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a path is a directory
|
|
131
|
+
*/
|
|
132
|
+
async isDirectory(path: string): Promise<boolean> {
|
|
133
|
+
if (this.isRemoteXPC) {
|
|
134
|
+
return await this.remoteXPCAfcService.isdir(path);
|
|
135
|
+
}
|
|
136
|
+
const fileInfo = await this.iosDeviceAfcService.getFileInfo(path);
|
|
137
|
+
return fileInfo.isDirectory();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* List directory contents
|
|
142
|
+
*/
|
|
143
|
+
async listDirectory(path: string): Promise<string[]> {
|
|
144
|
+
if (this.isRemoteXPC) {
|
|
145
|
+
return await this.remoteXPCAfcService.listdir(path);
|
|
146
|
+
}
|
|
147
|
+
return await this.iosDeviceAfcService.listDirectory(path);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create a directory
|
|
152
|
+
*/
|
|
153
|
+
async createDirectory(path: string): Promise<void> {
|
|
154
|
+
if (this.isRemoteXPC) {
|
|
155
|
+
await this.remoteXPCAfcService.mkdir(path);
|
|
156
|
+
} else {
|
|
157
|
+
await this.iosDeviceAfcService.createDirectory(path);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Delete a directory or file
|
|
163
|
+
*/
|
|
164
|
+
async deleteDirectory(path: string): Promise<void> {
|
|
165
|
+
if (this.isRemoteXPC) {
|
|
166
|
+
await this.remoteXPCAfcService.rm(path, true);
|
|
167
|
+
} else {
|
|
168
|
+
await this.iosDeviceAfcService.deleteDirectory(path);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get file contents as a buffer
|
|
174
|
+
*/
|
|
175
|
+
async getFileContents(path: string): Promise<Buffer> {
|
|
176
|
+
if (this.isRemoteXPC) {
|
|
177
|
+
return await this.remoteXPCAfcService.getFileContents(path);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// For ios-device, use stream-based approach
|
|
181
|
+
const stream = await this.iosDeviceAfcService.createReadStream(path, {
|
|
182
|
+
autoDestroy: true,
|
|
183
|
+
});
|
|
184
|
+
const buffers: Buffer[] = [];
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
stream.on('data', (data: Buffer) => buffers.push(data));
|
|
187
|
+
stream.on('end', () => resolve(Buffer.concat(buffers)));
|
|
188
|
+
stream.on('error', reject);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Set file contents from a buffer
|
|
194
|
+
*/
|
|
195
|
+
async setFileContents(path: string, data: Buffer): Promise<void> {
|
|
196
|
+
if (this.isRemoteXPC) {
|
|
197
|
+
await this.remoteXPCAfcService.setFileContents(path, data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// For ios-device, convert buffer to stream and use writeFromStream
|
|
201
|
+
const bufferStream = ReadableStream.from([data]);
|
|
202
|
+
return await this.writeFromStream(path, bufferStream);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Write file contents from a readable stream
|
|
207
|
+
*/
|
|
208
|
+
async writeFromStream(path: string, stream: Readable): Promise<void> {
|
|
209
|
+
if (this.isRemoteXPC) {
|
|
210
|
+
await this.remoteXPCAfcService.writeFromStream(path, stream);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const writeStream = await this.iosDeviceAfcService.createWriteStream(path, {
|
|
215
|
+
autoDestroy: true,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
writeStream.on('finish', () => {
|
|
219
|
+
if (typeof writeStream.destroy === 'function') {
|
|
220
|
+
writeStream.destroy();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
writeStream.on('close', resolve);
|
|
226
|
+
const onError = (e: Error) => {
|
|
227
|
+
stream.unpipe(writeStream);
|
|
228
|
+
reject(e);
|
|
229
|
+
};
|
|
230
|
+
writeStream.on('error', onError);
|
|
231
|
+
stream.on('error', onError);
|
|
232
|
+
stream.pipe(writeStream);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Pull files/folders from device to local filesystem.
|
|
238
|
+
* Uses the appropriate mechanism (walkDir for ios-device, pull for remotexpc).
|
|
239
|
+
*
|
|
240
|
+
* @param remotePath - Remote path on the device (file or directory)
|
|
241
|
+
* @param localPath - Local destination path
|
|
242
|
+
* @param options - Pull options (recursive, overwrite, onEntry)
|
|
243
|
+
*/
|
|
244
|
+
async pull(
|
|
245
|
+
remotePath: string,
|
|
246
|
+
localPath: string,
|
|
247
|
+
options: AfcPullOptions = {}
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (this.isRemoteXPC) {
|
|
250
|
+
// RemoteXPC expects 'callback' property, so map onEntry -> callback
|
|
251
|
+
const remoteXpcOptions = {
|
|
252
|
+
...options,
|
|
253
|
+
callback: options.onEntry,
|
|
254
|
+
};
|
|
255
|
+
delete remoteXpcOptions.onEntry;
|
|
256
|
+
await this.remoteXPCAfcService.pull(remotePath, localPath, remoteXpcOptions);
|
|
257
|
+
} else {
|
|
258
|
+
await this.pullWithWalkDir(remotePath, localPath, options);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Close the AFC service connection and remoteXPC connection if present
|
|
264
|
+
*/
|
|
265
|
+
async close(): Promise<void> {
|
|
266
|
+
this.service.close();
|
|
267
|
+
if (this.remoteXPCConnection) {
|
|
268
|
+
try {
|
|
269
|
+
await this.remoteXPCConnection.close();
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
//#endregion
|
|
275
|
+
|
|
276
|
+
//#region Private Methods
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if this client is using RemoteXPC
|
|
280
|
+
*/
|
|
281
|
+
private get isRemoteXPC(): boolean {
|
|
282
|
+
return !!this.remoteXPCConnection;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Helper to safely execute remoteXPC operations with connection cleanup
|
|
287
|
+
* @param operation - Async operation that returns an AfcClient
|
|
288
|
+
* @returns AfcClient on success, null on failure
|
|
289
|
+
*/
|
|
290
|
+
private static async withRemoteXpcConnection<T extends RemoteXPCAfcService | IOSDeviceAfcService>(
|
|
291
|
+
operation: () => Promise<{service: T; connection: RemoteXpcConnection}>
|
|
292
|
+
): Promise<AfcClient | null> {
|
|
293
|
+
let remoteXPCConnection: RemoteXpcConnection | undefined;
|
|
294
|
+
let succeeded = false;
|
|
295
|
+
try {
|
|
296
|
+
const {service, connection} = await operation();
|
|
297
|
+
remoteXPCConnection = connection;
|
|
298
|
+
const client = new AfcClient(service, remoteXPCConnection);
|
|
299
|
+
succeeded = true;
|
|
300
|
+
return client;
|
|
301
|
+
} catch (err: any) {
|
|
302
|
+
log.error(`Failed to create AFC client via RemoteXPC: ${err.message}, falling back to appium-ios-device`);
|
|
303
|
+
return null;
|
|
304
|
+
} finally {
|
|
305
|
+
// Only close connection if we failed (if succeeded, the client owns it)
|
|
306
|
+
if (remoteXPCConnection && !succeeded) {
|
|
307
|
+
try {
|
|
308
|
+
await remoteXPCConnection.close();
|
|
309
|
+
} catch {
|
|
310
|
+
// Ignore cleanup errors
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get service as RemoteXPC AFC service
|
|
318
|
+
*/
|
|
319
|
+
private get remoteXPCAfcService(): RemoteXPCAfcService {
|
|
320
|
+
return this.service as RemoteXPCAfcService;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get service as iOS Device AFC service
|
|
325
|
+
*/
|
|
326
|
+
private get iosDeviceAfcService(): IOSDeviceAfcService {
|
|
327
|
+
return this.service as IOSDeviceAfcService;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Create a read stream for a file (internal use only).
|
|
332
|
+
*/
|
|
333
|
+
private async createReadStream(remotePath: string, options?: {autoDestroy?: boolean}): Promise<Readable> {
|
|
334
|
+
if (this.isRemoteXPC) {
|
|
335
|
+
// Use readToStream which returns a streaming Readable
|
|
336
|
+
return await this.remoteXPCAfcService.readToStream(remotePath);
|
|
337
|
+
}
|
|
338
|
+
return await this.iosDeviceAfcService.createReadStream(remotePath, options);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Internal implementation of pull for ios-device using walkDir.
|
|
343
|
+
* Walks the remote directory tree and pulls files to local filesystem.
|
|
344
|
+
*/
|
|
345
|
+
private async pullWithWalkDir(
|
|
346
|
+
remotePath: string,
|
|
347
|
+
localPath: string,
|
|
348
|
+
options: AfcPullOptions
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
const {recursive = false, overwrite = true, onEntry} = options;
|
|
351
|
+
|
|
352
|
+
const isDir = await this.isDirectory(remotePath);
|
|
353
|
+
|
|
354
|
+
if (!isDir) {
|
|
355
|
+
// Single file pull
|
|
356
|
+
const localFilePath = (await this.isLocalDirectory(localPath))
|
|
357
|
+
? path.join(localPath, path.posix.basename(remotePath))
|
|
358
|
+
: localPath;
|
|
359
|
+
|
|
360
|
+
await this.checkOverwrite(localFilePath, overwrite);
|
|
361
|
+
await this.pullSingleFile(remotePath, localFilePath);
|
|
362
|
+
|
|
363
|
+
if (onEntry) {
|
|
364
|
+
await onEntry(remotePath, localFilePath, false);
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Directory pull requires recursive option
|
|
370
|
+
if (!recursive) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`Cannot pull directory '${remotePath}' without recursive option. Set recursive: true to pull directories.`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Determine local root directory
|
|
377
|
+
const localDstIsDirectory = await this.isLocalDirectory(localPath);
|
|
378
|
+
const localRootDir = localDstIsDirectory
|
|
379
|
+
? path.join(localPath, path.posix.basename(remotePath))
|
|
380
|
+
: localPath;
|
|
381
|
+
|
|
382
|
+
// Create the root directory
|
|
383
|
+
await mkdirp(localRootDir);
|
|
384
|
+
|
|
385
|
+
if (onEntry) {
|
|
386
|
+
await onEntry(remotePath, localRootDir, true);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const pullPromises: B<void>[] = [];
|
|
390
|
+
|
|
391
|
+
// Walk the remote directory and pull files in parallel
|
|
392
|
+
await this.iosDeviceAfcService.walkDir(remotePath, true, async (entryPath: string, isDirectory: boolean) => {
|
|
393
|
+
// Calculate relative path from remote root
|
|
394
|
+
const relativePath = entryPath.startsWith(remotePath + '/')
|
|
395
|
+
? entryPath.slice(remotePath.length + 1)
|
|
396
|
+
: entryPath.slice(remotePath.length);
|
|
397
|
+
const localEntryPath = path.join(localRootDir, relativePath);
|
|
398
|
+
|
|
399
|
+
if (isDirectory) {
|
|
400
|
+
await mkdirp(localEntryPath);
|
|
401
|
+
if (onEntry) {
|
|
402
|
+
await onEntry(entryPath, localEntryPath, true);
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
await this.checkOverwrite(localEntryPath, overwrite);
|
|
406
|
+
|
|
407
|
+
// Ensure parent directory exists
|
|
408
|
+
const parentDir = path.dirname(localEntryPath);
|
|
409
|
+
await mkdirp(parentDir);
|
|
410
|
+
|
|
411
|
+
// Start async file pull (non-blocking)
|
|
412
|
+
const readStream = await this.iosDeviceAfcService.createReadStream(entryPath, {
|
|
413
|
+
autoDestroy: true,
|
|
414
|
+
});
|
|
415
|
+
const writeStream = fs.createWriteStream(localEntryPath, {autoClose: true});
|
|
416
|
+
|
|
417
|
+
pullPromises.push(
|
|
418
|
+
new B<void>((resolve) => {
|
|
419
|
+
writeStream.on('close', async () => {
|
|
420
|
+
// Invoke onEntry callback after successful pull
|
|
421
|
+
if (onEntry) {
|
|
422
|
+
try {
|
|
423
|
+
await onEntry(entryPath, localEntryPath, false);
|
|
424
|
+
} catch (err: any) {
|
|
425
|
+
log.warn(`onEntry callback failed for '${entryPath}': ${err.message}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
resolve();
|
|
429
|
+
});
|
|
430
|
+
const onStreamingError = (e: Error) => {
|
|
431
|
+
readStream.unpipe(writeStream);
|
|
432
|
+
log.warn(
|
|
433
|
+
`Cannot pull '${entryPath}' to '${localEntryPath}'. ` +
|
|
434
|
+
`The file will be skipped. Original error: ${e.message}`
|
|
435
|
+
);
|
|
436
|
+
resolve();
|
|
437
|
+
};
|
|
438
|
+
writeStream.on('error', onStreamingError);
|
|
439
|
+
readStream.on('error', onStreamingError);
|
|
440
|
+
}).timeout(IO_TIMEOUT_MS)
|
|
441
|
+
);
|
|
442
|
+
readStream.pipe(writeStream);
|
|
443
|
+
|
|
444
|
+
if (pullPromises.length >= MAX_IO_CHUNK_SIZE) {
|
|
445
|
+
await B.any(pullPromises);
|
|
446
|
+
for (let i = pullPromises.length - 1; i >= 0; i--) {
|
|
447
|
+
if (pullPromises[i].isFulfilled()) {
|
|
448
|
+
pullPromises.splice(i, 1);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Wait for remaining files to be pulled
|
|
456
|
+
if (!_.isEmpty(pullPromises)) {
|
|
457
|
+
await B.all(pullPromises);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Check if local file exists and should not be overwritten.
|
|
463
|
+
* Throws an error if the file exists and overwrite is false.
|
|
464
|
+
*
|
|
465
|
+
* @param localPath - Local file path to check
|
|
466
|
+
* @param overwrite - Whether to allow overwriting existing files
|
|
467
|
+
*/
|
|
468
|
+
private async checkOverwrite(localPath: string, overwrite: boolean): Promise<void> {
|
|
469
|
+
if (!overwrite && await fs.exists(localPath)) {
|
|
470
|
+
throw new Error(`Local file already exists: ${localPath}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Pull a single file from device to local filesystem using streams.
|
|
476
|
+
* This method only works for ios-device.
|
|
477
|
+
*
|
|
478
|
+
* @param remotePath - Remote file path
|
|
479
|
+
* @param localPath - Local destination path
|
|
480
|
+
*/
|
|
481
|
+
private async pullSingleFile(remotePath: string, localPath: string): Promise<void> {
|
|
482
|
+
const readStream = await this.iosDeviceAfcService.createReadStream(remotePath, {
|
|
483
|
+
autoDestroy: true,
|
|
484
|
+
});
|
|
485
|
+
const writeStream = fs.createWriteStream(localPath, {autoClose: true});
|
|
486
|
+
|
|
487
|
+
await pipeline(readStream, writeStream);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Check if a local path exists and is a directory.
|
|
492
|
+
*/
|
|
493
|
+
private async isLocalDirectory(localPath: string): Promise<boolean> {
|
|
494
|
+
try {
|
|
495
|
+
const stats = await fs.stat(localPath);
|
|
496
|
+
return stats.isDirectory();
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
//#endregion
|
|
503
|
+
}
|