@cityholdings/minicube-dev 0.1.4 → 0.1.5
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/README.md +26 -1
- package/package.json +1 -1
- package/src/cli.js +58 -0
- package/src/device.js +10 -1
- package/src/installed.js +228 -0
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ by `MINICUBE_DEV_CONFIG` / `--config`. Create a skeleton with `minicube-dev conf
|
|
|
67
67
|
},
|
|
68
68
|
"devices": {
|
|
69
69
|
"android": { "package": "<android application id>", "cubeConnectActivity": "<debug activity accepting cubeActHost/cubeActPort>" },
|
|
70
|
-
"ios": { "bundleId": "<bundle id
|
|
70
|
+
"ios": { "bundleId": "<host app bundle id>" }
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
```
|
|
@@ -143,11 +143,36 @@ minicube-dev debug --whitelist-create qa --items 10001 # new whitelist for this
|
|
|
143
143
|
|
|
144
144
|
* Mini program / H5 previews open `citysuper://platformapi/startapp?…` links (the mPaaS IDE
|
|
145
145
|
preview layout with the app scheme instead of `mpaas`). Only non-production builds accept them.
|
|
146
|
+
* On iOS the host app resumes a mini program that is already open and ignores a new link, so the
|
|
147
|
+
new package would not load. minicube-dev therefore quits the app (`devices.ios.bundleId`) before
|
|
148
|
+
opening the link on the simulator and on physical iPhones; the link then cold-starts it.
|
|
146
149
|
* Card previews attach the app to `act server` without scanning: Android starts the debug-only
|
|
147
150
|
connect activity (emulators get an `adb reverse` tunnel); the iOS simulator opens
|
|
148
151
|
`citysuper://cubedebug/connect?host=127.0.0.1&port=9001` (UAT target, which links CubeDev).
|
|
149
152
|
Physical phones scan the ACT QR code.
|
|
150
153
|
|
|
154
|
+
## Installed versions on a device
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
minicube-dev installed --device ios-sim # alias: versions
|
|
158
|
+
minicube-dev installed --device android --type mini
|
|
159
|
+
minicube-dev installed --device ios:<udid> --id 1000000000000001 --json
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Lists every mini program, H5 offline package and Cube card the app has installed, newest version
|
|
163
|
+
first, with older copies still on disk. Mini programs and H5 packages come from each package's
|
|
164
|
+
`Manifest.xml`. Cards come from the Cube cache and show the card's own version together with the
|
|
165
|
+
server resource version. Names come from the server when that environment has a token; cards show
|
|
166
|
+
their template name.
|
|
167
|
+
|
|
168
|
+
- iOS keeps packages per environment. The ENV column is matched to your configured environments by
|
|
169
|
+
`baseUrl` host (`Documents/NebulaEnvironments/<sha256(host)[:16]>`).
|
|
170
|
+
- Android and cards are not stored per environment and are labelled with `--env` / `defaultEnv`.
|
|
171
|
+
- Only debug / development builds can be read: Android uses `run-as`; a USB iPhone uses go-ios
|
|
172
|
+
(`npm i -g go-ios`; iOS 17+ also needs `ios tunnel start --userspace`); the simulator needs nothing.
|
|
173
|
+
- Speed on a USB iPhone: about 45 s on iOS 16, where files are read in parallel. On iOS 17+ the tunnel only copes with
|
|
174
|
+
one file at a time, so it takes about 2–3 minutes; the command prints an estimate first.
|
|
175
|
+
|
|
151
176
|
## Release
|
|
152
177
|
|
|
153
178
|
```bash
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
CONFIG_TEMPLATE, PROJECT_FILE, globalConfigPath, loadGlobalConfig, loadProject, resolveEnv, saveGlobalConfig, saveProject, validateProject,
|
|
11
11
|
} from './config.js';
|
|
12
12
|
import { openUrl, parseTarget } from './device.js';
|
|
13
|
+
import { groupInstalled, readInstalled } from './installed.js';
|
|
13
14
|
import { CliError, color, log, table } from './log.js';
|
|
14
15
|
import { which } from './proc.js';
|
|
15
16
|
import { chooseVersion, previewUrl, resolveWhitelistIds, uploadMeta } from './workflow.js';
|
|
@@ -33,6 +34,9 @@ Develop
|
|
|
33
34
|
--items <userIds> add members to the (first) whitelist
|
|
34
35
|
--remote card only: upload a whitelist-gray card instead of a local push
|
|
35
36
|
preview [<url>|dir] --device <target> open a preview link, or the project's latest debug package
|
|
37
|
+
installed --device <target> [--type mini|h5|card] [--id <resourceId>] (alias: versions)
|
|
38
|
+
versions of mini programs, H5 offline packages and cards installed in the app on the
|
|
39
|
+
device (debug / development builds), with names from the server when a token is set
|
|
36
40
|
|
|
37
41
|
Release
|
|
38
42
|
release [dir] [--version x.y.z] [--publish full|whitelist] [--whitelist <id|name>] [--yes]
|
|
@@ -190,6 +194,60 @@ const COMMANDS = {
|
|
|
190
194
|
});
|
|
191
195
|
},
|
|
192
196
|
|
|
197
|
+
async installed(ctx) {
|
|
198
|
+
const config = loadGlobalConfig(ctx.flags);
|
|
199
|
+
const devices = config.data.devices ?? {};
|
|
200
|
+
const device = one(ctx.flags.device) ?? devices.default;
|
|
201
|
+
if (!device) throw new CliError('Usage: minicube-dev installed --device <target>', 'Targets: android, android:<serial>, ios-sim, ios-sim:<udid>, ios:<udid>');
|
|
202
|
+
const target = parseTarget(device, devices);
|
|
203
|
+
const environments = config.data.environments ?? {};
|
|
204
|
+
const found = await readInstalled(target, devices, environments);
|
|
205
|
+
const type = one(ctx.flags.type);
|
|
206
|
+
const id = one(ctx.flags.id);
|
|
207
|
+
// Android and cards are not stored per environment; label them with the selected one.
|
|
208
|
+
const selected = ctx.flags.env ? one(ctx.flags.env) : config.data.defaultEnv;
|
|
209
|
+
let rows = groupInstalled(found).map((row) => ({ ...row, env: row.env ?? (row.type === 'card' ? null : selected) }));
|
|
210
|
+
if (ctx.flags.env) rows = rows.filter((row) => !row.env || row.env === selected);
|
|
211
|
+
|
|
212
|
+
// Names (and mini/H5 types on iOS) from the server, best effort: needs that environment's token.
|
|
213
|
+
const known = new Map();
|
|
214
|
+
for (const name of new Set(rows.map((row) => row.env).filter((env) => env && environments[env]))) {
|
|
215
|
+
try {
|
|
216
|
+
const api = await connect(resolveEnv(config, { env: name }));
|
|
217
|
+
try {
|
|
218
|
+
for (const resource of await api.resources()) known.set(`${name}|${resource.resourceId}`, resource);
|
|
219
|
+
} finally {
|
|
220
|
+
api.close();
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log.detail(`No resource names from ${name}: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
rows = rows.map((row) => {
|
|
227
|
+
const resource = known.get(`${row.env}|${row.appId}`);
|
|
228
|
+
return { ...row, type: row.type ?? resource?.type ?? 'mini/h5', name: resource?.name ?? '' };
|
|
229
|
+
});
|
|
230
|
+
if (type) rows = rows.filter((row) => row.type === type);
|
|
231
|
+
if (id) rows = rows.filter((row) => row.appId === id);
|
|
232
|
+
|
|
233
|
+
log.detail(`Read from ${found.source}`);
|
|
234
|
+
output(ctx.flags, rows, (r) => (r.length === 0 ? 'Nothing installed matches.' : table(r.map((row) => ({
|
|
235
|
+
env: row.env ?? '-',
|
|
236
|
+
type: row.type,
|
|
237
|
+
appId: row.appId,
|
|
238
|
+
name: row.name,
|
|
239
|
+
installed: row.versions[0]?.label ?? '',
|
|
240
|
+
older: row.versions.slice(1).map((v) => v.label).join(', '),
|
|
241
|
+
})), [
|
|
242
|
+
{ key: 'env', title: 'ENV' }, { key: 'type', title: 'TYPE' }, { key: 'appId', title: 'ID' }, { key: 'name', title: 'NAME' },
|
|
243
|
+
{ key: 'installed', title: 'NEWEST INSTALLED' }, { key: 'older', title: 'OLDER COPIES' },
|
|
244
|
+
])));
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async versions(ctx) {
|
|
248
|
+
return COMMANDS.installed(ctx);
|
|
249
|
+
},
|
|
250
|
+
|
|
193
251
|
async packages(ctx) {
|
|
194
252
|
const project = ctx.args[0] ? null : loadProject('.', { required: false });
|
|
195
253
|
const id = ctx.args[0] ?? project?.data.resourceId;
|
package/src/device.js
CHANGED
|
@@ -21,7 +21,7 @@ export function parseTarget(value, devices = {}) {
|
|
|
21
21
|
throw new CliError(`Unknown device "${raw}"`, 'Use android, android:<serial>, ios-sim, ios-sim:<udid> or ios:<udid>.');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function adbPath() {
|
|
24
|
+
export function adbPath() {
|
|
25
25
|
const adb = process.env.ADB_BINARY ?? which('adb');
|
|
26
26
|
if (!adb) throw new CliError('adb not found', 'Install Android platform-tools and add them to PATH (or set ADB_BINARY).');
|
|
27
27
|
return adb;
|
|
@@ -60,6 +60,15 @@ export function openUrl(target, url, devices = {}) {
|
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
if (target.kind === 'ios-sim') {
|
|
63
|
+
// An iOS host app that already shows this mini program resumes it and ignores the link, so
|
|
64
|
+
// the new package never loads. Quit the app first (like devicectl --terminate-existing below);
|
|
65
|
+
// the link then cold-starts it.
|
|
66
|
+
const bundleId = devices.ios?.bundleId;
|
|
67
|
+
if (bundleId && !bundleId.includes('<')) {
|
|
68
|
+
if (runSync('xcrun', ['simctl', 'terminate', target.id, bundleId]).code === 0) log.detail(`Restarted ${bundleId} so the new package loads`);
|
|
69
|
+
} else {
|
|
70
|
+
log.warn('devices.ios.bundleId is not set: if the mini program is already open on the simulator, close it first or the new package will not load.');
|
|
71
|
+
}
|
|
63
72
|
const result = runSync('xcrun', ['simctl', 'openurl', target.id, url]);
|
|
64
73
|
if (result.code !== 0) throw new CliError(`simctl openurl failed: ${result.stderr.trim()}`, 'Is a simulator booted with the app installed? `xcrun simctl list devices booted`');
|
|
65
74
|
log.success(`Opened on iOS simulator ${target.id}`);
|
package/src/installed.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { adbPath, androidSerial } from './device.js';
|
|
6
|
+
import { CliError, log } from './log.js';
|
|
7
|
+
import { run, runSync, which } from './proc.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Package versions installed in the host app's sandbox on a device, read straight from the
|
|
11
|
+
* container the way the app stores them:
|
|
12
|
+
* - mini programs and H5 offline packages: one directory per installed version holding the
|
|
13
|
+
* package's Manifest.xml (<uid>, <version>). iOS keeps them per environment under
|
|
14
|
+
* Documents/NebulaEnvironments/<sha256(gateway host)[:16]>/NAMAPP_UNZIP/; Android under
|
|
15
|
+
* files/MriverInstallApps (mini programs) and files/nebulaInstallApps (H5 offline packages).
|
|
16
|
+
* - Cube cards: cubeCrystal/<hash>/<server version a_b_c_d>/<n>.template, whose header names the
|
|
17
|
+
* card and its own version.
|
|
18
|
+
* Reading another app's sandbox needs a debuggable / development-signed build.
|
|
19
|
+
*/
|
|
20
|
+
export async function readInstalled(target, devices, environments) {
|
|
21
|
+
const raw = target.kind === 'android' ? readAndroid(target, devices)
|
|
22
|
+
: target.kind === 'ios-sim' ? readSimulator(target, devices)
|
|
23
|
+
: await readIosDevice(target, devices);
|
|
24
|
+
const envByKey = new Map(Object.entries(environments ?? {}).map(([name, env]) => [environmentKey(env.baseUrl), name]));
|
|
25
|
+
const packages = [];
|
|
26
|
+
for (const { file, text } of raw.manifests) {
|
|
27
|
+
const appId = text.match(/<uid>\s*([^<\s]+)\s*<\/uid>/)?.[1];
|
|
28
|
+
const version = text.match(/<version>\s*([^<\s]+)\s*<\/version>/)?.[1];
|
|
29
|
+
if (!appId || !version) continue;
|
|
30
|
+
const parts = file.split('/');
|
|
31
|
+
const envKey = parts[parts.indexOf('NebulaEnvironments') + 1];
|
|
32
|
+
const type = file.includes('MriverInstallApps') ? 'mini' : file.includes('nebulaInstallApps') ? 'h5' : null;
|
|
33
|
+
packages.push({ appId, version, type, env: parts.includes('NebulaEnvironments') ? envByKey.get(envKey) ?? envKey : null });
|
|
34
|
+
}
|
|
35
|
+
const cards = [];
|
|
36
|
+
for (const { file, text } of raw.templates) {
|
|
37
|
+
const parts = file.split('/');
|
|
38
|
+
const serverVersion = parts[parts.length - 2]?.replaceAll('_', '.');
|
|
39
|
+
// The header is JSON with a few binary bytes interleaved; read the fields leniently.
|
|
40
|
+
const name = text.match(/"name"\s*:\s*"([^"]+)"/)?.[1] ?? text.match(/"assetId"\s*:\s*"([^"]+)"/)?.[1];
|
|
41
|
+
const cardVersion = text.match(/"vers[^"]{0,4}"\s*:\s*"([\d.]+)"/)?.[1];
|
|
42
|
+
if (name) cards.push({ appId: name, version: cardVersion ?? serverVersion, serverVersion, type: 'card', env: null });
|
|
43
|
+
}
|
|
44
|
+
return { packages, cards, source: raw.source };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Group installed versions per resource, newest first. */
|
|
48
|
+
export function groupInstalled({ packages, cards }) {
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
for (const item of [...packages, ...cards]) {
|
|
51
|
+
const key = `${item.env ?? ''}|${item.type ?? ''}|${item.appId}`;
|
|
52
|
+
const group = groups.get(key) ?? { env: item.env, type: item.type, appId: item.appId, versions: [] };
|
|
53
|
+
const label = item.serverVersion ? `${item.version} (server ${item.serverVersion})` : item.version;
|
|
54
|
+
if (!group.versions.some((v) => v.label === label)) group.versions.push({ version: item.version, serverVersion: item.serverVersion, label });
|
|
55
|
+
groups.set(key, group);
|
|
56
|
+
}
|
|
57
|
+
for (const group of groups.values()) {
|
|
58
|
+
group.versions.sort((a, b) => compareVersions(b.serverVersion ?? b.version, a.serverVersion ?? a.version));
|
|
59
|
+
}
|
|
60
|
+
return [...groups.values()].sort((a, b) => `${a.env}|${a.type}|${a.appId}`.localeCompare(`${b.env}|${b.type}|${b.appId}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compareVersions(a, b) {
|
|
64
|
+
const pa = String(a).split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
65
|
+
const pb = String(b).split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
66
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
67
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The iOS app's per-environment storage key: sha256 of the gateway hostname, first 16 hex chars. */
|
|
73
|
+
export function environmentKey(baseUrl) {
|
|
74
|
+
try {
|
|
75
|
+
const host = new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, '');
|
|
76
|
+
return createHash('sha256').update(host).digest('hex').slice(0, 16);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Android: one run-as shell pass (debuggable builds) ---------------------------------------------
|
|
83
|
+
|
|
84
|
+
function readAndroid(target, devices) {
|
|
85
|
+
const serial = androidSerial(target);
|
|
86
|
+
const pkg = devices.android?.package;
|
|
87
|
+
if (!pkg || pkg.includes('<')) throw new CliError('devices.android.package is not configured');
|
|
88
|
+
const script = [
|
|
89
|
+
'for f in files/MriverInstallApps/*/*/Manifest.xml files/nebulaInstallApps/*/*/Manifest.xml; do',
|
|
90
|
+
'[ -f "$f" ] && { echo "@@M $f"; cat "$f"; echo; }; done;',
|
|
91
|
+
'for t in cache/cubeCrystal/*/*/*.template; do [ -f "$t" ] && { echo "@@T $t"; head -c 400 "$t"; echo; }; done',
|
|
92
|
+
].join(' ');
|
|
93
|
+
const result = runSync(adbPath(), ['-s', serial, 'exec-out', 'run-as', pkg, 'sh', '-c', script], { encoding: 'latin1' });
|
|
94
|
+
if (/not debuggable|unknown package|is unknown/i.test(result.stdout + result.stderr)) {
|
|
95
|
+
throw new CliError(`Cannot read ${pkg} on ${serial}: ${(result.stderr || result.stdout).trim()}`,
|
|
96
|
+
'Installed versions can only be read from debug builds of the app (run-as).');
|
|
97
|
+
}
|
|
98
|
+
const manifests = [];
|
|
99
|
+
const templates = [];
|
|
100
|
+
for (const block of result.stdout.split(/\n(?=@@[MT] )/)) {
|
|
101
|
+
const match = block.match(/^@@([MT]) (\S+)\n?([\s\S]*)$/);
|
|
102
|
+
if (!match) continue;
|
|
103
|
+
(match[1] === 'M' ? manifests : templates).push({ file: match[2], text: match[3] });
|
|
104
|
+
}
|
|
105
|
+
return { manifests, templates, source: `Android ${serial} (${pkg})` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- iOS simulator: the data container is a local directory ------------------------------------------
|
|
109
|
+
|
|
110
|
+
function readSimulator(target, devices) {
|
|
111
|
+
const bundleId = iosBundleId(devices);
|
|
112
|
+
const result = runSync('xcrun', ['simctl', 'get_app_container', target.id, bundleId, 'data']);
|
|
113
|
+
if (result.code !== 0) throw new CliError(`${bundleId} is not installed on simulator ${target.id}: ${result.stderr.trim()}`);
|
|
114
|
+
const container = result.stdout.trim();
|
|
115
|
+
const manifests = [];
|
|
116
|
+
for (const root of [path.join(container, 'Documents', 'NebulaEnvironments'), path.join(container, 'Documents')]) {
|
|
117
|
+
for (const file of findFiles(root, (rel) => /(^|\/)NAMAPP_UNZIP\/[^/]+\/Manifest\.xml$/.test(rel), root.endsWith('Documents') ? 3 : 4)) {
|
|
118
|
+
manifests.push({ file: path.relative(container, file), text: readFileSync(file, 'utf8') });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const cubeRoot = path.join(container, 'Library', 'Caches', 'cubeCrystal');
|
|
122
|
+
const templates = findFiles(cubeRoot, (rel) => rel.endsWith('.template'), 3)
|
|
123
|
+
.map((file) => ({ file: path.relative(container, file), text: readHead(file, 400) }));
|
|
124
|
+
return { manifests: dedupe(manifests), templates, source: `iOS simulator ${target.id} (${bundleId})` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- iOS device over USB: go-ios house_arrest (development-signed builds) ---------------------------
|
|
128
|
+
|
|
129
|
+
async function readIosDevice(target, devices) {
|
|
130
|
+
const bundleId = iosBundleId(devices);
|
|
131
|
+
const ios = which('ios');
|
|
132
|
+
if (!ios) throw new CliError('go-ios not found', 'Install it with `npm i -g go-ios` (iOS 17+ also needs `ios tunnel start --userspace`).');
|
|
133
|
+
const base = ['fsync', '--udid', target.id, `--app=${bundleId}`];
|
|
134
|
+
const tree = (dir) => {
|
|
135
|
+
const result = runSync(ios, [...base, 'tree', `--path=${dir}`]);
|
|
136
|
+
return parseGoIosTree(result.stdout).map((rel) => `${dir}/${rel}`);
|
|
137
|
+
};
|
|
138
|
+
const files = [...tree('/Documents/NebulaEnvironments'), ...tree('/Library/Caches/cubeCrystal')];
|
|
139
|
+
if (files.length === 0) {
|
|
140
|
+
throw new CliError(`Could not read ${bundleId} on ${target.id}`,
|
|
141
|
+
'The app must be a development build; on iOS 17+ keep `ios tunnel start --userspace` running.');
|
|
142
|
+
}
|
|
143
|
+
const work = mkdtempSync(path.join(os.tmpdir(), 'minicube-installed-'));
|
|
144
|
+
try {
|
|
145
|
+
// Each go-ios pull is a separate device session (~1-2 s). Over USB (iOS <= 16) they run in
|
|
146
|
+
// parallel; through the iOS 17+ tunnel parallel sessions fail or hang, so pull one at a time
|
|
147
|
+
// with a timeout per file.
|
|
148
|
+
const major = Number.parseInt(String(JSON.parse(runSync(ios, ['info', '--udid', target.id]).stdout || '{}').ProductVersion), 10) || 0;
|
|
149
|
+
const tunnelled = major >= 17;
|
|
150
|
+
const pull = async (remote) => {
|
|
151
|
+
const local = path.join(work, remote.replace(/[^\w.-]+/g, '_'));
|
|
152
|
+
const args = [...base, 'pull', `--srcPath=${remote}`, `--dstPath=${local}`];
|
|
153
|
+
if (tunnelled) runSync(ios, args, { timeout: 30_000 });
|
|
154
|
+
else await run(ios, args, { capture: true });
|
|
155
|
+
const file = existsSync(local) && statSync(local).isDirectory() ? path.join(local, path.basename(remote)) : local;
|
|
156
|
+
return existsSync(file) ? file : null;
|
|
157
|
+
};
|
|
158
|
+
const wanted = files.filter((remote) => /\/NAMAPP_UNZIP\/[^/]+\/Manifest\.xml$/.test(remote) || remote.endsWith('.template'));
|
|
159
|
+
if (tunnelled) log.detail(`Reading ${wanted.length} files one at a time through the iOS ${major} tunnel (about ${Math.ceil(wanted.length * 1.5)} s)`);
|
|
160
|
+
const pulled = await mapLimit(wanted, tunnelled ? 1 : 8, async (remote) => ({ remote, file: await pull(remote) }));
|
|
161
|
+
const manifests = [];
|
|
162
|
+
const templates = [];
|
|
163
|
+
for (const { remote, file } of pulled) {
|
|
164
|
+
if (!file) continue;
|
|
165
|
+
if (remote.endsWith('.template')) templates.push({ file: remote, text: readHead(file, 400) });
|
|
166
|
+
else manifests.push({ file: remote, text: readFileSync(file, 'utf8') });
|
|
167
|
+
}
|
|
168
|
+
return { manifests, templates, source: `iPhone ${target.id} (${bundleId})` };
|
|
169
|
+
} finally {
|
|
170
|
+
rmSync(work, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** go-ios prints `| |-name` lines (directories end with "/"); return paths relative to the root. */
|
|
175
|
+
export function parseGoIosTree(text) {
|
|
176
|
+
const lines = text.split(/\r?\n/).map((line) => line.match(/^((?:\|\s\s)*)\|-(.+)$/)).filter(Boolean);
|
|
177
|
+
if (lines.length === 0) return [];
|
|
178
|
+
const base = Math.min(...lines.map((m) => m[1].length / 3));
|
|
179
|
+
const stack = [];
|
|
180
|
+
const files = [];
|
|
181
|
+
for (const [, indent, rawName] of lines) {
|
|
182
|
+
const depth = indent.length / 3 - base;
|
|
183
|
+
const name = rawName.replace(/\/+$/, '');
|
|
184
|
+
stack.length = depth;
|
|
185
|
+
stack[depth] = name;
|
|
186
|
+
if (!rawName.endsWith('/')) files.push(stack.slice(0, depth + 1).join('/'));
|
|
187
|
+
}
|
|
188
|
+
return files;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function iosBundleId(devices) {
|
|
192
|
+
const bundleId = devices.ios?.bundleId;
|
|
193
|
+
if (!bundleId || bundleId.includes('<')) throw new CliError('devices.ios.bundleId is not configured');
|
|
194
|
+
return bundleId;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function findFiles(root, accept, maxDepth, depth = 0, rel = '') {
|
|
198
|
+
if (!existsSync(root) || depth > maxDepth) return [];
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
201
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
202
|
+
const full = path.join(root, entry.name);
|
|
203
|
+
if (entry.isDirectory()) out.push(...findFiles(full, accept, maxDepth, depth + 1, childRel));
|
|
204
|
+
else if (accept(childRel)) out.push(full);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function mapLimit(items, limit, fn) {
|
|
210
|
+
const results = new Array(items.length);
|
|
211
|
+
let next = 0;
|
|
212
|
+
const worker = async () => {
|
|
213
|
+
while (next < items.length) {
|
|
214
|
+
const index = next++;
|
|
215
|
+
results[index] = await fn(items[index]);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readHead(file, bytes) {
|
|
223
|
+
return readFileSync(file).subarray(0, bytes).toString('latin1');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function dedupe(items) {
|
|
227
|
+
return [...new Map(items.map((item) => [item.file, item])).values()];
|
|
228
|
+
}
|