@livx.cc/appwrap 0.26.0 → 0.28.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
  3. package/runtime/app/app.ts +57 -39
  4. package/runtime/app/shell/android-helpers.ts +6 -6
  5. package/runtime/app/shell/background-bootstrap.generated.ts +8 -0
  6. package/runtime/app/shell/background-context.ts +20 -0
  7. package/runtime/app/shell/banner.ts +9 -4
  8. package/runtime/app/shell/bridge.ts +2 -0
  9. package/runtime/app/shell/capabilities.manifest.ts +22 -3
  10. package/runtime/app/shell/custom-webview.android.ts +50 -23
  11. package/runtime/app/shell/custom-webview.ios.ts +90 -78
  12. package/runtime/app/shell/devmenu.ts +2 -2
  13. package/runtime/app/shell/env.ts +1 -3
  14. package/runtime/app/shell/events.ts +25 -4
  15. package/runtime/app/shell/fcm-firebase.d.ts +2 -1
  16. package/runtime/app/shell/handlers-android.ts +16 -12
  17. package/runtime/app/shell/handlers-background.ts +290 -0
  18. package/runtime/app/shell/handlers-billing.ts +90 -82
  19. package/runtime/app/shell/handlers-extended.ts +141 -110
  20. package/runtime/app/shell/handlers-fs.ts +53 -41
  21. package/runtime/app/shell/handlers-health.ts +27 -19
  22. package/runtime/app/shell/handlers-media.ts +8 -14
  23. package/runtime/app/shell/handlers-oauth.ts +19 -19
  24. package/runtime/app/shell/handlers-parity.ts +118 -89
  25. package/runtime/app/shell/handlers-push.ts +5 -15
  26. package/runtime/app/shell/handlers-reviews.ts +5 -5
  27. package/runtime/app/shell/handlers-scanner.ts +36 -38
  28. package/runtime/app/shell/handlers-speech.ts +43 -49
  29. package/runtime/app/shell/handlers-system.ts +122 -1
  30. package/runtime/app/shell/handlers.ts +10 -3
  31. package/runtime/app/shell/ios-frameworks.d.ts +9 -44
  32. package/runtime/app/shell/ios-ui-delegate.ts +112 -108
  33. package/runtime/app/shell/native-log.ts +1 -3
  34. package/runtime/app/shell/status-bar.ts +7 -4
  35. package/runtime/app/shell/toast.ts +1 -1
  36. package/runtime/references.d.ts +21 -0
  37. package/runtime/tsconfig.json +1 -0
  38. package/src/cli.ts +74 -18
  39. package/src/config.ts +20 -0
  40. package/src/derive.ts +75 -0
@@ -12,118 +12,122 @@ import { Utils } from '@nativescript/core';
12
12
  *
13
13
  * Returns a retained delegate instance (the WKWebView holds uiDelegate weakly).
14
14
  */
