@livx.cc/appwrap 0.59.0 → 0.60.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
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -86,7 +86,7 @@ function armNativeSurfaceRecovery(webView: CustomWebView): void {
|
|
|
86
86
|
|
|
87
87
|
export function onPageLoaded(args: EventData): void {
|
|
88
88
|
const page = args.object as Page;
|
|
89
|
-
page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
|
|
89
|
+
page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor, appName: SHELL_CONFIG.name };
|
|
90
90
|
bindStatusBarPage(page);
|
|
91
91
|
if (isAndroid) enableAndroidEdgeToEdge();
|
|
92
92
|
applyThemeColor(SHELL_CONFIG.themeColor); // manifest/config theme_color → native chrome at boot
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- loader:'server' load-failure fallback (App Review 2.1a): shown by main-page.ts when the
|
|
10
10
|
server navigation fails (offline / server down) so the reviewer never sees a white screen. -->
|
|
11
11
|
<StackLayout id="loadFallback" row="0" visibility="collapse" backgroundColor="#ffffff" verticalAlignment="center" padding="32">
|
|
12
|
-
<Label text="
|
|
12
|
+
<Label text="{{ appName }}" fontSize="34" fontWeight="700" color="#000000" textAlignment="center" />
|
|
13
13
|
<Label text="Can't connect" fontSize="17" fontWeight="600" color="#333333" textAlignment="center" marginTop="18" />
|
|
14
14
|
<Label text="Check your internet connection — we'll keep trying." fontSize="14" color="#777777" textAlignment="center" textWrap="true" marginTop="6" />
|
|
15
15
|
<Button id="loadRetryBtn" text="Retry" fontSize="16" fontWeight="600" color="#ffffff" backgroundColor="#000000" borderRadius="22" height="44" width="160" marginTop="24" />
|
|
@@ -74,4 +74,11 @@ export const SHELL_CONFIG = {
|
|
|
74
74
|
* inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
|
|
75
75
|
* when ''). Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
|
|
76
76
|
envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '' },
|
|
77
|
+
/** TCC-gated web APIs this build DECLARED (active modules + the config's `permissions{}`) — what the
|
|
78
|
+
* document-start capability guard exposes to the page. NOT the same question as "is the Info.plist
|
|
79
|
+
* usage string present": the plist also carries the webview baseline (NSCameraUsageDescription is
|
|
80
|
+
* stamped in every build so WKWebView's `<input type="file">` "Take Photo" can't TCC-kill the
|
|
81
|
+
* process), and an app that never asked for the camera must still not be handing `getUserMedia` to
|
|
82
|
+
* whatever page it renders. Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
|
|
83
|
+
webCaps: { camera: false, microphone: false, geolocation: false },
|
|
77
84
|
};
|
|
@@ -332,11 +332,15 @@ export class CustomWebView extends WebView {
|
|
|
332
332
|
// (reject camera/mic/geolocation in JS before WebKit's native path — see capabilityGuardJs) +
|
|
333
333
|
// native-feel suppression, all injected before the page's own scripts run. Globals first so the page
|
|
334
334
|
// can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
|
|
335
|
-
|
|
335
|
+
// Driven by the STAMPED declaration, not by sniffing Info.plist: NSCameraUsageDescription is now
|
|
336
|
+
// present in every build (the webview baseline — so WKWebView's file-input "Take Photo" can't
|
|
337
|
+
// TCC-kill us), and reading presence would therefore open getUserMedia({video}) to every page an
|
|
338
|
+
// app renders, including ones a loader:'server' shell doesn't control. SHELL_CONFIG.webCaps is
|
|
339
|
+
// what the app actually declared (modules + `permissions{}`).
|
|
336
340
|
const capabilityGuard = capabilityGuardJs({
|
|
337
|
-
camera:
|
|
338
|
-
microphone:
|
|
339
|
-
geolocation:
|
|
341
|
+
camera: SHELL_CONFIG.webCaps.camera,
|
|
342
|
+
microphone: SHELL_CONFIG.webCaps.microphone,
|
|
343
|
+
geolocation: SHELL_CONFIG.webCaps.geolocation,
|
|
340
344
|
});
|
|
341
345
|
const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
|
|
342
346
|
const extNavGuard = externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser);
|
package/src/cli.ts
CHANGED
|
@@ -86,6 +86,12 @@ const IOS_PERMISSION_KEYS: Record<string, string[]> = {
|
|
|
86
86
|
calendar: ['NSCalendarsFullAccessUsageDescription', 'NSCalendarsUsageDescription'],
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
/** Default copy for the always-stamped NSCameraUsageDescription (see "the webview baseline" in
|
|
90
|
+
* nativeReqs). Written for an App Review reader: it describes the ONLY way a plain appwrap app reaches
|
|
91
|
+
* the camera — choosing "Take Photo" from a file upload. An app that calls the camera directly (the
|
|
92
|
+
* `camera`/`scanner` modules, or its own `permissions.camera`) replaces this with its own copy. */
|
|
93
|
+
const WEBVIEW_BASELINE_CAMERA_USAGE = 'Take a photo when you choose “Take Photo” while attaching a file.';
|
|
94
|
+
|
|
89
95
|
/** Runtime permissions stamped into AndroidManifest.xml per declared domain.
|
|
90
96
|
* photos/faceid need none: system picker / USE_BIOMETRIC is baseline. */
|
|
91
97
|
const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
|
|
@@ -232,6 +238,11 @@ interface NativeReqs {
|
|
|
232
238
|
activeOptIn: string[]; // opt-in capability names that are active (for the handshake map)
|
|
233
239
|
activeOptionalGroups: string[]; // strippable handler groups (own file) that are active
|
|
234
240
|
iosPlist: Array<{ key: string; usage: string }>;
|
|
241
|
+
/** What the app DECLARED it wants the web layer to be able to use — modules + `permissions{}`, and
|
|
242
|
+
* deliberately NOT the webview baseline. Drives the shell's document-start capability guard.
|
|
243
|
+
* Kept separate from `iosPlist` because the two answer different questions: the plist must cover
|
|
244
|
+
* everything the OS can kill us for REACHING, the guard must expose only what the app ASKED for. */
|
|
245
|
+
webCaps: { camera: boolean; microphone: boolean; geolocation: boolean };
|
|
235
246
|
iosEntitlements: Record<string, boolean | string | string[]>;
|
|
236
247
|
androidPerms: string[];
|
|
237
248
|
androidGradleDeps: string[];
|
|
@@ -277,7 +288,11 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
277
288
|
for (const p of m.ios?.permissions ?? []) {
|
|
278
289
|
if (seenKeys.has(p.key)) continue;
|
|
279
290
|
seenKeys.add(p.key);
|
|
280
|
-
|
|
291
|
+
// `permissions{}` overrides the module's default COPY. A `false` there is an opt-out of the
|
|
292
|
+
// webview baseline (below), never of a module's own key — the module ships native code that
|
|
293
|
+
// touches the class, so the string is mandatory; fall back to the default copy.
|
|
294
|
+
const override = cfg.permissions?.[p.domain as keyof typeof cfg.permissions];
|
|
295
|
+
iosPlist.push({ key: p.key, usage: typeof override === 'string' && override ? override : p.defaultUsage });
|
|
281
296
|
}
|
|
282
297
|
for (const ap of m.android?.permissions ?? []) androidPerms.add(ap);
|
|
283
298
|
for (const g of m.android?.gradleDeps ?? []) gradle.add(g);
|
|
@@ -289,14 +304,52 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
289
304
|
// resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
|
|
290
305
|
if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
|
|
291
306
|
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A DECLARED permission is always stamped — in BOTH modes. Before, `permissions{}` was read only in
|
|
310
|
+
// legacy mode; with `modules` present it merely overrode a module's usage copy, so declaring a domain
|
|
311
|
+
// no module owned stamped NOTHING. That silent inertness shipped a store rejection (Copy Bin,
|
|
312
|
+
// Guideline 2.1(a): TCC killed the app for a missing NSCameraUsageDescription). Module-derived keys are
|
|
313
|
+
// collected FIRST and win the dedupe, so an app whose declared domains its modules already own stamps
|
|
314
|
+
// byte-identically. `false` = an explicit opt-out (see the webview baseline below), never a usage string.
|
|
315
|
+
for (const [domain, text] of Object.entries(cfg.permissions ?? {})) {
|
|
316
|
+
if (text === false) continue;
|
|
317
|
+
for (const key of IOS_PERMISSION_KEYS[domain] ?? []) {
|
|
318
|
+
if (text && !seenKeys.has(key)) { seenKeys.add(key); iosPlist.push({ key, usage: text }); }
|
|
299
319
|
}
|
|
320
|
+
for (const p of ANDROID_PERMISSION_KEYS[domain] ?? []) androidPerms.add(p);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Snapshot the DECLARED capabilities before the baseline widens the plist — see NativeReqs.webCaps.
|
|
324
|
+
const webCaps = {
|
|
325
|
+
camera: seenKeys.has('NSCameraUsageDescription'),
|
|
326
|
+
microphone: seenKeys.has('NSMicrophoneUsageDescription'),
|
|
327
|
+
geolocation: seenKeys.has('NSLocationWhenInUseUsageDescription'),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// ── The webview baseline ───────────────────────────────────────────────────────────────────────
|
|
331
|
+
// The shell's invariant is that every TCC-gated capability a WEB PAGE can reach is either DECLARED
|
|
332
|
+
// (usage string present) or BLOCKED before WebKit enters its native path — see mediaCaptureGuardJs /
|
|
333
|
+
// geolocationGuardJs, which reject getUserMedia + geolocation in JS at document-start.
|
|
334
|
+
//
|
|
335
|
+
// `<input type="file">` is the one reachable path with no such seam: WKWebView's own picker is native
|
|
336
|
+
// and internal, it offers "Take Photo" for an image accept, and TCC hard-kills the HOST process for a
|
|
337
|
+
// camera access with no usage string. There is no JS hook to suppress that menu item and no config in
|
|
338
|
+
// which it is absent — EVERY appwrap iOS build can reach it. When the answer is unconditional the
|
|
339
|
+
// correct mechanism is a default, not a warning the developer has no way to act on: an app author
|
|
340
|
+
// reads `modules` as "features we call", and nothing about a file input reads as "camera".
|
|
341
|
+
//
|
|
342
|
+
// So the key is stamped by default, with honest copy, overridable via `permissions.camera` and
|
|
343
|
+
// opt-out-able with `permissions: { camera: false }`. An unused iOS usage string is inert (it is only
|
|
344
|
+
// ever surfaced when the class is actually requested) — it is not an App Review flag, unlike an
|
|
345
|
+
// entitlement or a background mode.
|
|
346
|
+
//
|
|
347
|
+
// iOS only: the Android chooser's capture path already handles an UNDECLARED CAMERA permission (it
|
|
348
|
+
// checks `declares()` and falls back to the plain picker — file-chooser.android.ts), so Android needs
|
|
349
|
+
// nothing and stays opt-in; adding a runtime permission there would change the Play listing.
|
|
350
|
+
if (cfg.permissions?.camera !== false && !seenKeys.has('NSCameraUsageDescription')) {
|
|
351
|
+
seenKeys.add('NSCameraUsageDescription');
|
|
352
|
+
iosPlist.push({ key: 'NSCameraUsageDescription', usage: WEBVIEW_BASELINE_CAMERA_USAGE });
|
|
300
353
|
}
|
|
301
354
|
|
|
302
355
|
// Active PACK modules (from the config's modulePacks) — their native source + register handler
|
|
@@ -316,6 +369,7 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
316
369
|
activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
|
|
317
370
|
activeOptionalGroups: OPTIONAL_GROUPS.filter((g) => activeMods.some((m) => m.group === g)),
|
|
318
371
|
iosPlist,
|
|
372
|
+
webCaps,
|
|
319
373
|
iosEntitlements,
|
|
320
374
|
androidPerms: [...androidPerms],
|
|
321
375
|
androidGradleDeps: [...gradle],
|
|
@@ -859,6 +913,12 @@ export async function loadConfig(cwd: string, flags: Record<string, string>): Pr
|
|
|
859
913
|
export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
860
914
|
// Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
|
|
861
915
|
// (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
|
|
916
|
+
// Which TCC-gated web APIs this build DECLARED (modules + `permissions{}`). The shell's guard used to
|
|
917
|
+
// sniff Info.plist for the usage strings, but the plist now also carries the webview baseline
|
|
918
|
+
// (an always-present NSCameraUsageDescription so the file-input picker can't kill the process) —
|
|
919
|
+
// sniffing it would silently hand getUserMedia({video}) to every app, including a loader:'server'
|
|
920
|
+
// shell showing pages the author doesn't control. Intent and plist are now stamped separately.
|
|
921
|
+
const webCaps = nativeReqs(cfg).webCaps;
|
|
862
922
|
const es = cfg.envSwitcher;
|
|
863
923
|
const envSwitcher = {
|
|
864
924
|
enabled: !!es && es.enabled !== false,
|
|
@@ -893,6 +953,7 @@ export const SHELL_CONFIG = {
|
|
|
893
953
|
pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
|
|
894
954
|
iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
|
|
895
955
|
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
|
|
956
|
+
webCaps: ${JSON.stringify(webCaps)} as { camera: boolean; microphone: boolean; geolocation: boolean },
|
|
896
957
|
};
|
|
897
958
|
`;
|
|
898
959
|
writeFileSync(join(outDir, 'app/shell/config.ts'), content);
|
|
@@ -3148,7 +3209,29 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
|
|
|
3148
3209
|
}
|
|
3149
3210
|
}
|
|
3150
3211
|
stats.push({ sum: runtime, newest: runtimeNewest });
|
|
3151
|
-
|
|
3212
|
+
const parts = stats.map((s) => s.sum);
|
|
3213
|
+
// The STAMPED shell config (native/app/shell/config.ts) — compiled into bundle.js, and the ONLY
|
|
3214
|
+
// input that carries values which never touch a fingerprinted file. `dev --url` / `deploy` stamp it
|
|
3215
|
+
// from the EFFECTIVE cfg (serverUrl, loader, debug), so with `--url` every other input is byte-identical
|
|
3216
|
+
// and the fingerprint matched → "inputs unchanged" → the previous .ipa (carrying the PREVIOUS
|
|
3217
|
+
// serverUrl) was reinstalled while the on-disk config said the new one. Silent stale deploy.
|
|
3218
|
+
// CONTENT, not mtime: sync/stamp rewrites this file unconditionally on every run, so its mtime is
|
|
3219
|
+
// pure noise (it would bust the cache always). Deliberately excluded from `newest` for the same
|
|
3220
|
+
// reason — an always-now mtime would make the `--resume` gate unusable, and the fingerprint is the
|
|
3221
|
+
// real gate; --resume is the evidence-relaxed first-run path by construction.
|
|
3222
|
+
parts.push(stringHash(readFileIfExists(join(resolve(cwd, flags.out ?? 'native'), 'app/shell/config.ts'))));
|
|
3223
|
+
return { parts, newest: Math.max(...stats.map((s) => s.newest)) };
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
function readFileIfExists(p: string): string {
|
|
3227
|
+
try { return readFileSync(p, 'utf8'); } catch { return ''; }
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/** djb2 over a string — same family as the fingerprint hash; only needs to change when content does. */
|
|
3231
|
+
function stringHash(s: string): number {
|
|
3232
|
+
let h = 5381;
|
|
3233
|
+
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
|
|
3234
|
+
return h;
|
|
3152
3235
|
}
|
|
3153
3236
|
|
|
3154
3237
|
/** Newest mtime across every build input — the evidence the `--resume` gate needs when no build cache
|
|
@@ -3662,6 +3745,11 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3662
3745
|
// ungated. (A fingerprintMatch skip already has an identical cache; nothing to write.)
|
|
3663
3746
|
if (!canSkipBuild || adoptCache) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
|
|
3664
3747
|
|
|
3748
|
+
// Prove the .ipa we're about to install actually carries the serverUrl we just stamped — the CLI used
|
|
3749
|
+
// to print "✓ Deployed" while shipping an artifact built from a PREVIOUS URL (a build-skip whose cache
|
|
3750
|
+
// key ignored the stamped shell config). Cheap, and it fails BEFORE the install rather than silently.
|
|
3751
|
+
assertShippedServerUrl(ipaPath, cfg);
|
|
3752
|
+
|
|
3665
3753
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
3666
3754
|
let installedViaUsbmux = false;
|
|
3667
3755
|
let installed = false;
|
|
@@ -3755,6 +3843,31 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3755
3843
|
: `✓ Deployed to ${device.name}.`);
|
|
3756
3844
|
}
|
|
3757
3845
|
|
|
3846
|
+
/** Deploy-time verification for `loader:'server'`: the .ipa's compiled bundle must contain the exact
|
|
3847
|
+
* `serverUrl` we stamped. Reads the bundle straight out of the zip (no extraction). Exits on mismatch —
|
|
3848
|
+
* shipping a shell pointed at the WRONG origin looks like a working deploy and is the hardest class of
|
|
3849
|
+
* bug to see from the outside. Unverifiable (no unzip / bundle not found) → warn, never block. */
|
|
3850
|
+
function assertShippedServerUrl(ipaPath: string, cfg: AppwrapConfig): void {
|
|
3851
|
+
if ((cfg.loader ?? 'app') !== 'server' || !cfg.serverUrl) return;
|
|
3852
|
+
let bundle = '';
|
|
3853
|
+
try {
|
|
3854
|
+
bundle = execFileSync('sh', ['-c', `unzip -p ${JSON.stringify(ipaPath)} 'Payload/*.app/app/bundle.js'`], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
3855
|
+
} catch { /* no unzip / unexpected layout → fall through to the unverifiable warning */ }
|
|
3856
|
+
if (!bundle) return void console.warn(` ⚠ could not read the bundle out of ${ipaPath.split('/').pop()} — shipped serverUrl NOT verified.`);
|
|
3857
|
+
if (bundle.includes(JSON.stringify(cfg.serverUrl))) {
|
|
3858
|
+
console.log(`✓ verified shipped serverUrl → ${cfg.serverUrl}`);
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
const shipped = [...bundle.matchAll(/serverUrl:\s*("(?:[^"\\]|\\.)*")/g)].map((m) => m[1]);
|
|
3862
|
+
console.error(
|
|
3863
|
+
`\n✖ The .ipa does NOT carry the serverUrl that was just stamped.\n` +
|
|
3864
|
+
` stamped: ${cfg.serverUrl}\n` +
|
|
3865
|
+
(shipped.length ? ` in .ipa: ${shipped.join(', ')}\n` : '') +
|
|
3866
|
+
` → a stale artifact was about to be installed. Re-run with --force to rebuild.\n`
|
|
3867
|
+
);
|
|
3868
|
+
process.exit(1);
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3758
3871
|
/** Install an .ipa over usbmux via ideviceinstaller — a separate stack from devicectl/CoreDevice, so it
|
|
3759
3872
|
* works when the CoreDevice tunnel is stuck. Returns false if ideviceinstaller is absent or the install
|
|
3760
3873
|
* fails (the caller then prints the re-plug / brew-install remedy). */
|
package/src/config.ts
CHANGED
|
@@ -245,11 +245,17 @@ export interface AppwrapConfig {
|
|
|
245
245
|
* testing — products resolve without App Store Connect. Only applies when launched from
|
|
246
246
|
* Xcode (simulator or device-from-Xcode), not a standalone devicectl sideload. */
|
|
247
247
|
storekitConfig?: string;
|
|
248
|
-
/** Permission usage strings, keyed by domain.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
248
|
+
/** Permission usage strings, keyed by domain. A listed domain is ALWAYS stamped (iOS: Info.plist
|
|
249
|
+
* usage string; Android: <uses-permission>) — with or without `modules`; when a module already owns
|
|
250
|
+
* the same key, the string here overrides its default copy. 'contacts' has no iOS key
|
|
251
|
+
* (CNContactPicker needs none) — it only stamps Android READ_CONTACTS.
|
|
252
|
+
*
|
|
253
|
+
* `camera` is stamped by DEFAULT in every iOS build: WKWebView's `<input type="file">` picker offers
|
|
254
|
+
* "Take Photo", and iOS hard-kills the app for a camera access with no usage string. Pass
|
|
255
|
+
* `camera: false` to opt out (only for an app with no file inputs at all), or your own string to
|
|
256
|
+
* replace the default copy. */
|
|
251
257
|
permissions?: Partial<
|
|
252
|
-
Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion' | 'tracking', string>
|
|
258
|
+
Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion' | 'tracking', string | false>
|
|
253
259
|
>;
|
|
254
260
|
/** App Tracking Transparency tracking domains (iOS, `tracking` module). When the module is active
|
|
255
261
|
* the CLI sets the privacy manifest's `NSPrivacyTracking` → true and fills `NSPrivacyTrackingDomains`
|