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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@
`Runtime::install_app_if_missing`, which `setup_app` now uses, and fails boot
with `DataRootPathTooLong` when lair's socket path would exceed the Unix socket
limit instead of lair's `path must be shorter than SUN_LEN`.
- `get_`, `default_` and `set_user_network_config` are plugin commands now
(`plugin:hc|…`), so Tauri's ACL applies to them. `hc:default` includes the
two reads; `set`, which repoints bootstrap and relay and restarts, needs
`hc:allow-set-user-network-config` on the window that hosts the settings
screen. Apps drop them from `generate_handler!`, keep
`.manage(UserNetworkConfigPath(..))`, and invoke them with the `plugin:hc|`
prefix; emergence does all three when it next bumps the plugin (Rust fails
to compile until then, the Svelte `invoke` calls fail at run time). Calling
them without that state is `Error::UserNetworkConfigPathNotManaged` rather
than a panic. `set` now requests the restart (`request_restart`), so it
returns `Ok` to the UI and the app exits through `RunEvent::ExitRequested`
and `Exit` instead of skipping them.
- `tauri-plugin-hc` adds `dev_network_config(url)` and `dev_network_url!()` for
local dev networks: one `kitsune2-bootstrap-srv` as bootstrap server and iroh
relay, plain-HTTP relay allowed, and kitsune2's gossip `initiateBurstFactor`
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tauri-plugin-hc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ holochain = { workspace = true }
holochain_types = { workspace = true }
tokio = { workspace = true }
tempfile = { workspace = true }
toml = "0.9"
uuid = { workspace = true }
test-happ = { path = "../test-happ" }
# Self-dependency: what turns `test-utils` on for this crate's own test targets.
Expand Down
15 changes: 6 additions & 9 deletions crates/tauri-plugin-hc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ It is built on [`holochain-conductor-runtime`](../runtime) and exposes it throug

## Permissions

`hc:default` grants `allow-sign-zome-call` and `allow-app-request`. `sign_payload` is deliberately outside it: `sign_zome_call` signs the hash of a well-formed `ZomeCallParams`, so what it produces is only usable as the call it describes, while `sign_payload` signs caller-chosen bytes with no such domain separation. A capability has to name `hc:allow-sign-payload` itself.
`hc:default` grants `allow-sign-zome-call`, `allow-app-request`, `allow-get-user-network-config` and `allow-default-user-network-config`. `sign_payload` and `set_user_network_config` are deliberately outside it and a capability has to name `hc:allow-sign-payload` or `hc:allow-set-user-network-config` for the one window that needs them; [permissions/default.toml](permissions/default.toml) says why.

The plugin identifier `hc` is what goes in capability files and `plugin:hc|…` invokes. The webview-facing names are unchanged Holochain names rather than plugin names: the injected global is `__HC_TAURI_HOLOCHAIN__` (which `@holochain/client` looks for) and events use the `holochain://` scheme.

Expand Down Expand Up @@ -52,11 +52,6 @@ let paths = tauri_plugin_hc::app_paths(APP_ID, env!("CARGO_PKG_AUTHORS"))?;

