From a9410fd81554eb2345faae48a656244d3335cbf2 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Fri, 9 Oct 2026 21:31:26 +0000 Subject: [PATCH 01/51] feat(browser): add owned browser foundation and Chromium desktop preview --- Cargo.lock | 15 + Cargo.toml | 2 + apps/desktop/native-browser/Cargo.toml | 7 + apps/desktop/native-browser/build.rs | 66 ++ apps/desktop/native-browser/src/chromium.rs | 116 +++ .../native-browser/src/chromium/callbacks.rs | 230 ++++++ .../native-browser/src/chromium/ffi.rs | 69 ++ .../native-browser/src/chromium/linked.rs | 484 ++++++++++++ apps/desktop/native-browser/src/engine.rs | 2 +- apps/desktop/native-browser/src/lib.rs | 6 + apps/desktop/native-browser/src/pki.rs | 209 +++++ apps/desktop/native-browser/src/pki/macos.rs | 218 ++++++ .../desktop/native-browser/src/pki/windows.rs | 155 ++++ apps/desktop/native-browser/src/types.rs | 12 +- apps/desktop/native-browser/src/view.rs | 116 +++ apps/desktop/native-credential-ui/src/lib.rs | 6 +- .../native-credential-ui/src/lifecycle.rs | 8 +- .../desktop/native-credential-ui/src/linux.rs | 45 +- .../desktop/native-credential-ui/src/macos.rs | 59 +- .../src/macos/formatter.rs | 29 +- .../native-credential-ui/src/macos/styling.rs | 25 +- .../native-credential-ui/src/password.rs | 37 + .../native-credential-ui/src/prompt.rs | 35 +- .../native-credential-ui/src/purpose.rs | 62 ++ .../native-credential-ui/src/validation.rs | 117 +++ .../native-credential-ui/src/windows.rs | 65 +- .../src/windows/acceptance.rs | 4 +- .../src/windows/controls.rs | 26 +- .../native-credential-ui/src/windows/input.rs | 102 ++- apps/desktop/src-tauri/Cargo.lock | 7 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/build.rs | 1 + .../src-tauri/capabilities/main-chat.json | 1 + .../src-tauri/src/browser/acceptance.rs | 5 +- .../src-tauri/src/browser/bootstrap.rs | 65 ++ .../src-tauri/src/browser/certificates.rs | 202 +++++ apps/desktop/src-tauri/src/browser/dto.rs | 43 ++ apps/desktop/src-tauri/src/browser/guest.rs | 87 +++ apps/desktop/src-tauri/src/browser/manager.rs | 296 ++++++-- apps/desktop/src-tauri/src/browser/mod.rs | 4 + .../src-tauri/src/browser/presentation.rs | 52 ++ .../desktop/src-tauri/src/browser/registry.rs | 33 +- .../desktop/src-tauri/src/browser/viewport.rs | 100 ++- apps/desktop/src-tauri/src/dictation.rs | 2 + apps/desktop/src-tauri/src/lib.rs | 35 +- apps/desktop/src-tauri/src/state.rs | 1 + apps/desktop/src-tauri/src/status_bar.rs | 5 + apps/desktop/src/browser-api.ts | 36 + .../browser/BrowserCertificates.tsx | 124 +++ .../components/browser/BrowserPane.test.ts | 33 + .../src/components/browser/BrowserPane.tsx | 57 +- .../src/components/browser/browser.css | 67 +- .../src/components/browser/useBrowser.ts | 26 + apps/desktop/src/dev/browser-fixture.ts | 19 +- .../tests/browser/browser-pane.spec.ts | 32 + crates/colossus-access/src/browser_tests.rs | 144 ++++ crates/colossus-access/src/descriptor.rs | 4 + crates/colossus-access/src/lib.rs | 3 + crates/colossus-access/src/registry.rs | 26 + crates/colossus-access/src/resolver.rs | 9 +- crates/colossus-browser/Cargo.toml | 21 + crates/colossus-browser/src/coordinator.rs | 136 ++++ .../src/coordinator/control.rs | 292 +++++++ .../src/coordinator/execute.rs | 328 ++++++++ .../src/coordinator/lifecycle.rs | 308 ++++++++ crates/colossus-browser/src/error.rs | 46 ++ crates/colossus-browser/src/lib.rs | 13 + crates/colossus-browser/src/state.rs | 161 ++++ crates/colossus-browser/src/validation.rs | 201 +++++ crates/colossus-browser/tests/coordinator.rs | 600 +++++++++++++++ crates/colossus-browser/tests/support/mod.rs | 214 ++++++ crates/colossus-contracts/Cargo.toml | 1 + crates/colossus-contracts/src/browser.rs | 12 + .../colossus-contracts/src/browser/action.rs | 221 ++++++ .../src/browser/identity.rs | 172 +++++ .../colossus-contracts/src/browser/session.rs | 261 +++++++ .../colossus-contracts/src/browser/tests.rs | 50 ++ crates/colossus-contracts/src/lib.rs | 2 + crates/colossus-policy/src/browser_tests.rs | 42 ++ crates/colossus-policy/src/builtin.rs | 1 + crates/colossus-policy/src/kernel.rs | 4 +- crates/colossus-policy/src/tests.rs | 6 + crates/colossus-ports/src/browser.rs | 124 +++ crates/colossus-ports/src/lib.rs | 3 + crates/colossus-runtime/Cargo.toml | 1 + crates/colossus-runtime/src/access_policy.rs | 19 +- crates/colossus-runtime/src/browser_tools.rs | 53 ++ .../src/browser_tools/arguments.rs | 196 +++++ .../src/browser_tools/execution.rs | 276 +++++++ .../src/browser_tools/host.rs | 28 + .../src/browser_tools/lifecycle.rs | 130 ++++ .../src/browser_tools/public.rs | 44 ++ .../src/browser_tools/service.rs | 187 +++++ .../src/browser_tools/tests.rs | 713 ++++++++++++++++++ crates/colossus-runtime/src/composition.rs | 38 +- crates/colossus-runtime/src/diagnostics.rs | 2 + crates/colossus-runtime/src/lib.rs | 2 + .../src/process_sessions/public.rs | 6 +- crates/colossus-runtime/src/tests.rs | 1 + crates/colossus-runtime/src/workspace.rs | 14 + crates/colossus-tools/src/browser.rs | 225 ++++++ crates/colossus-tools/src/browser_tests.rs | 336 +++++++++ crates/colossus-tools/src/builtin.rs | 1 + crates/colossus-tools/src/lib.rs | 5 + crates/colossus-tools/src/registry.rs | 13 + .../adr/0007-owned-chromium-browser.md | 79 ++ docs/develop/adr/index.md | 1 + docs/develop/architecture.md | 10 +- docs/develop/feature-inventory.md | 1 + docs/reference/tools-actions.md | 65 +- fuzz/Cargo.lock | 1 + native/browser/CMakeLists.txt | 65 ++ native/browser/README.md | 87 +++ native/browser/component/README.md | 155 ++++ native/browser/component/cef.lock.json | 40 + native/browser/include/colossus_cef.h | 123 +++ native/browser/scripts/.gitignore | 2 + native/browser/scripts/component.py | 500 ++++++++++++ native/browser/scripts/run_probe.py | 189 +++++ native/browser/scripts/test_component.py | 263 +++++++ native/browser/scripts/test_probe.py | 99 +++ native/browser/src/client.cc | 209 +++++ native/browser/src/control.cc | 120 +++ native/browser/src/helper_mac.mm | 12 + native/browser/src/host.cc | 156 ++++ native/browser/src/host_internal.h | 46 ++ native/browser/src/platform.cc | 35 + native/browser/src/platform_mac.mm | 53 ++ native/browser/src/probe.cc | 123 +++ native/browser/tests/fixture.html | 14 + release/verify-release-readiness.sh | 4 + scripts/ci/classify-changes.sh | 2 +- scripts/ci/release-readiness.test.mjs | 5 + scripts/ci/test-contracts.sh | 29 + zensical.toml | 1 + 135 files changed, 11590 insertions(+), 255 deletions(-) create mode 100644 apps/desktop/native-browser/build.rs create mode 100644 apps/desktop/native-browser/src/chromium.rs create mode 100644 apps/desktop/native-browser/src/chromium/callbacks.rs create mode 100644 apps/desktop/native-browser/src/chromium/ffi.rs create mode 100644 apps/desktop/native-browser/src/chromium/linked.rs create mode 100644 apps/desktop/native-browser/src/pki.rs create mode 100644 apps/desktop/native-browser/src/pki/macos.rs create mode 100644 apps/desktop/native-browser/src/pki/windows.rs create mode 100644 apps/desktop/native-browser/src/view.rs create mode 100644 apps/desktop/native-credential-ui/src/password.rs create mode 100644 apps/desktop/native-credential-ui/src/purpose.rs create mode 100644 apps/desktop/src-tauri/src/browser/bootstrap.rs create mode 100644 apps/desktop/src-tauri/src/browser/certificates.rs create mode 100644 apps/desktop/src-tauri/src/browser/guest.rs create mode 100644 apps/desktop/src-tauri/src/browser/presentation.rs create mode 100644 apps/desktop/src/components/browser/BrowserCertificates.tsx create mode 100644 crates/colossus-access/src/browser_tests.rs create mode 100644 crates/colossus-browser/Cargo.toml create mode 100644 crates/colossus-browser/src/coordinator.rs create mode 100644 crates/colossus-browser/src/coordinator/control.rs create mode 100644 crates/colossus-browser/src/coordinator/execute.rs create mode 100644 crates/colossus-browser/src/coordinator/lifecycle.rs create mode 100644 crates/colossus-browser/src/error.rs create mode 100644 crates/colossus-browser/src/lib.rs create mode 100644 crates/colossus-browser/src/state.rs create mode 100644 crates/colossus-browser/src/validation.rs create mode 100644 crates/colossus-browser/tests/coordinator.rs create mode 100644 crates/colossus-browser/tests/support/mod.rs create mode 100644 crates/colossus-contracts/src/browser.rs create mode 100644 crates/colossus-contracts/src/browser/action.rs create mode 100644 crates/colossus-contracts/src/browser/identity.rs create mode 100644 crates/colossus-contracts/src/browser/session.rs create mode 100644 crates/colossus-contracts/src/browser/tests.rs create mode 100644 crates/colossus-policy/src/browser_tests.rs create mode 100644 crates/colossus-ports/src/browser.rs create mode 100644 crates/colossus-runtime/src/browser_tools.rs create mode 100644 crates/colossus-runtime/src/browser_tools/arguments.rs create mode 100644 crates/colossus-runtime/src/browser_tools/execution.rs create mode 100644 crates/colossus-runtime/src/browser_tools/host.rs create mode 100644 crates/colossus-runtime/src/browser_tools/lifecycle.rs create mode 100644 crates/colossus-runtime/src/browser_tools/public.rs create mode 100644 crates/colossus-runtime/src/browser_tools/service.rs create mode 100644 crates/colossus-runtime/src/browser_tools/tests.rs create mode 100644 crates/colossus-tools/src/browser.rs create mode 100644 crates/colossus-tools/src/browser_tests.rs create mode 100644 docs/develop/adr/0007-owned-chromium-browser.md create mode 100644 native/browser/CMakeLists.txt create mode 100644 native/browser/README.md create mode 100644 native/browser/component/README.md create mode 100644 native/browser/component/cef.lock.json create mode 100644 native/browser/include/colossus_cef.h create mode 100644 native/browser/scripts/.gitignore create mode 100644 native/browser/scripts/component.py create mode 100644 native/browser/scripts/run_probe.py create mode 100644 native/browser/scripts/test_component.py create mode 100644 native/browser/scripts/test_probe.py create mode 100644 native/browser/src/client.cc create mode 100644 native/browser/src/control.cc create mode 100644 native/browser/src/helper_mac.mm create mode 100644 native/browser/src/host.cc create mode 100644 native/browser/src/host_internal.h create mode 100644 native/browser/src/platform.cc create mode 100644 native/browser/src/platform_mac.mm create mode 100644 native/browser/src/probe.cc create mode 100644 native/browser/tests/fixture.html diff --git a/Cargo.lock b/Cargo.lock index 9a8e19db5..590019243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,6 +1158,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "colossus-browser" +version = "0.11.8" +dependencies = [ + "async-trait", + "colossus-contracts", + "colossus-ports", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "colossus-bundled-plugins" version = "0.11.8" @@ -1439,6 +1452,7 @@ dependencies = [ "colossus-domain", "serde", "serde_json", + "url", "zeroize", ] @@ -1934,6 +1948,7 @@ dependencies = [ "colossus-access", "colossus-agent", "colossus-audit", + "colossus-browser", "colossus-bundled-plugins", "colossus-bundles", "colossus-context", diff --git a/Cargo.toml b/Cargo.toml index a84036d09..02ef5bf5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "xtask", "crates/colossus-domain", "crates/colossus-contracts", + "crates/colossus-browser", "crates/colossus-credentials", "crates/colossus-api-proto", "crates/colossus-api", @@ -74,6 +75,7 @@ repository = "https://github.com/obscuritylabs/Colossus" publish-sdks = false [workspace.dependencies] +colossus-browser = { version = "=0.11.8", path = "crates/colossus-browser" } chrono = "0.4.44" openidconnect = { version = "4.0.1", default-features = false, features = ["reqwest", "rustls-tls"] } colossus-connector = { version = "=0.11.8", path = "crates/colossus-connector" } diff --git a/apps/desktop/native-browser/Cargo.toml b/apps/desktop/native-browser/Cargo.toml index 7d8ab4b28..fc83d3afb 100644 --- a/apps/desktop/native-browser/Cargo.toml +++ b/apps/desktop/native-browser/Cargo.toml @@ -8,10 +8,16 @@ license = "Apache-2.0" workspace = "../src-tauri" [features] +cef-preview = ["dep:serde_json", "dep:sha2", "dep:rustls-pki-types", "dep:x509-parser", "dep:zeroize"] native-test-driver = ["objc2-foundation/NSData", "objc2-foundation/NSJSONSerialization"] [dependencies] serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", optional = true } +sha2 = { version = "0.10.9", optional = true } +rustls-pki-types = { version = "1.14.0", optional = true } +x509-parser = { version = "0.18.1", optional = true } +zeroize = { version = "1.8.2", optional = true } tauri = { version = "=2.11.5", features = ["unstable"] } tokio = { version = "1.49.0", features = ["sync", "time"] } url = "2.5.8" @@ -19,6 +25,7 @@ url = "2.5.8" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Security_Cryptography", "Win32_Foundation"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" diff --git a/apps/desktop/native-browser/build.rs b/apps/desktop/native-browser/build.rs new file mode 100644 index 000000000..c2351106f --- /dev/null +++ b/apps/desktop/native-browser/build.rs @@ -0,0 +1,66 @@ +//! Optional native shim linkage. Ordinary Desktop builds never download CEF. + +use std::{env, path::PathBuf}; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(colossus_cef_linked)"); + println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_NATIVE_LIB_DIR"); + println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_ROOT"); + if env::var_os("CARGO_FEATURE_CEF_PREVIEW").is_none() { + return; + } + let Some(directory) = env::var_os("COLOSSUS_CEF_NATIVE_LIB_DIR") else { + return; + }; + // This opt-in is a developer feasibility lane. Release composition cannot + // enable an unverified component by merely choosing a library directory. + assert_eq!( + env::var("PROFILE").as_deref(), + Ok("debug"), + "CEF preview linkage is limited to native development builds" + ); + let directory = PathBuf::from(directory) + .canonicalize() + .expect("native CEF shim directory must exist"); + assert!( + directory.is_dir(), + "native CEF shim directory must be a directory" + ); + println!("cargo:rustc-link-search=native={}", directory.display()); + let cef = PathBuf::from( + env::var_os("COLOSSUS_CEF_ROOT") + .expect("native CEF linkage requires the pinned distribution root"), + ); + assert!( + cef.join("Release").is_dir(), + "native CEF release libraries are absent" + ); + println!( + "cargo:rustc-link-search=native={}", + directory.join("libcef_dll_wrapper").display() + ); + println!( + "cargo:rustc-link-search=native={}", + cef.join("Release").display() + ); + println!("cargo:rustc-link-lib=static=colossus_cef"); + println!("cargo:rustc-link-lib=static=cef_dll_wrapper"); + match env::var("CARGO_CFG_TARGET_OS").as_deref() { + Ok("macos") => { + // CEF's supported scoped loader loads the framework at entry. Do + // not directly link the CEF framework before sandbox helper setup. + for framework in ["Cocoa", "IOSurface"] { + println!("cargo:rustc-link-lib=framework={framework}"); + } + println!("cargo:rustc-link-lib=c++"); + } + Ok("windows") => println!("cargo:rustc-link-lib=libcef"), + Ok("linux") => { + for library in ["cef", "stdc++", "dl", "pthread", "rt"] { + println!("cargo:rustc-link-lib={library}"); + } + } + _ => panic!("native CEF linkage target is unsupported"), + } + println!("cargo:rustc-cfg=colossus_cef_linked"); +} diff --git a/apps/desktop/native-browser/src/chromium.rs b/apps/desktop/native-browser/src/chromium.rs new file mode 100644 index 000000000..16f933f1d --- /dev/null +++ b/apps/desktop/native-browser/src/chromium.rs @@ -0,0 +1,116 @@ +//! Direct CEF guest presentation behind an explicit native feasibility gate. +//! +//! CEF application installation occurs before Tauri/AppKit. A missing component +//! never falls back to another engine under a Chromium label. + +#[cfg(colossus_cef_linked)] +mod callbacks; +#[cfg(colossus_cef_linked)] +mod ffi; +#[cfg(colossus_cef_linked)] +mod linked; + +#[cfg(colossus_cef_linked)] +pub use linked::{Surface, bootstrap, pump, shutdown}; + +use crate::BrowserError; + +/// Result of the real application-entry bootstrap, before any Tauri UI exists. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Bootstrap { + Ready, + SubprocessExit(i32), + Unavailable(BrowserError), +} + +/// Actual availability established by early bootstrap; never a compile-time guess. +/// +/// # Errors +/// Reports missing bootstrap, component, or native platform acceptance. +pub fn readiness() -> Result<(), BrowserError> { + #[cfg(colossus_cef_linked)] + return linked::readiness(); + #[cfg(not(colossus_cef_linked))] + Err(BrowserError::ComponentMissing) +} + +#[cfg(not(colossus_cef_linked))] +/// Missing-component implementation keeps optional preview builds inspectable. +pub struct Surface; + +#[cfg(not(colossus_cef_linked))] +impl Clone for Surface { + fn clone(&self) -> Self { + Self + } +} + +#[cfg(not(colossus_cef_linked))] +#[allow(clippy::unused_async)] // Preserve the same asynchronous linked lifecycle API. +impl Surface { + /// Create the same native guest later controlled by the runtime bridge. + /// + /// # Errors + /// The unlinked build reports the missing verified component explicitly. + pub async fn create( + _: &tauri::Window, + _: &str, + _: u64, + _: std::path::PathBuf, + _: crate::NavigationPolicy, + _: crate::EventSink, + ) -> Result { + Err(BrowserError::ComponentMissing) + } + + /// # Errors + /// Chromium is absent in this build. + pub fn navigate(&self, _: &url::Url) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn hide(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn show(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub fn set_bounds(&self, _: tauri::Rect) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub async fn close(&self) -> Result<(), BrowserError> { + readiness() + } + /// # Errors + /// Chromium is absent in this build. + pub async fn inspect(&self) -> Result { + Err(BrowserError::ComponentMissing) + } + /// # Errors + /// Chromium is absent in this build. + pub async fn control(&self, _: crate::NavigationAction) -> Result<(), BrowserError> { + readiness() + } +} + +#[cfg(not(colossus_cef_linked))] +/// Initialize before the native application is created. +#[must_use] +pub fn bootstrap(_: &std::path::Path, _: &std::path::Path) -> Bootstrap { + Bootstrap::Unavailable(BrowserError::ComponentMissing) +} + +#[cfg(not(colossus_cef_linked))] +/// Pump callback is inert when no component is linked. +pub fn pump() {} + +#[cfg(not(colossus_cef_linked))] +/// No native component needs teardown in an unlinked build. +pub fn shutdown() {} diff --git a/apps/desktop/native-browser/src/chromium/callbacks.rs b/apps/desktop/native-browser/src/chromium/callbacks.rs new file mode 100644 index 000000000..803209bad --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/callbacks.rs @@ -0,0 +1,230 @@ +//! Bounded callback copies and generation fencing. No page receives host objects. + +use std::{ + collections::HashMap, + ffi::{c_char, c_void}, + sync::{Mutex, OnceLock}, +}; + +use tokio::sync::oneshot; + +use crate::{BrowserEvent, EventSink, NavigationPolicy, PageState}; + +use super::ffi; + +const MAX_STATE_BYTES: usize = 32 * 1024; +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageUpdate { + url: Option, + title: Option, + can_go_back: Option, + can_go_forward: Option, + loading: Option, +} +pub(super) struct Entry { + pub generation: u64, + pub policy: NavigationPolicy, + pub sink: EventSink, + pub page: PageState, + pub created: Option>, + pub closed: Option>, + pub inspection: Option>, + pub abandoned: bool, +} + +pub(super) fn entries() -> &'static Mutex> { + static ENTRIES: OnceLock>> = OnceLock::new(); + ENTRIES.get_or_init(Mutex::default) +} + +pub(super) fn callbacks() -> ffi::Callbacks { + ffi::Callbacks { + owner: std::ptr::null_mut(), + event, + allow_url, + select_identity, + schedule_pump: None, + } +} + +unsafe extern "C" fn event( + _: *mut c_void, + tab: u64, + generation: u64, + event: u32, + _: i32, + success: i32, + payload: *const u8, + length: usize, +) { + // Native callers own the payload during this callback. All parsing/copies + // are bounded, and panics never unwind into Chromium. + let _ = std::panic::catch_unwind(|| { + let Ok(mut entries) = entries().lock() else { + return; + }; + let Some(entry) = entries + .get_mut(&tab) + .filter(|entry| entry.generation == generation) + else { + return; + }; + let mut emit = None; + match event { + 1 => { + if let Some(sender) = entry.created.take() { + let _ = sender.send(()); + } + } + 2 => { + entry.page.loading = success != 0; + emit = Some(BrowserEvent::Loading(success != 0)); + } + 3 if !payload.is_null() && length <= MAX_STATE_BYTES => { + // SAFETY: Shim guarantees a valid borrowed buffer of `length` bytes. + let bytes = unsafe { std::slice::from_raw_parts(payload, length) }; + if let Ok(page) = serde_json::from_slice::(bytes) { + if page.url.as_ref().is_some_and(|url| url.len() > 8_192) + || page.title.as_ref().is_some_and(|title| title.len() > 4_096) + { + return; + } + if let Some(url) = page.url { + entry.page.url = if entry.policy.allows(&url) { + url + } else { + String::new() + }; + } + if let Some(title) = page.title { + entry.page.title = title; + } + if let Some(value) = page.can_go_back { + entry.page.can_go_back = value; + } + if let Some(value) = page.can_go_forward { + entry.page.can_go_forward = value; + } + if let Some(value) = page.loading { + entry.page.loading = value; + } + if let Some(sender) = entry.inspection.take() { + let _ = sender.send(entry.page.clone()); + } + } + } + 4 => emit = Some(BrowserEvent::Failed), + 5 | 13 | 15 => emit = Some(BrowserEvent::Crashed), + 6 => emit = Some(BrowserEvent::Blocked), + 7 => emit = Some(BrowserEvent::Download), + 8 if !payload.is_null() && length <= 8_192 => { + // SAFETY: Shim guarantees callback buffer lifetime and length. + if let Ok(url) = + std::str::from_utf8(unsafe { std::slice::from_raw_parts(payload, length) }) + { + emit = Some(BrowserEvent::Popup(url.to_owned())); + } + } + 9 => emit = Some(BrowserEvent::TlsFailed), + 10 => { + if let Some(sender) = entry.closed.take() { + let _ = sender.send(()); + } + // OnBeforeClose is terminal even if the original waiter timed + // out. Removal lets a later native-close reconciliation succeed. + entries.remove(&tab); + return; + } + 14 => emit = Some(BrowserEvent::AuthenticationRequired), + _ => {} + } + let sink = entry.sink.clone(); + drop(entries); + if let Some(event) = emit { + sink(event); + } + }); +} + +unsafe extern "C" fn allow_url( + _: *mut c_void, + tab: u64, + generation: u64, + url: *const c_char, + length: usize, + _: i32, +) -> i32 { + std::panic::catch_unwind(|| { + if url.is_null() || length > 8_192 { + return 0; + } + // SAFETY: Shim supplies a valid, bounded borrowed URL buffer. + let Ok(url) = + std::str::from_utf8(unsafe { std::slice::from_raw_parts(url.cast(), length) }) + else { + return 0; + }; + // Clone the authority reference under one tiny critical section. URL + // parsing/policy work never holds the lifetime-event registry lock. + let policy = { + let Ok(entries) = entries().lock() else { + return 0; + }; + entries + .get(&tab) + .filter(|entry| entry.generation == generation && !entry.abandoned) + .map(|entry| entry.policy.clone()) + }; + i32::from(policy.is_some_and(|policy| policy.allows(url))) + }) + .unwrap_or(0) +} + +unsafe extern "C" fn select_identity( + _: *mut c_void, + _: u64, + _: u64, + _: *const c_char, + _: usize, + _: *const ffi::Certificate, + _: usize, +) -> i32 { + // Native origin-bound selection must be proven before enabling mTLS. Never + // fall through to an unrestricted Chromium or platform identity chooser. + -1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delayed_close_reconciles_after_waiter_cancellation_and_fences_generation() { + let tab = u64::MAX; + let (sender, receiver) = oneshot::channel(); + drop(receiver); + entries().lock().unwrap().insert( + tab, + Entry { + generation: 17, + policy: NavigationPolicy::default(), + sink: std::sync::Arc::new(|_| {}), + page: PageState::default(), + created: None, + closed: Some(sender), + inspection: None, + abandoned: false, + }, + ); + // SAFETY: Callback takes no payload for CLOSED and pointer is null. + unsafe { + event(std::ptr::null_mut(), tab, 16, 10, 0, 1, std::ptr::null(), 0); + } + assert!(entries().lock().unwrap().contains_key(&tab)); + unsafe { + event(std::ptr::null_mut(), tab, 17, 10, 0, 1, std::ptr::null(), 0); + } + assert!(!entries().lock().unwrap().contains_key(&tab)); + } +} diff --git a/apps/desktop/native-browser/src/chromium/ffi.rs b/apps/desktop/native-browser/src/chromium/ffi.rs new file mode 100644 index 000000000..5b1963612 --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/ffi.rs @@ -0,0 +1,69 @@ +//! Exact private ABI from `native/browser/include/colossus_cef.h`. + +use std::ffi::{c_char, c_void}; + +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct Bounds { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +#[repr(C)] +pub(super) struct Certificate { + pub der: *const u8, + pub der_len: usize, +} + +#[repr(C)] +pub(super) struct Callbacks { + pub owner: *mut c_void, + pub event: unsafe extern "C" fn(*mut c_void, u64, u64, u32, i32, i32, *const u8, usize), + pub allow_url: unsafe extern "C" fn(*mut c_void, u64, u64, *const c_char, usize, i32) -> i32, + pub select_identity: unsafe extern "C" fn( + *mut c_void, + u64, + u64, + *const c_char, + usize, + *const Certificate, + usize, + ) -> i32, + pub schedule_pump: Option, +} + +#[repr(C)] +pub(super) struct Options { + pub abi_version: u32, + pub argc: i32, + pub argv: *mut *mut c_char, + pub platform_instance: usize, + pub sandbox_info: *mut c_void, + pub root_cache_path: *const c_char, + pub browser_subprocess_path: *const c_char, + pub headless: i32, + pub callbacks: Callbacks, +} + +unsafe extern "C" { + pub(super) fn colossus_cef_bootstrap(options: *const Options, subprocess_exit: *mut i32) + -> i32; + pub(super) fn colossus_cef_pump() -> i32; + pub(super) fn colossus_cef_shutdown() -> i32; + pub(super) fn colossus_cef_create( + tab: u64, + generation: u64, + context_id: u64, + parent: usize, + bounds: Bounds, + initial_url: *const c_char, + ) -> i32; + pub(super) fn colossus_cef_navigate(tab: u64, generation: u64, url: *const c_char) -> i32; + pub(super) fn colossus_cef_control(tab: u64, generation: u64, action: u32) -> i32; + pub(super) fn colossus_cef_inspect(tab: u64, generation: u64) -> i32; + pub(super) fn colossus_cef_bounds_set(tab: u64, generation: u64, bounds: Bounds) -> i32; + pub(super) fn colossus_cef_visible(tab: u64, generation: u64, visible: i32) -> i32; + pub(super) fn colossus_cef_close(tab: u64, generation: u64) -> i32; +} diff --git a/apps/desktop/native-browser/src/chromium/linked.rs b/apps/desktop/native-browser/src/chromium/linked.rs new file mode 100644 index 000000000..ba8faeb2e --- /dev/null +++ b/apps/desktop/native-browser/src/chromium/linked.rs @@ -0,0 +1,484 @@ +//! Main-thread CEF lifecycle and real child-surface operations. + +use std::{ + collections::HashMap, + ffi::CString, + path::{Path, PathBuf}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use tauri::Window; +use tokio::sync::oneshot; + +use crate::{BrowserError, EventSink, NavigationAction, NavigationPolicy, PageState}; + +use super::{ + Bootstrap, + callbacks::{self, Entry}, + ffi, +}; + +static READY: OnceLock> = OnceLock::new(); +static NEXT_TAB: AtomicU64 = AtomicU64::new(1); +const DEADLINE: Duration = Duration::from_secs(5); + +pub(super) fn readiness() -> Result<(), BrowserError> { + READY + .get() + .copied() + .unwrap_or(Err(BrowserError::BootstrapRequired)) +} + +/// Initialize at the real entry point before Tauri creates its native application. +#[must_use] +pub fn bootstrap(root: &Path, helper: &Path) -> Bootstrap { + if READY.get().is_some() { + return Bootstrap::Unavailable(BrowserError::BootstrapRequired); + } + // Ordinary Tauri Windows entry does not possess supported CEF sandbox info. + // Never set no_sandbox=true just to make this topology start. + #[cfg(windows)] + { + let _ = (root, helper); + let _ = READY.set(Err(BrowserError::BootstrapRequired)); + return Bootstrap::Unavailable(BrowserError::BootstrapRequired); + } + #[cfg(not(windows))] + { + let result = bootstrap_inner(root, helper); + let _ = READY.set(match result { + Bootstrap::Ready => Ok(()), + Bootstrap::Unavailable(error) => Err(error), + Bootstrap::SubprocessExit(_) => Err(BrowserError::Closed), + }); + result + } +} + +#[cfg(not(windows))] +fn bootstrap_inner(root: &Path, helper: &Path) -> Bootstrap { + if !helper.is_file() { + return Bootstrap::Unavailable(BrowserError::ComponentMissing); + } + let build = || -> Result<(CString, CString, Vec), BrowserError> { + let root = CString::new(root.to_str().ok_or(BrowserError::Unavailable)?) + .map_err(|_| BrowserError::Unavailable)?; + let helper = CString::new(helper.to_str().ok_or(BrowserError::Unavailable)?) + .map_err(|_| BrowserError::Unavailable)?; + let args = std::env::args_os() + .map(|arg| { + arg.into_string() + .map_err(|_| BrowserError::Unavailable) + .and_then(|arg| CString::new(arg).map_err(|_| BrowserError::Unavailable)) + }) + .collect::, _>>()?; + Ok((root, helper, args)) + }; + let Ok((root, helper, args)) = build() else { + return Bootstrap::Unavailable(BrowserError::Unavailable); + }; + let mut pointers = args + .iter() + .map(|arg| arg.as_ptr().cast_mut()) + .collect::>(); + let options = ffi::Options { + abi_version: 1, + argc: i32::try_from(pointers.len()).unwrap_or(0), + argv: pointers.as_mut_ptr(), + platform_instance: 0, + sandbox_info: std::ptr::null_mut(), + root_cache_path: root.as_ptr(), + browser_subprocess_path: helper.as_ptr(), + headless: 0, + callbacks: callbacks::callbacks(), + }; + let mut exit = -1; + // SAFETY: Borrowed argument buffers outlive this call. Callbacks reference + // process-static storage. Native shim performs pre-AppKit installation. + let code = unsafe { ffi::colossus_cef_bootstrap(&raw const options, &raw mut exit) }; + if code != 0 { + return Bootstrap::Unavailable(BrowserError::NativeAcceptanceRequired); + } + if exit >= 0 { + Bootstrap::SubprocessExit(exit) + } else { + Bootstrap::Ready + } +} + +/// Called only from Tauri's main-thread scheduler. +pub fn pump() { + if readiness().is_ok() { + // SAFETY: Native shim rejects wrong-thread/lifecycle calls. + let _ = unsafe { ffi::colossus_cef_pump() }; + } +} + +/// Shutdown occurs on main thread after native close acknowledgements. +pub fn shutdown() { + if readiness().is_ok() { + // SAFETY: The application-entry owner remains alive until this returns. + if unsafe { ffi::colossus_cef_shutdown() } == 0 + && let Ok(mut entries) = callbacks::entries().lock() + { + entries.clear(); + } + } +} + +/// Real CEF tab, not a `WebView` navigated to a matching URL. +#[derive(Clone)] +pub struct Surface { + window: Window, + tab: u64, + generation: u64, +} + +struct PendingCreation(Option); +impl Drop for PendingCreation { + fn drop(&mut self) { + if let Some(surface) = self.0.take() { + surface.abandon(); + } + } +} + +impl Surface { + /// Create one hidden child CEF view after pre-application initialization. + /// + /// # Errors + /// Fails closed on missing bootstrap, parent, context or native readiness. + pub async fn create( + window: &Window, + _: &str, + generation: u64, + directory: PathBuf, + policy: NavigationPolicy, + sink: EventSink, + ) -> Result { + readiness()?; + let tab = NEXT_TAB.fetch_add(1, Ordering::Relaxed); + let context = context_id(directory)?; + let (send, created) = oneshot::channel(); + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .insert( + tab, + Entry { + generation, + policy, + sink, + page: PageState::default(), + created: Some(send), + closed: None, + inspection: None, + abandoned: false, + }, + ); + let surface = Self { + window: window.clone(), + tab, + generation, + }; + let mut pending = PendingCreation(Some(surface.clone())); + let parent_window = window.clone(); + surface + .dispatch(move || { + let parent = parent(&parent_window)?; + let url = CString::new("about:blank").map_err(|_| BrowserError::InvalidAddress)?; + // SAFETY: Parent belongs to the live Tauri main window; creation runs + // on its main thread. Native callback generation is registered first. + status(unsafe { + ffi::colossus_cef_create( + tab, + generation, + context, + parent, + ffi::Bounds { + x: -10_000, + y: -10_000, + width: 16, + height: 16, + }, + url.as_ptr(), + ) + }) + }) + .await?; + tokio::time::timeout(DEADLINE, created) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + surface.hide()?; + pending.0 = None; + Ok(surface) + } + + fn abandon(&self) { + if let Ok(mut entries) = callbacks::entries().lock() + && let Some(entry) = entries.get_mut(&self.tab) + { + entry.abandoned = true; + } + let (tab, generation) = (self.tab, self.generation); + let _ = self.window.run_on_main_thread(move || { + // SAFETY: Exact generation; shim closes a pending or created guest. + if unsafe { ffi::colossus_cef_close(tab, generation) } == 3 + && let Ok(mut entries) = callbacks::entries().lock() + { + entries.remove(&tab); + } + }); + } + + /// # Errors + /// Rejects invalid address, closed guest, or stale native generation. + pub fn navigate(&self, url: &url::Url) -> Result<(), BrowserError> { + let url = CString::new(url.as_str()).map_err(|_| BrowserError::InvalidAddress)?; + self.schedule(move |tab, generation| { + // SAFETY: Scheduled on the owning UI thread; URL lives through call. + status(unsafe { ffi::colossus_cef_navigate(tab, generation, url.as_ptr()) }) + }) + } + + /// # Errors + /// Fails when the native presentation owner has closed. + pub fn hide(&self) -> Result<(), BrowserError> { + self.visible(false) + } + /// # Errors + /// Fails when the native presentation owner has closed. + pub fn show(&self) -> Result<(), BrowserError> { + self.visible(true) + } + fn visible(&self, visible: bool) -> Result<(), BrowserError> { + self.schedule(move |tab, generation| { + // SAFETY: The shim validates exact native identity on UI thread. + status(unsafe { ffi::colossus_cef_visible(tab, generation, i32::from(visible)) }) + }) + } + + /// # Errors + /// Rejects nonfinite, oversized, or invalid native bounds. + pub fn set_bounds(&self, bounds: tauri::Rect) -> Result<(), BrowserError> { + let scale = self + .window + .scale_factor() + .map_err(|_| BrowserError::Closed)?; + let position = bounds.position.to_logical::(scale); + let size = bounds.size.to_logical::(scale); + let factor = if cfg!(windows) { scale } else { 1.0 }; + let values = [ + position.x * factor, + position.y * factor, + size.width * factor, + size.height * factor, + ]; + if values + .iter() + .any(|value| !value.is_finite() || *value < 0.0 || *value > f64::from(i32::MAX)) + || size.width < 16.0 + || size.height < 16.0 + { + return Err(BrowserError::Unavailable); + } + #[allow(clippy::cast_possible_truncation)] + let bounds = ffi::Bounds { + x: values[0].round() as i32, + y: values[1].round() as i32, + width: values[2].round() as i32, + height: values[3].round() as i32, + }; + self.schedule(move |tab, generation| { + // SAFETY: Bounds were validated and call runs on UI thread. + status(unsafe { ffi::colossus_cef_bounds_set(tab, generation, bounds) }) + }) + } + + /// Wait for Chromium's actual `OnBeforeClose` before releasing the profile. + /// + /// # Errors + /// Failure preserves native context/profile ownership for reconciliation. + pub async fn close(&self) -> Result<(), BrowserError> { + let (sender, receive) = oneshot::channel(); + { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let Some(entry) = entries.get_mut(&self.tab) else { + return Ok(()); + }; + if entry.generation != self.generation + || entry + .closed + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + { + return Err(BrowserError::Closed); + } + entry.closed = Some(sender); + } + let (tab, generation) = (self.tab, self.generation); + let dispatched = self + .dispatch(move || { + // SAFETY: Close occurs on the UI thread with exact generation. + status(unsafe { ffi::colossus_cef_close(tab, generation) }) + }) + .await; + if matches!(dispatched, Err(BrowserError::Closed)) { + // Native close returns CLOSED only for a missing/wrong-generation + // target, including a previously acknowledged delayed close. + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .remove(&tab); + return Ok(()); + } + dispatched?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)? + .remove(&self.tab); + Ok(()) + } + + /// # Errors + /// Fails if the native guest is closed, busy, or its callback expires. + pub async fn inspect(&self) -> Result { + let (sender, receive) = oneshot::channel(); + { + let mut entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + let entry = entries + .get_mut(&self.tab) + .filter(|entry| entry.generation == self.generation) + .ok_or(BrowserError::Closed)?; + if entry + .inspection + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + { + return Err(BrowserError::Unavailable); + } + entry.inspection = Some(sender); + } + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Native metadata read on owning UI thread. + status(unsafe { ffi::colossus_cef_inspect(tab, generation) }) + }) + .await?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed) + } + + /// # Errors + /// Fails when the tab has closed or its native generation changed. + pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { + let action = match action { + NavigationAction::Back => 1, + NavigationAction::Forward => 2, + NavigationAction::Reload => 3, + NavigationAction::Stop => 4, + }; + let (tab, generation) = (self.tab, self.generation); + self.dispatch(move || { + // SAFETY: Closed operation set, exact native identity, UI thread. + status(unsafe { ffi::colossus_cef_control(tab, generation, action) }) + }) + .await + } + + fn schedule( + &self, + action: impl FnOnce(u64, u64) -> Result<(), BrowserError> + Send + 'static, + ) -> Result<(), BrowserError> { + let (tab, generation) = (self.tab, self.generation); + let entries = callbacks::entries() + .lock() + .map_err(|_| BrowserError::Closed)?; + if !entries + .get(&tab) + .is_some_and(|entry| entry.generation == generation && entry.closed.is_none()) + { + return Err(BrowserError::Closed); + } + drop(entries); + let (sender, receive) = std::sync::mpsc::sync_channel(1); + self.window + .run_on_main_thread(move || { + let _ = sender.send(action(tab, generation)); + }) + .map_err(|_| BrowserError::Closed)?; + // Tauri executes the closure inline when this is its main thread. A + // worker waits only for the operation itself, never for CEF callbacks. + receive + .recv_timeout(DEADLINE) + .map_err(|_| BrowserError::TimedOut)? + } + + async fn dispatch( + &self, + action: impl FnOnce() -> Result + Send + 'static, + ) -> Result { + let (sender, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + let _ = sender.send(action()); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(DEADLINE, receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + } +} + +fn status(value: i32) -> Result<(), BrowserError> { + match value { + 0 => Ok(()), + 3 => Err(BrowserError::Closed), + _ => Err(BrowserError::Unavailable), + } +} + +fn context_id(directory: PathBuf) -> Result { + static CONTEXTS: OnceLock>> = OnceLock::new(); + let mut contexts = CONTEXTS + .get_or_init(Mutex::default) + .lock() + .map_err(|_| BrowserError::Closed)?; + let next = u64::try_from(contexts.len()).map_err(|_| BrowserError::Unavailable)? + 1; + Ok(*contexts.entry(directory).or_insert(next)) +} + +#[cfg(target_os = "macos")] +fn parent(window: &Window) -> Result { + window + .ns_view() + .map(|view| view as usize) + .map_err(|_| BrowserError::Closed) +} +#[cfg(windows)] +fn parent(window: &Window) -> Result { + window + .hwnd() + .map(|window| window.0 as usize) + .map_err(|_| BrowserError::Closed) +} +#[cfg(not(any(windows, target_os = "macos")))] +fn parent(_: &Window) -> Result { + Err(BrowserError::NativeAcceptanceRequired) +} diff --git a/apps/desktop/native-browser/src/engine.rs b/apps/desktop/native-browser/src/engine.rs index ca5887c8f..e1dbc62c3 100644 --- a/apps/desktop/native-browser/src/engine.rs +++ b/apps/desktop/native-browser/src/engine.rs @@ -108,7 +108,7 @@ async fn dispatch( #[cfg(not(any(windows, target_os = "macos")))] mod platform { - use super::*; + use super::{BrowserError, EventSink, NavigationAction, NavigationPolicy, PageState}; pub fn harden( _: &tauri::webview::PlatformWebview, _: NavigationPolicy, diff --git a/apps/desktop/native-browser/src/lib.rs b/apps/desktop/native-browser/src/lib.rs index 58e5a4d20..111c158bb 100644 --- a/apps/desktop/native-browser/src/lib.rs +++ b/apps/desktop/native-browser/src/lib.rs @@ -4,11 +4,16 @@ //! or host objects. Future agent control must enter through a separately authorized //! runtime adapter; possession of a guest handle is not an agent permit. +#[cfg(feature = "cef-preview")] +pub mod chromium; mod engine; mod navigation; +#[cfg(feature = "cef-preview")] +pub mod pki; #[cfg(any(target_os = "macos", test))] mod response; mod types; +mod view; #[cfg(all(target_os = "macos", feature = "native-test-driver"))] pub mod acceptance; @@ -21,6 +26,7 @@ mod windows; pub use engine::{control, harden, inspect, is_active, open_external, release, share_session}; pub use navigation::{NavigationPolicy, parse_address}; pub use types::{BrowserError, BrowserEvent, EventSink, NavigationAction, PageState}; +pub use view::BrowserView; #[cfg(all(windows, feature = "native-test-driver"))] pub use windows::probe_file_picker; diff --git a/apps/desktop/native-browser/src/pki.rs b/apps/desktop/native-browser/src/pki.rs new file mode 100644 index 000000000..7ddcfed1e --- /dev/null +++ b/apps/desktop/native-browser/src/pki.rs @@ -0,0 +1,209 @@ +//! Native software-certificate import. Store scope is wider than browser profiles. +//! +//! This API receives only native inputs. Password/key bytes have no serialization +//! or diagnostic surface. Imports do not establish Chromium identity-use proof. + +use rustls_pki_types::{CertificateDer, pem::PemObject as _}; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; +use x509_parser::prelude::{FromDer as _, X509Certificate}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +/// Non-secret native provisioning metadata only. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] // Independent native capability projection. +pub struct PkiStatus { + pub scope: &'static str, + pub ca_import_available: bool, + pub pfx_import_available: bool, + pub client_identity_selection_ready: bool, + pub acceptance_pending: bool, + pub message: &'static str, +} + +/// A completed native-store import, not proof of a successful mTLS handshake. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportReceipt { + pub scope: &'static str, + pub fingerprints_sha256: Vec, + pub identity_selection_ready: bool, +} + +/// Categorical failures never include selected paths or secret/native error text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PkiError { + Unsupported, + InvalidCertificate, + InvalidIdentity, + Unavailable, + StoreChangeUnknown, +} + +impl std::fmt::Display for PkiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Unsupported => "Native browser certificate import is unavailable on this platform.", + Self::InvalidCertificate => "Choose one valid CA certificate in PEM or DER format.", + Self::InvalidIdentity => "The PKCS#12 identity or its password could not be validated.", + Self::Unavailable => "The native certificate store could not complete the import.", + Self::StoreChangeUnknown => "The native store may have changed before import failed. Review the user certificate store before retrying.", + }) + } +} +impl std::error::Error for PkiError {} + +/// Actual compiled platform importer status. Native use still requires acceptance. +#[must_use] +pub fn status() -> PkiStatus { + let supported = cfg!(any(windows, target_os = "macos")); + PkiStatus { + scope: if supported { + "operating_system_user" + } else { + "unsupported" + }, + ca_import_available: supported, + pfx_import_available: supported, + client_identity_selection_ready: false, + acceptance_pending: true, + message: if supported { + "Imports affect this operating-system user's certificate store and can affect other applications. Browser profiles do not isolate this trust. Native import and Chromium key-use acceptance are pending." + } else { + "This Desktop build has no native browser certificate importer. Colossus network credentials do not provision Chromium trust or client identities." + }, + } +} + +/// Import one reviewed CA into the user's native trust store. +/// +/// # Errors +/// Rejects non-CA/expired/bounded input and reports categorical native failures. +pub fn import_ca(bytes: &[u8]) -> Result { + let certificate = validate_ca(bytes)?; + #[cfg(windows)] + windows::import_ca(&certificate)?; + #[cfg(target_os = "macos")] + macos::import_ca(&certificate)?; + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = certificate; + Err(PkiError::Unsupported) + } + #[cfg(any(windows, target_os = "macos"))] + Ok(receipt(vec![fingerprint(&certificate)])) +} + +/// Validate a selected native CA and return its DER fingerprint for final review. +/// +/// # Errors +/// Rejects invalid, expired, non-CA, or oversized certificates without mutation. +pub fn ca_fingerprint(bytes: &[u8]) -> Result { + validate_ca(bytes).map(|der| fingerprint(&der)) +} + +/// Import a native-entered PKCS#12 identity into the user's native key store. +/// Windows imports do not request exportable keys or overwrite existing keys. +/// +/// # Errors +/// Rejects empty/oversized input, wrong password, and categorical store failures. +pub fn import_pfx(bytes: &[u8], password: &str) -> Result { + if bytes.is_empty() + || bytes.len() > 4 * 1024 * 1024 + || password.len() > 64 * 1024 + || password.contains('\0') + { + return Err(PkiError::InvalidIdentity); + } + #[cfg(windows)] + return windows::import_pfx(bytes, password).map(receipt); + #[cfg(target_os = "macos")] + return macos::import_pfx(bytes, password).map(receipt); + #[cfg(not(any(windows, target_os = "macos")))] + Err(PkiError::Unsupported) +} + +#[cfg(any(windows, target_os = "macos"))] +fn receipt(fingerprints: Vec) -> ImportReceipt { + ImportReceipt { + scope: "operating_system_user", + fingerprints_sha256: fingerprints, + identity_selection_ready: false, + } +} + +fn fingerprint(bytes: &[u8]) -> String { + use std::fmt::Write as _; + Sha256::digest(bytes) + .iter() + .fold(String::with_capacity(64), |mut result, byte| { + let _ = write!(result, "{byte:02x}"); + result + }) +} + +fn validate_ca(bytes: &[u8]) -> Result, PkiError> { + if bytes.is_empty() || bytes.len() > 1024 * 1024 { + return Err(PkiError::InvalidCertificate); + } + let certificates = if bytes.starts_with(b"-----BEGIN") { + CertificateDer::pem_slice_iter(bytes) + .collect::, _>>() + .map_err(|_| PkiError::InvalidCertificate)? + } else { + vec![CertificateDer::from(bytes)] + }; + if certificates.len() != 1 { + return Err(PkiError::InvalidCertificate); + } + let der = certificates[0].as_ref(); + let (remaining, certificate) = + X509Certificate::from_der(der).map_err(|_| PkiError::InvalidCertificate)?; + if !remaining.is_empty() + || !certificate.validity().is_valid() + || !certificate + .basic_constraints() + .map_err(|_| PkiError::InvalidCertificate)? + .is_some_and(|value| value.value.ca) + || certificate + .key_usage() + .map_err(|_| PkiError::InvalidCertificate)? + .is_some_and(|usage| !usage.value.key_cert_sign()) + { + return Err(PkiError::InvalidCertificate); + } + Ok(der.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn imports_reject_oversized_or_malformed_inputs_before_native_effects() { + assert!(matches!( + import_ca(b"not a certificate"), + Err(PkiError::InvalidCertificate) + )); + assert!(matches!( + import_pfx(&[], ""), + Err(PkiError::InvalidIdentity) + )); + assert!(matches!( + import_pfx(b"x", "bad\0password"), + Err(PkiError::InvalidIdentity) + )); + assert_eq!(fingerprint(b"x").len(), 64); + } + #[test] + fn native_status_never_claims_profile_isolation_or_mtls_readiness() { + let status = status(); + assert!(!status.client_identity_selection_ready); + assert!(status.acceptance_pending); + assert_ne!(status.scope, "browser_profile"); + } +} diff --git a/apps/desktop/native-browser/src/pki/macos.rs b/apps/desktop/native-browser/src/pki/macos.rs new file mode 100644 index 000000000..6c1678e83 --- /dev/null +++ b/apps/desktop/native-browser/src/pki/macos.rs @@ -0,0 +1,218 @@ +//! Security.framework provisioning in the user's default keychain/trust domain. + +use std::ffi::c_void; + +use super::{PkiError, fingerprint}; + +type CF = *const c_void; +const UTF8: u32 = 0x0800_0100; +const DUPLICATE_ITEM: i32 = -25299; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(value: CF); + fn CFDataCreate(allocator: CF, bytes: *const u8, length: isize) -> CF; + fn CFDataGetLength(data: CF) -> isize; + fn CFDataGetBytePtr(data: CF) -> *const u8; + fn CFStringCreateWithBytes( + allocator: CF, + bytes: *const u8, + length: isize, + encoding: u32, + external: u8, + ) -> CF; + fn CFDictionaryCreate( + allocator: CF, + keys: *const CF, + values: *const CF, + count: isize, + key_callbacks: *const c_void, + value_callbacks: *const c_void, + ) -> CF; + fn CFDictionaryGetValue(dictionary: CF, key: CF) -> CF; + fn CFArrayGetCount(array: CF) -> isize; + fn CFArrayGetValueAtIndex(array: CF, index: isize) -> CF; + fn CFArrayCreate( + allocator: CF, + values: *const CF, + count: isize, + callbacks: *const c_void, + ) -> CF; + fn CFNumberCreate(allocator: CF, kind: isize, value: *const c_void) -> CF; +} + +#[link(name = "Security", kind = "framework")] +unsafe extern "C" { + static kSecImportExportPassphrase: CF; + static kSecImportExportKeychain: CF; + static kSecImportItemIdentity: CF; + static kSecTrustSettingsResult: CF; + fn SecKeychainCopyDefault(keychain: *mut CF) -> i32; + fn SecCertificateCreateWithData(allocator: CF, data: CF) -> CF; + fn SecCertificateAddToKeychain(certificate: CF, keychain: CF) -> i32; + fn SecTrustSettingsSetTrustSettings(certificate: CF, domain: u32, settings: CF) -> i32; + fn SecPKCS12Import(data: CF, options: CF, items: *mut CF) -> i32; + fn SecIdentityCopyCertificate(identity: CF, certificate: *mut CF) -> i32; + fn SecCertificateCopyData(certificate: CF) -> CF; +} + +struct Owned(CF); +impl Owned { + fn new(value: CF) -> Result { + if value.is_null() { + Err(PkiError::Unavailable) + } else { + Ok(Self(value)) + } + } +} +impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: Every owner represents one non-null Create/Copy reference. + unsafe { + CFRelease(self.0); + } + } +} + +fn data(bytes: &[u8]) -> Result { + // SAFETY: CF copies the bounded borrowed bytes into its native buffer. + Owned::new(unsafe { + CFDataCreate( + std::ptr::null(), + bytes.as_ptr(), + isize::try_from(bytes.len()).map_err(|_| PkiError::InvalidIdentity)?, + ) + }) +} + +fn keychain() -> Result { + let mut keychain = std::ptr::null(); + // SAFETY: Copy API writes one retained keychain reference on success. + if unsafe { SecKeychainCopyDefault(&raw mut keychain) } != 0 { + return Err(PkiError::Unavailable); + } + Owned::new(keychain) +} + +pub(super) fn import_ca(der: &[u8]) -> Result<(), PkiError> { + let bytes = data(der)?; + let keychain = keychain()?; + // SAFETY: Security.framework reads a live CFData and returns retained cert. + let certificate = + Owned::new(unsafe { SecCertificateCreateWithData(std::ptr::null(), bytes.0) })?; + // SAFETY: Explicit user default keychain, never System keychain. + let code = unsafe { SecCertificateAddToKeychain(certificate.0, keychain.0) }; + if code != 0 && code != DUPLICATE_ITEM { + return Err(PkiError::Unavailable); + } + // Explicit trust root/as-root result supports reviewed root and intermediate + // CA anchors. This is an OS-user change, not per-browser-profile trust. + let (_, parsed) = + x509_parser::parse_x509_certificate(der).map_err(|_| PkiError::InvalidCertificate)?; + let result: i32 = if parsed.subject() == parsed.issuer() { + 1 + } else { + 2 + }; + // kCFNumberSInt32Type = 3. The no-retain containers live only within this call. + let result = + Owned::new(unsafe { CFNumberCreate(std::ptr::null(), 3, (&raw const result).cast()) })?; + let keys = unsafe { [kSecTrustSettingsResult] }; + let values = [result.0]; + let dictionary = Owned::new(unsafe { + CFDictionaryCreate( + std::ptr::null(), + keys.as_ptr(), + values.as_ptr(), + 1, + std::ptr::null(), + std::ptr::null(), + ) + })?; + let settings = Owned::new(unsafe { + CFArrayCreate( + std::ptr::null(), + &raw const dictionary.0, + 1, + std::ptr::null(), + ) + })?; + // The OS may require native authorization before this operation succeeds. + // SAFETY: Certificate is native-owned and lives through synchronous call. + if unsafe { SecTrustSettingsSetTrustSettings(certificate.0, 0, settings.0) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + Ok(()) +} + +pub(super) fn import_pfx(bytes: &[u8], password: &str) -> Result, PkiError> { + let bytes = data(bytes)?; + let keychain = keychain()?; + // The passphrase is copied only into a native CFString. Its owned buffer is + // released after import and is never bridged to JavaScript, DTOs, or logs. + // OS/native control allocations cannot promise explicit byte zeroization. + // SAFETY: CF copies a valid bounded UTF-8 buffer. + let password = Owned::new(unsafe { + CFStringCreateWithBytes( + std::ptr::null(), + password.as_ptr(), + isize::try_from(password.len()).map_err(|_| PkiError::InvalidIdentity)?, + UTF8, + 0, + ) + })?; + // No-retain callbacks: local owners and Security's static keys outlive call. + let keys = unsafe { [kSecImportExportPassphrase, kSecImportExportKeychain] }; + let values = [password.0, keychain.0]; + // SAFETY: Both fixed arrays contain live CF references throughout import. + let options = Owned::new(unsafe { + CFDictionaryCreate( + std::ptr::null(), + keys.as_ptr(), + values.as_ptr(), + 2, + std::ptr::null(), + std::ptr::null(), + ) + })?; + let mut items = std::ptr::null(); + // Explicit keychain option avoids assuming PKCS12's platform default scope. + // SAFETY: Native objects live during synchronous import; API retains output. + if unsafe { SecPKCS12Import(bytes.0, options.0, &raw mut items) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + let items = Owned::new(items)?; + let count = unsafe { CFArrayGetCount(items.0) }; + if !(1..=64).contains(&count) { + return Err(PkiError::StoreChangeUnknown); + } + let mut fingerprints = Vec::new(); + for index in 0..count { + // SAFETY: PKCS12 import output is documented as array of dictionaries. + let dictionary = unsafe { CFArrayGetValueAtIndex(items.0, index) }; + let identity = unsafe { CFDictionaryGetValue(dictionary, kSecImportItemIdentity) }; + if identity.is_null() { + return Err(PkiError::StoreChangeUnknown); + } + let mut certificate = std::ptr::null(); + if unsafe { SecIdentityCopyCertificate(identity, &raw mut certificate) } != 0 { + return Err(PkiError::StoreChangeUnknown); + } + let certificate = Owned::new(certificate)?; + let der = Owned::new(unsafe { SecCertificateCopyData(certificate.0) })?; + let length = unsafe { CFDataGetLength(der.0) }; + if !(1..=65_536).contains(&length) { + return Err(PkiError::StoreChangeUnknown); + } + // SAFETY: CFData supplies a live bounded DER buffer until release. + let bytes = unsafe { + std::slice::from_raw_parts( + CFDataGetBytePtr(der.0), + usize::try_from(length).map_err(|_| PkiError::StoreChangeUnknown)?, + ) + }; + fingerprints.push(fingerprint(bytes)); + } + Ok(fingerprints) +} diff --git a/apps/desktop/native-browser/src/pki/windows.rs b/apps/desktop/native-browser/src/pki/windows.rs new file mode 100644 index 000000000..ea4d1d9fa --- /dev/null +++ b/apps/desktop/native-browser/src/pki/windows.rs @@ -0,0 +1,155 @@ +//! Current User stores only; keys are never marked exportable or overwritten. + +use windows_sys::Win32::Security::Cryptography::{ + CERT_CONTEXT, CERT_KEY_PROV_INFO_PROP_ID, CERT_STORE_ADD_NEW, CRYPT_INTEGER_BLOB, + CRYPT_USER_KEYSET, CertAddCertificateContextToStore, CertCloseStore, + CertCreateCertificateContext, CertEnumCertificatesInStore, CertFreeCertificateContext, + CertGetCertificateContextProperty, CertOpenSystemStoreW, HCERTSTORE, PFXImportCertStore, + PFXVerifyPassword, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING, +}; +use zeroize::Zeroizing; + +use super::{PkiError, fingerprint}; + +struct Store(HCERTSTORE); +impl Drop for Store { + fn drop(&mut self) { + // SAFETY: This owner holds exactly one returned store handle. + unsafe { + CertCloseStore(self.0, 0); + } + } +} +struct Certificate(*const CERT_CONTEXT); +impl Drop for Certificate { + fn drop(&mut self) { + // SAFETY: This owner holds exactly one certificate context reference. + unsafe { + CertFreeCertificateContext(self.0); + } + } +} + +fn user_store(name: &str) -> Result { + let name = name.encode_utf16().chain(Some(0)).collect::>(); + // SAFETY: Null provider and fixed ROOT/MY names select Current User stores. + let store = unsafe { CertOpenSystemStoreW(0, name.as_ptr()) }; + if store.is_null() { + Err(PkiError::Unavailable) + } else { + Ok(Store(store)) + } +} + +pub(super) fn import_ca(der: &[u8]) -> Result<(), PkiError> { + let store = user_store("ROOT")?; + // SAFETY: Validated bounded DER is borrowed for this call; API returns a ref. + let context = unsafe { + CertCreateCertificateContext( + X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, + der.as_ptr(), + u32::try_from(der.len()).map_err(|_| PkiError::InvalidCertificate)?, + ) + }; + if context.is_null() { + return Err(PkiError::InvalidCertificate); + } + let context = Certificate(context); + // ADD_NEW prevents silently replacing an existing trust record/properties. + // SAFETY: Both handles live through this synchronous native call. + if unsafe { + CertAddCertificateContextToStore( + store.0, + context.0, + CERT_STORE_ADD_NEW, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(PkiError::Unavailable); + } + Ok(()) +} + +pub(super) fn import_pfx(bytes: &[u8], password: &str) -> Result, PkiError> { + let mut password = Zeroizing::new(password.encode_utf16().chain(Some(0)).collect::>()); + let blob = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(bytes.len()).map_err(|_| PkiError::InvalidIdentity)?, + pbData: bytes.as_ptr().cast_mut(), + }; + // SAFETY: Borrowed PFX and zeroizing password remain alive. First prove + // password/structure before the persistent key import has side effects. + if unsafe { PFXVerifyPassword(&raw const blob, password.as_ptr(), 0) } == 0 { + return Err(PkiError::InvalidIdentity); + } + let target = user_store("MY")?; + // CRYPT_USER_KEYSET creates user keys; neither CRYPT_EXPORTABLE nor + // PKCS12_ALLOW_OVERWRITE_KEY is requested. Never use NO_PERSIST_KEY for the + // effective import: Chromium helpers must access the native persisted key. + // SAFETY: Native buffers are valid for synchronous import; no key leaves OS. + let imported = + unsafe { PFXImportCertStore(&raw const blob, password.as_mut_ptr(), CRYPT_USER_KEYSET) }; + if imported.is_null() { + return Err(PkiError::StoreChangeUnknown); + } + let imported = Store(imported); + let mut previous = std::ptr::null(); + let mut fingerprints = Vec::new(); + loop { + // Enumeration frees the previous reference on each call, including end. + // SAFETY: Store lives throughout enumeration; previous is API-owned ref. + let certificate = unsafe { CertEnumCertificatesInStore(imported.0, previous) }; + if certificate.is_null() { + break; + } + previous = certificate; + if fingerprints.len() >= 64 { + // SAFETY: Enumeration will not consume this remaining reference. + unsafe { + CertFreeCertificateContext(certificate); + } + return Err(PkiError::StoreChangeUnknown); + } + let mut property_size = 0; + // Only identities with a persisted provider binding enter MY. Root and + // intermediate certificates in a PFX never become new trust anchors. + // SAFETY: Query with null data obtains property length only. + let has_key = unsafe { + CertGetCertificateContextProperty( + certificate, + CERT_KEY_PROV_INFO_PROP_ID, + std::ptr::null_mut(), + &raw mut property_size, + ) + } != 0; + if !has_key { + continue; + } + // SAFETY: Context's DER buffer lives until next enumeration step. + let der = unsafe { + std::slice::from_raw_parts( + (*certificate).pbCertEncoded, + (*certificate).cbCertEncoded as usize, + ) + }; + if unsafe { + CertAddCertificateContextToStore( + target.0, + certificate, + CERT_STORE_ADD_NEW, + std::ptr::null_mut(), + ) + } == 0 + { + unsafe { + CertFreeCertificateContext(certificate); + } + return Err(PkiError::StoreChangeUnknown); + } + fingerprints.push(fingerprint(der)); + } + if fingerprints.is_empty() { + return Err(PkiError::StoreChangeUnknown); + } + Ok(fingerprints) +} diff --git a/apps/desktop/native-browser/src/types.rs b/apps/desktop/native-browser/src/types.rs index 0c0e13b48..baf2128ff 100644 --- a/apps/desktop/native-browser/src/types.rs +++ b/apps/desktop/native-browser/src/types.rs @@ -13,7 +13,7 @@ pub enum NavigationAction { } /// Native observations. Titles and URLs are untrusted display data. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PageState { pub url: String, @@ -32,6 +32,10 @@ pub enum BrowserEvent { Blocked, Download, Popup(String), + /// Chromium rejected a certificate; no TLS-error override is available. + TlsFailed, + /// No reviewed, exact-origin native client identity was selected. + AuthenticationRequired, } /// The manager owns the sink and validates the tab's lifecycle on delivery. @@ -44,6 +48,9 @@ pub enum BrowserError { Unavailable, Closed, TimedOut, + ComponentMissing, + BootstrapRequired, + NativeAcceptanceRequired, } impl std::fmt::Display for BrowserError { @@ -53,6 +60,9 @@ impl std::fmt::Display for BrowserError { Self::Unavailable => "The embedded browser is unavailable on this device.", Self::Closed => "This browser tab is no longer available.", Self::TimedOut => "The browser did not respond. Close the tab and try again.", + Self::ComponentMissing => "The verified Chromium component is not included in this build.", + Self::BootstrapRequired => "Embedded Chromium requires the supported sandboxed native bootstrap on this platform.", + Self::NativeAcceptanceRequired => "Embedded Chromium has not passed native acceptance on this platform.", }) } } diff --git a/apps/desktop/native-browser/src/view.rs b/apps/desktop/native-browser/src/view.rs new file mode 100644 index 000000000..0f2465e38 --- /dev/null +++ b/apps/desktop/native-browser/src/view.rs @@ -0,0 +1,116 @@ +//! Safe presentation handle shared by the existing guest and the Chromium preview. + +use tauri::{Rect, Webview}; +use url::Url; + +use crate::{BrowserError, NavigationAction, PageState}; + +/// One actual native guest. Chromium never navigates a second `WebKit` page. +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] // Unlinked preview surface is an inert zero-sized stub. +pub enum BrowserView { + /// Existing human browsing adapter retained until native Chromium acceptance. + System(Webview), + /// Explicitly enabled embedded Chromium preview. + #[cfg(feature = "cef-preview")] + Chromium(crate::chromium::Surface), +} + +impl BrowserView { + /// Navigate through the native guest's closed human interface. + /// + /// # Errors + /// Fails when the guest has closed or navigation is invalid. + pub fn navigate(&self, url: Url) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.navigate(url).map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.navigate(&url), + } + } + + /// Hide the real native page and prevent guest input. + /// + /// # Errors + /// Returns an error when the guest no longer exists. + pub fn hide(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.hide().map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.hide(), + } + } + + /// Display the native page after the controller has validated its viewport. + /// + /// # Errors + /// Returns an error when the guest no longer exists. + pub fn show(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.show().map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.show(), + } + } + + /// Apply validated logical viewport bounds on the native UI thread. + /// + /// # Errors + /// Returns an error when the guest no longer exists or its bounds are invalid. + pub fn set_bounds(&self, bounds: Rect) -> Result<(), BrowserError> { + match self { + Self::System(view) => view.set_bounds(bounds).map_err(|_| BrowserError::Closed), + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.set_bounds(bounds), + } + } + + /// Stop and destroy the native guest before releasing its profile. + /// + /// # Errors + /// Returns an error when native teardown cannot complete. + pub async fn close(&self) -> Result<(), BrowserError> { + match self { + Self::System(view) => { + let _ = crate::release(view).await; + view.close().map_err(|_| BrowserError::Closed) + } + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.close().await, + } + } + + /// Read native navigation metadata, never renderer-authored authority. + /// + /// # Errors + /// Fails if the guest is unavailable or its observation deadline expires. + pub async fn inspect(&self) -> Result { + match self { + Self::System(view) => crate::inspect(view).await, + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.inspect().await, + } + } + + /// Apply a human navigation control on this same guest. + /// + /// # Errors + /// Fails if the guest is unavailable or its operation deadline expires. + pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { + match self { + Self::System(view) => crate::control(view, action).await, + #[cfg(feature = "cef-preview")] + Self::Chromium(view) => view.control(action).await, + } + } + + /// Existing guest handle for sharing only the legacy engine's native context. + #[must_use] + pub fn system_view(&self) -> Option<&Webview> { + match self { + Self::System(view) => Some(view), + #[cfg(feature = "cef-preview")] + Self::Chromium(_) => None, + } + } +} diff --git a/apps/desktop/native-credential-ui/src/lib.rs b/apps/desktop/native-credential-ui/src/lib.rs index a0d5f4795..730ec3f22 100644 --- a/apps/desktop/native-credential-ui/src/lib.rs +++ b/apps/desktop/native-credential-ui/src/lib.rs @@ -7,8 +7,9 @@ mod appearance; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] mod lifecycle; +mod password; mod prompt; -#[cfg(any(windows, target_os = "macos", target_os = "linux"))] +mod purpose; mod validation; #[cfg(target_os = "linux")] @@ -20,7 +21,8 @@ mod macos; mod windows; pub use appearance::{ColorScheme, DialogAppearance, TextSize}; -pub use prompt::{PromptError, prompt}; +pub use password::NativePassword; +pub use prompt::{PromptError, prompt, prompt_password}; /// Run isolated, synthetic `AppKit` control acceptance on the process main thread. /// This entry point exists only in explicitly enabled native test builds. diff --git a/apps/desktop/native-credential-ui/src/lifecycle.rs b/apps/desktop/native-credential-ui/src/lifecycle.rs index 6e2c1ea5b..6a9b3f9aa 100644 --- a/apps/desktop/native-credential-ui/src/lifecycle.rs +++ b/apps/desktop/native-credential-ui/src/lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, }; -use colossus_contracts::HostSecret; +use crate::NativePassword; use tokio::sync::oneshot; use crate::PromptError; @@ -16,12 +16,12 @@ pub(crate) static TEST_OWNERSHIP: std::sync::Mutex<()> = std::sync::Mutex::new(( /// Held by the native window, not the waiting future, so cancelling a caller /// cannot admit another dialog until the first window has cleared and closed. pub(crate) struct Completion { - sender: Option>>, + sender: Option>>, } impl Completion { pub(crate) fn acquire() - -> Result<(Self, oneshot::Receiver>), PromptError> { + -> Result<(Self, oneshot::Receiver>), PromptError> { ACTIVE .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .map_err(|_| PromptError::Busy)?; @@ -34,7 +34,7 @@ impl Completion { )) } - pub(crate) fn finish(mut self, result: Result) { + pub(crate) fn finish(mut self, result: Result) { // Release ownership before waking a caller that might immediately retry. ACTIVE.store(false, Ordering::Release); if let Some(sender) = self.sender.take() { diff --git a/apps/desktop/native-credential-ui/src/linux.rs b/apps/desktop/native-credential-ui/src/linux.rs index 174776583..cbf8a167a 100644 --- a/apps/desktop/native-credential-ui/src/linux.rs +++ b/apps/desktop/native-credential-ui/src/linux.rs @@ -1,6 +1,7 @@ //! Owned GTK password entry on Tauri's existing UI thread; no nested event loop. -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, +}; use gtk::{glib, prelude::*}; use std::{ cell::RefCell, @@ -20,22 +21,40 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Ok(parent) = parent.gtk_window() else { completion.finish(Err(PromptError::Unavailable)); return; }; - let _ = create(parent.upcast_ref(), cancelled, completion, appearance); + let _ = create_for( + parent.upcast_ref(), + cancelled, + completion, + appearance, + purpose, + ); } +#[cfg(test)] fn create( parent: >k::Window, cancelled: Arc, completion: Completion, appearance: DialogAppearance, +) -> Result<(gtk::Dialog, gtk::Entry), PromptError> { + create_for(parent, cancelled, completion, appearance, Purpose::Token) +} + +fn create_for( + parent: >k::Window, + cancelled: Arc, + completion: Completion, + appearance: DialogAppearance, + purpose: Purpose, ) -> Result<(gtk::Dialog, gtk::Entry), PromptError> { let dialog = gtk::Dialog::builder() - .title("Save a credential") + .title(purpose.title()) .transient_for(parent) .modal(true) .destroy_with_parent(true) @@ -43,24 +62,23 @@ fn create( .resizable(false) .build(); dialog.add_button("Cancel", gtk::ResponseType::Cancel); - dialog.add_button("Save credential", gtk::ResponseType::Accept); + dialog.add_button(purpose.confirm(), gtk::ResponseType::Accept); dialog.set_default_response(gtk::ResponseType::Accept); let content = dialog.content_area(); styling::content(&content, appearance); - let description = gtk::Label::new(Some( - "This token stays in native credential storage and is never sent to the app's web view.", - )); + let description = gtk::Label::new(Some(purpose.description())); description.set_line_wrap(true); description.set_xalign(0.0); description .style_context() .add_class("credential-description"); - let label = gtk::Label::new(Some("_Token")); + let label = gtk::Label::new(Some(&format!("_{}", purpose.label()))); label.set_use_underline(true); label.set_xalign(0.0); label.style_context().add_class("credential-label"); let entry = gtk::Entry::new(); entry.set_visibility(false); + entry.set_placeholder_text(Some(purpose.placeholder())); entry.set_input_purpose(gtk::InputPurpose::Password); entry.set_activates_default(true); label.set_mnemonic_widget(Some(&entry)); @@ -94,13 +112,12 @@ fn create( } if response == gtk::ResponseType::Accept { let text = Zeroizing::new(entry.text().to_string()); - if let Err(invalid) = validation::validate(&text) { + if let Err(invalid) = purpose.validate(&text) { error.set_text(invalid.message()); entry.grab_focus(); return; } - *result.borrow_mut() = - Some(HostSecret::new(text.to_string()).map_err(|_| PromptError::Unavailable)); + *result.borrow_mut() = Some(NativePassword::new(text.to_string())); } else { *result.borrow_mut() = Some(Err(PromptError::Cancelled)); } @@ -143,8 +160,8 @@ fn create( mod tests { use super::*; fn wait_for_completion( - received: &mut tokio::sync::oneshot::Receiver>, - ) -> Result { + received: &mut tokio::sync::oneshot::Receiver>, + ) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(2); loop { match received.try_recv() { diff --git a/apps/desktop/native-credential-ui/src/macos.rs b/apps/desktop/native-credential-ui/src/macos.rs index 2e2a49594..ce2588dd5 100644 --- a/apps/desktop/native-credential-ui/src/macos.rs +++ b/apps/desktop/native-credential-ui/src/macos.rs @@ -10,8 +10,9 @@ mod styling; #[path = "macos/tests.rs"] pub(crate) mod tests; -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, +}; use formatter::TokenFormatter; use objc2::{ DefinedClass, MainThreadOnly, Message, define_class, msg_send, @@ -49,6 +50,7 @@ struct Session { timer: Retained, completion: Completion, cancelled: Arc, + purpose: Purpose, } #[derive(Default)] @@ -75,9 +77,11 @@ define_class!( fn changed(&self, _: &NSNotification) { if let Some(session) = self.ivars().session.borrow().as_ref() { let text = current_text(&session.input); - session.count.setStringValue(&NSString::from_str(&format!("{} / 65,536 bytes", text.length()))); + let bytes = formatter::native_bytes(&text, session.purpose); + let count = bytes.as_ref().map_or_else(|_| "Invalid passphrase character".into(), |bytes| format!("{bytes} / 65,536 bytes")); + session.count.setStringValue(&NSString::from_str(&count)); session.status.setStringValue(ns_string!("")); - session.save.setEnabled(text.length() > 0); + session.save.setEnabled(bytes.is_ok() && (text.length() > 0 || session.purpose == Purpose::Pkcs12Password)); } } } @@ -90,16 +94,16 @@ define_class!( let borrowed = self.ivars().session.borrow(); let Some(session) = borrowed.as_ref() else { return; }; let text = current_text(&session.input); - if let Err(error) = formatter::validate_native(&text) { + if let Err(error) = formatter::native_bytes(&text, session.purpose) { session.status.setStringValue(&NSString::from_str(error.message())); return; } let mut secret = Zeroizing::new(text.to_string()); - if let Err(error) = validation::validate(&secret) { + if let Err(error) = session.purpose.validate(&secret) { session.status.setStringValue(&NSString::from_str(error.message())); return; } - HostSecret::new(std::mem::take(&mut *secret)).map_err(|_| PromptError::Unavailable) + NativePassword::new(std::mem::take(&mut *secret)) }; self.finish(result); } @@ -126,7 +130,7 @@ impl Controller { unsafe { msg_send![super(this), init] } } - fn finish(&self, result: Result) { + fn finish(&self, result: Result) { // Keep self alive through timer invalidation and thread-local release. let _keep_alive = self.retain(); let Some(session) = self.ivars().session.borrow_mut().take() else { @@ -158,6 +162,7 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Some(mtm) = MainThreadMarker::new() else { completion.finish(Err(PromptError::Unavailable)); @@ -177,15 +182,34 @@ pub(crate) fn open( completion.finish(Err(PromptError::Cancelled)); return; } - open_sheet(&parent, mtm, cancelled, completion, appearance); + open_sheet_for(&parent, mtm, cancelled, completion, appearance, purpose); } +#[cfg(feature = "native-test-driver")] fn open_sheet( parent: &NSWindow, mtm: MainThreadMarker, cancelled: Arc, completion: Completion, appearance: DialogAppearance, +) { + open_sheet_for( + parent, + mtm, + cancelled, + completion, + appearance, + Purpose::Token, + ); +} + +fn open_sheet_for( + parent: &NSWindow, + mtm: MainThreadMarker, + cancelled: Arc, + completion: Completion, + appearance: DialogAppearance, + purpose: Purpose, ) { let controller = Controller::new(mtm); let style = styling::Style::new(parent, appearance); @@ -201,24 +225,24 @@ fn open_sheet( unsafe { panel.setReleasedWhenClosed(false); } - panel.setTitle(ns_string!("Save credential")); + panel.setTitle(&NSString::from_str(purpose.title())); style.panel(&panel); let Some(content) = panel.contentView() else { completion.finish(Err(PromptError::Unavailable)); return; }; - let [heading, description, label] = style.labels(mtm); + let [heading, description, label] = style.labels(mtm, purpose); let input = NSSecureTextField::initWithFrame( NSSecureTextField::alloc(mtm), style.rect(28.0, 145.0, 504.0, 44.0), ); - input.setPlaceholderString(Some(ns_string!("Paste your credential"))); + input.setPlaceholderString(Some(&NSString::from_str(purpose.placeholder()))); style.input(&input); - accessibility::label(&input, ns_string!("Token")); + accessibility::label(&input, &NSString::from_str(purpose.label())); let [count, status] = style.feedback(mtm); let save = unsafe { NSButton::buttonWithTitle_target_action( - ns_string!("Save"), + &NSString::from_str(purpose.confirm()), Some(&controller), Some(sel!(save:)), mtm, @@ -227,8 +251,8 @@ fn open_sheet( save.setFrame(style.rect(432.0, 24.0, 100.0, 40.0)); style.button(&save, true); save.setKeyEquivalent(ns_string!("\r")); - accessibility::label(&save, ns_string!("Save")); - save.setEnabled(false); + accessibility::label(&save, &NSString::from_str(purpose.confirm())); + save.setEnabled(purpose == Purpose::Pkcs12Password); let cancel = unsafe { NSButton::buttonWithTitle_target_action( ns_string!("Cancel"), @@ -241,7 +265,7 @@ fn open_sheet( style.button(&cancel, false); cancel.setKeyEquivalent(ns_string!("\u{1b}")); accessibility::label(&cancel, ns_string!("Cancel")); - let formatter = TokenFormatter::new(mtm, status.clone()); + let formatter = TokenFormatter::new(mtm, status.clone(), purpose); input.setFormatter(Some(&formatter)); unsafe { input.setDelegate(Some(ProtocolObject::from_ref(&*controller))); @@ -281,6 +305,7 @@ fn open_sheet( timer, completion, cancelled, + purpose, })); DIALOG.with(|dialog| dialog.replace(Some(controller))); parent.beginSheet_completionHandler(&panel, None); diff --git a/apps/desktop/native-credential-ui/src/macos/formatter.rs b/apps/desktop/native-credential-ui/src/macos/formatter.rs index fe919e746..c6da0b95a 100644 --- a/apps/desktop/native-credential-ui/src/macos/formatter.rs +++ b/apps/desktop/native-credential-ui/src/macos/formatter.rs @@ -1,3 +1,4 @@ +use crate::purpose::Purpose; use crate::validation::{InputError, validate_units}; use objc2::{ DefinedClass, MainThreadOnly, Message, define_class, msg_send, rc::Retained, runtime::AnyObject, @@ -7,6 +8,7 @@ use objc2_foundation::{MainThreadMarker, NSFormatter, NSObjectProtocol, NSString pub(super) struct FormatterIvars { status: Retained, + purpose: Purpose, } define_class!( @@ -28,7 +30,7 @@ define_class!( #[unsafe(method(getObjectValue:forString:errorDescription:))] fn parsed(&self, output: *mut *mut AnyObject, string: &NSString, _: *mut *mut NSString) -> bool { - if validate_native(string).is_err() { false } else { + if native_bytes(string, self.ivars().purpose).is_err() { false } else { if !output.is_null() { // SAFETY: Cocoa supplies a writable autoreleasing object out // parameter. Preserve the NSString with normal +0 semantics. @@ -40,8 +42,8 @@ define_class!( #[unsafe(method(isPartialStringValid:newEditingString:errorDescription:))] fn partial(&self, value: &NSString, _: *mut *mut NSString, _: *mut *mut NSString) -> bool { - match validate_native(value) { - Ok(()) => true, + match native_bytes(value, self.ivars().purpose) { + Ok(_) => true, Err(error) => { self.ivars().status.setStringValue(&NSString::from_str(error.message())); false @@ -52,17 +54,24 @@ define_class!( ); impl TokenFormatter { - pub(super) fn new(mtm: MainThreadMarker, status: Retained) -> Retained { - let this = Self::alloc(mtm).set_ivars(FormatterIvars { status }); + pub(super) fn new( + mtm: MainThreadMarker, + status: Retained, + purpose: Purpose, + ) -> Retained { + let this = Self::alloc(mtm).set_ivars(FormatterIvars { status, purpose }); // SAFETY: NSFormatter's designated NSObject initializer is valid here. unsafe { msg_send![super(this), init] } } } /// Validate the Cocoa-owned candidate without allocating an unbounded Rust copy. -pub(super) fn validate_native(value: &NSString) -> Result<(), InputError> { - validate_units( - (0..value.length()).map(|index| value.characterAtIndex(index)), - value.length(), - ) +pub(super) fn native_bytes(value: &NSString, purpose: Purpose) -> Result { + let units = (0..value.length()).map(|index| value.characterAtIndex(index)); + match purpose { + Purpose::Token => validate_units(units, value.length()).map(|()| value.length()), + Purpose::Pkcs12Password => { + crate::validation::password_units_bytes(units, value.length(), false) + } + } } diff --git a/apps/desktop/native-credential-ui/src/macos/styling.rs b/apps/desktop/native-credential-ui/src/macos/styling.rs index 8f70ebf8f..8f1bf1184 100644 --- a/apps/desktop/native-credential-ui/src/macos/styling.rs +++ b/apps/desktop/native-credential-ui/src/macos/styling.rs @@ -1,5 +1,6 @@ //! `AppKit` presentation only; credential input and completion stay in the controller. +use crate::purpose::Purpose; use crate::{ColorScheme, DialogAppearance, appearance::Palette}; use objc2::rc::Retained; use objc2_app_kit::{ @@ -8,7 +9,7 @@ use objc2_app_kit::{ NSAppearanceNameDarkAqua, NSButton, NSColor, NSControlSize, NSFont, NSSecureTextField, NSTextField, NSWindow, NSWorkspace, }; -use objc2_foundation::{MainThreadMarker, NSRect, ns_string}; +use objc2_foundation::{MainThreadMarker, NSRect, NSString, ns_string}; #[derive(Clone, Copy)] enum LabelTone { @@ -71,18 +72,24 @@ impl Style { )); } - pub(super) fn labels(&self, mtm: MainThreadMarker) -> [Retained; 3] { - let heading = NSTextField::labelWithString(ns_string!("Save credential"), mtm); + pub(super) fn labels( + &self, + mtm: MainThreadMarker, + purpose: Purpose, + ) -> [Retained; 3] { + let heading = NSTextField::labelWithString(&NSString::from_str(purpose.heading()), mtm); heading.setFrame(self.rect(28.0, 263.0, 504.0, 33.0)); heading.setFont(Some(&NSFont::boldSystemFontOfSize(20.0 * self.scale))); heading.setTextColor(Some(&self.color(self.palette.strong, NSColor::labelColor))); - let description = NSTextField::wrappingLabelWithString( - ns_string!("Your token is saved in the encrypted credential vault."), - mtm, - ); - description.setFrame(self.rect(28.0, 227.0, 504.0, 30.0)); + let description = + NSTextField::wrappingLabelWithString(&NSString::from_str(purpose.description()), mtm); + description.setFrame(if purpose == Purpose::Token { + self.rect(28.0, 227.0, 504.0, 30.0) + } else { + self.rect(28.0, 223.0, 504.0, 40.0) + }); self.label(&description, 14.0, LabelTone::Muted); - let label = NSTextField::labelWithString(ns_string!("Token"), mtm); + let label = NSTextField::labelWithString(&NSString::from_str(purpose.label()), mtm); label.setFrame(self.rect(28.0, 197.0, 504.0, 22.0)); self.label(&label, 14.0, LabelTone::Normal); [heading, description, label] diff --git a/apps/desktop/native-credential-ui/src/password.rs b/apps/desktop/native-credential-ui/src/password.rs new file mode 100644 index 000000000..1a12af041 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/password.rs @@ -0,0 +1,37 @@ +//! Owned native passphrase values, including an empty PKCS#12 passphrase. + +use zeroize::Zeroizing; + +/// A bounded PKCS#12 passphrase entered through a native secure control. +/// +/// The allocation clears on drop. This type has no serialization or cloning +/// surface; native controls may retain allocations that Rust cannot clear. +pub struct NativePassword(Zeroizing); + +impl NativePassword { + pub(crate) fn new(value: String) -> Result { + let value = Zeroizing::new(value); + crate::validation::validate_password(&value) + .map_err(|_| crate::PromptError::Unavailable)?; + Ok(Self(value)) + } + + /// Borrow the passphrase only in trusted native importing code. + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } + + pub(crate) fn into_token( + mut self, + ) -> Result { + colossus_contracts::HostSecret::new(std::mem::take(&mut *self.0)) + .map_err(|_| crate::PromptError::Unavailable) + } +} + +impl std::fmt::Debug for NativePassword { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("NativePassword([REDACTED])") + } +} diff --git a/apps/desktop/native-credential-ui/src/prompt.rs b/apps/desktop/native-credential-ui/src/prompt.rs index 2c5a52403..24e7687e5 100644 --- a/apps/desktop/native-credential-ui/src/prompt.rs +++ b/apps/desktop/native-credential-ui/src/prompt.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, atomic::AtomicBool}; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] use crate::ColorScheme; -use crate::DialogAppearance; +use crate::{DialogAppearance, NativePassword, purpose::Purpose}; use colossus_contracts::HostSecret; #[cfg(any(windows, target_os = "macos", target_os = "linux"))] @@ -44,9 +44,34 @@ pub async fn prompt( parent: tauri::Window, appearance: DialogAppearance, ) -> Result { + prompt_for(parent, appearance, Purpose::Token) + .await? + .into_token() +} + +/// Enter a PKCS#12 passphrase in a native secure control without saving it. +/// +/// Spaces, Unicode and an empty passphrase are preserved. The UTF-8 value is +/// bounded to 65,536 bytes; NUL, line breaks and control characters are rejected. +/// Native custody, cancellation and the exclusive dialog lease match [`prompt`]. +/// +/// # Errors +/// Returns only categorical native entry failures, never the entered value. +pub async fn prompt_password( + parent: tauri::Window, + appearance: DialogAppearance, +) -> Result { + prompt_for(parent, appearance, Purpose::Pkcs12Password).await +} + +async fn prompt_for( + parent: tauri::Window, + appearance: DialogAppearance, + purpose: Purpose, +) -> Result { #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { - let _ = (parent, appearance); + let _ = (parent, appearance, purpose); return Err(PromptError::Unsupported); } #[cfg(any(windows, target_os = "macos", target_os = "linux"))] @@ -66,11 +91,11 @@ pub async fn prompt( parent .run_on_main_thread(move || { #[cfg(windows)] - crate::windows::open(&native_parent, cancelled, completion, appearance); + crate::windows::open(&native_parent, cancelled, completion, appearance, purpose); #[cfg(target_os = "macos")] - crate::macos::open(&native_parent, cancelled, completion, appearance); + crate::macos::open(&native_parent, cancelled, completion, appearance, purpose); #[cfg(target_os = "linux")] - crate::linux::open(&native_parent, cancelled, completion, appearance); + crate::linux::open(&native_parent, cancelled, completion, appearance, purpose); }) .map_err(|_| PromptError::Unavailable)?; receiver.await.map_err(|_| PromptError::Cancelled)? diff --git a/apps/desktop/native-credential-ui/src/purpose.rs b/apps/desktop/native-credential-ui/src/purpose.rs new file mode 100644 index 000000000..7f0a021b2 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/purpose.rs @@ -0,0 +1,62 @@ +//! Native dialog purpose and fixed, non-secret copy. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Purpose { + Token, + Pkcs12Password, +} + +impl Purpose { + pub(crate) fn title(self) -> &'static str { + match self { + Self::Token => "Save a Colossus credential", + Self::Pkcs12Password => "Unlock PKCS#12 identity", + } + } + + #[cfg(any(windows, target_os = "macos"))] + pub(crate) fn heading(self) -> &'static str { + match self { + Self::Token => "Save credential", + Self::Pkcs12Password => "Unlock PKCS#12 identity", + } + } + + pub(crate) fn description(self) -> &'static str { + match self { + Self::Token => "Your token is saved in the encrypted credential vault.", + Self::Pkcs12Password => { + "Enter the selected certificate package's passphrase. It is used only for native import and is not saved." + } + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Token => "Token", + Self::Pkcs12Password => "Passphrase", + } + } + + pub(crate) fn placeholder(self) -> &'static str { + match self { + Self::Token => "Paste your token", + Self::Pkcs12Password => "Passphrase (leave empty if the package has none)", + } + } + + #[cfg(not(windows))] + pub(crate) fn confirm(self) -> &'static str { + match self { + Self::Token => "Save", + Self::Pkcs12Password => "Continue", + } + } + + pub(crate) fn validate(self, value: &str) -> Result<(), crate::validation::InputError> { + match self { + Self::Token => crate::validation::validate(value), + Self::Pkcs12Password => crate::validation::validate_password(value), + } + } +} diff --git a/apps/desktop/native-credential-ui/src/validation.rs b/apps/desktop/native-credential-ui/src/validation.rs index 8a3c84726..cbbd9892d 100644 --- a/apps/desktop/native-credential-ui/src/validation.rs +++ b/apps/desktop/native-credential-ui/src/validation.rs @@ -5,6 +5,8 @@ pub(crate) enum InputError { Empty, TooLong, InvalidCharacter, + InvalidPasswordCharacter, + PasswordTooLong, } impl InputError { @@ -15,8 +17,55 @@ impl InputError { Self::InvalidCharacter => { "Use a token without spaces, line breaks, or non-ASCII characters." } + Self::InvalidPasswordCharacter => { + "Use a passphrase without NUL, line breaks, or control characters." + } + Self::PasswordTooLong => { + "Passphrase exceeds 65,536 UTF-8 bytes. The change was not accepted." + } + } + } +} + +pub(crate) fn validate_password(value: &str) -> Result<(), InputError> { + if value.len() > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + if value.chars().any(password_control) { + return Err(InputError::InvalidPasswordCharacter); + } + Ok(()) +} + +fn password_control(character: char) -> bool { + character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') +} + +/// Inspect native UTF-16 without a plaintext Rust String allocation. A Windows +/// single-character edit may temporarily contain a high surrogate until the +/// paired low surrogate arrives; final acceptance always requires valid UTF-16. +#[cfg(any(windows, target_os = "macos", test))] +pub(crate) fn password_units_bytes( + units: impl IntoIterator, + unit_count: usize, + partial: bool, +) -> Result { + if unit_count > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + let mut bytes = 0_usize; + for character in char::decode_utf16(units) { + let length = match character { + Ok(character) if !password_control(character) => character.len_utf8(), + Err(error) if partial && (0xd800..=0xdbff).contains(&error.unpaired_surrogate()) => 3, + _ => return Err(InputError::InvalidPasswordCharacter), + }; + bytes = bytes.saturating_add(length); + if bytes > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); } } + Ok(bytes) } pub(crate) fn validate(value: &str) -> Result<(), InputError> { @@ -75,4 +124,72 @@ mod tests { Err(InputError::InvalidCharacter) ); } + + #[test] + fn native_passwords_preserve_empty_spaces_unicode_and_exact_utf8_limit() { + for password in ["", "two words", " 密碼 🔐 café "] { + assert_eq!(validate_password(password), Ok(())); + assert_eq!( + password_units_bytes( + password.encode_utf16(), + password.encode_utf16().count(), + false + ), + Ok(password.len()) + ); + let owned = crate::NativePassword::new(password.to_owned()).unwrap(); + assert_eq!(owned.expose(), password); + assert_eq!(format!("{owned:?}"), "NativePassword([REDACTED])"); + } + let exact = "🔐".repeat(MAX_HOST_SECRET_BYTES / 4); + assert_eq!(validate_password(&exact), Ok(())); + assert_eq!( + password_units_bytes(exact.encode_utf16(), exact.encode_utf16().count(), false), + Ok(MAX_HOST_SECRET_BYTES) + ); + let oversized = format!("{exact}x"); + assert_eq!( + validate_password(&oversized), + Err(InputError::PasswordTooLong) + ); + assert_eq!( + password_units_bytes( + oversized.encode_utf16(), + oversized.encode_utf16().count(), + false + ), + Err(InputError::PasswordTooLong) + ); + // Password support does not change the existing token contract. + assert_eq!(validate("two words"), Err(InputError::InvalidCharacter)); + assert_eq!(validate("密碼"), Err(InputError::InvalidCharacter)); + assert_eq!(validate(""), Err(InputError::Empty)); + } + + #[test] + fn passwords_reject_controls_and_malformed_final_utf16_without_normalizing() { + for password in ["a\0b", "a\nb", "a\rb", "a\tb", "a\u{2028}b", "a\u{2029}b"] { + assert_eq!( + validate_password(password), + Err(InputError::InvalidPasswordCharacter) + ); + } + assert_eq!( + password_units_bytes([0xd800], 1, false), + Err(InputError::InvalidPasswordCharacter) + ); + assert_eq!( + password_units_bytes([0xdc00], 1, true), + Err(InputError::InvalidPasswordCharacter) + ); + assert_eq!(password_units_bytes([0xd800], 1, true), Ok(3)); + assert_eq!(password_units_bytes([0xd83d, 0xdd10], 2, false), Ok(4)); + let decomposed = "e\u{301}"; + assert_eq!( + crate::NativePassword::new(decomposed.into()) + .unwrap() + .expose(), + decomposed + ); + } } diff --git a/apps/desktop/native-credential-ui/src/windows.rs b/apps/desktop/native-credential-ui/src/windows.rs index 33ebdaba1..30fba2e61 100644 --- a/apps/desktop/native-credential-ui/src/windows.rs +++ b/apps/desktop/native-credential-ui/src/windows.rs @@ -9,8 +9,10 @@ mod painting; mod tests; mod visuals; -use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; +use crate::{ + DialogAppearance, NativePassword, PromptError, lifecycle::Completion, purpose::Purpose, + validation, +}; use std::{ cell::{Cell, RefCell}, ptr::{null, null_mut}, @@ -56,12 +58,13 @@ struct Session { save: HWND, cancel: HWND, visuals: visuals::Visuals, + purpose: Purpose, has_error: Cell, hovered: Cell, cancelled: Arc, created: Arc, completion: RefCell>, - result: RefCell>>, + result: RefCell>>, } pub(crate) fn open( @@ -69,6 +72,7 @@ pub(crate) fn open( cancelled: Arc, completion: Completion, appearance: DialogAppearance, + purpose: Purpose, ) { let Ok(handle) = parent.hwnd() else { completion.finish(Err(PromptError::Unavailable)); @@ -78,16 +82,37 @@ pub(crate) fn open( // SAFETY: Called on Tauri's UI thread with a live native parent. The owned // window retains the boxed session until WM_NCDESTROY. No callback blocks. unsafe { - create(parent, cancelled, completion, true, appearance); + create_for(parent, cancelled, completion, true, appearance, purpose); } } +#[cfg(any(test, feature = "native-test-driver"))] unsafe fn create( parent: HWND, cancelled: Arc, completion: Completion, visible: bool, appearance: DialogAppearance, +) -> Option { + unsafe { + create_for( + parent, + cancelled, + completion, + visible, + appearance, + Purpose::Token, + ) + } +} + +unsafe fn create_for( + parent: HWND, + cancelled: Arc, + completion: Completion, + visible: bool, + appearance: DialogAppearance, + purpose: Purpose, ) -> Option { if cancelled.load(Ordering::Acquire) || unsafe { IsWindow(parent) } == 0 { completion.finish(Err(PromptError::Cancelled)); @@ -125,6 +150,7 @@ unsafe fn create( save: null_mut(), cancel: null_mut(), visuals, + purpose, has_error: Cell::new(false), hovered: Cell::new(null_mut()), cancelled, @@ -138,7 +164,7 @@ unsafe fn create( CreateWindowExW( WS_EX_DLGMODALFRAME, class.as_ptr(), - wide("Save a Colossus credential").as_ptr(), + wide(purpose.title()).as_ptr(), WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN, left, top, @@ -331,12 +357,24 @@ unsafe fn release_session(window: HWND, pointer: *mut Session) { unsafe fn update_count(pointer: *const Session) { let length = unsafe { GetWindowTextLengthW((*pointer).input) }.max(0); - let message = format!("{length} / 65,536 bytes"); + let purpose = unsafe { (*pointer).purpose }; + let byte_count = if purpose == Purpose::Pkcs12Password { + unsafe { input::current_password_bytes((*pointer).input) }.ok() + } else { + usize::try_from(length).ok() + }; + let message = byte_count.map_or_else( + || "Incomplete passphrase character".into(), + |bytes| format!("{bytes} / 65,536 bytes"), + ); unsafe { (*pointer).has_error.set(false); SetWindowTextW((*pointer).status, wide(&message).as_ptr()); SetWindowTextW((*pointer).error, wide("").as_ptr()); - EnableWindow((*pointer).save, i32::from(length > 0)); + EnableWindow( + (*pointer).save, + i32::from(byte_count.is_some() && (length > 0 || purpose == Purpose::Pkcs12Password)), + ); painting::invalidate_input(pointer); } } @@ -344,12 +382,19 @@ unsafe fn update_count(pointer: *const Session) { unsafe fn save(window: HWND, pointer: *const Session) { let length = unsafe { GetWindowTextLengthW((*pointer).input) }; let count = usize::try_from(length).unwrap_or(0); - if count == 0 || count > colossus_contracts::MAX_HOST_SECRET_BYTES { + let purpose = unsafe { (*pointer).purpose }; + if count == 0 && purpose == Purpose::Token { unsafe { input::error(pointer, validation::InputError::Empty); } return; } + if count > colossus_contracts::MAX_HOST_SECRET_BYTES { + unsafe { + input::error(pointer, validation::InputError::PasswordTooLong); + } + return; + } let mut units = Zeroizing::new(vec![0_u16; count + 1]); let copied = unsafe { GetWindowTextW((*pointer).input, units.as_mut_ptr(), length + 1) }; if copied != length { @@ -359,13 +404,13 @@ unsafe fn save(window: HWND, pointer: *const Session) { return; }; let mut secret = Zeroizing::new(decoded); - if let Err(error) = validation::validate(&secret) { + if let Err(error) = purpose.validate(&secret) { unsafe { input::error(pointer, error); } return; } - if let Ok(secret) = HostSecret::new(std::mem::take(&mut *secret)) { + if let Ok(secret) = NativePassword::new(std::mem::take(&mut *secret)) { unsafe { (*pointer).result.replace(Some(Ok(secret))); DestroyWindow(window); diff --git a/apps/desktop/native-credential-ui/src/windows/acceptance.rs b/apps/desktop/native-credential-ui/src/windows/acceptance.rs index 478bea2a8..9a451dae3 100644 --- a/apps/desktop/native-credential-ui/src/windows/acceptance.rs +++ b/apps/desktop/native-credential-ui/src/windows/acceptance.rs @@ -6,8 +6,8 @@ mod appearance; mod isolation; use super::{CANCEL_ID, INPUT_ID, SAVE_ID, Session, create, wide}; +use crate::NativePassword; use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; -use colossus_contracts::HostSecret; use isolation::{InputIsolation, set_clipboard}; use std::{ ptr::{null, null_mut}, @@ -54,7 +54,7 @@ struct Dialog { error: HWND, save: HWND, cancel: HWND, - result: oneshot::Receiver>, + result: oneshot::Receiver>, } impl Dialog { diff --git a/apps/desktop/native-credential-ui/src/windows/controls.rs b/apps/desktop/native-credential-ui/src/windows/controls.rs index a31a82023..af0e2460a 100644 --- a/apps/desktop/native-credential-ui/src/windows/controls.rs +++ b/apps/desktop/native-credential-ui/src/windows/controls.rs @@ -21,15 +21,10 @@ const SS_RIGHT: u32 = 2; pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { // Populate handles before fonts/layout can cause synchronous paint messages. unsafe { - (*pointer).heading = control(window, "STATIC", "Save credential", 0, 0); - (*pointer).description = control( - window, - "STATIC", - "Your token is saved in the encrypted credential vault.", - 0, - 0, - ); - (*pointer).label = control(window, "STATIC", "&Token", 0, 0); + let purpose = (*pointer).purpose; + (*pointer).heading = control(window, "STATIC", purpose.heading(), 0, 0); + (*pointer).description = control(window, "STATIC", purpose.description(), 0, 0); + (*pointer).label = control(window, "STATIC", &format!("&{}", purpose.label()), 0, 0); (*pointer).input = control( window, "EDIT", @@ -42,7 +37,11 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { (*pointer).save = control( window, "BUTTON", - "&Save", + if purpose == crate::purpose::Purpose::Token { + "&Save" + } else { + "C&ontinue" + }, WS_TABSTOP | BS_OWNERDRAW as u32, SAVE_ID, ); @@ -78,7 +77,7 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { session.input, EM_SETCUEBANNER, 0, - wide("Paste your token").as_ptr() as isize, + wide(session.purpose.placeholder()).as_ptr() as isize, ); } for handle in [session.input, session.save, session.cancel] { @@ -89,7 +88,10 @@ pub(super) unsafe fn create(window: HWND, pointer: *mut Session) -> bool { } unsafe { layout(window, pointer); - EnableWindow(session.save, 0); + EnableWindow( + session.save, + i32::from(session.purpose == crate::purpose::Purpose::Pkcs12Password), + ); } true } diff --git a/apps/desktop/native-credential-ui/src/windows/input.rs b/apps/desktop/native-credential-ui/src/windows/input.rs index e0e025cb3..fbdcf35c0 100644 --- a/apps/desktop/native-credential-ui/src/windows/input.rs +++ b/apps/desktop/native-credential-ui/src/windows/input.rs @@ -1,4 +1,5 @@ use super::{CANCEL_ID, SAVE_ID, Session, painting, wide}; +use crate::purpose::Purpose; use crate::validation::{InputError, validate_units}; use colossus_contracts::MAX_HOST_SECRET_BYTES; use std::slice; @@ -13,8 +14,9 @@ use windows_sys::Win32::{ Input::KeyboardAndMouse::{GetKeyState, SetFocus, VK_CONTROL, VK_SHIFT}, Shell::{DefSubclassProc, RemoveWindowSubclass}, WindowsAndMessaging::{ - GetParent, GetWindowTextLengthW, PostMessageW, SendMessageW, SetWindowTextW, WM_CHAR, - WM_CLOSE, WM_COMMAND, WM_KEYDOWN, WM_NCDESTROY, WM_PASTE, WM_SETTEXT, WM_SYSCHAR, + GetParent, GetWindowTextLengthW, GetWindowTextW, PostMessageW, SendMessageW, + SetWindowTextW, WM_CHAR, WM_CLOSE, WM_COMMAND, WM_KEYDOWN, WM_NCDESTROY, WM_PASTE, + WM_SETTEXT, WM_SYSCHAR, }, }, }; @@ -45,6 +47,72 @@ unsafe fn replacement_length(window: HWND, inserted: usize) -> usize { .saturating_add(inserted) } +unsafe fn current_units(window: HWND) -> Result>, InputError> { + let length = usize::try_from(unsafe { GetWindowTextLengthW(window) }) + .map_err(|_| InputError::InvalidPasswordCharacter)?; + if length > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + let mut units = Zeroizing::new(vec![0; length + 1]); + let capacity = i32::try_from(units.len()).map_err(|_| InputError::PasswordTooLong)?; + let copied = unsafe { GetWindowTextW(window, units.as_mut_ptr(), capacity) }; + if usize::try_from(copied).ok() != Some(length) { + return Err(InputError::InvalidPasswordCharacter); + } + units.truncate(length); + Ok(units) +} + +pub(super) unsafe fn current_password_bytes(window: HWND) -> Result { + let units = unsafe { current_units(window) }?; + crate::validation::password_units_bytes(units.iter().copied(), units.len(), false) +} + +unsafe fn validate_edit( + window: HWND, + pointer: *const Session, + inserted: &[u16], + replace_all: bool, + partial: bool, +) -> Result<(), InputError> { + if unsafe { (*pointer).purpose } == Purpose::Token { + let length = if replace_all { + inserted.len() + } else { + unsafe { replacement_length(window, inserted.len()) } + }; + return validate_units(inserted.iter().copied(), length); + } + let mut existing = unsafe { current_units(window) }?; + let (start, end) = if replace_all { + (0, existing.len()) + } else { + let (mut start, mut end) = (0_u32, 0_u32); + unsafe { + SendMessageW( + window, + EM_GETSEL, + &raw mut start as usize, + &raw mut end as isize, + ); + } + ( + (start as usize).min(existing.len()), + (end as usize).min(existing.len()), + ) + }; + let length = existing + .len() + .saturating_sub(end.saturating_sub(start)) + .saturating_add(inserted.len()); + if length > MAX_HOST_SECRET_BYTES { + return Err(InputError::PasswordTooLong); + } + existing.splice(start..end, inserted.iter().copied()); + crate::validation::password_units_bytes(existing.iter().copied(), existing.len(), partial) + .map(|_| ()) +} + pub(super) unsafe extern "system" fn control_proc( window: HWND, message: u32, @@ -87,10 +155,9 @@ pub(super) unsafe extern "system" fn control_proc( if wparam == 8 { return unsafe { DefSubclassProc(window, message, wparam, lparam) }; } - let length = unsafe { replacement_length(window, 1) }; match u16::try_from(wparam) .map_err(|_| InputError::InvalidCharacter) - .and_then(|unit| validate_units([unit], length)) + .and_then(|unit| unsafe { validate_edit(window, pointer, &[unit], false, true) }) { Ok(()) => unsafe { DefSubclassProc(window, message, wparam, lparam) }, Err(reason) => { @@ -112,13 +179,8 @@ pub(super) unsafe extern "system" fn control_proc( (0..=MAX_HOST_SECRET_BYTES).find(|index| unsafe { *raw.add(*index) } == 0); end.map(|length| unsafe { slice::from_raw_parts(raw, length) }) }; - let result = units.ok_or(InputError::TooLong).and_then(|units| { - let length = if message == WM_SETTEXT { - units.len() - } else { - unsafe { replacement_length(window, units.len()) } - }; - validate_units(units.iter().copied(), length) + let result = units.ok_or(InputError::TooLong).and_then(|units| unsafe { + validate_edit(window, pointer, units, message == WM_SETTEXT, false) }); match result { Ok(()) => unsafe { DefSubclassProc(window, message, wparam, lparam) }, @@ -160,9 +222,7 @@ unsafe fn paste(window: HWND, pointer: *const Session) { .position(|unit| *unit == 0) .ok_or(InputError::TooLong) .and_then(|end| { - validate_units(units[..end].iter().copied(), unsafe { - replacement_length(window, end) - })?; + unsafe { validate_edit(window, pointer, &units[..end], false, false) }?; Ok(Zeroizing::new(units[..=end].to_vec())) }); unsafe { @@ -245,15 +305,19 @@ pub(super) unsafe fn mnemonic(character: WPARAM, pointer: *const Session) -> boo return false; }; let session = unsafe { &*pointer }; + let (input_key, confirm_key) = match session.purpose { + Purpose::Token => (b't', b's'), + Purpose::Pkcs12Password => (b'p', b'o'), + }; match character.to_ascii_lowercase() { - b't' => unsafe { + character if character == input_key => unsafe { SetFocus(session.input); }, - b's' | b'c' => { - let command = if character.eq_ignore_ascii_case(&b's') { - SAVE_ID - } else { + character if character == b'c' || character == confirm_key => { + let command = if character.eq_ignore_ascii_case(&b'c') { CANCEL_ID + } else { + SAVE_ID }; unsafe { PostMessageW(GetParent(session.input), WM_COMMAND, command, 0); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 800a05323..2fe6e2227 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1062,6 +1062,7 @@ dependencies = [ "colossus-domain", "serde", "serde_json", + "url", "zeroize", ] @@ -1266,12 +1267,18 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "objc2-web-kit", + "rustls-pki-types", "serde", + "serde_json", + "sha2", "tauri", "tokio", "url", "webview2-com", "windows 0.61.3", + "windows-sys 0.61.2", + "x509-parser", + "zeroize", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 61ea949a5..c086786b5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ default = ["dictation"] plugin-test-bridge = [] approval-test-bridge = [] browser-preview = [] +embedded-chromium-preview = ["browser-preview", "colossus-native-browser/cef-preview"] browser-test-bridge = ["browser-preview", "colossus-native-browser/native-test-driver"] dictation = ["dep:colossus-native-dictation", "colossus-native-dictation/download"] dictation-preview = ["dictation"] diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 10bc0de50..393f4f9f4 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -23,6 +23,7 @@ const COMMANDS: &[&str] = &[ "browser_context", "browser_command", "browser_viewport", + "browser_certificates", "command_review_context", "finish_command_review", "remembered_command_count", diff --git a/apps/desktop/src-tauri/capabilities/main-chat.json b/apps/desktop/src-tauri/capabilities/main-chat.json index 9e08b9ba2..0e932c220 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -37,6 +37,7 @@ "allow-browser-context", "allow-browser-command", "allow-browser-viewport", + "allow-browser-certificates", "allow-get-plugin-inventory", "allow-resolve-plugin-selection", "allow-read-plugin-preview", diff --git a/apps/desktop/src-tauri/src/browser/acceptance.rs b/apps/desktop/src-tauri/src/browser/acceptance.rs index 28db7c584..46d44a96d 100644 --- a/apps/desktop/src-tauri/src/browser/acceptance.rs +++ b/apps/desktop/src-tauri/src/browser/acceptance.rs @@ -258,7 +258,10 @@ async fn sessions(app: &tauri::AppHandle, address: &str, generation: u64) -> any ); println!("PASS workspace isolation and temporary cookie sharing"); anyhow::ensure!( - state.browser.tab(sibling.label(), "acceptance-b").is_err(), + state + .browser + .legacy_tab(sibling.label(), "acceptance-b") + .is_err(), "foreign tab was accessible" ); evaluate( diff --git a/apps/desktop/src-tauri/src/browser/bootstrap.rs b/apps/desktop/src-tauri/src/browser/bootstrap.rs new file mode 100644 index 000000000..57ae3e0ee --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/bootstrap.rs @@ -0,0 +1,65 @@ +//! Entry-point CEF installation and nonblocking UI-thread message pumping. + +#[cfg(feature = "embedded-chromium-preview")] +use colossus_native_browser::chromium; + +/// Own the preview's private cache for the entire native application lifecycle. +pub(crate) fn initialize() -> Option { + #[cfg(feature = "embedded-chromium-preview")] + { + let store = crate::desktop_settings::SettingsStore::open_application().ok()?; + let cache = tempfile::Builder::new() + .prefix("browser-session-cef-") + .tempdir_in(store.application_root()) + .ok()?; + let executable = std::env::current_exe().ok()?; + let helper = executable.parent()?.join(if cfg!(windows) { + "colossus-browser-helper.exe" + } else { + "colossus-browser-helper" + }); + match chromium::bootstrap(cache.path(), &helper) { + chromium::Bootstrap::SubprocessExit(code) => std::process::exit(code), + chromium::Bootstrap::Ready => Some(cache), + chromium::Bootstrap::Unavailable(_) => None, + } + } + #[cfg(not(feature = "embedded-chromium-preview"))] + None +} + +pub(crate) fn start_pump(app: &tauri::AppHandle) { + #[cfg(feature = "embedded-chromium-preview")] + if chromium::readiness().is_ok() { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(10)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let (sender, receive) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + chromium::pump(); + let _ = sender.send(()); + }) + .is_err() + { + break; + } + // At most one main-thread pump is queued. A stalled Tauri loop + // cannot accumulate unbounded timer callbacks. + if receive.await.is_err() { + break; + } + } + }); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + let _ = app; +} + +pub(crate) fn shutdown() { + #[cfg(feature = "embedded-chromium-preview")] + chromium::shutdown(); +} diff --git a/apps/desktop/src-tauri/src/browser/certificates.rs b/apps/desktop/src-tauri/src/browser/certificates.rs new file mode 100644 index 000000000..1eae3f6f6 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/certificates.rs @@ -0,0 +1,202 @@ +//! Native PKI setup. Renderer requests actions/preferences, never secret material. + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State, Webview}; + +use super::{commands::require_controller, manager::error}; +use crate::{dto::CommandErrorDto, provider_enrollment::DialogAppearanceInput, state::AppState}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CertificateAction { + Status, + ImportCa, + ImportClientIdentity, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct CertificateRequest { + generation: u64, + action: CertificateAction, + appearance: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] // Independent native capability metadata. +pub(crate) struct CertificateStatusDto { + scope: &'static str, + ca_import_available: bool, + pfx_import_available: bool, + client_identity_selection_ready: bool, + acceptance_pending: bool, + message: String, + fingerprints_sha256: Vec, +} + +impl CertificateStatusDto { + fn current() -> Self { + #[cfg(feature = "embedded-chromium-preview")] + { + let status = colossus_native_browser::pki::status(); + Self { + scope: status.scope, + ca_import_available: status.ca_import_available, + pfx_import_available: status.pfx_import_available, + client_identity_selection_ready: status.client_identity_selection_ready, + acceptance_pending: status.acceptance_pending, + message: status.message.into(), + fingerprints_sha256: Vec::new(), + } + } + #[cfg(not(feature = "embedded-chromium-preview"))] + Self { scope: "operating_system_user", ca_import_available: false, pfx_import_available: false, client_identity_selection_ready: false, acceptance_pending: false, message: "The current browser uses native operating-system trust. Browser certificate import is available only in the embedded Chromium preview. Colossus network CA bundles and client identities do not configure this browser.".into(), fingerprints_sha256: Vec::new() } + } +} + +#[tauri::command] +pub(crate) async fn browser_certificates( + app: AppHandle, + caller: Webview, + state: State<'_, AppState>, + request: CertificateRequest, +) -> Result { + require_controller(&caller)?; + let scope = state.browser.validate(request.generation)?; + if matches!(request.action, CertificateAction::Status) { + return Ok(CertificateStatusDto::current()); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + { + let _ = (app, scope, request.appearance); + Err(error( + "Browser certificate import is unavailable in this build.", + )) + } + #[cfg(feature = "embedded-chromium-preview")] + import(app, &caller, &state, request, &scope).await +} + +#[cfg(feature = "embedded-chromium-preview")] +async fn import( + app: AppHandle, + caller: &Webview, + state: &AppState, + request: CertificateRequest, + scope: &str, +) -> Result { + use colossus_native_browser::pki; + use sha2::Digest as _; + use tauri::Manager as _; + use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind}; + let pki = pki::status(); + let client = matches!(request.action, CertificateAction::ImportClientIdentity); + if (client && !pki.pfx_import_available) || (!client && !pki.ca_import_available) { + return Err(error( + "Native browser certificate import is unavailable on this platform.", + )); + } + state.browser.hide_all(); + // The native confirmation identifies the actual trust scope. Browser profile + // isolation must never be implied for user-store mutations. + let dialog = app.dialog().file(); + let selected = if client { + dialog + .add_filter("Password-protected PKCS#12 identity", &["pfx", "p12"]) + .blocking_pick_file() + } else { + dialog + .add_filter( + "CA trust anchor (PEM or DER)", + &["pem", "crt", "cer", "der"], + ) + .blocking_pick_file() + }; + let Some(selected) = selected else { + return Ok(CertificateStatusDto::current()); + }; + let path = selected + .into_path() + .map_err(|_| error("The native certificate file could not be opened."))?; + // Existing no-follow bounded reader works for native-selected binary input; + // the bytes never enter a command result or the renderer. + let bytes = zeroize::Zeroizing::new( + crate::desktop_settings::read_ca_bundle_source(&path) + .map_err(|_| error("Choose a regular certificate file no larger than 4 MiB."))?, + ); + let password = if client { + let parent = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + Some( + colossus_native_credential_ui::prompt_password( + parent, + request.appearance.map(Into::into).unwrap_or_default(), + ) + .await + .map_err(|failure| error(&failure.to_string()))?, + ) + } else { + None + }; + let fingerprint = if client { + hex::encode(sha2::Sha256::digest(&bytes)) + } else { + pki::ca_fingerprint(&bytes).map_err(|failure| error(&failure.to_string()))? + }; + let description = if client { + "PKCS#12 package SHA-256" + } else { + "CA certificate DER SHA-256" + }; + let agreed = app.dialog().message(format!("Import this selected native certificate input?\n\n{description}: {fingerprint}\n\nThis changes the operating-system user's certificate store and can affect other applications. Browser profiles do not isolate this trust. All Colossus browser tabs will close before import. Chromium client-certificate selection is unavailable until native acceptance passes. Manage removal through the operating system's certificate manager.")).title("Review browser certificate import (preview)").kind(MessageDialogKind::Warning).buttons(MessageDialogButtons::OkCancel).blocking_show(); + if !agreed { + return Ok(CertificateStatusDto::current()); + } + require_controller(caller)?; + if state.browser.validate(request.generation)? != scope { + return Err(error( + "The selected workspace changed before certificate import.", + )); + } + let (generation, operation) = state + .browser + .close_all_authorized(request.generation, scope) + .await?; + // Recheck the selected controller after teardown and before native mutation. + require_controller(caller)?; + let lifecycle = state.browser.lock()?; + if lifecycle.scope.as_deref() != Some(scope) + || lifecycle.generation != generation + || !lifecycle.tabs.is_empty() + { + return Err(error( + "The selected workspace changed before certificate import.", + )); + } + let receipt = if let Some(password) = password { + pki::import_pfx(&bytes, password.expose()) + } else { + pki::import_ca(&bytes) + } + .map_err(|failure| error(&failure.to_string()))?; + drop(lifecycle); + drop(operation); + let mut status = CertificateStatusDto::current(); + status.fingerprints_sha256 = receipt.fingerprints_sha256; + status.message = "Imported into the operating-system user store. Native Chromium trust/key-use acceptance is pending; importing an identity does not enable mTLS selection. Manage removal through the operating system's certificate manager.".into(); + Ok(status) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn certificate_requests_have_no_path_password_or_key_fields() { + for secret in ["path", "password", "privateKey", "keyBytes"] { + let value = serde_json::json!({"generation": 1, "action": "import_client_identity", "appearance": null, (secret): "must not cross IPC"}); + assert!(serde_json::from_value::(value).is_err()); + } + } +} diff --git a/apps/desktop/src-tauri/src/browser/dto.rs b/apps/desktop/src-tauri/src/browser/dto.rs index 01fb421ea..f03bc9a75 100644 --- a/apps/desktop/src-tauri/src/browser/dto.rs +++ b/apps/desktop/src-tauri/src/browser/dto.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub(crate) struct BrowserTabDto { pub(crate) id: String, + pub(crate) session_id: String, + pub(crate) control: &'static str, #[serde(flatten)] pub(crate) page: PageState, pub(crate) error: Option, @@ -19,6 +21,47 @@ pub(crate) struct BrowserSnapshotDto { pub(crate) generation: u64, pub(crate) tabs: Vec, pub(crate) selected_tab_id: Option, + pub(crate) engine: BrowserEngineDto, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserEngineDto { + pub(crate) kind: &'static str, + pub(crate) preview: bool, + pub(crate) ready: bool, + pub(crate) message: Option, + pub(crate) agent_control_available: bool, +} + +impl BrowserEngineDto { + pub(super) fn current() -> Self { + #[cfg(feature = "embedded-chromium-preview")] + { + let status = colossus_native_browser::chromium::readiness(); + Self { + kind: "embedded_chromium", + preview: true, + ready: status.is_ok(), + message: status.err().map(|error| error.to_string()), + agent_control_available: false, + } + } + #[cfg(not(feature = "embedded-chromium-preview"))] + Self { + kind: if cfg!(windows) { + "webview2" + } else if cfg!(target_os = "macos") { + "webkit" + } else { + "unavailable" + }, + preview: true, + ready: cfg!(windows) || cfg!(all(feature = "browser-preview", target_os = "macos")), + message: None, + agent_control_available: false, + } + } } #[derive(Debug, Deserialize)] diff --git a/apps/desktop/src-tauri/src/browser/guest.rs b/apps/desktop/src-tauri/src/browser/guest.rs new file mode 100644 index 000000000..8b97b2867 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/guest.rs @@ -0,0 +1,87 @@ +//! Native guest construction. Neither branch exposes app capability to a page. + +use std::path::PathBuf; + +#[cfg(not(feature = "embedded-chromium-preview"))] +use colossus_native_browser::BrowserEvent; +use colossus_native_browser::{self as engine, BrowserView, EventSink, NavigationPolicy}; +use tauri::{AppHandle, Manager as _, Webview}; +#[cfg(not(feature = "embedded-chromium-preview"))] +use tauri::{ + WebviewUrl, + webview::{DownloadEvent, NewWindowResponse, WebviewBuilder}, +}; + +use super::manager::error; +use crate::dto::CommandErrorDto; + +pub(super) async fn create( + app: &AppHandle, + id: &str, + generation: u64, + directory: PathBuf, + source: Option, + policy: NavigationPolicy, + sink: EventSink, +) -> Result { + let window = app + .get_window("main") + .ok_or_else(|| error("The Desktop window has closed."))?; + #[cfg(feature = "embedded-chromium-preview")] + { + let _ = source; + return engine::chromium::Surface::create(&window, id, generation, directory, policy, sink) + .await + .map(BrowserView::Chromium) + .map_err(|failure| error(&failure.to_string())); + } + #[cfg(not(feature = "embedded-chromium-preview"))] + { + let _ = generation; + let popup_sink = sink.clone(); + let download_sink = sink.clone(); + let navigation = policy.clone(); + let mut builder = WebviewBuilder::new( + id, + WebviewUrl::External( + "about:blank" + .parse() + .map_err(|_| error("Browser initialization failed."))?, + ), + ) + .incognito(true) + .data_directory(directory) + .focused(false) + .devtools(false) + .on_navigation(move |url| navigation.allows(url.as_str())) + .on_new_window(move |url, _| { + popup_sink(BrowserEvent::Popup(url.to_string())); + NewWindowResponse::Deny + }) + .on_download(move |_, event| { + if matches!(event, DownloadEvent::Requested { .. }) { + download_sink(BrowserEvent::Download); + } + false + }); + if let Some(source) = source { + builder = engine::share_session(builder, &source) + .await + .map_err(|failure| error(&failure.to_string()))?; + } + let view = window + .add_child( + builder, + tauri::LogicalPosition::new(-10_000.0, -10_000.0), + tauri::LogicalSize::new(16.0, 16.0), + ) + .map_err(|_| error("The browser engine could not start."))?; + let _ = view.hide(); + if let Err(failure) = engine::harden(&view, policy, sink).await { + let _ = engine::release(&view).await; + let _ = view.close(); + return Err(error(&failure.to_string())); + } + Ok(BrowserView::System(view)) + } +} diff --git a/apps/desktop/src-tauri/src/browser/manager.rs b/apps/desktop/src-tauri/src/browser/manager.rs index 20c9449b8..9b7bfb35b 100644 --- a/apps/desktop/src-tauri/src/browser/manager.rs +++ b/apps/desktop/src-tauri/src/browser/manager.rs @@ -1,11 +1,12 @@ use colossus_native_browser::{ - self as engine, BrowserEvent, EventSink, NavigationAction, NavigationPolicy, PageState, + self as engine, BrowserEvent, BrowserView, EventSink, NavigationAction, NavigationPolicy, + PageState, }; -use std::sync::{Arc, Mutex, MutexGuard}; -use tauri::{ - AppHandle, Manager as _, Webview, WebviewUrl, - webview::{DownloadEvent, NewWindowResponse, WebviewBuilder}, +use std::sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicU64, Ordering}, }; +use tauri::{AppHandle, Manager as _, Webview}; use super::{ dto::{BrowserAction, BrowserSnapshotDto, BrowserTabDto}, @@ -35,38 +36,78 @@ impl BrowserManager { } pub(crate) fn selection_changed(&self, scope: Option) { - if let Ok(mut state) = self.data.lock() + let views = if let Ok(mut state) = self.data.lock() && state.scope != scope { for tab in &mut state.tabs { - let _ = tab.view.hide(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); tab.heartbeat = None; } state.generation = state.generation.wrapping_add(1); state.scope = scope; + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn hide_all(&self) { - if let Ok(mut state) = self.data.lock() { + let views = if let Ok(mut state) = self.data.lock() { for tab in &mut state.tabs { - let _ = tab.view.hide(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); tab.heartbeat = None; } + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn controller_loading(&self) { - self.hide_all(); - if let Ok(mut state) = self.data.lock() { + let views = if let Ok(mut state) = self.data.lock() { state.generation = state.generation.wrapping_add(1); + for tab in &mut state.tabs { + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + } + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } pub(crate) fn validate(&self, generation: u64) -> Result { let state = self.lock()?; if !state.snapshot().available { - return Err(error("Browser preview is not enabled in this build.")); + return Err(error( + state + .snapshot() + .engine + .message + .as_deref() + .unwrap_or("Browser preview is not enabled in this build."), + )); } if state.generation != generation { return Err(error( @@ -83,7 +124,7 @@ impl BrowserManager { &self, id: &str, scope: &str, - ) -> Result<(Webview, NavigationPolicy), CommandErrorDto> { + ) -> Result<(BrowserView, NavigationPolicy), CommandErrorDto> { self.lock()? .tabs .iter() @@ -92,6 +133,91 @@ impl BrowserManager { .ok_or_else(|| error("This tab is no longer available in the selected workspace.")) } + #[cfg(feature = "browser-test-bridge")] + pub(super) fn legacy_tab( + &self, + id: &str, + scope: &str, + ) -> Result<(Webview, NavigationPolicy), CommandErrorDto> { + let (view, policy) = self.tab(id, scope)?; + let view = view + .system_view() + .cloned() + .ok_or_else(|| error("Use the Chromium native acceptance harness for this build."))?; + Ok((view, policy)) + } + + /// Revoke controller generations, hide, then settle native teardown before + /// temporary profiles are dropped. Failed cleanup keeps profile ownership. + pub(crate) async fn close_all(&self) { + let _ = self.close_all_settled().await; + } + + pub(super) async fn close_all_settled(&self) -> Result { + let operation = self.operation.lock().await; + self.close_all_locked(&operation, None).await + } + + #[cfg(any(test, feature = "embedded-chromium-preview"))] + pub(super) async fn close_all_authorized<'a>( + &'a self, + expected_generation: u64, + expected_scope: &str, + ) -> Result<(u64, tokio::sync::MutexGuard<'a, ()>), CommandErrorDto> { + let operation = self.operation.lock().await; + let generation = self + .close_all_locked(&operation, Some((expected_generation, expected_scope))) + .await?; + // The native store caller retains this guard through its final + // lifecycle check and mutation, excluding creation of a new guest. + Ok((generation, operation)) + } + + async fn close_all_locked( + &self, + _operation: &tokio::sync::MutexGuard<'_, ()>, + expected: Option<(u64, &str)>, + ) -> Result { + let (generation, tabs, views) = { + let mut state = self.lock()?; + if let Some((generation, scope)) = expected + && (state.generation != generation || state.scope.as_deref() != Some(scope)) + { + return Err(error( + "The selected workspace changed before browser teardown.", + )); + } + state.generation = state.generation.wrapping_add(1); + for tab in &mut state.tabs { + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + } + ( + state.generation, + state + .tabs + .iter() + .map(|tab| (tab.scope.clone(), tab.dto.id.clone())) + .collect::>(), + state + .tabs + .iter() + .map(|tab| tab.view.clone()) + .collect::>(), + ) + }; + for view in views { + view.hide().map_err(engine_error)?; + } + for (scope, id) in tabs { + self.close(&scope, &id).await?; + } + if !self.lock()?.tabs.is_empty() { + return Err(error("Native browser sessions did not finish closing.")); + } + Ok(generation) + } + pub(crate) async fn snapshot(&self) -> Result { let views: Vec<_> = { let state = self.lock()?; @@ -104,7 +230,7 @@ impl BrowserManager { }; let inspections = views .into_iter() - .map(|(id, view)| async move { (id, engine::inspect(&view).await) }); + .map(|(id, view)| async move { (id, view.inspect().await) }); for (id, mut page) in collect_pages(inspections, SNAPSHOT_BUDGET).await { let mut state = self.lock()?; if let Some(tab) = state.tabs.iter_mut().find(|t| t.dto.id == id) { @@ -161,7 +287,10 @@ impl BrowserManager { } BrowserAction::Close { tab_id } => self.close(&scope, &tab_id).await?, BrowserAction::OpenExternal { tab_id } => { - let (view, _) = self.tab(&tab_id, &scope)?; + self.tab(&tab_id, &scope)?; + let view = app + .get_webview("main") + .ok_or_else(|| error("The Desktop controller has closed."))?; let url = self .lock()? .tabs @@ -204,7 +333,7 @@ impl BrowserManager { } else { self.reset_navigation(&id, None)?; } - engine::control(&view, action).await.map_err(engine_error)?; + view.control(action).await.map_err(engine_error)?; } } Ok(()) @@ -248,6 +377,10 @@ impl BrowserManager { .tempdir_in(store.application_root()) .map_err(|_| error("The temporary browser session could not be created."))?; state.profiles.insert(scope.to_owned(), directory); + state.sessions.insert( + scope.to_owned(), + format!("browser-session-{}", uuid::Uuid::new_v4()), + ); } Ok(( state @@ -259,8 +392,8 @@ impl BrowserManager { state .tabs .iter() - .find(|t| t.scope == scope) - .map(|t| t.view.clone()), + .filter(|t| t.scope == scope) + .find_map(|t| t.view.system_view().cloned()), )) } @@ -284,70 +417,54 @@ impl BrowserManager { let weak = Arc::downgrade(&self.data); let event_id = id.clone(); let sink: EventSink = Arc::new(move |event| { - if let Some(data) = weak.upgrade() - && let Ok(mut state) = data.lock() - { + let crashed = matches!(event, BrowserEvent::Crashed); + let view = weak.upgrade().and_then(|data| { + let mut state = data.lock().ok()?; state.event(&event_id, event); + crashed + .then(|| { + state + .tabs + .iter() + .find(|tab| tab.dto.id == event_id) + .map(|tab| tab.view.clone()) + }) + .flatten() + }); + if let Some(view) = view { + let _ = view.hide(); } }); - let popup_sink = sink.clone(); - let download_sink = sink.clone(); - let navigation = policy.clone(); - let mut builder = WebviewBuilder::new( + let generation = self.lock()?.generation; + let view = super::guest::create( + app, &id, - WebviewUrl::External( - "about:blank" - .parse() - .map_err(|_| error("Browser initialization failed."))?, - ), + generation, + directory, + source, + policy.clone(), + sink, ) - .incognito(true) - .data_directory(directory) - .focused(false) - .devtools(false) - .on_navigation(move |url| navigation.allows(url.as_str())) - .on_new_window(move |url, _| { - popup_sink(BrowserEvent::Popup(url.to_string())); - NewWindowResponse::Deny - }) - .on_download(move |_, event| { - if matches!(event, DownloadEvent::Requested { .. }) { - download_sink(BrowserEvent::Download); - } - false - }); - if let Some(source) = source { - builder = engine::share_session(builder, &source) - .await - .map_err(engine_error)?; - } - let window = app - .get_window("main") - .ok_or_else(|| error("The Desktop window has closed."))?; - let view = window - .add_child( - builder, - tauri::LogicalPosition::new(-10_000.0, -10_000.0), - tauri::LogicalSize::new(16.0, 16.0), - ) - .map_err(|_| error("The browser engine could not start."))?; - let _ = view.hide(); - if let Err(failure) = engine::harden(&view, policy.clone(), sink).await { - let _ = engine::release(&view).await; - let _ = view.close(); - return Err(engine_error(failure)); - } + .await?; self.hide_all(); { let mut state = self.lock()?; + let session_id = state + .sessions + .get(scope) + .cloned() + .ok_or_else(|| error("The browser session identity is unavailable."))?; state.selected.insert(scope.to_owned(), id.clone()); state.tabs.push(Tab { scope: scope.to_owned(), view: view.clone(), policy, heartbeat: None, + presentation_epoch: Arc::new(AtomicU64::new(0)), dto: BrowserTabDto { id: id.clone(), + session_id, + control: "human", page: PageState { url: url.as_ref().map(ToString::to_string).unwrap_or_default(), title: "New tab".into(), @@ -371,8 +488,8 @@ impl BrowserManager { async fn close(&self, scope: &str, id: &str) -> Result<(), CommandErrorDto> { let (view, _) = self.tab(id, scope)?; let _ = view.hide(); - let _ = engine::release(&view).await; view.close() + .await .map_err(|_| error("This browser tab could not close."))?; let mut state = self.lock()?; state.tabs.retain(|t| t.dto.id != id); @@ -393,7 +510,52 @@ impl BrowserManager { } if !state.tabs.iter().any(|t| t.scope == scope) { state.profiles.remove(scope); + state.sessions.remove(scope); } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn stale_certificate_teardown_is_rejected_after_waiting_for_operation_ownership() { + let manager = Arc::new(BrowserManager::default()); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.generation = 7; + } + let operation = manager.operation.lock().await; + let queued = manager.clone(); + let attempt = + tokio::spawn(async move { queued.close_all_authorized(7, "workspace").await.is_err() }); + tokio::task::yield_now().await; + // A same-workspace controller reload revokes the original authorization. + manager.lock().unwrap().generation = 8; + drop(operation); + assert!(attempt.await.unwrap()); + let state = manager.lock().unwrap(); + // Rejection occurs before advancing lifecycle or planning native effects. + assert_eq!(state.generation, 8); + assert_eq!(state.scope.as_deref(), Some("workspace")); + assert!(state.tabs.is_empty()); + } + + #[tokio::test] + async fn certificate_teardown_retains_operation_ownership_until_import_finishes() { + let manager = BrowserManager::default(); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.generation = 7; + } + let (generation, operation) = manager.close_all_authorized(7, "workspace").await.unwrap(); + assert_eq!(generation, 8); + assert!(manager.operation.try_lock().is_err()); + drop(operation); + assert!(manager.operation.try_lock().is_ok()); + } +} diff --git a/apps/desktop/src-tauri/src/browser/mod.rs b/apps/desktop/src-tauri/src/browser/mod.rs index bc6ef4d0f..0cc169eb2 100644 --- a/apps/desktop/src-tauri/src/browser/mod.rs +++ b/apps/desktop/src-tauri/src/browser/mod.rs @@ -2,10 +2,14 @@ #[cfg(feature = "browser-test-bridge")] pub(crate) mod acceptance; +pub(crate) mod bootstrap; +pub(crate) mod certificates; pub(crate) mod commands; pub(crate) mod dto; +mod guest; mod inspection; mod manager; +mod presentation; mod registry; pub(crate) mod viewport; diff --git a/apps/desktop/src-tauri/src/browser/presentation.rs b/apps/desktop/src-tauri/src/browser/presentation.rs new file mode 100644 index 000000000..d9a0f0d2e --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/presentation.rs @@ -0,0 +1,52 @@ +//! A late main-thread show cannot outlive workspace/overlay authority revocation. + +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use colossus_native_browser::{BrowserError, BrowserView}; +use tauri::Window; + +use super::dto::BrowserRect; + +pub(super) async fn apply( + window: &Window, + view: BrowserView, + lease: Arc, + epoch: u64, + bounds: Option, +) -> Result<(), BrowserError> { + let (sender, receive) = tokio::sync::oneshot::channel(); + window + .run_on_main_thread(move || { + let result = if !sender.is_closed() && lease.load(Ordering::Acquire) == epoch { + if let Some(rect) = bounds { + view.set_bounds(tauri::Rect { + position: tauri::LogicalPosition::new(rect.x, rect.y).into(), + size: tauri::LogicalSize::new(rect.width, rect.height).into(), + }) + .and_then(|()| { + // Resizing can invoke native callbacks. Recheck the + // current presentation owner immediately before show. + if !sender.is_closed() && lease.load(Ordering::Acquire) == epoch { + view.show() + } else { + view.hide()?; + Err(BrowserError::Closed) + } + }) + } else { + view.hide() + } + } else { + Err(BrowserError::Closed) + }; + let _ = sender.send(result); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(std::time::Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? +} diff --git a/apps/desktop/src-tauri/src/browser/registry.rs b/apps/desktop/src-tauri/src/browser/registry.rs index c1fe96937..06ff99370 100644 --- a/apps/desktop/src-tauri/src/browser/registry.rs +++ b/apps/desktop/src-tauri/src/browser/registry.rs @@ -1,16 +1,23 @@ -use super::dto::{BrowserSnapshotDto, BrowserTabDto}; -use colossus_native_browser::{BrowserEvent, NavigationPolicy}; -use std::{collections::HashMap, time::Instant}; -use tauri::Webview; +use super::dto::{BrowserEngineDto, BrowserSnapshotDto, BrowserTabDto}; +use colossus_native_browser::{BrowserEvent, BrowserView, NavigationPolicy}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; pub(super) const MAX_TABS: usize = 8; pub(super) struct Tab { pub(super) scope: String, - pub(super) view: Webview, + pub(super) view: BrowserView, pub(super) policy: NavigationPolicy, pub(super) dto: BrowserTabDto, pub(super) heartbeat: Option, + pub(super) presentation_epoch: Arc, } #[derive(Default)] @@ -20,12 +27,15 @@ pub(super) struct Registry { pub(super) tabs: Vec, pub(super) selected: HashMap, pub(super) profiles: HashMap, + pub(super) sessions: HashMap, } impl Registry { pub(super) fn snapshot(&self) -> BrowserSnapshotDto { + let engine = BrowserEngineDto::current(); BrowserSnapshotDto { - available: cfg!(windows) || cfg!(all(feature = "browser-preview", target_os = "macos")), + available: engine.ready, + engine, generation: self.generation, tabs: self .tabs @@ -64,7 +74,8 @@ impl Registry { tab.dto.error = Some( "This browser tab stopped responding. Close it and open a new tab.".into(), ); - let _ = tab.view.hide(); + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; } BrowserEvent::Blocked => { tab.dto.notice = Some("This page requested access that is unavailable in the embedded browser. You can open it in your system browser.".into()); @@ -83,6 +94,14 @@ impl Registry { tab.dto.notice = Some("This page wants to open another tab.".into()); } } + BrowserEvent::TlsFailed => { + tab.dto.page.loading = false; + tab.dto.error = Some("The website's certificate could not be verified. Check its hostname, expiry, and native CA trust configuration. TLS verification cannot be bypassed.".into()); + } + BrowserEvent::AuthenticationRequired => { + tab.dto.page.loading = false; + tab.dto.notice = Some("This website requires a client certificate. A native identity must be provisioned and reviewed for this exact HTTPS origin.".into()); + } } } } diff --git a/apps/desktop/src-tauri/src/browser/viewport.rs b/apps/desktop/src-tauri/src/browser/viewport.rs index 91ea8d202..7b3295a95 100644 --- a/apps/desktop/src-tauri/src/browser/viewport.rs +++ b/apps/desktop/src-tauri/src/browser/viewport.rs @@ -4,7 +4,10 @@ use super::{ manager::error, }; use crate::{dto::CommandErrorDto, state::AppState}; -use std::time::{Duration, Instant}; +use std::{ + sync::atomic::Ordering, + time::{Duration, Instant}, +}; use tauri::{AppHandle, Manager as _}; pub(crate) fn valid_rect(rect: BrowserRect, width: f64, height: f64) -> bool { @@ -42,47 +45,74 @@ impl BrowserManager { .inner_size() .map_err(|_| error("The browser viewport is unavailable."))? .to_logical::(scale); - let mut state = self.lock()?; - let selected = state.selected.get(&scope).cloned(); - for tab in &mut state.tabs { - let visible = active - && tab.scope == scope - && Some(&tab.dto.id) == request.tab_id.as_ref() - && request.tab_id == selected - && tab.dto.error.is_none(); - if let Some(rect) = request - .rect - .filter(|r| visible && valid_rect(*r, size.width, size.height)) + let operations = { + let mut state = self.lock()?; + if state.generation != request.generation + || state.scope.as_deref() != Some(scope.as_str()) { - tab.view - .set_bounds(tauri::Rect { - position: tauri::LogicalPosition::new(rect.x, rect.y).into(), - size: tauri::LogicalSize::new(rect.width, rect.height).into(), - }) - .map_err(|_| error("The browser viewport could not be resized."))?; - tab.view - .show() - .map_err(|_| error("The browser tab could not be displayed."))?; - tab.heartbeat = Some(Instant::now()); - } else { - let _ = tab.view.hide(); - tab.heartbeat = None; + return Err(error( + "The selected workspace changed. Reopen the browser pane.", + )); + } + let selected = state.selected.get(&scope).cloned(); + state + .tabs + .iter_mut() + .map(|tab| { + let visible = active + && tab.scope == scope + && Some(&tab.dto.id) == request.tab_id.as_ref() + && request.tab_id == selected + && tab.dto.error.is_none(); + let rect = request + .rect + .filter(|rect| visible && valid_rect(*rect, size.width, size.height)); + tab.heartbeat = rect.map(|_| Instant::now()); + ( + tab.view.clone(), + tab.presentation_epoch.clone(), + tab.presentation_epoch + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1), + rect, + ) + }) + .collect::>() + }; + // Drop registry locks before UI work: native event callbacks also need + // them. Each queued operation rechecks its independently revocable lease. + for (view, lease, epoch, rect) in operations { + match super::presentation::apply(&window, view, lease, epoch, rect).await { + Ok(()) | Err(colossus_native_browser::BrowserError::Closed) => {} + Err(_) => return Err(error("The browser viewport could not be updated.")), } } Ok(()) } fn hide_expired(&self) { - if let Ok(mut state) = self.data.lock() { - for tab in &mut state.tabs { - if tab - .heartbeat - .is_some_and(|last| last.elapsed() > Duration::from_secs(2)) - { - let _ = tab.view.hide(); - tab.heartbeat = None; - } - } + let views = if let Ok(mut state) = self.data.lock() { + state + .tabs + .iter_mut() + .filter_map(|tab| { + if tab + .heartbeat + .is_some_and(|last| last.elapsed() > Duration::from_secs(2)) + { + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + Some(tab.view.clone()) + } else { + None + } + }) + .collect::>() + } else { + Vec::new() + }; + for view in views { + let _ = view.hide(); } } } diff --git a/apps/desktop/src-tauri/src/dictation.rs b/apps/desktop/src-tauri/src/dictation.rs index 97dbdcb0e..49dc47861 100644 --- a/apps/desktop/src-tauri/src/dictation.rs +++ b/apps/desktop/src-tauri/src/dictation.rs @@ -176,6 +176,8 @@ pub(crate) async fn get_dictation_settings( #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +// The IPC schema remains stable when the dictation backend is disabled. +#[cfg_attr(not(feature = "dictation"), allow(dead_code))] pub(crate) struct DictationSettingsRequest { enabled: bool, model_id: String, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d40ddcb47..de988ec64 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run_browser_acceptance() -> i32 { browser::acceptance::run() } +use browser::certificates::browser_certificates; use browser::commands::{browser_command, browser_context, browser_viewport}; use codex_auth::{codex_auth_login, codex_auth_logout, codex_auth_status}; use command_review::{command_review_context, finish_command_review}; @@ -146,6 +147,9 @@ use workspace_git::commands::{ // Composition-only registration list: native command implementations stay in modules. #[allow(clippy::too_many_lines)] pub fn run() { + // CEF helper dispatch and macOS application protocol installation must + // precede app_context, Tauri, AppKit, and any late-loaded plugin. + let _browser_cache = browser::bootstrap::initialize(); #[cfg(feature = "dictation")] if let Some(code) = colossus_native_dictation::run_if_requested() { std::process::exit(code); @@ -189,6 +193,7 @@ pub fn run() { .setup(|app| { status_bar::setup(app)?; browser::start_watchdog(app.handle().clone()); + browser::bootstrap::start_pump(app.handle()); #[cfg(windows)] outlook_companion::start_watchdog(app.handle().clone()); terminal_commands::pane::start_watchdog(app.handle().clone()); @@ -232,6 +237,7 @@ pub fn run() { browser_context, browser_command, browser_viewport, + browser_certificates, command_review_context, finish_command_review, remembered_command_count, @@ -376,7 +382,8 @@ pub fn run() { .expect("failed to build the Colossus desktop application"); #[cfg(windows)] drop(launch_guard); - application.run(|app, event| { + let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)); + application.run(move |app, event| { #[cfg(target_os = "macos")] if matches!( event, @@ -387,11 +394,29 @@ pub fn run() { ) { status_bar::show_main_window(app); } - if matches!(event, tauri::RunEvent::ExitRequested { .. }) { + if let tauri::RunEvent::ExitRequested { api, .. } = event { + use std::sync::atomic::Ordering; use tauri::Manager as _; - app.state::().cancel(); - - tauri::async_runtime::block_on(app.state::().close_all()); + if shutdown.load(Ordering::Acquire) < 2 { + api.prevent_exit(); + if shutdown + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + app.state::().cancel(); + let app = app.clone(); + let shutdown = shutdown.clone(); + tauri::async_runtime::spawn(async move { + // Keep Tauri's main thread alive to settle native close + // callbacks. Blocking it here would deadlock CEF teardown. + app.state::().close_all().await; + shutdown.store(2, Ordering::Release); + app.exit(0); + }); + } + } + } else if matches!(event, tauri::RunEvent::Exit) { + browser::bootstrap::shutdown(); } }); } diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index f6bb174b4..add987710 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -1342,6 +1342,7 @@ impl AppState { } pub(crate) async fn close_all(&self) { + self.browser.close_all().await; #[cfg(windows)] self.stop_all_outlook_companions().await; { diff --git a/apps/desktop/src-tauri/src/status_bar.rs b/apps/desktop/src-tauri/src/status_bar.rs index 1c4166d83..bcc3cd776 100644 --- a/apps/desktop/src-tauri/src/status_bar.rs +++ b/apps/desktop/src-tauri/src/status_bar.rs @@ -231,6 +231,11 @@ fn status_bar_error() -> CommandErrorDto { ) } +// Keep the same fallible setup API used by the macOS and Windows composition. +#[cfg_attr( + not(any(target_os = "macos", windows)), + allow(clippy::unnecessary_wraps) +)] pub(crate) fn setup(app: &mut App) -> tauri::Result<()> { #[cfg(any(target_os = "macos", windows))] { diff --git a/apps/desktop/src/browser-api.ts b/apps/desktop/src/browser-api.ts index 287ed0a15..965125bac 100644 --- a/apps/desktop/src/browser-api.ts +++ b/apps/desktop/src/browser-api.ts @@ -10,13 +10,37 @@ export interface BrowserTab { error: string | null; notice: string | null; popupUrl: string | null; + sessionId?: string; + control?: "human" | "agent" | "paused" | "unavailable"; } +export interface BrowserEngine { + kind: "embedded_chromium" | "webview2" | "webkit" | "unavailable"; + preview: boolean; + ready: boolean; + message: string | null; + agentControlAvailable: boolean; +} + +export interface BrowserCertificateStatus { + scope: "operating_system_user" | "unsupported"; + caImportAvailable: boolean; + pfxImportAvailable: boolean; + clientIdentitySelectionReady: boolean; + acceptancePending: boolean; + message: string; + fingerprintsSha256: string[]; +} + +export type BrowserCertificateAction = + "status" | "import_ca" | "import_client_identity"; + export interface BrowserSnapshot { available: boolean; generation: number; tabs: BrowserTab[]; selectedTabId: string | null; + engine?: BrowserEngine; } export type BrowserAction = @@ -47,6 +71,14 @@ export interface BrowserApi { context(): Promise; command(generation: number, action: BrowserAction): Promise; viewport(request: BrowserViewport): Promise; + certificates?( + generation: number, + action: BrowserCertificateAction, + appearance: { + colorScheme: "system" | "dark" | "light"; + textSize: "compact" | "comfortable" | "large"; + }, + ): Promise; } export const nativeBrowserApi: BrowserApi = { @@ -54,6 +86,10 @@ export const nativeBrowserApi: BrowserApi = { command: (generation, action) => invoke("browser_command", { request: { generation, action } }), viewport: (request) => invoke("browser_viewport", { request }), + certificates: (generation, action, appearance) => + invoke("browser_certificates", { + request: { generation, action, appearance }, + }), }; export function browserErrorMessage(error: unknown): string { diff --git a/apps/desktop/src/components/browser/BrowserCertificates.tsx b/apps/desktop/src/components/browser/BrowserCertificates.tsx new file mode 100644 index 000000000..2cad21135 --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserCertificates.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@colossus/ui"; +import type { + BrowserCertificateAction, + BrowserCertificateStatus, +} from "../../browser-api"; +import { browserErrorMessage } from "../../browser-api"; +import { useAppearance } from "../../theme/AppearanceProvider"; +import type { BrowserController } from "./useBrowser"; + +export function BrowserCertificates({ + controller, + onClose, +}: { + controller: BrowserController; + onClose: () => void; +}) { + const preferences = useAppearance(); + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const closeRef = useRef(null); + const operation = useRef(0); + async function perform(action: BrowserCertificateAction) { + const sequence = ++operation.current; + setBusy(true); + setError(""); + try { + const status = await controller.certificates(action, { + colorScheme: preferences.resolvedColorTheme, + textSize: preferences.textSize, + }); + if (sequence === operation.current) setStatus(status); + } catch (cause) { + if (sequence === operation.current) setError(browserErrorMessage(cause)); + } finally { + if (sequence === operation.current) setBusy(false); + } + } + useEffect(() => { + closeRef.current?.focus(); + void perform("status"); + return () => { + ++operation.current; + }; + // Scope and controller generation are bound in the host request, not derived here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }} + > +
+

