@livx.cc/appwrap 0.47.3 → 0.48.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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
  3. package/runtime/app/shell/capabilities.manifest.ts +65 -3
  4. package/runtime/app/shell/custom-webview.android.ts +9 -0
  5. package/runtime/app/shell/handlers-android.ts +54 -9
  6. package/runtime/app/shell/handlers-extended.ts +82 -4
  7. package/runtime/app/shell/handlers-system.ts +48 -1
  8. package/runtime/app/shell/handlers-widget.ts +97 -0
  9. package/runtime/app/shell/notif-identity.ts +45 -0
  10. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
  11. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
  12. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
  13. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
  14. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
  15. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
  16. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
  17. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
  18. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
  19. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
  20. package/runtime/references.d.ts +1 -0
  21. package/runtime/tests/notif-identity.test.ts +43 -0
  22. package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
  23. package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
  24. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
  25. package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
  26. package/runtime-desktop/src-tauri/src/main.rs +191 -11
  27. package/src/cli.ts +74 -0
  28. package/src/config.ts +8 -1
  29. package/src/plugin/host.ts +5 -0
  30. package/src/plugin/types.ts +1 -0
@@ -73,6 +73,7 @@ fn percent_encode(s: &str) -> String {
73
73
  out
74
74
  }
75
75
 
76
+
76
77
  /// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
77
78
  /// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
78
79
  /// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
@@ -168,12 +169,18 @@ fn shell() -> &'static ShellConfig {
168
169
  })
169
170
  }
170
171
 
172
+ // Expose `window.appwrapNative` (page -> native bridge). Prefer the WKScriptMessageHandler transport
173
+ // (`webkit.messageHandlers.appwrapNative`, installed on the main webview) — it works from ANY origin
174
+ // including a REMOTE server-loaded page, where Tauri command IPC is ACL-blocked. Fall back to Tauri
175
+ // IPC for app:// pages if the handler isn't present.
171
176
  const INIT_JS: &str = r#"
172
177
  (() => {
173
- const invoke = window.__TAURI_INTERNALS__.invoke;
174
- window.appwrapNative = {
175
- postMessage: (json) => { invoke('appwrap_invoke', { envelope: json }); },
176
- };
178
+ try {
179
+ var mh = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.appwrapNative;
180
+ if (mh) { window.appwrapNative = { postMessage: function (json) { mh.postMessage(json); } }; return; }
181
+ var i = window.__TAURI_INTERNALS__;
182
+ if (i && i.invoke) { window.appwrapNative = { postMessage: function (json) { i.invoke('appwrap_invoke', { envelope: json }); } }; }
183
+ } catch (e) { /* no bridge available on this origin */ }
177
184
  })();
178
185
  "#;
179
186
 
@@ -423,6 +430,105 @@ fn handle(method: &str, params: &Value) -> HandlerResult {
423
430
  Ok(json!({ "ok": true }))
424
431
  }
425
432
  m if m.starts_with("storage.") => storage_handle(m, params),
