appium-ios-remotexpc 0.9.0 → 0.10.1
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/README.md +1 -0
- package/build/src/services/index.d.ts +2 -1
- package/build/src/services/index.d.ts.map +1 -1
- package/build/src/services/index.js +2 -1
- package/build/src/services/ios/afc/codec.d.ts +46 -0
- package/build/src/services/ios/afc/codec.d.ts.map +1 -0
- package/build/src/services/ios/afc/codec.js +263 -0
- package/build/src/services/ios/afc/constants.d.ts +11 -0
- package/build/src/services/ios/afc/constants.d.ts.map +1 -0
- package/build/src/services/ios/afc/constants.js +22 -0
- package/build/src/services/ios/afc/enums.d.ts +66 -0
- package/build/src/services/ios/afc/enums.d.ts.map +1 -0
- package/build/src/services/ios/afc/enums.js +70 -0
- package/build/src/services/ios/afc/index.d.ts +72 -0
- package/build/src/services/ios/afc/index.d.ts.map +1 -0
- package/build/src/services/ios/afc/index.js +385 -0
- package/build/src/services/ios/afc/stream-utils.d.ts +14 -0
- package/build/src/services/ios/afc/stream-utils.d.ts.map +1 -0
- package/build/src/services/ios/afc/stream-utils.js +60 -0
- package/build/src/services.d.ts +6 -0
- package/build/src/services.d.ts.map +1 -1
- package/build/src/services.js +13 -0
- package/package.json +2 -1
- package/src/services/index.ts +2 -0
- package/src/services/ios/afc/codec.ts +365 -0
- package/src/services/ios/afc/constants.ts +29 -0
- package/src/services/ios/afc/enums.ts +70 -0
- package/src/services/ios/afc/index.ts +511 -0
- package/src/services/ios/afc/stream-utils.ts +102 -0
- package/src/services.ts +15 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Readable, Writable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildClosePayload,
|
|
10
|
+
buildFopenPayload,
|
|
11
|
+
buildReadPayload,
|
|
12
|
+
buildRemovePayload,
|
|
13
|
+
buildRenamePayload,
|
|
14
|
+
buildStatPayload,
|
|
15
|
+
nanosecondsToMilliseconds,
|
|
16
|
+
nextReadChunkSize,
|
|
17
|
+
parseCStringArray,
|
|
18
|
+
parseKeyValueNullList,
|
|
19
|
+
readAfcResponse,
|
|
20
|
+
rsdHandshakeForRawService,
|
|
21
|
+
sendAfcPacket,
|
|
22
|
+
writeUInt64LE,
|
|
23
|
+
} from './codec.js';
|
|
24
|
+
import { AFC_FOPEN_TEXTUAL_MODES, AFC_WRITE_THIS_LENGTH } from './constants.js';
|
|
25
|
+
import { AfcError, AfcFileMode, AfcOpcode } from './enums.js';
|
|
26
|
+
import { createAfcReadStream, createAfcWriteStream } from './stream-utils.js';
|
|
27
|
+
|
|
28
|
+
const log = logger.getLogger('AfcService');
|
|
29
|
+
|
|
30
|
+
const NON_LISTABLE_ENTRIES = ['', '.', '..'];
|
|
31
|
+
|
|
32
|
+
export interface StatInfo {
|
|
33
|
+
st_ifmt: AfcFileMode;
|
|
34
|
+
st_size: bigint;
|
|
35
|
+
st_blocks: number;
|
|
36
|
+
st_mtime: Date;
|
|
37
|
+
st_birthtime: Date;
|
|
38
|
+
st_nlink: number;
|
|
39
|
+
LinkTarget?: string;
|
|
40
|
+
[k: string]: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* AFC client over RSD (Remote XPC shim).
|
|
45
|
+
* After RSDCheckin, speaks raw AFC protocol on the same socket.
|
|
46
|
+
*/
|
|
47
|
+
export class AfcService {
|
|
48
|
+
static readonly RSD_SERVICE_NAME = 'com.apple.afc.shim.remote';
|
|
49
|
+
|
|
50
|
+
private socket: net.Socket | null = null;
|
|
51
|
+
private packetNum: bigint = 0n;
|
|
52
|
+
private silent: boolean = false;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly address: [string, number],
|
|
56
|
+
silent?: boolean,
|
|
57
|
+
) {
|
|
58
|
+
this.silent = silent ?? process.env.NODE_ENV !== 'test';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* List directory entries. Returned entries do not include '.' and '..'
|
|
63
|
+
*/
|
|
64
|
+
async listdir(dirPath: string): Promise<string[]> {
|
|
65
|
+
const data = await this._doOperation(
|
|
66
|
+
AfcOpcode.READ_DIR,
|
|
67
|
+
buildStatPayload(dirPath),
|
|
68
|
+
);
|
|
69
|
+
const entries = parseCStringArray(data);
|
|
70
|
+
return entries.filter((x) => !NON_LISTABLE_ENTRIES.includes(x));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async stat(filePath: string): Promise<StatInfo> {
|
|
74
|
+
log.debug(`Getting file info for: ${filePath}`);
|
|
75
|
+
try {
|
|
76
|
+
const data = await this._doOperation(
|
|
77
|
+
AfcOpcode.GET_FILE_INFO,
|
|
78
|
+
buildStatPayload(filePath),
|
|
79
|
+
);
|
|
80
|
+
const kv = parseKeyValueNullList(data);
|
|
81
|
+
|
|
82
|
+
const out: StatInfo = {
|
|
83
|
+
st_size: BigInt(kv.st_size),
|
|
84
|
+
st_blocks: Number.parseInt(kv.st_blocks, 10),
|
|
85
|
+
st_mtime: new Date(nanosecondsToMilliseconds(kv.st_mtime)),
|
|
86
|
+
st_birthtime: new Date(nanosecondsToMilliseconds(kv.st_birthtime)),
|
|
87
|
+
st_nlink: Number.parseInt(kv.st_nlink, 10),
|
|
88
|
+
} as StatInfo;
|
|
89
|
+
for (const [k, v] of Object.entries(kv)) {
|
|
90
|
+
if (!(k in out)) {
|
|
91
|
+
(out as any)[k] = v;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!this.silent) {
|
|
97
|
+
log.error(`Failed to stat file '${filePath}':`, error);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async isdir(filePath: string): Promise<boolean> {
|
|
104
|
+
const st = await this.stat(filePath);
|
|
105
|
+
return st.st_ifmt === AfcFileMode.S_IFDIR;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async exists(filePath: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await this.stat(filePath);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async fopen(
|
|
118
|
+
filePath: string,
|
|
119
|
+
mode: keyof typeof AFC_FOPEN_TEXTUAL_MODES = 'r',
|
|
120
|
+
): Promise<bigint> {
|
|
121
|
+
const afcMode = AFC_FOPEN_TEXTUAL_MODES[mode];
|
|
122
|
+
if (!afcMode) {
|
|
123
|
+
const allowedModes = Object.keys(AFC_FOPEN_TEXTUAL_MODES).join(', ');
|
|
124
|
+
if (!this.silent) {
|
|
125
|
+
log.error(
|
|
126
|
+
`Invalid fopen mode '${mode}'. Allowed modes: ${allowedModes}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Invalid fopen mode '${mode}'. Allowed: ${allowedModes}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log.debug(`Opening file '${filePath}' with mode '${mode}'`);
|
|
133
|
+
try {
|
|
134
|
+
const data = await this._doOperation(
|
|
135
|
+
AfcOpcode.FILE_OPEN,
|
|
136
|
+
buildFopenPayload(afcMode, filePath),
|
|
137
|
+
);
|
|
138
|
+
// Response data contains UInt64LE 'handle'
|
|
139
|
+
const handle = data.readBigUInt64LE(0);
|
|
140
|
+
log.debug(`File opened successfully, handle: ${handle}`);
|
|
141
|
+
return handle;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!this.silent) {
|
|
144
|
+
log.error(
|
|
145
|
+
`Failed to open file '${filePath}' with mode '${mode}':`,
|
|
146
|
+
error,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async fclose(handle: bigint): Promise<void> {
|
|
154
|
+
await this._doOperation(AfcOpcode.FILE_CLOSE, buildClosePayload(handle));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
createReadStream(handle: bigint, size: bigint): Readable {
|
|
158
|
+
return createAfcReadStream(
|
|
159
|
+
handle,
|
|
160
|
+
size,
|
|
161
|
+
this._dispatch.bind(this),
|
|
162
|
+
this._receive.bind(this),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
createWriteStream(handle: bigint, chunkSize?: number): Writable {
|
|
167
|
+
return createAfcWriteStream(
|
|
168
|
+
handle,
|
|
169
|
+
this._dispatch.bind(this),
|
|
170
|
+
this._receive.bind(this),
|
|
171
|
+
chunkSize,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async fread(handle: bigint, size: bigint): Promise<Buffer> {
|
|
176
|
+
log.debug(`Reading ${size} bytes from handle ${handle}`);
|
|
177
|
+
const stream = this.createReadStream(handle, size);
|
|
178
|
+
const chunks: Buffer[] = [];
|
|
179
|
+
for await (const chunk of stream) {
|
|
180
|
+
chunks.push(chunk);
|
|
181
|
+
}
|
|
182
|
+
const buffer = Buffer.concat(chunks);
|
|
183
|
+
log.debug(`Successfully read ${buffer.length} bytes`);
|
|
184
|
+
return buffer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async fwrite(
|
|
188
|
+
handle: bigint,
|
|
189
|
+
data: Buffer,
|
|
190
|
+
chunkSize = data.length,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
log.debug(`Writing ${data.length} bytes to handle ${handle}`);
|
|
193
|
+
const effectiveChunkSize = chunkSize;
|
|
194
|
+
let offset = 0;
|
|
195
|
+
let chunkCount = 0;
|
|
196
|
+
|
|
197
|
+
while (offset < data.length) {
|
|
198
|
+
const end = Math.min(offset + effectiveChunkSize, data.length);
|
|
199
|
+
const chunk = data.subarray(offset, end);
|
|
200
|
+
chunkCount++;
|
|
201
|
+
|
|
202
|
+
await this._dispatch(
|
|
203
|
+
AfcOpcode.WRITE,
|
|
204
|
+
Buffer.concat([writeUInt64LE(handle), chunk]),
|
|
205
|
+
AFC_WRITE_THIS_LENGTH,
|
|
206
|
+
);
|
|
207
|
+
const { status } = await this._receive();
|
|
208
|
+
if (status !== AfcError.SUCCESS) {
|
|
209
|
+
const errorName = AfcError[status] || 'UNKNOWN';
|
|
210
|
+
if (!this.silent) {
|
|
211
|
+
log.error(
|
|
212
|
+
`Write operation failed at offset ${offset} with status ${errorName} (${status})`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
throw new Error(
|
|
216
|
+
`fwrite chunk failed with ${errorName} (${status}) at offset ${offset}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
offset = end;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
log.debug(
|
|
223
|
+
`Successfully wrote ${data.length} bytes in ${chunkCount} chunks`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async getFileContents(filePath: string): Promise<Buffer> {
|
|
228
|
+
log.debug(`Reading file contents: ${filePath}`);
|
|
229
|
+
const resolved = await this._resolvePath(filePath);
|
|
230
|
+
const st = await this.stat(resolved);
|
|
231
|
+
if (st.st_ifmt !== AfcFileMode.S_IFREG) {
|
|
232
|
+
if (!this.silent) {
|
|
233
|
+
log.error(
|
|
234
|
+
`Path '${resolved}' is not a regular file (type: ${st.st_ifmt})`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`'${resolved}' isn't a regular file`);
|
|
238
|
+
}
|
|
239
|
+
const h = await this.fopen(resolved, 'r');
|
|
240
|
+
try {
|
|
241
|
+
const buf = await this.fread(h, st.st_size);
|
|
242
|
+
log.debug(`Successfully read ${buf.length} bytes from ${filePath}`);
|
|
243
|
+
return buf;
|
|
244
|
+
} finally {
|
|
245
|
+
await this.fclose(h);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async setFileContents(filePath: string, data: Buffer): Promise<void> {
|
|
250
|
+
log.debug(`Writing ${data.length} bytes to file: ${filePath}`);
|
|
251
|
+
const h = await this.fopen(filePath, 'w');
|
|
252
|
+
try {
|
|
253
|
+
await this.fwrite(h, data);
|
|
254
|
+
log.debug(`Successfully wrote file: ${filePath}`);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
await this.rmSingle(filePath, true);
|
|
257
|
+
throw error;
|
|
258
|
+
} finally {
|
|
259
|
+
await this.fclose(h);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async readToStream(filePath: string): Promise<Readable> {
|
|
264
|
+
log.debug(`Creating read stream for: ${filePath}`);
|
|
265
|
+
const resolved = await this._resolvePath(filePath);
|
|
266
|
+
const st = await this.stat(resolved);
|
|
267
|
+
if (st.st_ifmt !== AfcFileMode.S_IFREG) {
|
|
268
|
+
throw new Error(`'${resolved}' isn't a regular file`);
|
|
269
|
+
}
|
|
270
|
+
const handle = await this.fopen(resolved, 'r');
|
|
271
|
+
const stream = this.createReadStream(handle, st.st_size);
|
|
272
|
+
stream.once('close', () => this.fclose(handle).catch(() => {}));
|
|
273
|
+
return stream;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async writeFromStream(filePath: string, stream: Readable): Promise<void> {
|
|
277
|
+
log.debug(`Writing stream to file: ${filePath}`);
|
|
278
|
+
const handle = await this.fopen(filePath, 'w');
|
|
279
|
+
const writeStream = this.createWriteStream(handle);
|
|
280
|
+
try {
|
|
281
|
+
await pipeline(stream, writeStream);
|
|
282
|
+
log.debug(`Successfully wrote file: ${filePath}`);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
await this.rmSingle(filePath, true);
|
|
285
|
+
throw error;
|
|
286
|
+
} finally {
|
|
287
|
+
await this.fclose(handle);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
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}'`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async rmSingle(filePath: string, force = false): Promise<boolean> {
|
|
300
|
+
log.debug(`Removing single path: ${filePath} (force: ${force})`);
|
|
301
|
+
try {
|
|
302
|
+
await this._doOperation(
|
|
303
|
+
AfcOpcode.REMOVE_PATH,
|
|
304
|
+
buildRemovePayload(filePath),
|
|
305
|
+
);
|
|
306
|
+
log.debug(`Successfully removed: ${filePath}`);
|
|
307
|
+
return true;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (force) {
|
|
310
|
+
log.debug(
|
|
311
|
+
`Failed to remove '${filePath}' (ignored due to force=true):`,
|
|
312
|
+
error,
|
|
313
|
+
);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (!this.silent) {
|
|
317
|
+
log.error(`Failed to remove '${filePath}':`, error);
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async rm(filePath: string, force = false): Promise<string[]> {
|
|
324
|
+
if (!(await this.exists(filePath))) {
|
|
325
|
+
return force ? [] : [filePath];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!(await this.isdir(filePath))) {
|
|
329
|
+
if (await this.rmSingle(filePath, force)) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
return [filePath];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const failedPaths: string[] = [];
|
|
336
|
+
for (const entry of await this.listdir(filePath)) {
|
|
337
|
+
const cur = path.posix.join(filePath, entry);
|
|
338
|
+
if (await this.isdir(cur)) {
|
|
339
|
+
const sub = await this.rm(cur, true);
|
|
340
|
+
failedPaths.push(...sub);
|
|
341
|
+
} else {
|
|
342
|
+
if (!(await this.rmSingle(cur, true))) {
|
|
343
|
+
failedPaths.push(cur);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
if (!(await this.rmSingle(filePath, force))) {
|
|
350
|
+
failedPaths.push(filePath);
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (failedPaths.length) {
|
|
354
|
+
failedPaths.push(filePath);
|
|
355
|
+
} else {
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return failedPaths;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async rename(src: string, dst: string): Promise<void> {
|
|
364
|
+
log.debug(`Renaming '${src}' to '${dst}'`);
|
|
365
|
+
try {
|
|
366
|
+
await this._doOperation(
|
|
367
|
+
AfcOpcode.RENAME_PATH,
|
|
368
|
+
buildRenamePayload(src, dst),
|
|
369
|
+
);
|
|
370
|
+
log.debug(`Successfully renamed '${src}' to '${dst}'`);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!this.silent) {
|
|
373
|
+
log.error(`Failed to rename '${src}' to '${dst}':`, error);
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async push(localSrc: string, remoteDst: string): Promise<void> {
|
|
380
|
+
log.debug(`Pushing file from '${localSrc}' to '${remoteDst}'`);
|
|
381
|
+
const readStream = fs.createReadStream(localSrc);
|
|
382
|
+
await this.writeFromStream(remoteDst, readStream);
|
|
383
|
+
log.debug(`Successfully pushed file to '${remoteDst}'`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async walk(
|
|
387
|
+
root: string,
|
|
388
|
+
): Promise<Array<{ dir: string; dirs: string[]; files: string[] }>> {
|
|
389
|
+
const out: Array<{ dir: string; dirs: string[]; files: string[] }> = [];
|
|
390
|
+
const entries = await this.listdir(root);
|
|
391
|
+
const dirs: string[] = [];
|
|
392
|
+
const files: string[] = [];
|
|
393
|
+
for (const e of entries) {
|
|
394
|
+
const p = path.posix.join(root, e);
|
|
395
|
+
if (await this.isdir(p)) {
|
|
396
|
+
dirs.push(e);
|
|
397
|
+
} else {
|
|
398
|
+
files.push(e);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
out.push({ dir: root, dirs, files });
|
|
402
|
+
for (const d of dirs) {
|
|
403
|
+
out.push(...(await this.walk(path.posix.join(root, d))));
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Close the underlying socket
|
|
410
|
+
*/
|
|
411
|
+
close(): void {
|
|
412
|
+
log.debug('Closing AFC service connection');
|
|
413
|
+
try {
|
|
414
|
+
this.socket?.end();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
log.debug('Error while closing socket (ignored):', error);
|
|
417
|
+
}
|
|
418
|
+
this.socket = null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Connect to RSD port and perform RSDCheckin.
|
|
423
|
+
* Keeps the underlying socket for raw AFC I/O.
|
|
424
|
+
*/
|
|
425
|
+
private async _connect(): Promise<net.Socket> {
|
|
426
|
+
if (this.socket && !this.socket.destroyed) {
|
|
427
|
+
return this.socket;
|
|
428
|
+
}
|
|
429
|
+
const [host, rsdPort] = this.address;
|
|
430
|
+
|
|
431
|
+
this.socket = await new Promise<net.Socket>((resolve, reject) => {
|
|
432
|
+
const s = net.createConnection({ host, port: rsdPort }, () => {
|
|
433
|
+
s.setTimeout(0);
|
|
434
|
+
s.setKeepAlive(true);
|
|
435
|
+
resolve(s);
|
|
436
|
+
});
|
|
437
|
+
s.once('error', reject);
|
|
438
|
+
s.setTimeout(30000, () => {
|
|
439
|
+
s.destroy();
|
|
440
|
+
reject(new Error('AFC connect timed out'));
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await rsdHandshakeForRawService(this.socket);
|
|
445
|
+
log.debug('RSD handshake complete; switching to raw AFC');
|
|
446
|
+
|
|
447
|
+
return this.socket;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async _resolvePath(filePath: string): Promise<string> {
|
|
451
|
+
const info = await this.stat(filePath);
|
|
452
|
+
if (info.st_ifmt === AfcFileMode.S_IFLNK && info.LinkTarget) {
|
|
453
|
+
const target = info.LinkTarget;
|
|
454
|
+
if (target.startsWith('/')) {
|
|
455
|
+
return target;
|
|
456
|
+
}
|
|
457
|
+
return path.posix.join(path.posix.dirname(filePath), target);
|
|
458
|
+
}
|
|
459
|
+
return filePath;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async _dispatch(
|
|
463
|
+
op: AfcOpcode,
|
|
464
|
+
payload: Buffer = Buffer.alloc(0),
|
|
465
|
+
thisLenOverride?: number,
|
|
466
|
+
): Promise<void> {
|
|
467
|
+
const sock = await this._connect();
|
|
468
|
+
await sendAfcPacket(sock, op, this.packetNum++, payload, thisLenOverride);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async _receive(): Promise<{ status: AfcError; data: Buffer }> {
|
|
472
|
+
const sock = await this._connect();
|
|
473
|
+
const res = await readAfcResponse(sock);
|
|
474
|
+
return { status: res.status, data: res.data };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Send a single-operation request and parse result.
|
|
479
|
+
* Throws if status != SUCCESS.
|
|
480
|
+
* Returns response DATA buffer when applicable.
|
|
481
|
+
*/
|
|
482
|
+
private async _doOperation(
|
|
483
|
+
op: AfcOpcode,
|
|
484
|
+
payload: Buffer = Buffer.alloc(0),
|
|
485
|
+
thisLenOverride?: number,
|
|
486
|
+
): Promise<Buffer> {
|
|
487
|
+
await this._dispatch(op, payload, thisLenOverride);
|
|
488
|
+
const { status, data } = await this._receive();
|
|
489
|
+
|
|
490
|
+
if (status !== AfcError.SUCCESS) {
|
|
491
|
+
const errorName = AfcError[status] || 'UNKNOWN';
|
|
492
|
+
const opName = AfcOpcode[op] || op.toString();
|
|
493
|
+
|
|
494
|
+
if (status === AfcError.OBJECT_NOT_FOUND) {
|
|
495
|
+
throw new Error(`AFC error: OBJECT_NOT_FOUND for operation ${opName}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!this.silent) {
|
|
499
|
+
log.error(
|
|
500
|
+
`AFC operation ${opName} failed with status ${errorName} (${status})`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
throw new Error(
|
|
504
|
+
`AFC operation ${opName} failed with ${errorName} (${status})`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return data;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export default AfcService;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Readable, Writable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
import { buildReadPayload, nextReadChunkSize, writeUInt64LE } from './codec.js';
|
|
4
|
+
import { AFC_WRITE_THIS_LENGTH } from './constants.js';
|
|
5
|
+
import { AfcError, AfcOpcode } from './enums.js';
|
|
6
|
+
|
|
7
|
+
type AfcDispatcher = (op: AfcOpcode, payload: Buffer) => Promise<void>;
|
|
8
|
+
|
|
9
|
+
type AfcWriteDispatcher = (
|
|
10
|
+
op: AfcOpcode,
|
|
11
|
+
payload: Buffer,
|
|
12
|
+
thisLenOverride?: number,
|
|
13
|
+
) => Promise<void>;
|
|
14
|
+
|
|
15
|
+
export function createAfcReadStream(
|
|
16
|
+
handle: bigint,
|
|
17
|
+
size: bigint,
|
|
18
|
+
dispatch: AfcDispatcher,
|
|
19
|
+
receive: () => Promise<{ status: AfcError; data: Buffer }>,
|
|
20
|
+
): Readable {
|
|
21
|
+
let left = size;
|
|
22
|
+
let totalRead = 0n;
|
|
23
|
+
|
|
24
|
+
return new Readable({
|
|
25
|
+
async read() {
|
|
26
|
+
try {
|
|
27
|
+
if (left <= 0n) {
|
|
28
|
+
this.push(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const toRead = nextReadChunkSize(left);
|
|
33
|
+
await dispatch(AfcOpcode.READ, buildReadPayload(handle, toRead));
|
|
34
|
+
const { status, data } = await receive();
|
|
35
|
+
|
|
36
|
+
if (status !== AfcError.SUCCESS) {
|
|
37
|
+
const errorName = AfcError[status] || 'UNKNOWN';
|
|
38
|
+
this.destroy(new Error(`fread error: ${errorName} (${status})`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
totalRead += BigInt(data.length);
|
|
43
|
+
left -= BigInt(data.length);
|
|
44
|
+
|
|
45
|
+
this.push(data);
|
|
46
|
+
|
|
47
|
+
if (BigInt(data.length) < toRead) {
|
|
48
|
+
this.push(null);
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
this.destroy(error as Error);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createAfcWriteStream(
|
|
58
|
+
handle: bigint,
|
|
59
|
+
dispatch: AfcWriteDispatcher,
|
|
60
|
+
receive: () => Promise<{ status: AfcError; data: Buffer }>,
|
|
61
|
+
chunkSize?: number,
|
|
62
|
+
): Writable {
|
|
63
|
+
return new Writable({
|
|
64
|
+
async write(
|
|
65
|
+
chunk: Buffer,
|
|
66
|
+
encoding: BufferEncoding,
|
|
67
|
+
callback: (error?: Error | null) => void,
|
|
68
|
+
) {
|
|
69
|
+
try {
|
|
70
|
+
let offset = 0;
|
|
71
|
+
while (offset < chunk.length) {
|
|
72
|
+
const end = Math.min(
|
|
73
|
+
offset + (chunkSize ?? chunk.length),
|
|
74
|
+
chunk.length,
|
|
75
|
+
);
|
|
76
|
+
const subchunk = chunk.subarray(offset, end);
|
|
77
|
+
|
|
78
|
+
await dispatch(
|
|
79
|
+
AfcOpcode.WRITE,
|
|
80
|
+
Buffer.concat([writeUInt64LE(handle), subchunk]),
|
|
81
|
+
AFC_WRITE_THIS_LENGTH,
|
|
82
|
+
);
|
|
83
|
+
const { status } = await receive();
|
|
84
|
+
|
|
85
|
+
if (status !== AfcError.SUCCESS) {
|
|
86
|
+
const errorName = AfcError[status] || 'UNKNOWN';
|
|
87
|
+
callback(
|
|
88
|
+
new Error(
|
|
89
|
+
`fwrite chunk failed with ${errorName} (${status}) at offset ${offset}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
offset = end;
|
|
95
|
+
}
|
|
96
|
+
callback();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
callback(error as Error);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
package/src/services.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SyslogService as SyslogServiceType,
|
|
14
14
|
WebInspectorServiceWithConnection,
|
|
15
15
|
} from './lib/types.js';
|
|
16
|
+
import AfcService from './services/ios/afc/index.js';
|
|
16
17
|
import DiagnosticsService from './services/ios/diagnostic-service/index.js';
|
|
17
18
|
import { MobileConfigService } from './services/ios/mobile-config/index.js';
|
|
18
19
|
import MobileImageMounterService from './services/ios/mobile-image-mounter/index.js';
|
|
@@ -72,6 +73,7 @@ export async function startMobileConfigService(
|
|
|
72
73
|
]),
|
|
73
74
|
};
|
|
74
75
|
}
|
|
76
|
+
|
|
75
77
|
export async function startMobileImageMounterService(
|
|
76
78
|
udid: string,
|
|
77
79
|
): Promise<MobileImageMounterServiceWithConnection> {
|
|
@@ -127,6 +129,19 @@ export async function startSyslogService(
|
|
|
127
129
|
return new SyslogService([tunnelConnection.host, tunnelConnection.port]);
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Start AFC service over RemoteXPC shim.
|
|
134
|
+
* Resolves the AFC service port via RemoteXPC and returns a ready-to-use AfcService instance.
|
|
135
|
+
*/
|
|
136
|
+
export async function startAfcService(udid: string): Promise<AfcService> {
|
|
137
|
+
const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
|
|
138
|
+
const afcDescriptor = remoteXPC.findService(AfcService.RSD_SERVICE_NAME);
|
|
139
|
+
return new AfcService([
|
|
140
|
+
tunnelConnection.host,
|
|
141
|
+
parseInt(afcDescriptor.port, 10),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
export async function startWebInspectorService(
|
|
131
146
|
udid: string,
|
|
132
147
|
): Promise<WebInspectorServiceWithConnection> {
|