diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 613d347c..50a36b1c 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -1212,7 +1212,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -2533,6 +2532,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -2557,6 +2566,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2581,6 +2601,8 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-javascript-core", + "objc2-security", ] [[package]] @@ -2608,6 +2630,8 @@ dependencies = [ "anyhow", "base64 0.22.1", "log", + "objc2", + "objc2-web-kit", "reqwest 0.12.28", "serde", "serde_json", @@ -3436,9 +3460,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 173f4d3d..85b0a109 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ devtools = ["tauri/devtools"] anyhow = "1.0.86" base64 = "0.22.1" log = "0.4.28" -reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] } +reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" tauri = { version = "2.10.3", features = ["macos-private-api"] } @@ -31,5 +31,12 @@ url = "2.5" [build-dependencies] tauri-build = { version = "2.5.6", features = [] } +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true + [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-web-kit = "0.3" window-vibrancy = "0.7.1" diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index c7964ad3..9dc941f9 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,6 +28,22 @@ use window_vibrancy::{ apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial, }; +/// Disable pinch-to-zoom / magnification gestures on macOS to avoid accidental +/// zoom and the continuous gesture event processing overhead. +#[cfg(target_os = "macos")] +fn disable_pinch_zoom(window: &tauri::WebviewWindow) { + let _ = window.with_webview(|webview| unsafe { + use objc2::rc::Retained; + use objc2_web_kit::WKWebView; + let wk_webview: Retained = + Retained::retain(webview.inner().cast()).unwrap(); + wk_webview.setAllowsMagnification(false); + }); +} + +#[cfg(not(target_os = "macos"))] +fn disable_pinch_zoom(_window: &tauri::WebviewWindow) {} + /// Global counter for generating unique window labels. static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -1174,9 +1190,11 @@ fn is_app_bundle_installed(bundle_name: &str) -> bool { const SIDECAR_NAME: &str = "openchamber-server"; const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); -const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250); +const HEALTH_POLL_INITIAL_INTERVAL: Duration = Duration::from_millis(250); +const HEALTH_POLL_MAX_INTERVAL: Duration = Duration::from_millis(2000); const LOCAL_SIDECAR_HEALTH_TIMEOUT: Duration = Duration::from_secs(8); -const LOCAL_SIDECAR_HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(100); +const LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL: Duration = Duration::from_millis(100); +const LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL: Duration = Duration::from_millis(1000); const STARTUP_REMOTE_PROBE_SOFT_TIMEOUT: Duration = Duration::from_secs(2); const STARTUP_REMOTE_PROBE_HARD_TIMEOUT: Duration = Duration::from_secs(10); @@ -1741,7 +1759,12 @@ fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotif let _ = builder.show(); } -async fn wait_for_health_with(url: &str, timeout: Duration, poll_interval: Duration) -> bool { +async fn wait_for_health_with( + url: &str, + timeout: Duration, + initial_interval: Duration, + max_interval: Duration, +) -> bool { let client = match reqwest::Client::builder().no_proxy().build() { Ok(c) => c, Err(_) => return false, @@ -1749,6 +1772,7 @@ async fn wait_for_health_with(url: &str, timeout: Duration, poll_interval: Durat let deadline = std::time::Instant::now() + timeout; let health_url = format!("{}/health", url.trim_end_matches('/')); + let mut interval = initial_interval; while std::time::Instant::now() < deadline { if let Ok(resp) = client.get(&health_url).send().await { @@ -1756,14 +1780,15 @@ async fn wait_for_health_with(url: &str, timeout: Duration, poll_interval: Durat return true; } } - tokio::time::sleep(poll_interval).await; + tokio::time::sleep(interval).await; + interval = (interval * 2).min(max_interval); } false } async fn wait_for_health(url: &str) -> bool { - wait_for_health_with(url, HEALTH_TIMEOUT, HEALTH_POLL_INTERVAL).await + wait_for_health_with(url, HEALTH_TIMEOUT, HEALTH_POLL_INITIAL_INTERVAL, HEALTH_POLL_MAX_INTERVAL).await } fn kill_sidecar(app: tauri::AppHandle) { @@ -1773,16 +1798,28 @@ fn kill_sidecar(app: tauri::AppHandle) { let sidecar_url = state.url.lock().expect("sidecar url mutex").clone(); if let Some(url) = sidecar_url { - let shutdown_url = format!("{}/api/system/shutdown", url.trim_end_matches('/')); - if let Ok(client) = reqwest::blocking::Client::builder() - .no_proxy() - .timeout(Duration::from_millis(1500)) - .build() - { - if let Ok(resp) = client.post(shutdown_url).send() { - if resp.status().is_success() { - std::thread::sleep(Duration::from_millis(100)); - } + // Attempt graceful shutdown via a raw HTTP POST to avoid pulling in + // reqwest::blocking (and its extra thread pool) just for this one call. + if let Ok(parsed) = url::Url::parse(&url) { + let host = parsed.host_str().unwrap_or("127.0.0.1"); + let port = parsed.port().unwrap_or(80); + let path = "/api/system/shutdown"; + if let Ok(mut stream) = + std::net::TcpStream::connect_timeout( + &format!("{host}:{port}").parse().unwrap(), + Duration::from_millis(1500), + ) + { + use std::io::Write; + let _ = stream.set_write_timeout(Some(Duration::from_millis(1500))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(1500))); + let request = format!( + "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(request.as_bytes()); + let _ = stream.flush(); + // Brief pause to let the sidecar begin its shutdown sequence. + std::thread::sleep(Duration::from_millis(100)); } } } @@ -2067,7 +2104,8 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { if !wait_for_health_with( &url, LOCAL_SIDECAR_HEALTH_TIMEOUT, - LOCAL_SIDECAR_HEALTH_POLL_INTERVAL, + LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL, + LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL, ) .await { @@ -2451,9 +2489,42 @@ fn read_desktop_theme_override() -> Option { parse_theme_override(theme_mode, theme_variant) } +fn read_desktop_vibrancy_enabled() -> bool { + let raw = fs::read_to_string(settings_file_path()).ok(); + let parsed = raw + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + parsed + .as_ref() + .and_then(|v| v.get("desktopVibrancy")) + .and_then(|v| v.as_bool()) + .unwrap_or(true) // enabled by default +} + +fn write_desktop_vibrancy_to_disk(enabled: bool) -> Result<()> { + let path = settings_file_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { + serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + if !root.is_object() { + root = serde_json::json!({}); + } + root["desktopVibrancy"] = serde_json::Value::Bool(enabled); + fs::write(&path, serde_json::to_string_pretty(&root)?)?; + Ok(()) +} + #[cfg(target_os = "macos")] fn apply_macos_window_vibrancy(window: &tauri::WebviewWindow) { - let _ = clear_vibrancy(window); + if !read_desktop_vibrancy_enabled() { + let _ = clear_vibrancy(window); + return; + } if let Err(error) = apply_vibrancy( window, @@ -2468,6 +2539,56 @@ fn apply_macos_window_vibrancy(window: &tauri::WebviewWindow) { #[cfg(not(target_os = "macos"))] fn apply_macos_window_vibrancy(_window: &tauri::WebviewWindow) {} +#[tauri::command] +fn desktop_set_vibrancy(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { + write_desktop_vibrancy_to_disk(enabled).map_err(|e| e.to_string())?; + + #[cfg(target_os = "macos")] + { + for window in app.webview_windows().values() { + if enabled { + if let Err(error) = apply_vibrancy( + window, + NSVisualEffectMaterial::Sidebar, + None, + None, + ) { + log::warn!("[desktop:vibrancy] Failed to apply vibrancy: {error}"); + } + } else { + let _ = clear_vibrancy(window); + } + } + } + + #[cfg(not(target_os = "macos"))] + let _ = app; + + Ok(()) +} + +/// Apply platform-specific window builder configuration. +fn apply_platform_window_config>( + builder: WebviewWindowBuilder<'_, tauri::Wry, M>, +) -> WebviewWindowBuilder<'_, tauri::Wry, M> { + #[cfg(target_os = "macos")] + let builder = builder + .transparent(true) + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { + x: 17.0, + y: 26.0, + })); + + #[cfg(target_os = "windows")] + let builder = builder.additional_browser_args( + "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection", + ); + + builder +} + #[tauri::command] fn desktop_set_window_theme( window: tauri::WebviewWindow, @@ -2631,6 +2752,8 @@ fn create_window( .visible(false) .initialization_script(&init_script); + builder = apply_platform_window_config(builder); + let apply_restored_state = restored_state .as_ref() .map(|state| is_window_state_visible(app, state)) @@ -2644,21 +2767,10 @@ fn create_window( .position(state.x as f64, state.y as f64); } - #[cfg(target_os = "macos")] - { - builder = builder - .transparent(true) - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Overlay) - .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { - x: 17.0, - y: 26.0, - })); - } - let window = builder.build()?; let _ = window.set_theme(read_desktop_theme_override()); apply_macos_window_vibrancy(&window); + disable_pinch_zoom(&window); if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { if state.maximized || state.fullscreen { @@ -2693,6 +2805,8 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu .visible(true) .initialization_script(&splash_script); + builder = apply_platform_window_config(builder); + let apply_restored_state = restored_state .as_ref() .map(|state| is_window_state_visible(app, state)) @@ -2706,21 +2820,10 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu .position(state.x as f64, state.y as f64); } - #[cfg(target_os = "macos")] - { - builder = builder - .transparent(true) - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Overlay) - .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { - x: 17.0, - y: 26.0, - })); - } - let window = builder.build()?; let _ = window.set_theme(read_desktop_theme_override()); apply_macos_window_vibrancy(&window); + disable_pinch_zoom(&window); if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { if state.maximized || state.fullscreen { @@ -2926,13 +3029,38 @@ fn open_new_window(app: &tauri::AppHandle) { } fn main() { + // Ensure localhost traffic never routes through a system/VPN proxy. + for key in ["NO_PROXY", "no_proxy"] { + let existing = env::var(key).unwrap_or_default(); + let loopback = ["127.0.0.1", "localhost", "::1"]; + let missing: Vec<&str> = loopback + .iter() + .filter(|addr| !existing.split(',').any(|part| part.trim() == **addr)) + .copied() + .collect(); + if !missing.is_empty() { + let merged = if existing.is_empty() { + missing.join(",") + } else { + format!("{},{}", existing, missing.join(",")) + }; + env::set_var(key, &merged); + } + } + let log_builder = tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .clear_targets() - .targets([ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), - ]); + .targets(if cfg!(debug_assertions) { + vec![ + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), + ] + } else { + vec![tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )] + }); let builder = tauri::Builder::default() .manage(SidecarState::default()) @@ -3155,6 +3283,7 @@ fn main() { desktop_hosts_set, desktop_host_probe, desktop_set_window_theme, + desktop_set_vibrancy, remote_ssh::desktop_ssh_instances_get, remote_ssh::desktop_ssh_instances_set, remote_ssh::desktop_ssh_import_hosts, diff --git a/packages/desktop/src-tauri/src/remote_ssh.rs b/packages/desktop/src-tauri/src/remote_ssh.rs index 141519aa..af37c8d6 100644 --- a/packages/desktop/src-tauri/src/remote_ssh.rs +++ b/packages/desktop/src-tauri/src/remote_ssh.rs @@ -22,6 +22,12 @@ const DEFAULT_READY_TIMEOUT_SEC: u64 = 30; const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5; const MAX_LOG_LINES_PER_INSTANCE: usize = 1200; +/// Monitor starts with fast polling and relaxes to steady-state after stabilization. +const MONITOR_INITIAL_POLL_SECS: u64 = 2; +const MONITOR_STEADY_POLL_SECS: u64 = 10; +/// Number of healthy ticks before switching from initial to steady-state polling. +const MONITOR_STABILIZE_TICKS: u32 = 5; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DesktopSshInstancesConfig { @@ -1027,6 +1033,7 @@ fn wait_for_master_ready( master: &mut Child, ) -> Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64); + let mut poll_ms: u64 = 250; while std::time::Instant::now() < deadline { let args = vec![ "-o".to_string(), @@ -1056,7 +1063,8 @@ fn wait_for_master_ready( return Err(anyhow!(stderr.trim().to_string())); } - std::thread::sleep(Duration::from_millis(250)); + std::thread::sleep(Duration::from_millis(poll_ms)); + poll_ms = (poll_ms * 2).min(2000); } Err(anyhow!("SSH ControlMaster connection timed out")) @@ -1552,19 +1560,34 @@ fn is_local_tunnel_reachable(local_port: u16) -> bool { } fn wait_local_forward_ready(local_port: u16) -> Result<()> { - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_millis(1000)) - .no_proxy() - .build()?; let deadline = std::time::Instant::now() + Duration::from_secs(DEFAULT_READY_TIMEOUT_SEC); - let target = format!("http://127.0.0.1:{local_port}/health"); + let addr: std::net::SocketAddr = format!("127.0.0.1:{local_port}").parse()?; + let mut poll_ms: u64 = 250; while std::time::Instant::now() < deadline { - if let Ok(response) = client.get(&target).send() { - if response.status().is_success() || response.status().as_u16() == 401 { - return Ok(()); + if let Ok(mut stream) = + TcpStream::connect_timeout(&addr, Duration::from_millis(1000)) + { + use std::io::{Read as IoRead, Write}; + let _ = stream.set_read_timeout(Some(Duration::from_millis(1000))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(1000))); + let request = format!( + "GET /health HTTP/1.1\r\nHost: 127.0.0.1:{local_port}\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(request.as_bytes()).is_ok() { + let mut buf = [0u8; 32]; + if let Ok(n) = stream.read(&mut buf) { + let head = std::str::from_utf8(&buf[..n]).unwrap_or(""); + // Match "HTTP/1.x 2xx" or "HTTP/1.x 401" + if head.starts_with("HTTP/1.") + && (head.contains(" 2") || head.contains(" 401")) + { + return Ok(()); + } + } } } - std::thread::sleep(Duration::from_millis(250)); + std::thread::sleep(Duration::from_millis(poll_ms)); + poll_ms = (poll_ms * 2).min(2000); } Err(anyhow!( "Timed out waiting for forwarded OpenChamber health" @@ -2353,8 +2376,14 @@ impl DesktopSshManagerInner { let inner = Arc::clone(self); let id_for_task = id.clone(); let handle = tauri::async_runtime::spawn(async move { + let mut healthy_ticks: u32 = 0; loop { - tokio::time::sleep(Duration::from_secs(2)).await; + let poll_secs = if healthy_ticks >= MONITOR_STABILIZE_TICKS { + MONITOR_STEADY_POLL_SECS + } else { + MONITOR_INITIAL_POLL_SECS + }; + tokio::time::sleep(Duration::from_secs(poll_secs)).await; let mut dropped_reason: Option = None; let mut detached_notice: Option = None; @@ -2424,18 +2453,21 @@ impl DesktopSshManagerInner { ); } } else if session.master_detached { - if !is_control_master_alive(&session.parsed, &session.control_path) { - if is_local_tunnel_reachable(session.local_port) { - if detached_notice.is_none() { - detached_notice = Some( - "SSH ControlMaster check failed but local tunnel is still reachable" - .to_string(), - ); - } - } else { - dropped_reason = - Some("SSH ControlMaster is not reachable".to_string()); - } + // Fast path: check local tunnel first (cheap TCP probe) + // before spawning an SSH subprocess for control master check. + if is_local_tunnel_reachable(session.local_port) { + // Tunnel is alive — skip the expensive SSH check entirely. + } else if !is_control_master_alive( + &session.parsed, + &session.control_path, + ) { + dropped_reason = + Some("SSH ControlMaster is not reachable".to_string()); + } else { + detached_notice = Some( + "Local tunnel unreachable but ControlMaster is alive" + .to_string(), + ); } } else if let Some(status) = session.master.try_wait().ok().flatten() { if status.success() @@ -2471,6 +2503,7 @@ impl DesktopSshManagerInner { } if dropped_reason.is_none() { + healthy_ticks = healthy_ticks.saturating_add(1); continue; } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b5de0414..9b7183f7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -277,6 +277,8 @@ function App({ apis }: AppProps) { if (providersCount > 0 && agentsCount > 0) return; let active = true; + let retries = 0; + const MAX_RETRIES = 15; const attempt = async () => { const state = useConfigStore.getState(); if (state.providers.length > 0 && state.agents.length > 0) return; @@ -287,7 +289,11 @@ function App({ apis }: AppProps) { }; void attempt(); - const id = setInterval(() => { if (active) void attempt(); }, 2000); + const id = setInterval(() => { + if (!active) return; + if (++retries >= MAX_RETRIES) { clearInterval(id); return; } + void attempt(); + }, 2000); return () => { active = false; clearInterval(id); }; }, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index cdc43f29..7194f26c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -19,7 +19,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; +import { isDesktopShell, isVSCodeRuntime, isWebRuntime, desktopSetVibrancy } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -209,6 +209,7 @@ export const OpenChamberVisualSettings: React.FC } = useThemeSystem(); const [themesReloading, setThemesReloading] = React.useState(false); + const [vibrancyEnabled, setVibrancyEnabled] = React.useState(true); const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0); const reportUsage = useUIStore(state => state.reportUsage); const setReportUsage = useUIStore(state => state.setReportUsage); @@ -219,6 +220,28 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ reportUsage: enabled }); }, [setReportUsage]); + const isMacDesktop = React.useMemo(() => { + if (!isDesktopShell()) return false; + if (typeof navigator === 'undefined') return false; + return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); + }, []); + + React.useEffect(() => { + if (!isMacDesktop) return; + const stored = localStorage.getItem('desktopVibrancy'); + if (stored !== null) { + setVibrancyEnabled(stored !== 'false'); + } + }, [isMacDesktop]); + + const handleVibrancyChange = React.useCallback((enabled: boolean) => { + setVibrancyEnabled(enabled); + localStorage.setItem('desktopVibrancy', String(enabled)); + document.documentElement.classList.toggle('no-vibrancy', !enabled); + void desktopSetVibrancy(enabled); + void updateDesktopSettings({ desktopVibrancy: enabled }); + }, []); + const shouldAnimateChatPreview = isSettingsDialogOpen && (visibleSettings ? visibleSettings.includes('chatRenderMode') : true); @@ -503,6 +526,24 @@ export const OpenChamberVisualSettings: React.FC + {isMacDesktop && ( +
+ +
+ + Window vibrancy + + + Translucent window background. Disabling may reduce energy usage. + +
+
+ )} + {showPwaInstallNameSetting && (
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 36e3b026..caef913b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -147,6 +147,8 @@ export type DesktopSettings = { skillCatalogs?: SkillCatalogConfig[]; // Opt-in to send anonymous usage reports for update checks (default: true) reportUsage?: boolean; + // macOS window vibrancy effect (default: true) + desktopVibrancy?: boolean; }; type TauriGlobal = { @@ -637,3 +639,18 @@ export const clearDesktopCache = async (): Promise => { return false; } }; + +export const desktopSetVibrancy = async (enabled: boolean): Promise => { + if (!isTauriShell()) { + return false; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + await tauri?.core?.invoke?.('desktop_set_vibrancy', { enabled }); + return true; + } catch (error) { + console.warn('Failed to set vibrancy', error); + return false; + } +}; diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index 3aada2a8..3b1f6913 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -52,6 +52,10 @@ const setRootDeviceAttributes = ( if (isTauriShellRuntime) { root.classList.add('desktop-runtime'); + // Apply no-vibrancy class early so the first paint uses a solid background + // when vibrancy was previously disabled by the user. + const vibrancyOff = localStorage.getItem('desktopVibrancy') === 'false'; + root.classList.toggle('no-vibrancy', vibrancyOff); root.style.setProperty('--is-mobile', '0'); root.style.setProperty('--device-type', 'desktop'); root.style.setProperty('--font-scale', '1'); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index dc97187a..88ae5e2c 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -82,6 +82,9 @@ const persistToLocalStorage = (settings: DesktopSettings) => { localStorage.removeItem('openchamber.pwaName'); } } + if (typeof settings.desktopVibrancy === 'boolean') { + localStorage.setItem('desktopVibrancy', String(settings.desktopVibrancy)); + } }; type PersistApi = { diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index f2beb7a2..b0c33aa6 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -135,12 +135,29 @@ overscroll-behavior-y: none; } - :root.desktop-runtime body, - :root.desktop-runtime #root { + :root.desktop-runtime:not(.no-vibrancy) body, + :root.desktop-runtime:not(.no-vibrancy) #root { background: transparent !important; background-color: transparent !important; } + :root.desktop-runtime.no-vibrancy body, + :root.desktop-runtime.no-vibrancy #root { + background: var(--background) !important; + background-color: var(--background) !important; + } + + /* When vibrancy is off, force sidebar overlays to solid and disable blur. */ + :root.desktop-runtime.no-vibrancy { + --sidebar-overlay-strong: var(--sidebar) !important; + --sidebar-overlay-soft: var(--sidebar) !important; + } + + :root.desktop-runtime.no-vibrancy .backdrop-blur { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + } + .font-sans { font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important; }