433
+ #[cfg(target_os = "macos")]
434
+ m if m.starts_with("browser.window.") => browser_window_handle(m, params),
435
+ #[cfg(target_os = "macos")]
436
+ m if m.starts_with("browser.tab.") => browser_tab_handle(m, params),
437
+ _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
438
+ }
439
+ }
440
+
441
+ /// browser.tab.* — create/drive an EMBEDDED browser tab from a served PWA, mounted `ChildOf` a
442
+ /// front-end-reserved pane NSView (the flexlayout tab region). Mirrors `browser.window.*` but for
443
+ /// the in-layout pane surface: the FE tracks the pane's DOM rect and pushes `setRect` on
444
+ /// resize/scroll. Runs on the bridge worker thread; the browser_tab entry points marshal to main.
445
+ #[cfg(target_os = "macos")]
446
+ fn browser_tab_handle(method: &str, params: &Value) -> HandlerResult {
447
+ let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
448
+ let need_id = || {
449
+ params["id"]
450
+ .as_str()
451
+ .map(str::to_string)
452
+ .ok_or(("NATIVE_ERROR", "missing id".to_string()))
453
+ };
454
+ match method {
455
+ "browser.tab.create" => {
456
+ let id = need_id()?;
457
+ let url = params["url"].as_str().unwrap_or("about:blank").to_string();
458
+ let profile = params["profileId"].as_str().unwrap_or("default").to_string();
459
+ let id = browser_tab::tab_create(app.clone(), id, url, profile, params["rect"].clone())
460
+ .map_err(|e| ("NATIVE_ERROR", e))?;
461
+ Ok(json!({ "id": id }))
462
+ }
463
+ "browser.tab.setRect" => {
464
+ browser_tab::tab_set_rect(app.clone(), need_id()?, params["rect"].clone())
465
+ .map_err(|e| ("NATIVE_ERROR", e))?;
466
+ Ok(json!({ "ok": true }))
467
+ }
468
+ "browser.tab.navigate" => {
469
+ let url = params["url"].as_str().ok_or(("NATIVE_ERROR", "missing url".to_string()))?.to_string();
470
+ browser_tab::tab_navigate(app.clone(), need_id()?, url).map_err(|e| ("NATIVE_ERROR", e))?;
471
+ Ok(json!({ "ok": true }))
472
+ }
473
+ "browser.tab.setMount" => {
474
+ let mode = params["mode"].as_str().ok_or(("NATIVE_ERROR", "missing mode".to_string()))?.to_string();
475
+ browser_tab::tab_set_mount(app.clone(), need_id()?, mode).map_err(|e| ("NATIVE_ERROR", e))?;
476
+ Ok(json!({ "ok": true }))
477
+ }
478
+ "browser.tab.close" => {
479
+ browser_tab::tab_close(app.clone(), need_id()?).map_err(|e| ("NATIVE_ERROR", e))?;
480
+ Ok(json!({ "ok": true }))
481
+ }
482
+ "browser.tab.back" | "browser.tab.forward" | "browser.tab.reload" => {
483
+ let op = &method["browser.tab.".len()..];
484
+ let op = match op { "back" => "back", "forward" => "forward", _ => "reload" };
485
+ browser_tab::tab_nav(app.clone(), need_id()?, op).map_err(|e| ("NATIVE_ERROR", e))?;
486
+ Ok(json!({ "ok": true }))
487
+ }
488
+ "browser.tab.currentUrl" => {
489
+ let s = browser_tab::tab_current_url(app.clone(), need_id()?).map_err(|e| ("NATIVE_ERROR", e))?;
490
+ // `s` is a JSON string {"href":..,"title":..}; embed as an object for the FE.
491
+ Ok(serde_json::from_str::<Value>(&s).unwrap_or_else(|_| json!({})))
492
+ }
493
+ "browser.tab.eval" => {
494
+ let js = params["js"].as_str().ok_or(("NATIVE_ERROR", "missing js".to_string()))?.to_string();
495
+ let s = browser_tab::tab_eval(app.clone(), need_id()?, js).map_err(|e| ("NATIVE_ERROR", e))?;
496
+ Ok(json!({ "result": s }))
497
+ }
498
+ "browser.tab.listProfiles" => Ok(json!({ "profiles": webview_control::list_profiles() })),
499
+ "browser.tab.switchProfile" => {
500
+ // Reuse switch_profile: closes the embedded webview + recreates it ChildOf the SAME pane
501
+ // container with the new profile's data store (page reset to about:blank; caller re-navigates
502
+ // to the profile's remembered url). Container/position is preserved (safe-area-correct).
503
+ let profile = params["profileId"].as_str()
504
+ .ok_or(("NATIVE_ERROR", "missing profileId".to_string()))?.to_string();
505
+ browser_tab::switch_subwindow_profile(app.clone(), need_id()?, profile)
506
+ .map_err(|e| ("NATIVE_ERROR", e))?;
507
+ Ok(json!({ "ok": true }))
508
+ }
509
+ _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
510
+ }
511
+ }
512
+
513
+ /// browser.window.* — spawn/control app-owned browser sub-windows from a served PWA (which has no
514
+ /// injected `__TAURI__`, so it can't call the Tauri commands directly). Runs on the bridge worker
515
+ /// thread; the browser_tab entry points marshal to the main thread themselves.
516
+ #[cfg(target_os = "macos")]
517
+ fn browser_window_handle(method: &str, params: &Value) -> HandlerResult {
518
+ let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
519
+ match method {
520
+ "browser.window.open" => {
521
+ let profile = params["profileId"].as_str().unwrap_or("default").to_string();
522
+ let id = browser_tab::open_subwindow(app.clone(), profile).map_err(|e| ("NATIVE_ERROR", e))?;
523
+ Ok(json!({ "id": id }))
524
+ }
525
+ "browser.window.listProfiles" => Ok(json!({ "profiles": webview_control::list_profiles() })),
526
+ "browser.window.switchProfile" => {
527
+ let id = params["id"].as_str().ok_or(("NATIVE_ERROR", "missing id".to_string()))?.to_string();
528
+ let profile = params["profileId"].as_str().ok_or(("NATIVE_ERROR", "missing profileId".to_string()))?.to_string();
529
+ browser_tab::switch_subwindow_profile(app.clone(), id, profile).map_err(|e| ("NATIVE_ERROR", e))?;
530
+ Ok(json!({ "ok": true }))
531
+ }
426
532
  _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
427
533
  }
428
534
  }
@@ -716,6 +822,7 @@ fn main() {
716
822
  .invoke_handler(tauri::generate_handler![
717
823
  appwrap_invoke,
718
824
  browser_tab::browser_tab_create,
825
+ browser_tab::browser_tab_set_rect,
719
826
  browser_tab::browser_tab_set_mount,
720
827
  browser_tab::browser_tab_navigate,
721
828
  browser_tab::browser_tab_close,
@@ -767,14 +874,35 @@ fn main() {
767
874
  // View ▸ Toggle DevTools (⌥⌘I). Opens/closes Tauri's devtools on the focused
768
875
  // window; the handler no-ops in release unless APPWRAP_DEVTOOLS is set
769
876
  // (devtools_enabled), matching the Safari-inspectable gate.
877
+ // Reload App (⌘R) — reloads the shell's main webview (like Slack's ⌘R), handy after
878
+ // a hot-reload hiccup or a stuck load.
879
+ let reload_app = MenuItem::with_id(
880
+ handle, "reload_app", "Reload App", true, Some("CmdOrCtrl+R"),
881
+ )?;
770
882
  let toggle_devtools = MenuItem::with_id(
771
883
  handle, "toggle_devtools", "Toggle DevTools", true,
772
884
  Some("Alt+CmdOrCtrl+I"),
773
885
  )?;
774
- let view = Submenu::with_id_and_items(
775
- handle, "view_menu", "View", true, &[&toggle_devtools],
776
- )?;
777
- menu.append(&view)?;
886
+ // Menu::default already installs a macOS "View" submenu — APPEND to it rather than
887
+ // adding a SECOND "View" (which produced two View menus, with DevTools stranded in
888
+ // the second one). Fall back to creating one only if the default lacks it.
889
+ let existing_view = menu.items()?.into_iter().find_map(|it| {
890
+ it.as_submenu()
891
+ .filter(|s| s.text().map(|t| t == "View").unwrap_or(false))
892
+ .cloned()
893
+ });
894
+ match existing_view {
895
+ Some(view) => {
896
+ view.append(&reload_app)?;
897
+ view.append(&toggle_devtools)?;
898
+ }
899
+ None => {
900
+ let view = Submenu::with_id_and_items(
901
+ handle, "view_menu", "View", true, &[&reload_app, &toggle_devtools],
902
+ )?;
903
+ menu.append(&view)?;
904
+ }
905
+ }
778
906
  Ok(menu)
779
907
  }) {
780
908
  Ok(menu) => {
@@ -786,11 +914,30 @@ fn main() {
786
914
  }
787
915
  app.on_menu_event(|app, event| {
788
916
  match event.id().as_ref() {
789
- "browser_new_window" => {
790
- if let Err(e) = browser_tab::browser_subwindow_open(app.clone(), Some("default".to_string())) {
791
- eprintln!("[browser] open sub-window (menu) failed: {e}");
917
+ "reload_app" => {
918
+ // Reload the shell's main webview (⌘R). Full app reload — re-fetches the UI
919
+ // (served or app://) from scratch, like Slack's ⌘R.
920
+ if let Some(win) = app.get_webview_window("main") {
921
+ // Tear down embedded browser panes FIRST: reload() never runs React's
922
+ // unmount cleanup, so `browser.tab.close` is never called and the panes
923
+ // orphan (leaked WKWebViews + visual mixing). Runs inline on the main
924
+ // thread (on_menu_event is on main); the reloaded FE re-creates its tabs.
925
+ browser_tab::close_all_embedded();
926
+ let _ = win.eval("window.location.reload()");
792
927
  }
793
928
  }
929
+ "browser_new_window" => {
930
+ // MUST spawn off the main thread: on_menu_event runs on the main thread, and
931
+ // browser_subwindow_open dispatches work back onto the main thread + blocks on
932
+ // it — calling it inline here would deadlock the app (the classic sync-on-main
933
+ // freeze). Spawn the async command onto the runtime so the main thread stays free.
934
+ let app = app.clone();
935
+ tauri::async_runtime::spawn(async move {
936
+ if let Err(e) = browser_tab::browser_subwindow_open(app, Some("default".to_string())).await {
937
+ eprintln!("[browser] open sub-window (menu) failed: {e}");
938
+ }
939
+ });
940
+ }
794
941
  "toggle_devtools" => {
795
942
  // Gate at runtime: inert in a release build unless APPWRAP_DEVTOOLS is set
796
943
  // (same policy as Safari-inspectable); always on in debug.
@@ -857,6 +1004,11 @@ fn main() {
857
1004
  let win_builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
858
1005
  .title(shell().title.as_str())
859
1006
  .inner_size(shell().width, shell().height)
1007
+ // wry installs a NATIVE OS drag-drop handler on the webview by default, which INTERCEPTS
1008
+ // all drag events — so in-page HTML5 drag-and-drop (dragover/drop) never reaches the DOM
1009
+ // (probe: dragstart fires but dragover never does). That silently breaks any web UI that
1010
+ // relies on HTML5 DnD (e.g. flexlayout tab reorder/dock). Disable it so the page gets DnD.
1011
+ .disable_drag_drop_handler()
860
1012
  .initialization_script(INIT_JS);
861
1013
  // With state restore on, the plugin repositions/resizes AFTER creation — build hidden so
862
1014
  // the restore lands before first paint (no visible jump), then show below.
@@ -872,11 +1024,28 @@ fn main() {
872
1024
  #[cfg(target_os = "macos")]
873
1025
  if let Some(window) = app.get_webview_window("main") {
874
1026
  let picker = shell().google_account_picker;
1027
+ let app_for_sink = app.handle().clone();
875
1028
  window.with_webview(move |w| {
876
1029
  popup_mac::install(w.inner() as _, picker);
877
1030
  // Safari Web Inspector on the main Tauri window's WKWebView (debug always;
878
1031
  // release only under APPWRAP_DEVTOOLS). Runs on the main thread (with_webview).
879
1032
  unsafe { webview_control::set_inspectable(w.inner() as *mut _) };
1033
+ // Native bridge for SERVED (remote-origin) pages: Tauri command IPC is ACL-blocked
1034
+ // for remote origins, and ws/http/custom-scheme are mixed-content-blocked from https
1035
+ // — but a WKScriptMessageHandler works from ANY origin. Served pages reach native via
1036
+ // `webkit.messageHandlers.appwrapNative.postMessage(envelope)`, routed through the
1037
+ // same dispatch as the Tauri-IPC `appwrap_invoke` (off-thread + response delivery).
1038
+ let mtm = objc2::MainThreadMarker::new().expect("with_webview runs on main");
1039
+ let sink: webview_control::MessageSink = Box::new(move |envelope: String| {
1040
+ if let Some(win) = app_for_sink.get_webview_window("main") {
1041
+ appwrap_invoke(win, envelope);
1042
+ }
1043
+ });
1044
+ unsafe {
1045
+ webview_control::install_named_message_handler(
1046
+ w.inner() as *mut _, mtm, "appwrapNative", sink,
1047
+ );
1048
+ }
880
1049
  })?;
881
1050
  }
882
1051
  // Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
@@ -889,6 +1058,14 @@ fn main() {
889
1058
  // `attachTo` matches "main" run their `onWindow(ctx)`. No-op when no host is running.
890
1059
  #[cfg(target_os = "macos")]
891
1060
  plugin_host::on_window_created("main", "index.html");
1061
+ // Re-open browser sub-windows that were open at last quit. MUST run off the main thread —
1062
+ // open() marshals to the main thread via with_webview + blocks, so a synchronous call here
1063
+ // (setup runs on the main thread) would deadlock (same class as the ⌘⇧N freeze fix).
1064
+ #[cfg(target_os = "macos")]
1065
+ {
1066
+ let app_handle = app.handle().clone();
1067
+ tauri::async_runtime::spawn(async move { browser_tab::restore_subwindows(app_handle); });
1068
+ }
892
1069
  // Local-server mode: boot the server child now (the window shows the bundled splash), then a
893
1070
  // background thread polls its port and navigates the window to it — or, on timeout/spawn
894
1071
  // failure, writes the reason into the splash. Absent `server` → the bundled dist just loads.
@@ -987,6 +1164,9 @@ fn main() {
987
1164
  // so persist geometry here — the window-state plugin's own save only fires on window
988
1165
  // CLOSE events, which those quit paths skip, so position/size wouldn't restore next run.
989
1166
  tauri::RunEvent::ExitRequested { .. } => {
1167
+ // Mark quitting BEFORE windows tear down, so sub-window teardown keeps (not prunes)
1168
+ // the restore snapshot — whatever's open at quit re-opens next launch.
1169
+ browser_tab::mark_quitting();
990
1170
  if shell().restore_window_state {
991
1171
  use tauri_plugin_window_state::{AppHandleExt, StateFlags};
992
1172
  let _ = app_handle.save_window_state(StateFlags::all());
package/src/cli.ts CHANGED
@@ -137,6 +137,8 @@ interface NativeReqs {
137
137
  androidKotlin: boolean; // any active module ships Kotlin native source
138
138
  androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
139
139
  nativeSrc: string[]; // active modules' nativeSrc dir names (under runtime/modules-native/)
140
+ widgetAppGroup?: string; // 'group.<appId>' when the `widget` module is active (App Group id
141
+ // shared by the app + its widget extension; templated into both)
140
142
  }
141
143
 
142
144
  function nativeReqs(cfg: AppwrapConfig): NativeReqs {
@@ -194,6 +196,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
194
196
  androidKotlin,
195
197
  androidManifestApp,
196
198
  nativeSrc,
199
+ // App Group for the widget extension ↔ app shared container. Derived from the app id so the module
200
+ // stays app-agnostic; templated into app.entitlements + the extension's entitlements/Swift.
201
+ widgetAppGroup: active.has('widget') ? `group.${cfg.id}` : undefined,
197
202
  };
198
203
  }
199
204
 
@@ -207,6 +212,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
207
212
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
208
213
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
209
214
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
215
+ widget: { file: './handlers-widget', fn: 'registerWidgetHandlers' },
210
216
  };
211
217
 
212
218
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
@@ -266,6 +272,9 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
266
272
  const file = join(iosDir, 'app.entitlements');
267
273
  const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
268
274
  if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
275
+ // Widget module → App Group on the MAIN app target (the extension's own entitlements file gets the
276
+ // same group via token substitution). Derived from the app id so no per-app config is needed.
277
+ if (req.widgetAppGroup) ent['com.apple.security.application-groups'] = [req.widgetAppGroup];
269
278
  const keys = Object.keys(ent);
270
279
  if (keys.length === 0) { rmSync(file, { force: true }); return; }
271
280
  const val = (v: boolean | string | string[]): string =>
@@ -306,6 +315,43 @@ function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
306
315
  }
307
316
  }
308
317
 
318
+ /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc). Currently
319
+ * only `__APP_GROUP__` → the derived App Group id, applied to the widget extension's Swift/entitlements/
320
+ * plist so the module ships app-agnostic and the CLI stamps the app-specific group in. Idempotent (the
321
+ * source is re-copied verbatim each sync, then re-substituted). */
322
+ function substituteModuleTokens(outDir: string, req: NativeReqs): void {
323
+ if (!req.widgetAppGroup) return;
324
+ const extRoot = join(outDir, 'App_Resources/iOS/extensions');
325
+ if (!existsSync(extRoot)) return;
326
+ const subst = (file: string) => {
327
+ const s = readFileSync(file, 'utf8');
328
+ if (!s.includes('__APP_GROUP__')) return;
329
+ writeFileSync(file, s.replaceAll('__APP_GROUP__', req.widgetAppGroup!));
330
+ };
331
+ const walk = (dir: string) => {
332
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
333
+ const p = join(dir, e.name);
334
+ if (e.isDirectory()) walk(p);
335
+ else if (/\.(swift|entitlements|plist|json)$/.test(e.name)) subst(p);
336
+ }
337
+ };
338
+ walk(extRoot);
339
+ console.log(` tokn ← __APP_GROUP__ = ${req.widgetAppGroup} (widget extension)`);
340
+ }
341
+
342
+ /** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
343
+ * `config.androidAppLinks` wins; otherwise derived from the iOS `associated-domains` `applinks:<host>`
344
+ * entries so a single universal-link declaration covers both platforms. */
345
+ function androidAppLinkHosts(cfg: AppwrapConfig): string[] {
346
+ if (cfg.androidAppLinks) return cfg.androidAppLinks.filter(Boolean);
347
+ const ad = cfg.iosEntitlements?.['com.apple.developer.associated-domains'];
348
+ const arr = Array.isArray(ad) ? ad : [];
349
+ return arr
350
+ .filter((d): d is string => typeof d === 'string' && d.startsWith('applinks:'))
351
+ .map((d) => d.slice('applinks:'.length).split(/[?/]/)[0].trim())
352
+ .filter(Boolean);
353
+ }
354
+
309
355
  /** Enable Kotlin in the NS Android build when an active module ships Kotlin native source. Injects
310
356
  * useKotlin/kotlinVersion into before-plugins.gradle's project.ext (re-stamped from template each run). */
311
357
  function stampKotlin(outDir: string, enable: boolean): void {
@@ -521,6 +567,14 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
521
567
  const items = cfg.queryUrlSchemes.map((s) => ` <string>${s}</string>`).join('\n');
522
568
  extras.push(` <key>LSApplicationQueriesSchemes</key>\n <array>\n${items}\n </array>`);
523
569
  }
570
+ // Communication Notifications (iOS 15+): an app that holds the `usernotifications.communication`
571
+ // entitlement MUST also declare it supports donating these intents, or SpringBoard denies the
572
+ // communication API at runtime ("has entitlement but does not support donating [INSendMessageIntent]")
573
+ // and notifications fall back to a plain banner with the host app icon. native-kit's notification
574
+ // identity path (INSendMessageIntent → communication notification) needs this to render the sender.
575
+ if (cfg.iosEntitlements?.['com.apple.developer.usernotifications.communication']) {
576
+ extras.push(` <key>NSUserActivityTypes</key>\n <array>\n <string>INSendMessageIntent</string>\n <string>INStartCallIntent</string>\n </array>`);
577
+ }
524
578
  if (extras.length) {
525
579
  src = src.replace(
526
580
  /<\/dict>\s*<\/plist>\s*$/,
@@ -1120,6 +1174,25 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1120
1174
  `<!-- appwrap:oauth-redirect-schemes -->${body}<!-- /appwrap:oauth-redirect-schemes -->`
1121
1175
  );
1122
1176
  }
1177
+ // App Links: an `android:autoVerify="true"` VIEW intent-filter per https host, so `https://<host>/…`
1178
+ // links open the app directly (Android's Universal-Links equivalent). Hosts come from
1179
+ // config.androidAppLinks or, unset, the iOS associated-domains applinks (one declaration → both
1180
+ // platforms). The host must ALSO serve /.well-known/assetlinks.json (package + signing SHA-256) for
1181
+ // Android to verify the association. Idempotent marker → re-sync-safe.
1182
+ {
1183
+ const hosts = androidAppLinkHosts(cfg);
1184
+ const filter = (h: string) =>
1185
+ `<intent-filter android:autoVerify="true">\n\t\t\t<action android:name="android.intent.action.VIEW" />\n` +
1186
+ `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
1187
+ `\t\t\t<category android:name="android.intent.category.BROWSABLE" />\n` +
1188
+ `\t\t\t<data android:scheme="https" android:host="${esc(h)}" />\n\t\t</intent-filter>`;
1189
+ const body = hosts.length ? '\n\t\t' + hosts.map(filter).join('\n\t\t') + '\n\t\t' : '';
1190
+ src = src.replace(
1191
+ /<!-- appwrap:app-links -->[\s\S]*?<!-- \/appwrap:app-links -->/,
1192
+ `<!-- appwrap:app-links -->${body}<!-- /appwrap:app-links -->`
1193
+ );
1194
+ if (hosts.length) console.log(` alnk ← Android App Links: ${hosts.join(', ')}`);
1195
+ }
1123
1196
  // Supported orientation (config > manifest) on the main <activity>. Skipped when unset → keep
1124
1197
  // the template default (free); 'any' removes the attribute, so re-sync stays idempotent.
1125
1198
  if (cfg.orientation) src = stampAndroidOrientation(src, androidScreenOrientation(cfg.orientation));
@@ -1640,6 +1713,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1640
1713
  stampKotlin(outDir, req.androidKotlin);
1641
1714
  generateModuleArtifacts(outDir, req);
1642
1715
  copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
1716
+ substituteModuleTokens(outDir, req); // stamp __APP_GROUP__ etc. into the copied extension source
1643
1717
  stampLaunchScreen(outDir, cfg);
1644
1718
  stampStoreKit(cwd, outDir, cfg);
1645
1719
  stampPush(cwd, outDir, cfg);
package/src/config.ts CHANGED
@@ -128,6 +128,13 @@ export interface AppwrapConfig {
128
128
  };
129
129
  /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
130
130
  urlScheme?: string;
131
+ /** Android App Links: https hosts whose `https://<host>/...` links open the app directly (the
132
+ * Android equivalent of iOS Universal Links / associated-domains `applinks:`). Each host gets an
133
+ * `android:autoVerify="true"` VIEW intent-filter in the manifest. Verification ALSO requires the host
134
+ * to serve `/.well-known/assetlinks.json` listing the app's package + signing-cert SHA-256.
135
+ * When OMITTED, the hosts are DERIVED from the iOS `associated-domains` `applinks:<host>` entries in
136
+ * `iosEntitlements` — so one universal-link declaration covers both platforms. `[]` disables it. */
137
+ androidAppLinks?: string[];
131
138
  /** Custom URL schemes `kit.app.canOpenUrl()` may probe (e.g. `['whatsapp', 'tg']`) — covers BOTH
132
139
  * platforms with a single declaration. Since iOS 9 / Android API 30+ a probe of an undeclared custom
133
140
  * scheme returns false for privacy. These stamp into iOS Info.plist `LSApplicationQueriesSchemes` AND
@@ -328,7 +335,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
328
335
  * with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
329
336
  */
330
337
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
331
- 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
338
+ 'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
332
339
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
333
340
  'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
334
341
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
@@ -27,6 +27,11 @@ export function matchScope(def: PluginDef, override: string | undefined, win: Wi
27
27
  if (typeof scope === 'function') return scope(win);
28
28
  if (scope === 'all') return true;
29
29
  if (scope === 'main') return win.id === 'main';
30
+ // 'browser' is a CATEGORY, not a literal id: the browser sub-windows (browser_tab.rs) whose
31
+ // controlled webview is labeled `<win_id>-view`. This is the safe default for remote-control.
32
+ // (Phase A does NOT yet emit window-created for sub-windows — browser_tab keeps its core bridging —
33
+ // so this matches once that emission lands in Phase B, or any window explicitly labeled `*-view`.)
34
+ if (scope === 'browser') return win.id.endsWith('-view');
30
35
  return scope === win.id;
31
36
  }
32
37
 
@@ -21,6 +21,7 @@ export const WINDOW_CTX_VERSION = 1 as const;
21
21
  * form is code-only (it lives in the host process). */
22
22
  export type WindowScope =
23
23
  | 'main' // the app's main window
24
+ | 'browser' // browser sub-windows (the controlled webview labeled `<win_id>-view`) — remote-control's default
24
25
  | 'all' // every window (incl. sub-windows)
25
26
  | string // an explicit window label / id
26
27
  | ((win: WindowIdentity) => boolean);