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,8 @@
# Rust build artifacts
target/
# Companion server static assets (copied by build.sh from frontend/out)
companion-www/
# Managed backend runtime bundle (copied by build scripts from backend/)
backend-runtime/
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,25 @@
[package]
name = "shadowbroker-tauri-shell"
version = "0.1.0"
version = "0.9.7"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2" }
tauri-build = { version = "2", features = [] }
[dependencies]
axum = "0.7"
base64 = "0.22"
bytes = "1"
getrandom = "0.2"
open = "5"
privacy-core = { path = "../../../privacy-core" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["net", "sync", "time"] }
tower-http = { version = "0.5", features = ["fs"] }
url = "2"
urlencoding = "2"
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -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());
}
}
@@ -1,21 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShadowBroker Desktop Shell",
"version": "0.1.0",
"productName": "ShadowBroker",
"version": "0.9.7",
"identifier": "com.shadowbroker.desktop",
"build": {
"frontendDist": "../../frontend/.next",
"frontendDist": "../../../frontend/out",
"devUrl": "http://127.0.0.1:3000"
},
"app": {
"windows": [
{
"label": "main",
"title": "ShadowBroker",
"width": 1600,
"height": 1000,
"resizable": true
}
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"resources": ["companion-www", "backend-runtime"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.icns",
"icons/icon.png",
"icons/Square30x30Logo.png",
"icons/Square44x44Logo.png",
"icons/Square71x71Logo.png",
"icons/Square89x89Logo.png",
"icons/Square107x107Logo.png",
"icons/Square142x142Logo.png",
"icons/Square150x150Logo.png",
"icons/Square284x284Logo.png",
"icons/Square310x310Logo.png",
"icons/StoreLogo.png"
]
},
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* https://*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*"
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDMUU1NkRENjNCNTI5RjUKUldUMUtiVmozVlllTEd0STJlMGtORUxUWHlGQ2V0ZXM3Z1BOc3hwc0pUK1c3dlplcWc2OFpKd3oK",
"endpoints": [
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}