15
- export function createUiDelegate(): any {
16
- const DelegateClass = (NSObject as any).extend(
17
- {
18
- // iOS 15+: WKUIDelegate media capture permission.
19
- webViewRequestMediaCapturePermissionForOriginInitiatedByFrameTypeDecisionHandler(
20
- _webView: WKWebView,
21
- _origin: any,
22
- _frame: any,
23
- _type: any,
24
- decisionHandler: (decision: number) => void
25
- ): void {
26
- // WKPermissionDecision.Grant === 1 — silently grant (the app already holds the
27
- // OS-level mic/camera permission; media.ensurePermission established TCC).
28
- // NOTE: this only ever sees capabilities the build DECLARED — a request for an
29
- // UNDECLARED capability is rejected upstream in JS (mediaCaptureGuardJs), because
30
- // WebKit asks TCC to authorize the device AROUND this callback and a missing usage
31
- // string is a hard process-kill a native `deny` here cannot prevent. So denying
32
- // here is pointless for the crash; the real guard must be (and is) at the JS layer.
33
- decisionHandler(1);
34
- },
15
+ @NativeClass()
16
+ class AppwrapUiDelegate extends NSObject implements WKUIDelegate {
17
+ static ObjCProtocols = [WKUIDelegate];
18
+ static new(): AppwrapUiDelegate {
19
+ return <AppwrapUiDelegate>super.new();
20
+ }
35
21
 
36
- // Preserve the behavior NS's own UIDelegate gave us (we fully replace it):
37
- // target=_blank / window.open has no new window in a shell → load it in place.
38
- webViewCreateWebViewWithConfigurationForNavigationActionWindowFeatures(
39
- webView: WKWebView,
40
- _configuration: any,
41
- navigationAction: WKNavigationAction,
42
- _windowFeatures: any
43
- ): WKWebView | null {
44
- const frame = navigationAction.targetFrame;
45
- if (!frame || !frame.mainFrame) webView.loadRequest(navigationAction.request);
46
- return null;
47
- },
22
+ // iOS 15+: WKUIDelegate media capture permission.
23
+ webViewRequestMediaCapturePermissionForOriginInitiatedByFrameTypeDecisionHandler(
24
+ _webView: WKWebView,
25
+ _origin: WKSecurityOrigin,
26
+ _frame: WKFrameInfo,
27
+ _type: WKMediaCaptureType,
28
+ decisionHandler: (decision: WKPermissionDecision) => void
29
+ ): void {
30
+ // WKPermissionDecision.Grant === 1 — silently grant (the app already holds the
31
+ // OS-level mic/camera permission; media.ensurePermission established TCC).
32
+ // NOTE: this only ever sees capabilities the build DECLARED — a request for an
33
+ // UNDECLARED capability is rejected upstream in JS (mediaCaptureGuardJs), because
34
+ // WebKit asks TCC to authorize the device AROUND this callback and a missing usage
35
+ // string is a hard process-kill a native `deny` here cannot prevent. So denying
36
+ // here is pointless for the crash; the real guard must be (and is) at the JS layer.
37
+ decisionHandler(WKPermissionDecision.Grant);
38
+ }
48
39
 
49
- webViewRunJavaScriptAlertPanelWithMessageInitiatedByFrameCompletionHandler(
50
- _webView: WKWebView,
51
- message: string,
52
- _frame: any,
53
- completionHandler: () => void
54
- ): void {
55
- Utils.dispatchToMainThread(() => {
56
- const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
57
- null as any,
58
- String(message ?? ''),
59
- UIAlertControllerStyle.Alert
60
- );
61
- alert.addAction(
62
- UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler())
63
- );
64
- present(alert);
65
- });
66
- },
40
+ // Preserve the behavior NS's own UIDelegate gave us (we fully replace it):
41
+ // target=_blank / window.open has no new window in a shell → load it in place.
42
+ webViewCreateWebViewWithConfigurationForNavigationActionWindowFeatures(
43
+ webView: WKWebView,
44
+ _configuration: WKWebViewConfiguration,
45
+ navigationAction: WKNavigationAction,
46
+ _windowFeatures: WKWindowFeatures
47
+ ): WKWebView {
48
+ const frame = navigationAction.targetFrame;
49
+ if (!frame || !frame.mainFrame) webView.loadRequest(navigationAction.request);
50
+ return null as unknown as WKWebView;
51
+ }
67
52
 
68
- webViewRunJavaScriptConfirmPanelWithMessageInitiatedByFrameCompletionHandler(
69
- _webView: WKWebView,
70
- message: string,
71
- _frame: any,
72
- completionHandler: (ok: boolean) => void
73
- ): void {
74
- Utils.dispatchToMainThread(() => {
75
- const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
76
- null as any,
77
- String(message ?? ''),
78
- UIAlertControllerStyle.Alert
79
- );
80
- alert.addAction(
81
- UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
82
- completionHandler(false)
83
- )
84
- );
85
- alert.addAction(
86
- UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler(true))
87
- );
88
- present(alert);
89
- });
90
- },
53
+ webViewRunJavaScriptAlertPanelWithMessageInitiatedByFrameCompletionHandler(
54
+ _webView: WKWebView,
55
+ message: string,
56
+ _frame: WKFrameInfo,
57
+ completionHandler: () => void
58
+ ): void {
59
+ Utils.dispatchToMainThread(() => {
60
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
61
+ null as unknown as string,
62
+ String(message ?? ''),
63
+ UIAlertControllerStyle.Alert
64
+ );
65
+ alert.addAction(
66
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler())
67
+ );
68
+ present(alert);
69
+ });
70
+ }
91
71
 
