@livx.cc/appwrap 0.42.3 → 0.42.4

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.42.3",
3
+ "version": "0.42.4",
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",
@@ -31,6 +31,58 @@ function buildBootstrapJs(): string {
31
31
  return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser)}\n${NATIVE_FEEL_JS}`;
32
32
  }
33
33
 
34
+ /**
35
+ * The extended WebChromeClient MUST be generated ONCE. NativeScript caches the Java proxy
36
+ * class it synthesizes for `extend({...})` keyed by the methods-object shape, so calling
37
+ * extend() again per-webview REUSES the first invocation's class — including its captured
38
+ * closure. A per-instance `owner` WeakRef baked into the closure therefore cross-wires every
39
+ * later webview's prompt() transport to the FIRST instance (device-proven in the feedox
40
+ * mini-app spike: webview-2's bridge request was answered as webview-1). Route by the native
41
+ * view the callback is given instead, resolved against a per-view registry.
42
+ */
43
+ let chromeClientClass: any;
44
+ function getChromeClientClass(): any {
45
+ if (chromeClientClass) return chromeClientClass;
46
+ // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
47
+ chromeClientClass = (android.webkit.WebChromeClient as any).extend({
48
+ onJsPrompt(
49
+ view: android.webkit.WebView,
50
+ _url: string,
51
+ message: string,
52
+ _defaultValue: string,
53
+ result: android.webkit.JsPromptResult
54
+ ): boolean {
55
+ if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
56
+ result.confirm('');
57
+ CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
58
+ return true;
59
+ }
60
+ return false; // genuine page prompt — default handling
61
+ },
62
+
63
+ // getUserMedia: grant the WebView's per-origin capture after ensuring the
64
+ // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
65
+ // No instance state — safe on the shared class.
66
+ onPermissionRequest(request: android.webkit.PermissionRequest): void {
67
+ const PR = android.webkit.PermissionRequest;
68
+ const resources: string[] = Array.from(request.getResources());
69
+ const perms = new Set<string>();
70
+ for (const r of resources) {
71
+ if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
72
+ if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
73
+ }
74
+ if (!perms.size) {
75
+ Utils.dispatchToMainThread(() => request.grant(request.getResources()));
76
+ return;
77
+ }
78
+ requestPermissions(Array.from(perms)).then((ok) =>
79
+ Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
80
+ );
81
+ },
82
+ });
83
+ return chromeClientClass;
84
+ }
85
+
34
86
  /**
35
87
  * Android CustomWebView — replaces NS's default clients after creation:
36
88
  * WebChromeClient.onJsPrompt is the appwrap transport, WebViewClient
@@ -41,6 +93,18 @@ export class CustomWebView extends WebView {
41
93
  /** Set by the bridge before load; receives raw envelope JSON. */
42
94
  onAppwrapMessage: ((json: string) => void) | null = null;
43
95
 
96
+ // Native-view → owning CustomWebView, keyed by the Java identity hash (stable per native
97
+ // object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
98
+ // so a destroyed view can be GC'd; entry pruned on dispose.
99
+ private static registry = new Map<number, WeakRef<CustomWebView>>();
100
+ static forNative(view: android.webkit.WebView): CustomWebView | undefined {
101
+ const owner = CustomWebView.registry.get(view.hashCode())?.deref();
102
+ // identityHashCode is not unique: on a (rare) collision the map holds the LAST writer —
103
+ // re-check native identity so a colliding view drops rather than cross-wires.
104
+ if (owner && owner.android !== view) return undefined;
105
+ return owner;
106
+ }
107
+
44
108
  initNativeView(): void {
45
109
  super.initNativeView();
46
110
  // NS core types the `.android` getter as `any`; narrow to the real WebView for this scope.
@@ -68,7 +132,15 @@ export class CustomWebView extends WebView {
68
132
 
69
133
  this.installDocumentStartShim(wv);
70
134
  wv.setWebViewClient(createAssetServingClient());
71
- wv.setWebChromeClient(this.createTransportChromeClient());
135
+ // Bind THIS native view to THIS instance before wiring the (shared, extend-once) client.
136
+ CustomWebView.registry.set(wv.hashCode(), new WeakRef<CustomWebView>(this));
137
+ wv.setWebChromeClient(new (getChromeClientClass())());
138
+ }
139
+
140
+ disposeNativeView(): void {
141
+ const wv = this.android as android.webkit.WebView | undefined;
142
+ if (wv) CustomWebView.registry.delete(wv.hashCode());
143
+ super.disposeNativeView();
72
144
  }
73
145
 
74
146
  /**
@@ -115,52 +187,15 @@ export class CustomWebView extends WebView {
115
187
  console.warn('AppWrap: addDocumentStartJavaScript failed', e);
116
188
  }
117
189
  }
118
-
119
- private createTransportChromeClient(): android.webkit.WebChromeClient {
120
- const owner = new WeakRef<CustomWebView>(this);
121
- // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
122
- const ChromeClient = (android.webkit.WebChromeClient as any).extend({
123
- onJsPrompt(
124
- _view: android.webkit.WebView,
125
- _url: string,
126
- message: string,
127
- _defaultValue: string,
128
- result: android.webkit.JsPromptResult
129
- ): boolean {
130
- if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
131
- result.confirm('');
132
- const view = owner.get ? owner.get() : (owner as { deref(): CustomWebView }).deref();
133
- view?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
134
- return true;
135
- }
136
- return false; // genuine page prompt — default handling
137
- },
138
-
139
- // getUserMedia: grant the WebView's per-origin capture after ensuring the
140
- // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
141
- onPermissionRequest(request: android.webkit.PermissionRequest): void {
142
- const PR = android.webkit.PermissionRequest;
143
- const resources: string[] = Array.from(request.getResources());
144
- const perms = new Set<string>();
145
- for (const r of resources) {
146
- if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
147
- if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
148
- }
149
- if (!perms.size) {
150
- Utils.dispatchToMainThread(() => request.grant(request.getResources()));
151
- return;
152
- }
153
- requestPermissions(Array.from(perms)).then((ok) =>
154
- Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
155
- );
156
- },
157
- });
158
- return new ChromeClient();
159
- }
160
190
  }
161
191
 
162
- /** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses. */
192
+ /** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses.
193
+ * Extend-once (same NS shape-caching pitfall as the ChromeClient above): the closure here is
194
+ * process-constant, but generating the class per webview would silently pin the FIRST call's
195
+ * closure anyway — build it once and reuse. */
196
+ let assetClientClass: any;
163
197
  function createAssetServingClient(): android.webkit.WebViewClient {
198
+ if (assetClientClass) return new assetClientClass();
164
199
  const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
165
200
 
166
201
  const respond = (filePath: string, ext: string): android.webkit.WebResourceResponse => {
@@ -246,7 +281,7 @@ function createAssetServingClient(): android.webkit.WebViewClient {
246
281
  };
247
282
 
248
283
  // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
249
- const Client = (android.webkit.WebViewClient as any).extend({
284
+ assetClientClass = (android.webkit.WebViewClient as any).extend({
250
285
  // Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
251
286
  // We defensively handle both, so the param is the union.
252
287
  shouldInterceptRequest(
@@ -305,5 +340,5 @@ function createAssetServingClient(): android.webkit.WebViewClient {
305
340
  }
306
341
  },
307
342
  });
308
- return new Client();
343
+ return new assetClientClass();
309
344
  }
@@ -97,18 +97,36 @@ export function registerAndroidHandlers(): void {
97
97
  }
98
98
  });
99
99
 
100
+ // NS caches the Java proxy class synthesized by .extend({...}) by the methods-object SHAPE, so
101
+ // re-extending per call would permanently bake in the FIRST call's resolve/reject closure — every
102
+ // later authenticate would settle the first (already-settled) promise and hang its own. Extend once
103
+ // and route through a mutable slot (only one biometric prompt can be active at a time).
104
+ let biometricCallbackClass: any;
105
+ let biometricPending: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | null = null;
106
+ const getBiometricCallbackClass = () => {
107
+ if (biometricCallbackClass) return biometricCallbackClass;
108
+ biometricCallbackClass = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
109
+ onAuthenticationSucceeded(_result: any) {
110
+ biometricPending?.resolve({ success: true });
111
+ biometricPending = null;
112
+ },
113
+ onAuthenticationError(_code: number, message: any) {
114
+ biometricPending?.reject(err('DENIED', String(message ?? 'authentication error')));
115
+ biometricPending = null;
116
+ },
117
+ });
118
+ return biometricCallbackClass;
119
+ };
120
+
100
121
  bridge.register('biometrics.authenticate', ({ reason }: { reason?: string }) => {
101
122
  return new Promise((resolve, reject) => {
102
123
  Utils.dispatchToMainThread(() => {
103
124
  try {
104
125
  const act = activity(); // NativeScriptActivity extends AppCompatActivity → FragmentActivity
105
126
  const executor = androidx.core.content.ContextCompat.getMainExecutor(act);
106
- const Callback = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
107
- onAuthenticationSucceeded(_result: any) { resolve({ success: true }); },
108
- onAuthenticationError(_code: number, message: any) {
109
- reject(err('DENIED', String(message ?? 'authentication error')));
110
- },
111
- });
127
+ biometricPending?.reject(err('DENIED', 'superseded by a new biometric prompt'));
128
+ biometricPending = { resolve, reject };
129
+ const Callback = getBiometricCallbackClass();
112
130
  const prompt = new androidx.biometric.BiometricPrompt(act, executor, new Callback());
113
131
  const info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
114
132
  .setTitle(String(reason ?? 'Authenticate'))
@@ -117,6 +135,7 @@ export function registerAndroidHandlers(): void {
117
135
  .build();
118
136
  prompt.authenticate(info);
119
137
  } catch (e: any) {
138
+ if (biometricPending?.reject === reject) biometricPending = null;
120
139
  reject(err('UNSUPPORTED', e?.message ?? String(e)));
121
140
  }
122
141
  });
@@ -14,6 +14,26 @@ module.exports = (env) => {
14
14
  webpack.init(env);
15
15
 
16
16
  webpack.chainWebpack((config) => {
17
+ // node:-scheme imports: pure-JS deps (e.g. @noble/hashes) conditionally probe Node builtins via
18
+ // the `node:` scheme, which webpack cannot even READ ("UnhandledSchemeError: Reading from
19
+ // 'node:crypto' is not handled by plugins") — the whole NS build fails. Strip the scheme so the
20
+ // request becomes a bare builtin name…
21
+ const { NormalModuleReplacementPlugin } = require('webpack');
22
+ config.plugin('AppwrapStripNodeScheme').use(NormalModuleReplacementPlugin, [
23
+ /^node:/,
24
+ (resource) => {
25
+ resource.request = resource.request.replace(/^node:/, '');
26
+ },
27
+ ]);
28
+ // …then stub every Node builtin the NS runtime doesn't have to an empty module (fallback: false),
29
+ // so conditional probes resolve gracefully instead of "Module not found". `module` is excluded:
30
+ // @nativescript/webpack maps it to its own polyfill and that must keep winning.
31
+ config.resolve.merge({
32
+ fallback: Object.fromEntries(
33
+ require('module').builtinModules.filter((m) => m !== 'module').map((m) => [m, false])
34
+ ),
35
+ });
36
+
17
37
  // Ship the PWA bundle inside the app package
18
38
  config.plugin('CopyWww').use(CopyWebpackPlugin, [
19
39
  {
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * appwrap CLI v0 — scaffold a native wrapper around a built PWA.
4
4
  *
5
- * appwrap init [--config <path>] [--out native] # from the PWA project dir
5
+ * appwrap init [--config <path>] [--out native] [--ci] # from the PWA project dir; --ci also scaffolds GH Actions workflows
6
6
  * appwrap sync [--config <path>] [--out native] # re-copy PWA dist into the wrapper
7
7
  *
8
8
  * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
@@ -1414,22 +1414,25 @@ export function isFrameworkRepo(root: string): boolean {
1414
1414
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
1415
1415
  }
1416
1416
 
1417
- /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
1417
+ /** Emit CI scaffolding (GH Actions → git repo root ONLY with --ci, fastlane → native/).
1418
+ * Writing `.github/workflows` into a consumer repo is a repo-level side effect (it can shadow or
1419
+ * clash with the project's own CI), so it is OPT-IN via `appwrap init --ci` — never implicit.
1418
1420
  * GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
1419
1421
  * (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
1420
1422
  * the recipe current after a framework upgrade; without --force it's still first-time-only. */
1421
- function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
1423
+ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false, withWorkflows = false): void {
1422
1424
  if (!existsSync(CI_TEMPLATE_DIR)) return;
1423
1425
  const repoRoot = gitRoot(cwd);
1424
1426
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
1425
1427
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
1426
1428
  // [from, to, overwritable]
1427
1429
  const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
1428
- // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
1429
- // workflows into the framework's .github — that's the stray-workflow-each-init bug.
1430
- if (isFrameworkRepo(repoRoot)) {
1430
+ // Even with --ci: if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold
1431
+ // consumer workflows into the framework's .github — that's the stray-workflow-each-init bug.
1432
+ const scaffoldWorkflows = withWorkflows && !isFrameworkRepo(repoRoot);
1433
+ if (withWorkflows && !scaffoldWorkflows) {
1431
1434
  console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
1432
- } else {
1435
+ } else if (scaffoldWorkflows) {
1433
1436
  targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
1434
1437
  }
1435
1438
  for (const [from, to, overwrite] of targets) {
@@ -1439,7 +1442,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1439
1442
  // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
1440
1443
  // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
1441
1444
  // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
1442
- if (!isFrameworkRepo(repoRoot)) {
1445
+ if (scaffoldWorkflows) {
1443
1446
  const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
1444
1447
  if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
1445
1448
  }
@@ -1454,9 +1457,9 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1454
1457
  .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
1455
1458
  writeFileSync(p, stamped);
1456
1459
  }
1457
- const ci = isFrameworkRepo(repoRoot)
1458
- ? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
1459
- : ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers';
1460
+ const ci = scaffoldWorkflows
1461
+ ? ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
1462
+ : ' ci ← fastlane (native/fastlane, signing stamped) — GH Actions workflows are opt-in: `appwrap init --ci`';
1460
1463
  console.log(ci);
1461
1464
  }
1462
1465
 
@@ -1577,7 +1580,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1577
1580
  console.log(`🎁 appwrap init → ${outDir}`);
1578
1581
  mkdirSync(outDir, { recursive: true });
1579
1582
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1580
- copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
1583
+ copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
1581
1584
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1582
1585
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1583
1586
  stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp