@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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
- package/runtime/app/shell/capabilities.manifest.ts +65 -3
- package/runtime/app/shell/custom-webview.android.ts +9 -0
- package/runtime/app/shell/handlers-android.ts +54 -9
- package/runtime/app/shell/handlers-extended.ts +82 -4
- package/runtime/app/shell/handlers-system.ts +48 -1
- package/runtime/app/shell/handlers-widget.ts +97 -0
- package/runtime/app/shell/notif-identity.ts +45 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
- package/runtime/references.d.ts +1 -0
- package/runtime/tests/notif-identity.test.ts +43 -0
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
- package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
- package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
- package/runtime-desktop/src-tauri/src/main.rs +191 -11
- package/src/cli.ts +74 -0
- package/src/config.ts +8 -1
- package/src/plugin/host.ts +5 -0
- 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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
)
|
|
777
|
-
menu.
|
|
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
|
-
"
|
|
790
|
-
|
|
791
|
-
|
|
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',
|
package/src/plugin/host.ts
CHANGED
|
@@ -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
|
|
package/src/plugin/types.ts
CHANGED
|
@@ -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);
|