92
- webViewRunJavaScriptTextInputPanelWithPromptDefaultTextInitiatedByFrameCompletionHandler(
93
- _webView: WKWebView,
94
- prompt: string,
95
- defaultText: string,
96
- _frame: any,
97
- completionHandler: (text: string | null) => void
98
- ): void {
99
- Utils.dispatchToMainThread(() => {
100
- const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
101
- null as any,
102
- String(prompt ?? ''),
103
- UIAlertControllerStyle.Alert
104
- );
105
- let field: UITextField | null = null;
106
- alert.addTextFieldWithConfigurationHandler((tf: UITextField) => {
107
- tf.text = String(defaultText ?? '');
108
- field = tf;
109
- });
110
- alert.addAction(
111
- UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
112
- completionHandler(null)
113
- )
114
- );
115
- alert.addAction(
116
- UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () =>
117
- completionHandler(field ? String(field.text ?? '') : '')
118
- )
119
- );
120
- present(alert);
121
- });
122
- },
123
- },
124
- { protocols: [WKUIDelegate] }
125
- );
126
- return DelegateClass.new();
72
+ webViewRunJavaScriptConfirmPanelWithMessageInitiatedByFrameCompletionHandler(
73
+ _webView: WKWebView,
74
+ message: string,
75
+ _frame: WKFrameInfo,
76
+ completionHandler: (ok: boolean) => void
77
+ ): void {
78
+ Utils.dispatchToMainThread(() => {
79
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
80
+ null as unknown as string,
81
+ String(message ?? ''),
82
+ UIAlertControllerStyle.Alert
83
+ );
84
+ alert.addAction(
85
+ UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
86
+ completionHandler(false)
87
+ )
88
+ );
89
+ alert.addAction(
90
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler(true))
91
+ );
92
+ present(alert);
93
+ });
94
+ }
95
+
96
+ webViewRunJavaScriptTextInputPanelWithPromptDefaultTextInitiatedByFrameCompletionHandler(
97
+ _webView: WKWebView,
98
+ prompt: string,
99
+ defaultText: string,
100
+ _frame: WKFrameInfo,
101
+ completionHandler: (text: string) => void
102
+ ): void {
103
+ Utils.dispatchToMainThread(() => {
104
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
105
+ null as unknown as string,
106
+ String(prompt ?? ''),
107
+ UIAlertControllerStyle.Alert
108
+ );
109
+ let field: UITextField | null = null;
110
+ alert.addTextFieldWithConfigurationHandler((tf: UITextField) => {
111
+ tf.text = String(defaultText ?? '');
112
+ field = tf;
113
+ });
114
+ alert.addAction(
115
+ UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
116
+ completionHandler(null as unknown as string)
117
+ )
118
+ );
119
+ alert.addAction(
120
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () =>
121
+ completionHandler(field ? String(field.text ?? '') : '')
122
+ )
123
+ );
124
+ present(alert);
125
+ });
126
+ }
127
+ }
128
+
129
+ export function createUiDelegate(): WKUIDelegate {
130
+ return AppwrapUiDelegate.new();
127
131
  }
128
132
 
129
133
  function present(alert: UIAlertController): void {
@@ -1,7 +1,5 @@
1
1
  import { knownFolders, path as nsPath } from '@nativescript/core';
2
2
 
3
- declare const NSString: any;
4
-
5
3
  /**
6
4
  * Single debug log sink → Documents/appwrap-web.log. Both the forwarded WebView console
7
5
  * (custom-webview) and native shell diagnostics (handlers) write here, because NativeScript's
@@ -15,7 +13,7 @@ export function appwrapNativeLog(line: string): void {
15
13
  _log += line + '\n';
16
14
  if (_log.length > 160000) _log = _log.slice(-120000);
17
15
  const p = nsPath.join(knownFolders.documents().path, 'appwrap-web.log');
18
- NSString.stringWithString(_log).writeToFileAtomicallyEncodingError(p, true, 4 /*NSUTF8*/, null as any);
16
+ NSString.stringWithString(_log).writeToFileAtomicallyEncodingError(p, true, 4 /*NSUTF8*/);
19
17
  } catch {
20
18
  /* best-effort */
21
19
  }
