@livx.cc/appwrap 0.23.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,6 +119,64 @@ export function serviceWorkerGuardJs(enabled: boolean): string {
119
119
  })();`;
120
120
  }
121
121
 
122
+ /**
123
+ * Make the shell behave like a regular native app when the web content tries to leave the app's own
124
+ * origin: hand external-origin navigations to the OS default browser (Safari / Chrome — via the core
125
+ * `app.openUrl` handler, which leaves the app) instead of replacing the shell WebView. Intercepts, at
126
+ * document-start (BEFORE the page wires its own handlers), two intents:
127
+ * - a click on an `<a href>` resolving to a DIFFERENT origin (incl. `target="_blank"`), and
128
+ * - `window.open(url, ...)` to a different origin.
129
+ * Same-origin links / SPA route changes pass straight through (the app keeps owning its own
130
+ * navigation), so this never ejects the user mid-flow inside their own app. Only runs in the top
131
+ * frame — embedded iframes (maps, video, ad frames) are left alone. Non-http(s) schemes
132
+ * (`tel:`/`mailto:`/`app:`) are ignored here and handled by the OS/WebView as usual.
133
+ *
134
+ * The request is dispatched fire-and-forget over the same raw transport the bridge listens on (iOS
135
+ * `webkit.messageHandlers.appwrap`, Android `appwrapNative.postMessage`) — independent of the
136
+ * consumer's native-kit, so it works even on an unmodified remotely-loaded (loader:'server') webapp.
137
+ *
138
+ * `enabled` = SHELL_CONFIG.openNewWindowsInBrowser (default false). Idempotent via a window guard.
139
+ */
140
+ export function externalNavGuardJs(enabled: boolean): string {
141
+ if (!enabled) return '';
142
+ return `(function(){
143
+ if (window.top !== window.self || window.__appwrapExtNavGuard) return;
144
+ window.__appwrapExtNavGuard = true;
145
+ function send(url){
146
+ try {
147
+ var json = JSON.stringify({ v:1, id:'extnav-'+Date.now()+'-'+Math.random().toString(36).slice(2),
148
+ kind:'request', method:'app.openUrl', params:{ url:url } });
149
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.appwrap) {
150
+ window.webkit.messageHandlers.appwrap.postMessage(json); return true; }
151
+ if (window.appwrapNative && window.appwrapNative.postMessage) {
152
+ window.appwrapNative.postMessage(json); return true; }
153
+ } catch (e) {}
154
+ return false;
155
+ }
156
+ // Resolve to an absolute http(s) URL that is on a DIFFERENT origin than the app shell; else null.
157
+ function external(href){
158
+ try {
159
+ var u = new URL(href, location.href);
160
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; // tel:/mailto:/app:/blob: → OS/WebView
161
+ return u.origin !== location.origin ? u.href : null;
162
+ } catch (e) { return null; }
163
+ }
164
+ document.addEventListener('click', function(e){
165
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
166
+ var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
167
+ if (!a) return;
168
+ var url = external(a.getAttribute('href'));
169
+ if (url && send(url)) { e.preventDefault(); e.stopPropagation(); }
170
+ }, true);
171
+ var open = window.open;
172
+ window.open = function(url){
173
+ var ext = url != null ? external(String(url)) : null;
174
+ if (ext && send(ext)) return null;
175
+ return open ? open.apply(window, arguments) : null;
176
+ };
177
+ })();`;
178
+ }
179
+
122
180
  /**
123
181
  * Native-feel injection shared by both CustomWebViews. Suppresses the "it's a
124
182
  * web page" tells: pinch / double-tap zoom, long-press callout & selection,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.18.0",
4
+ "version": "0.28.0",
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "dev:ios": "ns run ios",
package/src/cli.ts CHANGED
@@ -172,6 +172,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
172
172
  const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
173
173
  health: { file: './handlers-health', fn: 'registerHealthHandlers' },
174
174
  oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
175
+ reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
176
+ scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
177
+ speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
175
178
  };
176
179
 
177
180
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
@@ -363,8 +366,10 @@ export const SHELL_CONFIG = {
363
366
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
364
367
  devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
365
368
  neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
369
+ openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
366
370
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
367
371
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
372
+ pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
368
373
  };
369
374
  `;
370
375
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
@@ -433,7 +438,15 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
433
438
 
434
439
  function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
435
440
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
436
- if (!existsSync(xcconfig) || !cfg.teamId) return;
441
+ // A placeholder/empty teamId silently produces an unsignable build (DEVELOPMENT_TEAM = YOUR_APPLE_TEAM_ID
442
+ // → xcodebuild "No Account for Team"). Warn loudly rather than let a long build fail cryptically.
443
+ if (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId)) {
444
+ console.warn(`⚠ teamId is unset/placeholder ("${cfg.teamId ?? ''}") — device builds won't sign.\n` +
445
+ ' Set it to your Apple Team ID in appwrap.config (Xcode → Settings → Accounts shows it; ' +
446
+ 'Individual = paid, Personal Team = free).');
447
+ return;
448
+ }
449
+ if (!existsSync(xcconfig)) return;
437
450
  let src = readFileSync(xcconfig, 'utf8');
438
451
  src = /DEVELOPMENT_TEAM\s*=/.test(src)
439
452
  ? src.replace(/DEVELOPMENT_TEAM\s*=\s*[^;\n]*;?/, `DEVELOPMENT_TEAM = ${cfg.teamId};`)
@@ -1146,7 +1159,21 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1146
1159
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1147
1160
  stampShellConfig(outDir, { ...cfg, debug: true });
1148
1161
  console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
1149
- execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1162
+ try {
1163
+ execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1164
+ } catch (e) {
1165
+ // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
1166
+ console.error(
1167
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
1168
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
1169
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
1170
+ ' unique string and re-deploy.\n' +
1171
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
1172
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
1173
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
1174
+ );
1175
+ process.exit(1);
1176
+ }
1150
1177
 
1151
1178
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
1152
1179
  const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
package/src/config.ts CHANGED
@@ -79,6 +79,13 @@ export interface AppwrapConfig {
79
79
  * for in-WebView web-push as a fallback. NOTE: keeping the SW is the only way to get web push; native
80
80
  * push is the separate `push` lane (APNs/FCM) and does NOT need a SW. */
81
81
  neutralizeServiceWorker?: boolean;
82
+ /** Hand navigations that LEAVE the app's own origin to the OS default browser (Safari / Chrome)
83
+ * instead of replacing the shell WebView — so external links and `window.open(...)`/`target="_blank"`
84
+ * behave like a regular native app (open in the system browser) rather than navigating away inside
85
+ * the shell. Covers external-origin `<a>` clicks (including `target="_blank"`) and `window.open()`;
86
+ * same-origin SPA navigation, subframes/iframes and `tel:`/`mailto:` are left untouched. Default
87
+ * `false` (unchanged in-WebView navigation). See `externalNavGuardJs`. */
88
+ openNewWindowsInBrowser?: boolean;
82
89
  /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
83
90
  teamId?: string;
84
91
  /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
@@ -132,6 +139,10 @@ export interface AppwrapConfig {
132
139
  apsEnvironment?: 'development' | 'production';
133
140
  /** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
134
141
  googleServicesJson?: string;
142
+ /** Optional backend URL the shell POSTs the device token to NATIVELY on acquisition (`{token,
143
+ * platform}`). Native HTTP sidesteps the WKWebView app:// cross-origin wall — the token reaches
144
+ * your server with no WebView fetch. Your backend stores it + sends pushes (provider-agnostic). */
145
+ registrationUrl?: string;
135
146
  };
136
147
  }
137
148