@limrun/api 0.18.3 → 0.19.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 +9 -0
- package/folder-sync-watcher.d.mts +17 -0
- package/folder-sync-watcher.d.mts.map +1 -0
- package/folder-sync-watcher.d.ts +17 -0
- package/folder-sync-watcher.d.ts.map +1 -0
- package/folder-sync-watcher.js +91 -0
- package/folder-sync-watcher.js.map +1 -0
- package/folder-sync-watcher.mjs +87 -0
- package/folder-sync-watcher.mjs.map +1 -0
- package/folder-sync.d.mts +23 -0
- package/folder-sync.d.mts.map +1 -0
- package/folder-sync.d.ts +23 -0
- package/folder-sync.d.ts.map +1 -0
- package/folder-sync.js +447 -0
- package/folder-sync.js.map +1 -0
- package/folder-sync.mjs +442 -0
- package/folder-sync.mjs.map +1 -0
- package/ios-client.d.mts +13 -3
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +13 -3
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +38 -0
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +38 -0
- package/ios-client.mjs.map +1 -1
- package/package.json +21 -1
- package/resources/android-instances.d.mts +2 -2
- package/resources/android-instances.d.ts +2 -2
- package/resources/ios-instances.d.mts +2 -2
- package/resources/ios-instances.d.ts +2 -2
- package/src/folder-sync-watcher.ts +99 -0
- package/src/folder-sync.ts +557 -0
- package/src/ios-client.ts +67 -7
- package/src/resources/android-instances.ts +2 -2
- package/src/resources/ios-instances.ts +2 -2
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { watchFolderTree } from './folder-sync-watcher';
|
|
7
|
+
import { Readable } from 'stream';
|
|
8
|
+
import * as zlib from 'zlib';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Folder Sync (HTTP batch)
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type FolderSyncOptions = {
|
|
15
|
+
apiUrl: string;
|
|
16
|
+
token: string;
|
|
17
|
+
udid: string; // used only for local cache scoping
|
|
18
|
+
install?: boolean;
|
|
19
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning' | 'FailIfRunning';
|
|
20
|
+
/** If true, watch the folder and re-sync on any changes (debounced, single-flight). */
|
|
21
|
+
watch?: boolean;
|
|
22
|
+
/** Max patch size (bytes) to send as delta before falling back to full upload. */
|
|
23
|
+
maxPatchBytes?: number;
|
|
24
|
+
/** Controls logging verbosity */
|
|
25
|
+
log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SyncFolderResult = {
|
|
29
|
+
installedAppPath?: string;
|
|
30
|
+
installedBundleId?: string;
|
|
31
|
+
/** Present only when watch=true; call to stop watching. */
|
|
32
|
+
stopWatching?: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type FileEntry = { path: string; size: number; sha256: string; absPath: string; mode: number };
|
|
36
|
+
|
|
37
|
+
type FolderSyncHttpPayload = {
|
|
38
|
+
kind: 'delta' | 'full';
|
|
39
|
+
path: string;
|
|
40
|
+
/** Required for delta. Must match server's current sha for this path. */
|
|
41
|
+
basisSha256?: string;
|
|
42
|
+
/** Expected target sha after apply (also must match manifest's sha for path). */
|
|
43
|
+
targetSha256: string;
|
|
44
|
+
/** Number of bytes that will follow for this payload in the request body. */
|
|
45
|
+
length: number;
|
|
46
|
+
};
|
|
47
|
+
type FolderSyncHttpMeta = {
|
|
48
|
+
id: string;
|
|
49
|
+
rootName: string;
|
|
50
|
+
install?: boolean;
|
|
51
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning' | 'FailIfRunning';
|
|
52
|
+
files: { path: string; size: number; sha256: string; mode: number }[];
|
|
53
|
+
payloads: FolderSyncHttpPayload[];
|
|
54
|
+
};
|
|
55
|
+
type FolderSyncHttpResponse = {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
needFull?: string[];
|
|
58
|
+
installedAppPath?: string;
|
|
59
|
+
bundleId?: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const noopLogger = (_level: 'debug' | 'info' | 'warn' | 'error', _msg: string) => {
|
|
64
|
+
// Intentionally empty: callers (e.g. ios-client.ts) should provide their own logger
|
|
65
|
+
// to control verbosity and integrate with the SDK's logging setup.
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function nowMs(): number {
|
|
69
|
+
return Date.now();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fmtMs(ms: number): string {
|
|
73
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
74
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fmtBytes(bytes: number): string {
|
|
78
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
79
|
+
const kib = bytes / 1024;
|
|
80
|
+
if (kib < 1024) return `${kib.toFixed(1)}KiB`;
|
|
81
|
+
const mib = kib / 1024;
|
|
82
|
+
if (mib < 1024) return `${mib.toFixed(1)}MiB`;
|
|
83
|
+
const gib = mib / 1024;
|
|
84
|
+
return `${gib.toFixed(2)}GiB`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function genId(prefix: string): string {
|
|
88
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isENOENT(err: unknown): boolean {
|
|
92
|
+
const e = err as { code?: string; cause?: { code?: string } };
|
|
93
|
+
return e?.code === 'ENOENT' || e?.cause?.code === 'ENOENT';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function concurrencyLimit(): number {
|
|
97
|
+
// min(4, max(1, cpuCount-1))
|
|
98
|
+
const cpu = os.cpus()?.length ?? 1;
|
|
99
|
+
return Math.min(4, Math.max(1, cpu - 1));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
103
|
+
const results: R[] = new Array(items.length);
|
|
104
|
+
let idx = 0;
|
|
105
|
+
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
|
|
106
|
+
while (true) {
|
|
107
|
+
const my = idx++;
|
|
108
|
+
if (my >= items.length) return;
|
|
109
|
+
const item = items[my]!;
|
|
110
|
+
results[my] = await fn(item);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
await Promise.all(workers);
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function folderSyncHttpUrl(apiUrl: string): string {
|
|
118
|
+
return `${apiUrl}/folder-sync`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function u32be(n: number): Buffer {
|
|
122
|
+
const b = Buffer.allocUnsafe(4);
|
|
123
|
+
b.writeUInt32BE(n >>> 0, 0);
|
|
124
|
+
return b;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function httpFolderSyncBatch(
|
|
128
|
+
opts: FolderSyncOptions,
|
|
129
|
+
meta: FolderSyncHttpMeta,
|
|
130
|
+
payloadFiles: { filePath: string }[],
|
|
131
|
+
compression: 'zstd' | 'gzip' | 'identity',
|
|
132
|
+
): Promise<FolderSyncHttpResponse> {
|
|
133
|
+
const url = folderSyncHttpUrl(opts.apiUrl);
|
|
134
|
+
const headers: Record<string, string> = {
|
|
135
|
+
// OpenAPI route expects application/octet-stream.
|
|
136
|
+
'Content-Type': 'application/octet-stream',
|
|
137
|
+
Authorization: `Bearer ${opts.token}`,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const metaBytes = Buffer.from(JSON.stringify(meta), 'utf-8');
|
|
141
|
+
const head = Buffer.concat([u32be(metaBytes.length), metaBytes]);
|
|
142
|
+
|
|
143
|
+
async function* gen(): AsyncGenerator<Buffer> {
|
|
144
|
+
yield head;
|
|
145
|
+
for (const p of payloadFiles) {
|
|
146
|
+
const fd = await fs.promises.open(p.filePath, 'r');
|
|
147
|
+
try {
|
|
148
|
+
const st = await fd.stat();
|
|
149
|
+
let offset = 0;
|
|
150
|
+
while (offset < st.size) {
|
|
151
|
+
const len = Math.min(256 * 1024, st.size - offset);
|
|
152
|
+
const buf = Buffer.allocUnsafe(len);
|
|
153
|
+
const { bytesRead } = await fd.read(buf, 0, len, offset);
|
|
154
|
+
if (bytesRead <= 0) break;
|
|
155
|
+
offset += bytesRead;
|
|
156
|
+
yield buf.subarray(0, bytesRead);
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
await fd.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sourceStream = Readable.from(gen());
|
|
165
|
+
let bodyStream: Readable | NodeJS.ReadWriteStream;
|
|
166
|
+
if (compression === 'zstd') {
|
|
167
|
+
const createZstd = (zlib as any).createZstdCompress as
|
|
168
|
+
| ((opts?: { level?: number }) => NodeJS.ReadWriteStream)
|
|
169
|
+
| undefined;
|
|
170
|
+
if (!createZstd) {
|
|
171
|
+
throw new Error('zstd compression not available in this Node.js version');
|
|
172
|
+
}
|
|
173
|
+
bodyStream = sourceStream.pipe(createZstd({ level: 3 }));
|
|
174
|
+
headers['Content-Encoding'] = 'zstd';
|
|
175
|
+
} else if (compression === 'gzip') {
|
|
176
|
+
const createGzip = zlib.createGzip as ((opts?: zlib.ZlibOptions) => NodeJS.ReadWriteStream) | undefined;
|
|
177
|
+
if (!createGzip) {
|
|
178
|
+
throw new Error('gzip compression not available in this Node.js version');
|
|
179
|
+
}
|
|
180
|
+
bodyStream = sourceStream.pipe(createGzip({ level: 6 }));
|
|
181
|
+
headers['Content-Encoding'] = 'gzip';
|
|
182
|
+
} else {
|
|
183
|
+
bodyStream = sourceStream;
|
|
184
|
+
}
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
let streamError: unknown;
|
|
187
|
+
const onStreamError = (err: unknown) => {
|
|
188
|
+
streamError = err;
|
|
189
|
+
controller.abort();
|
|
190
|
+
};
|
|
191
|
+
sourceStream.on('error', onStreamError);
|
|
192
|
+
bodyStream.on('error', onStreamError);
|
|
193
|
+
const res = await fetch(url, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers,
|
|
196
|
+
body: bodyStream as any,
|
|
197
|
+
duplex: 'half' as any,
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
} as any).catch((err) => {
|
|
200
|
+
if (streamError) {
|
|
201
|
+
throw streamError;
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
});
|
|
205
|
+
const text = await res.text();
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
throw new Error(`folder-sync http failed: ${res.status} ${text}`);
|
|
208
|
+
}
|
|
209
|
+
return JSON.parse(text) as FolderSyncHttpResponse;
|
|
210
|
+
}
|
|
211
|
+
async function sha256FileHex(filePath: string): Promise<string> {
|
|
212
|
+
return await new Promise((resolve, reject) => {
|
|
213
|
+
const hash = crypto.createHash('sha256');
|
|
214
|
+
const stream = fs.createReadStream(filePath);
|
|
215
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
216
|
+
stream.on('error', reject);
|
|
217
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function walkFiles(root: string): Promise<FileEntry[]> {
|
|
222
|
+
const out: FileEntry[] = [];
|
|
223
|
+
const stack: string[] = [root];
|
|
224
|
+
const rootResolved = path.resolve(root);
|
|
225
|
+
while (stack.length) {
|
|
226
|
+
const dir = stack.pop()!;
|
|
227
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
228
|
+
for (const ent of entries) {
|
|
229
|
+
if (ent.name === '.DS_Store') continue;
|
|
230
|
+
const abs = path.join(dir, ent.name);
|
|
231
|
+
if (ent.isDirectory()) {
|
|
232
|
+
stack.push(abs);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!ent.isFile()) continue;
|
|
236
|
+
const st = await fs.promises.stat(abs);
|
|
237
|
+
const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
|
|
238
|
+
const sha256 = await sha256FileHex(abs);
|
|
239
|
+
// Preserve POSIX permission bits (including +x). Mask out file-type bits.
|
|
240
|
+
const mode = st.mode & 0o7777;
|
|
241
|
+
out.push({ path: rel, size: st.size, sha256, absPath: abs, mode });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let xdelta3Ready: Promise<void> | null = null;
|
|
249
|
+
async function ensureXdelta3(): Promise<void> {
|
|
250
|
+
if (!xdelta3Ready) {
|
|
251
|
+
xdelta3Ready = new Promise<void>((resolve, reject) => {
|
|
252
|
+
const p = spawn('xdelta3', ['-V']);
|
|
253
|
+
p.on('error', reject);
|
|
254
|
+
p.on('exit', (code) => {
|
|
255
|
+
if (code === 0) resolve();
|
|
256
|
+
else reject(new Error(`xdelta3 not available (exit=${code})`));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return await xdelta3Ready;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function runXdelta3Encode(basis: string, target: string, outPatch: string): Promise<void> {
|
|
264
|
+
await new Promise<void>((resolve, reject) => {
|
|
265
|
+
const p = spawn('xdelta3', ['-e', '-s', basis, target, outPatch], {
|
|
266
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
267
|
+
});
|
|
268
|
+
let stderr = '';
|
|
269
|
+
p.stderr.on('data', (d) => (stderr += d.toString()));
|
|
270
|
+
p.on('error', reject);
|
|
271
|
+
p.on('exit', (code) => {
|
|
272
|
+
if (code === 0) resolve();
|
|
273
|
+
else reject(new Error(`xdelta3 encode failed (exit=${code}): ${stderr.trim()}`));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function localBasisCacheRoot(opts: FolderSyncOptions, localFolderPath: string): string {
|
|
279
|
+
const hostKey = opts.apiUrl.replace(/[:/]+/g, '_');
|
|
280
|
+
const resolved = path.resolve(localFolderPath);
|
|
281
|
+
const base = path.basename(resolved);
|
|
282
|
+
const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 8);
|
|
283
|
+
// Include folder identity to avoid collisions between different roots.
|
|
284
|
+
return path.join(os.homedir(), '.cache', 'limulator', 'folder-sync', hostKey, opts.udid, `${base}-${hash}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function cachePut(cacheRoot: string, relPath: string, srcFile: string): Promise<void> {
|
|
288
|
+
const dst = path.join(cacheRoot, relPath.split('/').join(path.sep));
|
|
289
|
+
await fs.promises.mkdir(path.dirname(dst), { recursive: true });
|
|
290
|
+
await fs.promises.copyFile(srcFile, dst);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function cacheGet(cacheRoot: string, relPath: string): string {
|
|
294
|
+
return path.join(cacheRoot, relPath.split('/').join(path.sep));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type SyncAppResult = SyncFolderResult;
|
|
298
|
+
|
|
299
|
+
export async function syncApp(localFolderPath: string, opts: FolderSyncOptions): Promise<SyncFolderResult> {
|
|
300
|
+
if (!opts.watch) {
|
|
301
|
+
return await syncFolderOnce(localFolderPath, opts);
|
|
302
|
+
}
|
|
303
|
+
// Initial sync, then watch for changes and re-run sync in the background.
|
|
304
|
+
const first = await syncFolderOnce(localFolderPath, opts, 'startup');
|
|
305
|
+
let inFlight = false;
|
|
306
|
+
let queued = false;
|
|
307
|
+
|
|
308
|
+
const run = async (reason: string) => {
|
|
309
|
+
if (inFlight) {
|
|
310
|
+
queued = true;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
inFlight = true;
|
|
314
|
+
try {
|
|
315
|
+
await syncFolderOnce(localFolderPath, opts, reason);
|
|
316
|
+
} finally {
|
|
317
|
+
inFlight = false;
|
|
318
|
+
if (queued) {
|
|
319
|
+
queued = false;
|
|
320
|
+
void run('queued-changes');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const watcherLog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => {
|
|
326
|
+
(opts.log ?? noopLogger)(level, `syncApp: ${msg}`);
|
|
327
|
+
};
|
|
328
|
+
const watcher = await watchFolderTree({
|
|
329
|
+
rootPath: localFolderPath,
|
|
330
|
+
log: watcherLog,
|
|
331
|
+
onChange: (reason) => {
|
|
332
|
+
void run(reason);
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
...first,
|
|
338
|
+
stopWatching: () => {
|
|
339
|
+
watcher.close();
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Back-compat alias (older callers)
|
|
345
|
+
export async function syncFolder(
|
|
346
|
+
localFolderPath: string,
|
|
347
|
+
opts: FolderSyncOptions,
|
|
348
|
+
): Promise<SyncFolderResult> {
|
|
349
|
+
return await syncApp(localFolderPath, opts);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function syncFolderOnce(
|
|
353
|
+
localFolderPath: string,
|
|
354
|
+
opts: FolderSyncOptions,
|
|
355
|
+
reason?: string,
|
|
356
|
+
attempt = 0,
|
|
357
|
+
): Promise<SyncFolderResult> {
|
|
358
|
+
const totalStart = nowMs();
|
|
359
|
+
const log = opts.log ?? noopLogger;
|
|
360
|
+
const slog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => log(level, `syncApp: ${msg}`);
|
|
361
|
+
const maxPatchBytes = opts.maxPatchBytes ?? 4 * 1024 * 1024;
|
|
362
|
+
|
|
363
|
+
const tEnsureStart = nowMs();
|
|
364
|
+
await ensureXdelta3();
|
|
365
|
+
const tEnsureMs = nowMs() - tEnsureStart;
|
|
366
|
+
|
|
367
|
+
const tWalkStart = nowMs();
|
|
368
|
+
const files = await walkFiles(localFolderPath);
|
|
369
|
+
const tWalkMs = nowMs() - tWalkStart;
|
|
370
|
+
const fileMap = new Map(files.map((f) => [f.path, f]));
|
|
371
|
+
|
|
372
|
+
const syncId = genId('sync');
|
|
373
|
+
const rootName = path.basename(path.resolve(localFolderPath));
|
|
374
|
+
const preferredCompression = (zlib as any).createZstdCompress ? 'zstd' : 'gzip';
|
|
375
|
+
|
|
376
|
+
const cacheRoot = localBasisCacheRoot(opts, localFolderPath);
|
|
377
|
+
await fs.promises.mkdir(cacheRoot, { recursive: true });
|
|
378
|
+
|
|
379
|
+
// Track how many bytes we actually transmit to the server (single HTTP request).
|
|
380
|
+
let bytesSentFull = 0;
|
|
381
|
+
let bytesSentDelta = 0;
|
|
382
|
+
let httpSendMsTotal = 0;
|
|
383
|
+
let deltaEncodeMsTotal = 0;
|
|
384
|
+
type EncodedPayload = { payload: FolderSyncHttpPayload; filePath: string; cleanupDir?: string };
|
|
385
|
+
|
|
386
|
+
// Build payload list by comparing against local basis cache (single-flight/watch assumes server matches cache).
|
|
387
|
+
const encodeLimit = concurrencyLimit();
|
|
388
|
+
const changed: FileEntry[] = [];
|
|
389
|
+
for (const f of files) {
|
|
390
|
+
const basisPath = cacheGet(cacheRoot, f.path);
|
|
391
|
+
if (!fs.existsSync(basisPath)) {
|
|
392
|
+
changed.push(f);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const basisSha = await sha256FileHex(basisPath);
|
|
396
|
+
if (basisSha !== f.sha256.toLowerCase()) {
|
|
397
|
+
changed.push(f);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const encodedPayloads = await mapLimit(changed, encodeLimit, async (f): Promise<EncodedPayload> => {
|
|
402
|
+
const basisPath = cacheGet(cacheRoot, f.path);
|
|
403
|
+
if (fs.existsSync(basisPath)) {
|
|
404
|
+
const basisSha = await sha256FileHex(basisPath);
|
|
405
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'limulator-xdelta3-'));
|
|
406
|
+
const patchPath = path.join(tmpDir, 'patch.xdelta3');
|
|
407
|
+
const encodeStart = nowMs();
|
|
408
|
+
await runXdelta3Encode(basisPath, f.absPath, patchPath);
|
|
409
|
+
const encodeMs = nowMs() - encodeStart;
|
|
410
|
+
deltaEncodeMsTotal += encodeMs;
|
|
411
|
+
const st = await fs.promises.stat(patchPath);
|
|
412
|
+
if (st.size <= maxPatchBytes) {
|
|
413
|
+
slog(
|
|
414
|
+
'debug',
|
|
415
|
+
`delta(file): ${path.posix.basename(f.path)} patchSize=${st.size} encode=${fmtMs(encodeMs)}`,
|
|
416
|
+
);
|
|
417
|
+
bytesSentDelta += st.size;
|
|
418
|
+
return {
|
|
419
|
+
payload: {
|
|
420
|
+
kind: 'delta',
|
|
421
|
+
path: f.path,
|
|
422
|
+
basisSha256: basisSha.toLowerCase(),
|
|
423
|
+
targetSha256: f.sha256.toLowerCase(),
|
|
424
|
+
length: st.size,
|
|
425
|
+
},
|
|
426
|
+
filePath: patchPath,
|
|
427
|
+
cleanupDir: tmpDir,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// Patch too big, fall back to full
|
|
431
|
+
try {
|
|
432
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
433
|
+
} catch {
|
|
434
|
+
// ignore
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
slog('debug', `full(file): ${f.path} size=${f.size}`);
|
|
438
|
+
bytesSentFull += f.size;
|
|
439
|
+
return {
|
|
440
|
+
payload: { kind: 'full', path: f.path, targetSha256: f.sha256.toLowerCase(), length: f.size },
|
|
441
|
+
filePath: f.absPath,
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const meta: FolderSyncHttpMeta = {
|
|
446
|
+
id: syncId,
|
|
447
|
+
rootName,
|
|
448
|
+
install: opts.install ?? true,
|
|
449
|
+
...(opts.launchMode ? { launchMode: opts.launchMode } : {}),
|
|
450
|
+
files: files.map((f) => ({ path: f.path, size: f.size, sha256: f.sha256.toLowerCase(), mode: f.mode })),
|
|
451
|
+
payloads: encodedPayloads.map((p) => p.payload),
|
|
452
|
+
};
|
|
453
|
+
const hasDelta = encodedPayloads.some((p) => p.payload.kind === 'delta');
|
|
454
|
+
const compression: 'zstd' | 'gzip' | 'identity' = hasDelta ? 'identity' : preferredCompression;
|
|
455
|
+
slog(
|
|
456
|
+
'info',
|
|
457
|
+
`sync started files=${files.length}${reason ? ` reason=${reason}` : ''} compression=${compression}`,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const sendStart = nowMs();
|
|
461
|
+
let resp: FolderSyncHttpResponse;
|
|
462
|
+
try {
|
|
463
|
+
resp = await httpFolderSyncBatch(
|
|
464
|
+
opts,
|
|
465
|
+
meta,
|
|
466
|
+
encodedPayloads.map((p) => ({ filePath: p.filePath })),
|
|
467
|
+
compression,
|
|
468
|
+
);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (attempt < 1 && isENOENT(err)) {
|
|
471
|
+
slog('warn', `sync retrying after missing file during upload (ENOENT)`);
|
|
472
|
+
return await syncFolderOnce(localFolderPath, opts, reason, attempt + 1);
|
|
473
|
+
}
|
|
474
|
+
throw err;
|
|
475
|
+
}
|
|
476
|
+
httpSendMsTotal += nowMs() - sendStart;
|
|
477
|
+
|
|
478
|
+
// Retry once if server needs full for some paths (basis mismatch).
|
|
479
|
+
if (!resp.ok && resp.needFull && resp.needFull.length > 0) {
|
|
480
|
+
const need = new Set(resp.needFull);
|
|
481
|
+
const retryPayloads: EncodedPayload[] = [];
|
|
482
|
+
for (const p of need) {
|
|
483
|
+
const entry = fileMap.get(p);
|
|
484
|
+
if (!entry) continue;
|
|
485
|
+
retryPayloads.push({
|
|
486
|
+
payload: {
|
|
487
|
+
kind: 'full',
|
|
488
|
+
path: entry.path,
|
|
489
|
+
targetSha256: entry.sha256.toLowerCase(),
|
|
490
|
+
length: entry.size,
|
|
491
|
+
},
|
|
492
|
+
filePath: entry.absPath,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (retryPayloads.length > 0) {
|
|
496
|
+
slog('warn', `server requested full for ${retryPayloads.length} files; retrying once`);
|
|
497
|
+
const retryMeta: FolderSyncHttpMeta = {
|
|
498
|
+
...meta,
|
|
499
|
+
id: genId('sync'),
|
|
500
|
+
payloads: retryPayloads.map((p) => p.payload),
|
|
501
|
+
};
|
|
502
|
+
const retryStart = nowMs();
|
|
503
|
+
resp = await httpFolderSyncBatch(
|
|
504
|
+
opts,
|
|
505
|
+
retryMeta,
|
|
506
|
+
retryPayloads.map((p) => ({ filePath: p.filePath })),
|
|
507
|
+
preferredCompression,
|
|
508
|
+
);
|
|
509
|
+
httpSendMsTotal += nowMs() - retryStart;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Cleanup patch temp dirs
|
|
514
|
+
await Promise.all(
|
|
515
|
+
encodedPayloads.map(async (p) => {
|
|
516
|
+
if (!p.cleanupDir) return;
|
|
517
|
+
try {
|
|
518
|
+
await fs.promises.rm(p.cleanupDir, { recursive: true, force: true });
|
|
519
|
+
} catch {
|
|
520
|
+
// ignore
|
|
521
|
+
}
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Sync work includes: local hashing + planning + transfers (but excludes finalize/install wait).
|
|
526
|
+
const syncWorkMs = nowMs() - totalStart;
|
|
527
|
+
if (!resp.ok) {
|
|
528
|
+
throw new Error(resp.error ?? 'sync failed');
|
|
529
|
+
}
|
|
530
|
+
const tookMs = nowMs() - totalStart;
|
|
531
|
+
const totalBytes = bytesSentFull + bytesSentDelta;
|
|
532
|
+
slog(
|
|
533
|
+
'info',
|
|
534
|
+
`sync finished files=${files.length} sent=${fmtBytes(totalBytes)} syncWork=${fmtMs(
|
|
535
|
+
syncWorkMs,
|
|
536
|
+
)} total=${fmtMs(tookMs)}`,
|
|
537
|
+
);
|
|
538
|
+
slog('debug', `sync bytes full=${fmtBytes(bytesSentFull)} delta=${fmtBytes(bytesSentDelta)}`);
|
|
539
|
+
slog(
|
|
540
|
+
'debug',
|
|
541
|
+
`timing ensureXdelta3=${fmtMs(tEnsureMs)} walk=${fmtMs(tWalkMs)} httpSend=${fmtMs(
|
|
542
|
+
httpSendMsTotal,
|
|
543
|
+
)} deltaEncode=${fmtMs(deltaEncodeMsTotal)}`,
|
|
544
|
+
);
|
|
545
|
+
const out: SyncFolderResult = {};
|
|
546
|
+
if (resp.installedAppPath) {
|
|
547
|
+
out.installedAppPath = resp.installedAppPath;
|
|
548
|
+
}
|
|
549
|
+
if (resp.bundleId) {
|
|
550
|
+
out.installedBundleId = resp.bundleId;
|
|
551
|
+
}
|
|
552
|
+
// Update local cache optimistically: after a successful sync, cache reflects current local tree.
|
|
553
|
+
for (const f of files) {
|
|
554
|
+
await cachePut(cacheRoot, f.path, f.absPath);
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
package/src/ios-client.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { WebSocket, Data } from 'ws';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import { EventEmitter } from 'events';
|
|
4
4
|
import { isNonRetryableError } from './tunnel';
|
|
5
|
+
import { syncApp as syncAppImpl, type SyncFolderResult, type FolderSyncOptions } from './folder-sync';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Connection state of the instance client
|
|
@@ -249,6 +250,19 @@ export type InstanceClient = {
|
|
|
249
250
|
options?: { coordinate?: [number, number]; momentum?: number },
|
|
250
251
|
) => Promise<void>;
|
|
251
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Sync an iOS app bundle folder to the server and (optionally) install/launch it.
|
|
255
|
+
*/
|
|
256
|
+
syncApp: (
|
|
257
|
+
localAppBundlePath: string,
|
|
258
|
+
opts?: {
|
|
259
|
+
install?: boolean;
|
|
260
|
+
maxPatchBytes?: number;
|
|
261
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning' | 'FailIfRunning';
|
|
262
|
+
watch?: boolean;
|
|
263
|
+
},
|
|
264
|
+
) => Promise<SyncFolderResult>;
|
|
265
|
+
|
|
252
266
|
/**
|
|
253
267
|
* Disconnect from the Limrun instance
|
|
254
268
|
*/
|
|
@@ -349,9 +363,9 @@ export type InstanceClient = {
|
|
|
349
363
|
* Run `xcodebuild` command with the given arguments.
|
|
350
364
|
* Returns the complete output once the command finishes (non-streaming).
|
|
351
365
|
*
|
|
352
|
-
* Only `-version` is allowed.
|
|
366
|
+
* Only `-version` is allowed (validated server-side).
|
|
353
367
|
*
|
|
354
|
-
* @param args Arguments to pass to xcodebuild
|
|
368
|
+
* @param args Arguments to pass to xcodebuild
|
|
355
369
|
* @returns A promise that resolves to the command result with stdout, stderr, and exit code
|
|
356
370
|
*
|
|
357
371
|
* @example
|
|
@@ -363,7 +377,7 @@ export type InstanceClient = {
|
|
|
363
377
|
* // Build version 16A242d
|
|
364
378
|
* ```
|
|
365
379
|
*/
|
|
366
|
-
xcodebuild: (args: [
|
|
380
|
+
xcodebuild: (args: string[]) => Promise<CommandResult>;
|
|
367
381
|
|
|
368
382
|
/**
|
|
369
383
|
* List all open files on the instance. Useful to start tunnel to the
|
|
@@ -1001,6 +1015,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1001
1015
|
installApp,
|
|
1002
1016
|
setOrientation,
|
|
1003
1017
|
scroll,
|
|
1018
|
+
syncApp,
|
|
1004
1019
|
disconnect,
|
|
1005
1020
|
getConnectionState,
|
|
1006
1021
|
onConnectionStateChange,
|
|
@@ -1092,6 +1107,49 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1092
1107
|
});
|
|
1093
1108
|
};
|
|
1094
1109
|
|
|
1110
|
+
const syncApp = async (
|
|
1111
|
+
localAppBundlePath: string,
|
|
1112
|
+
opts?: {
|
|
1113
|
+
install?: boolean;
|
|
1114
|
+
maxPatchBytes?: number;
|
|
1115
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning' | 'FailIfRunning';
|
|
1116
|
+
watch?: boolean;
|
|
1117
|
+
},
|
|
1118
|
+
): Promise<SyncFolderResult> => {
|
|
1119
|
+
if (!cachedDeviceInfo) {
|
|
1120
|
+
throw new Error('Device info not available yet; wait for client connection to be established.');
|
|
1121
|
+
}
|
|
1122
|
+
const appSyncOpts: FolderSyncOptions = {
|
|
1123
|
+
apiUrl: options.apiUrl,
|
|
1124
|
+
token: options.token,
|
|
1125
|
+
udid: cachedDeviceInfo.udid,
|
|
1126
|
+
log: (level, msg) => {
|
|
1127
|
+
switch (level) {
|
|
1128
|
+
case 'debug':
|
|
1129
|
+
logger.debug(msg);
|
|
1130
|
+
break;
|
|
1131
|
+
case 'info':
|
|
1132
|
+
logger.info(msg);
|
|
1133
|
+
break;
|
|
1134
|
+
case 'warn':
|
|
1135
|
+
logger.warn(msg);
|
|
1136
|
+
break;
|
|
1137
|
+
case 'error':
|
|
1138
|
+
logger.error(msg);
|
|
1139
|
+
break;
|
|
1140
|
+
default:
|
|
1141
|
+
logger.info(msg);
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
...(opts?.install !== undefined ? { install: opts.install } : {}),
|
|
1146
|
+
...(opts?.maxPatchBytes !== undefined ? { maxPatchBytes: opts.maxPatchBytes } : {}),
|
|
1147
|
+
...(opts?.launchMode !== undefined ? { launchMode: opts.launchMode } : {}),
|
|
1148
|
+
...(opts?.watch !== undefined ? { watch: opts.watch } : {}),
|
|
1149
|
+
};
|
|
1150
|
+
return await syncAppImpl(localAppBundlePath, appSyncOpts);
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1095
1153
|
const lsof = (): Promise<LsofEntry[]> => {
|
|
1096
1154
|
return sendRequest<LsofEntry[]>('listOpenFiles', { kind: 'unix' });
|
|
1097
1155
|
};
|
|
@@ -1100,7 +1158,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1100
1158
|
return sendRequest<CommandResult>('xcrun', { args });
|
|
1101
1159
|
};
|
|
1102
1160
|
|
|
1103
|
-
const xcodebuild = (args: [
|
|
1161
|
+
const xcodebuild = (args: string[]): Promise<CommandResult> => {
|
|
1104
1162
|
return sendRequest<CommandResult>('xcodebuild', { args });
|
|
1105
1163
|
};
|
|
1106
1164
|
|
|
@@ -1171,6 +1229,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1171
1229
|
const fileStream = fs.createReadStream(filePath);
|
|
1172
1230
|
const uploadUrl = `${options.apiUrl}/files?name=${encodeURIComponent(name)}`;
|
|
1173
1231
|
try {
|
|
1232
|
+
// Node's fetch (undici) supports streaming request bodies but TS DOM types may not include
|
|
1233
|
+
// `duplex` and may not accept Node ReadStreams as BodyInit in some configs.
|
|
1174
1234
|
const response = await fetch(uploadUrl, {
|
|
1175
1235
|
method: 'PUT',
|
|
1176
1236
|
headers: {
|
|
@@ -1178,9 +1238,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1178
1238
|
'Content-Length': fs.statSync(filePath).size.toString(),
|
|
1179
1239
|
Authorization: `Bearer ${options.token}`,
|
|
1180
1240
|
},
|
|
1181
|
-
body: fileStream,
|
|
1182
|
-
duplex: 'half',
|
|
1183
|
-
});
|
|
1241
|
+
body: fileStream as any,
|
|
1242
|
+
duplex: 'half' as any,
|
|
1243
|
+
} as any);
|
|
1184
1244
|
if (!response.ok) {
|
|
1185
1245
|
const errorBody = await response.text();
|
|
1186
1246
|
logger.debug(`Upload failed: ${response.status} ${errorBody}`);
|
|
@@ -132,12 +132,12 @@ export interface AndroidInstanceCreateParams {
|
|
|
132
132
|
wait?: boolean;
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* Body param
|
|
135
|
+
* Body param
|
|
136
136
|
*/
|
|
137
137
|
metadata?: AndroidInstanceCreateParams.Metadata;
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* Body param
|
|
140
|
+
* Body param
|
|
141
141
|
*/
|
|
142
142
|
spec?: AndroidInstanceCreateParams.Spec;
|
|
143
143
|
}
|