@@ -28,7 +28,7 @@ export function enableAndroidEdgeToEdge(): void {
28
28
  try {
29
29
  // Explicit transparent status-bar colors (light + dark) so the page background — not the NS
30
30
  // page backgroundColor — shows under the status bar. Nav bar keeps NS's default subtle scrim.
31
- (Utils as any).android.enableEdgeToEdge(activity, {
31
+ Utils.android.enableEdgeToEdge(activity, {
32
32
  statusBarLightColor: transparent,
33
33
  statusBarDarkColor: transparent,
34
34
  });
@@ -55,6 +55,7 @@ export function wireAndroidSafeArea(webView: View): void {
55
55
  // Un-box the layout that wraps the WebView so it (and its child WebView) fills the window UNDER the
56
56
  // bars. 'dont-apply' = NS won't pad it with the system-bar insets.
57
57
  const layout = (webView.parent as View) ?? webView;
58
+ // androidOverflowEdge: not in the public View typings — an NS edge-to-edge layout hint set dynamically.
58
59
  (layout as any).androidOverflowEdge = 'dont-apply';
59
60
 
60
61
  const density = Screen.mainScreen.scale || 1; // physical px per dp
@@ -65,7 +66,8 @@ export function wireAndroidSafeArea(webView: View): void {
65
66
  `s.setProperty('--saie-right','${r}px');s.setProperty('--saie-bottom','${b}px');` +
66
67
  `})(document.documentElement.style);`;
67
68
  try {
68
- (webView as any).android?.evaluateJavascript(js, null);
69
+ // webView.android is typed `any` by NS core; on Android it's an android.webkit.WebView.
70
+ webView.android?.evaluateJavascript(js, null);
69
71
  } catch (e) {
70
72
  console.warn('AppWrap: safe-area inject failed', e);
71
73
  }
@@ -82,7 +84,7 @@ export function wireAndroidSafeArea(webView: View): void {
82
84
  const decor = Application.android?.startActivity?.getWindow()?.getDecorView();
83
85
  const wi = decor?.getRootWindowInsets?.();
84
86
  if (!wi) return false;
85
- const Type = (global as any).android.view.WindowInsets.Type;
87
+ const Type = android.view.WindowInsets.Type;
86
88
  const ins = wi.getInsets(Type.systemBars() | Type.displayCutout());
87
89
  inject(dp(ins.left), dp(ins.top), dp(ins.right), dp(ins.bottom));
88
90
  return ins.top + ins.bottom + ins.left + ins.right > 0;
@@ -132,12 +134,13 @@ export function applyThemeColor(color: string): void {
132
134
  /** 'light' = white icons/text (for dark backgrounds), 'dark' = black. */
133
135
  export function setStatusBarStyle(style: 'light' | 'dark'): void {
134
136
  if (isIOS) {
137
+ // statusBarStyle is a CSS-backed Page property, not on the public Page type — set dynamically.
135
138
  if (currentPage) (currentPage as any).statusBarStyle = style;
136
139
  } else if (isAndroid) {
137
140
  const window = Application.android?.startActivity?.getWindow();
138
141
  if (!window) return;
139
142
  const decorView = window.getDecorView();
140
- const controller = (decorView as any).getWindowInsetsController?.();
143
+ const controller = decorView.getWindowInsetsController?.();
141
144
  if (controller) {
142
145
  const APPEARANCE_LIGHT_STATUS_BARS = 8; // WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
143
146
  // Android "light status bar" = dark icons; invert from our naming
@@ -4,7 +4,7 @@ import { Utils, isAndroid, isIOS } from '@nativescript/core';
4
4
  export function showToast(message: string, duration: 'short' | 'long' = 'short'): void {
5
5
  const ms = duration === 'long' ? 3500 : 2000;
6
6
  if (isAndroid) {
7
- const Toast = (android as any).widget.Toast;
7
+ const Toast = android.widget.Toast;
8
8
  Toast.makeText(
9
9
  Utils.android.getApplicationContext(),
10
10
  message,
@@ -1,2 +1,23 @@
1
1
  /// <reference path="./node_modules/@nativescript/types-ios/index.d.ts" />
2
2
  /// <reference path="./node_modules/@nativescript/types-android/lib/android-32.d.ts" />
3
+
4
+ // Opt-in iOS framework typings the shell's native handlers use. @nativescript/types-ios ships the FULL
5
+ // SDK but its default index references only a curated subset (for IDE/compile speed); reference the
6
+ // extras here so handlers get real types + autocomplete instead of `any`. (NativeScript-documented:
7
+ // blog.nativescript.org/where-did-my-types-go) Path arch ('objc-x86_64') is where they were generated;
8
+ // the API surface is arch-independent for type-checking.
9
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Contacts.d.ts" />
10
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!ContactsUI.d.ts" />
11
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!HealthKit.d.ts" />
12
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!EventKit.d.ts" />
13
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!CoreMotion.d.ts" />
14
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!StoreKit.d.ts" />
15
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!LocalAuthentication.d.ts" />
16
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!PhotosUI.d.ts" />
17
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!_LocationEssentials.d.ts" />
18
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!CoreLocation.d.ts" />
19
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AVFoundation.d.ts" />
20
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Speech.d.ts" />
21
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!BackgroundTasks.d.ts" />
22
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AuthenticationServices.d.ts" />
23
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Photos.d.ts" />
@@ -5,6 +5,7 @@
5
5
  "moduleResolution": "bundler",
6
6
  "lib": ["es2021", "dom"],
7
7
  "experimentalDecorators": true,
8
+ "noImplicitAny": true,
8
9
  "removeComments": false,
9
10
  "noEmitHelpers": true,
10
11
  "noEmitOnError": true,
package/src/cli.ts CHANGED
@@ -25,8 +25,25 @@ import {
25
25
  iosOrientations,
26
26
  mergeManifest,
27
27
  stampAndroidOrientation,
28
+ stampAndroidQueries,
29
+ stampPlistBackgroundTasks,
28
30
  stampPlistOrientations,
29
31
  } from './derive';
32
+ import type { WebManifest } from './derive';
33
+
34
+ /** What `child_process.execFileSync` attaches to the Error it throws on a non-zero exit
35
+ * (stdout/stderr are Buffer with the default encoding, string when `encoding` is set). */
36
+ interface ExecError extends Error {
37
+ stdout?: string | Buffer;
38
+ stderr?: string | Buffer;
39
+ status?: number | null;
40
+ }
41
+ const asExecError = (e: unknown): ExecError => (e ?? {}) as ExecError;
42
+ /** Combined stdout+stderr captured on an exec failure (empty string when none). */
43
+ const execErrText = (e: unknown): string => {
44
+ const err = asExecError(e);
45
+ return `${err.stdout ?? ''}${err.stderr ?? ''}`;
46
+ };
30
47
 
31
48
  /** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
32
49
  * across semver bumps so store re-uploads are always accepted without a manual bump. */
@@ -175,6 +192,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
175
192
  reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
176
193
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
177
194
  speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
195
+ backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
178
196
  };
179
197
 
180
198
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
@@ -195,6 +213,18 @@ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
195
213
  `/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
196
214
  `${imports}${imports ? '\n' : ''}\nexport function registerOptionalHandlers(): void {\n${calls}\n}\n`
197
215
  );
216
+
217
+ // iOS BGTaskScheduler launch handlers must register at didFinishLaunching (the AppDelegate calls
218
+ // registerBackgroundLaunchHandlers) — too early for the page-load barrel. Wire the real impl ONLY
219
+ // when backgroundTask is active, so a build without it never references BGTaskScheduler. No-op default.
220
+ const bgActive = req.activeOptionalGroups.includes('backgroundTask');
221
+ writeFileSync(
222
+ join(shell, 'background-bootstrap.generated.ts'),
223
+ `/** Generated by \`appwrap\` — wires the iOS BGTask launch handlers only when backgroundTask is active. Do not edit. */\n` +
224
+ (bgActive
225
+ ? `export { registerBackgroundTaskLaunchHandlers as registerBackgroundLaunchHandlers } from './handlers-background';\n`
226
+ : `export function registerBackgroundLaunchHandlers(): void {}\n`)
227
+ );
198
228
  }
199
229
 
200
230
  /** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
@@ -286,15 +316,15 @@ function parseArgs(argv: string[]) {
286
316
  }
287
317
 
288
318
  /** Parse the PWA's web manifest (manifest.json / .webmanifest) from the dist dir, or null. */
289
- function loadManifest(cwd: string, cfg: AppwrapConfig): Record<string, any> | null {
319
+ function loadManifest(cwd: string, cfg: AppwrapConfig): WebManifest | null {
290
320
  const dist = resolve(cwd, cfg.pwaDist);
291
321
  for (const name of ['manifest.json', 'manifest.webmanifest']) {
292
322
  const mf = join(dist, name);
293
323
  if (!existsSync(mf)) continue;
294
324
  try {
295
325
  return JSON.parse(readFileSync(mf, 'utf8'));
296
- } catch (e: any) {
297
- console.warn(`⚠ Could not parse ${name}: ${e.message}`);
326
+ } catch (e: unknown) {
327
+ console.warn(`⚠ Could not parse ${name}: ${e instanceof Error ? e.message : String(e)}`);
298
328
  }
299
329
  }
300
330
  return null;
@@ -398,6 +428,12 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
398
428
  // arrays (iPhone + ~ipad). Skipped when unset → keep the template's free-rotation default.
399
429
  if (cfg.orientation) src = stampPlistOrientations(src, iosOrientations(cfg.orientation));
400
430
 
431
+ // Headless background tasks (backgroundTask module): stamp BGTaskSchedulerPermittedIdentifiers +
432
+ // fetch/processing background modes from `backgroundTasks`. Idempotent both ways — passing []/undefined
433
+ // strips the block — so it no-ops (and cleans up) when the module is inactive or the field is absent.
434
+ const bgActive = req.activeOptionalGroups.includes('backgroundTask');
435
+ src = stampPlistBackgroundTasks(src, bgActive ? cfg.backgroundTasks : undefined);
436
+
401
437
  // Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
402
438
  src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
403
439
  const extras: string[] = [];
@@ -412,6 +448,12 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
412
448
  ` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleTypeRole</key>\n <string>Editor</string>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
413
449
  );
414
450
  }
451
+ // Schemes kit.app.canOpenUrl() may probe → LSApplicationQueriesSchemes (iOS 9+ requires declaration
452
+ // for custom schemes). No-op when absent.
453
+ if (cfg.queryUrlSchemes?.length) {
454
+ const items = cfg.queryUrlSchemes.map((s) => ` <string>${s}</string>`).join('\n');
455
+ extras.push(` <key>LSApplicationQueriesSchemes</key>\n <array>\n${items}\n </array>`);
456
+ }
415
457
  if (extras.length) {
416
458
  src = src.replace(
417
459
  /<\/dict>\s*<\/plist>\s*$/,
@@ -644,6 +686,10 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
644
686
  /<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
645
687
  `<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
646
688
  );
689
+ // <queries> for kit.app.canOpenUrl() visibility probes (API 30+) — idempotent marker. queryPackages
690
+ // → explicit <package>; queryUrlSchemes → a VIEW <intent> per scheme (symmetric with iOS's
691
+ // LSApplicationQueriesSchemes). See stampAndroidQueries.
692
+ src = stampAndroidQueries(src, cfg.queryPackages, cfg.queryUrlSchemes);
647
693
  writeFileSync(manifest, src);
648
694
  }
649
695
  }
@@ -845,13 +891,13 @@ function vendorBackendAssets(www: string, cfg: AppwrapConfig): void {
845
891
  // Retry a couple times (the backend can briefly reset under deploy/cold-start), and on total
846
892
  // failure fall back to the cached copy if one exists rather than breaking the whole sync.
847
893
  let ok = false;
848
- let lastErr: any;
894
+ let lastErr: unknown;
849
895
  for (let attempt = 0; attempt < 3 && !ok; attempt++) {
850
896
  try {
851
897
  execFileSync('curl', ['-fsSL', '--retry', '2', url, '-o', tmp], { stdio: 'pipe' });
852
898
  cpSync(tmp, dest);
853
899
  ok = true;
854
- } catch (e: any) {
900
+ } catch (e: unknown) {
855
901
  lastErr = e;
856
902
  }
857
903
  }
@@ -860,10 +906,12 @@ function vendorBackendAssets(www: string, cfg: AppwrapConfig): void {
860
906
  console.log(` vendor ← ${url}`);
861
907
  } else if (existsSync(dest) && readFileSync(dest).length > 0) {
862
908
  console.warn(`⚠ vendor fetch failed: ${url} — using cached copy (backend unreachable).`);
863
- if (lastErr?.stderr) console.warn(` ${String(lastErr.stderr).trim()}`);
909
+ const err = execErrText(lastErr);
910
+ if (err) console.warn(` ${err.trim()}`);
864
911
  } else {
865
912
  console.error(`✖ vendor fetch failed: ${url} (backend reachable? path correct?) — no cached copy to fall back to`);
866
- if (lastErr?.stderr) console.error(String(lastErr.stderr).trim());
913
+ const err = execErrText(lastErr);
914
+ if (err) console.error(err.trim());
867
915
  process.exit(1);
868
916
  }
869
917
  }
@@ -1090,19 +1138,27 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
1090
1138
 
1091
1139
  interface DeviceInfo { id: string; name: string; model: string; transport: string }
1092
1140
 
1141
+ /** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
1142
+ interface DevicectlDevice {
1143
+ identifier?: string;
1144
+ deviceProperties?: { name?: string };
1145
+ hardwareProperties?: { platform?: string; marketingName?: string; productType?: string };
1146
+ connectionProperties?: { tunnelState?: string; transportType?: string };
1147
+ }
1148
+
1093
1149
  /** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
1094
1150
  * tunnels and non-iOS (watch). Returns [] if none. */
1095
1151
  function listIosDevices(): DeviceInfo[] {
1096
1152
  const out = join(tmpdir(), `appwrap-devices-${process.pid}.json`);
1097
1153
  try {
1098
1154
  execFileSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', out], { stdio: 'pipe' });
1099
- const j = JSON.parse(readFileSync(out, 'utf8'));
1155
+ const j = JSON.parse(readFileSync(out, 'utf8')) as { result?: { devices?: DevicectlDevice[] } };
1100
1156
  rmSync(out, { force: true });
1101
1157
  return (j?.result?.devices ?? [])
1102
- .filter((d: any) => d?.hardwareProperties?.platform === 'iOS'
1158
+ .filter((d) => d?.hardwareProperties?.platform === 'iOS'
1103
1159
  && d?.connectionProperties?.tunnelState !== 'unavailable')
1104
- .map((d: any) => ({
1105
- id: d.identifier,
1160
+ .map((d) => ({
1161
+ id: d.identifier ?? '',
1106
1162
  name: d?.deviceProperties?.name ?? '(unknown)',
1107
1163
  model: d?.hardwareProperties?.marketingName ?? d?.hardwareProperties?.productType ?? '',
1108
1164
  transport: d?.connectionProperties?.transportType ?? '',
@@ -1129,7 +1185,7 @@ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1129
1185
  }
1130
1186
  console.log('Multiple devices connected:');
1131
1187
  devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name} — ${d.model || 'iPhone'} [${d.transport}]`));
1132
- const ans = (globalThis as any).prompt(`Select device [1-${devices.length}]: `);
1188
+ const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(`Select device [1-${devices.length}]: `);
1133
1189
  const idx = Number(ans) - 1;
1134
1190
  if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) {
1135
1191
  console.error('✖ Invalid selection.'); process.exit(1);
@@ -1192,8 +1248,8 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1192
1248
  execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', '25', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
1193
1249
  );
1194
1250
  process.stdout.write(out);
1195
- } catch (e: any) {
1196
- const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
1251
+ } catch (e: unknown) {
1252
+ const log = execErrText(e);
1197
1253
  process.stderr.write(log);
1198
1254
  if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) {
1199
1255
  // Free Apple developer profile caps a device at 3 app IDs — a sibling appwrap/WDA build often eats a slot.
@@ -1255,8 +1311,8 @@ function usbmuxInstall(ipaPath: string): boolean {
1255
1311
  const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
1256
1312
  process.stdout.write(out);
1257
1313
  return /Complete|Installed/i.test(out);
1258
- } catch (e: any) {
1259
- process.stderr.write(`${e?.stdout ?? ''}${e?.stderr ?? ''}`);
1314
+ } catch (e: unknown) {
1315
+ process.stderr.write(execErrText(e));
1260
1316
  return false;
1261
1317
  }
1262
1318
  }
@@ -1269,8 +1325,8 @@ function withUnlockRetry<T>(label: string, run: () => T, tries = 40, delayMs = 3
1269
1325
  for (let i = 0; ; i++) {
1270
1326
  try {
1271
1327
  return run();
1272
- } catch (e: any) {
1273
- const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
1328
+ } catch (e: unknown) {
1329
+ const log = execErrText(e);
1274
1330
  if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) throw e;
1275
1331
  // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs) is NOT a lock —
1276
1332
  // retrying as "waiting for unlock" is pointless + misleading. Re-throw so the caller can fall back.
package/src/config.ts CHANGED
@@ -43,6 +43,19 @@ export interface AppwrapConfig {
43
43
  pwaDist: string;
44
44
  /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
45
45
  urlScheme?: string;
46
+ /** Custom URL schemes `kit.app.canOpenUrl()` may probe (e.g. `['whatsapp', 'tg']`) — covers BOTH
47
+ * platforms with a single declaration. Since iOS 9 / Android API 30+ a probe of an undeclared custom
48
+ * scheme returns false for privacy. These stamp into iOS Info.plist `LSApplicationQueriesSchemes` AND
49
+ * Android `<queries>` as a VIEW `<intent>` per scheme — so scheme-based `canOpenUrl` is symmetric
50
+ * without hand-mapping each scheme to a package. Common schemes (http/https/tel/mailto/sms) need no
51
+ * declaration on either platform. No-op when absent. */
52
+ queryUrlSchemes?: string[];
53
+ /** Android-only explicit package visibility for `kit.app.canOpenUrl()` (e.g. `['com.whatsapp']`) —
54
+ * for probing a specific package directly. Under API 30+ package visibility, `resolveActivity`
55
+ * returns null for undeclared packages — these stamp into AndroidManifest `<queries>` as
56
+ * `<package android:name="..."/>`. For scheme probes prefer `queryUrlSchemes` (cross-platform).
57
+ * No-op when absent. */
58
+ queryPackages?: string[];
46
59
  /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
47
60
  icon?: string;
48
61
  /** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
@@ -120,6 +133,13 @@ export interface AppwrapConfig {
120
133
  * (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
121
134
  * capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
122
135
  modules?: string[];
136
+ /** Permitted headless background-task identifiers (for the `backgroundTask` module). iOS REQUIRES
137
+ * these declared at build time — they stamp into Info.plist `BGTaskSchedulerPermittedIdentifiers`
138
+ * (without them `BGTaskScheduler.register` throws) plus `fetch`+`processing` into UIBackgroundModes.
139
+ * Android self-initializes WorkManager via its androidx startup provider — nothing to stamp. The
140
+ * same ids are what `kit.backgroundTask.register(id, …)` / `.schedule({id})` use. No-op when absent
141
+ * or the module is inactive. */
142
+ backgroundTasks?: string[];
123
143
  /** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
124
144
  * team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
125
145
  * handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw