release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
@@ -0,0 +1,723 @@
use std::fmt::Write as _;
use std::fs;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const RESOURCE_DIR_NAME: &str = "backend-runtime";
const INSTALL_DIR_NAME: &str = "managed-backend";
const BUNDLE_VERSION_FILE: &str = ".bundle-version";
const PERSISTENT_NAMES: &[&str] = &[".env", "data"];
const RELEASE_ATTESTATION_RELATIVE_PATH: &[&str] = &["data", "release_attestation.json"];
const GENERATED_SECRET_BYTES: usize = 32;
struct ManagedBackendSecrets {
admin_key: String,
}
struct ManagedSecretSpec {
key: &'static str,
min_len: usize,
}
struct ManagedBoolDefaultSpec {
key: &'static str,
default_value: bool,
preserve_non_default: bool,
}
pub struct ManagedBackendHandle {
child: Option<Child>,
base_url: String,
admin_key: String,
}
impl ManagedBackendHandle {
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn admin_key(&self) -> Option<&str> {
if self.admin_key.is_empty() {
None
} else {
Some(self.admin_key.as_str())
}
}
}
impl Drop for ManagedBackendHandle {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
pub fn bundled_backend_root(resource_dir: &Path) -> Option<PathBuf> {
let candidate = resource_dir.join(RESOURCE_DIR_NAME);
if candidate.join("main.py").exists() {
Some(candidate)
} else {
None
}
}
pub async fn ensure_and_start_managed_backend(
bundled_root: PathBuf,
app_local_data_dir: PathBuf,
desired_admin_key: Option<String>,
) -> Result<ManagedBackendHandle, String> {
let runtime_root = install_bundled_backend(&bundled_root, &app_local_data_dir)?;
let python_bin = resolve_python_bin(&runtime_root)?;
let port = reserve_loopback_port()?;
let base_url = format!("http://127.0.0.1:{port}");
let data_dir = runtime_root.join("data");
fs::create_dir_all(&data_dir).map_err(|e| format!("managed_backend_data_dir_failed:{e}"))?;
let secrets = ensure_env_file(&runtime_root, desired_admin_key)?;
let stdout_log = data_dir.join("backend_stdout.log");
let stderr_log = data_dir.join("backend_stderr.log");
let stdout = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&stdout_log)
.map_err(|e| format!("managed_backend_stdout_log_failed:{e}"))?;
let stderr = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&stderr_log)
.map_err(|e| format!("managed_backend_stderr_log_failed:{e}"))?;
let mut child = Command::new(&python_bin)
.current_dir(&runtime_root)
.arg("-m")
.arg("uvicorn")
.arg("main:app")
.arg("--host")
.arg("127.0.0.1")
.arg("--port")
.arg(port.to_string())
.arg("--timeout-keep-alive")
.arg("120")
.env("PYTHONUNBUFFERED", "1")
.env("SB_DATA_DIR", data_dir.as_os_str())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.map_err(|e| format!("managed_backend_spawn_failed:{e}"))?;
wait_for_backend_ready(&base_url, &mut child).await?;
Ok(ManagedBackendHandle {
child: Some(child),
base_url,
admin_key: secrets.admin_key,
})
}
fn install_bundled_backend(
bundled_root: &Path,
app_local_data_dir: &Path,
) -> Result<PathBuf, String> {
let install_root = app_local_data_dir.join(INSTALL_DIR_NAME);
let bundled_version = read_trimmed_file(&bundled_root.join(BUNDLE_VERSION_FILE))?;
let installed_version = read_trimmed_file_optional(&install_root.join(BUNDLE_VERSION_FILE));
let should_sync = !install_root.join("main.py").exists()
|| installed_version.as_deref() != Some(bundled_version.as_str());
if should_sync {
fs::create_dir_all(&install_root)
.map_err(|e| format!("managed_backend_install_dir_failed:{e}"))?;
sync_runtime_tree(bundled_root, &install_root)?;
fs::write(
install_root.join(BUNDLE_VERSION_FILE),
format!("{bundled_version}\n"),
)
.map_err(|e| format!("managed_backend_version_write_failed:{e}"))?;
}
fs::create_dir_all(install_root.join("data"))
.map_err(|e| format!("managed_backend_data_preserve_dir_failed:{e}"))?;
sync_release_attestation(bundled_root, &install_root)?;
Ok(install_root)
}
fn sync_runtime_tree(src: &Path, dst: &Path) -> Result<(), String> {
for entry in fs::read_dir(src).map_err(|e| format!("managed_backend_read_dir_failed:{e}"))? {
let entry = entry.map_err(|e| format!("managed_backend_dir_entry_failed:{e}"))?;
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if PERSISTENT_NAMES.contains(&file_name_str.as_ref()) {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&file_name);
let file_type = entry
.file_type()
.map_err(|e| format!("managed_backend_file_type_failed:{e}"))?;
if file_type.is_dir() {
fs::create_dir_all(&dst_path)
.map_err(|e| format!("managed_backend_mkdir_failed:{e}"))?;
sync_runtime_tree(&src_path, &dst_path)?;
} else {
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("managed_backend_parent_dir_failed:{e}"))?;
}
fs::copy(&src_path, &dst_path)
.map_err(|e| format!("managed_backend_copy_failed:{e}"))?;
}
}
Ok(())
}
fn sync_release_attestation(bundled_root: &Path, install_root: &Path) -> Result<(), String> {
let bundled_path = release_attestation_path(bundled_root);
let installed_path = release_attestation_path(install_root);
if !bundled_path.exists() {
return Ok(());
}
if let Some(parent) = installed_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("managed_backend_attestation_dir_failed:{e}"))?;
}
fs::copy(&bundled_path, &installed_path)
.map_err(|e| format!("managed_backend_attestation_copy_failed:{e}"))?;
Ok(())
}
fn release_attestation_path(root: &Path) -> PathBuf {
RELEASE_ATTESTATION_RELATIVE_PATH
.iter()
.fold(root.to_path_buf(), |acc, part| acc.join(part))
}
fn ensure_env_file(
runtime_root: &Path,
desired_admin_key: Option<String>,
) -> Result<ManagedBackendSecrets, String> {
let env_path = runtime_root.join(".env");
if env_path.exists() {
return seed_managed_env(&env_path, desired_admin_key);
}
let example_path = runtime_root.join(".env.example");
if example_path.exists() {
fs::copy(&example_path, &env_path)
.map_err(|e| format!("managed_backend_env_copy_failed:{e}"))?;
} else {
fs::write(&env_path, b"").map_err(|e| format!("managed_backend_env_create_failed:{e}"))?;
}
seed_managed_env(&env_path, desired_admin_key)
}
fn seed_managed_env(
env_path: &Path,
desired_admin_key: Option<String>,
) -> Result<ManagedBackendSecrets, String> {
let mut lines: Vec<String> = fs::read_to_string(env_path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect();
let mut modified = false;
let mut resolved_admin_key = String::new();
for spec in managed_secret_specs() {
let override_value = if spec.key == "ADMIN_KEY" {
desired_admin_key.as_deref()
} else {
None
};
let mut found = false;
for line in &mut lines {
if let Some(current) = parse_env_value(line, spec.key) {
found = true;
if let Some(forced) = override_value {
if current != forced {
*line = format!("{}={}", spec.key, forced);
modified = true;
}
if spec.key == "ADMIN_KEY" {
resolved_admin_key = forced.to_string();
}
} else if is_invalid_secret_value(current, spec.min_len) {
let generated = generate_secret()?;
*line = format!("{}={}", spec.key, generated);
modified = true;
if spec.key == "ADMIN_KEY" {
resolved_admin_key = generated;
}
} else if spec.key == "ADMIN_KEY" {
resolved_admin_key = current.to_string();
}
break;
}
}
if !found {
let value = if let Some(forced) = override_value {
forced.to_string()
} else {
generate_secret()?
};
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
lines.push(String::new());
}
lines.push(format!("{}={}", spec.key, value));
modified = true;
if spec.key == "ADMIN_KEY" {
resolved_admin_key = value;
}
}
}
for spec in managed_bool_default_specs() {
let mut found = false;
for line in &mut lines {
if let Some(current) = parse_env_value(line, spec.key) {
found = true;
match parse_env_boolish(current) {
Some(parsed) if spec.preserve_non_default || parsed == spec.default_value => {}
_ => {
*line = format!("{}={}", spec.key, render_env_bool(spec.default_value));
modified = true;
}
}
break;
}
}
if !found {
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
lines.push(String::new());
}
lines.push(format!(
"{}={}",
spec.key,
render_env_bool(spec.default_value)
));
modified = true;
}
}
if modified {
let mut rendered = lines.join("\n");
if !rendered.ends_with('\n') {
rendered.push('\n');
}
fs::write(env_path, rendered)
.map_err(|e| format!("managed_backend_env_seed_failed:{e}"))?;
}
Ok(ManagedBackendSecrets {
admin_key: resolved_admin_key,
})
}
fn managed_secret_specs() -> Vec<ManagedSecretSpec> {
let mut specs = vec![
ManagedSecretSpec {
key: "ADMIN_KEY",
min_len: 32,
},
ManagedSecretSpec {
key: "MESH_PEER_PUSH_SECRET",
min_len: 16,
},
ManagedSecretSpec {
key: "MESH_DM_TOKEN_PEPPER",
min_len: 16,
},
];
if !cfg!(target_os = "windows") {
specs.push(ManagedSecretSpec {
key: "MESH_SECURE_STORAGE_SECRET",
min_len: 16,
});
}
specs
}
fn managed_bool_default_specs() -> Vec<ManagedBoolDefaultSpec> {
vec![
ManagedBoolDefaultSpec {
key: "MESH_BLOCK_LEGACY_NODE_ID_COMPAT",
default_value: true,
preserve_non_default: false,
},
ManagedBoolDefaultSpec {
key: "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP",
default_value: true,
preserve_non_default: true,
},
]
}
fn parse_env_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let normalized = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let (line_key, raw_value) = normalized.split_once('=')?;
if line_key.trim() != key {
return None;
}
Some(raw_value.trim().trim_matches('"').trim_matches('\'').trim())
}
fn parse_env_boolish(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn render_env_bool(value: bool) -> &'static str {
if value {
"true"
} else {
"false"
}
}
fn is_invalid_secret_value(value: &str, min_len: usize) -> bool {
let raw = value.trim();
let lowered = raw.to_ascii_lowercase();
raw.is_empty() || lowered == "change-me" || lowered == "changeme" || raw.len() < min_len
}
fn generate_secret() -> Result<String, String> {
let mut bytes = [0u8; GENERATED_SECRET_BYTES];
getrandom::getrandom(&mut bytes)
.map_err(|e| format!("managed_backend_secret_rng_failed:{e}"))?;
let mut out = String::with_capacity(GENERATED_SECRET_BYTES * 2);
for byte in bytes {
let _ = write!(&mut out, "{byte:02x}");
}
Ok(out)
}
fn reserve_loopback_port() -> Result<u16, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("managed_backend_port_bind_failed:{e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("managed_backend_port_addr_failed:{e}"))?
.port();
drop(listener);
Ok(port)
}
fn resolve_python_bin(runtime_root: &Path) -> Result<PathBuf, String> {
let selected_venv = read_trimmed_file_optional(&runtime_root.join(".venv-dir"))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "venv".to_string());
let mut candidate_roots = vec![runtime_root.join(&selected_venv)];
if selected_venv != "venv" {
candidate_roots.push(runtime_root.join("venv"));
}
let candidates = if cfg!(target_os = "windows") {
candidate_roots
.into_iter()
.map(|root| root.join("Scripts").join("python.exe"))
.collect::<Vec<_>>()
} else {
candidate_roots
.into_iter()
.flat_map(|root| {
[
root.join("bin").join("python3"),
root.join("bin").join("python"),
]
})
.collect::<Vec<_>>()
};
for candidate in candidates {
if candidate.exists() {
return Ok(candidate);
}
}
Err("managed_backend_python_missing".to_string())
}
async fn wait_for_backend_ready(base_url: &str, child: &mut Child) -> Result<(), String> {
let client = reqwest::Client::new();
let deadline = Instant::now() + Duration::from_secs(45);
let health_url = format!("{base_url}/api/health");
while Instant::now() < deadline {
if let Some(status) = child
.try_wait()
.map_err(|e| format!("managed_backend_wait_failed:{e}"))?
{
return Err(format!("managed_backend_exited_early:{status}"));
}
if let Ok(response) = client.get(&health_url).send().await {
if response.status().is_success() {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
let _ = child.kill();
let _ = child.wait();
Err("managed_backend_health_timeout".to_string())
}
fn read_trimmed_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path)
.map(|s| s.trim().to_string())
.map_err(|e| format!("managed_backend_version_read_failed:{e}"))
}
fn read_trimmed_file_optional(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_backend_root_requires_main_py() {
let temp = std::env::temp_dir().join(format!(
"sb_backend_root_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let resource_dir = temp.join("resources");
let backend_dir = resource_dir.join(RESOURCE_DIR_NAME);
fs::create_dir_all(&backend_dir).unwrap();
assert!(bundled_backend_root(&resource_dir).is_none());
fs::write(backend_dir.join("main.py"), "print('ok')").unwrap();
assert_eq!(
bundled_backend_root(&resource_dir),
Some(backend_dir.clone())
);
let _ = fs::remove_dir_all(temp);
}
#[test]
fn sync_runtime_tree_preserves_env_and_data() {
let temp = std::env::temp_dir().join(format!(
"sb_backend_sync_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let src = temp.join("src");
let dst = temp.join("dst");
fs::create_dir_all(src.join("config")).unwrap();
fs::create_dir_all(dst.join("data")).unwrap();
fs::write(src.join("main.py"), "print('new')").unwrap();
fs::write(src.join(".env.example"), "ADMIN_KEY=").unwrap();
fs::write(dst.join(".env"), "preserve_me").unwrap();
fs::write(dst.join("data").join("keep.txt"), "keep").unwrap();
sync_runtime_tree(&src, &dst).unwrap();
assert_eq!(fs::read_to_string(dst.join(".env")).unwrap(), "preserve_me");
assert_eq!(
fs::read_to_string(dst.join("data").join("keep.txt")).unwrap(),
"keep"
);
assert_eq!(
fs::read_to_string(dst.join("main.py")).unwrap(),
"print('new')"
);
let _ = fs::remove_dir_all(temp);
}
#[test]
fn sync_release_attestation_updates_only_attestation_file() {
let temp = std::env::temp_dir().join(format!(
"sb_backend_attestation_sync_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let src = temp.join("src");
let dst = temp.join("dst");
fs::create_dir_all(src.join("data")).unwrap();
fs::create_dir_all(dst.join("data")).unwrap();
fs::write(release_attestation_path(&src), "{\"commit\":\"new\"}\n").unwrap();
fs::write(release_attestation_path(&dst), "{\"commit\":\"old\"}\n").unwrap();
fs::write(dst.join("data").join("keep.txt"), "keep").unwrap();
sync_release_attestation(&src, &dst).unwrap();
assert_eq!(
fs::read_to_string(release_attestation_path(&dst)).unwrap(),
"{\"commit\":\"new\"}\n"
);
assert_eq!(
fs::read_to_string(dst.join("data").join("keep.txt")).unwrap(),
"keep"
);
let _ = fs::remove_dir_all(temp);
}
#[test]
fn ensure_env_file_generates_required_managed_secrets() {
let temp = std::env::temp_dir().join(format!(
"sb_backend_env_seed_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&temp).unwrap();
fs::write(temp.join(".env.example"), "AIS_API_KEY=\n").unwrap();
let secrets = ensure_env_file(&temp, None).unwrap();
let env_text = fs::read_to_string(temp.join(".env")).unwrap();
let env_lines: Vec<&str> = env_text.lines().collect();
assert!(secrets.admin_key.len() >= 32);
assert!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "ADMIN_KEY"))
.unwrap()
.len()
>= 32
);
assert!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_PEER_PUSH_SECRET"))
.unwrap()
.len()
>= 16
);
assert!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_DM_TOKEN_PEPPER"))
.unwrap()
.len()
>= 16
);
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_NODE_ID_COMPAT"))
.unwrap(),
"true"
);
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP"))
.unwrap(),
"true"
);
if cfg!(target_os = "windows") {
assert!(env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_SECURE_STORAGE_SECRET"))
.is_none());
} else {
assert!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_SECURE_STORAGE_SECRET"))
.unwrap()
.len()
>= 16
);
}
let _ = fs::remove_dir_all(temp);
}
#[test]
fn ensure_env_file_replaces_invalid_values_and_preserves_valid_ones() {
let temp = std::env::temp_dir().join(format!(
"sb_backend_env_backfill_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&temp).unwrap();
fs::write(
temp.join(".env"),
"ADMIN_KEY=short\nMESH_PEER_PUSH_SECRET=change-me\nMESH_DM_TOKEN_PEPPER=valid-pepper-value-1234\nMESH_BLOCK_LEGACY_NODE_ID_COMPAT=false\nMESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=\n",
)
.unwrap();
let secrets = ensure_env_file(
&temp,
Some("desktop-admin-key-0123456789abcdef".to_string()),
)
.unwrap();
let env_text = fs::read_to_string(temp.join(".env")).unwrap();
let env_lines: Vec<&str> = env_text.lines().collect();
assert_eq!(secrets.admin_key, "desktop-admin-key-0123456789abcdef");
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "ADMIN_KEY"))
.unwrap(),
"desktop-admin-key-0123456789abcdef"
);
assert_ne!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_PEER_PUSH_SECRET"))
.unwrap(),
"change-me"
);
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_DM_TOKEN_PEPPER"))
.unwrap(),
"valid-pepper-value-1234"
);
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_NODE_ID_COMPAT"))
.unwrap(),
"true"
);
assert_eq!(
env_lines
.iter()
.find_map(|line| parse_env_value(line, "MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP"))
.unwrap(),
"true"
);
let _ = fs::remove_dir_all(temp);
}
}
@@ -1,19 +1,73 @@
use serde_json::Value;
use tauri::State;
use crate::{handlers::dispatch_control_command, DesktopAppState};
use crate::handlers::dispatch_control_command;
use crate::policy::{self, PolicyOutcome};
use crate::{DesktopAppState, NativeGateCryptoState};
#[tauri::command]
pub async fn invoke_local_control(
command: String,
payload: Option<Value>,
meta: Option<Value>,
state: State<'_, DesktopAppState>,
gate_crypto_state: State<'_, NativeGateCryptoState>,
) -> Result<Value, String> {
// Enforce policy on the Rust side — this runs even if webview JS is
// bypassed and invoke_local_control is called directly via Tauri IPC.
match policy::enforce(&command, &payload, &meta) {
PolicyOutcome::Allowed(entry) => {
if let Ok(mut ring) = state.audit_ring.lock() {
ring.record(entry);
}
}
PolicyOutcome::ProfileWarn(entry) => {
// Profile mismatch but not enforced — log warning, allow dispatch
eprintln!(
"native_control_profile_warn: command={} profile={:?} cap={}",
entry.command, entry.session_profile, entry.expected_capability
);
if let Ok(mut ring) = state.audit_ring.lock() {
ring.record(entry);
}
}
PolicyOutcome::Denied(entry, message) => {
if let Ok(mut ring) = state.audit_ring.lock() {
ring.record(entry);
}
return Err(message);
}
}
dispatch_control_command(
&state.backend_base_url,
state.admin_key.as_deref(),
&command,
payload,
&gate_crypto_state,
)
.await
}
#[tauri::command]
pub fn get_native_audit_report(
limit: Option<usize>,
state: State<'_, DesktopAppState>,
) -> Result<Value, String> {
let ring = state
.audit_ring
.lock()
.map_err(|e| format!("audit_lock_failed:{e}"))?;
let report = ring.snapshot(limit.unwrap_or(25));
serde_json::to_value(report).map_err(|e| format!("audit_serialize_failed:{e}"))
}
#[tauri::command]
pub fn clear_native_audit_report(state: State<'_, DesktopAppState>) -> Result<(), String> {
let mut ring = state
.audit_ring
.lock()
.map_err(|e| format!("audit_lock_failed:{e}"))?;
ring.clear();
Ok(())
}
@@ -0,0 +1,396 @@
//! Optional localhost/browser companion mode.
//!
//! When explicitly enabled by the user, allows opening the frontend in the
//! system browser on a loopback-only URL. The browser session does **not**
//! receive the native desktop control boundary (`window.__SHADOWBROKER_DESKTOP__`)
//! and therefore cannot invoke any of the 27 native-control commands. It
//! operates at materially reduced trust compared with the native window.
//!
//! **Important honesty note:**
//! The browser companion session in packaged mode does **not** have the same
//! capabilities as standalone browser mode (i.e. `npm run dev` + a real
//! Next.js server). The built-in loopback server is a thin static + API
//! proxy — it does NOT reproduce Next.js middleware, the catch-all `/api/*`
//! route's admin session cookie logic, the wormhole routing logic, or the
//! sensitive-path `X-Admin-Key` injection. Admin-gated backend endpoints
//! (settings, wormhole lifecycle, gate operations, system update) are
//! **not reachable** from the browser companion.
//!
//! **Ownership model (post-P6D-R):**
//! In packaged mode the loopback server is started at app launch by
//! `main.rs` (not by `companion_enable`) so that the Tauri main window also
//! uses it as its HTTP origin. Companion state simply tracks whether the
//! browser opener is enabled and what URL to hand out. Server lifecycle is
//! owned by the app, not by this module.
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
// ---------------------------------------------------------------------------
// Warning text
// ---------------------------------------------------------------------------
/// Warning shown to users when enabling or querying companion mode.
///
/// Honest about what the browser session cannot do. Does NOT claim parity
/// with standalone browser mode, because the built-in loopback server is a
/// thin proxy and does not reproduce Next.js middleware or admin session
/// handling.
pub const COMPANION_WARNING: &str = "\
Browser companion mode opens the app in your default browser on localhost. \
This is less secure than the native desktop window: browser extensions, \
shared cookies, and local processes can interact with the page. The browser \
session does NOT receive native desktop control privileges and cannot use \
admin-gated APIs (settings, wormhole lifecycle, gate operations, system \
update). In packaged builds, only public data endpoints are reachable from \
the browser session — it is not equivalent to standalone browser mode. \
Use the native window for any sensitive or admin-gated operations.";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Serializable status returned by companion commands.
#[derive(Debug, Clone, Serialize)]
pub struct CompanionStatus {
pub enabled: bool,
pub url: Option<String>,
pub warning: &'static str,
}
/// Companion mode state. Disabled by default.
///
/// This module does NOT own the loopback server lifecycle. In packaged mode
/// the server is started at app launch (see `main.rs`) and its URL is
/// registered here via `set_app_server_url`. Companion mode then uses that
/// shared URL when the user enables the browser opener.
pub struct CompanionState {
enabled: bool,
/// Default frontend URL (from `SHADOWBROKER_FRONTEND_URL` or the
/// `http://127.0.0.1:3000` fallback used in dev mode).
default_frontend_url: String,
/// Whether `SHADOWBROKER_FRONTEND_URL` was explicitly set by the user.
/// When true the default URL is honored even in packaged builds
/// (explicit override beats built-in server).
frontend_url_explicit: bool,
/// URL of the app-level loopback server, set by `main.rs` at startup
/// when packaged assets are available and no explicit URL override is
/// active. `None` in dev mode or when no bundled assets were found.
app_server_url: Option<String>,
/// Path to bundled frontend assets (informational; server lifecycle
/// is owned by `main.rs`). Set during setup when the resource
/// directory contains `companion-www/index.html`.
www_root: Option<PathBuf>,
}
pub type SharedCompanionState = Mutex<CompanionState>;
/// Create initial companion state. Called from `main()`.
pub fn new_companion_state(
default_frontend_url: String,
frontend_url_explicit: bool,
) -> SharedCompanionState {
Mutex::new(CompanionState {
enabled: false,
default_frontend_url,
frontend_url_explicit,
app_server_url: None,
www_root: None,
})
}
impl CompanionState {
fn status(&self) -> CompanionStatus {
CompanionStatus {
enabled: self.enabled,
url: if self.enabled {
Some(self.effective_url())
} else {
None
},
warning: COMPANION_WARNING,
}
}
/// Resolve the URL the browser should open.
///
/// Packaged mode with server running (no explicit URL override): use the
/// app-level loopback server URL. Otherwise fall back to the configured
/// default frontend URL (dev mode or explicit override).
fn effective_url(&self) -> String {
if !self.frontend_url_explicit {
if let Some(url) = self.app_server_url.as_deref() {
return url.to_string();
}
}
self.default_frontend_url.clone()
}
/// Whether this companion state will route through the built-in
/// loopback server (packaged mode without explicit override).
#[cfg_attr(not(test), allow(dead_code))]
pub fn uses_builtin_server(&self) -> bool {
!self.frontend_url_explicit && self.app_server_url.is_some()
}
/// Set the URL of the app-level loopback server. Called from `main.rs`
/// setup once the server has successfully bound.
pub fn set_app_server_url(&mut self, url: String) {
self.app_server_url = Some(url);
}
/// Record the bundled frontend asset path (packaged build indicator).
pub fn set_www_root(&mut self, path: PathBuf) {
self.www_root = Some(path);
}
}
// ---------------------------------------------------------------------------
// Loopback validation
// ---------------------------------------------------------------------------
/// Check whether a URL string points to a loopback address.
/// Only `127.0.0.1`, `localhost`, and `::1` (including bracketed `[::1]`)
/// are considered loopback. `0.0.0.0`, LAN IPs, and public hosts are rejected.
pub fn is_loopback_origin(url: &str) -> bool {
let after_scheme = match url.split_once("://") {
Some((_, rest)) => rest,
None => return false,
};
let host_port = after_scheme.split('/').next().unwrap_or("");
let host = if host_port.starts_with('[') {
// IPv6: [::1]:port
host_port
.split(']')
.next()
.unwrap_or("")
.trim_start_matches('[')
} else {
host_port.split(':').next().unwrap_or("")
};
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Query companion mode status.
#[tauri::command]
pub fn companion_status(state: State<'_, SharedCompanionState>) -> Result<CompanionStatus, String> {
let cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
Ok(cs.status())
}
/// Enable companion mode.
///
/// In packaged mode, uses the already-running app loopback server URL.
/// In dev mode / explicit override, uses the configured frontend URL.
/// Either way, validates the URL is loopback-only before enabling.
#[tauri::command]
pub fn companion_enable(state: State<'_, SharedCompanionState>) -> Result<CompanionStatus, String> {
let mut cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
if cs.enabled {
return Ok(cs.status());
}
let url = cs.effective_url();
if !is_loopback_origin(&url) {
return Err(format!(
"companion_not_loopback: frontend origin '{url}' is not a loopback address"
));
}
cs.enabled = true;
Ok(cs.status())
}
/// Disable companion mode. Does not affect the app-level loopback server
/// (which remains running for the native main window).
#[tauri::command]
pub fn companion_disable(
state: State<'_, SharedCompanionState>,
) -> Result<CompanionStatus, String> {
let mut cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
cs.enabled = false;
Ok(cs.status())
}
/// Open the frontend in the system browser. Only works when companion mode
/// is enabled and the URL is loopback-only.
#[tauri::command]
pub fn companion_open_browser(
state: State<'_, SharedCompanionState>,
) -> Result<CompanionStatus, String> {
let cs = state.lock().map_err(|e| format!("companion_lock:{e}"))?;
if !cs.enabled {
return Err(
"companion_not_enabled: enable companion mode before opening in browser".to_string(),
);
}
let url = cs.effective_url();
// Defense in depth: re-verify loopback before launching the browser.
if !is_loopback_origin(&url) {
return Err(format!(
"companion_not_loopback: refusing to open non-loopback origin '{url}'"
));
}
let status = cs.status();
drop(cs); // release lock before launching browser
open::that(&url).map_err(|e| format!("companion_open_failed:{e}"))?;
Ok(status)
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- Loopback validation --
#[test]
fn loopback_127_0_0_1() {
assert!(is_loopback_origin("http://127.0.0.1:3000"));
assert!(is_loopback_origin("http://127.0.0.1"));
assert!(is_loopback_origin("https://127.0.0.1:8443/path"));
}
#[test]
fn loopback_localhost() {
assert!(is_loopback_origin("http://localhost:3000"));
assert!(is_loopback_origin("http://localhost"));
assert!(is_loopback_origin("https://localhost:8443/path"));
}
#[test]
fn loopback_ipv6() {
assert!(is_loopback_origin("http://[::1]:3000"));
assert!(is_loopback_origin("http://[::1]"));
}
#[test]
fn rejects_non_loopback() {
assert!(!is_loopback_origin("http://0.0.0.0:3000"));
assert!(!is_loopback_origin("http://192.168.1.1:3000"));
assert!(!is_loopback_origin("http://example.com"));
assert!(!is_loopback_origin("https://10.0.0.1:8443"));
}
#[test]
fn rejects_empty_and_malformed() {
assert!(!is_loopback_origin(""));
assert!(!is_loopback_origin("not-a-url"));
assert!(!is_loopback_origin("://127.0.0.1"));
}
// -- Companion state --
#[test]
fn disabled_by_default() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
let cs = state.lock().unwrap();
let status = cs.status();
assert!(!status.enabled);
assert!(status.url.is_none());
}
#[test]
fn status_includes_honest_warning() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
let cs = state.lock().unwrap();
let warning = cs.status().warning;
assert!(!warning.is_empty());
assert!(
warning.contains("less secure"),
"warning should mention reduced trust"
);
assert!(
warning.contains("native desktop window"),
"warning should reference the native window"
);
assert!(
warning.contains("admin-gated"),
"warning must name the specific capabilities browser companion lacks"
);
assert!(
warning.contains("not equivalent to standalone browser mode"),
"warning must NOT imply standalone browser parity"
);
}
#[test]
fn url_hidden_when_disabled() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
let cs = state.lock().unwrap();
assert!(cs.status().url.is_none(), "URL must not leak when disabled");
}
// -- Mode detection: effective URL resolution --
#[test]
fn dev_mode_uses_default_url() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
let cs = state.lock().unwrap();
assert_eq!(cs.effective_url(), "http://127.0.0.1:3000");
assert!(!cs.uses_builtin_server());
}
#[test]
fn packaged_mode_prefers_app_server_url() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
{
let mut cs = state.lock().unwrap();
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
}
let cs = state.lock().unwrap();
assert_eq!(cs.effective_url(), "http://127.0.0.1:54321");
assert!(cs.uses_builtin_server());
}
#[test]
fn explicit_override_beats_app_server() {
let state = new_companion_state("http://127.0.0.1:4000".to_string(), true);
{
let mut cs = state.lock().unwrap();
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
}
let cs = state.lock().unwrap();
assert_eq!(
cs.effective_url(),
"http://127.0.0.1:4000",
"explicit SHADOWBROKER_FRONTEND_URL must beat the built-in server URL"
);
assert!(!cs.uses_builtin_server());
}
#[test]
fn enable_returns_url_reflecting_mode() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
{
let mut cs = state.lock().unwrap();
cs.set_app_server_url("http://127.0.0.1:54321".to_string());
cs.enabled = true;
}
let cs = state.lock().unwrap();
assert_eq!(
cs.status().url,
Some("http://127.0.0.1:54321".to_string()),
"enabled status URL should reflect the app server URL in packaged mode"
);
}
#[test]
fn set_www_root_records_path() {
let state = new_companion_state("http://127.0.0.1:3000".to_string(), false);
let mut cs = state.lock().unwrap();
cs.set_www_root(PathBuf::from("/tmp/companion-www"));
assert!(cs.www_root.is_some());
}
}
@@ -0,0 +1,306 @@
//! Loopback-only HTTP server for packaged desktop builds.
//!
//! Serves the bundled frontend static assets on `127.0.0.1` (dynamic port)
//! and proxies `/api/*` requests to the backend. The proxy does **not** inject
//! `X-Admin-Key` and does **not** reproduce the Next.js catch-all route's
//! admin session cookie logic, wormhole routing, or sensitive-path handling.
//! It is intentionally a thin loopback shim, not a Next.js replacement.
//!
//! **Dual role (post-P6D-R):**
//! 1. Origin of the packaged Tauri main window — same-origin `/api/*` gives
//! the main window a working HTTP path for ordinary non-privileged data
//! fetches. Privileged (27-command) paths still go through the Rust IPC
//! control boundary with its own admin key ownership and policy
//! enforcement — they do NOT traverse this server.
//! 2. Origin for the optional browser companion opener. Browser sessions
//! have materially reduced trust compared to standalone browser mode:
//! no admin session cookies, no admin-gated backend endpoints, no
//! Next.js middleware. Only public data endpoints are reachable.
//!
//! **Not used in dev mode** — when `SHADOWBROKER_FRONTEND_URL` is explicitly
//! set, or when no bundled frontend assets exist, this server is not started.
//! In those cases the main window and companion both fall back to the
//! configured external URL (a running Next.js dev server).
use axum::{
extract::State,
http::{HeaderMap, Method, StatusCode, Uri},
response::IntoResponse,
routing::any,
Router,
};
use bytes::Bytes;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::services::{ServeDir, ServeFile};
// ---------------------------------------------------------------------------
// Server state
// ---------------------------------------------------------------------------
struct ServerState {
backend_url: String,
client: reqwest::Client,
}
// ---------------------------------------------------------------------------
// Header stripping
// ---------------------------------------------------------------------------
/// Headers stripped from proxied requests (hop-by-hop + security-sensitive).
/// `x-admin-key` is stripped intentionally — browser companion is reduced trust.
const STRIP_REQ: &[&str] = &[
"host",
"connection",
"transfer-encoding",
"x-admin-key",
"keep-alive",
"proxy-authorization",
"te",
"trailers",
"upgrade",
];
/// Headers stripped from proxied responses.
const STRIP_RESP: &[&str] = &[
"connection",
"transfer-encoding",
"content-encoding",
"content-length",
"keep-alive",
"te",
"trailers",
"upgrade",
];
// ---------------------------------------------------------------------------
// API proxy handler
// ---------------------------------------------------------------------------
/// Proxy `/api/*` to the backend without `X-Admin-Key` (reduced trust).
///
/// Forwards the request method, safe headers, and body to the backend.
/// The response is returned verbatim (minus hop-by-hop headers).
async fn api_proxy(
State(state): State<Arc<ServerState>>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
let target = format!("{}{}", state.backend_url, path_and_query);
let req_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
let mut builder = state.client.request(req_method.clone(), &target);
// Forward headers, stripping hop-by-hop and security-sensitive ones.
for (key, value) in &headers {
let name = key.as_str().to_lowercase();
if !STRIP_REQ.contains(&name.as_str()) {
if let Ok(val) = value.to_str() {
builder = builder.header(key.as_str(), val);
}
}
}
// Forward body for non-GET/HEAD methods.
let is_bodyless = req_method == reqwest::Method::GET || req_method == reqwest::Method::HEAD;
if !is_bodyless && !body.is_empty() {
builder = builder.body(body);
}
match builder.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let upstream_headers = resp.headers().clone();
let resp_bytes = resp.bytes().await.unwrap_or_default();
let mut response = axum::response::Response::builder().status(status);
for (key, value) in upstream_headers.iter() {
let name = key.as_str().to_lowercase();
if !STRIP_RESP.contains(&name.as_str()) {
response = response.header(key, value);
}
}
match response.body(axum::body::Body::from(resp_bytes)) {
Ok(r) => r.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
Err(_) => (
StatusCode::BAD_GATEWAY,
[("content-type", "application/json")],
"{\"error\":\"Backend unavailable\"}",
)
.into_response(),
}
}
// ---------------------------------------------------------------------------
// Server handle
// ---------------------------------------------------------------------------
/// Handle to a running companion server.
///
/// Dropping the handle gracefully shuts down the server.
pub struct CompanionServerHandle {
addr: SocketAddr,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl CompanionServerHandle {
/// The loopback URL browsers should open.
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.addr.port())
}
/// Gracefully stop the server.
pub fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
impl Drop for CompanionServerHandle {
fn drop(&mut self) {
self.shutdown();
}
}
// ---------------------------------------------------------------------------
// Server startup
// ---------------------------------------------------------------------------
/// Start the companion loopback server.
///
/// Binds to `127.0.0.1:0` (OS-assigned port), serves static frontend files
/// from `www_root`, and proxies `/api/*` to `backend_url` without admin key.
///
/// Static file serving uses an index.html SPA fallback: requests that don't
/// match a static file are served the root `index.html`, letting Next.js
/// client-side routing handle the path.
pub async fn start_companion_server(
www_root: PathBuf,
backend_url: String,
) -> Result<CompanionServerHandle, String> {
let state = Arc::new(ServerState {
backend_url,
client: reqwest::Client::new(),
});
// Static file serving with SPA fallback to index.html.
let index_fallback = www_root.join("index.html");
let serve = ServeDir::new(&www_root)
.append_index_html_on_directories(true)
.not_found_service(ServeFile::new(index_fallback));
let app = Router::new()
.route("/api/*rest", any(api_proxy))
.with_state(state)
.fallback_service(serve);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("companion_bind_failed:{e}"))?;
let addr = listener
.local_addr()
.map_err(|e| format!("companion_addr_failed:{e}"))?;
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.ok();
});
Ok(CompanionServerHandle {
addr,
shutdown_tx: Some(shutdown_tx),
})
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_handle_url_format() {
let handle = CompanionServerHandle {
addr: "127.0.0.1:12345".parse().unwrap(),
shutdown_tx: None,
};
assert_eq!(handle.url(), "http://127.0.0.1:12345");
}
#[test]
fn strip_lists_include_admin_key() {
assert!(
STRIP_REQ.contains(&"x-admin-key"),
"proxy must strip X-Admin-Key from requests (reduced trust)"
);
}
#[test]
fn strip_lists_include_hop_by_hop() {
for header in &["connection", "transfer-encoding", "keep-alive"] {
assert!(
STRIP_REQ.contains(header),
"should strip {header} from requests"
);
assert!(
STRIP_RESP.contains(header),
"should strip {header} from responses"
);
}
}
#[tokio::test]
async fn binds_to_loopback() {
let tmp = std::env::temp_dir().join("sb_companion_server_test");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("index.html"), "<html></html>").unwrap();
let handle = start_companion_server(tmp.clone(), "http://127.0.0.1:9999".to_string())
.await
.expect("server should start");
assert!(handle.addr.ip().is_loopback(), "must bind to loopback");
assert_ne!(handle.addr.port(), 0, "port should be assigned");
assert!(handle.url().starts_with("http://127.0.0.1:"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn shutdown_is_idempotent() {
let tmp = std::env::temp_dir().join("sb_companion_shutdown_test");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("index.html"), "<html></html>").unwrap();
let mut handle = start_companion_server(tmp.clone(), "http://127.0.0.1:9999".to_string())
.await
.expect("server should start");
// First shutdown
handle.shutdown();
// Second shutdown is safe (idempotent)
handle.shutdown();
let _ = std::fs::remove_dir_all(&tmp);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,57 +1,418 @@
use reqwest::Method;
use serde_json::Value;
use crate::gate_crypto;
use crate::http_client::call_backend_json;
use crate::NativeGateCryptoState;
fn extract_gate_id(payload: &Option<Value>) -> Result<String, String> {
payload
.as_ref()
.and_then(|v| v.get("gate_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| urlencoding::encode(s).into_owned())
.ok_or_else(|| "missing_or_empty_gate_id".to_string())
}
fn payload_gate_id(payload: &Option<Value>) -> Option<String> {
payload
.as_ref()
.and_then(|v| v.get("gate_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
fn command_expects_gate_authority_change(command: &str) -> bool {
matches!(
command,
"wormhole.gate.enter"
| "wormhole.gate.leave"
| "wormhole.gate.persona.create"
| "wormhole.gate.persona.activate"
| "wormhole.gate.persona.clear"
| "wormhole.gate.key.rotate"
)
}
fn command_requires_gate_state_snapshot(command: &str) -> bool {
matches!(
command,
"wormhole.gate.enter"
| "wormhole.gate.persona.create"
| "wormhole.gate.persona.activate"
| "wormhole.gate.persona.clear"
| "wormhole.gate.key.rotate"
)
}
fn payload_prefers_backend_gate_decrypt(command: &str, payload: &Option<Value>) -> bool {
let Some(value) = payload.as_ref() else {
return false;
};
match command {
"wormhole.gate.message.decrypt" => {
let format = value
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("mls1")
.trim()
.to_ascii_lowercase();
let recovery_requested = value
.get("recovery_envelope")
.and_then(|v| v.as_bool())
.unwrap_or(false);
recovery_requested || format != "mls1"
}
"wormhole.gate.messages.decrypt" => value
.get("messages")
.and_then(|v| v.as_array())
.map(|messages| {
messages.iter().any(|message| {
let format = message
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("mls1")
.trim()
.to_ascii_lowercase();
let recovery_requested = message
.get("recovery_envelope")
.and_then(|v| v.as_bool())
.unwrap_or(false);
recovery_requested || format != "mls1"
})
})
.unwrap_or(false),
_ => false,
}
}
pub async fn dispatch_control_command(
backend_base_url: &str,
admin_key: Option<&str>,
command: &str,
payload: Option<Value>,
gate_crypto_state: &NativeGateCryptoState,
) -> Result<Value, String> {
match command {
let expected_gate_change = if command_expects_gate_authority_change(command) {
payload_gate_id(&payload)
} else {
None
};
if let Some(gate_id) = expected_gate_change.as_deref() {
gate_crypto::mark_expected_gate_change(&gate_crypto_state.0, gate_id)?;
}
let result = match command {
// --- Wormhole lifecycle ---
"wormhole.status" => {
call_backend_json(backend_base_url, admin_key, "/api/wormhole/status", Method::GET, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/status",
Method::GET,
None,
)
.await
}
"wormhole.connect" => {
call_backend_json(backend_base_url, admin_key, "/api/wormhole/connect", Method::POST, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/connect",
Method::POST,
None,
)
.await
}
"wormhole.disconnect" => {
call_backend_json(backend_base_url, admin_key, "/api/wormhole/disconnect", Method::POST, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/disconnect",
Method::POST,
None,
)
.await
}
"wormhole.restart" => {
call_backend_json(backend_base_url, admin_key, "/api/wormhole/restart", Method::POST, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/restart",
Method::POST,
None,
)
.await
}
// --- Gate access ---
"wormhole.gate.enter" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/enter",
Method::POST,
payload,
)
.await
}
"wormhole.gate.leave" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/leave",
Method::POST,
payload,
)
.await
}
// --- Gate personas ---
"wormhole.gate.personas.get" => {
let gate_id = extract_gate_id(&payload)?;
let path = format!("/api/wormhole/gate/{gate_id}/personas");
call_backend_json(backend_base_url, admin_key, &path, Method::GET, None).await
}
"wormhole.gate.persona.create" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/persona/create",
Method::POST,
payload,
)
.await
}
"wormhole.gate.persona.activate" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/persona/activate",
Method::POST,
payload,
)
.await
}
"wormhole.gate.persona.clear" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/persona/clear",
Method::POST,
payload,
)
.await
}
// --- Gate keys ---
"wormhole.gate.key.get" => {
let gate_id = extract_gate_id(&payload)?;
let path = format!("/api/wormhole/gate/{gate_id}/key");
call_backend_json(backend_base_url, admin_key, &path, Method::GET, None).await
}
"wormhole.gate.key.rotate" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/key/rotate",
Method::POST,
payload,
)
.await
}
"wormhole.gate.state.resync" => {
gate_crypto::resync_gate_state(
&gate_crypto_state.0,
backend_base_url,
admin_key,
payload,
)
.await
}
// --- Gate messages ---
"wormhole.gate.proof" => {
call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/proof",
Method::POST,
payload,
)
.await
}
"wormhole.gate.message.compose" => {
gate_crypto::compose_gate_message(
&gate_crypto_state.0,
backend_base_url,
admin_key,
payload,
)
.await
}
"wormhole.gate.message.post" => {
gate_crypto::post_gate_message(
&gate_crypto_state.0,
backend_base_url,
admin_key,
payload,
)
.await
}
"wormhole.gate.message.decrypt" => {
if payload_prefers_backend_gate_decrypt(command, &payload) {
return call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/message/decrypt",
Method::POST,
payload,
)
.await;
}
gate_crypto::decrypt_gate_message(
&gate_crypto_state.0,
backend_base_url,
admin_key,
payload,
)
.await
}
"wormhole.gate.messages.decrypt" => {
if payload_prefers_backend_gate_decrypt(command, &payload) {
return call_backend_json(
backend_base_url,
admin_key,
"/api/wormhole/gate/messages/decrypt",
Method::POST,
payload,
)
.await;
}
gate_crypto::decrypt_gate_messages(
&gate_crypto_state.0,
backend_base_url,
admin_key,
payload,
)
.await
}
// --- Settings ---
"settings.wormhole.get" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/wormhole", Method::GET, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/wormhole",
Method::GET,
None,
)
.await
}
"settings.wormhole.set" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/wormhole", Method::PUT, payload).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/wormhole",
Method::PUT,
payload,
)
.await
}
"settings.privacy.get" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/privacy-profile", Method::GET, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/privacy-profile",
Method::GET,
None,
)
.await
}
"settings.privacy.set" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/privacy-profile", Method::PUT, payload).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/privacy-profile",
Method::PUT,
payload,
)
.await
}
"settings.api_keys.get" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/api-keys", Method::GET, None).await
}
"settings.api_keys.set" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/api-keys", Method::PUT, payload).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/api-keys",
Method::GET,
None,
)
.await
}
"settings.news.get" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds", Method::GET, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/news-feeds",
Method::GET,
None,
)
.await
}
"settings.news.set" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds", Method::PUT, payload).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/news-feeds",
Method::PUT,
payload,
)
.await
}
"settings.news.reset" => {
call_backend_json(backend_base_url, admin_key, "/api/settings/news-feeds/reset", Method::POST, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/settings/news-feeds/reset",
Method::POST,
None,
)
.await
}
// --- System ---
"system.update" => {
call_backend_json(backend_base_url, admin_key, "/api/system/update", Method::POST, None).await
call_backend_json(
backend_base_url,
admin_key,
"/api/system/update",
Method::POST,
None,
)
.await
}
_ => Err(format!("unsupported_control_command:{command}")),
};
if let Some(gate_id) = expected_gate_change.as_deref() {
if result.is_err() {
let _ = gate_crypto::clear_expected_gate_change(&gate_crypto_state.0, gate_id);
} else if command == "wormhole.gate.leave" {
let _ = gate_crypto::forget_gate_state(&gate_crypto_state.0, gate_id);
} else if command_requires_gate_state_snapshot(command) {
if let Ok(value) = result.as_ref() {
if let Err(err) =
gate_crypto::adopt_gate_state_snapshot_from_result(&gate_crypto_state.0, value)
{
let _ = gate_crypto::clear_expected_gate_change(&gate_crypto_state.0, gate_id);
return Err(err);
}
}
}
}
result
}
@@ -0,0 +1,654 @@
use std::ffi::c_void;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use base64::Engine as _;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
const ENVELOPE_KIND: &str = "sb_local_custody";
const ENVELOPE_VERSION: u8 = 1;
#[derive(Clone, Debug, Serialize)]
pub struct LocalCustodyStatus {
pub code: String,
pub label: String,
pub provider: String,
pub detail: String,
pub protected_at_rest: bool,
pub last_error: String,
}
#[derive(Clone, Debug)]
pub struct LoadOutcome<T> {
pub value: T,
pub migrated: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct LocalCustodyEnvelope {
kind: String,
version: u8,
scope: String,
provider: String,
protected_at_rest: bool,
#[serde(default)]
protected_payload: String,
#[serde(default)]
payload_b64: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ProviderMode {
Dpapi,
Raw,
#[cfg(test)]
TestProtected,
#[cfg(test)]
TestProtectedAlt,
#[cfg(test)]
TestFailWrap,
}
fn status_labels(code: &str) -> &'static str {
match code {
"protected_at_rest" => "Protected at rest",
"degraded_local_custody" => "Degraded local custody",
"migration_in_progress" => "Migration in progress",
"migration_failed" => "Migration failed",
_ => "Degraded local custody",
}
}
fn default_status() -> LocalCustodyStatus {
LocalCustodyStatus {
code: "degraded_local_custody".to_string(),
label: status_labels("degraded_local_custody").to_string(),
provider: "unknown".to_string(),
detail: "Native local custody has not been initialized yet.".to_string(),
protected_at_rest: false,
last_error: String::new(),
}
}
fn status_cell() -> &'static Mutex<LocalCustodyStatus> {
static STATUS: OnceLock<Mutex<LocalCustodyStatus>> = OnceLock::new();
STATUS.get_or_init(|| Mutex::new(default_status()))
}
fn set_status(status: LocalCustodyStatus) {
if let Ok(mut guard) = status_cell().lock() {
*guard = status;
}
}
fn provider_status(mode: ProviderMode, detail: &str) -> LocalCustodyStatus {
let (code, provider, protected_at_rest) = match mode {
ProviderMode::Dpapi => ("protected_at_rest", "dpapi-machine", true),
ProviderMode::Raw => ("degraded_local_custody", "raw", false),
#[cfg(test)]
ProviderMode::TestProtected => ("protected_at_rest", "test-protected", true),
#[cfg(test)]
ProviderMode::TestProtectedAlt => ("protected_at_rest", "test-protected-alt", true),
#[cfg(test)]
ProviderMode::TestFailWrap => ("protected_at_rest", "test-protected", true),
};
LocalCustodyStatus {
code: code.to_string(),
label: status_labels(code).to_string(),
provider: provider.to_string(),
detail: detail.to_string(),
protected_at_rest,
last_error: String::new(),
}
}
fn set_migration_status(code: &str, detail: &str, last_error: &str) {
let (provider, protected_at_rest) = if let Ok(guard) = status_cell().lock() {
(guard.provider.clone(), guard.protected_at_rest)
} else {
("unknown".to_string(), false)
};
set_status(LocalCustodyStatus {
code: code.to_string(),
label: status_labels(code).to_string(),
provider,
detail: detail.to_string(),
protected_at_rest,
last_error: last_error.to_string(),
});
}
pub fn local_custody_status() -> LocalCustodyStatus {
status_cell()
.lock()
.map(|guard| guard.clone())
.unwrap_or_else(|_| default_status())
}
fn normalized_scope(scope: &str) -> String {
scope.trim().to_ascii_lowercase()
}
fn is_custody_envelope(value: &serde_json::Value) -> bool {
value
.get("kind")
.and_then(serde_json::Value::as_str)
.map(|kind| kind == ENVELOPE_KIND)
.unwrap_or(false)
&& value
.get("version")
.and_then(serde_json::Value::as_u64)
.map(|version| version == ENVELOPE_VERSION as u64)
.unwrap_or(false)
}
fn active_provider() -> ProviderMode {
#[cfg(test)]
if let Some(mode) = test_provider() {
return mode;
}
if cfg!(target_os = "windows") {
ProviderMode::Dpapi
} else {
ProviderMode::Raw
}
}
fn provider_for_name(provider: &str) -> Result<ProviderMode, String> {
match provider.trim().to_ascii_lowercase().as_str() {
"dpapi-machine" => Ok(ProviderMode::Dpapi),
"raw" => Ok(ProviderMode::Raw),
#[cfg(test)]
"test-protected" => Ok(ProviderMode::TestProtected),
#[cfg(test)]
"test-protected-alt" => Ok(ProviderMode::TestProtectedAlt),
#[cfg(test)]
"test-fail-wrap" => Ok(ProviderMode::TestFailWrap),
other if other.is_empty() => Err("local_custody_provider_missing".to_string()),
other => Err(format!("local_custody_provider_unsupported:{other}")),
}
}
fn wrap_bytes(scope: &str, plaintext: &[u8]) -> Result<LocalCustodyEnvelope, String> {
let scope = normalized_scope(scope);
let provider = active_provider();
let envelope = match provider {
ProviderMode::Dpapi => LocalCustodyEnvelope {
kind: ENVELOPE_KIND.to_string(),
version: ENVELOPE_VERSION,
scope,
provider: "dpapi-machine".to_string(),
protected_at_rest: true,
protected_payload: base64::engine::general_purpose::STANDARD
.encode(dpapi_protect(plaintext)?),
payload_b64: String::new(),
},
ProviderMode::Raw => LocalCustodyEnvelope {
kind: ENVELOPE_KIND.to_string(),
version: ENVELOPE_VERSION,
scope,
provider: "raw".to_string(),
protected_at_rest: false,
protected_payload: String::new(),
payload_b64: base64::engine::general_purpose::STANDARD.encode(plaintext),
},
#[cfg(test)]
ProviderMode::TestProtected => LocalCustodyEnvelope {
kind: ENVELOPE_KIND.to_string(),
version: ENVELOPE_VERSION,
scope,
provider: "test-protected".to_string(),
protected_at_rest: true,
protected_payload: base64::engine::general_purpose::STANDARD
.encode(test_protect(plaintext)),
payload_b64: String::new(),
},
#[cfg(test)]
ProviderMode::TestProtectedAlt => LocalCustodyEnvelope {
kind: ENVELOPE_KIND.to_string(),
version: ENVELOPE_VERSION,
scope,
provider: "test-protected-alt".to_string(),
protected_at_rest: true,
protected_payload: base64::engine::general_purpose::STANDARD
.encode(test_protect_alt(plaintext)),
payload_b64: String::new(),
},
#[cfg(test)]
ProviderMode::TestFailWrap => return Err(format!("test_wrap_failed:{scope}")),
};
set_status(provider_status(
provider,
if envelope.protected_at_rest {
"Native gate state is wrapped before persistence."
} else {
"Native gate state is preserved, but the local custody provider is degraded."
},
));
Ok(envelope)
}
fn unwrap_bytes(scope: &str, envelope: &LocalCustodyEnvelope) -> Result<Vec<u8>, String> {
let scope = normalized_scope(scope);
if !envelope.scope.is_empty() && normalized_scope(&envelope.scope) != scope {
return Err(format!(
"local_custody_scope_mismatch:{}:{}",
envelope.scope, scope
));
}
match provider_for_name(&envelope.provider)? {
ProviderMode::Dpapi => {
let protected = base64::engine::general_purpose::STANDARD
.decode(envelope.protected_payload.trim())
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
dpapi_unprotect(&protected)
}
ProviderMode::Raw => base64::engine::general_purpose::STANDARD
.decode(envelope.payload_b64.trim())
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}")),
#[cfg(test)]
ProviderMode::TestProtected => {
let protected = base64::engine::general_purpose::STANDARD
.decode(envelope.protected_payload.trim())
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
Ok(test_unprotect(&protected))
}
#[cfg(test)]
ProviderMode::TestProtectedAlt => {
let protected = base64::engine::general_purpose::STANDARD
.decode(envelope.protected_payload.trim())
.map_err(|e| format!("local_custody_payload_b64_invalid:{e}"))?;
Ok(test_unprotect_alt(&protected))
}
#[cfg(test)]
ProviderMode::TestFailWrap => Err("test_wrap_provider_cannot_unwrap".to_string()),
}
}
fn atomic_write_bytes(target: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = target
.parent()
.ok_or_else(|| "native_local_custody_parent_missing".to_string())?;
fs::create_dir_all(parent).map_err(|e| format!("native_local_custody_dir_failed:{e}"))?;
let tmp_path = target.with_extension("tmp");
{
let mut file = fs::File::create(&tmp_path)
.map_err(|e| format!("native_local_custody_tmp_create_failed:{e}"))?;
file.write_all(bytes)
.map_err(|e| format!("native_local_custody_tmp_write_failed:{e}"))?;
file.flush()
.map_err(|e| format!("native_local_custody_tmp_flush_failed:{e}"))?;
}
fs::rename(&tmp_path, target).map_err(|e| format!("native_local_custody_rename_failed:{e}"))
}
pub fn write_protected_json_file<T: Serialize>(
path: &Path,
scope: &str,
value: &T,
) -> Result<(), String> {
let plaintext = serde_json::to_vec(value)
.map_err(|e| format!("native_local_custody_serialize_failed:{e}"))?;
let envelope = wrap_bytes(scope, &plaintext)?;
let encoded = serde_json::to_vec(&envelope)
.map_err(|e| format!("native_local_custody_envelope_serialize_failed:{e}"))?;
atomic_write_bytes(path, &encoded)
}
pub fn read_or_migrate_json_file<T: Serialize + DeserializeOwned>(
path: &Path,
scope: &str,
) -> Result<Option<LoadOutcome<T>>, String> {
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(path).map_err(|e| format!("native_local_custody_read_failed:{e}"))?;
let raw_value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| format!("native_local_custody_json_invalid:{e}"))?;
if is_custody_envelope(&raw_value) {
let envelope: LocalCustodyEnvelope = serde_json::from_value(raw_value)
.map_err(|e| format!("native_local_custody_envelope_invalid:{e}"))?;
let provider = provider_for_name(&envelope.provider)?;
let plaintext = unwrap_bytes(scope, &envelope)?;
let value = serde_json::from_slice(&plaintext)
.map_err(|e| format!("native_local_custody_decode_failed:{e}"))?;
set_status(provider_status(
provider,
if envelope.protected_at_rest {
"Native gate state is wrapped before persistence."
} else {
"Native gate state is preserved, but the local custody provider is degraded."
},
));
return Ok(Some(LoadOutcome {
value,
migrated: false,
}));
}
let legacy_bytes = bytes;
let legacy_value: T = serde_json::from_slice(&legacy_bytes)
.map_err(|e| format!("native_local_custody_legacy_decode_failed:{e}"))?;
set_migration_status(
"migration_in_progress",
"Native gate state is being migrated to wrapped local custody.",
"",
);
match write_protected_json_file(path, scope, &legacy_value) {
Ok(()) => match read_or_migrate_json_file(path, scope)? {
Some(LoadOutcome { value, .. }) => Ok(Some(LoadOutcome {
value,
migrated: true,
})),
None => Err("native_local_custody_migration_missing".to_string()),
},
Err(err) => {
let _ = atomic_write_bytes(path, &legacy_bytes);
set_migration_status(
"migration_failed",
"Native gate state could not be migrated and remains in the legacy readable form.",
&err,
);
Ok(Some(LoadOutcome {
value: legacy_value,
migrated: false,
}))
}
}
}
#[cfg(target_os = "windows")]
#[repr(C)]
struct DataBlob {
cb_data: u32,
pb_data: *mut u8,
}
#[cfg(target_os = "windows")]
#[link(name = "Crypt32")]
extern "system" {
fn CryptProtectData(
p_data_in: *const DataBlob,
sz_data_descr: *const u16,
p_optional_entropy: *const DataBlob,
pv_reserved: *mut c_void,
p_prompt_struct: *mut c_void,
dw_flags: u32,
p_data_out: *mut DataBlob,
) -> i32;
fn CryptUnprotectData(
p_data_in: *const DataBlob,
ppsz_data_descr: *mut *mut u16,
p_optional_entropy: *const DataBlob,
pv_reserved: *mut c_void,
p_prompt_struct: *mut c_void,
dw_flags: u32,
p_data_out: *mut DataBlob,
) -> i32;
}
#[cfg(target_os = "windows")]
#[link(name = "Kernel32")]
extern "system" {
fn LocalFree(mem: *mut c_void) -> *mut c_void;
}
#[cfg(target_os = "windows")]
fn dpapi_protect(bytes: &[u8]) -> Result<Vec<u8>, String> {
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;
const CRYPTPROTECT_LOCAL_MACHINE: u32 = 0x4;
let mut input = bytes.to_vec();
let in_blob = DataBlob {
cb_data: input.len() as u32,
pb_data: input.as_mut_ptr(),
};
let mut out_blob = DataBlob {
cb_data: 0,
pb_data: std::ptr::null_mut(),
};
let ok = unsafe {
CryptProtectData(
&in_blob,
std::ptr::null(),
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null_mut(),
CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE,
&mut out_blob,
)
};
if ok == 0 {
return Err("native_local_custody_dpapi_protect_failed".to_string());
}
let out =
unsafe { std::slice::from_raw_parts(out_blob.pb_data, out_blob.cb_data as usize).to_vec() };
unsafe {
LocalFree(out_blob.pb_data as *mut c_void);
}
Ok(out)
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect(bytes: &[u8]) -> Result<Vec<u8>, String> {
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;
let mut input = bytes.to_vec();
let in_blob = DataBlob {
cb_data: input.len() as u32,
pb_data: input.as_mut_ptr(),
};
let mut out_blob = DataBlob {
cb_data: 0,
pb_data: std::ptr::null_mut(),
};
let ok = unsafe {
CryptUnprotectData(
&in_blob,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null_mut(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if ok == 0 {
return Err("native_local_custody_dpapi_unprotect_failed".to_string());
}
let out =
unsafe { std::slice::from_raw_parts(out_blob.pb_data, out_blob.cb_data as usize).to_vec() };
unsafe {
LocalFree(out_blob.pb_data as *mut c_void);
}
Ok(out)
}
#[cfg(not(target_os = "windows"))]
fn dpapi_protect(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("native_local_custody_dpapi_unavailable".to_string())
}
#[cfg(not(target_os = "windows"))]
fn dpapi_unprotect(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("native_local_custody_dpapi_unavailable".to_string())
}
#[cfg(test)]
fn test_provider_cell() -> &'static Mutex<Option<ProviderMode>> {
static TEST_PROVIDER: OnceLock<Mutex<Option<ProviderMode>>> = OnceLock::new();
TEST_PROVIDER.get_or_init(|| Mutex::new(None))
}
#[cfg(test)]
fn test_provider() -> Option<ProviderMode> {
test_provider_cell().lock().ok().and_then(|guard| *guard)
}
#[cfg(test)]
pub(crate) fn set_test_provider_for_tests(provider: Option<ProviderMode>) {
if let Ok(mut guard) = test_provider_cell().lock() {
*guard = provider;
}
reset_local_custody_for_tests();
}
#[cfg(test)]
pub(crate) fn reset_local_custody_for_tests() {
set_status(default_status());
}
#[cfg(test)]
fn test_protect(bytes: &[u8]) -> Vec<u8> {
bytes.iter().rev().map(|byte| byte ^ 0x5a).collect()
}
#[cfg(test)]
fn test_unprotect(bytes: &[u8]) -> Vec<u8> {
bytes.iter().rev().map(|byte| byte ^ 0x5a).collect()
}
#[cfg(test)]
fn test_protect_alt(bytes: &[u8]) -> Vec<u8> {
bytes.iter().rev().map(|byte| byte ^ 0x33).collect()
}
#[cfg(test)]
fn test_unprotect_alt(bytes: &[u8]) -> Vec<u8> {
bytes.iter().rev().map(|byte| byte ^ 0x33).collect()
}
#[cfg(test)]
mod tests {
use super::{
local_custody_status, read_or_migrate_json_file, reset_local_custody_for_tests,
set_test_provider_for_tests, write_protected_json_file, ProviderMode,
};
use serde_json::json;
use std::fs;
use std::sync::{Mutex, OnceLock};
fn test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
fn tmp_file(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("shadowbroker-local-custody-{name}"));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root.join("state.json")
}
#[test]
fn protected_native_state_is_not_persisted_as_plaintext() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
let path = tmp_file("protected");
write_protected_json_file(&path, "gate::ops", &json!({"rust_state_blob_b64":"opaque"}))
.unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(!raw.contains("opaque"));
assert!(raw.contains("sb_local_custody"));
assert_eq!(local_custody_status().code, "protected_at_rest");
set_test_provider_for_tests(None);
}
#[test]
fn legacy_native_state_auto_migrates() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
let path = tmp_file("migrate");
fs::write(
&path,
serde_json::to_vec(&json!({"gate_id":"ops","epoch":7})).unwrap(),
)
.unwrap();
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
.unwrap()
.unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(loaded.value["gate_id"], "ops");
assert!(loaded.migrated);
assert!(raw.contains("sb_local_custody"));
set_test_provider_for_tests(None);
}
#[test]
fn failed_native_migration_preserves_legacy_readable_state() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
set_test_provider_for_tests(Some(ProviderMode::TestFailWrap));
let path = tmp_file("fail-migrate");
let legacy = serde_json::to_vec(&json!({"gate_id":"ops","epoch":7})).unwrap();
fs::write(&path, &legacy).unwrap();
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
.unwrap()
.unwrap();
let raw = fs::read(&path).unwrap();
assert_eq!(loaded.value["gate_id"], "ops");
assert_eq!(raw, legacy);
assert_eq!(local_custody_status().code, "migration_failed");
set_test_provider_for_tests(None);
}
#[test]
fn degraded_status_is_exposed_when_only_raw_provider_is_available() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
set_test_provider_for_tests(Some(ProviderMode::Raw));
let path = tmp_file("raw");
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops"})).unwrap();
assert_eq!(local_custody_status().code, "degraded_local_custody");
set_test_provider_for_tests(None);
}
#[test]
fn provider_aware_read_handles_raw_to_protected_transition() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
let path = tmp_file("raw-to-protected");
set_test_provider_for_tests(Some(ProviderMode::Raw));
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops","epoch":7})).unwrap();
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
.unwrap()
.unwrap();
assert_eq!(loaded.value["gate_id"], "ops");
assert_eq!(local_custody_status().provider, "raw");
assert_eq!(local_custody_status().code, "degraded_local_custody");
set_test_provider_for_tests(None);
}
#[test]
fn provider_aware_read_handles_protected_to_other_provider_transition() {
let _guard = test_lock().lock().unwrap();
reset_local_custody_for_tests();
let path = tmp_file("protected-transition");
set_test_provider_for_tests(Some(ProviderMode::TestProtected));
write_protected_json_file(&path, "gate::ops", &json!({"gate_id":"ops","epoch":9})).unwrap();
set_test_provider_for_tests(Some(ProviderMode::TestProtectedAlt));
let loaded = read_or_migrate_json_file::<serde_json::Value>(&path, "gate::ops")
.unwrap()
.unwrap();
assert_eq!(loaded.value["epoch"], 9);
assert_eq!(local_custody_status().provider, "test-protected");
assert_eq!(local_custody_status().code, "protected_at_rest");
set_test_provider_for_tests(None);
}
}
@@ -1,37 +1,556 @@
mod backend_runtime;
mod bridge;
mod companion;
mod companion_server;
mod gate_crypto;
mod handlers;
mod http_client;
mod local_custody;
pub mod policy;
mod tray;
use bridge::invoke_local_control;
use bridge::{clear_native_audit_report, get_native_audit_report, invoke_local_control};
use companion::{companion_disable, companion_enable, companion_open_browser, companion_status};
use policy::SharedAuditRing;
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use url::Url;
pub struct DesktopAppState {
pub backend_base_url: String,
pub admin_key: Option<String>,
pub audit_ring: SharedAuditRing,
pub owns_managed_backend: bool,
}
/// Retained tray icon handle. Stored in Tauri managed state to keep the handle
/// alive for the app's lifetime — dropping it may cause the OS to unregister
/// the tray icon.
#[allow(dead_code)]
pub struct TrayHandle(tauri::tray::TrayIcon);
/// Retained app-level loopback server handle. Stored in Tauri managed state
/// so the server lives for the app's lifetime. Dropping it gracefully shuts
/// the server down (see `CompanionServerHandle::Drop`).
///
/// Wrapped in a `Mutex` to satisfy Tauri's managed-state `Send + Sync` bound:
/// the underlying handle contains a `tokio::sync::oneshot::Sender` which is
/// `Send` but not `Sync`. The mutex is never contended — the handle is only
/// touched on shutdown via `Drop`.
#[allow(dead_code)]
pub struct AppServerHandle(std::sync::Mutex<companion_server::CompanionServerHandle>);
/// Retained managed backend process handle for packaged builds. Stored in
/// managed state so the child process lives for the app's lifetime and is
/// terminated on shutdown via `Drop`.
#[allow(dead_code)]
pub struct ManagedBackendState(std::sync::Mutex<backend_runtime::ManagedBackendHandle>);
/// Retained native gate-crypto runtime. This lets the packaged native window
/// import opaque gate MLS state into the Tauri boundary and decrypt there,
/// rather than handing ordinary gate reads back to backend HTTP decrypt routes.
#[allow(dead_code)]
pub struct NativeGateCryptoState(std::sync::Mutex<gate_crypto::GateCryptoRuntime>);
// Initialization script installed into every page load of the main webview.
//
// SECURITY MODEL:
// Authoritative policy enforcement (capability mismatch, session profile
// warn/deny) lives in Rust — see policy.rs and bridge.rs. The JS-side
// preflight checks here are defense in depth only; even if bypassed via
// direct Tauri IPC, the Rust side enforces the same semantics and records
// every invocation in its AuditRing.
//
// AUDIT MODEL:
// Rust AuditRing is the authoritative audit trail for ALL invocations
// (including direct IPC bypasses). The Rust audit is accessible via Tauri
// commands: get_native_audit_report / clear_native_audit_report. The
// JS-side audit shadow below mirrors wrapper-path invocations and provides
// the synchronous getNativeControlAuditReport() interface that the
// existing frontend consumers (MeshTerminal, useMeshChat) depend on.
//
// DELIVERY MODEL (post-P6D-R):
// The script is delivered via `WebviewWindowBuilder::initialization_script`
// so it runs on every page load of the native window, regardless of the
// URL being served (static frontendDist in dev or the loopback app server
// in packaged mode). It is NOT served to the browser companion — the
// companion loads from the same loopback server but in a plain browser
// webview, which does not inject this script. That boundary preserves the
// "native window only" trust model for `__SHADOWBROKER_DESKTOP__`.
const DESKTOP_INIT_SCRIPT: &str = r#"
(function() {
if (typeof window === 'undefined') return;
if (window.__SHADOWBROKER_DESKTOP__) return; // idempotent on navigation
var _auditLog = [];
var _totalRecorded = 0;
var MAX_AUDIT = 100;
// --- Capability resolution (defense-in-depth, mirrors policy.rs) ---
var _capMap = {
'wormhole.status': 'wormhole_runtime',
'wormhole.connect': 'wormhole_runtime',
'wormhole.disconnect': 'wormhole_runtime',
'wormhole.restart': 'wormhole_runtime',
'wormhole.gate.enter': 'wormhole_gate_persona',
'wormhole.gate.leave': 'wormhole_gate_persona',
'wormhole.gate.personas.get': 'wormhole_gate_persona',
'wormhole.gate.persona.create': 'wormhole_gate_persona',
'wormhole.gate.persona.activate': 'wormhole_gate_persona',
'wormhole.gate.persona.clear': 'wormhole_gate_persona',
'wormhole.gate.key.get': 'wormhole_gate_key',
'wormhole.gate.key.rotate': 'wormhole_gate_key',
'wormhole.gate.state.resync': 'wormhole_gate_key',
'wormhole.gate.proof': 'wormhole_gate_content',
'wormhole.gate.message.compose': 'wormhole_gate_content',
'wormhole.gate.message.post': 'wormhole_gate_content',
'wormhole.gate.message.decrypt': 'wormhole_gate_content',
'wormhole.gate.messages.decrypt': 'wormhole_gate_content',
'settings.wormhole.get': 'settings',
'settings.wormhole.set': 'settings',
'settings.privacy.get': 'settings',
'settings.privacy.set': 'settings',
'settings.api_keys.get': 'settings',
'settings.news.get': 'settings',
'settings.news.set': 'settings',
'settings.news.reset': 'settings',
'system.update': 'settings'
};
// --- Profile → capabilities (defense-in-depth, mirrors policy.rs) ---
var _profileCaps = {
'full_app': ['wormhole_gate_persona','wormhole_gate_key','wormhole_gate_content','wormhole_runtime','settings'],
'gate_observe': ['wormhole_gate_content'],
'gate_operator': ['wormhole_gate_persona','wormhole_gate_key','wormhole_gate_content'],
'wormhole_runtime': ['wormhole_runtime'],
'settings_only': ['settings']
};
var _gateCommands = [
'wormhole.gate.enter','wormhole.gate.leave',
'wormhole.gate.personas.get','wormhole.gate.persona.create',
'wormhole.gate.persona.activate','wormhole.gate.persona.clear',
'wormhole.gate.key.get','wormhole.gate.key.rotate',
'wormhole.gate.state.resync',
'wormhole.gate.proof','wormhole.gate.message.compose',
'wormhole.gate.message.post','wormhole.gate.message.decrypt'
];
function _extractTargetRef(command, payload) {
if (!payload || typeof payload !== 'object') return undefined;
var gid = payload.gate_id;
if (typeof gid !== 'string' || !gid) return undefined;
return _gateCommands.indexOf(command) !== -1 ? gid : undefined;
}
function _recordAudit(entry) {
_totalRecorded += 1;
entry.recordedAt = Date.now();
_auditLog.push(entry);
if (_auditLog.length > MAX_AUDIT) {
_auditLog.splice(0, _auditLog.length - MAX_AUDIT);
}
}
window.__SHADOWBROKER_DESKTOP__ = {
invokeLocalControl: function(command, payload, meta) {
var expectedCap = _capMap[command];
if (!expectedCap) {
return Promise.reject('unsupported_control_command:' + command);
}
var m = meta || {};
var profile = m.sessionProfileHint;
var profileCaps = profile && _profileCaps[profile] ? _profileCaps[profile] : [];
var profileAllows = !profile || profileCaps.length === 0 || profileCaps.indexOf(expectedCap) !== -1;
var enforced = Boolean(m.enforceProfileHint && profile);
var targetRef = _extractTargetRef(command, payload);
var auditBase = {
command: command,
expectedCapability: expectedCap,
declaredCapability: m.capability,
sessionProfileHint: m.sessionProfileHint,
enforceProfileHint: m.enforceProfileHint,
profileAllows: profileAllows,
allowedCapabilitiesConfigured: false,
enforced: enforced
};
if (targetRef) auditBase.targetRef = targetRef;
if (profile) auditBase.sessionProfile = profile;
if (m.capability && m.capability !== expectedCap) {
_recordAudit(Object.assign({}, auditBase, { outcome: 'capability_mismatch' }));
return Promise.reject(
'native_control_capability_mismatch:' + m.capability + ':' + expectedCap
);
}
if (!profileAllows) {
var profileOutcome = enforced ? 'profile_denied' : 'profile_warn';
_recordAudit(Object.assign({}, auditBase, { outcome: profileOutcome }));
if (enforced) {
return Promise.reject(
'native_control_profile_mismatch:' + profile + ':' + expectedCap
);
}
console.warn('native_control_profile_mismatch:' + profile + ':' + expectedCap, {
command: command, sessionProfileHint: m.sessionProfileHint
});
}
if (profileAllows) {
_recordAudit(Object.assign({}, auditBase, { outcome: 'allowed' }));
}
return window.__TAURI__.core.invoke('invoke_local_control', {
command: command,
payload: payload || null,
meta: m.capability || m.sessionProfileHint || m.enforceProfileHint
? {
capability: m.capability || null,
sessionProfileHint: m.sessionProfileHint || null,
enforceProfileHint: Boolean(m.enforceProfileHint)
}
: null
});
},
getNativeControlAuditReport: function(limit) {
var n = Math.max(1, limit || 25);
var recent = _auditLog.slice(-n).reverse();
var byOutcome = {};
var lastDenied;
var lastProfileMismatch;
_auditLog.forEach(function(e) {
byOutcome[e.outcome] = (byOutcome[e.outcome] || 0) + 1;
if (e.outcome === 'profile_warn' || e.outcome === 'profile_denied') lastProfileMismatch = e;
if (e.outcome === 'profile_denied' || e.outcome === 'capability_denied') lastDenied = e;
});
return {
totalEvents: _auditLog.length,
totalRecorded: _totalRecorded,
recent: recent,
byOutcome: byOutcome,
lastProfileMismatch: lastProfileMismatch,
lastDenied: lastDenied
};
},
clearNativeControlAuditReport: function() {
_auditLog.splice(0, _auditLog.length);
_totalRecorded = 0;
if (window.__TAURI__ && window.__TAURI__.core) {
window.__TAURI__.core.invoke('clear_native_audit_report', {});
}
}
};
})();
"#;
#[derive(Clone, serde::Serialize)]
struct DesktopUpdateContext {
mode: &'static str,
platform: &'static str,
is_packaged_build: bool,
backend_mode: &'static str,
owns_local_backend: bool,
}
#[tauri::command]
fn desktop_update_context(state: tauri::State<'_, DesktopAppState>) -> DesktopUpdateContext {
let is_packaged_build = !cfg!(debug_assertions);
DesktopUpdateContext {
mode: if is_packaged_build { "packaged" } else { "dev" },
platform: match std::env::consts::OS {
"windows" => "windows",
"macos" => "macos",
"linux" => "linux",
_ => "unknown",
},
is_packaged_build,
backend_mode: if state.owns_managed_backend {
"managed"
} else {
"external"
},
owns_local_backend: state.owns_managed_backend,
}
}
#[tauri::command]
fn desktop_local_custody_status() -> local_custody::LocalCustodyStatus {
local_custody::local_custody_status()
}
fn main() {
let backend_base_url =
std::env::var("SHADOWBROKER_BACKEND_URL").unwrap_or_else(|_| "http://127.0.0.1:8000".to_string());
let explicit_backend_url = std::env::var("SHADOWBROKER_BACKEND_URL").ok();
let admin_key = std::env::var("SHADOWBROKER_ADMIN_KEY").ok();
// Frontend URL detection:
// - If SHADOWBROKER_FRONTEND_URL is explicitly set → honor it (dev mode
// or custom setup; the built-in loopback app server is skipped)
// - Else → default to http://127.0.0.1:3000 for dev; in packaged mode
// we'll start the loopback app server in setup below and override this.
let frontend_url_explicit = std::env::var("SHADOWBROKER_FRONTEND_URL").ok();
let default_frontend_url = frontend_url_explicit
.clone()
.unwrap_or_else(|| "http://127.0.0.1:3000".to_string());
tauri::Builder::default()
.manage(DesktopAppState {
backend_base_url,
admin_key,
})
.invoke_handler(tauri::generate_handler![invoke_local_control])
.setup(|app| {
if let Some(window) = app.get_webview_window("main") {
let script = r#"
window.__SHADOWBROKER_DESKTOP__ = {
invokeLocalControl: (command, payload) =>
window.__TAURI__.core.invoke('invoke_local_control', { command, payload })
};
"#;
let _ = window.eval(script);
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(NativeGateCryptoState(std::sync::Mutex::new(
gate_crypto::GateCryptoRuntime::default(),
)))
.manage(companion::new_companion_state(
default_frontend_url.clone(),
frontend_url_explicit.is_some(),
))
.invoke_handler(tauri::generate_handler![
desktop_update_context,
desktop_local_custody_status,
invoke_local_control,
get_native_audit_report,
clear_native_audit_report,
companion_status,
companion_enable,
companion_disable,
companion_open_browser,
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
tray::handle_close_requested(window, api);
}
})
.setup(move |app| {
// ---- Tray setup (existing behavior, unchanged) ----
match tray::setup_tray(app.handle()) {
Ok(tray_icon) => {
app.manage(TrayHandle(tray_icon));
}
Err(e) => {
eprintln!(
"tray setup failed (app will run without tray, close will quit normally): {e}"
);
}
}
let resource_dir = app.path().resource_dir().ok();
let app_local_data_dir = app
.path()
.app_local_data_dir()
.or_else(|_| app.path().app_data_dir())
.ok();
if let Some(cache_root) = app_local_data_dir
.as_ref()
.map(|dir| dir.join("gate-state-cache"))
{
if let Ok(mut runtime) = app
.state::<NativeGateCryptoState>()
.0
.lock()
{
runtime.set_cache_root(cache_root);
}
}
// ---- Resolve bundled frontend + backend assets (packaged mode indicators) ----
//
// Packaged desktop now owns a bundled local backend runtime as
// well as the static frontend export. In packaged mode, when the
// user has NOT explicitly set SHADOWBROKER_BACKEND_URL, the app:
// 1. installs/refreshes the bundled backend into app-local
// writable storage
// 2. launches it as a managed child process on loopback
// 3. points the loopback app server and native bridge at that
// managed backend
//
// Dev/custom setups can still override the backend explicitly.
let www_root: Option<std::path::PathBuf> = resource_dir
.as_ref()
.map(|d| d.join("companion-www"))
.filter(|p| p.join("index.html").exists());
let bundled_backend_root = resource_dir
.as_ref()
.and_then(|d| backend_runtime::bundled_backend_root(d));
if let Some(root) = www_root.as_ref() {
let companion_state_lock =
app.state::<companion::SharedCompanionState>();
if let Ok(mut cs) = companion_state_lock.lock() {
cs.set_www_root(root.clone());
};
}
let audit_ring = policy::new_shared_audit_ring(100);
let packaged_frontend_present = www_root.is_some();
let (resolved_backend_base_url, owns_managed_backend, resolved_admin_key) =
if let Some(url) = explicit_backend_url.as_ref() {
(url.clone(), false, admin_key.clone())
} else if let Some(bundled_root) = bundled_backend_root {
let app_local_data_dir = app_local_data_dir
.clone()
.ok_or_else(|| "managed_backend_app_data_dir_failed:no_app_data_dir".to_string())?;
match tauri::async_runtime::block_on(
backend_runtime::ensure_and_start_managed_backend(
bundled_root,
app_local_data_dir,
admin_key.clone(),
),
) {
Ok(handle) => {
let base_url = handle.base_url().to_string();
let resolved_admin_key =
handle.admin_key().map(str::to_string);
app.manage(ManagedBackendState(std::sync::Mutex::new(handle)));
(base_url, true, resolved_admin_key)
}
Err(e) => {
return Err(format!(
"ShadowBroker cannot start: the bundled local backend failed to launch.\n\n\
This packaged desktop build now owns its backend runtime and cannot fall back \
to an external service silently.\n\n\
Technical detail: {e}"
)
.into());
}
}
} else if packaged_frontend_present {
return Err(
"ShadowBroker cannot start: this packaged build is missing the bundled backend runtime."
.into(),
);
} else {
("http://127.0.0.1:8000".to_string(), false, admin_key.clone())
};
app.manage(DesktopAppState {
backend_base_url: resolved_backend_base_url.clone(),
admin_key: resolved_admin_key,
audit_ring,
owns_managed_backend,
});
// ---- Start app-level loopback server (packaged mode only) ----
//
// The loopback server has two jobs post-P6D-R:
// 1. Act as the HTTP origin for the packaged Tauri main window
// so ordinary non-privileged /api/* fetches have a real,
// same-origin path to the backend.
// 2. Serve the optional browser companion opener.
//
// It is NOT started when the user explicitly overrides the
// frontend URL — in that case the user owns the frontend
// environment (dev server, remote mirror, etc.).
let packaged_server_url: Option<String> = if www_root.is_some()
&& frontend_url_explicit.is_none()
{
let root = www_root.clone().unwrap();
let backend = resolved_backend_base_url.clone();
// Synchronously start the server in the Tauri async runtime
// so we have the bound URL before creating the webview. The
// server task is spawned inside and continues running for
// the app's lifetime (owned by AppServerHandle below).
match tauri::async_runtime::block_on(async move {
companion_server::start_companion_server(root, backend).await
}) {
Ok(server) => {
let url_string = server.url();
// Defense in depth: refuse anything that isn't loopback.
if !companion::is_loopback_origin(&url_string) {
eprintln!(
"loopback app server bound to non-loopback origin '{url_string}' — refusing to use it"
);
None
} else {
// Register the URL with companion state so the
// browser companion opener hands out the same URL.
{
let companion_state_lock = app
.state::<companion::SharedCompanionState>();
if let Ok(mut cs) = companion_state_lock.lock() {
cs.set_app_server_url(url_string.clone());
};
}
// Keep the handle alive for the app's lifetime.
app.manage(AppServerHandle(std::sync::Mutex::new(server)));
Some(url_string)
}
}
Err(e) => {
// In packaged mode the loopback server is required —
// without it, the webview has no same-origin /api/*
// path and the app is non-functional. Fail honestly
// rather than presenting a silently broken UI.
return Err(format!(
"ShadowBroker cannot start: the packaged loopback server failed to bind.\n\n\
This usually means another process is using all available loopback ports, \
or a firewall is blocking localhost listeners.\n\n\
Technical detail: {e}"
).into());
}
}
} else {
None
};
// ---- Create the main window ----
//
// We create the main window programmatically (rather than via
// tauri.conf.json's app.windows) so we can:
// (a) Point it at the loopback app server URL in packaged mode
// — giving the webview same-origin /api/* access.
// (b) Attach an initialization_script that runs BEFORE any page
// JavaScript on every page load (including full reloads),
// so the __SHADOWBROKER_DESKTOP__ native control bridge is
// always present in the native window but never leaks into
// browser companion sessions.
//
// URL resolution order:
// 1. Packaged mode with loopback app server → server URL
// 2. Explicit SHADOWBROKER_FRONTEND_URL → that URL
// (packaged + explicit override, or custom dev setup)
// 3. Fall through to WebviewUrl::default() → resolves to
// build.devUrl (dev) or build.frontendDist (release) from
// tauri.conf.json
fn parse_or_default(url: &str, label: &str) -> WebviewUrl {
match Url::parse(url) {
Ok(parsed) => WebviewUrl::External(parsed),
Err(e) => {
eprintln!(
"failed to parse {label} URL '{url}' ({e}) — falling back to default webview URL"
);
WebviewUrl::default()
}
}
}
let main_url: WebviewUrl =
if let Some(url) = packaged_server_url.as_deref() {
parse_or_default(url, "loopback server")
} else if let Some(url) = frontend_url_explicit.as_deref() {
parse_or_default(url, "explicit frontend override")
} else {
WebviewUrl::default()
};
WebviewWindowBuilder::new(app, "main", main_url)
.title("ShadowBroker")
.inner_size(1600.0, 1000.0)
.resizable(true)
.initialization_script(DESKTOP_INIT_SCRIPT)
.build()?;
Ok(())
})
.run(tauri::generate_context!())
.expect("failed to run shadowbroker tauri shell");
.build(tauri::generate_context!())
.expect("failed to build shadowbroker tauri shell")
.run(|app, event| {
// macOS dock-icon reopen: restore/focus the main window when
// the user clicks the dock icon while the app is hidden in the
// background. On Windows/Linux this event is not emitted, so
// the existing tray restore path is the only restore mechanism.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = event {
tray::show_main_window(app);
}
// All other events use default handling.
let _ = (app, event);
});
}
@@ -0,0 +1,654 @@
//! Native-side policy enforcement and audit ring for local-control commands.
//!
//! This module is the authoritative guardrail layer. Even if webview JS is
//! bypassed and `invoke_local_control` is called directly via Tauri IPC,
//! every invocation passes through `enforce_and_audit()` before reaching
//! the backend HTTP dispatch.
//!
//! The capability and profile tables mirror the TypeScript source of truth
//! in `frontend/src/lib/desktopControlContract.ts`.
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Mutex;
// ---------------------------------------------------------------------------
// Capability resolution (mirrors controlCommandCapability in TS)
// ---------------------------------------------------------------------------
pub fn resolve_command_capability(command: &str) -> Option<&'static str> {
match command {
"wormhole.status" | "wormhole.connect" | "wormhole.disconnect" | "wormhole.restart" => {
Some("wormhole_runtime")
}
"wormhole.gate.enter"
| "wormhole.gate.leave"
| "wormhole.gate.personas.get"
| "wormhole.gate.persona.create"
| "wormhole.gate.persona.activate"
| "wormhole.gate.persona.clear" => Some("wormhole_gate_persona"),
"wormhole.gate.key.get" | "wormhole.gate.key.rotate" | "wormhole.gate.state.resync" => {
Some("wormhole_gate_key")
}
"wormhole.gate.proof"
| "wormhole.gate.message.compose"
| "wormhole.gate.message.post"
| "wormhole.gate.message.decrypt"
| "wormhole.gate.messages.decrypt" => Some("wormhole_gate_content"),
"settings.wormhole.get"
| "settings.wormhole.set"
| "settings.privacy.get"
| "settings.privacy.set"
| "settings.api_keys.get"
| "settings.news.get"
| "settings.news.set"
| "settings.news.reset"
| "system.update" => Some("settings"),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Profile → capabilities (mirrors sessionProfileCapabilities in TS)
// ---------------------------------------------------------------------------
pub fn resolve_profile_capabilities(profile: &str) -> &'static [&'static str] {
match profile {
"full_app" => &[
"wormhole_gate_persona",
"wormhole_gate_key",
"wormhole_gate_content",
"wormhole_runtime",
"settings",
],
"gate_observe" => &["wormhole_gate_content"],
"gate_operator" => &[
"wormhole_gate_persona",
"wormhole_gate_key",
"wormhole_gate_content",
],
"wormhole_runtime" => &["wormhole_runtime"],
"settings_only" => &["settings"],
_ => &[],
}
}
// ---------------------------------------------------------------------------
// Gate target ref extraction (mirrors extractGateTargetRef in TS)
// ---------------------------------------------------------------------------
fn is_gate_target_command(command: &str) -> bool {
matches!(
command,
"wormhole.gate.enter"
| "wormhole.gate.leave"
| "wormhole.gate.personas.get"
| "wormhole.gate.persona.create"
| "wormhole.gate.persona.activate"
| "wormhole.gate.persona.clear"
| "wormhole.gate.key.get"
| "wormhole.gate.key.rotate"
| "wormhole.gate.state.resync"
| "wormhole.gate.proof"
| "wormhole.gate.message.compose"
| "wormhole.gate.message.post"
| "wormhole.gate.message.decrypt"
)
}
fn extract_target_ref(command: &str, payload: &Option<Value>) -> Option<String> {
if !is_gate_target_command(command) {
return None;
}
payload
.as_ref()
.and_then(|v| v.get("gate_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
// ---------------------------------------------------------------------------
// Audit entry and ring
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub command: String,
#[serde(rename = "expectedCapability")]
pub expected_capability: String,
#[serde(rename = "declaredCapability", skip_serializing_if = "Option::is_none")]
pub declared_capability: Option<String>,
#[serde(rename = "targetRef", skip_serializing_if = "Option::is_none")]
pub target_ref: Option<String>,
#[serde(rename = "sessionProfile", skip_serializing_if = "Option::is_none")]
pub session_profile: Option<String>,
#[serde(rename = "sessionProfileHint", skip_serializing_if = "Option::is_none")]
pub session_profile_hint: Option<String>,
#[serde(rename = "enforceProfileHint")]
pub enforce_profile_hint: bool,
#[serde(rename = "profileAllows")]
pub profile_allows: bool,
#[serde(rename = "allowedCapabilitiesConfigured")]
pub allowed_capabilities_configured: bool,
pub enforced: bool,
pub outcome: String,
#[serde(rename = "recordedAt")]
pub recorded_at: u64,
}
#[derive(Serialize)]
pub struct AuditReport {
#[serde(rename = "totalEvents")]
pub total_events: u64,
#[serde(rename = "totalRecorded")]
pub total_recorded: u64,
pub recent: Vec<AuditEntry>,
#[serde(rename = "byOutcome")]
pub by_outcome: HashMap<String, u64>,
#[serde(
rename = "lastProfileMismatch",
skip_serializing_if = "Option::is_none"
)]
pub last_profile_mismatch: Option<AuditEntry>,
#[serde(rename = "lastDenied", skip_serializing_if = "Option::is_none")]
pub last_denied: Option<AuditEntry>,
}
pub struct AuditRing {
entries: Vec<AuditEntry>,
max_entries: usize,
total_recorded: u64,
}
impl AuditRing {
pub fn new(max_entries: usize) -> Self {
Self {
entries: Vec::new(),
max_entries,
total_recorded: 0,
}
}
pub fn record(&mut self, entry: AuditEntry) {
self.total_recorded += 1;
self.entries.push(entry);
if self.entries.len() > self.max_entries {
let excess = self.entries.len() - self.max_entries;
self.entries.drain(..excess);
}
}
pub fn snapshot(&self, limit: usize) -> AuditReport {
let n = limit.max(1);
let start = self.entries.len().saturating_sub(n);
let recent: Vec<AuditEntry> = self.entries[start..].iter().rev().cloned().collect();
let mut by_outcome: HashMap<String, u64> = HashMap::new();
let mut last_profile_mismatch: Option<AuditEntry> = None;
let mut last_denied: Option<AuditEntry> = None;
for entry in &self.entries {
*by_outcome.entry(entry.outcome.clone()).or_insert(0) += 1;
if entry.outcome == "profile_warn" || entry.outcome == "profile_denied" {
last_profile_mismatch = Some(entry.clone());
}
if entry.outcome == "profile_denied" || entry.outcome == "capability_denied" {
last_denied = Some(entry.clone());
}
}
AuditReport {
total_events: self.entries.len() as u64,
total_recorded: self.total_recorded,
recent,
by_outcome,
last_profile_mismatch,
last_denied,
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.total_recorded = 0;
}
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
// ---------------------------------------------------------------------------
// Policy enforcement — returns the audit entry on success, or (entry, error
// message) on denial. The caller records the entry into the AuditRing
// regardless of outcome.
// ---------------------------------------------------------------------------
pub enum PolicyOutcome {
/// Command is allowed — proceed with dispatch.
Allowed(AuditEntry),
/// Profile mismatch but not enforced — proceed with dispatch, log a warning.
ProfileWarn(AuditEntry),
/// Denied — do not dispatch.
Denied(AuditEntry, String),
}
pub fn enforce(command: &str, payload: &Option<Value>, meta: &Option<Value>) -> PolicyOutcome {
let expected_capability = match resolve_command_capability(command) {
Some(cap) => cap.to_string(),
None => {
let entry = AuditEntry {
command: command.to_string(),
expected_capability: "unknown".to_string(),
declared_capability: None,
target_ref: None,
session_profile: None,
session_profile_hint: None,
enforce_profile_hint: false,
profile_allows: false,
allowed_capabilities_configured: false,
enforced: false,
outcome: "capability_denied".to_string(),
recorded_at: now_millis(),
};
return PolicyOutcome::Denied(entry, format!("unsupported_control_command:{command}"));
}
};
// Parse meta fields
let declared_capability = meta
.as_ref()
.and_then(|m| m.get("capability"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let session_profile_hint = meta
.as_ref()
.and_then(|m| m.get("sessionProfileHint"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let enforce_profile_hint = meta
.as_ref()
.and_then(|m| m.get("enforceProfileHint"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let profile = session_profile_hint.as_deref();
let profile_caps = profile.map(resolve_profile_capabilities).unwrap_or(&[]);
let profile_allows = profile.is_none()
|| profile_caps.is_empty()
|| profile_caps.contains(&expected_capability.as_str());
let enforced = enforce_profile_hint && profile.is_some();
let target_ref = extract_target_ref(command, payload);
let base = AuditEntry {
command: command.to_string(),
expected_capability: expected_capability.clone(),
declared_capability: declared_capability.clone(),
target_ref,
session_profile: profile.map(|s| s.to_string()),
session_profile_hint: session_profile_hint.clone(),
enforce_profile_hint,
profile_allows,
allowed_capabilities_configured: false,
enforced,
outcome: String::new(),
recorded_at: now_millis(),
};
// --- Capability mismatch check ---
if let Some(ref declared) = declared_capability {
if *declared != expected_capability {
let mut entry = base;
entry.outcome = "capability_mismatch".to_string();
return PolicyOutcome::Denied(
entry,
format!("native_control_capability_mismatch:{declared}:{expected_capability}"),
);
}
}
// --- Profile enforcement ---
if !profile_allows {
let profile_str = profile.unwrap_or("unknown");
if enforced {
let mut entry = base;
entry.outcome = "profile_denied".to_string();
return PolicyOutcome::Denied(
entry,
format!("native_control_profile_mismatch:{profile_str}:{expected_capability}"),
);
} else {
let mut entry = base;
entry.outcome = "profile_warn".to_string();
return PolicyOutcome::ProfileWarn(entry);
}
}
// --- Allowed ---
let mut entry = base;
entry.outcome = "allowed".to_string();
PolicyOutcome::Allowed(entry)
}
/// Thread-safe wrapper for shared audit state.
pub type SharedAuditRing = Mutex<AuditRing>;
pub fn new_shared_audit_ring(max_entries: usize) -> SharedAuditRing {
Mutex::new(AuditRing::new(max_entries))
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn allowed_command_without_meta() {
let result = enforce("wormhole.status", &None, &None);
match result {
PolicyOutcome::Allowed(entry) => {
assert_eq!(entry.outcome, "allowed");
assert_eq!(entry.expected_capability, "wormhole_runtime");
assert!(entry.profile_allows);
assert!(!entry.enforced);
}
_ => panic!("expected Allowed"),
}
}
#[test]
fn allowed_command_with_matching_capability() {
let meta = Some(json!({ "capability": "wormhole_runtime" }));
let result = enforce("wormhole.status", &None, &meta);
match result {
PolicyOutcome::Allowed(entry) => {
assert_eq!(entry.outcome, "allowed");
assert_eq!(
entry.declared_capability.as_deref(),
Some("wormhole_runtime")
);
}
_ => panic!("expected Allowed"),
}
}
#[test]
fn capability_mismatch_is_denied() {
let meta = Some(json!({ "capability": "settings" }));
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
match result {
PolicyOutcome::Denied(entry, msg) => {
assert_eq!(entry.outcome, "capability_mismatch");
assert!(msg.contains("native_control_capability_mismatch"));
assert!(msg.contains("settings"));
assert!(msg.contains("wormhole_gate_key"));
}
_ => panic!("expected Denied"),
}
}
#[test]
fn enforced_profile_denial() {
let meta = Some(json!({
"capability": "wormhole_gate_key",
"sessionProfileHint": "settings_only",
"enforceProfileHint": true
}));
let payload = Some(json!({ "gate_id": "infonet", "reason": "test" }));
let result = enforce("wormhole.gate.key.rotate", &payload, &meta);
match result {
PolicyOutcome::Denied(entry, msg) => {
assert_eq!(entry.outcome, "profile_denied");
assert_eq!(entry.target_ref.as_deref(), Some("infonet"));
assert_eq!(entry.session_profile.as_deref(), Some("settings_only"));
assert!(entry.enforced);
assert!(!entry.profile_allows);
assert!(msg.contains("native_control_profile_mismatch"));
}
_ => panic!("expected Denied"),
}
}
#[test]
fn non_enforced_profile_mismatch_warns() {
let meta = Some(json!({
"capability": "wormhole_gate_key",
"sessionProfileHint": "settings_only"
}));
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
match result {
PolicyOutcome::ProfileWarn(entry) => {
assert_eq!(entry.outcome, "profile_warn");
assert!(!entry.enforced);
assert!(!entry.profile_allows);
}
_ => panic!("expected ProfileWarn"),
}
}
#[test]
fn full_app_profile_allows_everything() {
let meta = Some(json!({
"sessionProfileHint": "full_app",
"enforceProfileHint": true
}));
let result = enforce("wormhole.gate.key.rotate", &None, &meta);
match result {
PolicyOutcome::Allowed(entry) => {
assert_eq!(entry.outcome, "allowed");
assert!(entry.profile_allows);
}
_ => panic!("expected Allowed"),
}
}
#[test]
fn unsupported_command_is_denied() {
let result = enforce("nonexistent.command", &None, &None);
match result {
PolicyOutcome::Denied(entry, msg) => {
assert_eq!(entry.outcome, "capability_denied");
assert!(msg.contains("unsupported_control_command"));
}
_ => panic!("expected Denied"),
}
}
#[test]
fn gate_command_extracts_target_ref() {
let payload = Some(json!({ "gate_id": "testgate", "reason": "r" }));
let result = enforce("wormhole.gate.key.rotate", &payload, &None);
match result {
PolicyOutcome::Allowed(entry) => {
assert_eq!(entry.target_ref.as_deref(), Some("testgate"));
}
_ => panic!("expected Allowed"),
}
}
#[test]
fn non_gate_command_has_no_target_ref() {
let result = enforce("wormhole.status", &None, &None);
match result {
PolicyOutcome::Allowed(entry) => {
assert!(entry.target_ref.is_none());
}
_ => panic!("expected Allowed"),
}
}
#[test]
fn audit_ring_records_and_snapshots() {
let mut ring = AuditRing::new(5);
for i in 0..3 {
ring.record(AuditEntry {
command: format!("cmd.{i}"),
expected_capability: "settings".to_string(),
declared_capability: None,
target_ref: None,
session_profile: None,
session_profile_hint: None,
enforce_profile_hint: false,
profile_allows: true,
allowed_capabilities_configured: false,
enforced: false,
outcome: "allowed".to_string(),
recorded_at: 1000 + i,
});
}
let report = ring.snapshot(10);
assert_eq!(report.total_events, 3);
assert_eq!(report.total_recorded, 3);
assert_eq!(report.recent.len(), 3);
// Most recent first
assert_eq!(report.recent[0].command, "cmd.2");
assert_eq!(*report.by_outcome.get("allowed").unwrap(), 3);
}
#[test]
fn audit_ring_evicts_oldest() {
let mut ring = AuditRing::new(2);
for i in 0..4 {
ring.record(AuditEntry {
command: format!("cmd.{i}"),
expected_capability: "settings".to_string(),
declared_capability: None,
target_ref: None,
session_profile: None,
session_profile_hint: None,
enforce_profile_hint: false,
profile_allows: true,
allowed_capabilities_configured: false,
enforced: false,
outcome: "allowed".to_string(),
recorded_at: 1000 + i,
});
}
let report = ring.snapshot(10);
assert_eq!(report.total_events, 2);
assert_eq!(report.total_recorded, 4);
assert_eq!(report.recent[0].command, "cmd.3");
assert_eq!(report.recent[1].command, "cmd.2");
}
#[test]
fn audit_ring_clear() {
let mut ring = AuditRing::new(10);
ring.record(AuditEntry {
command: "test".to_string(),
expected_capability: "settings".to_string(),
declared_capability: None,
target_ref: None,
session_profile: None,
session_profile_hint: None,
enforce_profile_hint: false,
profile_allows: true,
allowed_capabilities_configured: false,
enforced: false,
outcome: "allowed".to_string(),
recorded_at: 1000,
});
ring.clear();
let report = ring.snapshot(10);
assert_eq!(report.total_events, 0);
assert_eq!(report.total_recorded, 0);
}
#[test]
fn audit_ring_tracks_denied_entries() {
let mut ring = AuditRing::new(10);
ring.record(AuditEntry {
command: "wormhole.gate.key.rotate".to_string(),
expected_capability: "wormhole_gate_key".to_string(),
declared_capability: None,
target_ref: None,
session_profile: Some("settings_only".to_string()),
session_profile_hint: Some("settings_only".to_string()),
enforce_profile_hint: true,
profile_allows: false,
allowed_capabilities_configured: false,
enforced: true,
outcome: "profile_denied".to_string(),
recorded_at: 1000,
});
let report = ring.snapshot(10);
assert!(report.last_denied.is_some());
assert!(report.last_profile_mismatch.is_some());
assert_eq!(
report.last_denied.as_ref().unwrap().outcome,
"profile_denied"
);
assert_eq!(*report.by_outcome.get("profile_denied").unwrap(), 1);
}
#[test]
fn all_27_commands_resolve_capability() {
let commands = [
"wormhole.status",
"wormhole.connect",
"wormhole.disconnect",
"wormhole.restart",
"wormhole.gate.enter",
"wormhole.gate.leave",
"wormhole.gate.personas.get",
"wormhole.gate.persona.create",
"wormhole.gate.persona.activate",
"wormhole.gate.persona.clear",
"wormhole.gate.key.get",
"wormhole.gate.key.rotate",
"wormhole.gate.proof",
"wormhole.gate.message.compose",
"wormhole.gate.message.post",
"wormhole.gate.message.decrypt",
"wormhole.gate.messages.decrypt",
"settings.wormhole.get",
"settings.wormhole.set",
"settings.privacy.get",
"settings.privacy.set",
"settings.api_keys.get",
"settings.news.get",
"settings.news.set",
"settings.news.reset",
"system.update",
];
assert_eq!(commands.len(), 26);
for cmd in &commands {
assert!(
resolve_command_capability(cmd).is_some(),
"command {cmd} should resolve to a capability"
);
}
}
#[test]
fn all_profiles_resolve_non_empty() {
let profiles = [
"full_app",
"gate_observe",
"gate_operator",
"wormhole_runtime",
"settings_only",
];
for profile in &profiles {
let caps = resolve_profile_capabilities(profile);
assert!(
!caps.is_empty(),
"profile {profile} should have capabilities"
);
}
assert_eq!(resolve_profile_capabilities("full_app").len(), 5);
assert_eq!(resolve_profile_capabilities("settings_only"), &["settings"]);
assert_eq!(
resolve_profile_capabilities("gate_observe"),
&["wormhole_gate_content"]
);
}
}
@@ -0,0 +1,262 @@
//! Cross-platform tray / menu-bar background lifecycle.
//!
//! Provides:
//! - System tray icon with Show / Hide / Quit menu
//! - Window close interception (hide to background instead of quit)
//! - Restore from tray on menu action or tray icon click
//!
//! **Close behavior is conditional on tray availability:**
//! - If tray setup succeeds: close hides to background (tray can restore/quit)
//! - If tray setup fails: close behaves normally (app exits)
//! - The user is never stranded with a hidden app and no restore path.
//!
//! Platform behavior:
//! - **Windows**: Tray icon in system notification area. Left-click opens
//! the menu; "Show ShadowBroker" restores the window. "Quit" exits fully.
//! - **macOS**: Menu bar icon. Click opens menu (macOS convention).
//! - **Linux**: Appindicator tray icon (requires libayatana-appindicator3).
//! Click opens menu. Behavior depends on the desktop environment —
//! not all DEs render appindicator icons identically.
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::image::Image;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, CloseRequestApi, Manager};
// ---------------------------------------------------------------------------
// Tray menu item IDs
// ---------------------------------------------------------------------------
pub const MENU_ID_SHOW: &str = "sb_tray_show";
pub const MENU_ID_HIDE: &str = "sb_tray_hide";
pub const MENU_ID_QUIT: &str = "sb_tray_quit";
// ---------------------------------------------------------------------------
// Tray icon generation
// ---------------------------------------------------------------------------
const ICON_SIZE: u32 = 32;
/// Generate a minimal 32x32 RGBA tray icon: a filled teal circle on a
/// transparent background. Avoids requiring external asset files.
pub fn generate_tray_icon_rgba() -> (Vec<u8>, u32, u32) {
let size = ICON_SIZE;
let mut rgba = vec![0u8; (size * size * 4) as usize];
let center = size as f32 / 2.0;
let radius = center - 2.0;
for y in 0..size {
for x in 0..size {
let dx = x as f32 - center;
let dy = y as f32 - center;
let dist = (dx * dx + dy * dy).sqrt();
let idx = ((y * size + x) * 4) as usize;
if dist <= radius {
// Teal/green brand accent
rgba[idx] = 0x1B; // R
rgba[idx + 1] = 0xC4; // G
rgba[idx + 2] = 0x9D; // B
rgba[idx + 3] = 0xFF; // A
}
// Transparent otherwise (already zeroed)
}
}
(rgba, size, size)
}
// ---------------------------------------------------------------------------
// Tray readiness state
// ---------------------------------------------------------------------------
/// Shared atomic flag indicating whether the tray icon was successfully set up.
/// Used by `should_hide_on_close()` to decide whether close should hide to
/// background (tray alive → restore path exists) or quit normally (no tray →
/// hiding would strand the user).
pub static TRAY_READY: AtomicBool = AtomicBool::new(false);
/// Returns `true` if the tray icon is live and the app should hide on close
/// instead of quitting.
pub fn should_hide_on_close() -> bool {
TRAY_READY.load(Ordering::Relaxed)
}
// ---------------------------------------------------------------------------
// Tray setup
// ---------------------------------------------------------------------------
/// Set up the system tray icon with a Show / Hide / Quit menu.
/// On success, returns the `TrayIcon` handle — the caller **must** retain it
/// for the lifetime of the app (dropping it may unregister the tray icon).
/// Also sets `TRAY_READY` to `true`.
///
/// On failure (e.g. missing appindicator on Linux), returns an error string
/// and `TRAY_READY` remains `false`.
pub fn setup_tray(app: &AppHandle) -> Result<TrayIcon, String> {
let show_item = MenuItem::with_id(app, MENU_ID_SHOW, "Show ShadowBroker", true, None::<&str>)
.map_err(|e| format!("tray_menu_show:{e}"))?;
let hide_item = MenuItem::with_id(app, MENU_ID_HIDE, "Hide to Background", true, None::<&str>)
.map_err(|e| format!("tray_menu_hide:{e}"))?;
let separator =
PredefinedMenuItem::separator(app).map_err(|e| format!("tray_menu_separator:{e}"))?;
let quit_item = MenuItem::with_id(app, MENU_ID_QUIT, "Quit ShadowBroker", true, None::<&str>)
.map_err(|e| format!("tray_menu_quit:{e}"))?;
let menu = Menu::with_items(app, &[&show_item, &hide_item, &separator, &quit_item])
.map_err(|e| format!("tray_menu_build:{e}"))?;
let (rgba, width, height) = generate_tray_icon_rgba();
let icon = Image::new_owned(rgba, width, height);
let tray = TrayIconBuilder::new()
.icon(icon)
.tooltip("ShadowBroker")
.menu(&menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, event.id.as_ref());
})
.on_tray_icon_event(|tray, event| {
// Double-click left button: show window (cross-platform convenience)
if let TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)
.map_err(|e| format!("tray_build:{e}"))?;
TRAY_READY.store(true, Ordering::Relaxed);
Ok(tray)
}
// ---------------------------------------------------------------------------
// Menu event handling
// ---------------------------------------------------------------------------
fn handle_tray_menu_event(app: &AppHandle, id: &str) {
match id {
MENU_ID_SHOW => show_main_window(app),
MENU_ID_HIDE => hide_main_window(app),
MENU_ID_QUIT => app.exit(0),
_ => {}
}
}
// ---------------------------------------------------------------------------
// Window lifecycle
// ---------------------------------------------------------------------------
/// Show, unminimize, and focus the main window.
pub fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Hide the main window to the background.
pub fn hide_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
/// Handle a window close request. Behavior depends on tray availability:
/// - **Tray alive** (`should_hide_on_close()` = true): prevent close, hide to
/// background. The user can restore via tray menu or quit via "Quit ShadowBroker".
/// - **No tray** (`should_hide_on_close()` = false): allow the close to proceed
/// normally so the app exits. Never strand the user with a hidden window and
/// no visible restore path.
pub fn handle_close_requested(window: &tauri::Window, api: &CloseRequestApi) {
if window.label() == "main" && should_hide_on_close() {
api.prevent_close();
let _ = window.hide();
}
// If tray is not ready or window is not "main", close proceeds normally.
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn icon_rgba_has_correct_dimensions() {
let (rgba, w, h) = generate_tray_icon_rgba();
assert_eq!(w, ICON_SIZE);
assert_eq!(h, ICON_SIZE);
assert_eq!(rgba.len(), (w * h * 4) as usize);
}
#[test]
fn icon_center_pixel_is_opaque_teal() {
let (rgba, w, _h) = generate_tray_icon_rgba();
let center = w / 2;
let idx = ((center * w + center) * 4) as usize;
// R=0x1B, G=0xC4, B=0x9D, A=0xFF
assert_eq!(rgba[idx], 0x1B);
assert_eq!(rgba[idx + 1], 0xC4);
assert_eq!(rgba[idx + 2], 0x9D);
assert_eq!(rgba[idx + 3], 0xFF);
}
#[test]
fn icon_corner_pixel_is_transparent() {
let (rgba, _w, _h) = generate_tray_icon_rgba();
// Top-left corner (0,0) should be transparent
assert_eq!(rgba[0], 0); // R
assert_eq!(rgba[1], 0); // G
assert_eq!(rgba[2], 0); // B
assert_eq!(rgba[3], 0); // A
}
#[test]
fn menu_ids_are_distinct() {
assert_ne!(MENU_ID_SHOW, MENU_ID_HIDE);
assert_ne!(MENU_ID_SHOW, MENU_ID_QUIT);
assert_ne!(MENU_ID_HIDE, MENU_ID_QUIT);
}
#[test]
fn menu_ids_are_namespaced() {
// All IDs should be prefixed to avoid collisions
assert!(MENU_ID_SHOW.starts_with("sb_tray_"));
assert!(MENU_ID_HIDE.starts_with("sb_tray_"));
assert!(MENU_ID_QUIT.starts_with("sb_tray_"));
}
#[test]
fn should_hide_reflects_tray_ready_state() {
// Reset to known state
TRAY_READY.store(false, Ordering::Relaxed);
assert!(
!should_hide_on_close(),
"should not hide when tray is not ready"
);
TRAY_READY.store(true, Ordering::Relaxed);
assert!(should_hide_on_close(), "should hide when tray is ready");
// Clean up for other tests
TRAY_READY.store(false, Ordering::Relaxed);
}
#[test]
fn tray_ready_default_is_false() {
// TRAY_READY is initialized to false — if no tray setup runs,
// close should behave normally (no stranding).
// Note: other tests may have mutated TRAY_READY, so we verify
// the semantic contract via should_hide_on_close after explicit reset.
TRAY_READY.store(false, Ordering::Relaxed);
assert!(!should_hide_on_close());
}
}