@livx.cc/appwrap 0.30.2 → 0.32.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/package.json +1 -1
- package/runtime/app/shell/handlers-push.ts +20 -6
- package/runtime/package.json +1 -1
- package/src/cli.ts +293 -33
package/package.json
CHANGED
|
@@ -19,10 +19,24 @@ import { SHELL_CONFIG } from './config';
|
|
|
19
19
|
let pendingRegister: { resolve: (t: PushToken) => void; reject: (e: Error) => void } | null = null;
|
|
20
20
|
let cachedToken: string | null = null;
|
|
21
21
|
|
|
22
|
-
interface PushToken { platform: 'apns' | 'fcm'; token: string; }
|
|
22
|
+
interface PushToken { platform: 'apns' | 'fcm'; token: string; topic?: string; }
|
|
23
|
+
|
|
24
|
+
/** The app's bundle/package id — the APNs `apns-topic` MUST equal it or APNs rejects with
|
|
25
|
+
* `DeviceTokenNotForTopic`. iOS: NSBundle bundle id; Android: the application package id (FCM
|
|
26
|
+
* ignores apns-topic, so it's informational there). Resolved natively so consumers never hardcode it. */
|
|
27
|
+
function appTopic(): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
if (isIOS) return NSBundle.mainBundle.bundleIdentifier;
|
|
30
|
+
if (isAndroid) return Utils.android.getApplicationContext().getPackageName();
|
|
31
|
+
} catch (e: any) {
|
|
32
|
+
console.log('[push] bundle-id resolve failed: ' + (e?.message ?? e));
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
23
36
|
|
|
24
37
|
/** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
|
|
25
|
-
* cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured.
|
|
38
|
+
* cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured.
|
|
39
|
+
* Includes `topic` (the bundle/package id) so the backend can set the correct apns-topic header. */
|
|
26
40
|
function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
|
|
27
41
|
const url = SHELL_CONFIG.pushRegistrationUrl;
|
|
28
42
|
if (!url) return;
|
|
@@ -31,7 +45,7 @@ function registerTokenWithBackend(platform: 'ios' | 'android', token: string): v
|
|
|
31
45
|
try {
|
|
32
46
|
Http.request({
|
|
33
47
|
url, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
34
|
-
content: JSON.stringify({ token, platform }),
|
|
48
|
+
content: JSON.stringify({ token, platform, topic: appTopic() }),
|
|
35
49
|
})
|
|
36
50
|
.then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
|
|
37
51
|
.catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
|
|
@@ -48,7 +62,7 @@ export function onApnsToken(hexToken: string): void {
|
|
|
48
62
|
console.log('[push] APNs token: ' + hexToken);
|
|
49
63
|
registerTokenWithBackend('ios', hexToken);
|
|
50
64
|
if (pendingRegister) {
|
|
51
|
-
pendingRegister.resolve({ platform: 'apns', token: hexToken });
|
|
65
|
+
pendingRegister.resolve({ platform: 'apns', token: hexToken, topic: appTopic() });
|
|
52
66
|
pendingRegister = null;
|
|
53
67
|
}
|
|
54
68
|
}
|
|
@@ -143,7 +157,7 @@ export function registerPushHandlers(): void {
|
|
|
143
157
|
bridge.register('push.register', () => {
|
|
144
158
|
if (isIOS) {
|
|
145
159
|
return new Promise<PushToken>((resolve, reject) => {
|
|
146
|
-
if (cachedToken) return resolve({ platform: 'apns', token: cachedToken });
|
|
160
|
+
if (cachedToken) return resolve({ platform: 'apns', token: cachedToken, topic: appTopic() });
|
|
147
161
|
pendingRegister = { resolve, reject };
|
|
148
162
|
Utils.dispatchToMainThread(() => UIApplication.sharedApplication.registerForRemoteNotifications());
|
|
149
163
|
setTimeout(() => {
|
|
@@ -235,7 +249,7 @@ function androidGetToken(): Promise<PushToken> {
|
|
|
235
249
|
if (t.isSuccessful()) {
|
|
236
250
|
const tok = String(t.getResult());
|
|
237
251
|
registerTokenWithBackend('android', tok); // parity with iOS onApnsToken
|
|
238
|
-
resolve({ platform: 'fcm', token: tok });
|
|
252
|
+
resolve({ platform: 'fcm', token: tok, topic: appTopic() });
|
|
239
253
|
} else reject(Object.assign(new Error('FCM token fetch failed'), { code: 'NATIVE_ERROR' }));
|
|
240
254
|
},
|
|
241
255
|
}));
|
package/runtime/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync } from 'child_process';
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
13
13
|
import { networkInterfaces, tmpdir } from 'os';
|
|
14
14
|
import { dirname, join, resolve } from 'path';
|
|
15
15
|
import { pathToFileURL } from 'url';
|
|
@@ -336,11 +336,17 @@ async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
|
|
|
336
336
|
return cfg as AppwrapConfig;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
/** Resolve the user's appwrap config file path the CLI loads from: explicit --config wins;
|
|
340
|
+
* otherwise probe ts → js → json (TS preferred). Single source of discovery (reused by the
|
|
341
|
+
* team-id pin-to-config writer so it never re-invents the probe). */
|
|
342
|
+
function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
|
|
343
|
+
return flags.config
|
|
342
344
|
? resolve(cwd, flags.config)
|
|
343
345
|
: (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
|
|
349
|
+
const configPath = resolveConfigPath(cwd, flags);
|
|
344
350
|
if (!existsSync(configPath)) {
|
|
345
351
|
console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
|
|
346
352
|
process.exit(1);
|
|
@@ -463,15 +469,31 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
463
469
|
writeFileSync(plist, src);
|
|
464
470
|
}
|
|
465
471
|
|
|
466
|
-
function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
|
|
472
|
+
function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
|
|
467
473
|
const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
474
|
+
// Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
|
|
475
|
+
// else the enriched interactive picker (which then offers to pin its choice to the config). This
|
|
476
|
+
// intercepts BEFORE `ns build` runs so NS never shows its plain, unenriched prompt.
|
|
477
|
+
const isPlaceholder = !cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId);
|
|
478
|
+
if (isPlaceholder) {
|
|
479
|
+
const envTeam = process.env.APPWRAP_TEAM_ID?.trim();
|
|
480
|
+
if (envTeam) {
|
|
481
|
+
cfg.teamId = envTeam;
|
|
482
|
+
console.log(` team ← ${envTeam} (from $APPWRAP_TEAM_ID)`);
|
|
483
|
+
} else if (!process.stdout.isTTY) {
|
|
484
|
+
console.warn(`⚠ teamId is unset — set it in appwrap.config, set $APPWRAP_TEAM_ID, or run interactively to pick from your teams.`);
|
|
485
|
+
return;
|
|
486
|
+
} else {
|
|
487
|
+
const picked = pickTeamIdInteractively();
|
|
488
|
+
cfg.teamId = picked.teamId;
|
|
489
|
+
// Offer to persist the choice so the user isn't re-prompted on every deploy. No-TTY/headless is
|
|
490
|
+
// already handled above; promptYesNo additionally guards against a non-interactive stdin.
|
|
491
|
+
if (ctx && promptYesNo(` Pin "${picked.name} (${picked.teamId})" to appwrap.config so you're not asked again?`, true)) {
|
|
492
|
+
pinTeamIdToConfig(ctx.configPath, picked.teamId);
|
|
493
|
+
} else {
|
|
494
|
+
console.log(` ⓘ To skip this prompt: set teamId: "${picked.teamId}" in appwrap.config (or set $APPWRAP_TEAM_ID).`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
475
497
|
}
|
|
476
498
|
if (!existsSync(xcconfig)) return;
|
|
477
499
|
let src = readFileSync(xcconfig, 'utf8');
|
|
@@ -948,7 +970,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
|
|
|
948
970
|
* Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
|
|
949
971
|
* callers sequence around this so overrides win LAST and the marker writes after.
|
|
950
972
|
*/
|
|
951
|
-
function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
|
|
973
|
+
function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean; flags?: Record<string, string> } = {}): void {
|
|
952
974
|
const req = nativeReqs(cfg);
|
|
953
975
|
if (opts.firstRun && !req.explicit) {
|
|
954
976
|
console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
|
|
@@ -964,7 +986,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
964
986
|
stampShellConfig(outDir, cfg);
|
|
965
987
|
stampNativeScriptConfig(outDir, cfg);
|
|
966
988
|
stampIOSDisplayName(outDir, cfg, req);
|
|
967
|
-
stampTeamId(outDir, cfg);
|
|
989
|
+
stampTeamId(outDir, cfg, { cwd, configPath: resolveConfigPath(cwd, opts.flags ?? {}) });
|
|
968
990
|
stampAndroidAppName(outDir, cfg, req);
|
|
969
991
|
stampAndroidVersion(outDir, cfg);
|
|
970
992
|
stampAndroidGradleDeps(outDir, req.androidGradleDeps);
|
|
@@ -1004,7 +1026,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1004
1026
|
|
|
1005
1027
|
console.log(`🎁 appwrap init → ${outDir}`);
|
|
1006
1028
|
mkdirSync(outDir, { recursive: true });
|
|
1007
|
-
regenerateCore(cwd, outDir, cfg, { firstRun: true });
|
|
1029
|
+
regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
|
|
1008
1030
|
copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
|
|
1009
1031
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
1010
1032
|
applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
|
|
@@ -1022,7 +1044,7 @@ async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1022
1044
|
console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
|
|
1023
1045
|
process.exit(1);
|
|
1024
1046
|
}
|
|
1025
|
-
regenerateCore(cwd, outDir, cfg);
|
|
1047
|
+
regenerateCore(cwd, outDir, cfg, { flags });
|
|
1026
1048
|
applyOverrides(cwd, outDir, cfg); // overrides win last
|
|
1027
1049
|
stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
|
|
1028
1050
|
console.log('✓ Synced.');
|
|
@@ -1121,6 +1143,7 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
|
|
|
1121
1143
|
}
|
|
1122
1144
|
}
|
|
1123
1145
|
|
|
1146
|
+
interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
|
|
1124
1147
|
interface DeviceInfo { id: string; name: string; model: string; transport: string }
|
|
1125
1148
|
|
|
1126
1149
|
/** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
|
|
@@ -1131,6 +1154,217 @@ interface DevicectlDevice {
|
|
|
1131
1154
|
connectionProperties?: { tunnelState?: string; transportType?: string };
|
|
1132
1155
|
}
|
|
1133
1156
|
|
|
1157
|
+
/** Read Apple team metadata from provisioning profiles + distribution certs in the keychain.
|
|
1158
|
+
* Provisioning profiles give us the reliable teamId↔teamName mapping; distribution certs
|
|
1159
|
+
* often embed the account email in the display name. */
|
|
1160
|
+
function detectAppleTeams(): AppleTeam[] {
|
|
1161
|
+
const teams = new Map<string, AppleTeam>();
|
|
1162
|
+
|
|
1163
|
+
// 1. Provisioning profiles → teamId + teamName (most reliable)
|
|
1164
|
+
const profilesDir = join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles');
|
|
1165
|
+
if (existsSync(profilesDir)) {
|
|
1166
|
+
try {
|
|
1167
|
+
const files = readdirSync(profilesDir).filter((f) => f.endsWith('.mobileprovision'));
|
|
1168
|
+
for (const f of files) {
|
|
1169
|
+
try {
|
|
1170
|
+
const raw = execFileSync('security', ['cms', '-D', '-i', join(profilesDir, f)],
|
|
1171
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1172
|
+
const idMatch = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
|
|
1173
|
+
const nameMatch = raw.match(/<key>TeamName<\/key>\s*<string>([^<]+)<\/string>/);
|
|
1174
|
+
if (idMatch && nameMatch) {
|
|
1175
|
+
const teamId = idMatch[1];
|
|
1176
|
+
const name = nameMatch[1];
|
|
1177
|
+
const free = /personal team/i.test(name);
|
|
1178
|
+
if (!teams.has(teamId)) teams.set(teamId, { teamId, name, paid: !free });
|
|
1179
|
+
}
|
|
1180
|
+
} catch { /* skip unreadable profile */ }
|
|
1181
|
+
}
|
|
1182
|
+
} catch { /* skip if dir unreadable */ }
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// 2. Keychain distribution/Developer-ID certs → teamId + possible email in name
|
|
1186
|
+
try {
|
|
1187
|
+
const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'],
|
|
1188
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1189
|
+
for (const line of out.split('\n')) {
|
|
1190
|
+
const m = line.match(/"(?:Apple Distribution|Developer ID Application): (.+?) \(([A-Z0-9]{10})\)"/);
|
|
1191
|
+
if (!m) continue;
|
|
1192
|
+
const [, label, teamId] = m;
|
|
1193
|
+
const email = label.includes('@') ? label.trim() : undefined;
|
|
1194
|
+
const existing = teams.get(teamId);
|
|
1195
|
+
if (existing) {
|
|
1196
|
+
if (email && !existing.email) existing.email = email;
|
|
1197
|
+
} else {
|
|
1198
|
+
teams.set(teamId, { teamId, name: label.trim(), email, paid: !/personal team/i.test(label) });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
} catch { /* keychain unavailable */ }
|
|
1202
|
+
|
|
1203
|
+
return [...teams.values()];
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Arrow-key interactive selector. Returns the index of the chosen item. */
|
|
1207
|
+
function arrowSelect(prompt: string, items: string[]): number {
|
|
1208
|
+
const tty = openSync('/dev/tty', 'r+');
|
|
1209
|
+
const write = (s: string) => writeSync(tty, s);
|
|
1210
|
+
const ESC = '\x1b';
|
|
1211
|
+
|
|
1212
|
+
write(`${prompt}\n`);
|
|
1213
|
+
let idx = 0;
|
|
1214
|
+
const HINT = '\x1b[2m ↑↓ / j k to move · Enter to confirm\x1b[0m';
|
|
1215
|
+
const render = (clear: boolean) => {
|
|
1216
|
+
if (clear) write(`\x1b[${items.length + 1}A`); // +1 for the hint line
|
|
1217
|
+
for (let i = 0; i < items.length; i++)
|
|
1218
|
+
write(`\r\x1b[K${i === idx ? '❯ ' : ' '}${items[i]}\n`);
|
|
1219
|
+
write(`\r\x1b[K${HINT}\n`);
|
|
1220
|
+
};
|
|
1221
|
+
render(false);
|
|
1222
|
+
|
|
1223
|
+
// raw mode via stty
|
|
1224
|
+
execFileSync('stty', ['-icanon', '-echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
|
|
1225
|
+
const buf = Buffer.alloc(6);
|
|
1226
|
+
try {
|
|
1227
|
+
for (;;) {
|
|
1228
|
+
const n = readSync(tty, buf, 0, 6, null);
|
|
1229
|
+
const key = buf.slice(0, n).toString();
|
|
1230
|
+
if (key === `${ESC}[A` || key === 'k') { idx = (idx - 1 + items.length) % items.length; render(true); }
|
|
1231
|
+
else if (key === `${ESC}[B` || key === 'j') { idx = (idx + 1) % items.length; render(true); }
|
|
1232
|
+
else if (key === '\r' || key === '\n') break;
|
|
1233
|
+
else if (key === '\x03') { write('\n'); process.exit(1); } // Ctrl-C
|
|
1234
|
+
}
|
|
1235
|
+
} finally {
|
|
1236
|
+
execFileSync('stty', ['icanon', 'echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
|
|
1237
|
+
// Erase the hint line so the selected value prints cleanly after
|
|
1238
|
+
write(`\x1b[1A\r\x1b[K`);
|
|
1239
|
+
write('\n');
|
|
1240
|
+
closeSync(tty);
|
|
1241
|
+
}
|
|
1242
|
+
return idx;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Interactively prompt for an Apple team when teamId is unset. Shows enriched metadata
|
|
1246
|
+
* (email, paid/free) sourced from local keychain + provisioning profiles. */
|
|
1247
|
+
function pickTeamIdInteractively(): { teamId: string; name: string } {
|
|
1248
|
+
const teams = detectAppleTeams();
|
|
1249
|
+
if (teams.length === 0) {
|
|
1250
|
+
console.error('✖ No Apple signing teams found in keychain/provisioning profiles.\n' +
|
|
1251
|
+
' Sign into Xcode → Settings → Accounts, then re-run.');
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
if (teams.length === 1) {
|
|
1255
|
+
const t = teams[0];
|
|
1256
|
+
console.log(` team ← ${t.name} (${t.teamId})${t.email ? ` <${t.email}>` : ''} [${t.paid ? 'paid' : 'free'}] (only option)`);
|
|
1257
|
+
return { teamId: t.teamId, name: t.name };
|
|
1258
|
+
}
|
|
1259
|
+
const items = teams.map((t) => {
|
|
1260
|
+
const badge = t.paid ? '✓ paid' : '○ free';
|
|
1261
|
+
const email = t.email ? ` <${t.email}>` : '';
|
|
1262
|
+
return `${t.name} (${t.teamId})${email} [${badge}]`;
|
|
1263
|
+
});
|
|
1264
|
+
const idx = arrowSelect('Found multiple Apple teams — pick one to use for signing:', items);
|
|
1265
|
+
return { teamId: teams[idx].teamId, name: teams[idx].name };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Y/n confirmation on the TTY (default-yes here). Reuses the global `prompt` primitive. A
|
|
1269
|
+
* non-interactive / piped stdin returns null → falls back to `def` ONLY when there's a real TTY;
|
|
1270
|
+
* a fully headless run never reaches here (callers gate on `process.stdout.isTTY` first), but be
|
|
1271
|
+
* defensive: if stdin can't be read, do NOT pin (safer to re-ask than to silently mutate config). */
|
|
1272
|
+
function promptYesNo(message: string, def: boolean): boolean {
|
|
1273
|
+
if (!process.stdin.isTTY) return false;
|
|
1274
|
+
const suffix = def ? ' [Y/n] ' : ' [y/N] ';
|
|
1275
|
+
const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(message + suffix);
|
|
1276
|
+
if (ans == null) return false;
|
|
1277
|
+
const a = ans.trim().toLowerCase();
|
|
1278
|
+
if (a === '') return def;
|
|
1279
|
+
return a === 'y' || a === 'yes';
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Persist `teamId` into the user's appwrap config so the interactive picker isn't re-run every
|
|
1283
|
+
* deploy. Pure string surgery (returns the new file content) so it's unit-testable across both
|
|
1284
|
+
* supported formats:
|
|
1285
|
+
* - `.json` — set/replace the top-level `"teamId"` property (preserves 2-space indent).
|
|
1286
|
+
* - `.ts`/`.js` — replace an existing `teamId:` field value (incl. the `YOUR_APPLE_TEAM_ID`
|
|
1287
|
+
* placeholder), else insert a new `teamId: '<id>',` line near the other top-level fields
|
|
1288
|
+
* (after `id:`, matching its indentation/quote style). If the shape is unexpected, returns
|
|
1289
|
+
* `null` so the caller skips the write rather than corrupting the file. */
|
|
1290
|
+
export function pinTeamIdInConfigSource(src: string, teamId: string, isJson: boolean): string | null {
|
|
1291
|
+
if (isJson) {
|
|
1292
|
+
let obj: Record<string, unknown>;
|
|
1293
|
+
try { obj = JSON.parse(src) as Record<string, unknown>; } catch { return null; }
|
|
1294
|
+
if (typeof obj !== 'object' || obj == null) return null;
|
|
1295
|
+
obj.teamId = teamId;
|
|
1296
|
+
return JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : '');
|
|
1297
|
+
}
|
|
1298
|
+
// TS/JS: replace an existing teamId field value, preserving its quote style.
|
|
1299
|
+
const existing = /(\bteamId\s*:\s*)(['"`])[^'"`]*\2/;
|
|
1300
|
+
if (existing.test(src)) {
|
|
1301
|
+
return src.replace(existing, (_m, lead: string, q: string) => `${lead}${q}${teamId}${q}`);
|
|
1302
|
+
}
|
|
1303
|
+
// No teamId field — insert after the `id:` field (mirroring its indentation + quote style).
|
|
1304
|
+
const idLine = /^([ \t]*)id\s*:\s*(['"`])[^'"`]*\2\s*,?[ \t]*$/m;
|
|
1305
|
+
const m = idLine.exec(src);
|
|
1306
|
+
if (!m) return null; // unfamiliar shape — don't risk corrupting it
|
|
1307
|
+
const indent = m[1];
|
|
1308
|
+
const quote = m[2];
|
|
1309
|
+
return src.slice(0, m.index + m[0].length)
|
|
1310
|
+
+ `\n${indent}teamId: ${quote}${teamId}${quote},`
|
|
1311
|
+
+ src.slice(m.index + m[0].length);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** Write the pinned teamId to the resolved config file (thin IO wrapper over the pure helper). */
|
|
1315
|
+
function pinTeamIdToConfig(configPath: string, teamId: string): void {
|
|
1316
|
+
if (!existsSync(configPath)) {
|
|
1317
|
+
console.warn(` ⚠ could not pin teamId — config not found at ${configPath}`);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const src = readFileSync(configPath, 'utf8');
|
|
1321
|
+
const next = pinTeamIdInConfigSource(src, teamId, configPath.endsWith('.json'));
|
|
1322
|
+
if (next == null) {
|
|
1323
|
+
console.warn(` ⚠ couldn't safely edit ${configPath} (unexpected shape) — leaving it untouched. Set teamId: '${teamId}' manually.`);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
writeFileSync(configPath, next);
|
|
1327
|
+
console.log(` ✓ pinned teamId: '${teamId}' to ${configPath}`);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
|
|
1331
|
+
|
|
1332
|
+
/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
|
|
1333
|
+
* App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
|
|
1334
|
+
* changes and would make the fingerprint permanently stale.
|
|
1335
|
+
* Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
|
|
1336
|
+
function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
|
|
1337
|
+
const mtime = (p: string): number => {
|
|
1338
|
+
if (!existsSync(p)) return 0;
|
|
1339
|
+
try {
|
|
1340
|
+
const s = statSync(p);
|
|
1341
|
+
if (s.isDirectory()) {
|
|
1342
|
+
let sum = 0;
|
|
1343
|
+
for (const e of readdirSync(p, { withFileTypes: true }))
|
|
1344
|
+
sum += mtime(join(p, e.name));
|
|
1345
|
+
return sum;
|
|
1346
|
+
}
|
|
1347
|
+
return s.mtimeMs;
|
|
1348
|
+
} catch { return 0; }
|
|
1349
|
+
};
|
|
1350
|
+
const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
|
|
1351
|
+
const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
|
|
1352
|
+
// Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
|
|
1353
|
+
let h = 5381;
|
|
1354
|
+
for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
|
|
1355
|
+
return h.toString(36);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const BUILD_CACHE_FILE = '.appwrap-build-cache.json';
|
|
1359
|
+
interface BuildCache { fingerprint: string; ipaPath: string; builtAt: string }
|
|
1360
|
+
|
|
1361
|
+
function readBuildCache(outDir: string): BuildCache | null {
|
|
1362
|
+
try { return JSON.parse(readFileSync(join(outDir, BUILD_CACHE_FILE), 'utf8')); } catch { return null; }
|
|
1363
|
+
}
|
|
1364
|
+
function writeBuildCache(outDir: string, cache: BuildCache): void {
|
|
1365
|
+
try { writeFileSync(join(outDir, BUILD_CACHE_FILE), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1134
1368
|
/** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
|
|
1135
1369
|
* tunnels and non-iOS (watch). Returns [] if none. */
|
|
1136
1370
|
function listIosDevices(): DeviceInfo[] {
|
|
@@ -1199,27 +1433,53 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1199
1433
|
await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
|
|
1200
1434
|
// Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
|
|
1201
1435
|
stampShellConfig(outDir, { ...cfg, debug: true });
|
|
1202
|
-
console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
|
|
1203
|
-
try {
|
|
1204
|
-
execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
|
|
1205
|
-
} catch (e) {
|
|
1206
|
-
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
1207
|
-
console.error(
|
|
1208
|
-
'\n✖ Device build failed — if the errors above mention signing:\n' +
|
|
1209
|
-
` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
|
|
1210
|
-
' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
|
|
1211
|
-
' unique string and re-deploy.\n' +
|
|
1212
|
-
' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
|
|
1213
|
-
' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
|
|
1214
|
-
' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
|
|
1215
|
-
);
|
|
1216
|
-
process.exit(1);
|
|
1217
|
-
}
|
|
1218
1436
|
|
|
1219
1437
|
const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
|
|
1220
|
-
|
|
1438
|
+
|
|
1439
|
+
// Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
|
|
1440
|
+
// --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
|
|
1441
|
+
// the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
|
|
1442
|
+
// Auto: always checks fingerprint; never skips if sources/deps changed.
|
|
1443
|
+
const resume = 'resume' in flags || 'r' in flags;
|
|
1444
|
+
const force = 'force' in flags || 'f' in flags;
|
|
1445
|
+
const fp = buildFingerprint(cwd, cfg, outDir);
|
|
1446
|
+
const cache = readBuildCache(outDir);
|
|
1447
|
+
const existingIpa = existsSync(ipaDir)
|
|
1448
|
+
? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
|
|
1449
|
+
: undefined;
|
|
1450
|
+
const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.ipaPath === join(ipaDir, existingIpa);
|
|
1451
|
+
// --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
|
|
1452
|
+
// but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
|
|
1453
|
+
const noCache = existingIpa && !cache;
|
|
1454
|
+
const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
|
|
1455
|
+
|
|
1456
|
+
if (canSkipBuild) {
|
|
1457
|
+
const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
|
|
1458
|
+
console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
|
|
1459
|
+
} else {
|
|
1460
|
+
console.log(`▶ ns build ios --for-device (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
|
|
1461
|
+
try {
|
|
1462
|
+
execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
|
|
1463
|
+
} catch (e) {
|
|
1464
|
+
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
1465
|
+
console.error(
|
|
1466
|
+
'\n✖ Device build failed — if the errors above mention signing:\n' +
|
|
1467
|
+
` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
|
|
1468
|
+
' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
|
|
1469
|
+
' unique string and re-deploy.\n' +
|
|
1470
|
+
' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
|
|
1471
|
+
' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
|
|
1472
|
+
' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
|
|
1473
|
+
);
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
|
|
1479
|
+
const ipa = builtIpa;
|
|
1221
1480
|
if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
|
|
1222
1481
|
const ipaPath = join(ipaDir, ipa);
|
|
1482
|
+
if (!canSkipBuild) writeBuildCache(outDir, { fingerprint: fp, ipaPath, builtAt: new Date().toISOString() });
|
|
1223
1483
|
|
|
1224
1484
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
1225
1485
|
let installedViaUsbmux = false;
|