diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce9cfe..ebdf15e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Unreleased +- Breaking: the legacy app websocket is removed. It attached an app interface + that accepted any origin and injected a reusable, non-expiring token, and no + consumer still used it; windows reach the conductor over Tauri IPC only. + `WindowOptions::use_app_websocket` and the injected `__HC_LAUNCHER_ENV__` are + gone, and `holochain-conductor-runtime` drops `Runtime::ensure_app_websocket`, + `Runtime::setup_app` and `AppAuth`. Call `Runtime::install_app_if_missing` + where you called `setup_app`. - The Makefile is gone; `npm run ci` is what CI runs (`fmt:check`, `lint`, `test`). `npm run lint` now builds the example UI first, since `cargo clippy --workspace` compiles the example app and Tauri resolves `frontendDist` at compile time. CI @@ -48,6 +55,16 @@ with 100 there were no drops and a fresh phone agent synced in about 90 s. `dev_network_url!()` reads `INTERNAL_IP`/`BOOTSTRAP_PORT` at run time on desktop and at compile time on mobile. +- Windows from `main_window_builder` are confined to the origin they first + load from, recorded from the webview itself rather than predicted from the + config: navigation elsewhere is refused and logged (`blob:` URLs of the + origin excepted, so exports still work), and on desktop `window.open` and + `target="_blank"` open nothing. Tauri has no `on_new_window` on mobile: there + Android loads the target in the same webview, where the navigation check + applies, and iOS opens nothing. `HolochainPlugin::lock_navigation` applies + the policy to a window the app builds itself; `navigation_allowed`, + `origin_of` and `same_origin` are public. `on_new_window` arrived in Tauri + 2.8; the workspace floor moves from 2.5.1 to 2.11, the version CI builds. - Linux: `tauri-plugin-hc` grants WebKitGTK user-media permission requests on every webview it sees, so a hApp UI can call `getUserMedia` (camera and microphone) on Linux as it already could on Android. WebKitGTK denies these diff --git a/Cargo.toml b/Cargo.toml index b6b941d..2e35b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ url2 = "0.0.6" lair_keystore_api = "0.7.1" rustls = "0.23.25" -tauri = "2.5.1" +tauri = "2.11" tauri-plugin = "2.2.0" tauri-build = { version = "2.2.0", default-features = false } diff --git a/README.md b/README.md index 1db7d0b..b287069 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ your Tauri app → tauri-plugin-hc → holochain-conductor-runtime → conductor | Crate | | | --- | --- | -| [holochain-conductor-runtime](./crates/runtime) | Framework-free wrapper around the Holochain conductor. Two-phase boot (lair first, then the conductor on that same keystore), app install/enable/disable/uninstall, app websockets, zome-call and payload signing, key generation and seed import/export, hc-auth, network stats. Talks to the conductor through `AdminInterfaceApi`/`AppInterfaceApi` in-process — it never opens an admin websocket. | +| [holochain-conductor-runtime](./crates/runtime) | Framework-free wrapper around the Holochain conductor. Two-phase boot (lair first, then the conductor on that same keystore), app install/enable/disable/uninstall, zome-call and payload signing, key generation and seed import/export, hc-auth, network stats. Talks to the conductor through `AdminInterfaceApi`/`AppInterfaceApi` in-process — it never opens an admin websocket. | | [tauri-plugin-hc](./crates/tauri-plugin-hc) | The Tauri integration, and the runtime's only consumer here. Boots the conductor, binds webview windows to installed apps, forwards signals, serves the App API over Tauri IPC, and signs zome calls for the UI. | [create-holochain-tauri](./packages/create-holochain-tauri) adds a desktop and Android app built on the plugin to an existing hApp repository, such as one from `hc-scaffold`: `npm create holochain-tauri`. [apps/holochain-runtime-example](./apps/holochain-runtime-example) is a working app for all three platforms: it boots a conductor, installs the bundled `forum.happ` fixture, and opens a window connected to it. -The plugin injects a `__HC_TAURI_HOLOCHAIN__` env into each window it opens. The UI reads it and connects with `@holochain/client` over Tauri IPC; the older loopback-app-websocket path is still selectable per window. +The plugin injects a `__HC_TAURI_HOLOCHAIN__` env into each window it opens. The UI reads it and connects with `@holochain/client` over Tauri IPC. ## Platform support diff --git a/apps/holochain-runtime-example/src-tauri/src/lib.rs b/apps/holochain-runtime-example/src-tauri/src/lib.rs index 3ca5e46..84aecc4 100644 --- a/apps/holochain-runtime-example/src-tauri/src/lib.rs +++ b/apps/holochain-runtime-example/src-tauri/src/lib.rs @@ -107,9 +107,7 @@ async fn open_main_window(handle: AppHandle) -> Result<(), Box> { // Install and enable the forum hApp on first run only. An app that is // already installed is left in whatever state it is in, so one a user - // disabled stays disabled. This deliberately avoids `Runtime::setup_app`, - // which also attaches an app websocket interface: the window below reaches - // the conductor over Tauri IPC, so that port would sit open and unused. + // disabled stays disabled. if !runtime.is_app_installed(APP_ID.into()).await? { runtime .install_app(InstallAppPayload { @@ -125,9 +123,8 @@ async fn open_main_window(handle: AppHandle) -> Result<(), Box> { runtime.enable_app(APP_ID.into()).await?; } - // Open a window bound to the app. With `use_app_websocket` left at its - // default (false), the plugin injects `__HC_TAURI_HOLOCHAIN__` and serves - // the App API and zome-call signing over Tauri IPC. + // Open a window bound to the app. The plugin injects `__HC_TAURI_HOLOCHAIN__` + // and serves the App API and zome-call signing over Tauri IPC. plugin .main_window_builder( "main", diff --git a/apps/holochain-runtime-example/ui/src/main.js b/apps/holochain-runtime-example/ui/src/main.js index 538ea05..484a312 100644 --- a/apps/holochain-runtime-example/ui/src/main.js +++ b/apps/holochain-runtime-example/ui/src/main.js @@ -19,10 +19,8 @@ const show = (id, text, ok) => { if (ok !== undefined) report(id, ok, text); }; -// 1. Prove the plugin injected an env into this webview. Direct mode injects -// __HC_TAURI_HOLOCHAIN__ (no websocket); legacy mode injects __HC_LAUNCHER_ENV__. +// 1. Prove the plugin injected its __HC_TAURI_HOLOCHAIN__ env into this webview. const tauriEnv = window.__HC_TAURI_HOLOCHAIN__; -const wsEnv = window.__HC_LAUNCHER_ENV__; if (tauriEnv) { show( "env", @@ -34,8 +32,6 @@ if (tauriEnv) { !!tauriEnv.subscribeSignals, true ); -} else if (wsEnv && wsEnv.APP_INTERFACE_PORT) { - show("env", "websocket — APP_INTERFACE_PORT=" + wsEnv.APP_INTERFACE_PORT, true); } else { show("env", "no holochain env injected", false); } diff --git a/crates/runtime/README.md b/crates/runtime/README.md index 3a35173..dab16e4 100644 --- a/crates/runtime/README.md +++ b/crates/runtime/README.md @@ -7,7 +7,7 @@ It calls the conductor through `AdminInterfaceApi` and `AppInterfaceApi` in-proc What it covers: - **Two-phase boot.** Lair is spawned first, the hc-auth flow (if configured) signs a challenge against it and injects the resulting auth material into the `NetworkConfig`, and only then is the conductor built on that same keystore. This is what makes authenticated bootstrap and relay work on a first boot. -- **App lifecycle** — install, enable, disable, uninstall, list; `install_app_if_missing` for install-if-needed plus enable, and `setup_app`, which also attaches an app websocket. +- **App lifecycle** — install, enable, disable, uninstall, list; `install_app_if_missing` for install-if-needed plus enable. - **Signing** — zome calls, and arbitrary payloads against a caller-chosen agent key. - **Keys** — device key derivation, agent key generation, seed import and export. - **App API and signals** — `handle_app_request` serves the full App API in-process; `subscribe_to_app_signals` yields an app's signal stream. diff --git a/crates/runtime/src/runtime.rs b/crates/runtime/src/runtime.rs index b000ccc..7e2b515 100644 --- a/crates/runtime/src/runtime.rs +++ b/crates/runtime/src/runtime.rs @@ -1,9 +1,6 @@ use crate::hc_auth::{self, HcAuthConfig, HcAuthStatus}; -use crate::{ - AppAuth, AppInstallOutcome, RuntimeConfig, RuntimeError, RuntimeResult, DEVICE_SEED_LAIR_TAG, -}; -use holochain::conductor::api::IssueAppAuthenticationTokenPayload; -use holochain::conductor::api::{AppAuthenticationTokenIssued, ZomeCallParamsSigned}; +use crate::{AppInstallOutcome, RuntimeConfig, RuntimeError, RuntimeResult, DEVICE_SEED_LAIR_TAG}; +use holochain::conductor::api::ZomeCallParamsSigned; use holochain::{ conductor::{ api::{ @@ -18,22 +15,16 @@ use holochain::{ use holochain_keystore::MetaLairClient; use holochain_types::network::HolochainTransportStats; use holochain_types::signal::Signal; -use holochain_types::websocket::AllowedOrigins; use lair_keystore_api::types::SharedLockedArray; use log::{debug, error}; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use tokio::sync::broadcast; -/// Map of app ids to their associated app websocket & authentication -pub type AppAuths = Arc>>; - /// Slim wrapper around holochain Conductor with calls wrapping AdminInterfaceApi requests #[derive(Clone)] pub struct Runtime { conductor: ConductorHandle, - app_auths: AppAuths, // --- Phase 3: controllable boot / hc-auth / restart-keeping-lair --- /// The lair keystore client, spawned in-proc *before* the conductor and @@ -237,7 +228,6 @@ impl Runtime { Ok(Self { conductor, - app_auths: Arc::new(RwLock::new(HashMap::new())), lair_client, device_agent_key, passphrase, @@ -615,7 +605,6 @@ impl Runtime { Ok(Runtime { conductor, - app_auths: Arc::new(RwLock::new(HashMap::new())), lair_client: self.lair_client.clone(), device_agent_key: self.device_agent_key.clone(), passphrase: self.passphrase.clone(), @@ -673,72 +662,13 @@ impl Runtime { Ok(*signature.0) } - pub async fn ensure_app_websocket( - &self, - installed_app_id: InstalledAppId, - ) -> RuntimeResult { - let app_auths = self.app_auths.read().unwrap().clone(); - match app_auths.get(&installed_app_id) { - Some(app_websocket) => Ok(app_websocket.clone()), - None => { - let authentication = self - .issue_app_authentication_token(IssueAppAuthenticationTokenPayload { - installed_app_id: installed_app_id.clone(), - expiry_seconds: 0, - single_use: false, - }) - .await?; - let port = self - .attach_app_interface(None, AllowedOrigins::Any, Some(installed_app_id.clone())) - .await?; - let app_auth = AppAuth { - authentication, - port, - }; - - let mut app_auths = self.app_auths.write().unwrap(); - app_auths.insert(installed_app_id, app_auth.clone()); - - Ok(app_auth) - } - } - } - - /// Full process to setup an app - /// - /// Check if app is installed, if not install it, then optionally enable it. - /// Then ensure there is an app websocket and authentication for it. - /// - /// If an app is already installed, it will not be enabled. It is only enabled after a successful install. - /// The reasoning is that if an app is disabled after that point, - /// it is assumed to have been manually disabled in the admin interface, which we don't want to override. - pub async fn setup_app( - &self, - payload: InstallAppPayload, - enable_after_install: bool, - ) -> RuntimeResult { - // This is a temporary workaround because we cannot clone AppBundleSource, - // which is needed to read the actual app name from the manifest - // See https://github.com/holochain/holochain/pull/4882 - let installed_app_id = payload - .installed_app_id - .clone() - .ok_or(RuntimeError::InstalledAppIdNotSpecified)?; - - self.install_app_if_missing(payload, enable_after_install) - .await?; - - self.ensure_app_websocket(installed_app_id).await - } - /// Install the app in `payload` unless an app with its `installed_app_id` is /// already installed, and enable it after a fresh install if /// `enable_after_install` is set. /// /// An app that is already installed is left as it is, including when it is /// disabled: that is assumed to have been done deliberately, and is not - /// overridden. This is [`Self::setup_app`] without the app websocket, for apps - /// whose UI reaches the conductor over in-process IPC. + /// overridden. pub async fn install_app_if_missing( &self, payload: InstallAppPayload, @@ -775,9 +705,9 @@ impl Runtime { /// calls directly. /// /// The caller is responsible for scoping `installed_app_id` to what the - /// requester is allowed to access — the app websocket path uses a per-app - /// auth token for this; the in-process path must bind it some other way - /// (e.g. the calling window). + /// requester is allowed to access. An app websocket would use a per-app auth + /// token for this; here the caller must bind it some other way (e.g. the + /// calling window). pub async fn handle_app_request( &self, installed_app_id: InstalledAppId, @@ -838,39 +768,6 @@ impl Runtime { .handle_request(Ok(request)) .await?) } - - async fn issue_app_authentication_token( - &self, - payload: IssueAppAuthenticationTokenPayload, - ) -> RuntimeResult { - let response = self - .req_admin_api(AdminRequest::IssueAppAuthenticationToken(payload)) - .await?; - match response { - AdminResponse::AppAuthenticationTokenIssued(auth) => Ok(auth), - fail => Err(RuntimeError::AdminApiBadResponse(Box::new(fail))), - } - } - - async fn attach_app_interface( - &self, - port: Option, - allowed_origins: AllowedOrigins, - installed_app_id: Option, - ) -> RuntimeResult { - let response = self - .req_admin_api(AdminRequest::AttachAppInterface { - port, - allowed_origins, - installed_app_id, - danger_bind_addr: None, - }) - .await?; - match response { - AdminResponse::AppInterfaceAttached { port } => Ok(port), - fail => Err(RuntimeError::AdminApiBadResponse(Box::new(fail))), - } - } } #[cfg(test)] @@ -890,6 +787,7 @@ mod test { use holochain_types::prelude::Timestamp; use sodoken::LockedArray; + use std::collections::HashMap; use std::sync::Mutex; use std::time::Duration; use tempfile::TempDir; @@ -1640,64 +1538,6 @@ mod test { assert!(matches!(signal, Signal::App { .. })); } - #[tokio::test(flavor = "multi_thread")] - async fn test_ensure_app_websocket() { - let tmp_dir = TempDir::new().unwrap(); - let tmp_dir_path = tmp_dir.path().to_path_buf(); - let runtime = Runtime::new( - Arc::new(Mutex::new(LockedArray::from(vec![0, 0, 0, 0]))), - RuntimeConfig { - data_root_path: tmp_dir_path, - network: RuntimeNetworkConfig::default(), - }, - ) - .await - .unwrap(); - - // An app only gets one app ws - let app_websocket = runtime - .ensure_app_websocket("my-app-1".into()) - .await - .unwrap(); - let app_websocket_2 = runtime - .ensure_app_websocket("my-app-1".into()) - .await - .unwrap(); - let app_websocket_3 = { - let all_app_auths = runtime.app_auths.read().unwrap(); - all_app_auths.get("my-app-1").unwrap().clone() - }; - assert_eq!(app_websocket.port, app_websocket_2.port); - assert_eq!( - app_websocket.authentication.token, - app_websocket_2.authentication.token - ); - assert_eq!( - app_websocket.authentication.expires_at, - app_websocket_2.authentication.expires_at - ); - assert_eq!(app_websocket_3.port, app_websocket.port); - assert_eq!( - app_websocket_3.authentication.token, - app_websocket.authentication.token - ); - assert_eq!( - app_websocket_3.authentication.expires_at, - app_websocket.authentication.expires_at - ); - - // Different apps get different ports and tokens - let app_websocket_4 = runtime - .ensure_app_websocket("my-app-2".into()) - .await - .unwrap(); - assert_ne!(app_websocket_4.port, app_websocket.port); - assert_ne!( - app_websocket_4.authentication.token, - app_websocket.authentication.token - ); - } - #[tokio::test(flavor = "multi_thread")] async fn test_api_err_bad_response() { let tmp_dir = TempDir::new().unwrap(); @@ -1718,7 +1558,7 @@ mod test { } #[tokio::test(flavor = "multi_thread")] - async fn test_setup_app_installs_when_app_id_different() { + async fn test_install_app_if_missing_installs_when_app_id_different() { let tmp_dir = TempDir::new().unwrap(); let tmp_dir_path = tmp_dir.path().to_path_buf(); let runtime = Runtime::new( @@ -1732,7 +1572,7 @@ mod test { .unwrap(); let res = runtime - .setup_app( + .install_app_if_missing( InstallAppPayload { source: AppBundleSource::Bytes(test_happ_bytes().into()), agent_key: None, @@ -1751,7 +1591,7 @@ mod test { assert_eq!(apps.len(), 1); let res = runtime - .setup_app( + .install_app_if_missing( InstallAppPayload { source: AppBundleSource::Bytes(test_happ_bytes().into()), agent_key: None, @@ -1771,7 +1611,7 @@ mod test { } #[tokio::test(flavor = "multi_thread")] - async fn test_setup_app_does_not_enable_after_install() { + async fn test_install_app_if_missing_does_not_enable_after_install() { let tmp_dir = TempDir::new().unwrap(); let tmp_dir_path = tmp_dir.path().to_path_buf(); let runtime = Runtime::new( @@ -1785,7 +1625,7 @@ mod test { .unwrap(); let res = runtime - .setup_app( + .install_app_if_missing( InstallAppPayload { source: AppBundleSource::Bytes(test_happ_bytes().into()), agent_key: None, @@ -1807,7 +1647,7 @@ mod test { } #[tokio::test(flavor = "multi_thread")] - async fn test_setup_app_does_enable_after_install() { + async fn test_install_app_if_missing_does_enable_after_install() { let tmp_dir = TempDir::new().unwrap(); let tmp_dir_path = tmp_dir.path().to_path_buf(); let runtime = Runtime::new( @@ -1821,7 +1661,7 @@ mod test { .unwrap(); let res = runtime - .setup_app( + .install_app_if_missing( InstallAppPayload { source: AppBundleSource::Bytes(test_happ_bytes().into()), agent_key: None, diff --git a/crates/runtime/src/types.rs b/crates/runtime/src/types.rs index eefeb3e..6df4d0d 100644 --- a/crates/runtime/src/types.rs +++ b/crates/runtime/src/types.rs @@ -1,11 +1,4 @@ -use holochain::conductor::api::{AppAuthenticationTokenIssued, AppInfo}; - -/// An app websocket port with an authentication token -#[derive(Clone, Debug)] -pub struct AppAuth { - pub authentication: AppAuthenticationTokenIssued, - pub port: u16, -} +use holochain::conductor::api::AppInfo; /// What [`crate::Runtime::install_app_if_missing`] did. #[derive(Clone, Debug)] diff --git a/crates/tauri-plugin-hc/README.md b/crates/tauri-plugin-hc/README.md index 15a8f48..776fa90 100644 --- a/crates/tauri-plugin-hc/README.md +++ b/crates/tauri-plugin-hc/README.md @@ -7,7 +7,8 @@ It is built on [`holochain-conductor-runtime`](../runtime) and exposes it throug ## What it does - Unlocks the lair keystore and boots the conductor, emitting `holochain://lair-ready`, then `holochain://ready` — or `holochain://setup-failed` with the cause. -- Opens webview windows bound to an installed app. The injected `__HC_TAURI_HOLOCHAIN__` env lets `@holochain/client` reach the conductor over Tauri IPC, with no loopback websocket; the older app-websocket path stays available per window via `WindowOptions`. +- Opens webview windows bound to an installed app. The injected `__HC_TAURI_HOLOCHAIN__` env lets `@holochain/client` reach the conductor over Tauri IPC, with no loopback websocket. +- Confines those windows to the origin they first load from: navigation anywhere else is refused (and logged), and `window.open` / `target="_blank"` open nothing (on Android they load in the same window, subject to the same check), so a link cannot swap the app's UI for a remote page that keeps the app's IPC. `blob:` URLs of that origin still navigate, so a UI can hand the user a file. `HolochainPlugin::lock_navigation` applies the same policy to a window the app builds itself. - Forwards each bound app's conductor signals to its window as `holochain://signal`. - Moves a window between installed apps in place with `rebind_window`, without recreating the OS window. A monotonic `seq` on the `holochain://rebound` event makes out-of-order delivery safe, and a failed rebind keeps the prior binding. - Signs zome calls for the UI, and arbitrary payloads via `sign_payload`. diff --git a/crates/tauri-plugin-hc/dist-js/holochain-env/index.min.js b/crates/tauri-plugin-hc/dist-js/holochain-env/index.min.js index 6b7f4f9..8c5901c 100644 --- a/crates/tauri-plugin-hc/dist-js/holochain-env/index.min.js +++ b/crates/tauri-plugin-hc/dist-js/holochain-env/index.min.js @@ -1 +1 @@ -const e=new TextEncoder;function t(t,i,r){t.length>50?function(t,i,r){e.encodeInto(t,i.subarray(r))}(t,i,r):function(e,t,i){const r=e.length;let s=i,n=0;for(;n=55296&&i<=56319&&n>18&7|240,t[s++]=i>>12&63|128,t[s++]=i>>6&63|128):(t[s++]=i>>12&15|224,t[s++]=i>>6&63|128)}else t[s++]=i>>6&31|192;t[s++]=63&i|128}else t[s++]=i}}(t,i,r)}new TextDecoder;class i{constructor(e,t){this.type=e,this.data=t}}class r extends Error{constructor(e){super(e);const t=Object.create(r.prototype);Object.setPrototypeOf(this,t),Object.defineProperty(this,"name",{configurable:!0,enumerable:!1,value:r.name})}}function s(e,t,i){const r=Math.floor(i/4294967296),s=i;e.setUint32(t,r),e.setUint32(t+4,s)}const n={type:-1,encode:function(e){if(e instanceof Date){return function({sec:e,nsec:t}){if(e>=0&&t>=0&&e<=17179869183){if(0===t&&e<=4294967295){const t=new Uint8Array(4);return new DataView(t.buffer).setUint32(0,e),t}{const i=e/4294967296,r=4294967295&e,s=new Uint8Array(8),n=new DataView(s.buffer);return n.setUint32(0,t<<2|3&i),n.setUint32(4,r),s}}{const i=new Uint8Array(12),r=new DataView(i.buffer);return r.setUint32(0,t),s(r,4,e),i}}(function(e){const t=e.getTime(),i=Math.floor(t/1e3),r=1e6*(t-1e3*i),s=Math.floor(r/1e9);return{sec:i+s,nsec:r-1e9*s}}(e))}return null},decode:function(e){const t=function(e){const t=new DataView(e.buffer,e.byteOffset,e.byteLength);switch(e.byteLength){case 4:return{sec:t.getUint32(0),nsec:0};case 8:{const e=t.getUint32(0);return{sec:4294967296*(3&e)+t.getUint32(4),nsec:e>>>2}}case 12:{const e=function(e,t){return 4294967296*e.getInt32(t)+e.getUint32(t+4)}(t,4);return{sec:e,nsec:t.getUint32(0)}}default:throw new r(`Unrecognized data size for timestamp (expected 4, 8, or 12): ${e.length}`)}}(e);return new Date(1e3*t.sec+t.nsec/1e6)}};class o{constructor(){this.builtInEncoders=[],this.builtInDecoders=[],this.encoders=[],this.decoders=[],this.register(n)}register({type:e,encode:t,decode:i}){if(e>=0)this.encoders[e]=t,this.decoders[e]=i;else{const r=-1-e;this.builtInEncoders[r]=t,this.builtInDecoders[r]=i}}tryToEncode(e,t){for(let r=0;rthis.maxDepth)throw new Error(`Too deep objects in depth ${t}`);null==e?this.encodeNil():"boolean"==typeof e?this.encodeBoolean(e):"number"==typeof e?this.forceIntegerToFloat?this.encodeNumberAsFloat(e):this.encodeNumber(e):"string"==typeof e?this.encodeString(e):this.useBigInt64&&"bigint"==typeof e?this.encodeBigInt64(e):this.encodeObject(e,t)}ensureBufferSizeToWrite(e){const t=this.pos+e;this.view.byteLength=0?e<128?this.writeU8(e):e<256?(this.writeU8(204),this.writeU8(e)):e<65536?(this.writeU8(205),this.writeU16(e)):e<4294967296?(this.writeU8(206),this.writeU32(e)):this.useBigInt64?this.encodeNumberAsFloat(e):(this.writeU8(207),this.writeU64(e)):e>=-32?this.writeU8(224|e+32):e>=-128?(this.writeU8(208),this.writeI8(e)):e>=-32768?(this.writeU8(209),this.writeI16(e)):e>=-2147483648?(this.writeU8(210),this.writeI32(e)):this.useBigInt64?this.encodeNumberAsFloat(e):(this.writeU8(211),this.writeI64(e)):this.encodeNumberAsFloat(e)}encodeNumberAsFloat(e){this.forceFloat32?(this.writeU8(202),this.writeF32(e)):(this.writeU8(203),this.writeF64(e))}encodeBigInt64(e){e>=BigInt(0)?(this.writeU8(207),this.writeBigUint64(e)):(this.writeU8(211),this.writeBigInt64(e))}writeStringHeader(e){if(e<32)this.writeU8(160+e);else if(e<256)this.writeU8(217),this.writeU8(e);else if(e<65536)this.writeU8(218),this.writeU16(e);else{if(!(e<4294967296))throw new Error(`Too long string: ${e} bytes in UTF-8`);this.writeU8(219),this.writeU32(e)}}encodeString(e){const i=function(e){const t=e.length;let i=0,r=0;for(;r=55296&&s<=56319&&r=4294967296)throw new Error(`Too large extension object: ${i}`);return this.writeU8(201),this.writeU32(i),this.writeI8(e.type),void this.writeU8a(t)}const t=e.data.length;if(1===t)this.writeU8(212);else if(2===t)this.writeU8(213);else if(4===t)this.writeU8(214);else if(8===t)this.writeU8(215);else if(16===t)this.writeU8(216);else if(t<256)this.writeU8(199),this.writeU8(t);else if(t<65536)this.writeU8(200),this.writeU16(t);else{if(!(t<4294967296))throw new Error(`Too large extension object: ${t}`);this.writeU8(201),this.writeU32(t)}this.writeI8(e.type),this.writeU8a(e.data)}writeU8(e){this.ensureBufferSizeToWrite(1),this.view.setUint8(this.pos,e),this.pos++}writeU8a(e){const t=e.length;this.ensureBufferSizeToWrite(t),this.bytes.set(e,this.pos),this.pos+=t}writeI8(e){this.ensureBufferSizeToWrite(1),this.view.setInt8(this.pos,e),this.pos++}writeU16(e){this.ensureBufferSizeToWrite(2),this.view.setUint16(this.pos,e),this.pos+=2}writeI16(e){this.ensureBufferSizeToWrite(2),this.view.setInt16(this.pos,e),this.pos+=2}writeU32(e){this.ensureBufferSizeToWrite(4),this.view.setUint32(this.pos,e),this.pos+=4}writeI32(e){this.ensureBufferSizeToWrite(4),this.view.setInt32(this.pos,e),this.pos+=4}writeF32(e){this.ensureBufferSizeToWrite(4),this.view.setFloat32(this.pos,e),this.pos+=4}writeF64(e){this.ensureBufferSizeToWrite(8),this.view.setFloat64(this.pos,e),this.pos+=8}writeU64(e){this.ensureBufferSizeToWrite(8),function(e,t,i){const r=i/4294967296,s=i;e.setUint32(t,r),e.setUint32(t+4,s)}(this.view,this.pos,e),this.pos+=8}writeI64(e){this.ensureBufferSizeToWrite(8),s(this.view,this.pos,e),this.pos+=8}writeBigUint64(e){this.ensureBufferSizeToWrite(8),this.view.setBigUint64(this.pos,e),this.pos+=8}writeBigInt64(e){this.ensureBufferSizeToWrite(8),this.view.setBigInt64(this.pos,e),this.pos+=8}}function a(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}async function f(e,t={},i){return window.__TAURI_INTERNALS__.invoke(e,t,i)}var u;async function w(e,t,i){const r={kind:"Any"};return f("plugin:event|listen",{event:e,target:r,handler:a(t)}).then(t=>async()=>async function(e,t){await f("plugin:event|unlisten",{event:e,eventId:t})}(e,t))}function d(e){return{signZomeCall:async t=>{const i=Uint8Array.from(await crypto.getRandomValues(new Uint8Array(32))),r=1e3*(Date.now()+3e5),s=Array.from((n=t.payload,new c(o).encodeSharedRef(n)));var n,o;const h={provenance:Array.from(t.provenance),cellIdDnaHash:Array.from(t.cell_id[0]),cellIdAgentPubKey:Array.from(t.cell_id[1]),zomeName:t.zome_name,fnName:t.fn_name,capSecret:null,payload:s,nonce:Array.from(i),expiresAt:r},a=await window.__TAURI_INTERNALS__.invoke(`plugin:${e}|sign_zome_call`,{request:h});return{bytes:Uint8Array.from(a.bytes),signature:Uint8Array.from(a.signature)}}}}"function"==typeof SuppressedError&&SuppressedError,function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"}(u||(u={})),window.injectHolochainClientEnv=function(e,t,i,r){window.__HC_LAUNCHER_ENV__={INSTALLED_APP_ID:e,APP_INTERFACE_PORT:t,APP_INTERFACE_TOKEN:i},window.__HC_ZOME_CALL_SIGNER__=d(r)},window.injectHolochainTauriEnv=function(e,t){const i={INSTALLED_APP_ID:e,PLUGIN_NAME:t,subscribeSignals:e=>{let t,i=!1;return w("holochain://signal",t=>{e(Uint8Array.from(t.payload))}).then(e=>{i?e():t=e}),()=>{i=!0,t?.()}}};window.__HC_TAURI_HOLOCHAIN__=i,window.__HC_ZOME_CALL_SIGNER__=d(t);let r=0;w("holochain://rebound",e=>{if(e.payload.seq<=r)return;r=e.payload.seq;const t=e.payload.app_id;i.INSTALLED_APP_ID=t??"",window.dispatchEvent(new CustomEvent("holochain-rebound",{detail:{installedAppId:t}}))})}; +const e=new TextEncoder;function t(t,i,r){t.length>50?function(t,i,r){e.encodeInto(t,i.subarray(r))}(t,i,r):function(e,t,i){const r=e.length;let s=i,n=0;for(;n=55296&&i<=56319&&n>18&7|240,t[s++]=i>>12&63|128,t[s++]=i>>6&63|128):(t[s++]=i>>12&15|224,t[s++]=i>>6&63|128)}else t[s++]=i>>6&31|192;t[s++]=63&i|128}else t[s++]=i}}(t,i,r)}new TextDecoder;class i{type;data;constructor(e,t){this.type=e,this.data=t}}class r extends Error{constructor(e){super(e);const t=Object.create(r.prototype);Object.setPrototypeOf(this,t),Object.defineProperty(this,"name",{configurable:!0,enumerable:!1,value:r.name})}}function s(e,t,i){const r=Math.floor(i/4294967296),s=i;e.setUint32(t,r),e.setUint32(t+4,s)}const n={type:-1,encode:function(e){if(e instanceof Date){return function({sec:e,nsec:t}){if(e>=0&&t>=0&&e<=17179869183){if(0===t&&e<=4294967295){const t=new Uint8Array(4);return new DataView(t.buffer).setUint32(0,e),t}{const i=e/4294967296,r=4294967295&e,s=new Uint8Array(8),n=new DataView(s.buffer);return n.setUint32(0,t<<2|3&i),n.setUint32(4,r),s}}{const i=new Uint8Array(12),r=new DataView(i.buffer);return r.setUint32(0,t),s(r,4,e),i}}(function(e){const t=e.getTime(),i=Math.floor(t/1e3),r=1e6*(t-1e3*i),s=Math.floor(r/1e9);return{sec:i+s,nsec:r-1e9*s}}(e))}return null},decode:function(e){const t=function(e){const t=new DataView(e.buffer,e.byteOffset,e.byteLength);switch(e.byteLength){case 4:return{sec:t.getUint32(0),nsec:0};case 8:{const e=t.getUint32(0);return{sec:4294967296*(3&e)+t.getUint32(4),nsec:e>>>2}}case 12:{const e=function(e,t){return 4294967296*e.getInt32(t)+e.getUint32(t+4)}(t,4);return{sec:e,nsec:t.getUint32(0)}}default:throw new r(`Unrecognized data size for timestamp (expected 4, 8, or 12): ${e.length}`)}}(e);return new Date(1e3*t.sec+t.nsec/1e6)}};class o{static defaultCodec=new o;__brand;builtInEncoders=[];builtInDecoders=[];encoders=[];decoders=[];constructor(){this.register(n)}register({type:e,encode:t,decode:i}){if(e>=0)this.encoders[e]=t,this.decoders[e]=i;else{const r=-1-e;this.builtInEncoders[r]=t,this.builtInDecoders[r]=i}}tryToEncode(e,t){for(let r=0;rthis.maxDepth)throw new Error(`Too deep objects in depth ${t}`);null==e?this.encodeNil():"boolean"==typeof e?this.encodeBoolean(e):"number"==typeof e?this.forceIntegerToFloat?this.encodeNumberAsFloat(e):this.encodeNumber(e):"string"==typeof e?this.encodeString(e):this.useBigInt64&&"bigint"==typeof e?this.encodeBigInt64(e):this.encodeObject(e,t)}ensureBufferSizeToWrite(e){const t=this.pos+e;this.view.byteLength=0?e<128?this.writeU8(e):e<256?(this.writeU8(204),this.writeU8(e)):e<65536?(this.writeU8(205),this.writeU16(e)):e<4294967296?(this.writeU8(206),this.writeU32(e)):this.useBigInt64?this.encodeNumberAsFloat(e):(this.writeU8(207),this.writeU64(e)):e>=-32?this.writeU8(224|e+32):e>=-128?(this.writeU8(208),this.writeI8(e)):e>=-32768?(this.writeU8(209),this.writeI16(e)):e>=-2147483648?(this.writeU8(210),this.writeI32(e)):this.useBigInt64?this.encodeNumberAsFloat(e):(this.writeU8(211),this.writeI64(e)):this.encodeNumberAsFloat(e)}encodeNumberAsFloat(e){this.forceFloat32?(this.writeU8(202),this.writeF32(e)):(this.writeU8(203),this.writeF64(e))}encodeBigInt64(e){e>=BigInt(0)?(this.writeU8(207),this.writeBigUint64(e)):(this.writeU8(211),this.writeBigInt64(e))}writeStringHeader(e){if(e<32)this.writeU8(160+e);else if(e<256)this.writeU8(217),this.writeU8(e);else if(e<65536)this.writeU8(218),this.writeU16(e);else{if(!(e<4294967296))throw new Error(`Too long string: ${e} bytes in UTF-8`);this.writeU8(219),this.writeU32(e)}}encodeString(e){const i=function(e){const t=e.length;let i=0,r=0;for(;r=55296&&s<=56319&&r=4294967296)throw new Error(`Too large extension object: ${i}`);return this.writeU8(201),this.writeU32(i),this.writeI8(e.type),void this.writeU8a(t)}const t=e.data.length;if(1===t)this.writeU8(212);else if(2===t)this.writeU8(213);else if(4===t)this.writeU8(214);else if(8===t)this.writeU8(215);else if(16===t)this.writeU8(216);else if(t<256)this.writeU8(199),this.writeU8(t);else if(t<65536)this.writeU8(200),this.writeU16(t);else{if(!(t<4294967296))throw new Error(`Too large extension object: ${t}`);this.writeU8(201),this.writeU32(t)}this.writeI8(e.type),this.writeU8a(e.data)}writeU8(e){this.ensureBufferSizeToWrite(1),this.view.setUint8(this.pos,e),this.pos++}writeU8a(e){const t=e.length;this.ensureBufferSizeToWrite(t),this.bytes.set(e,this.pos),this.pos+=t}writeI8(e){this.ensureBufferSizeToWrite(1),this.view.setInt8(this.pos,e),this.pos++}writeU16(e){this.ensureBufferSizeToWrite(2),this.view.setUint16(this.pos,e),this.pos+=2}writeI16(e){this.ensureBufferSizeToWrite(2),this.view.setInt16(this.pos,e),this.pos+=2}writeU32(e){this.ensureBufferSizeToWrite(4),this.view.setUint32(this.pos,e),this.pos+=4}writeI32(e){this.ensureBufferSizeToWrite(4),this.view.setInt32(this.pos,e),this.pos+=4}writeF32(e){this.ensureBufferSizeToWrite(4),this.view.setFloat32(this.pos,e),this.pos+=4}writeF64(e){this.ensureBufferSizeToWrite(8),this.view.setFloat64(this.pos,e),this.pos+=8}writeU64(e){this.ensureBufferSizeToWrite(8),function(e,t,i){const r=i/4294967296,s=i;e.setUint32(t,r),e.setUint32(t+4,s)}(this.view,this.pos,e),this.pos+=8}writeI64(e){this.ensureBufferSizeToWrite(8),s(this.view,this.pos,e),this.pos+=8}writeBigUint64(e){this.ensureBufferSizeToWrite(8),this.view.setBigUint64(this.pos,e),this.pos+=8}writeBigInt64(e){this.ensureBufferSizeToWrite(8),this.view.setBigInt64(this.pos,e),this.pos+=8}}function a(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}async function f(e,t={},i){return window.__TAURI_INTERNALS__.invoke(e,t,i)}var u;async function d(e,t,i){const r={kind:"Any"};return f("plugin:event|listen",{event:e,target:r,handler:a(t)}).then(t=>async()=>async function(e,t){window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(e,t),await f("plugin:event|unlisten",{event:e,eventId:t})}(e,t))}function w(e){return{signZomeCall:async t=>{const i=Uint8Array.from(await crypto.getRandomValues(new Uint8Array(32))),r=1e3*(Date.now()+3e5),s=Array.from((n=t.payload,new c(o).encodeSharedRef(n)));var n,o;const h={provenance:Array.from(t.provenance),cellIdDnaHash:Array.from(t.cell_id[0]),cellIdAgentPubKey:Array.from(t.cell_id[1]),zomeName:t.zome_name,fnName:t.fn_name,capSecret:null,payload:s,nonce:Array.from(i),expiresAt:r},a=await window.__TAURI_INTERNALS__.invoke(`plugin:${e}|sign_zome_call`,{request:h});return{bytes:Uint8Array.from(a.bytes),signature:Uint8Array.from(a.signature)}}}}"function"==typeof SuppressedError&&SuppressedError,function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WINDOW_SUSPENDED="tauri://suspended",e.WINDOW_RESUMED="tauri://resumed",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"}(u||(u={})),window.injectHolochainTauriEnv=function(e,t){const i={INSTALLED_APP_ID:e,PLUGIN_NAME:t,subscribeSignals:e=>{let t,i=!1;return d("holochain://signal",t=>{e(Uint8Array.from(t.payload))}).then(e=>{i?e():t=e}),()=>{i=!0,t?.()}}};window.__HC_TAURI_HOLOCHAIN__=i,window.__HC_ZOME_CALL_SIGNER__=w(t);let r=0;d("holochain://rebound",e=>{if(e.payload.seq<=r)return;r=e.payload.seq;const t=e.payload.app_id;i.INSTALLED_APP_ID=t??"",window.dispatchEvent(new CustomEvent("holochain-rebound",{detail:{installedAppId:t}}))})}; diff --git a/crates/tauri-plugin-hc/guest-js/holochain-env/index.ts b/crates/tauri-plugin-hc/guest-js/holochain-env/index.ts index cbfd4b8..ff90085 100644 --- a/crates/tauri-plugin-hc/guest-js/holochain-env/index.ts +++ b/crates/tauri-plugin-hc/guest-js/holochain-env/index.ts @@ -1,17 +1,14 @@ /// Injects the env `@holochain/client` needs into a webview opened by /// `tauri-plugin-hc`, plus a zome-call signer backed by the plugin's -/// keystore. Two modes: -/// - injectHolochainClientEnv: legacy, connects to the in-process conductor's -/// app *websocket* (`__HC_LAUNCHER_ENV__`). -/// - injectHolochainTauriEnv: direct, routes the App API over Tauri IPC with -/// no websocket (`__HC_TAURI_HOLOCHAIN__`), and bridges conductor signals. +/// keystore. `injectHolochainTauriEnv` routes the App API over Tauri IPC with +/// no websocket (`__HC_TAURI_HOLOCHAIN__`) and bridges conductor signals. import { encode } from '@msgpack/msgpack'; import { listen } from '@tauri-apps/api/event'; import { type CallZomeRequest, type CallZomeRequestSigned } from '@holochain/client'; /// Build the zome-call signer that signs via the plugin's `sign_zome_call` -/// command. Shared by both injection modes. +/// command. function makeZomeCallSigner(pluginName: string) { return { signZomeCall: async (request: CallZomeRequest): Promise => { @@ -44,22 +41,6 @@ function makeZomeCallSigner(pluginName: string) { }; } -/// Legacy app-websocket wiring: `@holochain/client` dials `ws://localhost:` -/// and authenticates with the token. -function injectHolochainClientEnv( - installedAppId: string, - port: number, - token: Uint8Array, - pluginName: string, -) { - (window as any).__HC_LAUNCHER_ENV__ = { - INSTALLED_APP_ID: installedAppId, - APP_INTERFACE_PORT: port, - APP_INTERFACE_TOKEN: token, - }; - (window as any).__HC_ZOME_CALL_SIGNER__ = makeZomeCallSigner(pluginName); -} - /// Direct Tauri-IPC wiring: `@holochain/client` routes the App API through the /// plugin's `app_request` command and receives signals forwarded as /// `holochain://signal` events — no app websocket. @@ -109,5 +90,4 @@ function injectHolochainTauriEnv(installedAppId: string, pluginName: string) { }); } -(window as any).injectHolochainClientEnv = injectHolochainClientEnv; (window as any).injectHolochainTauriEnv = injectHolochainTauriEnv; diff --git a/crates/tauri-plugin-hc/src/lib.rs b/crates/tauri-plugin-hc/src/lib.rs index 098248e..a7e2505 100644 --- a/crates/tauri-plugin-hc/src/lib.rs +++ b/crates/tauri-plugin-hc/src/lib.rs @@ -5,8 +5,7 @@ //! it to a Tauri app via the [`HolochainExt`] trait. A webview opened with //! [`HolochainPlugin::main_window_builder`] is bound to an installed app and //! reaches the conductor over Tauri IPC, so `@holochain/client` in the UI needs -//! no loopback websocket; [`WindowOptions`] can still select the app-websocket -//! path per window. +//! no loopback websocket. //! //! The Tauri plugin identifier is `hc`: permissions are `hc:default` and //! `hc:allow-*`, and commands are invoked as `plugin:hc|`. It has to @@ -20,6 +19,7 @@ mod dev_network; mod error; #[cfg(target_os = "linux")] mod linux_media; +mod origin; mod paths; mod ready; mod user_network; @@ -29,6 +29,7 @@ pub mod test_support; pub use dev_network::{dev_network_config, DEV_INITIATE_BURST_FACTOR}; pub use error::{Error, Result}; +pub use origin::{navigation_allowed, origin_of, same_origin}; pub use paths::{app_paths, AppPaths, MAX_DEV_INSTANCES}; pub use ready::on_ready; pub use user_network::{UserNetworkConfig, UserNetworkConfigPath}; @@ -55,7 +56,8 @@ use tokio::sync::broadcast; use sodoken::LockedArray; use tauri::{ plugin::{Builder, TauriPlugin}, - AppHandle, Emitter, Manager, RunEvent, Runtime as TauriRuntime, WebviewUrl, + webview::{NewWindowResponse, PageLoadEvent}, + AppHandle, Emitter, Manager, RunEvent, Runtime as TauriRuntime, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; @@ -151,10 +153,20 @@ pub struct WindowOptions { pub url: Option, /// Window title (desktop). pub title: Option, - /// Use the legacy app-websocket wiring (attach an app interface and inject - /// `__HC_LAUNCHER_ENV__`) instead of direct Tauri IPC. Defaults to `false` - /// (direct), which needs no loopback websocket. - pub use_app_websocket: bool, +} + +/// Per-window first-loaded origins, shared with the closures that consult them. +type WindowOrigins = Arc>>; + +/// Record `url`'s origin as the `label` webview's origin unless one is already +/// recorded. First observation wins: everything after is checked against it. +fn record_origin_if_absent(origins: &WindowOrigins, label: &str, url: &Url) { + let mut origins = origins.lock().unwrap(); + if !origins.contains_key(label) { + let origin = origin::origin_of(url); + log::debug!("webview {label} locked to origin {origin}"); + origins.insert(label.to_string(), origin); + } } /// Outcome of the conductor boot, held in place of a bare `Option` so a @@ -197,6 +209,11 @@ pub struct HolochainPlugin { /// by [`swap_runtime`]) can abort the previous forwarder before starting the /// new one — otherwise the old app's signals would keep arriving at the window. window_forwarders: Arc>>>, + /// The origin each webview first loaded from, by label: recorded on its + /// first navigation or page load, kept until the window is destroyed (see + /// `origin.rs`). Windows from [`HolochainPlugin::lock_navigation`] refuse + /// to leave it. + window_origins: WindowOrigins, /// Monotonic rebind counter — rides each [`EVENT_REBOUND`] as its `seq` so the /// injected env can drop a stale (out-of-order) rebound rather than leave the /// UI on a different app than `app_request` routes to. @@ -205,7 +222,7 @@ pub struct HolochainPlugin { impl HolochainPlugin { /// The underlying conductor runtime. The full lifecycle API - /// (`install_app`, `enable_app`, `setup_app`, `ensure_app_websocket`, + /// (`install_app`, `enable_app`, `install_app_if_missing`, /// `sign_zome_call`, `import_key_seed`, ...) lives here — this plugin is a /// thin Tauri adapter, not a re-implementation. /// @@ -434,6 +451,52 @@ impl HolochainPlugin { } } + /// Confine the `label` window to the origin it first loads from. + /// + /// Installs the plugin's navigation policy on `builder`: the first + /// navigation (or, on Android, where the webview does not report its initial + /// load as a navigation, the first page load) fixes the window's origin, and + /// every later navigation is checked with [`navigation_allowed`], refused and + /// logged when it fails. On desktop `window.open` and `target="_blank"` open + /// nothing. Tauri does not support `on_new_window` on mobile: Android loads + /// the target in the same webview, where the navigation check still + /// applies, and iOS opens nothing. A hApp UI opens external links through + /// the opener plugin. `main_window_builder` applies this itself; call it for + /// windows the app builds directly, and do not chain another `on_navigation` + /// or `on_new_window` after it, since Tauri keeps only the last handler of + /// each. + pub fn lock_navigation<'a, M: Manager>( + &self, + label: impl Into, + builder: WebviewWindowBuilder<'a, R, M>, + ) -> WebviewWindowBuilder<'a, R, M> { + let label = label.into(); + let origins = self.window_origins.clone(); + builder + .on_navigation(move |target| { + let locked = origins.lock().unwrap().get(&label).cloned(); + match locked { + None => { + record_origin_if_absent(&origins, &label, target); + true + } + Some(origin) => { + let allowed = origin::navigation_allowed(target, &origin); + if !allowed { + log::warn!( + "refusing navigation of window {label} to {target}: not on its origin {origin}" + ); + } + allowed + } + } + }) + .on_new_window(|target, _features| { + log::warn!("refusing to open a new window for {target}"); + NewWindowResponse::Deny + }) + } + fn app_id_for_window(&self, label: &str) -> Result { self.bound_app(label).ok_or(Error::WindowNotBound) } @@ -459,12 +522,15 @@ impl HolochainPlugin { /// Build a webview window wired to the in-process conductor for `app_id`. /// - /// By default this uses **direct Tauri IPC**: the window is bound to the app - /// (so `app_request` calls are scoped to it), the app's signals are - /// forwarded to it as [`EVENT_SIGNAL`], and `__HC_TAURI_HOLOCHAIN__` is + /// The window reaches the conductor over **direct Tauri IPC**: it is bound + /// to the app (so `app_request` calls are scoped to it), the app's signals + /// are forwarded to it as [`EVENT_SIGNAL`], and `__HC_TAURI_HOLOCHAIN__` is /// injected so `@holochain/client` routes the App API through IPC with no - /// loopback websocket. Set [`WindowOptions::use_app_websocket`] to fall back - /// to the legacy `__HC_LAUNCHER_ENV__` websocket wiring instead. + /// loopback websocket. `None` opens an app-less window (dashboard) that + /// [`Self::rebind_window`] can bind later without recreating the OS window. + /// + /// The window is confined to the origin it first loads from (see + /// [`Self::lock_navigation`], which this applies). /// /// Call `.build()` on the returned builder to actually open the window. pub async fn main_window_builder( @@ -478,35 +544,19 @@ impl HolochainPlugin { .url .unwrap_or_else(|| WebviewUrl::App("index.html".into())); - let env_script = if options.use_app_websocket { - // Legacy: attach an app websocket and point @holochain/client at it. - // This path requires a bound app. - let app_id = app_id.ok_or(Error::WindowNotBound)?; - let app_auth = self - .try_runtime()? - .ensure_app_websocket(app_id.clone()) + if let Some(app_id) = &app_id { + self.bind_window(label.clone(), app_id.clone()); + self.spawn_signal_forwarder(label.clone(), app_id.clone()) .await?; - format!( - r#"window.injectHolochainClientEnv("{}", {}, {:?}, "{}");"#, - app_id, app_auth.port, app_auth.authentication.token, PLUGIN_NAME, - ) - } else { - // Direct: inject the IPC env (+ the rebound listener). If an app is - // given, bind the window and forward its signals; `None` opens an - // app-less window (dashboard) that `rebind_window` can bind later - // without recreating the OS window. - if let Some(app_id) = &app_id { - self.bind_window(label.clone(), app_id.clone()); - self.spawn_signal_forwarder(label.clone(), app_id.clone()) - .await?; - } - let injected = app_id.unwrap_or_default(); - format!(r#"window.injectHolochainTauriEnv({injected:?}, "{PLUGIN_NAME}");"#) - }; + } + let injected = app_id.unwrap_or_default(); + let env_script = + format!(r#"window.injectHolochainTauriEnv({injected:?}, "{PLUGIN_NAME}");"#); - let mut window_builder = WebviewWindowBuilder::new(&self.app_handle, label, url) + let window_builder = WebviewWindowBuilder::new(&self.app_handle, label.clone(), url) .initialization_script(include_str!("../dist-js/holochain-env/index.min.js")) .initialization_script(env_script.as_str()); + let mut window_builder = self.lock_navigation(label, window_builder); if let Some(title) = options.title { window_builder = window_builder.title(title); @@ -600,11 +650,22 @@ fn plugin_builder( app_handle: app.clone(), window_apps: Arc::new(Mutex::new(HashMap::new())), window_forwarders: Arc::new(Mutex::new(HashMap::new())), + window_origins: Arc::new(Mutex::new(HashMap::new())), rebind_seq: AtomicU64::new(0), }); on_setup(app); Ok(()) }) + // Every webview's first page load fixes its origin (see origin.rs). This + // is what records it for windows the app builds itself, and on Android, + // where the initial load never reaches the navigation handler. + .on_page_load(|webview, payload| { + if payload.event() == PageLoadEvent::Started { + if let Ok(plugin) = webview.app_handle().holochain() { + record_origin_if_absent(&plugin.window_origins, webview.label(), payload.url()); + } + } + }) // Linux: WebKitGTK denies camera/microphone access unless the embedder // answers its permission-request signal (see linux_media.rs). .on_webview_ready(|webview| { @@ -616,6 +677,8 @@ fn plugin_builder( // Prune a window's routing + signal forwarder when it is destroyed, so // the maps don't grow unbounded and a closed window's forwarder task is // aborted rather than left running until its app's signal channel closes. + // The origin record goes with it, and only then: a live window that is + // merely unbound must keep its lock. .on_event(|app_handle, event| { if let RunEvent::WindowEvent { label, @@ -625,6 +688,7 @@ fn plugin_builder( { if let Ok(plugin) = app_handle.holochain() { plugin.drop_window(label); + plugin.window_origins.lock().unwrap().remove(label); } } }) diff --git a/crates/tauri-plugin-hc/src/origin.rs b/crates/tauri-plugin-hc/src/origin.rs new file mode 100644 index 0000000..81e793b --- /dev/null +++ b/crates/tauri-plugin-hc/src/origin.rs @@ -0,0 +1,147 @@ +//! The origin a webview is confined to: where its UI actually loaded from, and +//! nothing else. +//! +//! The origin is observed, not derived. Tauri resolves a `WebviewUrl` in ways +//! that depend on platform and build (a LAN `devUrl` is proxied through +//! `tauri://localhost` in mobile dev builds, custom schemes become +//! `http://.` on Windows and Android, `use_https_scheme` swaps the +//! scheme), and any hand-made copy of those rules is a blank window the first +//! time it is wrong. So the plugin records the origin of each webview's first +//! navigation or page load and locks to that. Every window from +//! [`crate::HolochainPlugin::main_window_builder`] then refuses to navigate +//! anywhere else and to open new windows. + +use tauri::Url; + +/// `url` reduced to its origin: scheme, host and explicit port, no path. +/// +/// Kept as a `Url` so it can be compared with [`same_origin`] and printed. +pub fn origin_of(url: &Url) -> Url { + Url::parse(&origin_header_value(url)).expect("scheme://host[:port] parses") +} + +/// Whether `url` has the same scheme, host and port as `origin`. +/// +/// Compared by hand rather than through [`Url::origin`], which treats every +/// non-special scheme (`tauri:`, `happ:`) as opaque and never equal to anything. +pub fn same_origin(url: &Url, origin: &Url) -> bool { + url.scheme() == origin.scheme() + && url.host_str() == origin.host_str() + && url.port_or_known_default() == origin.port_or_known_default() +} + +/// Whether a window locked to `origin` may navigate to `target`. +/// +/// Same-origin URLs, and `blob:` URLs minted by that origin (`blob:/`, +/// which is how a UI hands the user a file to save), are allowed. `data:` URLs +/// are not: they have no origin, and a top-level `data:` page is exactly the +/// look-alike the lock exists to refuse. +pub fn navigation_allowed(target: &Url, origin: &Url) -> bool { + if same_origin(target, origin) { + return true; + } + target.scheme() == "blob" + && Url::parse(target.path()) + .map(|inner| same_origin(&inner, origin)) + .unwrap_or(false) +} + +/// `origin` as a browser puts it in an `Origin` header: scheme, host and any +/// explicit port, no trailing slash. +pub(crate) fn origin_header_value(origin: &Url) -> String { + let mut value = format!( + "{}://{}", + origin.scheme(), + origin.host_str().unwrap_or_default() + ); + if let Some(port) = origin.port() { + value.push_str(&format!(":{port}")); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + fn url(s: &str) -> Url { + Url::parse(s).unwrap() + } + + #[test] + fn same_origin_matches_scheme_host_and_port_only() { + let origin = url("tauri://localhost"); + assert!(same_origin(&url("tauri://localhost/index.html"), &origin)); + assert!(same_origin( + &url("tauri://localhost/deep/path?x=1#y"), + &origin + )); + assert!(!same_origin(&url("https://localhost/index.html"), &origin)); + assert!(!same_origin( + &url("tauri://evil.example/index.html"), + &origin + )); + assert!(!same_origin(&url("https://example.org/"), &origin)); + assert!(!same_origin(&url("about:blank"), &origin)); + + let dev = url("http://localhost:1420"); + assert!(same_origin(&url("http://localhost:1420/src/main.ts"), &dev)); + assert!(!same_origin(&url("http://localhost:1421/"), &dev)); + assert!(!same_origin(&url("http://127.0.0.1:1420/"), &dev)); + // The default port is the same origin whether or not it is written. + assert!(same_origin( + &url("http://localhost:80/"), + &url("http://localhost") + )); + } + + #[test] + fn origin_of_keeps_scheme_host_and_explicit_port() { + assert_eq!( + origin_of(&url("tauri://localhost/index.html")).as_str(), + "tauri://localhost" + ); + assert_eq!( + origin_of(&url("http://localhost:1420/index.html")).as_str(), + "http://localhost:1420/" + ); + assert_eq!( + origin_header_value(&url("http://tauri.localhost/")), + "http://tauri.localhost" + ); + assert_eq!( + origin_header_value(&url("https://ui.example.org:443/")), + "https://ui.example.org" + ); + } + + #[test] + fn navigation_allows_same_origin_and_its_blobs_only() { + let origin = url("tauri://localhost"); + assert!(navigation_allowed( + &url("tauri://localhost/other.html"), + &origin + )); + assert!(navigation_allowed( + &url("blob:tauri://localhost/3f1c-4b2e"), + &origin + )); + assert!(!navigation_allowed( + &url("blob:https://evil.example/3f1c-4b2e"), + &origin + )); + assert!(!navigation_allowed(&url("blob:nonsense"), &origin)); + assert!(!navigation_allowed( + &url("data:text/html,

look-alike

"), + &origin + )); + assert!(!navigation_allowed(&url("https://evil.example/"), &origin)); + assert!(!navigation_allowed(&url("about:blank"), &origin)); + + let windows = url("http://tauri.localhost"); + assert!(navigation_allowed( + &url("blob:http://tauri.localhost/3f1c"), + &windows + )); + } +} diff --git a/crates/tauri-plugin-hc/tests/integration.rs b/crates/tauri-plugin-hc/tests/integration.rs index a908046..0f60439 100644 --- a/crates/tauri-plugin-hc/tests/integration.rs +++ b/crates/tauri-plugin-hc/tests/integration.rs @@ -1,6 +1,6 @@ //! Integration tests (Approach A): prove the plugin boots a real Holochain -//! conductor *inside a Tauri app* and serves it both ways — the legacy app -//! websocket and the new in-process `app_request` IPC path. +//! conductor *inside a Tauri app* and serves the App API over the in-process +//! `app_request` IPC path. //! //! These drive the plugin as a real app would: build a Tauri app with the //! plugin and wait for `holochain://ready`. They do not open a webview — the @@ -64,18 +64,6 @@ fn plugin_boots_conductor_in_tauri_app() { install_and_enable_test_happ(&runtime).await; - // Attach an app interface — this is the websocket the legacy injection - // wires a webview to. A real bound port proves the endpoint exists. - let app_auth = runtime - .ensure_app_websocket(APP_ID.into()) - .await - .expect("ensure_app_websocket failed"); - assert!(app_auth.port > 0, "expected a bound app interface port"); - assert!( - !app_auth.authentication.token.is_empty(), - "expected a non-empty app auth token" - ); - let apps = runtime.list_apps().await.expect("list_apps failed"); assert_eq!(apps.len(), 1); assert_eq!(apps[0].installed_app_id, APP_ID); @@ -305,9 +293,9 @@ fn shipped_bundle_matches_rebound_payload_shape() { ); } -/// Both injection modes must take the plugin identifier from the Rust side. A +/// The injected env must take the plugin identifier from the Rust side. A /// literal name baked into the bundle would make the zome-call signer invoke a -/// plugin the ACL does not know — the legacy websocket path once hardcoded +/// plugin the ACL does not know — the removed websocket path once hardcoded /// `"holochain"`, which broke silently when the identifier became `hc`. #[test] fn shipped_bundle_does_not_hardcode_a_plugin_name() { @@ -427,3 +415,14 @@ fn install_app_if_missing_installs_once() { assert!(matches!(apps[0].status, AppStatus::Disabled(_))); }); } + +/// The legacy app-websocket env is gone from the Rust side; the bundle must not +/// still define an injector for it. +#[test] +fn shipped_bundle_has_no_app_websocket_env() { + let bundle = include_str!("../dist-js/holochain-env/index.min.js"); + assert!( + !bundle.contains("__HC_LAUNCHER_ENV__"), + "dist-js/holochain-env/index.min.js is stale — run `npm run build` in crates/tauri-plugin-hc" + ); +}