diff --git a/CHANGELOG.md b/CHANGELOG.md index 987709a..0902e41 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 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 276e055..03ccdf3 100644 --- a/apps/holochain-runtime-example/ui/src/main.js +++ b/apps/holochain-runtime-example/ui/src/main.js @@ -18,10 +18,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", @@ -33,8 +31,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 14d43c6..ed079fc 100644 --- a/crates/tauri-plugin-hc/README.md +++ b/crates/tauri-plugin-hc/README.md @@ -7,7 +7,7 @@ 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. 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 33d1a08..e479d1a 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 @@ -157,10 +156,6 @@ 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. @@ -230,7 +225,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. /// @@ -530,12 +525,12 @@ 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). @@ -552,31 +547,14 @@ 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 window_builder = WebviewWindowBuilder::new(&self.app_handle, label.clone(), url) .initialization_script(include_str!("../dist-js/holochain-env/index.min.js")) 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" + ); +}