@livx.cc/appwrap 0.39.4 → 0.39.8

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.
@@ -15,21 +15,31 @@ const err = (code: string, message: string) => Object.assign(new Error(message),
15
15
  // empty protocols list it registers under the wrong selector and AVFoundation never calls it — the
16
16
  // scanner opens but never decodes. The first decoded object is forwarded via the `onResult` instance
17
17
  // field (assigned after `new()`), replacing the original closure capture.
18
- @NativeClass()
19
- class MetadataDelegate extends NSObject implements AVCaptureMetadataOutputObjectsDelegate {
20
- static ObjCProtocols = [AVCaptureMetadataOutputObjectsDelegate];
21
- onResult?: (obj: AVMetadataObject) => void;
22
- static new(): MetadataDelegate {
23
- return <MetadataDelegate>super.new();
24
- }
25
- captureOutputDidOutputMetadataObjectsFromConnection(
26
- _output: AVCaptureOutput,
27
- metadataObjects: NSArray<AVMetadataObject> | AVMetadataObject[],
28
- _connection: AVCaptureConnection
29
- ): void {
30
- const obj = (metadataObjects as NSArray<AVMetadataObject>)?.firstObject;
31
- if (obj) this.onResult?.(obj);
18
+ // Built lazily INSIDE the iOS-only path (mirrors banner.ts): NSObject/AV* are iOS globals and this file
19
+ // is imported on Android too, so a top-level `@NativeClass extends NSObject` would crash at module load.
20
+ // any: runtime-built ObjC subclass holder; the class BODY stays fully typed.
21
+ let MetadataDelegate: any;
22
+ function metadataDelegateClass(): any {
23
+ if (!MetadataDelegate) {
24
+ @NativeClass()
25
+ class MetadataDelegateImpl extends NSObject implements AVCaptureMetadataOutputObjectsDelegate {
26
+ static ObjCProtocols = [AVCaptureMetadataOutputObjectsDelegate];
27
+ onResult?: (obj: AVMetadataObject) => void;
28
+ static new(): MetadataDelegateImpl {
29
+ return <MetadataDelegateImpl>super.new();
30
+ }
31
+ captureOutputDidOutputMetadataObjectsFromConnection(
32
+ _output: AVCaptureOutput,
33
+ metadataObjects: NSArray<AVMetadataObject> | AVMetadataObject[],
34
+ _connection: AVCaptureConnection
35
+ ): void {
36
+ const obj = (metadataObjects as NSArray<AVMetadataObject>)?.firstObject;
37
+ if (obj) this.onResult?.(obj);
38
+ }
39
+ }
40
+ MetadataDelegate = MetadataDelegateImpl;
32
41
  }
42
+ return MetadataDelegate;
33
43
  }
34
44
 
35
45
  /**
@@ -124,8 +134,8 @@ function registerIos(): void {
124
134
 
125
135
  // Delegate fires on a dedicated serial queue (see dispatch_queue_create below); settle()
126
136
  // hops back to the main thread for the WKWebView bridge reply.
127
- const delegate = MetadataDelegate.new();
128
- delegate.onResult = (obj) => {
137
+ const delegate = metadataDelegateClass().new();
138
+ delegate.onResult = (obj: AVMetadataObject) => {
129
139
  if (!pending) return;
130
140
  const value = (obj as AVMetadataMachineReadableCodeObject).stringValue;
131
141
  if (value == null) return;
@@ -4,23 +4,6 @@ import { requestPermissions } from './android-helpers';
4
4
 
5
5
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
6
6
 
7
- // iOS speech-synthesizer delegate — both finish & cancel settle the pending speak (the JS
8
- // `speakPending` is captured via the `onSettle` instance field, assigned after `new()`).
9
- @NativeClass()
10
- class SpeakDelegate extends NSObject implements AVSpeechSynthesizerDelegate {
11
- static ObjCProtocols = [AVSpeechSynthesizerDelegate];
12
- onSettle?: () => void;
13
- static new(): SpeakDelegate {
14
- return <SpeakDelegate>super.new();
15
- }
16
- speechSynthesizerDidFinishSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
17
- this.onSettle?.();
18
- }
19
- speechSynthesizerDidCancelSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
20
- this.onSettle?.();
21
- }
22
- }
23
-
24
7
  /**
25
8
  * Voice I/O. TTS: iOS AVSpeechSynthesizer / Android TextToSpeech — speak resolves on finish.
26
9
  * STT: iOS SFSpeechRecognizer + AVAudioEngine mic tap / Android SpeechRecognizer — listen resolves
@@ -37,6 +20,26 @@ export function registerSpeechHandlers(): void {
37
20
  }
38
21
 
39
22
  function registerIos(): void {
23
+ // iOS speech-synthesizer delegate — both finish & cancel settle the pending speak (the JS
24
+ // `speakPending` is captured via the `onSettle` instance field, assigned after `new()`). Declared
25
+ // INSIDE this iOS-only registrar (mirrors app.ts): NSObject/AV* are iOS globals and this shared
26
+ // module is imported on Android too, so a top-level `@NativeClass extends NSObject` would crash at
27
+ // module load.
28
+ @NativeClass()
29
+ class SpeakDelegate extends NSObject implements AVSpeechSynthesizerDelegate {
30
+ static ObjCProtocols = [AVSpeechSynthesizerDelegate];
31
+ onSettle?: () => void;
32
+ static new(): SpeakDelegate {
33
+ return <SpeakDelegate>super.new();
34
+ }
35
+ speechSynthesizerDidFinishSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
36
+ this.onSettle?.();
37
+ }
38
+ speechSynthesizerDidCancelSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
39
+ this.onSettle?.();
40
+ }
41
+ }
42
+
40
43
  // Strong refs while a session is live — ARC would otherwise free the synthesizer/engine/delegate
41
44
  // the moment the JS locals fall out of scope (same hazard as handlers-scanner/oauth).
42
45
  let synth: AVSpeechSynthesizer | null = null;
@@ -9,14 +9,24 @@ const err = (code: string, message: string) => Object.assign(new Error(message),
9
9
  // (orphaned tracking window / throttled renderer), so recover from its didFinish. Strong-ref the
10
10
  // delegate while the browser is up (ARC would otherwise free it). Mirrors the OAuth delegate pattern.
11
11
  let activeSafariDelegate: NSObject | null = null;
12
- @NativeClass()
13
- class SafariDelegate extends NSObject implements SFSafariViewControllerDelegate {
14
- static ObjCProtocols = [SFSafariViewControllerDelegate];
15
- static new(): SafariDelegate { return <SafariDelegate>super.new(); }
16
- safariViewControllerDidFinish(_controller: SFSafariViewController): void {
17
- activeSafariDelegate = null;
18
- bridge.getWebView()?.recoverAfterNativeSurface();
12
+ // Built lazily INSIDE an iOS-only path (mirrors banner.ts): NSObject/SF* are iOS globals and this file
13
+ // is imported on Android too, so a top-level `@NativeClass extends NSObject` would crash at module load.
14
+ // any: runtime-built ObjC subclass holder; the class BODY stays fully typed.
15
+ let SafariDelegate: any;
16
+ function safariDelegateClass(): any {
17
+ if (!SafariDelegate) {
18
+ @NativeClass()
19
+ class SafariDelegateImpl extends NSObject implements SFSafariViewControllerDelegate {
20
+ static ObjCProtocols = [SFSafariViewControllerDelegate];
21
+ static new(): SafariDelegateImpl { return <SafariDelegateImpl>super.new(); }
22
+ safariViewControllerDidFinish(_controller: SFSafariViewController): void {
23
+ activeSafariDelegate = null;
24
+ bridge.getWebView()?.recoverAfterNativeSurface();
25
+ }
26
+ }
27
+ SafariDelegate = SafariDelegateImpl;
19
28
  }
29
+ return SafariDelegate;
20
30
  }
21
31
 
22
32
  /** Running on a simulator/emulator? (mirrors env.ts detection; kept local to avoid a cross-import). */
@@ -204,7 +214,7 @@ export function registerSystemHandlers(): void {
204
214
  Utils.dispatchToMainThread(() => {
205
215
  const vc = SFSafariViewController.alloc().initWithURL(NSURL.URLWithString(target));
206
216
  if (toolbarColor) vc.preferredControlTintColor = new Color(String(toolbarColor)).ios;
207
- activeSafariDelegate = SafariDelegate.new();
217
+ activeSafariDelegate = safariDelegateClass().new();
208
218
  vc.delegate = activeSafariDelegate as SFSafariViewControllerDelegate;
209
219
  Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(vc, true, null);
210
220
  });
@@ -11,123 +11,134 @@ import { Utils } from '@nativescript/core';
11
11
  * window.alert/confirm/prompt.
12
12
  *
13
13
  * Returns a retained delegate instance (the WKWebView holds uiDelegate weakly).
14
+ *
15
+ * Built lazily INSIDE createUiDelegate (mirrors banner.ts): NSObject/WK* are iOS globals, and although
16
+ * this module is reached only from the iOS WebView path today, defining the class at top level would
17
+ * still evaluate those globals at ES-module instantiation — keep it inside the factory to stay safe.
14
18
  */
15
- @NativeClass()
16
- class AppwrapUiDelegate extends NSObject implements WKUIDelegate {
17
- static ObjCProtocols = [WKUIDelegate];
18
- static new(): AppwrapUiDelegate {
19
- return <AppwrapUiDelegate>super.new();
20
- }
19
+ // any: runtime-built ObjC subclass holder; the class BODY stays fully typed.
20
+ let AppwrapUiDelegate: any;
21
+ function appwrapUiDelegateClass(): any {
22
+ if (AppwrapUiDelegate) return AppwrapUiDelegate;
23
+ @NativeClass()
24
+ class AppwrapUiDelegateImpl extends NSObject implements WKUIDelegate {
25
+ static ObjCProtocols = [WKUIDelegate];
26
+ static new(): AppwrapUiDelegateImpl {
27
+ return <AppwrapUiDelegateImpl>super.new();
28
+ }
21
29
 
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
- }
30
+ // iOS 15+: WKUIDelegate media capture permission.
31
+ webViewRequestMediaCapturePermissionForOriginInitiatedByFrameTypeDecisionHandler(
32
+ _webView: WKWebView,
33
+ _origin: WKSecurityOrigin,
34
+ _frame: WKFrameInfo,
35
+ _type: WKMediaCaptureType,
36
+ decisionHandler: (decision: WKPermissionDecision) => void
37
+ ): void {
38
+ // WKPermissionDecision.Grant === 1 — silently grant (the app already holds the
39
+ // OS-level mic/camera permission; media.ensurePermission established TCC).
40
+ // NOTE: this only ever sees capabilities the build DECLARED — a request for an
41
+ // UNDECLARED capability is rejected upstream in JS (mediaCaptureGuardJs), because
42
+ // WebKit asks TCC to authorize the device AROUND this callback and a missing usage
43
+ // string is a hard process-kill a native `deny` here cannot prevent. So denying
44
+ // here is pointless for the crash; the real guard must be (and is) at the JS layer.
45
+ decisionHandler(WKPermissionDecision.Grant);
46
+ }
39
47
 
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
- }
48
+ // Preserve the behavior NS's own UIDelegate gave us (we fully replace it):
49
+ // target=_blank / window.open has no new window in a shell → load it in place.
50
+ webViewCreateWebViewWithConfigurationForNavigationActionWindowFeatures(
51
+ webView: WKWebView,
52
+ _configuration: WKWebViewConfiguration,
53
+ navigationAction: WKNavigationAction,
54
+ _windowFeatures: WKWindowFeatures
55
+ ): WKWebView {
56
+ const frame = navigationAction.targetFrame;
57
+ if (!frame || !frame.mainFrame) webView.loadRequest(navigationAction.request);
58
+ return null as unknown as WKWebView;
59
+ }
52
60
 
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
- }
61
+ webViewRunJavaScriptAlertPanelWithMessageInitiatedByFrameCompletionHandler(
62
+ _webView: WKWebView,
63
+ message: string,
64
+ _frame: WKFrameInfo,
65
+ completionHandler: () => void
66
+ ): void {
67
+ Utils.dispatchToMainThread(() => {
68
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
69
+ null as unknown as string,
70
+ String(message ?? ''),
71
+ UIAlertControllerStyle.Alert
72
+ );
73
+ alert.addAction(
74
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler())
75
+ );
76
+ present(alert);
77
+ });
78
+ }
71
79
 
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
- }
80
+ webViewRunJavaScriptConfirmPanelWithMessageInitiatedByFrameCompletionHandler(
81
+ _webView: WKWebView,
82
+ message: string,
83
+ _frame: WKFrameInfo,
84
+ completionHandler: (ok: boolean) => void
85
+ ): void {
86
+ Utils.dispatchToMainThread(() => {
87
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
88
+ null as unknown as string,
89
+ String(message ?? ''),
90
+ UIAlertControllerStyle.Alert
91
+ );
92
+ alert.addAction(
93
+ UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
94
+ completionHandler(false)
95
+ )
96
+ );
97
+ alert.addAction(
98
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () => completionHandler(true))
99
+ );
100
+ present(alert);
101
+ });
102
+ }
95
103
 
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;
104
+ webViewRunJavaScriptTextInputPanelWithPromptDefaultTextInitiatedByFrameCompletionHandler(
105
+ _webView: WKWebView,
106
+ prompt: string,
107
+ defaultText: string,
108
+ _frame: WKFrameInfo,
109
+ completionHandler: (text: string) => void
110
+ ): void {
111
+ Utils.dispatchToMainThread(() => {
112
+ const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
113
+ null as unknown as string,
114
+ String(prompt ?? ''),
115
+ UIAlertControllerStyle.Alert
116
+ );
117
+ let field: UITextField | null = null;
118
+ alert.addTextFieldWithConfigurationHandler((tf: UITextField) => {
119
+ tf.text = String(defaultText ?? '');
120
+ field = tf;
121
+ });
122
+ alert.addAction(
123
+ UIAlertAction.actionWithTitleStyleHandler('Cancel', UIAlertActionStyle.Cancel, () =>
124
+ completionHandler(null as unknown as string)
125
+ )
126
+ );
127
+ alert.addAction(
128
+ UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, () =>
129
+ completionHandler(field ? String(field.text ?? '') : '')
130
+ )
131
+ );
132
+ present(alert);
113
133
  });
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
- });
134
+ }
126
135
  }