tauri::Builder::default()
.manage(tauri_plugin_hc::UserNetworkConfigPath(paths.user_network_config.clone()))
.invoke_handler(tauri::generate_handler![
tauri_plugin_hc::get_user_network_config,
tauri_plugin_hc::default_user_network_config,
tauri_plugin_hc::set_user_network_config,
])
.plugin(tauri_plugin_hc::init(
vec_to_locked(vec![]),
HolochainPluginConfig::new(paths.holochain_dir.clone(), network_config(&paths)),
Expand All @@ -80,9 +75,11 @@ tauri::Builder::default()
a lock file, so several dev agents can run side by side. It rejects a data
directory too long for lair's socket (about 107 bytes).
- `UserNetworkConfig::apply_saved` overrides the app's bootstrap and relay URLs
with ones the user saved. `get_user_network_config`,
`default_user_network_config` and `set_user_network_config` are app commands
for a settings screen; setting restarts the app.
with ones the user saved. `plugin:hc|get_user_network_config`,
`plugin:hc|default_user_network_config` and `plugin:hc|set_user_network_config`
are plugin commands for a settings screen, reading and writing the file named
by the managed `UserNetworkConfigPath`; setting restarts the app and needs
`hc:allow-set-user-network-config` in the window's capability.
- `on_ready` runs startup work once the conductor is up, including when it came
up before `on_ready` was called, and only once.
- `Runtime::install_app_if_missing` installs and enables the hApp on first run
Expand Down
9 changes: 8 additions & 1 deletion crates/tauri-plugin-hc/build.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
const COMMANDS: &[&str] = &["sign_zome_call", "sign_payload", "app_request"];
const COMMANDS: &[&str] = &[
"sign_zome_call",
"sign_payload",
"app_request",
"get_user_network_config",
"default_user_network_config",
"set_user_network_config",
];

fn main() {
tauri_plugin::Builder::new(COMMANDS).build();
Expand Down
22 changes: 20 additions & 2 deletions crates/tauri-plugin-hc/permissions/default.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
[default]
description = "Default permissions for the in-process Holochain plugin"
permissions = ["allow-sign-zome-call", "allow-app-request"]
description = """Default permissions for the in-process Holochain plugin.

Included: signing zome calls, serving App API requests, and reading the saved
and default network settings.

Not included, and to be granted by name to the one window that needs them:

- `allow-sign-payload`: `sign_zome_call` signs the hash of a well-formed
`ZomeCallParams`, so what it produces is only usable as the call it describes;
`sign_payload` signs caller-chosen bytes with no such domain separation.
- `allow-set-user-network-config`: repoints the conductor's bootstrap and relay
servers and restarts the app, so any webview that can call it can move the
node onto servers of its choosing. Only the window with the settings screen
should hold it."""
permissions = [
"allow-sign-zome-call",
"allow-app-request",
"allow-get-user-network-config",
"allow-default-user-network-config",
]
5 changes: 5 additions & 0 deletions crates/tauri-plugin-hc/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ pub enum Error {
/// The saved user network settings could not be read or written.
#[error("user network settings error: {0}")]
UserNetworkConfig(String),

/// A user network command ran in an app that never registered a
/// [`crate::UserNetworkConfigPath`] with `.manage(..)`.
#[error("no UserNetworkConfigPath is managed; add `.manage(UserNetworkConfigPath(..))` to the app builder")]
UserNetworkConfigPathNotManaged,
}

impl Serialize for Error {
Expand Down
10 changes: 5 additions & 5 deletions crates/tauri-plugin-hc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ pub use dev_network::{dev_network_config, DEV_INITIATE_BURST_FACTOR};
pub use error::{Error, Result};
pub use paths::{app_paths, AppPaths, MAX_DEV_INSTANCES};
pub use ready::on_ready;
pub use user_network::{
default_user_network_config, get_user_network_config, set_user_network_config,
UserNetworkConfig, UserNetworkConfigPath,
};
pub use user_network::{UserNetworkConfig, UserNetworkConfigPath};

// Re-export the native config type consumers build, and the runtime itself.
pub use holochain::conductor::config::NetworkConfig;
Expand Down Expand Up @@ -590,7 +587,10 @@ fn plugin_builder<R: TauriRuntime>(
.invoke_handler(tauri::generate_handler![
commands::sign_zome_call,
commands::sign_payload,
commands::app_request
commands::app_request,
user_network::get_user_network_config,
user_network::default_user_network_config,
user_network::set_user_network_config,
])
.setup(move |app, _api| {
app.manage(HolochainPlugin {
Expand Down
161 changes: 140 additions & 21 deletions crates/tauri-plugin-hc/src/user_network.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
//! Network settings a user saves from the app's UI, applied on top of the
//! network config the app builds.
//!
//! The commands are ordinary app commands, not plugin commands, so the UI calls
//! them by their bare names (`invoke("get_user_network_config")`). Register them
//! with the file's location as managed state:
//! The commands are plugin commands, invoked as `plugin:hc|get_user_network_config`
//! and so on, and Tauri's ACL decides which windows may call them; which ones
//! `hc:default` includes and why is in `permissions/default.toml`.
//!
//! The app tells the plugin where the file lives by managing its path:
//!
//! ```ignore
//! tauri::Builder::default()
//! .manage(tauri_plugin_hc::UserNetworkConfigPath(paths.user_network_config.clone()))
//! .invoke_handler(tauri::generate_handler![
//! tauri_plugin_hc::get_user_network_config,
//! tauri_plugin_hc::default_user_network_config,
//! tauri_plugin_hc::set_user_network_config,
//! ])
//! ```

use crate::{Error, NetworkConfig, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime, State};
use tauri::{AppHandle, Manager, Runtime};
use url2::Url2;

/// Saved bootstrap and relay URLs. A field left `None` keeps the app's value.
Expand Down Expand Up @@ -78,17 +75,25 @@ fn file_error(path: &Path, e: impl std::fmt::Display) -> Error {
/// Managed state naming the file the user network commands read and write.
pub struct UserNetworkConfigPath(pub PathBuf);

/// The managed [`UserNetworkConfigPath`], or a clean error when the app never
/// registered one (a bare `State` argument would panic instead).
fn config_path<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf> {
app.try_state::<UserNetworkConfigPath>()
.map(|path| path.0.clone())
.ok_or(Error::UserNetworkConfigPathNotManaged)
}

/// The saved network settings, or `null` if none have been saved.
#[tauri::command]
pub fn get_user_network_config(
path: State<'_, UserNetworkConfigPath>,
) -> std::result::Result<Option<UserNetworkConfig>, String> {
UserNetworkConfig::read(&path.0).map_err(|e| e.to_string())
pub(crate) fn get_user_network_config<R: Runtime>(
app: AppHandle<R>,
) -> Result<Option<UserNetworkConfig>> {
UserNetworkConfig::read(&config_path(&app)?)
}

/// Holochain's default bootstrap and relay URLs, to prefill or reset the form.
#[tauri::command]
pub fn default_user_network_config() -> UserNetworkConfig {
pub(crate) fn default_user_network_config() -> UserNetworkConfig {
let defaults = NetworkConfig::default();
UserNetworkConfig {
bootstrap_url: Some(defaults.bootstrap_url),
Expand All @@ -97,21 +102,24 @@ pub fn default_user_network_config() -> UserNetworkConfig {
}

/// Save new bootstrap and relay URLs and restart the app so the conductor boots
/// with them.
/// with them. Outside `hc:default`; see `permissions/default.toml`.
///
/// The restart is requested rather than immediate (`restart` on the main thread,
/// where sync commands run, skips `RunEvent::ExitRequested` and `Exit`), so the
/// app shuts down through its normal exit path.
#[tauri::command]
pub fn set_user_network_config<R: Runtime>(
pub(crate) fn set_user_network_config<R: Runtime>(
app: AppHandle<R>,
path: State<'_, UserNetworkConfigPath>,
bootstrap_url: Url2,
relay_url: Url2,
) -> std::result::Result<(), String> {
) -> Result<()> {
UserNetworkConfig {
bootstrap_url: Some(bootstrap_url),
relay_url: Some(relay_url),
}
.write(&path.0)
.map_err(|e| e.to_string())?;
app.restart();
.write(&config_path(&app)?)?;
app.request_restart();
Ok(())
}

#[cfg(test)]
Expand Down Expand Up @@ -152,6 +160,117 @@ mod tests {
assert_eq!(network.bootstrap_url, before);
}

#[test]
fn commands_fail_cleanly_when_no_path_is_managed() {
use tauri::test::{mock_builder, mock_context, noop_assets};
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app builds");

let result = get_user_network_config(app.handle().clone());
assert!(
matches!(result, Err(Error::UserNetworkConfigPathNotManaged)),
"expected a managed-state error, got {result:?}"
);

// Fails before it would restart, so the command itself can be called.
let result = set_user_network_config(
app.handle().clone(),
Url2::parse("https://bootstrap.example.org"),
Url2::parse("https://relay.example.org"),
);
assert!(
matches!(result, Err(Error::UserNetworkConfigPathNotManaged)),
"expected a managed-state error, got {result:?}"
);
}

#[test]
fn set_saves_both_urls_then_requests_a_restart() {
use tauri::test::{mock_builder, mock_context, noop_assets};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("user-network-config.json");
let app = mock_builder()
.manage(UserNetworkConfigPath(path.clone()))
.build(mock_context(noop_assets()))
.expect("mock app builds");

// Tauri's mock runtime cannot exit: requesting the restart panics with
// `unimplemented!()` in its `request_exit`. That panic is the sign the
// command got as far as the restart, and it must come after the write.
let handle = app.handle().clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
set_user_network_config(
handle,
Url2::parse("https://bootstrap.example.org"),
Url2::parse("https://relay.example.org"),
)
}));
assert!(result.is_err(), "expected the command to request a restart");
assert_eq!(
get_user_network_config(app.handle().clone()).unwrap(),
Some(UserNetworkConfig {
bootstrap_url: Some(Url2::parse("https://bootstrap.example.org")),
relay_url: Some(Url2::parse("https://relay.example.org")),
})
);
}

#[test]
fn default_offers_holochains_urls() {
let defaults = NetworkConfig::default();
assert_eq!(
default_user_network_config(),
UserNetworkConfig {
bootstrap_url: Some(defaults.bootstrap_url),
relay_url: Some(defaults.relay_url),
}
);
}

#[test]
fn default_permissions_leave_out_set() {
#[derive(Deserialize)]
struct DefaultPermissions {
default: DefaultSet,
}
#[derive(Deserialize)]
struct DefaultSet {
permissions: Vec<String>,
}
let file: DefaultPermissions =
toml::from_str(include_str!("../permissions/default.toml")).unwrap();
let granted = file.default.permissions;
assert!(granted.contains(&"allow-get-user-network-config".to_string()));
assert!(granted.contains(&"allow-default-user-network-config".to_string()));
assert!(
!granted.contains(&"allow-set-user-network-config".to_string()),
"hc:default must not let every window repoint the network: {granted:?}"
);
}

#[test]
fn get_reads_the_managed_path() {
Comment thread
mattyg marked this conversation as resolved.
use tauri::test::{mock_builder, mock_context, noop_assets};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("user-network-config.json");
let app = mock_builder()
.manage(UserNetworkConfigPath(path.clone()))
.build(mock_context(noop_assets()))
.expect("mock app builds");

assert_eq!(get_user_network_config(app.handle().clone()).unwrap(), None);
let saved = UserNetworkConfig {
bootstrap_url: Some(Url2::parse("https://bootstrap.example.org")),
relay_url: None,
};
saved.write(&path).unwrap();
assert_eq!(
get_user_network_config(app.handle().clone()).unwrap(),
Some(saved)
);
}

#[test]
fn file_format_matches_what_the_ui_sends() {
let json = r#"{"bootstrapUrl":"https://b.example.org/","relayUrl":null}"#;
Expand Down
3 changes: 3 additions & 0 deletions packages/create-holochain-tauri/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Scripts:
`src-tauri/src/lib.rs` is the app's from then on: it is a short use of the plugin's
startup helpers (`app_paths`, `on_ready`, `install_app_if_missing`,
`dev_network_config`, `UserNetworkConfig`), and app-specific startup work goes there.
The main window's capability grants `hc:default`; a settings screen that changes the
bootstrap and relay servers also needs `hc:allow-set-user-network-config` (see the
plugin's `permissions/default.toml`).

`src-tauri/tauri.conf.json` sets a Content-Security-Policy that allows script
from the app itself only and connections to Tauri's IPC. A UI that loads remote
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,8 @@ pub fn run() {
.expect("Could not set up the app's data directory");

tauri::Builder::default()
// Where the plugin's `plugin:hc|*_user_network_config` commands read and write.
.manage(UserNetworkConfigPath(paths.user_network_config.clone()))
.invoke_handler(tauri::generate_handler![
tauri_plugin_hc::get_user_network_config,
tauri_plugin_hc::default_user_network_config,
tauri_plugin_hc::set_user_network_config,
])
.plugin(init(
vec_to_locked(vec![]),
HolochainPluginConfig::new(paths.holochain_dir.clone(), network_config(&paths)),
Expand Down Expand Up @@ -88,7 +84,7 @@ fn network_config(paths: &AppPaths) -> NetworkConfig {
NetworkConfig::default()
};

// Bootstrap and relay URLs saved through set_user_network_config take priority.
// Bootstrap and relay URLs saved through `plugin:hc|set_user_network_config` take priority.
UserNetworkConfig::apply_saved(&paths.user_network_config, &mut network_config);

network_config
Expand Down
Loading