@livx.cc/appwrap 0.59.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.59.0",
3
+ "version": "0.60.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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="Blank" fontSize="34" fontWeight="700" color="#000000" textAlignment="center" />
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
- const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
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: hasPlistKey('NSCameraUsageDescription'),
338
- microphone: hasPlistKey('NSMicrophoneUsageDescription'),
339
- geolocation: hasPlistKey('NSLocationWhenInUseUsageDescription'),
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);
@@ -66,7 +66,7 @@ export function registerAndroidHandlers(): void {
66
66
  return ok ? 'granted' : 'denied';
67
67
  });
68
68
 
69
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon }: any) => {
69
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, silent }: any) => {
70
70
  const ident = notifIdentity({ title, body, sender, icon });
71
71
  // Per-sender channel gives the mini-app its own identity + settings row; else the shared one.
72
72
  const channelId = ident.useIdentity && ident.senderName ? senderChannelId(ident.senderName) : CHANNEL_ID;
@@ -82,6 +82,10 @@ export function registerAndroidHandlers(): void {
82
82
  .setSmallIcon(ctx.getApplicationInfo().icon)
83
83
  .setContentTitle(ident.title)
84
84
  .setAutoCancel(true);
85
+ // The channel already carries the alert sound (IMPORTANCE_DEFAULT); `silent: true` is the
86
+ // per-notification opt-out that matches iOS's nil-sound path. API 29+ only — below that the
87
+ // channel's sound wins and there is nothing to suppress.
88
+ if (silent && android.os.Build.VERSION.SDK_INT >= 29) builder.setSilent(true);
85
89
  if (ident.subtitle) builder.setSubText(ident.subtitle);
86
90
  if (ident.body) builder.setContentText(ident.body);
87
91
  // Tap → re-open the (singleTask) activity with a VIEW intent; onNewIntent
@@ -227,7 +227,7 @@ export function registerExtendedHandlers(): void {
227
227
  });
228
228
  });
229
229
 
230
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, badge }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number }) => {
230
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, badge, silent }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number; silent?: boolean }) => {
231
231
  if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
232
232
  const nid = id ?? Math.floor(Math.random() * 100000);
233
233
  const ident = notifIdentity({ title, body, sender, icon });
@@ -238,6 +238,13 @@ export function registerExtendedHandlers(): void {
238
238
  if (ident.body) content.body = ident.body;
239
239
  // App-icon badge on delivery (set before the communication-style wrap so it's carried through).
240
240
  if (typeof badge === 'number') content.badge = badge;
241
+ // ALERT SOUND. iOS leaves `content.sound` nil by default, which delivers the notification
242
+ // SILENTLY — so a scheduled timer/alarm fired while the app was backgrounded made no noise at
243
+ // all, with nothing in the API to hint why. Android's side has always played one (its channel
244
+ // is IMPORTANCE_DEFAULT), so the platforms disagreed on the same call. Default to the system
245
+ // alert sound and let a caller opt out with `silent: true`; set BEFORE the communication-style
246
+ // wrap so contentByUpdatingWithProvider carries it through.
247
+ if (!silent) content.sound = UNNotificationSound.defaultSound;
241
248
  // Group per-sender so the mini-app's notifications thread together (iOS 15+ also
242
249
  // uses this alongside the communication-style avatar below).
243
250
  if (ident.useIdentity && ident.senderName) content.threadIdentifier = ident.senderName;
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
- iosPlist.push({ key: p.key, usage: cfg.permissions?.[p.domain as keyof typeof cfg.permissions] ?? p.defaultUsage });
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
- } else {
293
- // legacy: only what `permissions{}` declares (via the key maps) — no behavior change
294
- for (const [domain, text] of Object.entries(cfg.permissions ?? {})) {
295
- for (const key of IOS_PERMISSION_KEYS[domain] ?? []) {
296
- if (text && !seenKeys.has(key)) { seenKeys.add(key); iosPlist.push({ key, usage: text }); }
297
- }
298
- for (const p of ANDROID_PERMISSION_KEYS[domain] ?? []) androidPerms.add(p);
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
- return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
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. Only listed ones are stamped
249
- * (iOS: Info.plist usage string; Android: <uses-permission>). 'contacts' has no
250
- * iOS key (CNContactPicker needs none) — it only stamps Android READ_CONTACTS. */
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`