Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 3 additions & 6 deletions apps/holochain-runtime-example/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@ async fn open_main_window(handle: AppHandle) -> Result<(), Box<dyn Error>> {

// 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 {
Expand All @@ -125,9 +123,8 @@ async fn open_main_window(handle: AppHandle) -> Result<(), Box<dyn Error>> {
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",
Expand Down
6 changes: 1 addition & 5 deletions apps/holochain-runtime-example/ui/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
188 changes: 14 additions & 174 deletions crates/runtime/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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<RwLock<HashMap<InstalledAppId, AppAuth>>>;

/// 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
Expand Down Expand Up @@ -237,7 +228,6 @@ impl Runtime {

Ok(Self {
conductor,
app_auths: Arc::new(RwLock::new(HashMap::new())),
lair_client,
device_agent_key,
passphrase,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -673,72 +662,13 @@ impl Runtime {
Ok(*signature.0)
}

pub async fn ensure_app_websocket(
&self,
installed_app_id: InstalledAppId,
) -> RuntimeResult<AppAuth> {
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<AppAuth> {
// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -838,39 +768,6 @@ impl Runtime {
.handle_request(Ok(request))
.await?)
}

async fn issue_app_authentication_token(
&self,
payload: IssueAppAuthenticationTokenPayload,
) -> RuntimeResult<AppAuthenticationTokenIssued> {
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<u16>,
allowed_origins: AllowedOrigins,
installed_app_id: Option<InstalledAppId>,
) -> RuntimeResult<u16> {
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)]
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand Down
Loading