136
+ AppwrapUiDelegate = AppwrapUiDelegateImpl;
137
+ return AppwrapUiDelegate;
127
138
  }
128
139
 
129
140
  export function createUiDelegate(): WKUIDelegate {
130
- return AppwrapUiDelegate.new();
141
+ return appwrapUiDelegateClass().new();
131
142
  }
132
143
 
133
144
  function present(alert: UIAlertController): void {
@@ -27,10 +27,14 @@ export function enableAndroidEdgeToEdge(): void {
27
27
  if (!activity) return;
28
28
  try {
29
29
  // Explicit transparent status-bar colors (light + dark) so the page background — not the NS
30
- // page backgroundColor — shows under the status bar. Nav bar keeps NS's default subtle scrim.
30
+ // page backgroundColor — shows under the status bar. In FULL edge-to-edge mode (the edgeToEdge
31
+ // config + wireAndroidSafeArea un-boxing) also make the NAV bar transparent so the web content
32
+ // draws under it too; otherwise keep NS's default nav-bar scrim (so the buttons stay legible on
33
+ // apps that aren't drawing their own content down there).
31
34
  Utils.android.enableEdgeToEdge(activity, {
32
35
  statusBarLightColor: transparent,
33
36
  statusBarDarkColor: transparent,
37
+ ...(SHELL_CONFIG.edgeToEdge ? { navigationBarLightColor: transparent, navigationBarDarkColor: transparent } : {}),
34
38
  });
35
39
  } catch (e) {
36
40
  console.warn('AppWrap: edge-to-edge setup failed', e);
@@ -52,11 +56,39 @@ export function enableAndroidEdgeToEdge(): void {
52
56
  */
53
57
  export function wireAndroidSafeArea(webView: View): void {
54
58
  if (!isAndroid || !SHELL_CONFIG.edgeToEdge) return;
55
- // Un-box the layout that wraps the WebView so it (and its child WebView) fills the window UNDER the
56
- // bars. 'dont-apply' = NS won't pad it with the system-bar insets.
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.
59
- (layout as any).androidOverflowEdge = 'dont-apply';
59
+ // Un-box the WebView so it fills the window UNDER the system bars. 'dont-apply' = NS won't pad the
60
+ // view with the system-bar insets. The insets are consumed/applied by the root FRAME (NS sets its
61
+ // androidOverflowEdge to 'ignore' by default — ui/frame/index.android.ts), NOT by the WebView's
62
+ // immediate parent, so overriding only webView.parent left the content boxed. Set it up the whole
63
+ // chain — root view (the Frame), the Page, and the WebView's parent — so whichever applies the
64
+ // insets stops padding. (androidOverflowEdge isn't in the public View typings — an NS layout hint.)
65
+ const targets: Array<View | null> = [
66
+ Application.getRootView() as View | null,
67
+ currentPage,
68
+ (webView.parent as View) ?? null,
69
+ webView,
70
+ ];
71
+ for (const t of targets) if (t) (t as any).androidOverflowEdge = 'dont-apply';
72
+
73
+ // HIDE the navigation bar (immersive) so the game is genuinely full-bleed at the bottom — drawing
74
+ // under a nav bar you can still see is moot. Status bar stays visible (clock/battery over the page).
75
+ // BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the bottom edge reveals it briefly, then it
76
+ // re-hides — the standard game pattern. Immersive resets on resume / when transiently shown, so it's
77
+ // re-applied below. API 30+ (WindowInsetsController); the device floor for edge-to-edge is well above.
78
+ const hideNav = () => {
79
+ try {
80
+ const controller = Application.android?.startActivity?.getWindow()?.getInsetsController?.();
81
+ if (!controller) return;
82
+ controller.hide(android.view.WindowInsets.Type.navigationBars());
83
+ controller.setSystemBarsBehavior(
84
+ android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
85
+ );
86
+ } catch (e) {
87
+ console.warn('AppWrap: hide nav bar failed', e);
88
+ }
89
+ };
90
+ hideNav();
91
+ setTimeout(hideNav, 60); // win the race against NS's own post-load bar setup
60
92
 
61
93
  const density = Screen.mainScreen.scale || 1; // physical px per dp
62
94
  const inject = (l: number, t: number, r: number, b: number) => {
@@ -102,8 +134,8 @@ export function wireAndroidSafeArea(webView: View): void {
102
134
  setTimeout(tick, 150);
103
135
  };
104
136
  setTimeout(tick, 80);
105
- Application.on(Application.resumeEvent, () => setTimeout(readAndInject, 120));
106
- Application.on(Application.orientationChangedEvent, () => setTimeout(readAndInject, 200));
137
+ Application.on(Application.resumeEvent, () => { setTimeout(readAndInject, 120); setTimeout(hideNav, 120); });
138
+ Application.on(Application.orientationChangedEvent, () => { setTimeout(readAndInject, 200); setTimeout(hideNav, 200); });
107
139
  }
108
140
 
109
141
  export function bindStatusBarPage(page: Page): void {
package/src/cli.ts CHANGED
@@ -20,6 +20,7 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
20
20
  // Config shape lives in its own import-safe module so a `appwrap.config.ts` file can import the
21
21
  // type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
22
22
  import type { AppwrapConfig } from './config';
23
+ import { unknownConfigKeys } from './config';
23
24
  import {
24
25
  androidScreenOrientation,
25
26
  applyBuildNumberFlag,
@@ -377,6 +378,15 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
377
378
  }
378
379
  const cfg = await readConfigFile(configPath);
379
380
 
381
+ // Warn (never fail) on keys this appwrap doesn't recognize. A config authored for a NEWER appwrap
382
+ // silently no-ops its unknown keys on an older install (e.g. `targetedDevices` before 0.39 → wrong
383
+ // device family, no error). Turn that silent no-op into a signal.
384
+ const stray = unknownConfigKeys(cfg as unknown as Record<string, unknown>);
385
+ if (stray.length) {
386
+ const ver = pkgVersion(resolve(import.meta.dir, '../package.json'));
387
+ for (const k of stray) console.warn(`⚠ appwrap: unrecognized config key '${k}' — ignored. If you expect it to apply, your installed @livx.cc/appwrap (${ver}) may predate it — upgrade.`);
388
+ }
389
+
380
390
  // Manifest as source: the appwrap config wins, the PWA manifest fills the gaps, template default last.
381
391
  // (DRY single-source — devs don't re-type identity already declared in the manifest.) See mergeManifest.
382
392
  if (cfg.pwaDist) mergeManifest(cfg, loadManifest(cwd, cfg));
@@ -819,6 +829,15 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
819
829
  console.warn('⚠ No app icon source found (manifest icons or config `icon`) — keeping template icons');
820
830
  return;
821
831
  }
832
+ // Icon rasterization uses `sips`, which is macOS-only. On Linux/CI (e.g. a GitHub Actions Android
833
+ // build) it's absent — skip gracefully and keep the template icons rather than throwing and failing
834
+ // the whole init/build. Regenerate real icons on macOS for store builds.
835
+ try {
836
+ execFileSync('which', ['sips'], { stdio: 'ignore' });
837
+ } catch {
838
+ console.warn('⚠ `sips` unavailable (non-macOS host) — keeping template icons; regenerate on macOS for store builds');
839
+ return;
840
+ }
822
841
  const probe = (prop: string) =>
823
842
  parseInt(execFileSync('sips', ['-g', prop, source]).toString().match(/(\d+)\s*$/)?.[1] ?? '0', 10);
824
843
  const w = probe('pixelWidth');
@@ -868,16 +887,36 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
868
887
 
869
888
  /** Tint the iOS launch screen to the configured background color. */
870
889
  function stampLaunchScreen(outDir: string, cfg: AppwrapConfig): void {
871
- const storyboard = join(outDir, 'App_Resources/iOS/LaunchScreen.storyboard');
872
- if (!existsSync(storyboard) || !cfg.backgroundColor) return;
890
+ if (!cfg.backgroundColor) return;
873
891
  const hex = cfg.backgroundColor.replace('#', '');
874
892
  if (!/^[0-9a-fA-F]{6}$/.test(hex)) return;
875
- const ch = (i: number) => (parseInt(hex.slice(i, i + 2), 16) / 255).toFixed(4);
876
- const src = readFileSync(storyboard, 'utf8').replace(
877
- /<color key="backgroundColor"[^/]*\/>/g,
878
- `<color key="backgroundColor" red="${ch(0)}" green="${ch(2)}" blue="${ch(4)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>`
879
- );
880
- writeFileSync(storyboard, src);
893
+
894
+ // iOS: tint the storyboard launch background.
895
+ const storyboard = join(outDir, 'App_Resources/iOS/LaunchScreen.storyboard');
896
+ if (existsSync(storyboard)) {
897
+ const ch = (i: number) => (parseInt(hex.slice(i, i + 2), 16) / 255).toFixed(4);
898
+ const src = readFileSync(storyboard, 'utf8').replace(
899
+ /<color key="backgroundColor"[^/]*\/>/g,
900
+ `<color key="backgroundColor" red="${ch(0)}" green="${ch(2)}" blue="${ch(4)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>`
901
+ );
902
+ writeFileSync(storyboard, src);
903
+ }
904
+
905
+ // Android: replace the default NativeScript splash (background bitmap + NS logo) with a solid fill
906
+ // of the app's backgroundColor — parity with the iOS solid launch screen, and no NS branding flash.
907
+ const splash = join(outDir, 'App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml');
908
+ if (existsSync(splash)) {
909
+ writeFileSync(splash,
910
+ `<?xml version="1.0" encoding="utf-8"?>\n` +
911
+ `<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:gravity="fill">\n` +
912
+ ` <item>\n` +
913
+ ` <shape android:shape="rectangle">\n` +
914
+ ` <solid android:color="#${hex.toUpperCase()}" />\n` +
915
+ ` </shape>\n` +
916
+ ` </item>\n` +
917
+ `</layer-list>\n`
918
+ );
919
+ }
881
920
  }
882
921
 
883
922
  const VERSION_FILE = '.appwrap-version';
@@ -1201,11 +1240,33 @@ function runNs(outDir: string, args: string[]): void {
1201
1240
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
1202
1241
  }
1203
1242
 
1243
+ /** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
1244
+ * preserve an ACTIVE `dev` loader across `run` (see run()'s footgun note). Returns null if the
1245
+ * generated config is absent/unreadable — the caller then falls back to the appwrap config. */
1246
+ function readStampedLoader(outDir: string): { loader: string; serverUrl: string; debug: boolean } | null {
1247
+ try {
1248
+ const src = readFileSync(join(outDir, 'app/shell/config.ts'), 'utf8');
1249
+ const loader = src.match(/loader:\s*"([^"]*)"/)?.[1];
1250
+ if (!loader) return null;
1251
+ return {
1252
+ loader,
1253
+ serverUrl: src.match(/serverUrl:\s*"([^"]*)"/)?.[1] ?? '',
1254
+ debug: /debug:\s*true/.test(src),
1255
+ };
1256
+ } catch {
1257
+ return null;
1258
+ }
1259
+ }
1260
+
1204
1261
  /** `appwrap run <ios|android> [--device <id|name>]` — compile + boot the wrapper in a
1205
1262
  * simulator/emulator (or named device) with live reload: the appwrap-managed replacement for raw
1206
- * `ns run`, driven from the PWA project root with deps auto-installed. Like `ns run`, it reflects
1207
- * whatever loader is currently stamped (`init`/`sync`/`dev`/`build` own stamping) and does NOT
1208
- * re-stamp — so it won't clobber a `dev` loader. */
1263
+ * `ns run`, driven from the PWA project root with deps auto-installed.
1264
+ *
1265
+ * Refreshes the generated wrapper from the source template + PWA first (same regenerateCore as
1266
+ * `sync`/`build`) so framework `runtime/` edits and PWA rebuilds actually reach the device — `run`
1267
+ * used to skip this and silently ship the STALE generated shell (the run-without-sync footgun). An
1268
+ * ACTIVE `dev` loader (loader:'server', stamped by `appwrap dev`) is preserved so dev→run
1269
+ * live-reload isn't clobbered back to the bundled loader. */
1209
1270
  async function run(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1210
1271
  const platform = positionals[0];
1211
1272
  if (platform !== 'ios' && platform !== 'android') {
@@ -1217,6 +1278,17 @@ async function run(cwd: string, flags: Record<string, string>, positionals: stri
1217
1278
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1218
1279
  process.exit(1);
1219
1280
  }
1281
+ const cfg = await loadConfig(cwd, flags);
1282
+ const stamped = readStampedLoader(outDir);
1283
+ const devActive = stamped?.loader === 'server';
1284
+ // Preserve a live dev loader; otherwise regenerate from the appwrap config (bundled loader).
1285
+ const effectiveCfg = devActive
1286
+ ? { ...cfg, loader: 'server' as const, serverUrl: stamped!.serverUrl, debug: stamped!.debug }
1287
+ : cfg;
1288
+ regenerateCore(cwd, outDir, effectiveCfg, { flags });
1289
+ applyOverrides(cwd, outDir, effectiveCfg); // overrides win last — same order as sync (else run wipes them)
1290
+ stampVersionManifest(outDir, effectiveCfg); // keep the managed-marker / provenance current
1291
+ console.log(devActive ? `✓ Refreshed wrapper (preserved dev loader → ${stamped!.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1220
1292
  runNs(outDir, ['run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
1221
1293
  }
1222
1294
 
package/src/config.ts CHANGED
@@ -200,3 +200,24 @@ export interface AppwrapConfig {
200
200
  export function defineConfig(config: AppwrapConfig): AppwrapConfig {
201
201
  return config;
202
202
  }
203
+
204
+ /**
205
+ * Top-level keys appwrap recognizes — KEEP IN SYNC with `AppwrapConfig` above (top-level only; nested
206
+ * keys like `push.ios` are not listed). Drives `unknownConfigKeys`, which warns (never fails) on stray
207
+ * keys at load time. The motivating bug: a config written for a NEWER appwrap silently no-ops its
208
+ * unknown keys on an OLDER installed version (e.g. `targetedDevices` before 0.39 → a universal build
209
+ * with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
210
+ */
211
+ export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
212
+ 'appBoundDomains', 'backendOrigin', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
213
+ 'debugLog', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'loader', 'modules', 'name',
214
+ 'neutralizeServiceWorker', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
215
+ 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'statusBarStyle',
216
+ 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
217
+ 'usesNonExemptEncryption', 'vendorPaths', 'version',
218
+ ]);
219
+
220
+ /** Config keys appwrap doesn't recognize (pure — the caller decides how to surface them). */
221
+ export function unknownConfigKeys(cfg: Record<string, unknown>): string[] {
222
+ return Object.keys(cfg).filter((k) => !KNOWN_CONFIG_KEYS.has(k));
223
+ }