Browser certificates

+ +
+

{status?.message ?? "Reading native certificate support…"}

+ {status ? ( +
+
Trust scope
+
+ {status.scope === "operating_system_user" + ? "Operating-system user store" + : "Unavailable"} +
+
Client certificate selection
+
+ {status.clientIdentitySelectionReady ? "Available" : "Unavailable"} +
+ {status.acceptancePending ? ( + <> +
Native acceptance
+
Pending
+ + ) : null} +
+ ) : null} + {error ?

{error}

: null} + {status?.fingerprintsSha256.length ? ( +
+

Imported certificate fingerprints (SHA-256)

+
    + {status.fingerprintsSha256.map((fingerprint) => ( +
  • + {fingerprint} +
  • + ))} +
+
+ ) : null} +
+ + +
+

+ Files and passphrases are selected in native dialogs. Imports can affect + other applications. Client certificate import does not grant an agent + access. +

+
+ ); +} diff --git a/apps/desktop/src/components/browser/BrowserPane.test.ts b/apps/desktop/src/components/browser/BrowserPane.test.ts index 3550061b9..37f5769e3 100644 --- a/apps/desktop/src/components/browser/BrowserPane.test.ts +++ b/apps/desktop/src/components/browser/BrowserPane.test.ts @@ -6,6 +6,7 @@ import type { BrowserController } from "./useBrowser"; function render( overrides: Partial = {}, + snapshotOverrides: Partial = {}, ) { const controller: BrowserController = { snapshot: { @@ -26,6 +27,7 @@ function render( ...overrides, }, ], + ...snapshotOverrides, }, error: "", busy: false, @@ -33,6 +35,15 @@ function render( fixture: true, command: vi.fn(async () => {}), viewport: vi.fn(async () => {}), + certificates: vi.fn(async () => ({ + scope: "unsupported" as const, + caImportAvailable: false, + pfxImportAvailable: false, + clientIdentitySelectionReady: false, + acceptancePending: true, + message: "Unavailable", + fingerprintsSha256: [], + })), }; return renderToStaticMarkup( createElement(BrowserPane, { @@ -45,6 +56,28 @@ function render( } describe("browser controls", () => { + it("reports a missing Chromium component without claiming a working browser", () => { + const html = render( + {}, + { + available: false, + engine: { + kind: "embedded_chromium", + preview: true, + ready: false, + message: + "The verified Chromium component is not included in this build.", + agentControlAvailable: false, + }, + }, + ); + expect(html).toContain("Browser unavailable"); + expect(html).toContain("Control unavailable"); + expect(html).not.toContain("Human control"); + expect(html).toContain("The verified Chromium component is not included"); + expect(html).not.toContain("Chromium preview ·"); + expect(html).not.toContain("Resume agent"); + }); it("keeps stop and close available while a page loads", () => { const html = render({ loading: true }); expect(html).toContain('aria-label="Stop loading"'); diff --git a/apps/desktop/src/components/browser/BrowserPane.tsx b/apps/desktop/src/components/browser/BrowserPane.tsx index 8d4e5dccb..9c606bef1 100644 --- a/apps/desktop/src/components/browser/BrowserPane.tsx +++ b/apps/desktop/src/components/browser/BrowserPane.tsx @@ -12,6 +12,7 @@ import { IconX, } from "@tabler/icons-react"; import type { BrowserController } from "./useBrowser"; +import { BrowserCertificates } from "./BrowserCertificates"; import { useDesktopPreferences } from "../../DesktopPreferencesProvider"; import "./browser.css"; @@ -32,14 +33,41 @@ export function BrowserPane({ const { snapshot, command, error, busy, fixture, viewport } = controller; const active = snapshot.tabs.find((tab) => tab.id === snapshot.selectedTabId); const [address, setAddress] = useState(active?.url ?? ""); + const [showCertificates, setShowCertificates] = useState(false); const addressRef = useRef(null); const selectedTabRef = useRef(null); const viewportRef = useRef(null); + const ready = + snapshot.available && + snapshot.engine?.ready !== false && + snapshot.engine?.kind !== "unavailable"; + const engineLabel = !ready + ? "Browser unavailable" + : snapshot.engine?.kind === "embedded_chromium" + ? "Chromium preview" + : snapshot.engine?.kind === "webview2" + ? "WebView2" + : snapshot.engine?.kind === "webkit" + ? "WebKit" + : "Browser"; + const controlLabel = !ready + ? "Control unavailable" + : active?.control === "agent" + ? "Agent control" + : active?.control === "paused" + ? "Paused" + : active?.control === "unavailable" + ? "Control unavailable" + : "Human control"; useEffect(() => { setAddress(active?.url ?? ""); }, [active?.id, active?.url]); + useEffect(() => { + if (!snapshot.available) setShowCertificates(false); + }, [snapshot.available]); + useEffect(() => { selectedTabRef.current?.scrollIntoView({ block: "nearest", @@ -179,7 +207,7 @@ export function BrowserPane({ className="icon-button" type="button" aria-label="New browser tab" - disabled={busy} + disabled={busy || !snapshot.available} onClick={() => void command({ type: "new", url: browserNewTabUrl })} > @@ -213,7 +241,7 @@ export function BrowserPane({ className="browser-address-bar" onSubmit={(event) => { event.preventDefault(); - if (address.trim()) + if (snapshot.available && address.trim()) void command( active ? { type: "navigate", tabId: active.id, url: address } @@ -288,6 +316,11 @@ export function BrowserPane({ {error} ) : null} + {snapshot.engine?.message ? ( +
+ {snapshot.engine.message} +
+ ) : null} {active?.notice ? (
{active.notice} @@ -318,6 +351,12 @@ export function BrowserPane({
) : null}
+ {showCertificates ? ( + setShowCertificates(false)} + /> + ) : null} {active?.error ? (
@@ -352,7 +391,7 @@ export function BrowserPane({