From 224e1104047930caae6394c940ac2ecf40c2264b Mon Sep 17 00:00:00 2001 From: Odrian Date: Tue, 27 Oct 2026 18:10:10 +0300 Subject: [PATCH 1/8] proxy daemon: macos --- Cargo.lock | 58 ++- Cargo.toml | 5 + crates/wizard-cli/Cargo.toml | 2 + crates/wizard-cli/src/main.rs | 8 +- crates/wizard-gui/Cargo.toml | 4 +- .../ai.coreinfra.wizard.proxy.plist | 22 + crates/wizard-gui/src/agents.rs | 9 +- crates/wizard-gui/src/lib.rs | 15 +- crates/wizard-gui/src/main.rs | 6 + crates/wizard-gui/src/proxy.rs | 272 +++-------- crates/wizard-gui/tauri.conf.json | 5 +- crates/wizard-proxy-daemon/Cargo.toml | 28 ++ crates/wizard-proxy-daemon/src/lib.rs | 228 +++++++++ crates/wizard-proxy-daemon/src/limits.rs | 38 ++ crates/wizard-proxy-daemon/src/logging.rs | 82 ++++ crates/wizard-proxy-daemon/src/macos.rs | 157 +++++++ crates/wizard-proxy/Cargo.toml | 5 +- crates/wizard-proxy/src/control.rs | 121 +++++ crates/wizard-proxy/src/http.rs | 269 +++++++++-- crates/wizard-proxy/src/http_test.rs | 222 +++++++-- crates/wizard-proxy/src/lib.rs | 434 +++++++++++++++--- crates/wizard-proxy/src/server_test.rs | 173 +++++++ 22 files changed, 1779 insertions(+), 384 deletions(-) create mode 100644 crates/wizard-gui/ai.coreinfra.wizard.proxy.plist create mode 100644 crates/wizard-proxy-daemon/Cargo.toml create mode 100644 crates/wizard-proxy-daemon/src/lib.rs create mode 100644 crates/wizard-proxy-daemon/src/limits.rs create mode 100644 crates/wizard-proxy-daemon/src/logging.rs create mode 100644 crates/wizard-proxy-daemon/src/macos.rs create mode 100644 crates/wizard-proxy/src/control.rs create mode 100644 crates/wizard-proxy/src/server_test.rs diff --git a/Cargo.lock b/Cargo.lock index 02fd1d1..b276965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,6 +1153,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-rotate" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" +dependencies = [ + "chrono", + "flate2", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2564,6 +2574,30 @@ 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.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5393,6 +5427,8 @@ dependencies = [ "serde_json", "tokio", "wizard-core", + "wizard-proxy", + "wizard-proxy-daemon", "wizard-settings", ] @@ -5430,7 +5466,6 @@ name = "wizard-gui" version = "0.2.10" dependencies = [ "anyhow", - "libc", "log", "serde", "serde_json", @@ -5443,6 +5478,7 @@ dependencies = [ "tokio", "wizard-core", "wizard-proxy", + "wizard-proxy-daemon", "wizard-settings", ] @@ -5456,10 +5492,30 @@ dependencies = [ "hyper-util", "log", "rustls-native-certs", + "serde", + "serde_json", "socket2", "tokio", "tokio-rustls", "url", + "wizard-settings", +] + +[[package]] +name = "wizard-proxy-daemon" +version = "0.2.10" +dependencies = [ + "block2", + "chrono", + "file-rotate", + "libc", + "log", + "objc2", + "objc2-foundation", + "objc2-service-management", + "tokio", + "wizard-proxy", + "wizard-settings", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 563655b..31fcd2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,11 +37,13 @@ missing_panics_doc = "allow" [workspace.dependencies] wizard-core = { path = "crates/wizard-core" } wizard-proxy = { path = "crates/wizard-proxy" } +wizard-proxy-daemon = { path = "crates/wizard-proxy-daemon" } wizard-settings = { path = "crates/wizard-settings" } anyhow = "1.0.104" chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock"] } dirs = "6" +file-rotate = "0.8.0" jsonc-parser = { version = "0.34.0", features = ["cst", "serde_json"] } libc = "0.2.189" base64 = "0.22.1" @@ -52,6 +54,9 @@ http-body-util = "0.1.5" hyper = "1.11.1" hyper-util = "0.1.20" minisign-verify = "0.2.5" +block2 = "0.6.2" +objc2 = "0.6.4" +objc2-service-management = { version = "0.3.2", default-features = false, features = ["std", "SMAppService", "objc2", "objc2-foundation", "block2"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSRunningApplication"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] } plist = "1.10.1" diff --git a/crates/wizard-cli/Cargo.toml b/crates/wizard-cli/Cargo.toml index 586d4b9..415ec73 100644 --- a/crates/wizard-cli/Cargo.toml +++ b/crates/wizard-cli/Cargo.toml @@ -17,4 +17,6 @@ clap = { workspace = true, features = ["derive"] } serde_json.workspace = true tokio.workspace = true wizard-core.workspace = true +wizard-proxy-daemon.workspace = true +wizard-proxy.workspace = true wizard-settings.workspace = true diff --git a/crates/wizard-cli/src/main.rs b/crates/wizard-cli/src/main.rs index 7240169..513dc11 100644 --- a/crates/wizard-cli/src/main.rs +++ b/crates/wizard-cli/src/main.rs @@ -11,7 +11,7 @@ use wizard_core::hub; #[derive(Parser)] #[command( version, - about = "CoreInfra Wizard backend CLI. Close the GUI before changing settings.", + about = "CoreInfra Wizard backend CLI. Close the GUI before running.", disable_help_subcommand = true )] struct Cli { @@ -41,6 +41,12 @@ enum Command { } fn main() -> ExitCode { + if let Some(arg1) = std::env::args_os().nth(1) + && arg1 == "--proxy" + { + return wizard_proxy_daemon::main(); + } + let cli = Cli::parse(); if let Err(error) = run(cli) { eprintln!("wizard-cli: {error:#}"); diff --git a/crates/wizard-gui/Cargo.toml b/crates/wizard-gui/Cargo.toml index b082788..66aa0f2 100644 --- a/crates/wizard-gui/Cargo.toml +++ b/crates/wizard-gui/Cargo.toml @@ -34,11 +34,9 @@ tauri-plugin-updater.workspace = true tokio.workspace = true wizard-core.workspace = true wizard-proxy.workspace = true +wizard-proxy-daemon.workspace = true wizard-settings.workspace = true -[target.'cfg(unix)'.dependencies] -libc.workspace = true - [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] tauri = { workspace = true, features = ["tray-icon"] } tauri-plugin-autostart.workspace = true diff --git a/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist b/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist new file mode 100644 index 0000000..df7ac64 --- /dev/null +++ b/crates/wizard-gui/ai.coreinfra.wizard.proxy.plist @@ -0,0 +1,22 @@ + + + + + Label + ai.coreinfra.wizard.proxy + BundleProgram + Contents/MacOS/wizard + ProgramArguments + + wizard + --proxy + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + diff --git a/crates/wizard-gui/src/agents.rs b/crates/wizard-gui/src/agents.rs index da878eb..306284c 100644 --- a/crates/wizard-gui/src/agents.rs +++ b/crates/wizard-gui/src/agents.rs @@ -191,8 +191,9 @@ pub(crate) async fn apply_agent_plan( let proxy_app = app.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - // Sync even when the setting is unchanged: «Применить» restarts a stopped listener. - // Also sync after a failed save: tray or autostart can fail after the setting is saved. + // Sync even when the setting is unchanged: «Применить» starts a stopped daemon. + // Reload checks both slots; unchanged tokens preserve active connections. + // Even if saving fails, synchronize with the settings actually on disk. plan.apply_with_proxies( |token| { tauri::async_runtime::block_on(async { @@ -200,7 +201,7 @@ pub(crate) async fn apply_agent_plan( settings.claude_proxy_enabled = token; }) .await; - let synced = proxy::sync_one(&proxy_app, proxy::App::ClaudeDesktop).await; + let synced = proxy::sync(&proxy_app).await; saved.and(synced) }) }, @@ -210,7 +211,7 @@ pub(crate) async fn apply_agent_plan( settings.codex_proxy_enabled = token; }) .await; - let synced = proxy::sync_one(&proxy_app, proxy::App::ChatGpt).await; + let synced = proxy::sync(&proxy_app).await; saved.and(synced) }) }, diff --git a/crates/wizard-gui/src/lib.rs b/crates/wizard-gui/src/lib.rs index c305465..e9c6e56 100644 --- a/crates/wizard-gui/src/lib.rs +++ b/crates/wizard-gui/src/lib.rs @@ -72,7 +72,6 @@ pub fn run_application() -> Result<()> { .plugin(tauri_plugin_updater::Builder::new().build()) .manage(Arc::new(claude_desktop_install::InstallManager::default())) .manage(proxy::ProxyState::default()) - .manage(revision_signal::RevisionSignal::new(proxy::Proxy::default())) .manage(Arc::new(revision_signal::RevisionSignal::new( updater::UpdateState::Checking, ))) @@ -99,11 +98,13 @@ pub fn run_application() -> Result<()> { #[cfg(target_os = "linux")] let tray_enabled = false; - #[cfg(unix)] - proxy::raise_open_file_limit(); - if let Err(error) = tauri::async_runtime::block_on(proxy::sync(app.handle())) { + if let Err(error) = tauri::async_runtime::block_on(async { + wizard_proxy_daemon::create_daemon().await?; + proxy::sync(app.handle()).await + }) { log::error!("proxy startup failed: {error:#}"); } + proxy::start_observer(app.handle()); if !minimized || !tray_enabled { request_main_window(app.handle()); @@ -153,11 +154,7 @@ pub fn run_application() -> Result<()> { } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => request_main_window(app), - tauri::RunEvent::Exit => { - if let Err(error) = tauri::async_runtime::block_on(proxy::stop(app)) { - log::error!("proxy shutdown failed: {error:#}"); - } - } + tauri::RunEvent::Exit => proxy::stop_observer(app), _ => {} }); Ok(()) diff --git a/crates/wizard-gui/src/main.rs b/crates/wizard-gui/src/main.rs index 13a8da3..c0ca3f1 100644 --- a/crates/wizard-gui/src/main.rs +++ b/crates/wizard-gui/src/main.rs @@ -4,6 +4,12 @@ use std::process::ExitCode; fn main() -> ExitCode { + if let Some(arg1) = std::env::args_os().nth(1) + && arg1 == "--proxy" + { + return wizard_proxy_daemon::main(); + } + if let Err(error) = wizard_gui::run_application() { eprintln!("application failed: {error:#}"); return ExitCode::FAILURE; diff --git a/crates/wizard-gui/src/proxy.rs b/crates/wizard-gui/src/proxy.rs index 66e5352..42d8f30 100644 --- a/crates/wizard-gui/src/proxy.rs +++ b/crates/wizard-gui/src/proxy.rs @@ -1,244 +1,100 @@ -use crate::{ - revision_signal::{RevisionSignal, RevisionSnapshot}, - settings, -}; -use anyhow::{Context as _, Result, anyhow}; +use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; +use anyhow::{Context as _, Result}; +use core::time::Duration; use serde::Serialize; +use std::{io, sync::OnceLock}; use tauri::Manager as _; use tokio::sync::Mutex; -use wizard_settings::Settings; -/// Actual state of each desktop app's listener. +/// Runtime proxy enablement reported by the daemon, not saved demand. #[derive(Clone, Default, PartialEq, Eq, Serialize)] pub(crate) struct Proxy { claude_desktop: bool, chatgpt: bool, } -#[derive(Clone, Copy)] -pub(crate) enum App { - ClaudeDesktop, - ChatGpt, +pub(crate) struct ProxyState { + // Serialize observations with sync so an earlier status cannot overwrite a + // status fetched after Reload. This does not own the daemon's lifecycle. + operation: Mutex<()>, + signal: RevisionSignal>, + observer: OnceLock>, } -impl App { - const ALL: [Self; 2] = [Self::ClaudeDesktop, Self::ChatGpt]; - - fn name(self) -> &'static str { - match self { - Self::ClaudeDesktop => "Claude Desktop", - Self::ChatGpt => "ChatGPT", - } - } - - fn address(self) -> &'static str { - match self { - Self::ClaudeDesktop => wizard_proxy::CLAUDE_DESKTOP_ADDRESS, - Self::ChatGpt => wizard_proxy::CHATGPT_ADDRESS, - } - } - - fn token(self, settings: &Settings) -> Option<&str> { - match self { - Self::ClaudeDesktop => settings.claude_proxy_enabled.as_deref(), - Self::ChatGpt => settings.codex_proxy_enabled.as_deref(), +impl Default for ProxyState { + fn default() -> Self { + Self { + operation: Mutex::new(()), + signal: RevisionSignal::new(Err("proxy status is not loaded".to_owned())), + observer: OnceLock::new(), } } } -// No Debug: holds the listener's startup token. -struct RunningProxy { - handle: wizard_proxy::Proxy, - observer: tauri::async_runtime::JoinHandle<()>, - token: String, -} - -#[derive(Default)] -struct Listeners { - claude_desktop: Option, - chatgpt: Option, -} - -impl Listeners { - fn get(&mut self, app: App) -> &mut Option { - match app { - App::ClaudeDesktop => &mut self.claude_desktop, - App::ChatGpt => &mut self.chatgpt, - } - } -} - -#[derive(Default)] -pub(crate) struct ProxyState(Mutex); - #[tauri::command] pub(crate) async fn get_proxy_state( last_revision: Option, - signal: tauri::State<'_, RevisionSignal>, + state: tauri::State<'_, ProxyState>, ) -> Result, String> { - signal + let snapshot = state + .signal .wait(last_revision) .await - .map_err(|error| format!("{error:#}")) -} - -// Call while holding ProxyState's mutex so lifecycle events remain ordered. -fn publish(app: &tauri::AppHandle, proxy_app: App, running: bool) { - app.state::>().update(|current| { - let mut next = current.clone(); - match proxy_app { - App::ClaudeDesktop => next.claude_desktop = running, - App::ChatGpt => next.chatgpt = running, - } - (next != *current).then_some(next) - }); -} - -/// macOS starts GUI apps with a soft limit of 256 descriptors, and each tunnel holds two; -/// as the system proxy, the listeners would run out. Never lowers the limit. -#[cfg(unix)] -#[expect( - unsafe_code, - reason = "std has no safe bindings for getrlimit and setrlimit" -)] -pub(crate) fn raise_open_file_limit() { - // macOS OPEN_MAX: setrlimit(2) there recommends min(OPEN_MAX, rlim_max) for RLIMIT_NOFILE. - const TARGET: libc::rlim_t = 10240; - - let mut limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `limit` is a valid, writable rlimit. - if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } != 0 { - let error = std::io::Error::last_os_error(); - log::warn!("failed to read open file limit: {error}"); - return; - } - let previous = limit.rlim_cur; - limit.rlim_cur = TARGET.min(limit.rlim_max); - if limit.rlim_cur <= previous { - return; - } - // SAFETY: `limit` is a valid rlimit whose soft limit does not exceed the hard one. - if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const limit) } != 0 { - let error = std::io::Error::last_os_error(); - log::warn!("failed to raise open file limit from {previous}: {error}"); - return; - } - log::info!( - "open file limit raised from {previous} to {}", - limit.rlim_cur - ); + .map_err(|error| format!("{error:#}"))?; + Ok(RevisionSnapshot { + revision: snapshot.revision, + state: snapshot.state?, + }) } -/// Called at startup for both apps; one failure does not skip the other. +/// Called at startup and after proxy settings have been saved and published. pub(crate) async fn sync(app: &tauri::AppHandle) -> Result<()> { - let mut results = Vec::new(); - for proxy_app in App::ALL { - results.push(sync_one(app, proxy_app).await); - } - combine(results) -} - -/// Called after applying the app's proxy setting. Its listener runs while the app has a token and -/// is restarted when that token changes; a stopped or failed listener is started again. -pub(crate) async fn sync_one(app: &tauri::AppHandle, proxy_app: App) -> Result<()> { let state = app.state::(); - let mut listeners = state.0.lock().await; - let settings = settings::get_state(app)?; - sync_app( - app, - listeners.get(proxy_app), - proxy_app, - proxy_app.token(&settings), - ) - .await + let _guard = state.operation.lock().await; + let result = wizard_proxy_daemon::sync_proxy() + .await + .context("failed to synchronize proxy daemon"); + // Reload can partially apply or fail; observe runtime instead of assuming + // it matches the saved settings. Observations never launch or reload anything. + refresh(&state).await; + result } -async fn sync_app( - app: &tauri::AppHandle, - slot: &mut Option, - proxy_app: App, - token: Option<&str>, -) -> Result<()> { - if slot.as_ref().map(|running| running.token.as_str()) == token { - return Ok(()); - } - stop_proxy(app, slot, proxy_app).await?; - let Some(token) = token else { - return Ok(()); +async fn refresh(state: &ProxyState) { + let next = match wizard_proxy::control::status().await { + Ok(status) => Ok(Proxy { + claude_desktop: status.claude_desktop_enabled, + chatgpt: status.chatgpt_enabled, + }), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => Ok(Proxy::default()), + Err(_) => Err("failed to read proxy daemon status".to_owned()), }; - - // TODO: Retry failed starts without requiring a toggle or application restart. - let handle = wizard_proxy::start(proxy_app.address(), token) - .await - .with_context(|| format!("failed to start {} local proxy", proxy_app.name()))?; - let stopped = handle.stopped(); - let observer_app = app.clone(); - let observer = tauri::async_runtime::spawn(async move { - stopped.await; - - let state = observer_app.state::(); - let mut listeners = state.0.lock().await; - if let Some(running) = listeners.get(proxy_app).take() { - // The task already exited. Join it to report its error and release the handle. - let result = running.handle.stop().await; - publish(&observer_app, proxy_app, false); - match result { - Ok(()) => log::warn!("{} local proxy stopped unexpectedly", proxy_app.name()), - Err(error) => log::error!( - "{} local proxy stopped unexpectedly: {error:#}", - proxy_app.name() - ), - } - } - }); - *slot = Some(RunningProxy { - handle, - observer, - token: token.to_owned(), - }); - publish(app, proxy_app, true); - log::info!( - "{} local proxy started on {}", - proxy_app.name(), - proxy_app.address() - ); - Ok(()) + state + .signal + .update(|current| (current != &next).then_some(next)); } -pub(crate) async fn stop(app: &tauri::AppHandle) -> Result<()> { +/// Observe the independent process even if it stops outside a settings update. +pub(crate) fn start_observer(app: &tauri::AppHandle) { let state = app.state::(); - let mut listeners = state.0.lock().await; - - let mut results = Vec::new(); - for proxy_app in App::ALL { - results.push(stop_proxy(app, listeners.get(proxy_app), proxy_app).await); - } - combine(results) + let app = app.clone(); + state.observer.get_or_init(|| { + tauri::async_runtime::spawn(async move { + loop { + { + let state = app.state::(); + let _guard = state.operation.lock().await; + refresh(&state).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + }); } -async fn stop_proxy( - app: &tauri::AppHandle, - slot: &mut Option, - proxy_app: App, -) -> Result<()> { - if let Some(running) = slot.take() { - // Cancel this instance's observer before a replacement can acquire the mutex. - running.observer.abort(); - let result = running.handle.stop().await; - publish(app, proxy_app, false); - result.with_context(|| format!("failed to stop {} local proxy", proxy_app.name()))?; - log::info!("{} local proxy stopped", proxy_app.name()); +/// GUI exit stops only its observer, never the independent proxy process. +pub(crate) fn stop_observer(app: &tauri::AppHandle) { + if let Some(observer) = app.state::().observer.get() { + observer.abort(); } - Ok(()) -} - -fn combine(results: Vec>) -> Result<()> { - let mut errors = results.into_iter().filter_map(Result::err); - let Some(first) = errors.next() else { - return Ok(()); - }; - Err(errors.fold(first, |all, error| anyhow!("{all:#}; {error:#}"))) } diff --git a/crates/wizard-gui/tauri.conf.json b/crates/wizard-gui/tauri.conf.json index 4331e67..abd914f 100644 --- a/crates/wizard-gui/tauri.conf.json +++ b/crates/wizard-gui/tauri.conf.json @@ -57,7 +57,10 @@ "targets": "all", "createUpdaterArtifacts": false, "macOS": { - "signingIdentity": "-" + "signingIdentity": "-", + "files": { + "Library/LaunchAgents/ai.coreinfra.wizard.proxy.plist": "ai.coreinfra.wizard.proxy.plist" + } }, "icon": [ "icons/32x32.png", diff --git a/crates/wizard-proxy-daemon/Cargo.toml b/crates/wizard-proxy-daemon/Cargo.toml new file mode 100644 index 0000000..fb5875b --- /dev/null +++ b/crates/wizard-proxy-daemon/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "wizard-proxy-daemon" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +file-rotate.workspace = true +log.workspace = true +tokio = { workspace = true, features = ["rt", "macros", "signal"] } +wizard-proxy.workspace = true +wizard-settings.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +block2.workspace = true +objc2.workspace = true +objc2-foundation.workspace = true +objc2-service-management.workspace = true diff --git a/crates/wizard-proxy-daemon/src/lib.rs b/crates/wizard-proxy-daemon/src/lib.rs new file mode 100644 index 0000000..8becaf3 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/lib.rs @@ -0,0 +1,228 @@ +use core::time::Duration; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io; +use std::process::ExitCode; +use tokio::sync::Mutex; + +mod logging; + +#[cfg(unix)] +mod limits; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; + +#[cfg(not(target_os = "macos"))] +mod platform { + pub(super) fn create_daemon() -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "proxy service registration is not implemented on this platform", + )) + } + + pub(super) async fn start_daemon() -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "proxy service startup is not implemented on this platform", + )) + } +} + +/// Prepare the service once at GUI startup, before synchronizing settings. +/// In release builds, a running daemon with the same workspace crate version is +/// left untouched. Debug builds ignore version matches to pick up local rebuilds. +/// Otherwise a valid Status or refused connection triggers platform registration +/// on a blocking worker. Other HTTP errors leave registration unchanged. +pub async fn create_daemon() -> io::Result<()> { + let guard = SYNC_LOCK.lock().await; + match wizard_proxy::control::status().await { + Ok(status) if !cfg!(debug_assertions) && status.version == env!("CARGO_PKG_VERSION") => { + log::info!("proxy daemon version matches; keeping current service"); + return Ok(()); + } + Ok(_) => log::info!("preparing proxy service registration: version differs or debug build"), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + log::info!("preparing proxy service registration: daemon is not listening"); + } + Err(error) => return Err(error), + } + + tokio::task::spawn_blocking(move || { + // Keep registration serialized even if the awaiting caller is cancelled. + let _guard = guard; + let result = platform::create_daemon(); + match &result { + Ok(()) => log::info!("proxy service registration ready"), + Err(error) => log::error!("proxy service registration failed: kind={:?}", error.kind()), + } + result + }) + .await + .map_err(|_| io::Error::other("proxy service registration task failed"))? +} + +static SYNC_LOCK: Mutex<()> = Mutex::const_new(()); + +/// Synchronize the independent proxy process with saved settings. +/// Only a refused connection permits starting a process: a timeout or failed +/// Reload may already have applied changes and must not trigger a second launch. +/// A single platform start request is followed by bounded Status polling. +/// This never registers the service; call `create_daemon` at GUI startup first. +/// Neither a launch nor a mutating Reload is automatically retried. +pub async fn sync_proxy() -> io::Result<()> { + let _guard = SYNC_LOCK.lock().await; + log::info!("synchronizing proxy daemon with saved settings"); + match wizard_proxy::control::reload().await { + Ok(response) => { + if let Some(error) = response.error { + Err(io::Error::other(error)) + } else { + log::info!("proxy daemon settings synchronized"); + Ok(()) + } + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + let enabled = wizard_settings::load_from_file() + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled(); + if enabled { + log::info!("requesting proxy service start"); + platform::start_daemon().await?; + log::info!("waiting for proxy daemon readiness"); + wait_for_proxy().await?; + } else { + log::info!("proxy service start skipped: both proxies are disabled"); + } + Ok(()) + } + Err(error) => Err(error), + } +} + +async fn wait_for_proxy() -> io::Result<()> { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match wizard_proxy::control::status().await { + Ok(_) => { + log::info!("proxy daemon HTTP management is ready"); + return Ok(()); + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { + // Settings may have been disabled while startup + // was pending. In that case a clean daemon exit is expected. + if !wizard_settings::load_from_file() + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled() + { + log::info!("proxy readiness wait ended: both proxies are now disabled"); + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(error), + } + } + }) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy daemon did not become ready"))? +} + +/// Run the proxy process without initializing the GUI. +/// Call once at process startup, before any other logger or Tokio runtime. +#[must_use] +pub fn main() -> ExitCode { + let result = (|| { + let _instance = acquire_instance_lock()?; + logging::initialize()?; + #[cfg(unix)] + limits::raise_open_file_limit(); + log::info!( + "proxy daemon starting: version={}, pid={}", + env!("CARGO_PKG_VERSION"), + std::process::id(), + ); + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "failed to create proxy runtime") + .and_then(|runtime| runtime.block_on(run())); + match &result { + Ok(()) => log::info!("proxy daemon stopped"), + Err(operation) => log::error!("{operation}"), + } + log::logger().flush(); + result + })(); + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(operation) => { + // Only static diagnostics: never print settings, tokens or raw errors. + eprintln!("{operation}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), &'static str> { + // Register handlers before starting listeners so startup-time events remain pending. + #[cfg(any(target_os = "macos", target_os = "linux"))] + let shutdown = { + use tokio::signal::unix::{SignalKind, signal}; + + let mut interrupt = + signal(SignalKind::interrupt()).map_err(|_| "failed to register SIGINT handler")?; + let mut terminate = + signal(SignalKind::terminate()).map_err(|_| "failed to register SIGTERM handler")?; + async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received SIGINT"), + _ = terminate.recv() => log::info!("proxy daemon received SIGTERM"), + } + } + }; + #[cfg(windows)] + let shutdown = { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let mut interrupt = ctrl_c().map_err(|_| "failed to register Ctrl+C handler")?; + let mut stop = ctrl_break().map_err(|_| "failed to register Ctrl+Break handler")?; + let mut close = ctrl_close().map_err(|_| "failed to register console-close handler")?; + async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received Ctrl+C"), + _ = stop.recv() => log::info!("proxy daemon received Ctrl+Break"), + _ = close.recv() => log::info!("proxy daemon received console-close"), + } + } + }; + wizard_proxy::run_managed(shutdown) + .await + .map_err(|_| "proxy server failed") +} + +/// Keep the returned file open until shutdown. Never unlink the lock file: another +/// process could otherwise lock a different file at the same path. +fn acquire_instance_lock() -> Result { + let directory = wizard_settings::paths::config_dir() + .map_err(|_| "failed to resolve proxy lock directory")?; + fs::create_dir_all(&directory).map_err(|_| "failed to create proxy lock directory")?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let file = options + .open(directory.join("proxy-daemon.lock")) + .map_err(|_| "failed to open proxy lock file")?; + file.try_lock().map_err(|error| match error { + TryLockError::WouldBlock => "proxy daemon is already running", + TryLockError::Error(_) => "failed to lock proxy instance", + })?; + Ok(file) +} diff --git a/crates/wizard-proxy-daemon/src/limits.rs b/crates/wizard-proxy-daemon/src/limits.rs new file mode 100644 index 0000000..a406417 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/limits.rs @@ -0,0 +1,38 @@ +//! Process-local descriptor budget for the daemon, which owns the proxy sockets. + +/// Raise the soft limit without lowering it or exceeding the hard limit. +#[expect( + unsafe_code, + reason = "std has no safe bindings for getrlimit and setrlimit" +)] +pub(super) fn raise_open_file_limit() { + // macOS setrlimit(2) recommends min(OPEN_MAX, rlim_max) for RLIMIT_NOFILE. + const TARGET: libc::rlim_t = 10240; + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: limit is a valid, writable rlimit. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } != 0 { + let error = std::io::Error::last_os_error(); + log::error!("failed to read daemon open file limit: {error}"); + return; + } + + let previous = limit.rlim_cur; + limit.rlim_cur = TARGET.min(limit.rlim_max); + if limit.rlim_cur <= previous { + return; + } + + // SAFETY: limit is valid and its soft limit does not exceed its hard limit. + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const limit) } != 0 { + let error = std::io::Error::last_os_error(); + log::error!("failed to raise daemon open file limit from {previous}: {error}"); + return; + } + log::info!( + "daemon open file limit raised from {previous} to {}", + limit.rlim_cur + ); +} diff --git a/crates/wizard-proxy-daemon/src/logging.rs b/crates/wizard-proxy-daemon/src/logging.rs new file mode 100644 index 0000000..97f228e --- /dev/null +++ b/crates/wizard-proxy-daemon/src/logging.rs @@ -0,0 +1,82 @@ +use file_rotate::{ContentLimit, FileRotate, compression::Compression, suffix::AppendCount}; +use log::{LevelFilter, Log, Metadata, Record}; +use std::{ + fs::{self, OpenOptions}, + io::Write as _, + sync::{Mutex, OnceLock}, +}; + +static LOGGER: OnceLock = OnceLock::new(); + +struct Logger { + output: Mutex>, +} + +pub(crate) fn initialize() -> Result<(), &'static str> { + let level = match std::env::var("WIZARD_LOG") { + Ok(value) => value.parse().map_err(|_| "invalid WIZARD_LOG level")?, + Err(std::env::VarError::NotPresent) => LevelFilter::Info, + Err(std::env::VarError::NotUnicode(_)) => return Err("invalid WIZARD_LOG encoding"), + }; + let directory = + wizard_settings::paths::logs_dir().map_err(|_| "failed to resolve proxy log directory")?; + fs::create_dir_all(&directory).map_err(|_| "failed to create proxy log directory")?; + let mut options = OpenOptions::new(); + options.read(true).create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let path = directory.join("Proxy.log"); + // FileRotate suppresses open errors; report initialization failures before using it. + drop( + options + .open(&path) + .map_err(|_| "failed to open proxy log file")?, + ); + let output = FileRotate::new( + path, + AppendCount::new(5), + ContentLimit::BytesSurpassed(1024 * 1024), + Compression::None, + Some(options), + ); + let logger = LOGGER.get_or_init(|| Logger { + output: Mutex::new(output), + }); + log::set_logger(logger).map_err(|_| "proxy logger already initialized")?; + log::set_max_level(level); + Ok(()) +} + +impl Log for Logger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= log::max_level() && metadata.target().starts_with("wizard_proxy") + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let Ok(mut output) = self.output.lock() else { + return; + }; + // Match Wizard.log's default UTC timestamps and field layout. + // One buffer per record prevents size-based rotation from splitting a line. + let line = format!( + "[{}][{}][{}] {}\n", + chrono::Utc::now().format("%Y-%m-%d][%H:%M:%S"), + record.target(), + record.level(), + record.args(), + ); + let _ = output.write_all(line.as_bytes()); + } + + fn flush(&self) { + if let Ok(mut output) = self.output.lock() { + let _ = output.flush(); + } + } +} diff --git a/crates/wizard-proxy-daemon/src/macos.rs b/crates/wizard-proxy-daemon/src/macos.rs new file mode 100644 index 0000000..71a69ca --- /dev/null +++ b/crates/wizard-proxy-daemon/src/macos.rs @@ -0,0 +1,157 @@ +use block2::RcBlock; +use core::time::Duration; +use objc2::rc::autoreleasepool; +use objc2_foundation::{NSError, NSString}; +use objc2_service_management::{SMAppService, SMAppServiceStatus}; +use std::{io, process::Stdio, sync::mpsc}; +use tokio::process::Command; + +const SERVICE_LABEL: &str = "ai.coreinfra.wizard.proxy"; +const PLIST_NAME: &str = "ai.coreinfra.wizard.proxy.plist"; + +pub async fn start_daemon() -> io::Result<()> { + // Do not trust a UID environment variable or infer identity from file owners. + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new("/usr/bin/id") + .arg("-u") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "current user lookup timed out"))??; + if !output.status.success() { + return Err(io::Error::other("failed to determine current user")); + } + let uid = core::str::from_utf8(&output.stdout) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| io::Error::other("invalid current user identifier"))?; + + // Registration may already have launched the agent. Without -k, kickstart + // does not kill an existing instance. Never bypass approval via enable or + // launch the executable directly as a fallback. + let status = tokio::time::timeout( + Duration::from_secs(5), + Command::new("/bin/launchctl") + .arg("kickstart") + .arg(format!("gui/{uid}/{SERVICE_LABEL}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "proxy service start request timed out", + ) + })??; + if !status.success() { + return Err(io::Error::other( + "launchd rejected the proxy service start request", + )); + } + Ok(()) +} + +#[expect( + unsafe_code, + reason = "SMAppService bindings require checked native calls" +)] +pub fn create_daemon() -> io::Result<()> { + if !objc2::available!(macos = 13.0) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service registration requires macOS 13 or later", + )); + } + + autoreleasepool(|_| { + let name = NSString::from_str(PLIST_NAME); + // SAFETY: macOS availability is checked above. The fixed name identifies + // our bundled LaunchAgent, not arbitrary caller-supplied service data. + // The returned service is retained for every subsequent native call. + let service = unsafe { SMAppService::agentServiceWithPlistName(&name) }; + // SAFETY: service is a live SMAppService on a supported OS. + let mut status = unsafe { service.status() }; + log::info!("proxy service status before registration: {}", status.0); + if status == SMAppServiceStatus::Enabled { + log::info!("unregistering proxy service and waiting for termination"); + let (finished, completion) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |error: *mut NSError| { + // SAFETY: SMAppService supplies either null or an NSError valid + // during this callback. Only its numeric code leaves the callback. + let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + Err(io::Error::other(format!( + "proxy service unregistration failed (code {})", + error.code(), + ))) + }); + let _ = finished.try_send(result); + }); + // SAFETY: service is live; SMAppService retains the escaping block + // and invokes it on a dispatch queue after termination or an error. + unsafe { service.unregisterWithCompletionHandler(&handler) }; + completion + .recv_timeout(Duration::from_secs(10)) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => io::Error::new( + io::ErrorKind::TimedOut, + "proxy service unregistration timed out", + ), + mpsc::RecvTimeoutError::Disconnected => { + io::Error::other("proxy service unregistration callback was lost") + } + })??; + // Never register before successful completion, including on timeout: + // the native unregistration may still finish after we return an error. + log::info!("proxy service unregistration completed"); + status = SMAppServiceStatus::NotRegistered; + } + if status == SMAppServiceStatus::NotRegistered || status == SMAppServiceStatus::NotFound { + // NotFound may mean the background task record is missing. Attempt + // registration so its result, rather than the initial status, decides. + log::info!("registering bundled proxy LaunchAgent"); + // SAFETY: the service and its fixed plist name remain valid. This is + // the registration step, before requesting launch through launchd. + let registration = unsafe { service.registerAndReturnError() }; + // SAFETY: service remains alive after the registration attempt. + status = unsafe { service.status() }; + log::info!("proxy service status after registration: {}", status.0); + if status != SMAppServiceStatus::Enabled + && status != SMAppServiceStatus::RequiresApproval + { + // Keep diagnostics useful without exposing NSError descriptions. + registration.map_err(|error| { + io::Error::other(format!( + "proxy service registration failed (code {})", + error.code(), + )) + })?; + } + } + + match status { + SMAppServiceStatus::Enabled => Ok(()), + SMAppServiceStatus::RequiresApproval => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy service requires approval in System Settings > General > Login Items", + )), + SMAppServiceStatus::NotFound => Err(io::Error::new( + io::ErrorKind::NotFound, + "proxy LaunchAgent was not found by SMAppService", + )), + SMAppServiceStatus::NotRegistered => { + Err(io::Error::other("proxy service is not registered")) + } + _ => Err(io::Error::other( + "unknown proxy service registration status", + )), + } + }) +} diff --git a/crates/wizard-proxy/Cargo.toml b/crates/wizard-proxy/Cargo.toml index 4dae19e..a818bed 100644 --- a/crates/wizard-proxy/Cargo.toml +++ b/crates/wizard-proxy/Cargo.toml @@ -11,13 +11,16 @@ publish.workspace = true workspace = true [dependencies] +log.workspace = true base64.workspace = true http-body-util.workspace = true hyper = { workspace = true, features = ["client", "server", "http1"] } hyper-util = { workspace = true, features = ["tokio"] } -log.workspace = true rustls-native-certs.workspace = true socket2.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true tokio = { workspace = true, features = ["rt", "net", "io-util", "macros"] } tokio-rustls.workspace = true url.workspace = true +wizard-settings.workspace = true diff --git a/crates/wizard-proxy/src/control.rs b/crates/wizard-proxy/src/control.rs new file mode 100644 index 0000000..53827df --- /dev/null +++ b/crates/wizard-proxy/src/control.rs @@ -0,0 +1,121 @@ +//! Unauthenticated loopback HTTP management of the standalone server. +//! These endpoints are not provided by GUI-owned standalone listeners. + +use core::time::Duration; +use http_body_util::{BodyExt as _, Empty, Limited}; +use hyper::{Request, body::Bytes}; +use hyper_util::rt::TokioIo; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::io; +use tokio::net::TcpStream; + +pub(crate) const TIMEOUT: Duration = Duration::from_secs(2); +const MAX_RESPONSE: usize = 1024; + +/// Current runtime permission to proxy, not upstream health or token validity. +/// Both ports remain bound while the managed server is running. +#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Status { + /// Empty for older responses without a version; current servers report their crate version. + #[serde(default)] + pub version: String, + pub claude_desktop_enabled: bool, + pub chatgpt_enabled: bool, +} + +impl Status { + pub(crate) fn enabled(&self) -> bool { + self.claude_desktop_enabled || self.chatgpt_enabled + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReloadResponse { + pub status: Status, + pub error: Option, +} + +#[derive(Clone, Copy)] +pub(crate) enum Command { + Status, + Reload, +} + +/// An unreachable server is an error, never a disabled-proxy report. +pub async fn status() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Status).await +} + +/// Once accepted, Reload completes even if this exchange times out or disconnects. +/// Do not automatically retry: after a timeout the result may be unknown. +pub async fn reload() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Reload).await +} + +pub(crate) async fn request(address: &str, command: Command) -> io::Result { + tokio::time::timeout(TIMEOUT, async { + // Connect directly, without HTTP_PROXY, redirects, or retries. + let stream = TcpStream::connect(address).await?; + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .map_err(|_| io::Error::other("management HTTP handshake failed"))?; + let (method, path) = match command { + Command::Status => ("GET", "/status"), + Command::Reload => ("POST", "/reload"), + }; + let request = Request::builder() + .method(method) + .uri(path) + .header("Host", address) + .header("Connection", "close") + .header("Content-Length", "0") + .body(Empty::::new()) + .map_err(|_| io::Error::other("cannot construct management request"))?; + let exchange = async { + let response = sender + .send_request(request) + .await + .map_err(|_| io::Error::other("management HTTP request failed"))?; + if response.status() != hyper::StatusCode::OK { + return Err(io::Error::other("management HTTP request rejected")); + } + let bytes = Limited::new(response.into_body(), MAX_RESPONSE) + .collect() + .await + .map_err(|_| io::Error::other("invalid management response body"))? + .to_bytes(); + serde_json::from_slice(&bytes) + .map_err(|_| io::Error::other("invalid management JSON response")) + }; + let (_, result) = tokio::join!(connection, exchange); + result + }) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? +} + +#[cfg(test)] +mod tests { + use super::Status; + + #[test] + fn legacy_status_has_unknown_version_but_malformed_status_is_rejected() { + let status: Status = + serde_json::from_str(r#"{"claude_desktop_enabled":true,"chatgpt_enabled":false}"#) + .expect("legacy status"); + assert!(status.version.is_empty()); + assert!(status.claude_desktop_enabled); + assert!(!status.chatgpt_enabled); + + for response in [ + r#"{"version":null,"claude_desktop_enabled":true,"chatgpt_enabled":false}"#, + r#"{"version":42,"claude_desktop_enabled":true,"chatgpt_enabled":false}"#, + r#"{"claude_desktop_enabled":true}"#, + r#"{"claude_desktop_enabled":true,"chatgpt_enabled":false,"unexpected":true}"#, + ] { + assert!(serde_json::from_str::(response).is_err()); + } + } +} diff --git a/crates/wizard-proxy/src/http.rs b/crates/wizard-proxy/src/http.rs index 40fe237..70b7c52 100644 --- a/crates/wizard-proxy/src/http.rs +++ b/crates/wizard-proxy/src/http.rs @@ -1,5 +1,8 @@ use crate::{ + ProxySlot, ServerState, + control::{Command, TIMEOUT}, destination::{Route, route}, + run_proxy_session, upstream::Upstream, }; use alloc::sync::Arc; @@ -10,7 +13,7 @@ use hyper::header::{ ALLOW, CONNECTION, CONTENT_LENGTH, HOST, HeaderName, HeaderValue, PROXY_AUTHORIZATION, TRANSFER_ENCODING, USER_AGENT, }; -use hyper::http::uri::Authority; +use hyper::http::uri::{Authority, PathAndQuery}; use hyper::service::service_fn; use hyper::upgrade::{OnUpgrade, Upgraded}; use hyper::{HeaderMap, Method, Request, Response, StatusCode, Uri}; @@ -18,7 +21,7 @@ use hyper_util::rt::{TokioIo, TokioTimer}; use std::io; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; use tokio::net::TcpStream; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; #[cfg(test)] @@ -26,8 +29,8 @@ use tokio::time::timeout; mod tests; const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); -/// After one side of a tunnel closes cleanly, the other may finish for this long. A client -/// that half-closes and then vanishes would otherwise leave a silent remote open forever. +// Allow the remaining direction to finish after a clean half-close, but do not +// retain a silent peer forever. This is not an idle timeout on an open tunnel. const HALF_CLOSE_TIMEOUT: Duration = Duration::from_secs(if cfg!(test) { 1 } else { 30 }); type Body = UnsyncBoxBody; @@ -43,55 +46,166 @@ struct Tunnel { remote: Remote, } -pub(crate) async fn serve_client(stream: TcpStream, upstream: Arc) { +// The HTTP service announces the request kind before starting any proxy I/O. +// The connection task then supplies an upstream or a management response. +enum Dispatch { + Proxy(oneshot::Sender>>), + Management(Command, oneshot::Sender>), +} + +pub(crate) async fn serve_client( + stream: TcpStream, + slot: Arc, + management: Option>, +) { + let managed = management.is_some(); + let address = stream.local_addr().ok().map(|address| address.to_string()); + let (dispatch, mut requests) = mpsc::channel(1); let (driver_sender, mut drivers) = mpsc::channel::(1); let (tunnel_sender, mut tunnels) = mpsc::channel::(1); - let service = service_fn(move |request| { - let upstream = Arc::clone(&upstream); + let service = service_fn(move |request: Request| { + let dispatch = dispatch.clone(); + let address = address.clone(); let driver_sender = driver_sender.clone(); let tunnel_sender = tunnel_sender.clone(); - async move { Ok::<_, Infallible>(handle(request, upstream, driver_sender, tunnel_sender).await) } + async move { + let response = if managed + && request.method() != Method::CONNECT + && request.uri().scheme().is_none() + && request.uri().authority().is_none() + { + match management_command(&request, address.as_deref()) { + Err((status, allow)) => { + let mut response = error_response(status); + if let Some(allow) = allow { + response + .headers_mut() + .insert(ALLOW, HeaderValue::from_static(allow)); + } + response + } + Ok(command) => { + let (reply, response) = oneshot::channel(); + if dispatch + .send(Dispatch::Management(command, reply)) + .await + .is_err() + { + return Ok::<_, Infallible>(error_response( + StatusCode::SERVICE_UNAVAILABLE, + )); + } + response + .await + .unwrap_or_else(|_| error_response(StatusCode::SERVICE_UNAVAILABLE)) + } + } + } else { + let (ready, upstream) = oneshot::channel(); + if dispatch.send(Dispatch::Proxy(ready)).await.is_err() { + return Ok::<_, Infallible>(error_response(StatusCode::SERVICE_UNAVAILABLE)); + } + handle( + request, + upstream.await.unwrap_or(None), + driver_sender, + tunnel_sender, + ) + .await + }; + Ok::<_, Infallible>(response) + } }); let mut builder = hyper::server::conn::http1::Builder::new(); - builder - .keep_alive(false) - // Enables hyper's default 30 s timeout for clients that never send a request head. - .timer(TokioTimer::new()); + builder.keep_alive(false).timer(TokioTimer::new()); + if managed { + builder + .timer(TokioTimer::new()) + .header_read_timeout(TIMEOUT); + } let connection = builder .serve_connection(TokioIo::new(stream), service) .with_upgrades(); - tokio::pin!(connection); - - // Drive both sides in this one task. Cancelling it drops both sockets, with no - // detached upstream tasks. Each client connection serves one request or tunnel. - // A direct CONNECT has no HTTP client driver; it hands us a TCP socket - // when the server connection upgrades. - let driver = tokio::select! { - _ = &mut connection => None, - driver = drivers.recv() => driver, + // Own the complete connection future, not just its pinned reference, so the + // generation wrapper can drop every socket before releasing its read guard. + let mut session = Box::pin(async move { + tokio::pin!(connection); + let driver = tokio::select! { + _ = &mut connection => None, + driver = drivers.recv() => driver, + }; + if let Some(mut driver) = driver { + tokio::select! { + _ = &mut connection => {}, + _ = &mut driver => { let _ = (&mut connection).await; }, + } + } + if let Ok(tunnel) = tunnels.try_recv() + && let Ok(client) = tunnel.client.await + { + let client = TokioIo::new(client); + let result = match tunnel.remote { + Remote::Direct(remote) => relay(client, remote).await, + Remote::Upstream(remote) => relay(client, TokioIo::new(remote)).await, + }; + if let Err(error) = result { + log::error!( + "proxy tunnel transfer failed: kind={:?}, os_error={:?}", + error.kind(), + error.raw_os_error() + ); + } + } + }); + let request = tokio::select! { + request = requests.recv() => request, + () = &mut session => return, }; - if let Some(mut driver) = driver { - tokio::select! { - _ = &mut connection => {}, - _ = &mut driver => { let _ = (&mut connection).await; }, + match request { + Some(Dispatch::Proxy(ready)) => { + let generation = slot.current.read().await.clone(); + if let Some(generation) = generation { + let upstream = Arc::clone(&generation.upstream); + run_proxy_session(generation, async move { + let _ = ready.send(Some(upstream)); + session.await; + }) + .await; + } else { + let _ = ready.send(None); + session.await; + } } - } - - if let Ok(tunnel) = tunnels.try_recv() - && let Ok(client) = tunnel.client.await - { - let client = TokioIo::new(client); - match tunnel.remote { - Remote::Direct(remote) => relay(client, remote).await, - Remote::Upstream(remote) => relay(client, TokioIo::new(remote)).await, + Some(Dispatch::Management(command, reply)) => { + let Some(state) = management else { return }; + let _active = state.management.read().await; + // Ownership in this inner scope ensures socket cleanup precedes the + // management guard's release, including on task cancellation. + { + let session = session; + let stopping = *state.stopping.borrow(); + let response = if stopping { + error_response(StatusCode::SERVICE_UNAVAILABLE) + } else { + match command { + Command::Status => json_response(&state.status_with_lock().await), + Command::Reload => json_response(&state.reload_with_lock().await), + } + }; + // Do not race accepted work against client disconnects. Only the + // reply attempt is bounded; shutdown waits on the active guard. + let _ = reply.send(response); + let _ = timeout(TIMEOUT, session).await; + } } + None => {} } } -/// Copy both directions until both close; an error, or [`HALF_CLOSE_TIMEOUT`] after -/// the first clean close, drops both sides. TLS and application protocols inside -/// CONNECT are opaque bytes. -async fn relay(client: impl AsyncRead + AsyncWrite, remote: impl AsyncRead + AsyncWrite) { +async fn relay( + client: impl AsyncRead + AsyncWrite, + remote: impl AsyncRead + AsyncWrite, +) -> io::Result<()> { let (mut client_read, mut client_write) = tokio::io::split(client); let (mut remote_read, mut remote_write) = tokio::io::split(remote); let upload = async { @@ -103,22 +217,89 @@ async fn relay(client: impl AsyncRead + AsyncWrite, remote: impl AsyncRead + Asy client_write.shutdown().await }; tokio::pin!(upload, download); - tokio::select! { - result = &mut upload => if result.is_ok() { - let _ = timeout(HALF_CLOSE_TIMEOUT, download).await; + let remaining = tokio::select! { + result = &mut upload => { + result?; + timeout(HALF_CLOSE_TIMEOUT, download).await }, - result = &mut download => if result.is_ok() { - let _ = timeout(HALF_CLOSE_TIMEOUT, upload).await; + result = &mut download => { + result?; + timeout(HALF_CLOSE_TIMEOUT, upload).await }, + }; + remaining.map_err(|_| { + log::error!("proxy tunnel half-close deadline expired"); + io::Error::new( + io::ErrorKind::TimedOut, + "proxy tunnel half-close deadline expired", + ) + })? +} + +fn management_command( + request: &Request, + address: Option<&str>, +) -> Result)> { + if request + .headers() + .get(HOST) + .and_then(|host| host.to_str().ok()) + != address + || address.is_none() + || request.headers().contains_key(TRANSFER_ENCODING) + || request + .headers() + .get(CONTENT_LENGTH) + .is_some_and(|length| length != "0") + { + return Err((StatusCode::BAD_REQUEST, None)); } + let (method, command) = match request.uri().path_and_query().map(PathAndQuery::as_str) { + Some("/status") => (Method::GET, Command::Status), + Some("/reload") => (Method::POST, Command::Reload), + _ => return Err((StatusCode::NOT_FOUND, None)), + }; + if request.method() != method { + let allow = match command { + Command::Status => "GET", + Command::Reload => "POST", + }; + return Err((StatusCode::METHOD_NOT_ALLOWED, Some(allow))); + } + Ok(command) +} + +fn json_response(value: &impl serde::Serialize) -> Response { + let Ok(bytes) = serde_json::to_vec(value) else { + return error_response(StatusCode::INTERNAL_SERVER_ERROR); + }; + let mut response = Response::new( + Full::new(Bytes::from(bytes)) + .map_err(|never| match never {}) + .boxed_unsync(), + ); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("close")); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + response } async fn handle( mut request: Request, - upstream: Arc, + upstream: Option>, driver: mpsc::Sender, tunnels: mpsc::Sender, ) -> Response { + let Some(upstream) = upstream else { + return error_response(StatusCode::SERVICE_UNAVAILABLE); + }; + if request.method() == Method::CONNECT { if !valid_connect_target(request.uri()) || request.headers().contains_key(TRANSFER_ENCODING) diff --git a/crates/wizard-proxy/src/http_test.rs b/crates/wizard-proxy/src/http_test.rs index 69b5c36..555d2b3 100644 --- a/crates/wizard-proxy/src/http_test.rs +++ b/crates/wizard-proxy/src/http_test.rs @@ -1,4 +1,4 @@ -use crate::{Proxy, serve, upstream}; +use crate::{Proxy, ProxySlot, run_proxy_session, spawn, upstream}; use alloc::sync::Arc; use core::{net::SocketAddr, time::Duration}; use hyper::http::uri::Authority; @@ -6,7 +6,6 @@ use socket2::SockRef; use std::io; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{oneshot, watch}; use tokio::time::timeout; use tokio_rustls::rustls::{ClientConfig, RootCertStore}; @@ -20,26 +19,43 @@ async fn start_proxy() -> io::Result<(SocketAddr, Proxy)> { .expect("TLS versions") .with_root_certificates(RootCertStore::empty()) .with_no_client_auth(); - let upstream = Arc::new(upstream::Upstream::new( - tls, - upstream::authorization("test-token")?, - )); + let slot = Arc::new(ProxySlot::default()); + slot.set_token(Some("test-token"), &tls).await?; let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; - let (shutdown, receiver) = oneshot::channel(); - let (finished, stopped) = watch::channel(()); - let task = tokio::spawn(async move { - let _finished = finished; - serve(listener, address, upstream, receiver).await; - }); - Ok(( - address, - Proxy { - shutdown, - task, - stopped, - }, - )) + Ok((address, spawn(listener, slot, tls, None))) +} + +#[tokio::test] +async fn half_closed_tunnel_to_silent_remote_closes_after_timeout() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let target = origin.local_addr()?; + let (address, proxy) = start_proxy().await?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (mut remote, _) = origin.accept().await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); + client.shutdown().await?; + let mut byte = [0]; + assert_eq!(remote.read(&mut byte).await?, 0); + assert_eq!(client.read(&mut byte).await?, 0); + proxy.stop().await + }) + .await + .expect("half-closed tunnel stayed open") +} + +#[tokio::test] +async fn direct_connections_use_tcp_keepalive() -> io::Result<()> { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let authority = + Authority::try_from(origin.local_addr()?.to_string()).map_err(io::Error::other)?; + let stream = super::connect_direct(&authority, 80).await?; + assert!(SockRef::from(&stream).keepalive()?); + Ok(()) } async fn read_head(stream: &mut (impl AsyncRead + Unpin)) -> io::Result { @@ -186,29 +202,6 @@ async fn direct_connect_preserves_buffered_bytes_and_half_close() -> io::Result< }).await.expect("direct CONNECT timed out") } -#[tokio::test] -async fn half_closed_tunnel_to_silent_remote_closes_after_timeout() -> io::Result<()> { - timeout(Duration::from_secs(5), async { - let origin = TcpListener::bind("127.0.0.1:0").await?; - let target = origin.local_addr()?; - let (address, proxy) = start_proxy().await?; - let mut client = TcpStream::connect(address).await?; - client - .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) - .await?; - // The remote stays open and silent after the client's FIN. - let (mut remote, _) = origin.accept().await?; - assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); - client.shutdown().await?; - let mut byte = [0]; - assert_eq!(remote.read(&mut byte).await?, 0); - assert_eq!(client.read(&mut byte).await?, 0); - proxy.stop().await - }) - .await - .expect("half-closed tunnel stayed open") -} - #[tokio::test] async fn stopping_proxy_closes_both_direct_tunnel_sockets() -> io::Result<()> { timeout(Duration::from_secs(5), async { @@ -257,6 +250,9 @@ async fn malformed_destination_is_rejected_before_dialing() -> io::Result<()> { timeout(Duration::from_secs(5), async { let (address, proxy) = start_proxy().await?; for request in [ + // Standalone (GUI-owned) listeners never expose management routes. + "GET /status HTTP/1.1\r\nHost: localhost\r\n\r\n", + "POST /reload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n", "CONNECT example.com HTTP/1.1\r\nHost: example.com\r\n\r\n", "CONNECT 127.0.0.1:0 HTTP/1.1\r\nHost: 127.0.0.1:0\r\n\r\n", "CONNECT user@example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", @@ -275,12 +271,138 @@ async fn malformed_destination_is_rejected_before_dialing() -> io::Result<()> { }).await.expect("invalid destination timed out") } -#[tokio::test] -async fn direct_connections_use_tcp_keepalive() -> io::Result<()> { - let origin = TcpListener::bind("127.0.0.1:0").await?; - let authority = - Authority::try_from(origin.local_addr()?.to_string()).map_err(io::Error::other)?; - let stream = super::connect_direct(&authority, 80).await?; - assert!(SockRef::from(&stream).keepalive()?); +async fn open_tunnel( + address: SocketAddr, + origin: &TcpListener, +) -> io::Result<(TcpStream, TcpStream)> { + let target = origin.local_addr()?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (remote, _) = origin.accept().await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 200 ")); + Ok((client, remote)) +} + +async fn exchange(client: &mut TcpStream, remote: &mut TcpStream) -> io::Result<()> { + client.write_all(b"ping").await?; + let mut bytes = [0; 4]; + remote.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"ping"); + remote.write_all(b"pong").await?; + client.read_exact(&mut bytes).await?; + assert_eq!(&bytes, b"pong"); Ok(()) } + +#[tokio::test] +async fn token_changes_drain_sessions_but_preserve_the_listener() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let origin = TcpListener::bind("127.0.0.1:0").await?; + let (address, mut proxy) = start_proxy().await?; + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + + proxy.set_token(Some("test-token")).await?; + exchange(&mut client, &mut remote).await?; + // Failed preparation must not publish a replacement or cancel sessions. + assert!(proxy.set_token(Some("")).await.is_err()); + exchange(&mut client, &mut remote).await?; + + proxy.set_token(Some("replacement-token")).await?; + let mut byte = [0]; + assert_eq!(client.read(&mut byte).await?, 0); + assert_eq!(remote.read(&mut byte).await?, 0); + assert!( + proxy + .slot + .current + .read() + .await + .as_ref() + .is_some_and(|generation| { + generation.upstream.authorization + == upstream::authorization("replacement-token") + .expect("fixture authorization") + }) + ); + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + exchange(&mut client, &mut remote).await?; + + proxy.set_token(None).await?; + assert_eq!(client.read(&mut byte).await?, 0); + assert_eq!(remote.read(&mut byte).await?, 0); + assert!(!proxy.task.is_finished()); + assert!(TcpListener::bind(address).await.is_err()); + let target = origin.local_addr()?; + for request in [ + format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"), + format!("GET http://{target}/ HTTP/1.1\r\nHost: {target}\r\n\r\n"), + ] { + let mut client = TcpStream::connect(address).await?; + client.write_all(request.as_bytes()).await?; + assert!(read_head(&mut client).await?.starts_with("HTTP/1.1 503 ")); + } + + proxy.set_token(Some("enabled-again")).await?; + let (mut client, mut remote) = open_tunnel(address, &origin).await?; + exchange(&mut client, &mut remote).await?; + proxy.stop().await + }) + .await + .expect("token update timed out") +} + +#[tokio::test] +async fn retired_generation_cannot_start_a_late_session() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let (_, mut proxy) = start_proxy().await?; + let old = proxy + .slot + .current + .read() + .await + .clone() + .expect("enabled proxy"); + proxy.set_token(Some("replacement-token")).await?; + + let mut polled = false; + run_proxy_session(old, async { polled = true }).await; + assert!(!polled, "a retired generation must not perform proxy I/O"); + proxy.stop().await + }) + .await + .expect("late session timed out") +} + +#[tokio::test] +async fn token_update_waits_for_session_future_to_be_dropped() -> io::Result<()> { + timeout(Duration::from_secs(5), async { + let (_, mut proxy) = start_proxy().await?; + let old = proxy + .slot + .current + .read() + .await + .clone() + .expect("enabled proxy"); + let (started, ready) = tokio::sync::oneshot::channel(); + let (held, mut released) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(run_proxy_session(old, async move { + let _held = held; + let _ = started.send(()); + core::future::pending::<()>().await; + })); + ready.await.map_err(io::Error::other)?; + + proxy.set_token(None).await?; + assert!(matches!( + released.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) + )); + task.await.map_err(io::Error::other)?; + proxy.stop().await + }) + .await + .expect("session cleanup timed out") +} diff --git a/crates/wizard-proxy/src/lib.rs b/crates/wizard-proxy/src/lib.rs index b606ec0..5d19313 100644 --- a/crates/wizard-proxy/src/lib.rs +++ b/crates/wizard-proxy/src/lib.rs @@ -3,27 +3,30 @@ //! return 405 with Allow: CONNECT); unlisted ports return 403 for every method. //! Unmatched domains and explicit IP addresses connect directly, without proxy credentials. //! Malformed destinations are rejected before opening a connection. -//! Failed accepts, e.g. while file descriptors are exhausted, are retried: the listener -//! stops only on request. Outgoing sockets use TCP keepalive, so a peer that vanishes -//! without closing does not hold a tunnel open forever; neither does a half-closed tunnel. //! Dropping the handle requests shutdown; use [`Proxy::stop`] to wait for all sockets to close. extern crate alloc; use alloc::sync::Arc; -use core::{net::SocketAddr, time::Duration}; +use core::time::Duration; use socket2::{SockRef, TcpKeepalive}; use std::env::consts::{ARCH, OS}; use std::io; -use std::time::Instant; use tokio::net::{TcpListener, TcpStream, ToSocketAddrs}; -use tokio::sync::{oneshot, watch}; +use tokio::sync::{Mutex, RwLock, oneshot, watch}; use tokio::task::{JoinHandle, JoinSet}; +use tokio_rustls::rustls::ClientConfig; + +pub mod control; mod destination; mod http; mod upstream; +#[cfg(test)] +#[path = "server_test.rs"] +mod server_tests; + pub use upstream::{HOST as UPSTREAM_HOST, PORT as UPSTREAM_PORT, USERNAME as UPSTREAM_USERNAME}; /// Listener of Claude Desktop, with its own token. @@ -31,17 +34,18 @@ pub const CLAUDE_DESKTOP_ADDRESS: &str = "127.0.0.1:33787"; /// Listener of `ChatGPT`, with its own token. pub const CHATGPT_ADDRESS: &str = "127.0.0.1:33788"; -/// While descriptors are exhausted, an immediate retry would fail again at once. -const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100); -/// Accept failures are logged at most this often, so they cannot rotate older entries out of the log. -const ACCEPT_WARNING_INTERVAL: Duration = Duration::from_secs(60); - -/// Detects a dead peer within about three minutes. Tunnels have no idle timeout, -/// because idle WebSocket and streaming connections are legitimate. +// Idle WebSocket and streaming connections remain valid; detect vanished peers +// with TCP keepalive instead of applying an application-level idle timeout. const KEEPALIVE: TcpKeepalive = TcpKeepalive::new() .with_time(Duration::from_secs(60)) .with_interval(Duration::from_secs(15)); +async fn connect(address: impl ToSocketAddrs) -> io::Result { + let stream = TcpStream::connect(address).await?; + SockRef::from(&stream).set_tcp_keepalive(&KEEPALIVE)?; + Ok(stream) +} + /// `User-Agent` of requests Wizard itself sends, e.g. `CoreInfraAI-Wizard/ (macos; aarch64)`. /// OS and architecture are those of the running binary. #[must_use] @@ -52,15 +56,185 @@ pub fn user_agent() -> String { ) } -/// Owns a running listener. No settings are read or watched by this crate. +/// Owns a standalone listener. This handle does not read or watch settings. pub struct Proxy { shutdown: oneshot::Sender<()>, - task: JoinHandle<()>, + task: JoinHandle>, stopped: watch::Receiver<()>, + slot: Arc, + tls: ClientConfig, +} + +// Shared configuration, never listener handles: tasks must not own themselves +// through this state. No Debug because the slots contain credentials. +struct ServerState { + reload_lock: Mutex<()>, + tls: ClientConfig, + claude_desktop: Arc, + chatgpt: Arc, + // Shutdown waits for accepted commands and their bounded reply attempts. + management: RwLock<()>, + stopping: watch::Sender, +} + +impl ServerState { + fn new(tls: ClientConfig) -> Self { + Self { + reload_lock: Mutex::new(()), + tls, + claude_desktop: Arc::new(ProxySlot::default()), + chatgpt: Arc::new(ProxySlot::default()), + management: RwLock::new(()), + stopping: watch::channel(false).0, + } + } + + async fn status_with_lock(&self) -> control::Status { + let _guard = self.reload_lock.lock().await; + self.current_status().await + } + + // Called with reload_lock held, so the pair cannot be observed mid-update. + async fn current_status(&self) -> control::Status { + control::Status { + version: env!("CARGO_PKG_VERSION").to_owned(), + claude_desktop_enabled: self.claude_desktop.current.read().await.is_some(), + chatgpt_enabled: self.chatgpt.current.read().await.is_some(), + } + } + + async fn reload_with_lock(&self) -> control::ReloadResponse { + let _guard = self.reload_lock.lock().await; + let started = std::time::Instant::now(); + log::info!("proxy settings reload started"); + let result = async { + if *self.stopping.borrow() { + return Err("proxy server is stopping"); + } + let mut settings = + wizard_settings::load_from_file().map_err(|_| "failed to load Wizard settings")?; + let claude_token = settings.claude_proxy_enabled.take(); + let chatgpt_token = settings.codex_proxy_enabled.take(); + drop(settings); + + log::info!("applying Claude Desktop proxy settings"); + let claude = self + .claude_desktop + .set_token(claude_token.as_deref(), &self.tls) + .await + .map_err(|_| "failed to update Claude Desktop proxy"); + log::info!("applying ChatGPT proxy settings"); + let chatgpt = self + .chatgpt + .set_token(chatgpt_token.as_deref(), &self.tls) + .await + .map_err(|_| "failed to update ChatGPT proxy"); + // Attempt both, retaining the first error without rolling back success. + claude.and(chatgpt) + } + .await; + let status = self.current_status().await; + match &result { + Ok(()) => log::info!( + "proxy settings reload completed: claude_desktop={}, chatgpt={}, elapsed_ms={}", + status.claude_desktop_enabled, + status.chatgpt_enabled, + started.elapsed().as_millis(), + ), + // These errors are static diagnostics, never settings or credentials. + Err(error) => log::error!( + "proxy settings reload failed: {error}; claude_desktop={}, chatgpt={}, elapsed_ms={}", + status.claude_desktop_enabled, + status.chatgpt_enabled, + started.elapsed().as_millis(), + ), + } + if result.is_ok() && !status.enabled() { + // Prevent later queued Reloads from re-enabling during shutdown. + // run_listeners still waits for the current HTTP reply to finish. + self.stopping.send_replace(true); + } + control::ReloadResponse { + status, + error: result.err().map(str::to_owned), + } + } +} + +// No Debug: generations contain tokens and upstream credentials. +#[derive(Default)] +struct ProxySlot { + current: RwLock>>, +} + +struct Generation { + token: String, + upstream: Arc, + cancelled: watch::Sender, + active: RwLock<()>, +} + +impl ProxySlot { + // Writers must be serialized through the whole update, including draining. + // The standalone handle enforces this with &mut self; the managed server + // holds ServerState::reload_lock across both slot updates. + async fn set_token(&self, token: Option<&str>, tls: &ClientConfig) -> io::Result<()> { + let mut current = self.current.write().await; + if current.as_ref().map(|generation| generation.token.as_str()) == token { + return Ok(()); + } + + let next = match token { + None => None, + Some(token) => { + validate_token(token)?; + let authorization = upstream::authorization(token)?; + let upstream = upstream::Upstream::new(tls.clone(), authorization); + let (cancelled, _) = watch::channel(false); + Some(Arc::new(Generation { + token: token.to_owned(), + upstream: Arc::new(upstream), + cancelled, + active: RwLock::new(()), + })) + } + }; + + let change = match (current.is_some(), next.is_some()) { + (false, true) => "enabled", + (true, true) => "replaced", + (_, false) => "disabled", + }; + let previous = core::mem::replace(&mut *current, next); + log::info!("proxy generation {change}"); + if let Some(previous) = previous { + let started = std::time::Instant::now(); + log::info!("cancelling and draining previous proxy generation"); + previous.cancelled.send_replace(true); + drop(current); + + // Old sessions drop their sockets before releasing their read guards. + // Late arrivals check cancellation before doing any proxy I/O. + let _drained = previous.active.write().await; + log::info!( + "previous proxy generation drained: elapsed_ms={}", + started.elapsed().as_millis(), + ); + } + Ok(()) + } } impl Proxy { - /// Wait for the listener task to finish, including panic or cancellation. + /// Update proxying without rebinding the listener. None rejects proxy requests + /// with HTTP 503. Unchanged tokens preserve sessions; changes cancel and drain + /// the old generation. Preparation errors preserve the current generation. + /// The caller must finish awaiting this operation to wait for session cleanup. + pub async fn set_token(&mut self, token: Option<&str>) -> io::Result<()> { + self.slot.set_token(token, &self.tls).await + } + + /// Wait for the listener task to finish, including failure, panic or cancellation. /// The returned future does not borrow the handle and can be observed independently. pub fn stopped(&self) -> impl Future + Send + 'static + use<> { let mut stopped = self.stopped.clone(); @@ -72,94 +246,230 @@ impl Proxy { /// Stop accepting requests, cancel active connections, and wait for their cleanup. pub async fn stop(self) -> io::Result<()> { drop(self.shutdown); - self.task.await.map_err(io::Error::other) + self.task.await.map_err(io::Error::other)? } } +/// Read Wizard settings and run both fixed-address listeners with unauthenticated +/// GET /status and POST /reload. With both proxies disabled, return without binding. +/// TLS roots load once for the pair; disabled proxy traffic receives HTTP 503. +/// Accepted management commands and bounded reply attempts finish before shutdown. +/// This function does not register OS services or change saved settings. +pub async fn run_managed(shutdown: impl Future) -> io::Result<()> { + // Avoid loading system certificates when no proxy is needed. Reload rereads + // after the potentially slow TLS initialization, using the latest settings. + if !wizard_settings::load_from_file() + .map_err(|_| io::Error::other("failed to load Wizard settings"))? + .local_proxy_enabled() + { + log::info!("proxy startup skipped: both proxies are disabled"); + return Ok(()); + } + log::info!("loading proxy TLS roots"); + let tls = tokio::task::spawn_blocking(upstream::tls_config) + .await + .map_err(io::Error::other)??; + let state = Arc::new(ServerState::new(tls)); + let response = state.reload_with_lock().await; + if let Some(error) = response.error { + return Err(io::Error::other(error)); + } + if !response.status.enabled() { + return Ok(()); + } + + // Bind both before starting tasks. If the second bind fails, the first + // listener is dropped too; a disabled app's port must also be available. + let claude = TcpListener::bind(CLAUDE_DESKTOP_ADDRESS) + .await + .inspect_err(|error| { + log::error!( + "Claude Desktop listener bind failed: kind={:?}", + error.kind() + ); + })?; + let chatgpt = TcpListener::bind(CHATGPT_ADDRESS) + .await + .inspect_err(|error| { + log::error!("ChatGPT listener bind failed: kind={:?}", error.kind()); + })?; + log::info!( + "proxy listeners bound: claude_desktop={CLAUDE_DESKTOP_ADDRESS}, chatgpt={CHATGPT_ADDRESS}" + ); + run_listeners(state, claude, chatgpt, shutdown).await +} + +async fn run_listeners( + state: Arc, + claude: TcpListener, + chatgpt: TcpListener, + shutdown: impl Future, +) -> io::Result<()> { + // Handles stay private. Only ServerState::reload changes managed tokens. + let claude = spawn( + claude, + Arc::clone(&state.claude_desktop), + state.tls.clone(), + Some(Arc::clone(&state)), + ); + let chatgpt = spawn( + chatgpt, + Arc::clone(&state.chatgpt), + state.tls.clone(), + Some(Arc::clone(&state)), + ); + let mut stopping = state.stopping.subscribe(); + + tokio::pin!(shutdown); + let result = tokio::select! { + biased; + () = &mut shutdown => { + log::info!("proxy shutdown requested by caller"); + Ok(()) + }, + _ = stopping.wait_for(|stopping| *stopping) => { + log::info!("proxy shutdown requested by settings reload"); + Ok(()) + }, + () = claude.stopped() => { + log::error!("Claude Desktop listener stopped unexpectedly"); + Err(io::Error::other("Claude Desktop listener stopped unexpectedly")) + }, + () = chatgpt.stopped() => { + log::error!("ChatGPT listener stopped unexpectedly"); + Err(io::Error::other("ChatGPT listener stopped unexpectedly")) + }, + }; + + state.stopping.send_replace(true); + // Release the barrier before joining listeners: an accept-failure path may + // also be waiting for this barrier. stopping already prevents new work. + log::info!("waiting for accepted management commands before shutdown"); + drop(state.management.write().await); + log::info!("stopping proxy listeners and draining connections"); + let (claude, chatgpt) = tokio::join!(claude.stop(), chatgpt.stop()); + let result = result.and(claude).and(chatgpt); + match &result { + Ok(()) => log::info!("proxy listeners stopped"), + Err(error) => log::error!( + "proxy listeners stopped with error: kind={:?}", + error.kind() + ), + } + result +} + /// Bind `address` and start proxying with a snapshot of `token`. /// Upstream connectivity is checked per request, not during startup. pub async fn start(address: &str, token: &str) -> io::Result { + listen(address, Some(token)).await +} + +/// Bind once, optionally enabling proxying. A disabled listener returns HTTP 503 +/// until enabled through [`Proxy::set_token`]. No management endpoints are served. +/// System TLS roots are loaded once, including when initially disabled. +pub async fn listen(address: &str, token: Option<&str>) -> io::Result { + if let Some(token) = token { + validate_token(token)?; + } + let tls = tokio::task::spawn_blocking(upstream::tls_config) + .await + .map_err(io::Error::other)??; + let slot = Arc::new(ProxySlot::default()); + slot.set_token(token, &tls).await?; + let listener = TcpListener::bind(address).await?; + Ok(spawn(listener, slot, tls, None)) +} + +fn validate_token(token: &str) -> io::Result<()> { if token.is_empty() { - // TODO: Decide the final behavior when the saved CoreInfra token is absent. return Err(io::Error::new( io::ErrorKind::InvalidInput, "CoreInfra token is not set", )); } + Ok(()) +} - let authorization = upstream::authorization(token)?; - let tls = tokio::task::spawn_blocking(upstream::tls_config) - .await - .map_err(io::Error::other)??; - let upstream = Arc::new(upstream::Upstream::new(tls, authorization)); - let listener = TcpListener::bind(address).await?; - let address = listener.local_addr()?; +fn spawn( + listener: TcpListener, + slot: Arc, + tls: ClientConfig, + management: Option>, +) -> Proxy { let (shutdown, receiver) = oneshot::channel(); let (finished, stopped) = watch::channel(()); + let state = Arc::clone(&slot); let task = tokio::spawn(async move { // Dropping the only sender wakes observers on every task-exit path. let _finished = finished; - serve(listener, address, upstream, receiver).await; + serve(listener, state, management, receiver).await }); - Ok(Proxy { + Proxy { shutdown, task, stopped, - }) + slot, + tls, + } } async fn serve( listener: TcpListener, - address: SocketAddr, - upstream: Arc, + slot: Arc, + management: Option>, mut shutdown: oneshot::Receiver<()>, -) { +) -> io::Result<()> { let mut connections = JoinSet::new(); - let mut warned_at: Option = None; - loop { + let result = loop { tokio::select! { biased; - _ = &mut shutdown => break, + _ = &mut shutdown => break Ok(()), Some(_) = connections.join_next(), if !connections.is_empty() => {}, accepted = listener.accept() => { match accepted { Ok((stream, _)) => { - let upstream = Arc::clone(&upstream); - connections.spawn(http::serve_client(stream, upstream)); - } - // Only this client is gone; the listener is unaffected. - Err(error) - if matches!( - error.kind(), - io::ErrorKind::ConnectionAborted | io::ErrorKind::ConnectionReset - ) => {} - // Typically EMFILE: closing tunnels will free descriptors. - Err(error) => { - if warned_at.is_none_or(|at| at.elapsed() >= ACCEPT_WARNING_INTERVAL) { - warned_at = Some(Instant::now()); - log::warn!( - "local proxy on {address} cannot accept connections, retrying: {error}" - ); - } - tokio::select! { - biased; - _ = &mut shutdown => break, - () = tokio::time::sleep(ACCEPT_RETRY_DELAY) => {}, - } + connections.spawn(http::serve_client( + stream, Arc::clone(&slot), management.clone(), + )); } + Err(error) => break Err(error), } } } - } + }; drop(listener); + // An accept failure must not cancel a Reload already accepted on this port. + // Normal shutdown is quiesced by run_listeners before stop() is called. + if result.is_err() + && let Some(state) = &management + { + state.stopping.send_replace(true); + drop(state.management.write().await); + } connections.abort_all(); while connections.join_next().await.is_some() {} + result } -/// Connect to a remote host, enabling [`KEEPALIVE`] on the socket. -async fn connect(address: impl ToSocketAddrs) -> io::Result { - let stream = TcpStream::connect(address).await?; - SockRef::from(&stream).set_tcp_keepalive(&KEEPALIVE)?; - Ok(stream) +async fn run_proxy_session(generation: Arc, session: impl Future) { + let active = generation.active.read().await; + { + // Own the future in this inner scope: even on cancellation, its sockets + // must be dropped before releasing the active-session guard below. + let session = session; + let mut cancelled = generation.cancelled.subscribe(); + let is_cancelled = *cancelled.borrow(); + if is_cancelled { + drop(session); + } else { + tokio::select! { + biased; + _ = cancelled.changed() => {}, + () = session => {}, + } + } + } + drop(active); } diff --git a/crates/wizard-proxy/src/server_test.rs b/crates/wizard-proxy/src/server_test.rs new file mode 100644 index 0000000..8681c62 --- /dev/null +++ b/crates/wizard-proxy/src/server_test.rs @@ -0,0 +1,173 @@ +use super::*; +use core::time::Duration; +use std::{ + fs, + path::Path, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::io::AsyncWriteExt as _; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::time::timeout; +use tokio_rustls::rustls::RootCertStore; + +fn state() -> Arc { + let tls = ClientConfig::builder_with_provider(Arc::new( + tokio_rustls::rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("TLS versions") + .with_root_certificates(RootCertStore::empty()) + .with_no_client_auth(); + Arc::new(ServerState::new(tls)) +} + +fn settings(claude: Option<&str>, chatgpt: Option<&str>) { + wizard_settings::save_to_file(&wizard_settings::Settings { + claude_proxy_enabled: claude.map(str::to_owned), + codex_proxy_enabled: chatgpt.map(str::to_owned), + ..wizard_settings::Settings::default() + }) + .expect("save isolated settings"); +} + +async fn disabling_reload_replies_before_exit() -> io::Result<()> { + settings(Some("claude-fixture"), Some("chatgpt-fixture")); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = chatgpt.local_addr()?; + let task = tokio::spawn(run_listeners( + state, + claude, + chatgpt, + core::future::pending(), + )); + + let status: control::Status = + control::request(&address.to_string(), control::Command::Status).await?; + assert_eq!(status.version, env!("CARGO_PKG_VERSION")); + assert!(status.claude_desktop_enabled && status.chatgpt_enabled); + + settings(None, None); + let response: control::ReloadResponse = + control::request(&address.to_string(), control::Command::Reload).await?; + assert!(response.error.is_none()); + assert_eq!(response.status.version, env!("CARGO_PKG_VERSION")); + assert!(!response.status.enabled()); + task.await.map_err(io::Error::other)??; + Ok(()) +} + +async fn accepted_reload_survives_disconnect_and_shutdown() -> io::Result<()> { + settings(Some("initial"), None); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let old = state + .claude_desktop + .current + .read() + .await + .clone() + .expect("enabled slot"); + // Hold draining so disconnect and shutdown occur during an accepted Reload. + let active = old.active.read().await; + let mut cancelled = old.cancelled.subscribe(); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = claude.local_addr()?; + let (stop, shutdown) = oneshot::channel(); + let mut task = tokio::spawn(run_listeners(Arc::clone(&state), claude, chatgpt, async { + let _ = shutdown.await; + })); + + settings(Some("replacement"), None); + let mut client = TcpStream::connect(address).await?; + client + .write_all( + format!("POST /reload HTTP/1.1\r\nHost: {address}\r\nContent-Length: 0\r\n\r\n") + .as_bytes(), + ) + .await?; + cancelled.changed().await.map_err(io::Error::other)?; + drop(client); + let _ = stop.send(()); + assert!( + timeout(Duration::from_millis(100), &mut task) + .await + .is_err() + ); + + drop(active); + task.await.map_err(io::Error::other)??; + assert!( + state + .claude_desktop + .current + .read() + .await + .as_ref() + .is_some_and(|generation| generation.token == "replacement") + ); + Ok(()) +} + +// Windows Known Folder resolution ignores HOME; never use a real user profile. +// The test and all shared protocol code still compile on Windows. +#[test] +#[cfg_attr( + windows, + ignore = "requires isolated Unix HOME/XDG settings resolution" +)] +fn isolated_http_management() { + const MARKER: &str = "WIZARD_HTTP_TEST_HOME"; + if let Some(home) = std::env::var_os(MARKER) { + assert_eq!(std::env::var_os("HOME"), Some(home.clone())); + let path = wizard_settings::paths::settings_path().expect("settings path"); + assert!( + path.starts_with(Path::new(&home)), + "refuse real settings path" + ); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + timeout(Duration::from_secs(30), async { + disabling_reload_replies_before_exit() + .await + .expect("disabling Reload response"); + accepted_reload_survives_disconnect_and_shutdown() + .await + .expect("shutdown during disconnected Reload"); + }) + .await + .expect("management fixtures timed out"); + }); + return; + } + let directory = std::env::temp_dir().join(format!( + "wizard-http-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir(&directory).expect("fixture directory"); + let result = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", "server_tests::isolated_http_management"]) + .env(MARKER, &directory) + .env("HOME", &directory) + .env("XDG_CONFIG_HOME", directory.join("config")) + .output() + .expect("isolated test process"); + fs::remove_dir_all(directory).expect("fixture cleanup"); + assert!( + result.status.success(), + "isolated management test failed: {}", + String::from_utf8_lossy(&result.stderr) + ); +} From c9aa83169b8b58f4aabb4ddd4eb6719dc07f604e Mon Sep 17 00:00:00 2001 From: Odrian Date: Thu, 29 Oct 2026 15:27:09 +0300 Subject: [PATCH 2/8] proxy daemon: windows --- Cargo.lock | 2 + Cargo.toml | 1 + crates/wizard-gui/src/main.rs | 4 +- crates/wizard-proxy-daemon/Cargo.toml | 4 + crates/wizard-proxy-daemon/src/lib.rs | 125 +++++----- crates/wizard-proxy-daemon/src/linux.rs | 17 ++ crates/wizard-proxy-daemon/src/logging.rs | 35 ++- crates/wizard-proxy-daemon/src/macos.rs | 23 +- crates/wizard-proxy-daemon/src/unix.rs | 19 ++ .../wizard-proxy-daemon/src/windows-task.xml | 34 +++ crates/wizard-proxy-daemon/src/windows.rs | 224 ++++++++++++++++++ .../wizard-proxy-daemon/src/windows_task.rs | 97 ++++++++ crates/wizard-proxy/src/control.rs | 44 +++- crates/wizard-proxy/src/diagnostics.rs | 83 +++++++ crates/wizard-proxy/src/http.rs | 107 +++++++-- crates/wizard-proxy/src/lib.rs | 57 +++-- crates/wizard-proxy/src/server_test.rs | 44 +++- 17 files changed, 794 insertions(+), 126 deletions(-) create mode 100644 crates/wizard-proxy-daemon/src/linux.rs create mode 100644 crates/wizard-proxy-daemon/src/unix.rs create mode 100644 crates/wizard-proxy-daemon/src/windows-task.xml create mode 100644 crates/wizard-proxy-daemon/src/windows.rs create mode 100644 crates/wizard-proxy-daemon/src/windows_task.rs create mode 100644 crates/wizard-proxy/src/diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index b276965..4f9bff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5513,7 +5513,9 @@ dependencies = [ "objc2", "objc2-foundation", "objc2-service-management", + "tempfile", "tokio", + "windows-sys 0.61.2", "wizard-proxy", "wizard-settings", ] diff --git a/Cargo.toml b/Cargo.toml index 31fcd2c..65faf6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ tauri-plugin-log = "2.8.0" tauri-plugin-single-instance = "2.4.0" tauri-plugin-updater = { version = "2.11.0", default-features = false, features = ["rustls-tls"] } tempfile = "3.27.0" +windows-sys = "0.61.2" url = "2.5.7" uuid = { version = "1.26.0", features = ["v4"] } toml_edit = "0.25.15" diff --git a/crates/wizard-gui/src/main.rs b/crates/wizard-gui/src/main.rs index c0ca3f1..6ee0576 100644 --- a/crates/wizard-gui/src/main.rs +++ b/crates/wizard-gui/src/main.rs @@ -1,5 +1,5 @@ -// Prevents an additional console window on Windows in release builds. -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Prevents an additional console window on Windows. +#![cfg_attr(windows, windows_subsystem = "windows")] use std::process::ExitCode; diff --git a/crates/wizard-proxy-daemon/Cargo.toml b/crates/wizard-proxy-daemon/Cargo.toml index fb5875b..aa85288 100644 --- a/crates/wizard-proxy-daemon/Cargo.toml +++ b/crates/wizard-proxy-daemon/Cargo.toml @@ -21,6 +21,10 @@ wizard-settings.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(windows)'.dependencies] +tempfile.workspace = true +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_SystemInformation", "Win32_System_Threading"] } + [target.'cfg(target_os = "macos")'.dependencies] block2.workspace = true objc2.workspace = true diff --git a/crates/wizard-proxy-daemon/src/lib.rs b/crates/wizard-proxy-daemon/src/lib.rs index 8becaf3..6b8f91e 100644 --- a/crates/wizard-proxy-daemon/src/lib.rs +++ b/crates/wizard-proxy-daemon/src/lib.rs @@ -3,41 +3,42 @@ use std::fs::{self, File, OpenOptions, TryLockError}; use std::io; use std::process::ExitCode; use tokio::sync::Mutex; +use wizard_proxy::diagnostics; mod logging; #[cfg(unix)] mod limits; +#[cfg(unix)] +mod unix; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] use macos as platform; -#[cfg(not(target_os = "macos"))] -mod platform { - pub(super) fn create_daemon() -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "proxy service registration is not implemented on this platform", - )) - } +#[cfg(windows)] +mod windows; +#[cfg(windows)] +use windows as platform; - pub(super) async fn start_daemon() -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "proxy service startup is not implemented on this platform", - )) - } -} +#[cfg(any(windows, test))] +mod windows_task; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux as platform; /// Prepare the service once at GUI startup, before synchronizing settings. /// In release builds, a running daemon with the same workspace crate version is /// left untouched. Debug builds ignore version matches to pick up local rebuilds. -/// Otherwise a valid Status or refused connection triggers platform registration -/// on a blocking worker. Other HTTP errors leave registration unchanged. +/// Otherwise a valid Status or refused connection triggers platform registration. +/// Other HTTP errors leave registration unchanged. +/// Await this operation to completion: cancellation can leave native registration +/// running after the synchronization lock is released. pub async fn create_daemon() -> io::Result<()> { - let guard = SYNC_LOCK.lock().await; + let _guard = SYNC_LOCK.lock().await; match wizard_proxy::control::status().await { Ok(status) if !cfg!(debug_assertions) && status.version == env!("CARGO_PKG_VERSION") => { log::info!("proxy daemon version matches; keeping current service"); @@ -47,21 +48,18 @@ pub async fn create_daemon() -> io::Result<()> { Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { log::info!("preparing proxy service registration: daemon is not listening"); } - Err(error) => return Err(error), + Err(error) => { + diagnostics::error("proxy version lookup before registration", &error); + return Err(error); + } } - tokio::task::spawn_blocking(move || { - // Keep registration serialized even if the awaiting caller is cancelled. - let _guard = guard; - let result = platform::create_daemon(); - match &result { - Ok(()) => log::info!("proxy service registration ready"), - Err(error) => log::error!("proxy service registration failed: kind={:?}", error.kind()), - } - result - }) - .await - .map_err(|_| io::Error::other("proxy service registration task failed"))? + let result = platform::create_daemon().await; + match &result { + Ok(()) => log::info!("proxy service registration ready"), + Err(error) => diagnostics::error("proxy service registration", error), + } + result } static SYNC_LOCK: Mutex<()> = Mutex::const_new(()); @@ -75,6 +73,14 @@ static SYNC_LOCK: Mutex<()> = Mutex::const_new(()); pub async fn sync_proxy() -> io::Result<()> { let _guard = SYNC_LOCK.lock().await; log::info!("synchronizing proxy daemon with saved settings"); + let result = sync_with_settings().await; + if let Err(error) = &result { + diagnostics::error("proxy settings synchronization", error); + } + result +} + +async fn sync_with_settings() -> io::Result<()> { match wizard_proxy::control::reload().await { Ok(response) => { if let Some(error) = response.error { @@ -86,13 +92,20 @@ pub async fn sync_proxy() -> io::Result<()> { } Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { let enabled = wizard_settings::load_from_file() + .inspect_err(|error| { + diagnostics::error("proxy startup demand load", error.as_ref()); + }) .map_err(|_| io::Error::other("failed to load Wizard settings"))? .local_proxy_enabled(); if enabled { log::info!("requesting proxy service start"); - platform::start_daemon().await?; + platform::start_daemon() + .await + .inspect_err(|error| diagnostics::error("proxy service start", error))?; log::info!("waiting for proxy daemon readiness"); - wait_for_proxy().await?; + wait_for_proxy() + .await + .inspect_err(|error| diagnostics::error("proxy readiness wait", error))?; } else { log::info!("proxy service start skipped: both proxies are disabled"); } @@ -114,6 +127,9 @@ async fn wait_for_proxy() -> io::Result<()> { // Settings may have been disabled while startup // was pending. In that case a clean daemon exit is expected. if !wizard_settings::load_from_file() + .inspect_err(|error| { + diagnostics::error("proxy readiness demand load", error.as_ref()); + }) .map_err(|_| io::Error::other("failed to load Wizard settings"))? .local_proxy_enabled() { @@ -147,6 +163,7 @@ pub fn main() -> ExitCode { let result = tokio::runtime::Builder::new_current_thread() .enable_all() .build() + .inspect_err(|error| diagnostics::error("proxy runtime creation", error)) .map_err(|_| "failed to create proxy runtime") .and_then(|runtime| runtime.block_on(run())); match &result { @@ -169,38 +186,10 @@ pub fn main() -> ExitCode { async fn run() -> Result<(), &'static str> { // Register handlers before starting listeners so startup-time events remain pending. - #[cfg(any(target_os = "macos", target_os = "linux"))] - let shutdown = { - use tokio::signal::unix::{SignalKind, signal}; - - let mut interrupt = - signal(SignalKind::interrupt()).map_err(|_| "failed to register SIGINT handler")?; - let mut terminate = - signal(SignalKind::terminate()).map_err(|_| "failed to register SIGTERM handler")?; - async move { - tokio::select! { - _ = interrupt.recv() => log::info!("proxy daemon received SIGINT"), - _ = terminate.recv() => log::info!("proxy daemon received SIGTERM"), - } - } - }; - #[cfg(windows)] - let shutdown = { - use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; - - let mut interrupt = ctrl_c().map_err(|_| "failed to register Ctrl+C handler")?; - let mut stop = ctrl_break().map_err(|_| "failed to register Ctrl+Break handler")?; - let mut close = ctrl_close().map_err(|_| "failed to register console-close handler")?; - async move { - tokio::select! { - _ = interrupt.recv() => log::info!("proxy daemon received Ctrl+C"), - _ = stop.recv() => log::info!("proxy daemon received Ctrl+Break"), - _ = close.recv() => log::info!("proxy daemon received console-close"), - } - } - }; + let shutdown = platform::shutdown_signal()?; wizard_proxy::run_managed(shutdown) .await + .inspect_err(|error| diagnostics::error("proxy server", error)) .map_err(|_| "proxy server failed") } @@ -209,7 +198,9 @@ async fn run() -> Result<(), &'static str> { fn acquire_instance_lock() -> Result { let directory = wizard_settings::paths::config_dir() .map_err(|_| "failed to resolve proxy lock directory")?; - fs::create_dir_all(&directory).map_err(|_| "failed to create proxy lock directory")?; + fs::create_dir_all(&directory) + .inspect_err(|error| logging::report_stderr("proxy lock directory creation", error)) + .map_err(|_| "failed to create proxy lock directory")?; let mut options = OpenOptions::new(); options.read(true).write(true).create(true).truncate(false); #[cfg(unix)] @@ -219,10 +210,14 @@ fn acquire_instance_lock() -> Result { } let file = options .open(directory.join("proxy-daemon.lock")) + .inspect_err(|error| logging::report_stderr("proxy lock file open", error)) .map_err(|_| "failed to open proxy lock file")?; file.try_lock().map_err(|error| match error { TryLockError::WouldBlock => "proxy daemon is already running", - TryLockError::Error(_) => "failed to lock proxy instance", + TryLockError::Error(error) => { + logging::report_stderr("proxy instance lock", &error); + "failed to lock proxy instance" + } })?; Ok(file) } diff --git a/crates/wizard-proxy-daemon/src/linux.rs b/crates/wizard-proxy-daemon/src/linux.rs new file mode 100644 index 0000000..7845e44 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/linux.rs @@ -0,0 +1,17 @@ +use std::io; + +pub(super) use super::unix::shutdown_signal; + +pub(super) async fn create_daemon() -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service registration is not implemented on this platform", + )) +} + +pub(super) async fn start_daemon() -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "proxy service startup is not implemented on this platform", + )) +} diff --git a/crates/wizard-proxy-daemon/src/logging.rs b/crates/wizard-proxy-daemon/src/logging.rs index 97f228e..bb39e17 100644 --- a/crates/wizard-proxy-daemon/src/logging.rs +++ b/crates/wizard-proxy-daemon/src/logging.rs @@ -20,7 +20,9 @@ pub(crate) fn initialize() -> Result<(), &'static str> { }; let directory = wizard_settings::paths::logs_dir().map_err(|_| "failed to resolve proxy log directory")?; - fs::create_dir_all(&directory).map_err(|_| "failed to create proxy log directory")?; + fs::create_dir_all(&directory) + .inspect_err(|error| report_stderr("proxy log directory creation", error)) + .map_err(|_| "failed to create proxy log directory")?; let mut options = OpenOptions::new(); options.read(true).create(true).append(true); #[cfg(unix)] @@ -33,6 +35,7 @@ pub(crate) fn initialize() -> Result<(), &'static str> { drop( options .open(&path) + .inspect_err(|error| report_stderr("proxy log file open", error)) .map_err(|_| "failed to open proxy log file")?, ); let output = FileRotate::new( @@ -50,6 +53,15 @@ pub(crate) fn initialize() -> Result<(), &'static str> { Ok(()) } +pub(crate) fn report_stderr(operation: &'static str, error: &(dyn core::error::Error + 'static)) { + // If stderr also fails there is no remaining diagnostic sink; do not recurse. + let _ = writeln!( + std::io::stderr().lock(), + "{operation} failed:{}", + wizard_proxy::diagnostics::summary(error) + ); +} + impl Log for Logger { fn enabled(&self, metadata: &Metadata<'_>) -> bool { metadata.level() <= log::max_level() && metadata.target().starts_with("wizard_proxy") @@ -59,9 +71,10 @@ impl Log for Logger { if !self.enabled(record.metadata()) { return; } - let Ok(mut output) = self.output.lock() else { - return; - }; + let mut output = self + .output + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); // Match Wizard.log's default UTC timestamps and field layout. // One buffer per record prevents size-based rotation from splitting a line. let line = format!( @@ -71,12 +84,20 @@ impl Log for Logger { record.level(), record.args(), ); - let _ = output.write_all(line.as_bytes()); + if let Err(error) = output.write_all(line.as_bytes()) { + report_stderr("proxy log write", &error); + // Never recurse through the logger when its output is failing. + let _ = std::io::stderr().lock().write_all(line.as_bytes()); + } } fn flush(&self) { - if let Ok(mut output) = self.output.lock() { - let _ = output.flush(); + let mut output = self + .output + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Err(error) = output.flush() { + report_stderr("proxy log flush", &error); } } } diff --git a/crates/wizard-proxy-daemon/src/macos.rs b/crates/wizard-proxy-daemon/src/macos.rs index 71a69ca..acec554 100644 --- a/crates/wizard-proxy-daemon/src/macos.rs +++ b/crates/wizard-proxy-daemon/src/macos.rs @@ -6,6 +6,8 @@ use objc2_service_management::{SMAppService, SMAppServiceStatus}; use std::{io, process::Stdio, sync::mpsc}; use tokio::process::Command; +pub(super) use super::unix::shutdown_signal; + const SERVICE_LABEL: &str = "ai.coreinfra.wizard.proxy"; const PLIST_NAME: &str = "ai.coreinfra.wizard.proxy.plist"; @@ -59,11 +61,18 @@ pub async fn start_daemon() -> io::Result<()> { Ok(()) } +pub async fn create_daemon() -> io::Result<()> { + tokio::task::spawn_blocking(register_daemon) + .await + .inspect_err(|error| wizard_proxy::diagnostics::error("proxy registration worker", error)) + .map_err(|_| io::Error::other("proxy service registration worker failed"))? +} + #[expect( unsafe_code, reason = "SMAppService bindings require checked native calls" )] -pub fn create_daemon() -> io::Result<()> { +fn register_daemon() -> io::Result<()> { if !objc2::available!(macos = 13.0) { return Err(io::Error::new( io::ErrorKind::Unsupported, @@ -87,12 +96,18 @@ pub fn create_daemon() -> io::Result<()> { // SAFETY: SMAppService supplies either null or an NSError valid // during this callback. Only its numeric code leaves the callback. let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + log::error!( + "proxy service unregistration failed: native_code={}", + error.code() + ); Err(io::Error::other(format!( "proxy service unregistration failed (code {})", error.code(), ))) }); - let _ = finished.try_send(result); + if finished.try_send(result).is_err() { + log::error!("proxy unregistration completion could not reach its caller"); + } }); // SAFETY: service is live; SMAppService retains the escaping block // and invokes it on a dispatch queue after termination or an error. @@ -128,6 +143,10 @@ pub fn create_daemon() -> io::Result<()> { { // Keep diagnostics useful without exposing NSError descriptions. registration.map_err(|error| { + log::error!( + "proxy service registration failed: native_code={}", + error.code() + ); io::Error::other(format!( "proxy service registration failed (code {})", error.code(), diff --git a/crates/wizard-proxy-daemon/src/unix.rs b/crates/wizard-proxy-daemon/src/unix.rs new file mode 100644 index 0000000..ca1ee84 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/unix.rs @@ -0,0 +1,19 @@ +use tokio::signal::unix::{SignalKind, signal}; + +// Install handlers now, before run_managed starts loading settings and binding. +pub(super) fn shutdown_signal() -> Result, &'static str> { + let mut interrupt = signal(SignalKind::interrupt()) + .inspect_err(|error| wizard_proxy::diagnostics::error("SIGINT handler registration", error)) + .map_err(|_| "failed to register SIGINT handler")?; + let mut terminate = signal(SignalKind::terminate()) + .inspect_err(|error| { + wizard_proxy::diagnostics::error("SIGTERM handler registration", error); + }) + .map_err(|_| "failed to register SIGTERM handler")?; + Ok(async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received SIGINT"), + _ = terminate.recv() => log::info!("proxy daemon received SIGTERM"), + } + }) +} diff --git a/crates/wizard-proxy-daemon/src/windows-task.xml b/crates/wizard-proxy-daemon/src/windows-task.xml new file mode 100644 index 0000000..7c0b08f --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows-task.xml @@ -0,0 +1,34 @@ + + + + + true + {sid} + + + + + {sid} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + false + PT0S + true + + PT1M + 3 + + + + + {executable} + --proxy + + + diff --git a/crates/wizard-proxy-daemon/src/windows.rs b/crates/wizard-proxy-daemon/src/windows.rs new file mode 100644 index 0000000..ee34a60 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows.rs @@ -0,0 +1,224 @@ +use core::{ptr, time::Duration}; +use std::{ + ffi::OsString, + io::{self, Write as _}, + os::windows::{ + ffi::OsStringExt as _, + io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}, + }, + path::PathBuf, + process::Stdio, +}; +use tokio::process::Command; +use windows_sys::Win32::{ + Foundation::{ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::{ + SystemInformation::GetSystemDirectoryW, + Threading::{CREATE_NO_WINDOW, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }, +}; + +pub(super) async fn create_daemon() -> io::Result<()> { + log::info!("requesting graceful proxy shutdown before task update"); + match wizard_proxy::control::shutdown().await { + Ok(response) => { + tokio::task::spawn_blocking(move || wait_for_exit(response.pid)) + .await + .inspect_err(|error| { + wizard_proxy::diagnostics::error("proxy process exit worker", error); + }) + .map_err(|_| io::Error::other("proxy process exit worker failed"))??; + } + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {} + Err(error) => return Err(error), + } + + let executable = std::env::current_exe()?; + // The caller must be the executable supporting --proxy, even if renamed. + let executable = executable.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "proxy executable path is not valid Unicode", + ) + })?; + let sid = current_user_sid().await?; + let xml = super::windows_task::definition(&sid, executable)?; + // UTF-16LE with BOM makes the file encoding explicit to schtasks. + let bytes: Vec = core::iter::once(0xfeff) + .chain(xml.encode_utf16()) + .flat_map(u16::to_le_bytes) + .collect(); + let mut file = tempfile::Builder::new() + .prefix("wizard-proxy-") + .suffix(".xml") + .tempfile()?; + file.write_all(&bytes)?; + // schtasks opens XML without write sharing. Close the write handle while + // retaining the path guard so the temporary file is removed after import. + let path = file.into_temp_path(); + + let mut command = Command::new(system_executable("schtasks.exe")?); + command + .args(["/Create", "/TN", &task_name(&sid), "/XML"]) + .arg(&path) + .arg("/F"); + log::info!("registering current user's proxy task"); + run_schtasks(&mut command, "registration").await +} + +pub(super) async fn start_daemon() -> io::Result<()> { + let sid = current_user_sid().await?; + let mut command = Command::new(system_executable("schtasks.exe")?); + command.args(["/Run", "/TN", &task_name(&sid)]); + run_schtasks(&mut command, "start").await +} + +pub(super) fn shutdown_signal() -> Result, &'static str> { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let mut interrupt = ctrl_c() + .inspect_err(|error| wizard_proxy::diagnostics::error("Ctrl+C handler registration", error)) + .map_err(|_| "failed to register Ctrl+C handler")?; + let mut stop = ctrl_break() + .inspect_err(|error| { + wizard_proxy::diagnostics::error("Ctrl+Break handler registration", error); + }) + .map_err(|_| "failed to register Ctrl+Break handler")?; + let mut close = ctrl_close() + .inspect_err(|error| { + wizard_proxy::diagnostics::error("console-close handler registration", error); + }) + .map_err(|_| "failed to register console-close handler")?; + Ok(async move { + tokio::select! { + _ = interrupt.recv() => log::info!("proxy daemon received Ctrl+C"), + _ = stop.recv() => log::info!("proxy daemon received Ctrl+Break"), + _ = close.recv() => log::info!("proxy daemon received console-close"), + } + }) +} + +fn task_name(sid: &str) -> String { + format!("ai.coreinfra.wizard.proxy.{sid}") +} + +async fn run_schtasks(command: &mut Command, operation: &'static str) -> io::Result<()> { + // Dropping the timed-out future terminates the utility, but cannot roll back + // a registration already accepted by Task Scheduler. Never retry automatically. + let status = tokio::time::timeout( + Duration::from_secs(5), + command + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("proxy task {operation} timed out"), + ) + })??; + if !status.success() { + log::error!( + "proxy task {operation} failed: exit_code={:?}", + status.code() + ); + return Err(io::Error::other(format!( + "proxy task {operation} failed (exit code {:?})", + status.code(), + ))); + } + Ok(()) +} + +#[expect( + unsafe_code, + reason = "wait on an owned Windows process handle without terminating it" +)] +fn wait_for_exit(pid: u32) -> io::Result<()> { + if pid == 0 || pid == std::process::id() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid proxy shutdown PID", + )); + } + // SAFETY: OpenProcess validates the PID. Request only synchronization access. + let raw = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + if raw.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == i32::try_from(ERROR_INVALID_PARAMETER).ok() { + log::info!("proxy daemon already exited"); + return Ok(()); + } + return Err(error); + } + // SAFETY: OpenProcess returned a new valid handle; transfer ownership once. + let process = unsafe { OwnedHandle::from_raw_handle(raw) }; + log::info!("waiting for proxy daemon process exit"); + // SAFETY: the owned process handle remains alive throughout this bounded wait. + match unsafe { WaitForSingleObject(process.as_raw_handle(), 10_000) } { + WAIT_OBJECT_0 => { + log::info!("proxy daemon process exited"); + Ok(()) + } + WAIT_TIMEOUT => Err(io::Error::new( + io::ErrorKind::TimedOut, + "proxy daemon did not exit", + )), + _ => Err(io::Error::last_os_error()), + } +} + +#[expect( + unsafe_code, + reason = "resolve the system directory without trusting PATH or environment variables" +)] +fn system_executable(name: &str) -> io::Result { + // SAFETY: a null buffer with zero capacity queries the required size. + let required = unsafe { GetSystemDirectoryW(ptr::null_mut(), 0) }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0_u16; required as usize]; + // SAFETY: buffer has capacity for required UTF-16 units. + let written = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), required) }; + if written == 0 { + return Err(io::Error::last_os_error()); + } + if written >= required { + return Err(io::Error::other( + "system directory exceeded its reported size", + )); + } + Ok(PathBuf::from(OsString::from_wide(&buffer[..written as usize])).join(name)) +} + +async fn current_user_sid() -> io::Result { + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new(system_executable("whoami.exe")?) + .args(["/user", "/fo", "csv", "/nh"]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "current user SID lookup timed out"))??; + if !output.status.success() { + log::error!( + "current user SID lookup failed: exit_code={:?}", + output.status.code() + ); + return Err(io::Error::other(format!( + "current user SID lookup failed (exit code {:?})", + output.status.code(), + ))); + } + super::windows_task::sid_from_whoami(&output.stdout) +} diff --git a/crates/wizard-proxy-daemon/src/windows_task.rs b/crates/wizard-proxy-daemon/src/windows_task.rs new file mode 100644 index 0000000..72cc230 --- /dev/null +++ b/crates/wizard-proxy-daemon/src/windows_task.rs @@ -0,0 +1,97 @@ +use std::io; + +// Kept platform-independent so XML escaping is tested without registering tasks. +pub(super) fn definition(sid: &str, executable: &str) -> io::Result { + Ok(format!( + include_str!("windows-task.xml"), + sid = xml_text(sid)?, + executable = xml_text(executable)?, + )) +} + +pub(super) fn sid_from_whoami(output: &[u8]) -> io::Result { + // whoami /user /fo csv /nh ends its row with a quoted ASCII SID. Ignore + // the account name: it may contain commas, escaped quotes or non-UTF-8 bytes. + let output = String::from_utf8_lossy(output); + let sid = output + .trim() + .rsplit_once(',') + .and_then(|(_, value)| value.strip_prefix('"')) + .and_then(|value| value.strip_suffix('"')) + .filter(|sid| { + sid.strip_prefix("S-1-").is_some_and(|rest| { + rest.split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + }) + }) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid current user SID response", + ) + })?; + Ok(sid.to_owned()) +} + +fn xml_text(value: &str) -> io::Result { + let mut escaped = String::new(); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + '\t' + | '\n' + | '\r' + | '\u{20}'..='\u{d7ff}' + | '\u{e000}'..='\u{fffd}' + | '\u{10000}'..='\u{10ffff}' => escaped.push(character), + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid XML character in proxy task definition", + )); + } + } + } + Ok(escaped) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn whoami_sid_does_not_depend_on_account_name_encoding_or_csv_escaping() { + let output = b"\"domain\\na\xffme, \"\"quoted\"\"\",\"S-1-5-21-123-456-789-1001\"\r\n"; + assert_eq!( + sid_from_whoami(output).expect("SID"), + "S-1-5-21-123-456-789-1001" + ); + for invalid in [ + &b""[..], + &b"\"name\",\"not-a-sid\""[..], + &b"\"name\",\"S-1-5-\""[..], + &b"\"name\",\"S-1-5\"extra"[..], + ] { + assert!(sid_from_whoami(invalid).is_err()); + } + } + + #[test] + fn executable_is_xml_text_not_markup() { + let xml = definition("S-1-5-21-123", r#"C:\工具 & \Wizard "test'.exe"#) + .expect("task definition"); + assert!( + xml.contains( + r"C:\工具 & <dev>\Wizard "test'.exe", + ) + ); + let injection = + definition("S-1-5-21-123", "injected").expect("escaped markup"); + assert_eq!(injection.matches("").count(), 1); + assert!(definition("S-1-5-21-123", "invalid\0path").is_err()); + } +} diff --git a/crates/wizard-proxy/src/control.rs b/crates/wizard-proxy/src/control.rs index 53827df..d6220ab 100644 --- a/crates/wizard-proxy/src/control.rs +++ b/crates/wizard-proxy/src/control.rs @@ -10,6 +10,7 @@ use std::io; use tokio::net::TcpStream; pub(crate) const TIMEOUT: Duration = Duration::from_secs(2); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); const MAX_RESPONSE: usize = 1024; /// Current runtime permission to proxy, not upstream health or token validity. @@ -37,10 +38,17 @@ pub struct ReloadResponse { pub error: Option, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShutdownResponse { + pub pid: u32, +} + #[derive(Clone, Copy)] pub(crate) enum Command { Status, Reload, + Shutdown, } /// An unreachable server is an error, never a disabled-proxy report. @@ -54,16 +62,25 @@ pub async fn reload() -> io::Result { request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Reload).await } +/// Request graceful exit without changing saved settings. The reply precedes +/// process exit; callers must wait for the returned PID separately. Do not retry +/// automatically after an ambiguous exchange failure. +pub async fn shutdown() -> io::Result { + request(crate::CLAUDE_DESKTOP_ADDRESS, Command::Shutdown).await +} + pub(crate) async fn request(address: &str, command: Command) -> io::Result { - tokio::time::timeout(TIMEOUT, async { + tokio::time::timeout(REQUEST_TIMEOUT, async { // Connect directly, without HTTP_PROXY, redirects, or retries. let stream = TcpStream::connect(address).await?; let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) .await - .map_err(|_| io::Error::other("management HTTP handshake failed"))?; + .inspect_err(|error| crate::diagnostics::error("management HTTP handshake", error)) + .map_err(io::Error::other)?; let (method, path) = match command { Command::Status => ("GET", "/status"), Command::Reload => ("POST", "/reload"), + Command::Shutdown => ("POST", "/shutdown"), }; let request = Request::builder() .method(method) @@ -77,19 +94,32 @@ pub(crate) async fn request(address: &str, command: Command let response = sender .send_request(request) .await - .map_err(|_| io::Error::other("management HTTP request failed"))?; + .inspect_err(|error| crate::diagnostics::error("management HTTP request", error)) + .map_err(io::Error::other)?; if response.status() != hyper::StatusCode::OK { + log::error!( + "management HTTP request rejected: status={}", + response.status().as_u16() + ); return Err(io::Error::other("management HTTP request rejected")); } let bytes = Limited::new(response.into_body(), MAX_RESPONSE) .collect() .await - .map_err(|_| io::Error::other("invalid management response body"))? + .inspect_err(|error| { + crate::diagnostics::error("management response body", error.as_ref()); + }) + .map_err(io::Error::other)? .to_bytes(); - serde_json::from_slice(&bytes) - .map_err(|_| io::Error::other("invalid management JSON response")) + serde_json::from_slice(&bytes).map_err(|_| { + log::error!("invalid management JSON response"); + io::Error::other("invalid management JSON response") + }) }; - let (_, result) = tokio::join!(connection, exchange); + let (connection, result) = tokio::join!(connection, exchange); + if let Err(error) = connection { + crate::diagnostics::error("management HTTP connection", &error); + } result }) .await diff --git a/crates/wizard-proxy/src/diagnostics.rs b/crates/wizard-proxy/src/diagnostics.rs new file mode 100644 index 0000000..8cb09e5 --- /dev/null +++ b/crates/wizard-proxy/src/diagnostics.rs @@ -0,0 +1,83 @@ +//! Error diagnostics without request data, credentials, or destinations. + +use core::{error::Error, fmt::Write as _}; +use std::io; + +/// Describe typed failures and native OS errors, never arbitrary error messages. +/// Custom error strings can contain URLs, credentials, or certificate identities. +pub fn summary(error: &(dyn Error + 'static)) -> String { + let mut description = String::new(); + let mut current = Some(error); + for _ in 0..32 { + let Some(error) = current else { break }; + if let Some(error) = error.downcast_ref::() { + let _ = write!(description, " kind={:?}", error.kind()); + if let Some(code) = error.raw_os_error() { + // Reconstruct from the numeric code, excluding caller-supplied context. + let _ = write!( + description, + " os_error={code} ({})", + io::Error::from_raw_os_error(code) + ); + } + current = error + .get_ref() + .map(|error| error as &dyn Error) + .or_else(|| error.source()); + } else { + if let Some(error) = error.downcast_ref::() { + let _ = write!( + description, + " http_error=true parse={} incomplete={} closed={} timeout={} user={}", + error.is_parse(), + error.is_incomplete_message(), + error.is_closed(), + error.is_timeout(), + error.is_user() + ); + } else if let Some(error) = error.downcast_ref::() { + let category = match error { + tokio_rustls::rustls::Error::InvalidCertificate(_) => "invalid certificate", + tokio_rustls::rustls::Error::NoCertificatesPresented => "missing certificate", + tokio_rustls::rustls::Error::AlertReceived(_) => "peer alert", + _ => "TLS protocol or configuration error", + }; + let _ = write!(description, " tls_error={category}"); + } else if let Some(error) = error.downcast_ref::() { + let _ = write!( + description, + " task_error=true panic={} cancelled={}", + error.is_panic(), + error.is_cancelled() + ); + } + current = error.source(); + } + } + if description.is_empty() { + description.push_str(" non-OS error; unstructured details withheld"); + } + description +} + +/// Log a failed operation with safe diagnostics. Operation names must be static. +pub fn error(operation: &'static str, error: &(dyn Error + 'static)) { + log::error!("{operation} failed:{}", summary(error)); +} + +#[cfg(test)] +mod tests { + use super::summary; + use std::io; + + #[test] + fn wrapped_os_errors_remain_visible_without_exposing_custom_messages() { + let error = io::Error::other(io::Error::from_raw_os_error(24)); + assert!(summary(&error).contains("os_error=24")); + let error = io::Error::other("secret token and destination must not be logged"); + let message = summary(&error); + assert!(message.contains("kind=Other")); + assert!(!message.contains("secret")); + assert!(!message.contains("destination")); + } +} diff --git a/crates/wizard-proxy/src/http.rs b/crates/wizard-proxy/src/http.rs index 70b7c52..f62f5a1 100644 --- a/crates/wizard-proxy/src/http.rs +++ b/crates/wizard-proxy/src/http.rs @@ -2,7 +2,7 @@ use crate::{ ProxySlot, ServerState, control::{Command, TIMEOUT}, destination::{Route, route}, - run_proxy_session, + diagnostics, run_proxy_session, upstream::Upstream, }; use alloc::sync::Arc; @@ -59,7 +59,13 @@ pub(crate) async fn serve_client( management: Option>, ) { let managed = management.is_some(); - let address = stream.local_addr().ok().map(|address| address.to_string()); + let address = match stream.local_addr() { + Ok(address) => Some(address.to_string()), + Err(error) => { + diagnostics::error("proxy local socket address lookup", &error); + None + } + }; let (dispatch, mut requests) = mpsc::channel(1); let (driver_sender, mut drivers) = mpsc::channel::(1); let (tunnel_sender, mut tunnels) = mpsc::channel::(1); @@ -131,29 +137,46 @@ pub(crate) async fn serve_client( let mut session = Box::pin(async move { tokio::pin!(connection); let driver = tokio::select! { - _ = &mut connection => None, + result = &mut connection => { + if let Err(error) = result { + diagnostics::error("client HTTP connection", &error); + } + None + }, driver = drivers.recv() => driver, }; if let Some(mut driver) = driver { tokio::select! { - _ = &mut connection => {}, - _ = &mut driver => { let _ = (&mut connection).await; }, + result = &mut connection => { + if let Err(error) = result { + diagnostics::error("client HTTP connection", &error); + } + }, + result = &mut driver => { + if let Err(error) = result { + diagnostics::error("remote HTTP connection", &error); + } + if let Err(error) = (&mut connection).await { + diagnostics::error("client HTTP connection", &error); + } + }, } } - if let Ok(tunnel) = tunnels.try_recv() - && let Ok(client) = tunnel.client.await - { + if let Ok(tunnel) = tunnels.try_recv() { + let client = match tunnel.client.await { + Ok(client) => client, + Err(error) => { + diagnostics::error("client CONNECT upgrade", &error); + return; + } + }; let client = TokioIo::new(client); let result = match tunnel.remote { Remote::Direct(remote) => relay(client, remote).await, Remote::Upstream(remote) => relay(client, TokioIo::new(remote)).await, }; if let Err(error) = result { - log::error!( - "proxy tunnel transfer failed: kind={:?}, os_error={:?}", - error.kind(), - error.raw_os_error() - ); + diagnostics::error("proxy tunnel transfer", &error); } } }); @@ -167,12 +190,16 @@ pub(crate) async fn serve_client( if let Some(generation) = generation { let upstream = Arc::clone(&generation.upstream); run_proxy_session(generation, async move { - let _ = ready.send(Some(upstream)); + if ready.send(Some(upstream)).is_err() { + log::error!("proxy dispatch receiver closed before generation delivery"); + } session.await; }) .await; } else { - let _ = ready.send(None); + if ready.send(None).is_err() { + log::error!("proxy dispatch receiver closed before disabled response"); + } session.await; } } @@ -190,12 +217,17 @@ pub(crate) async fn serve_client( match command { Command::Status => json_response(&state.status_with_lock().await), Command::Reload => json_response(&state.reload_with_lock().await), + Command::Shutdown => json_response(&state.shutdown_with_lock().await), } }; // Do not race accepted work against client disconnects. Only the // reply attempt is bounded; shutdown waits on the active guard. - let _ = reply.send(response); - let _ = timeout(TIMEOUT, session).await; + if reply.send(response).is_err() { + log::error!("management response receiver closed"); + } + if timeout(TIMEOUT, session).await.is_err() { + log::error!("management reply timed out"); + } } } None => {} @@ -257,12 +289,13 @@ fn management_command( let (method, command) = match request.uri().path_and_query().map(PathAndQuery::as_str) { Some("/status") => (Method::GET, Command::Status), Some("/reload") => (Method::POST, Command::Reload), + Some("/shutdown") => (Method::POST, Command::Shutdown), _ => return Err((StatusCode::NOT_FOUND, None)), }; if request.method() != method { let allow = match command { Command::Status => "GET", - Command::Reload => "POST", + Command::Reload | Command::Shutdown => "POST", }; return Err((StatusCode::METHOD_NOT_ALLOWED, Some(allow))); } @@ -322,7 +355,10 @@ async fn handle( let remote = match result { Ok(Ok(remote)) => remote, Ok(Err(status)) => return error_response(status), - Err(_) => return error_response(StatusCode::GATEWAY_TIMEOUT), + Err(_) => { + log::error!("CONNECT establishment timed out"); + return error_response(StatusCode::GATEWAY_TIMEOUT); + } }; let tunnel = Tunnel { client: hyper::upgrade::on(&mut request), @@ -391,15 +427,25 @@ async fn handle( }; let mut sender = match timeout(CONNECT_TIMEOUT, connection).await { Ok(Ok(sender)) => sender, - Ok(Err(_)) => return error_response(StatusCode::BAD_GATEWAY), - Err(_) => return error_response(StatusCode::GATEWAY_TIMEOUT), + Ok(Err(error)) => { + diagnostics::error("direct HTTP connection establishment", &error); + return error_response(StatusCode::BAD_GATEWAY); + } + Err(_) => { + log::error!("direct HTTP connection establishment timed out"); + return error_response(StatusCode::GATEWAY_TIMEOUT); + } }; // Do not impose the connection deadline on streaming request/response bodies. let response = sender .send_request(request.map(BodyExt::boxed_unsync)) .await; - let Ok(mut response) = response else { - return error_response(StatusCode::BAD_GATEWAY); + let mut response = match response { + Ok(response) => response, + Err(error) => { + diagnostics::error("direct HTTP request", &error); + return error_response(StatusCode::BAD_GATEWAY); + } }; if response.status() == StatusCode::PROXY_AUTHENTICATION_REQUIRED || response.status() == StatusCode::SWITCHING_PROTOCOLS @@ -446,7 +492,10 @@ async fn connect_remote( connect_tunnel(target, upstream, driver) .await .map(Remote::Upstream) - .map_err(|_| StatusCode::BAD_GATEWAY) + .map_err(|error| { + diagnostics::error("upstream CONNECT establishment", &error); + StatusCode::BAD_GATEWAY + }) } Route::Direct => { let authority = target.authority().ok_or(StatusCode::BAD_REQUEST)?; @@ -454,7 +503,10 @@ async fn connect_remote( connect_direct(authority, port) .await .map(Remote::Direct) - .map_err(|_| StatusCode::BAD_GATEWAY) + .map_err(|error| { + diagnostics::error("direct CONNECT establishment", &error); + StatusCode::BAD_GATEWAY + }) } } } @@ -504,6 +556,10 @@ async fn connect_tunnel( .await .map_err(io::Error::other)?; if !response.status().is_success() { + log::error!( + "upstream rejected CONNECT: status={}", + response.status().as_u16() + ); return Err(io::Error::other("upstream rejected CONNECT")); } hyper::upgrade::on(&mut response) @@ -548,6 +604,7 @@ fn empty_body() -> Body { } fn error_response(status: StatusCode) -> Response { + log::warn!("proxy request rejected: status={}", status.as_u16()); // Never expose upstream response bodies, credentials, or request details in errors. let message = status.canonical_reason().unwrap_or("Proxy error"); let mut response = Response::new( diff --git a/crates/wizard-proxy/src/lib.rs b/crates/wizard-proxy/src/lib.rs index 5d19313..9a4dee7 100644 --- a/crates/wizard-proxy/src/lib.rs +++ b/crates/wizard-proxy/src/lib.rs @@ -18,6 +18,7 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio_rustls::rustls::ClientConfig; pub mod control; +pub mod diagnostics; mod destination; mod http; @@ -103,6 +104,15 @@ impl ServerState { } } + async fn shutdown_with_lock(&self) -> control::ShutdownResponse { + let _guard = self.reload_lock.lock().await; + log::info!("graceful proxy shutdown requested through HTTP"); + self.stopping.send_replace(true); + control::ShutdownResponse { + pid: std::process::id(), + } + } + async fn reload_with_lock(&self) -> control::ReloadResponse { let _guard = self.reload_lock.lock().await; let started = std::time::Instant::now(); @@ -111,8 +121,9 @@ impl ServerState { if *self.stopping.borrow() { return Err("proxy server is stopping"); } - let mut settings = - wizard_settings::load_from_file().map_err(|_| "failed to load Wizard settings")?; + let mut settings = wizard_settings::load_from_file() + .inspect_err(|error| diagnostics::error("proxy settings load", error.as_ref())) + .map_err(|_| "failed to load Wizard settings")?; let claude_token = settings.claude_proxy_enabled.take(); let chatgpt_token = settings.codex_proxy_enabled.take(); drop(settings); @@ -122,12 +133,14 @@ impl ServerState { .claude_desktop .set_token(claude_token.as_deref(), &self.tls) .await + .inspect_err(|error| diagnostics::error("Claude Desktop generation update", error)) .map_err(|_| "failed to update Claude Desktop proxy"); log::info!("applying ChatGPT proxy settings"); let chatgpt = self .chatgpt .set_token(chatgpt_token.as_deref(), &self.tls) .await + .inspect_err(|error| diagnostics::error("ChatGPT generation update", error)) .map_err(|_| "failed to update ChatGPT proxy"); // Attempt both, retaining the first error without rolling back success. claude.and(chatgpt) @@ -251,7 +264,8 @@ impl Proxy { } /// Read Wizard settings and run both fixed-address listeners with unauthenticated -/// GET /status and POST /reload. With both proxies disabled, return without binding. +/// GET /status, POST /reload and POST /shutdown. With both proxies disabled, +/// return without binding. /// TLS roots load once for the pair; disabled proxy traffic receives HTTP 503. /// Accepted management commands and bounded reply attempts finish before shutdown. /// This function does not register OS services or change saved settings. @@ -259,6 +273,7 @@ pub async fn run_managed(shutdown: impl Future) -> io::Result<()> { // Avoid loading system certificates when no proxy is needed. Reload rereads // after the potentially slow TLS initialization, using the latest settings. if !wizard_settings::load_from_file() + .inspect_err(|error| diagnostics::error("proxy startup settings load", error.as_ref())) .map_err(|_| io::Error::other("failed to load Wizard settings"))? .local_proxy_enabled() { @@ -283,15 +298,12 @@ pub async fn run_managed(shutdown: impl Future) -> io::Result<()> { let claude = TcpListener::bind(CLAUDE_DESKTOP_ADDRESS) .await .inspect_err(|error| { - log::error!( - "Claude Desktop listener bind failed: kind={:?}", - error.kind() - ); + diagnostics::error("Claude Desktop listener bind", error); })?; let chatgpt = TcpListener::bind(CHATGPT_ADDRESS) .await .inspect_err(|error| { - log::error!("ChatGPT listener bind failed: kind={:?}", error.kind()); + diagnostics::error("ChatGPT listener bind", error); })?; log::info!( "proxy listeners bound: claude_desktop={CLAUDE_DESKTOP_ADDRESS}, chatgpt={CHATGPT_ADDRESS}" @@ -328,7 +340,7 @@ async fn run_listeners( Ok(()) }, _ = stopping.wait_for(|stopping| *stopping) => { - log::info!("proxy shutdown requested by settings reload"); + log::info!("proxy shutdown requested through management"); Ok(()) }, () = claude.stopped() => { @@ -348,13 +360,11 @@ async fn run_listeners( drop(state.management.write().await); log::info!("stopping proxy listeners and draining connections"); let (claude, chatgpt) = tokio::join!(claude.stop(), chatgpt.stop()); - let result = result.and(claude).and(chatgpt); + // Prefer the original listener failure to a synthetic stopped notification. + let result = claude.and(chatgpt).and(result); match &result { Ok(()) => log::info!("proxy listeners stopped"), - Err(error) => log::error!( - "proxy listeners stopped with error: kind={:?}", - error.kind() - ), + Err(error) => diagnostics::error("proxy listeners shutdown", error), } result } @@ -425,7 +435,11 @@ async fn serve( tokio::select! { biased; _ = &mut shutdown => break Ok(()), - Some(_) = connections.join_next(), if !connections.is_empty() => {}, + Some(result) = connections.join_next(), if !connections.is_empty() => { + if let Err(error) = result { + diagnostics::error("proxy connection task", &error); + } + }, accepted = listener.accept() => { match accepted { Ok((stream, _)) => { @@ -433,7 +447,10 @@ async fn serve( stream, Arc::clone(&slot), management.clone(), )); } - Err(error) => break Err(error), + Err(error) => { + diagnostics::error("proxy listener accept", &error); + break Err(error); + } } } } @@ -449,7 +466,13 @@ async fn serve( drop(state.management.write().await); } connections.abort_all(); - while connections.join_next().await.is_some() {} + while let Some(result) = connections.join_next().await { + if let Err(error) = result + && !error.is_cancelled() + { + diagnostics::error("proxy connection task during shutdown", &error); + } + } result } diff --git a/crates/wizard-proxy/src/server_test.rs b/crates/wizard-proxy/src/server_test.rs index 8681c62..63c3f56 100644 --- a/crates/wizard-proxy/src/server_test.rs +++ b/crates/wizard-proxy/src/server_test.rs @@ -6,7 +6,7 @@ use std::{ process::Command, time::{SystemTime, UNIX_EPOCH}, }; -use tokio::io::AsyncWriteExt as _; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::TcpStream; use tokio::sync::oneshot; use tokio::time::timeout; @@ -61,6 +61,45 @@ async fn disabling_reload_replies_before_exit() -> io::Result<()> { Ok(()) } +async fn shutdown_replies_before_exit_and_preserves_settings() -> io::Result<()> { + settings(Some("shutdown-fixture"), None); + let state = state(); + assert!(state.reload_with_lock().await.error.is_none()); + let claude = TcpListener::bind("127.0.0.1:0").await?; + let chatgpt = TcpListener::bind("127.0.0.1:0").await?; + let address = claude.local_addr()?; + let task = tokio::spawn(run_listeners( + state, + claude, + chatgpt, + core::future::pending(), + )); + + let destination = TcpListener::bind("127.0.0.1:0").await?; + let target = destination.local_addr()?; + let mut client = TcpStream::connect(address).await?; + client + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await?; + let (mut remote, _) = destination.accept().await?; + + let response: control::ShutdownResponse = + control::request(&address.to_string(), control::Command::Shutdown).await?; + assert_eq!(response.pid, std::process::id()); + task.await.map_err(io::Error::other)??; + // A completed Shutdown closes both ends of an active direct tunnel. + let mut reply = Vec::new(); + client.read_to_end(&mut reply).await?; + assert!(reply.starts_with(b"HTTP/1.1 200")); + assert_eq!(remote.read(&mut [0_u8; 1]).await?, 0); + assert!( + wizard_settings::load_from_file() + .expect("saved settings") + .local_proxy_enabled() + ); + Ok(()) +} + async fn accepted_reload_survives_disconnect_and_shutdown() -> io::Result<()> { settings(Some("initial"), None); let state = state(); @@ -139,6 +178,9 @@ fn isolated_http_management() { disabling_reload_replies_before_exit() .await .expect("disabling Reload response"); + shutdown_replies_before_exit_and_preserves_settings() + .await + .expect("graceful Shutdown response"); accepted_reload_survives_disconnect_and_shutdown() .await .expect("shutdown during disconnected Reload"); From 8b2868a5ad436f5d62add97e1222bf67e5627834 Mon Sep 17 00:00:00 2001 From: Odrian Date: Thu, 29 Oct 2026 15:57:44 +0300 Subject: [PATCH 3/8] wizard-gui: show proxy log --- crates/wizard-gui/src/logs.rs | 16 ++++-- .../wizard-ui/src/logs/LogWindow.tsx | 52 ++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/wizard-gui/src/logs.rs b/crates/wizard-gui/src/logs.rs index e44f99c..c15627d 100644 --- a/crates/wizard-gui/src/logs.rs +++ b/crates/wizard-gui/src/logs.rs @@ -1,4 +1,4 @@ -//! Read-only viewer for the current GUI log file. +//! Read-only viewer for the current GUI and proxy log files. use anyhow::{Context as _, Result}; use std::{ @@ -11,6 +11,13 @@ use tauri::Manager as _; const WINDOW_LABEL: &str = "logs"; const MAX_LOG_BYTES: u64 = 256 * 1024; +#[derive(serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LogSource { + Wizard, + Proxy, +} + #[tauri::command] pub(crate) async fn open_logs(app: tauri::AppHandle) -> Result<(), String> { let result = (|| -> Result<()> { @@ -41,10 +48,13 @@ pub(crate) async fn open_logs(app: tauri::AppHandle) -> Result<(), String> { } #[tauri::command] -pub(crate) async fn read_logs() -> Result { +pub(crate) async fn read_logs(source: LogSource) -> Result { let path = wizard_settings::paths::logs_dir() .map_err(|error| error.to_string())? - .join("Wizard.log"); + .join(match source { + LogSource::Wizard => "Wizard.log", + LogSource::Proxy => "Proxy.log", + }); tauri::async_runtime::spawn_blocking(move || read_tail(&path)) .await .map_err(|error| format!("log reader task failed: {error}"))? diff --git a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx index 7d11a9c..ae1fbf5 100644 --- a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx +++ b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx @@ -5,39 +5,47 @@ import { Heading } from "../components/catalyst/heading"; import { Text } from "../components/catalyst/text"; import { ErrorText } from "../components/ErrorText"; +type LogSource = "wizard" | "proxy"; + export function LogWindow() { - const [text, setText] = useState(); - const [error, setError] = useState(); + const [source, setSource] = useState("wizard"); + const [result, setResult] = useState<{ source: LogSource; text?: string; error?: string }>(); + const text = result?.source === source ? result.text : undefined; + const error = result?.source === source ? result.error : undefined; + const fileName = source === "wizard" ? "Wizard.log" : "Proxy.log"; const [paused, setPaused] = useState(false); const [follow, setFollow] = useState(true); const output = useRef(null); useEffect(() => { - if (paused) return; let disposed = false; let timer: ReturnType | undefined; async function refresh() { try { - const next = await invoke("read_logs"); - if (!disposed) { - setText(next); - setError(undefined); - } + const next = await invoke("read_logs", { source }); + if (!disposed) setResult({ source, text: next }); } catch (cause: unknown) { // Do not log polling failures back into the file being displayed. - if (!disposed) setError(`Не удалось прочитать журнал: ${String(cause)}`); + if (!disposed) { + setResult((previous) => ({ + source, + text: previous?.source === source ? previous.text : undefined, + error: `Не удалось прочитать журнал: ${String(cause)}`, + })); + } } finally { - if (!disposed) timer = setTimeout(() => void refresh(), 1000); + if (!disposed && !paused) timer = setTimeout(() => void refresh(), 1000); } } + // Always read the selected file once; pause only stops periodic refreshes. void refresh(); return () => { disposed = true; clearTimeout(timer); }; - }, [paused]); + }, [source, paused]); useEffect(() => { if (follow && output.current) output.current.scrollTop = output.current.scrollHeight; @@ -46,7 +54,23 @@ export function LogWindow() { return (
- Журнал +
+ Журнал +
+ {(["wizard", "proxy"] as const).map((value) => ( + + ))} +
+
- Последние 256 КиБ текущего файла Wizard.log. {paused ? "Обновление приостановлено." : "Обновляется раз в секунду."} + Последние 256 КиБ текущего файла {fileName}. {paused ? "Автообновление приостановлено." : "Обновляется раз в секунду."} {error && {error}}
         {text === undefined ? "Загрузка…" : text || "Журнал пока пуст."}

From 86ac39e94429a9ad5b77a8873aa8901c7954d94a Mon Sep 17 00:00:00 2001
From: Odrian 
Date: Thu, 29 Oct 2026 17:15:45 +0300
Subject: [PATCH 4/8] proxy-daemon: windows uninstall

---
 crates/wizard-gui/src/main.rs                 | 18 ++++
 crates/wizard-gui/tauri.conf.json             |  6 ++
 crates/wizard-gui/windows/README.md           | 48 ++++++++++
 crates/wizard-gui/windows/hooks.nsh           | 20 ++++
 crates/wizard-proxy-daemon/src/lib.rs         | 43 +++++++++
 .../src/windows-uninstall.ps1                 | 46 ++++++++++
 crates/wizard-proxy-daemon/src/windows.rs     | 92 +++++++++++++++++++
 .../tests/windows-uninstall.ps1               | 55 +++++++++++
 8 files changed, 328 insertions(+)
 create mode 100644 crates/wizard-gui/windows/README.md
 create mode 100644 crates/wizard-gui/windows/hooks.nsh
 create mode 100644 crates/wizard-proxy-daemon/src/windows-uninstall.ps1
 create mode 100644 crates/wizard-proxy-daemon/tests/windows-uninstall.ps1

diff --git a/crates/wizard-gui/src/main.rs b/crates/wizard-gui/src/main.rs
index 6ee0576..277f300 100644
--- a/crates/wizard-gui/src/main.rs
+++ b/crates/wizard-gui/src/main.rs
@@ -10,6 +10,24 @@ fn main() -> ExitCode {
         return wizard_proxy_daemon::main();
     }
 
+    #[cfg(windows)]
+    if std::env::args_os()
+        .nth(1)
+        .is_some_and(|arg| arg == "--uninstall-proxy")
+    {
+        return wizard_proxy_daemon::uninstall();
+    }
+
+    // Shared across GUI instances; uninstall requires exclusive access.
+    #[cfg(windows)]
+    let _uninstall_guard = match wizard_proxy_daemon::gui_guard() {
+        Ok(guard) => guard,
+        Err(error) => {
+            eprintln!("cannot start Wizard during proxy cleanup: {error}");
+            return ExitCode::FAILURE;
+        }
+    };
+
     if let Err(error) = wizard_gui::run_application() {
         eprintln!("application failed: {error:#}");
         return ExitCode::FAILURE;
diff --git a/crates/wizard-gui/tauri.conf.json b/crates/wizard-gui/tauri.conf.json
index abd914f..bce71dc 100644
--- a/crates/wizard-gui/tauri.conf.json
+++ b/crates/wizard-gui/tauri.conf.json
@@ -56,6 +56,12 @@
     "active": true,
     "targets": "all",
     "createUpdaterArtifacts": false,
+    "windows": {
+      "nsis": {
+        "installMode": "currentUser",
+        "installerHooks": "./windows/hooks.nsh"
+      }
+    },
     "macOS": {
       "signingIdentity": "-",
       "files": {
diff --git a/crates/wizard-gui/windows/README.md b/crates/wizard-gui/windows/README.md
new file mode 100644
index 0000000..1a64b0b
--- /dev/null
+++ b/crates/wizard-gui/windows/README.md
@@ -0,0 +1,48 @@
+# Windows uninstall
+
+The NSIS pre-uninstall hook invokes the installed executable with
+`--uninstall-proxy` before Tauri checks running processes or deletes files.
+Exit Wizard from the tray first: a shared GUI lock prevents cleanup while any
+current GUI instance is running. Cleanup holds the exclusive lock until it exits.
+
+Cleanup validates the current user's task action against the installed EXE,
+disables the task, requests HTTP shutdown and waits for process exit, then deletes
+the task. It never force-kills the daemon. A refused connection is not proof of
+exit: cleanup also acquires the daemon instance lock and checks task instances.
+Without a registered task, an occupied instance lock causes cleanup to fail
+rather than sending shutdown to an unidentified server.
+
+Windows PowerShell (the OS-provided WindowsPowerShell executable) accesses Task
+Scheduler through COM. Task names and paths are passed in the child environment,
+not interpolated into script source. No additional package is required. Policy
+restrictions on PowerShell/COM cause uninstall to fail safely.
+
+Errors abort before file removal, also in silent/passive mode. A failure after
+disabling the task leaves it disabled; retry uninstall after resolving the error.
+Starting Wizard again normally restores registration when no daemon is listening.
+If the EXE is missing, repair/reinstall before retrying uninstall.
+
+Settings and logs are preserved. The hook clears Tauri 2.11.4's
+`DeleteAppDataCheckboxState`, even if its stock checkbox was selected.
+The GUI/uninstall coordination file `proxy-uninstall.lock` is intentionally retained
+alongside the existing daemon lock; unlinking live lock files is unsafe.
+
+## Verification
+
+Windows `cargo x ci` includes a test using a uniquely named temporary scheduler
+task. It verifies missing-task handling, repeated cleanup, disable/delete, and
+rejection of a different executable or arguments. It does not touch the real
+Wizard task or settings.
+
+Before release, also test the **built NSIS installer**:
+- Running proxy, closed GUI: uninstall stops the process and removes its task.
+- Open GUI: uninstall aborts without removing files; exit via tray and retry.
+- Proxy starting or unresponsive: cleanup either waits or aborts without force-kill.
+- Already absent task and stopped proxy: uninstall succeeds.
+- Normal, silent (`/S`), and passive (`/P`) uninstall preserve `config.toml`,
+  including when the stock delete-data checkbox is selected.
+- Upgrade/reinstall: settings survive and the next GUI start registers the task.
+- Paths with spaces and non-ASCII characters.
+
+Recheck the hook position, `MAINBINARYNAME`, `PassiveMode`, and
+`DeleteAppDataCheckboxState` when updating Tauri's NSIS template.
diff --git a/crates/wizard-gui/windows/hooks.nsh b/crates/wizard-gui/windows/hooks.nsh
new file mode 100644
index 0000000..000d3e1
--- /dev/null
+++ b/crates/wizard-gui/windows/hooks.nsh
@@ -0,0 +1,20 @@
+; Verified against the Tauri CLI 2.11.4 NSIS template.
+!macro NSIS_HOOK_PREUNINSTALL
+  ; Settings must survive even if Tauri's "Delete app data" box was checked.
+  StrCpy $DeleteAppDataCheckboxState 0
+
+  ClearErrors
+  ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-proxy' $0
+  ${If} ${Errors}
+    StrCpy $0 2
+  ${EndIf}
+  ${If} $0 != 0
+    DetailPrint "Proxy cleanup failed. Close Wizard from the tray and retry. Settings have been preserved."
+    ${IfNot} ${Silent}
+    ${AndIf} $PassiveMode != 1
+      MessageBox MB_ICONSTOP|MB_OK "Cannot remove the proxy task. Close Wizard from the tray and retry. No application files have been removed."
+    ${EndIf}
+    SetErrorLevel 2
+    Abort
+  ${EndIf}
+!macroend
diff --git a/crates/wizard-proxy-daemon/src/lib.rs b/crates/wizard-proxy-daemon/src/lib.rs
index 6b8f91e..916439c 100644
--- a/crates/wizard-proxy-daemon/src/lib.rs
+++ b/crates/wizard-proxy-daemon/src/lib.rs
@@ -146,6 +146,49 @@ async fn wait_for_proxy() -> io::Result<()> {
     .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy daemon did not become ready"))?
 }
 
+/// Hold for the GUI lifetime so uninstall cannot race GUI synchronization.
+#[cfg(windows)]
+pub fn gui_guard() -> io::Result {
+    let file = uninstall_lock_file()?;
+    file.try_lock_shared().map_err(io::Error::from)?;
+    Ok(file)
+}
+
+#[cfg(windows)]
+fn uninstall_lock_file() -> io::Result {
+    let directory = wizard_settings::paths::config_dir()
+        .map_err(|_| io::Error::other("failed to resolve uninstall lock directory"))?;
+    fs::create_dir_all(&directory)?;
+    OpenOptions::new()
+        .read(true)
+        .write(true)
+        .create(true)
+        .truncate(false)
+        .open(directory.join("proxy-uninstall.lock"))
+}
+
+/// Remove the current installation's Windows proxy task without changing settings.
+#[cfg(windows)]
+#[must_use]
+pub fn uninstall() -> ExitCode {
+    let result = (|| -> io::Result<()> {
+        let guard = uninstall_lock_file()?;
+        guard.try_lock().map_err(io::Error::from)?;
+        tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()?
+            .block_on(windows::uninstall())
+    })();
+
+    match result {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(error) => {
+            eprintln!("proxy uninstall failed: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
 /// Run the proxy process without initializing the GUI.
 /// Call once at process startup, before any other logger or Tokio runtime.
 #[must_use]
diff --git a/crates/wizard-proxy-daemon/src/windows-uninstall.ps1 b/crates/wizard-proxy-daemon/src/windows-uninstall.ps1
new file mode 100644
index 0000000..88d0353
--- /dev/null
+++ b/crates/wizard-proxy-daemon/src/windows-uninstall.ps1
@@ -0,0 +1,46 @@
+# Invoked by Rust with -NoProfile -NonInteractive. Values travel through the
+# child environment, never through PowerShell source interpolation.
+$ErrorActionPreference = 'Stop'
+try {
+    $scheduler = New-Object -ComObject Schedule.Service
+    $scheduler.Connect()
+    $folder = $scheduler.GetFolder('\')
+    try {
+        $task = $folder.GetTask($env:WIZARD_UNINSTALL_TASK)
+    } catch {
+        # HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND), not access denied.
+        if ($_.Exception.HResult -eq -2147024894 -or
+            $_.Exception.InnerException.HResult -eq -2147024894) {
+            exit 10
+        }
+        throw
+    }
+
+    $actions = $task.Definition.Actions
+    if ($actions.Count -ne 1) { throw 'Unexpected task actions' }
+    $action = $actions.Item(1)
+    if ($action.Type -ne 0 -or $action.Arguments -cne '--proxy' -or
+        -not [string]::Equals(
+            [IO.Path]::GetFullPath($action.Path),
+            [IO.Path]::GetFullPath($env:WIZARD_UNINSTALL_EXE),
+            [StringComparison]::OrdinalIgnoreCase)) {
+        throw 'Task belongs to another installation'
+    }
+
+    switch ($env:WIZARD_UNINSTALL_OPERATION) {
+        'disable' { $task.Enabled = $false }
+        'delete' {
+            # No forced termination: Rust already waited for graceful shutdown.
+            if ($task.GetInstances(0).Count -ne 0) {
+                throw 'Proxy task still has running instances; retry uninstall'
+            }
+            $folder.DeleteTask($env:WIZARD_UNINSTALL_TASK, 0)
+        }
+        default { throw 'Invalid cleanup operation' }
+    }
+    exit 0
+} catch {
+    # Do not emit task XML, settings, or environment values.
+    [Console]::Error.WriteLine('Task Scheduler cleanup failed.')
+    exit 1
+}
diff --git a/crates/wizard-proxy-daemon/src/windows.rs b/crates/wizard-proxy-daemon/src/windows.rs
index ee34a60..db331e9 100644
--- a/crates/wizard-proxy-daemon/src/windows.rs
+++ b/crates/wizard-proxy-daemon/src/windows.rs
@@ -66,6 +66,61 @@ pub(super) async fn create_daemon() -> io::Result<()> {
     run_schtasks(&mut command, "registration").await
 }
 
+pub(super) async fn uninstall() -> io::Result<()> {
+    let sid = current_user_sid().await?;
+    let executable = std::env::current_exe()?;
+    if !update_uninstall_task(&sid, &executable, "disable").await? {
+        // Without a task we cannot establish installation ownership. Do not
+        // send shutdown to an arbitrary process on the management port.
+        let _instance = super::acquire_instance_lock().map_err(io::Error::other)?;
+        return Ok(());
+    }
+
+    match wizard_proxy::control::status().await {
+        Ok(_) => {
+            let response = wizard_proxy::control::shutdown().await?;
+            tokio::task::spawn_blocking(move || wait_for_exit(response.pid))
+                .await
+                .map_err(|_| io::Error::other("proxy exit worker failed"))??;
+        }
+        Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {}
+        Err(error) => return Err(error),
+    }
+
+    let _instance = super::acquire_instance_lock().map_err(io::Error::other)?;
+    update_uninstall_task(&sid, &executable, "delete").await?;
+    Ok(())
+}
+
+async fn update_uninstall_task(
+    sid: &str,
+    executable: &std::path::Path,
+    operation: &str,
+) -> io::Result {
+    let status = tokio::time::timeout(
+        Duration::from_secs(15),
+        Command::new(system_executable(r"WindowsPowerShell\v1.0\powershell.exe")?)
+            .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"])
+            .arg(include_str!("windows-uninstall.ps1"))
+            .env("WIZARD_UNINSTALL_TASK", task_name(sid))
+            .env("WIZARD_UNINSTALL_EXE", executable)
+            .env("WIZARD_UNINSTALL_OPERATION", operation)
+            .creation_flags(CREATE_NO_WINDOW)
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .kill_on_drop(true)
+            .status(),
+    )
+    .await
+    .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy task cleanup timed out"))??;
+    match status.code() {
+        Some(0) => Ok(true),
+        Some(10) => Ok(false),
+        _ => Err(io::Error::other("proxy task cleanup failed")),
+    }
+}
+
 pub(super) async fn start_daemon() -> io::Result<()> {
     let sid = current_user_sid().await?;
     let mut command = Command::new(system_executable("schtasks.exe")?);
@@ -222,3 +277,40 @@ async fn current_user_sid() -> io::Result {
     }
     super::windows_task::sid_from_whoami(&output.stdout)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn uninstall_task_ownership_and_idempotency() -> io::Result<()> {
+        let output = tokio::time::timeout(
+            Duration::from_secs(60),
+            Command::new(system_executable(r"WindowsPowerShell\v1.0\powershell.exe")?)
+                .args([
+                    "-NoProfile",
+                    "-NonInteractive",
+                    "-ExecutionPolicy",
+                    "Bypass",
+                    "-File",
+                ])
+                .arg(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/tests/windows-uninstall.ps1"
+                ))
+                .creation_flags(CREATE_NO_WINDOW)
+                .stdin(Stdio::null())
+                .kill_on_drop(true)
+                .output(),
+        )
+        .await
+        .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "task cleanup test timed out"))??;
+        assert!(
+            output.status.success(),
+            "task cleanup test failed: {} {}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr),
+        );
+        Ok(())
+    }
+}
diff --git a/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1 b/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1
new file mode 100644
index 0000000..63d0611
--- /dev/null
+++ b/crates/wizard-proxy-daemon/tests/windows-uninstall.ps1
@@ -0,0 +1,55 @@
+# Run on Windows: powershell -NoProfile -ExecutionPolicy Bypass -File 
+# Only creates/deletes a uniquely named test task; never touches Wizard settings.
+$ErrorActionPreference = 'Stop'
+$script = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../src/windows-uninstall.ps1'))
+$scheduler = New-Object -ComObject Schedule.Service
+$scheduler.Connect()
+$folder = $scheduler.GetFolder('\')
+$name = 'wizard-uninstall-test-' + [Guid]::NewGuid().ToString('N')
+$env:WIZARD_UNINSTALL_TASK = $name
+$env:WIZARD_UNINSTALL_EXE = "$env:SystemRoot\System32\cmd.exe"
+
+function Invoke-Cleanup($operation, $expected) {
+    $env:WIZARD_UNINSTALL_OPERATION = $operation
+    & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -Command $script
+    if ($LASTEXITCODE -ne $expected) { throw "Unexpected exit code for $operation : $LASTEXITCODE" }
+}
+
+try {
+    Invoke-Cleanup disable 10
+    Invoke-Cleanup delete 10
+
+    $definition = $scheduler.NewTask(0)
+    $definition.Principal.UserId = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+    $definition.Principal.LogonType = 3
+    $definition.Settings.ExecutionTimeLimit = 'PT1M'
+    $action = $definition.Actions.Create(0)
+    $action.Path = $env:WIZARD_UNINSTALL_EXE
+    $action.Arguments = '--proxy'
+    $task = $folder.RegisterTaskDefinition($name, $definition, 6, $null, $null, 3)
+
+    $env:WIZARD_UNINSTALL_EXE = "$env:SystemRoot\System32\notepad.exe"
+    Invoke-Cleanup disable 1
+    Invoke-Cleanup delete 1
+    if (-not $folder.GetTask($name).Enabled) { throw 'Foreign task was disabled' }
+
+    $env:WIZARD_UNINSTALL_EXE = $action.Path
+    Invoke-Cleanup disable 0
+    if ($folder.GetTask($name).Enabled) { throw 'Task was not disabled' }
+    Invoke-Cleanup disable 0
+    Invoke-Cleanup delete 0
+    Invoke-Cleanup delete 10
+
+    # Wrong arguments must not be treated as our proxy, even for the same EXE.
+    $action.Arguments = '/c exit 0'
+    $task = $folder.RegisterTaskDefinition($name, $definition, 6, $null, $null, 3)
+    Invoke-Cleanup disable 1
+    Invoke-Cleanup delete 1
+    Write-Host 'Windows uninstall task tests passed.'
+} finally {
+    try { $folder.DeleteTask($name, 0) } catch {
+        if ($_.Exception.HResult -ne -2147024894 -and
+            $_.Exception.InnerException.HResult -ne -2147024894) { throw }
+    }
+    Remove-Item Env:WIZARD_UNINSTALL_TASK, Env:WIZARD_UNINSTALL_EXE, Env:WIZARD_UNINSTALL_OPERATION -ErrorAction SilentlyContinue
+}

From ad25189e19e198009e3a2d2466bc98a87182c1fe Mon Sep 17 00:00:00 2001
From: Odrian 
Date: Fri, 30 Oct 2026 18:32:58 +0300
Subject: [PATCH 5/8] proxy daemon fixes

---
 crates/wizard-gui/src/main.rs             |   2 -
 crates/wizard-gui/src/updater.rs          |  29 +++-
 crates/wizard-proxy-daemon/src/lib.rs     | 156 ++++++++++++++++--
 crates/wizard-proxy-daemon/src/linux.rs   |   5 +
 crates/wizard-proxy-daemon/src/macos.rs   | 118 ++++++++++----
 crates/wizard-proxy-daemon/src/windows.rs |   7 +-
 crates/wizard-proxy/src/lib.rs            |  83 ++++++----
 crates/wizard-proxy/src/server_test.rs    | 187 ++++++++++++++++++++++
 8 files changed, 503 insertions(+), 84 deletions(-)

diff --git a/crates/wizard-gui/src/main.rs b/crates/wizard-gui/src/main.rs
index 277f300..9a85bdf 100644
--- a/crates/wizard-gui/src/main.rs
+++ b/crates/wizard-gui/src/main.rs
@@ -10,7 +10,6 @@ fn main() -> ExitCode {
         return wizard_proxy_daemon::main();
     }
 
-    #[cfg(windows)]
     if std::env::args_os()
         .nth(1)
         .is_some_and(|arg| arg == "--uninstall-proxy")
@@ -19,7 +18,6 @@ fn main() -> ExitCode {
     }
 
     // Shared across GUI instances; uninstall requires exclusive access.
-    #[cfg(windows)]
     let _uninstall_guard = match wizard_proxy_daemon::gui_guard() {
         Ok(guard) => guard,
         Err(error) => {
diff --git a/crates/wizard-gui/src/updater.rs b/crates/wizard-gui/src/updater.rs
index 5be8ed3..0148087 100644
--- a/crates/wizard-gui/src/updater.rs
+++ b/crates/wizard-gui/src/updater.rs
@@ -112,11 +112,34 @@ async fn check_and_install_update(
         .await
         .context("Не удалось скачать обновление")?;
 
-    tauri::async_runtime::spawn_blocking(move || update.install(bytes))
+    // download() verifies the update signature before service removal begins.
+    let update_guard = wizard_proxy_daemon::prepare_update()
         .await
-        .context("Не удалось выполнить задачу установки обновления")?
-        .context("Не удалось установить обновление")?;
+        .context("Не удалось подготовить прокси к обновлению; установка отменена")?;
+
+    let (update_guard, installation) = tauri::async_runtime::spawn_blocking(move || {
+        // Keep lifecycle locked even if the awaiting task is cancelled while the
+        // blocking installer runs. Catch installer panics so recovery retains the guard.
+        let installation =
+            std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| update.install(bytes)))
+                .map_err(|_| anyhow::anyhow!("Задача установки обновления завершилась с panic"))
+                .and_then(|result| result.context("Не удалось установить обновление"));
+        (update_guard, installation)
+    })
+    .await
+    .context("Не удалось выполнить задачу установки обновления")?;
+
+    if let Err(error) = installation {
+        if let Err(recovery) = update_guard.restore_after_failure().await {
+            return Err(error.context(format!(
+                "Также не удалось восстановить сервис прокси: {recovery}"
+            )));
+        }
+        return Err(error);
+    }
 
+    // Keep update_guard until process restart; queued lifecycle calls must not
+    // start a daemon from the new bundle while the old GUI is still running.
     log::info!("application update installed; restarting");
     app.restart();
 }
diff --git a/crates/wizard-proxy-daemon/src/lib.rs b/crates/wizard-proxy-daemon/src/lib.rs
index 916439c..1afbbb4 100644
--- a/crates/wizard-proxy-daemon/src/lib.rs
+++ b/crates/wizard-proxy-daemon/src/lib.rs
@@ -64,6 +64,64 @@ pub async fn create_daemon() -> io::Result<()> {
 
 static SYNC_LOCK: Mutex<()> = Mutex::const_new(());
 
+/// Exclusive access to service lifecycle operations while replacing the application.
+/// Keep this guard until restart, or consume it to recover after an installation error.
+/// Unlike CLI uninstall, this is used inside a GUI holding the shared GUI lifetime lock.
+#[must_use = "keep the guard alive throughout installation and until application restart"]
+pub struct UpdateGuard {
+    _lock: tokio::sync::MutexGuard<'static, ()>,
+    restore_service: bool,
+}
+
+/// Stop and unregister the service after downloading and verifying an update.
+/// Failure must abort installation. Await completion: native work may outlive cancellation.
+pub async fn prepare_update() -> io::Result {
+    prepare_update_with(platform::uninstall()).await
+}
+
+async fn prepare_update_with(
+    uninstall: impl Future>,
+) -> io::Result {
+    let lock = SYNC_LOCK.lock().await;
+    log::info!("preparing proxy service for application update");
+    let restore_service = uninstall
+        .await
+        .inspect_err(|error| diagnostics::error("proxy update preparation", error))?;
+    log::info!("proxy update preparation completed: registration_removed={restore_service}");
+    Ok(UpdateGuard {
+        _lock: lock,
+        restore_service,
+    })
+}
+
+impl UpdateGuard {
+    /// Best-effort restoration from the bundle left by a failed installer.
+    /// Never reacquire `SYNC_LOCK` here: this guard already owns it.
+    pub async fn restore_after_failure(self) -> io::Result<()> {
+        self.restore_with(async {
+            platform::create_daemon().await?;
+            sync_with_settings().await
+        })
+        .await
+    }
+
+    async fn restore_with(self, restore: impl Future>) -> io::Result<()> {
+        if !self.restore_service {
+            log::info!("proxy recovery skipped: no platform service was removed");
+            return Ok(());
+        }
+        log::info!("restoring proxy service after failed application update");
+        let result = restore.await;
+        match &result {
+            Ok(()) => log::info!("proxy service restored after failed application update"),
+            Err(error) => {
+                diagnostics::error("proxy recovery after failed application update", error);
+            }
+        }
+        result
+    }
+}
+
 /// Synchronize the independent proxy process with saved settings.
 /// Only a refused connection permits starting a process: a timeout or failed
 /// Reload may already have applied changes and must not trigger a second launch.
@@ -147,37 +205,43 @@ async fn wait_for_proxy() -> io::Result<()> {
 }
 
 /// Hold for the GUI lifetime so uninstall cannot race GUI synchronization.
-#[cfg(windows)]
 pub fn gui_guard() -> io::Result {
     let file = uninstall_lock_file()?;
     file.try_lock_shared().map_err(io::Error::from)?;
     Ok(file)
 }
 
-#[cfg(windows)]
 fn uninstall_lock_file() -> io::Result {
     let directory = wizard_settings::paths::config_dir()
         .map_err(|_| io::Error::other("failed to resolve uninstall lock directory"))?;
     fs::create_dir_all(&directory)?;
-    OpenOptions::new()
-        .read(true)
-        .write(true)
-        .create(true)
-        .truncate(false)
-        .open(directory.join("proxy-uninstall.lock"))
+    let mut options = OpenOptions::new();
+    options.read(true).write(true).create(true).truncate(false);
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::OpenOptionsExt as _;
+        options.mode(0o600);
+    }
+    options.open(directory.join("proxy-uninstall.lock"))
 }
 
-/// Remove the current installation's Windows proxy task without changing settings.
-#[cfg(windows)]
+/// Remove the current installation's proxy service without changing settings.
+/// On macOS, run from the application bundle that registered the service.
+/// On macOS and Windows, close GUI instances first. Linux is a successful no-op.
 #[must_use]
 pub fn uninstall() -> ExitCode {
     let result = (|| -> io::Result<()> {
-        let guard = uninstall_lock_file()?;
-        guard.try_lock().map_err(io::Error::from)?;
+        #[cfg(any(target_os = "macos", windows))]
+        let _guard = {
+            let guard = uninstall_lock_file()?;
+            guard.try_lock().map_err(io::Error::from)?;
+            guard
+        };
         tokio::runtime::Builder::new_current_thread()
             .enable_all()
             .build()?
-            .block_on(windows::uninstall())
+            .block_on(platform::uninstall())
+            .map(|_| ())
     })();
 
     match result {
@@ -264,3 +328,69 @@ fn acquire_instance_lock() -> Result {
     })?;
     Ok(file)
 }
+
+#[cfg(test)]
+mod update_tests {
+    use super::{SYNC_LOCK, prepare_update_with};
+    use std::io;
+
+    #[tokio::test]
+    async fn update_guard_serializes_preparation_installation_and_recovery() {
+        // Fake removal: no registration, process, or user settings are touched.
+        let guard = prepare_update_with(async {
+            assert!(SYNC_LOCK.try_lock().is_err(), "lock must precede removal");
+            Ok(false)
+        })
+        .await
+        .expect("prepare update");
+        assert!(
+            SYNC_LOCK.try_lock().is_err(),
+            "installation must remain exclusive"
+        );
+        // The guard can be owned by a blocking installer without unlocking lifecycle operations.
+        let guard = tokio::task::spawn_blocking(move || {
+            assert!(SYNC_LOCK.try_lock().is_err());
+            guard
+        })
+        .await
+        .expect("fake installer");
+        assert!(SYNC_LOCK.try_lock().is_err());
+        // No service was removed: recovery must not call platform registration.
+        guard
+            .restore_with(async { panic!("must not create an absent service") })
+            .await
+            .expect("no-op recovery");
+        assert!(SYNC_LOCK.try_lock().is_ok());
+
+        let guard = prepare_update_with(async { Ok(true) })
+            .await
+            .expect("fake service removal");
+        let error = guard
+            .restore_with(async {
+                assert!(
+                    SYNC_LOCK.try_lock().is_err(),
+                    "recovery must remain exclusive"
+                );
+                tokio::task::yield_now().await;
+                assert!(SYNC_LOCK.try_lock().is_err());
+                Err(io::Error::from(io::ErrorKind::ConnectionRefused))
+            })
+            .await
+            .expect_err("recovery failure must be reported");
+        assert_eq!(error.kind(), io::ErrorKind::ConnectionRefused);
+        assert!(SYNC_LOCK.try_lock().is_ok());
+
+        let error = prepare_update_with(async {
+            assert!(SYNC_LOCK.try_lock().is_err());
+            Err(io::Error::from(io::ErrorKind::PermissionDenied))
+        })
+        .await
+        .err()
+        .expect("preparation must fail");
+        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
+        assert!(
+            SYNC_LOCK.try_lock().is_ok(),
+            "failed preparation must release the lock"
+        );
+    }
+}
diff --git a/crates/wizard-proxy-daemon/src/linux.rs b/crates/wizard-proxy-daemon/src/linux.rs
index 7845e44..e5f9212 100644
--- a/crates/wizard-proxy-daemon/src/linux.rs
+++ b/crates/wizard-proxy-daemon/src/linux.rs
@@ -2,6 +2,11 @@ use std::io;
 
 pub(super) use super::unix::shutdown_signal;
 
+// No platform service is installed on Linux yet. Cleanup is a successful no-op.
+pub(super) async fn uninstall() -> io::Result {
+    Ok(false)
+}
+
 pub(super) async fn create_daemon() -> io::Result<()> {
     Err(io::Error::new(
         io::ErrorKind::Unsupported,
diff --git a/crates/wizard-proxy-daemon/src/macos.rs b/crates/wizard-proxy-daemon/src/macos.rs
index acec554..f272d43 100644
--- a/crates/wizard-proxy-daemon/src/macos.rs
+++ b/crates/wizard-proxy-daemon/src/macos.rs
@@ -35,8 +35,10 @@ pub async fn start_daemon() -> io::Result<()> {
     // Registration may already have launched the agent. Without -k, kickstart
     // does not kill an existing instance. Never bypass approval via enable or
     // launch the executable directly as a fallback.
+    // launchd's default throttle can defer a rapid restart for 10 seconds.
+    // kickstart waits for that launch; allow the throttle plus scheduling slack.
     let status = tokio::time::timeout(
-        Duration::from_secs(5),
+        Duration::from_secs(15),
         Command::new("/bin/launchctl")
             .arg("kickstart")
             .arg(format!("gui/{uid}/{SERVICE_LABEL}"))
@@ -61,6 +63,85 @@ pub async fn start_daemon() -> io::Result<()> {
     Ok(())
 }
 
+// Returns whether a registration was removed, for update-failure recovery.
+pub(super) async fn uninstall() -> io::Result {
+    tokio::task::spawn_blocking(unregister_daemon)
+        .await
+        .inspect_err(|error| wizard_proxy::diagnostics::error("proxy unregistration worker", error))
+        .map_err(|_| io::Error::other("proxy service unregistration worker failed"))?
+}
+
+#[expect(
+    unsafe_code,
+    reason = "SMAppService bindings require checked native calls"
+)]
+fn unregister_daemon() -> io::Result {
+    if !objc2::available!(macos = 13.0) {
+        return Err(io::Error::new(
+            io::ErrorKind::Unsupported,
+            "proxy service removal requires macOS 13 or later",
+        ));
+    }
+
+    autoreleasepool(|_| {
+        let name = NSString::from_str(PLIST_NAME);
+        // SAFETY: macOS availability is checked; the fixed plist belongs to this bundle.
+        let service = unsafe { SMAppService::agentServiceWithPlistName(&name) };
+        // SAFETY: service is retained and valid for this call.
+        let status = unsafe { service.status() };
+        if status != SMAppServiceStatus::NotRegistered {
+            // Includes pending approval. Do not treat NotFound as successful removal:
+            // ask SMAppService for the authoritative unregistration result.
+            unregister_service(&service)?;
+        }
+        // Confirm process cleanup without shutting down an unrelated loopback listener.
+        let _instance = super::acquire_instance_lock().map_err(io::Error::other)?;
+        Ok(status != SMAppServiceStatus::NotRegistered)
+    })
+}
+
+#[expect(
+    unsafe_code,
+    reason = "unregister a retained SMAppService and read its completion error"
+)]
+fn unregister_service(service: &SMAppService) -> io::Result<()> {
+    log::info!("unregistering proxy service and waiting for termination");
+    let (finished, completion) = mpsc::sync_channel(1);
+    let handler = RcBlock::new(move |error: *mut NSError| {
+        // SAFETY: SMAppService supplies null or an NSError valid during this callback.
+        // Only the numeric code is reported; native descriptions may contain private data.
+        let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| {
+            log::error!(
+                "proxy service unregistration failed: native_code={}",
+                error.code()
+            );
+            Err(io::Error::other(format!(
+                "proxy service unregistration failed (code {})",
+                error.code(),
+            )))
+        });
+        if finished.try_send(result).is_err() {
+            log::error!("proxy unregistration completion could not reach its caller");
+        }
+    });
+    // SAFETY: callers hold a live service on macOS 13+. SMAppService retains the
+    // escaping block and invokes it on a dispatch queue after termination or error.
+    unsafe { service.unregisterWithCompletionHandler(&handler) };
+    completion
+        .recv_timeout(Duration::from_secs(10))
+        .map_err(|error| match error {
+            mpsc::RecvTimeoutError::Timeout => io::Error::new(
+                io::ErrorKind::TimedOut,
+                "proxy service unregistration timed out",
+            ),
+            mpsc::RecvTimeoutError::Disconnected => {
+                io::Error::other("proxy service unregistration callback was lost")
+            }
+        })??;
+    log::info!("proxy service unregistration completed");
+    Ok(())
+}
+
 pub async fn create_daemon() -> io::Result<()> {
     tokio::task::spawn_blocking(register_daemon)
         .await
@@ -90,42 +171,9 @@ fn register_daemon() -> io::Result<()> {
         let mut status = unsafe { service.status() };
         log::info!("proxy service status before registration: {}", status.0);
         if status == SMAppServiceStatus::Enabled {
-            log::info!("unregistering proxy service and waiting for termination");
-            let (finished, completion) = mpsc::sync_channel(1);
-            let handler = RcBlock::new(move |error: *mut NSError| {
-                // SAFETY: SMAppService supplies either null or an NSError valid
-                // during this callback. Only its numeric code leaves the callback.
-                let result = unsafe { error.as_ref() }.map_or(Ok(()), |error| {
-                    log::error!(
-                        "proxy service unregistration failed: native_code={}",
-                        error.code()
-                    );
-                    Err(io::Error::other(format!(
-                        "proxy service unregistration failed (code {})",
-                        error.code(),
-                    )))
-                });
-                if finished.try_send(result).is_err() {
-                    log::error!("proxy unregistration completion could not reach its caller");
-                }
-            });
-            // SAFETY: service is live; SMAppService retains the escaping block
-            // and invokes it on a dispatch queue after termination or an error.
-            unsafe { service.unregisterWithCompletionHandler(&handler) };
-            completion
-                .recv_timeout(Duration::from_secs(10))
-                .map_err(|error| match error {
-                    mpsc::RecvTimeoutError::Timeout => io::Error::new(
-                        io::ErrorKind::TimedOut,
-                        "proxy service unregistration timed out",
-                    ),
-                    mpsc::RecvTimeoutError::Disconnected => {
-                        io::Error::other("proxy service unregistration callback was lost")
-                    }
-                })??;
             // Never register before successful completion, including on timeout:
             // the native unregistration may still finish after we return an error.
-            log::info!("proxy service unregistration completed");
+            unregister_service(&service)?;
             status = SMAppServiceStatus::NotRegistered;
         }
         if status == SMAppServiceStatus::NotRegistered || status == SMAppServiceStatus::NotFound {
diff --git a/crates/wizard-proxy-daemon/src/windows.rs b/crates/wizard-proxy-daemon/src/windows.rs
index db331e9..f7a1105 100644
--- a/crates/wizard-proxy-daemon/src/windows.rs
+++ b/crates/wizard-proxy-daemon/src/windows.rs
@@ -66,14 +66,15 @@ pub(super) async fn create_daemon() -> io::Result<()> {
     run_schtasks(&mut command, "registration").await
 }
 
-pub(super) async fn uninstall() -> io::Result<()> {
+// Returns whether this installation had a task, for update-failure recovery.
+pub(super) async fn uninstall() -> io::Result {
     let sid = current_user_sid().await?;
     let executable = std::env::current_exe()?;
     if !update_uninstall_task(&sid, &executable, "disable").await? {
         // Without a task we cannot establish installation ownership. Do not
         // send shutdown to an arbitrary process on the management port.
         let _instance = super::acquire_instance_lock().map_err(io::Error::other)?;
-        return Ok(());
+        return Ok(false);
     }
 
     match wizard_proxy::control::status().await {
@@ -89,7 +90,7 @@ pub(super) async fn uninstall() -> io::Result<()> {
 
     let _instance = super::acquire_instance_lock().map_err(io::Error::other)?;
     update_uninstall_task(&sid, &executable, "delete").await?;
-    Ok(())
+    Ok(true)
 }
 
 async fn update_uninstall_task(
diff --git a/crates/wizard-proxy/src/lib.rs b/crates/wizard-proxy/src/lib.rs
index 9a4dee7..957e9b9 100644
--- a/crates/wizard-proxy/src/lib.rs
+++ b/crates/wizard-proxy/src/lib.rs
@@ -354,8 +354,8 @@ async fn run_listeners(
     };
 
     state.stopping.send_replace(true);
-    // Release the barrier before joining listeners: an accept-failure path may
-    // also be waiting for this barrier. stopping already prevents new work.
+    // Finish accepted management work before cancelling connection tasks.
+    // stopping already prevents new work.
     log::info!("waiting for accepted management commands before shutdown");
     drop(state.management.write().await);
     log::info!("stopping proxy listeners and draining connections");
@@ -431,49 +431,76 @@ async fn serve(
     mut shutdown: oneshot::Receiver<()>,
 ) -> io::Result<()> {
     let mut connections = JoinSet::new();
-    let result = loop {
+    accept_connections(
+        || async { listener.accept().await.map(|(stream, _)| stream) },
+        &slot,
+        management.as_ref(),
+        &mut shutdown,
+        &mut connections,
+    )
+    .await;
+
+    drop(listener);
+    // Managed shutdown is quiesced by run_listeners before stop() is called.
+    connections.abort_all();
+    while let Some(result) = connections.join_next().await {
+        if let Err(error) = result
+            && !error.is_cancelled()
+        {
+            diagnostics::error("proxy connection task during shutdown", &error);
+        }
+    }
+    Ok(())
+}
+
+const ACCEPT_RETRY_DELAY: core::time::Duration = core::time::Duration::from_millis(500);
+
+// Keep the accept operation injectable so resource exhaustion can be tested
+// without changing process limits or exhausting the machine's descriptors.
+async fn accept_connections(
+    mut accept: impl FnMut() -> F,
+    slot: &Arc,
+    management: Option<&Arc>,
+    shutdown: &mut oneshot::Receiver<()>,
+    connections: &mut JoinSet<()>,
+) where
+    F: Future>,
+{
+    let mut retry_at = None;
+    loop {
         tokio::select! {
             biased;
-            _ = &mut shutdown => break Ok(()),
+            _ = &mut *shutdown => break,
             Some(result) = connections.join_next(), if !connections.is_empty() => {
                 if let Err(error) = result {
                     diagnostics::error("proxy connection task", &error);
                 }
             },
-            accepted = listener.accept() => {
+            accepted = async {
+                if let Some(deadline) = retry_at {
+                    tokio::time::sleep_until(deadline).await;
+                }
+                accept().await
+            } => {
                 match accepted {
-                    Ok((stream, _)) => {
+                    Ok(stream) => {
+                        retry_at = None;
                         connections.spawn(http::serve_client(
-                            stream, Arc::clone(&slot), management.clone(),
+                            stream, Arc::clone(slot), management.cloned(),
                         ));
                     }
                     Err(error) => {
-                        diagnostics::error("proxy listener accept", &error);
-                        break Err(error);
+                        diagnostics::error("proxy listener accept; retrying after 500 ms", &error);
+                        // accept errors do not invalidate the owned listener. Keep
+                        // sessions alive and retry without rebinding or spinning.
+                        // Store the deadline outside the select future: completed
+                        // connections must not bypass or extend this backoff.
+                        retry_at = Some(tokio::time::Instant::now() + ACCEPT_RETRY_DELAY);
                     }
                 }
             }
         }
-    };
-
-    drop(listener);
-    // An accept failure must not cancel a Reload already accepted on this port.
-    // Normal shutdown is quiesced by run_listeners before stop() is called.
-    if result.is_err()
-        && let Some(state) = &management
-    {
-        state.stopping.send_replace(true);
-        drop(state.management.write().await);
-    }
-    connections.abort_all();
-    while let Some(result) = connections.join_next().await {
-        if let Err(error) = result
-            && !error.is_cancelled()
-        {
-            diagnostics::error("proxy connection task during shutdown", &error);
-        }
     }
-    result
 }
 
 async fn run_proxy_session(generation: Arc, session: impl Future) {
diff --git a/crates/wizard-proxy/src/server_test.rs b/crates/wizard-proxy/src/server_test.rs
index 63c3f56..1ddacb0 100644
--- a/crates/wizard-proxy/src/server_test.rs
+++ b/crates/wizard-proxy/src/server_test.rs
@@ -23,6 +23,124 @@ fn state() -> Arc {
     Arc::new(ServerState::new(tls))
 }
 
+#[tokio::test]
+async fn accept_errors_preserve_tunnels_and_resume_requests_after_backoff() -> io::Result<()> {
+    timeout(Duration::from_secs(5), async {
+        let state = state();
+        state
+            .claude_desktop
+            .set_token(Some("fixture"), &state.tls)
+            .await?;
+        let listener = TcpListener::bind("127.0.0.1:0").await?;
+        let address = listener.local_addr()?;
+        let destination = TcpListener::bind("127.0.0.1:0").await?;
+        let target = destination.local_addr()?;
+        let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
+        let recorded = Arc::clone(&attempts);
+        let (stop, mut shutdown) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            let mut connections = JoinSet::new();
+            accept_connections(
+                || {
+                    let attempt = {
+                        let mut times = recorded.lock().expect("attempt times");
+                        times.push(tokio::time::Instant::now());
+                        times.len()
+                    };
+                    let listener = &listener;
+                    async move {
+                        match attempt {
+                            2 => Err(io::Error::from(io::ErrorKind::OutOfMemory)),
+                            3 => Err(io::Error::from(io::ErrorKind::ConnectionAborted)),
+                            _ => listener.accept().await.map(|(stream, _)| stream),
+                        }
+                    }
+                },
+                &state.claude_desktop,
+                Some(&state),
+                &mut shutdown,
+                &mut connections,
+            )
+            .await;
+            connections.abort_all();
+            while let Some(result) = connections.join_next().await {
+                if let Err(error) = result {
+                    assert!(
+                        error.is_cancelled(),
+                        "unexpected connection failure: {error}"
+                    );
+                }
+            }
+        });
+
+        let mut client = TcpStream::connect(address).await?;
+        client
+            .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes())
+            .await?;
+        let (mut remote, _) = destination.accept().await?;
+        let mut header = Vec::new();
+        while !header.ends_with(b"\r\n\r\n") {
+            header.push(client.read_u8().await?);
+            assert!(header.len() < 8192);
+        }
+        assert!(header.starts_with(b"HTTP/1.1 200"));
+        // The first accepted connection survives subsequent accept failures.
+        client.write_all(b"live").await?;
+        let mut bytes = [0; 4];
+        remote.read_exact(&mut bytes).await?;
+        assert_eq!(&bytes, b"live");
+
+        let response: control::Status =
+            control::request(&address.to_string(), control::Command::Status).await?;
+        assert!(response.claude_desktop_enabled);
+        {
+            let times = attempts.lock().expect("attempt times");
+            assert!(times.len() >= 4);
+            assert!(times[2] - times[1] >= ACCEPT_RETRY_DELAY);
+            assert!(times[3] - times[2] >= ACCEPT_RETRY_DELAY);
+        }
+        remote.write_all(b"back").await?;
+        client.read_exact(&mut bytes).await?;
+        assert_eq!(&bytes, b"back");
+        let _ = stop.send(());
+        task.await.map_err(io::Error::other)?;
+        assert_eq!(client.read(&mut bytes).await?, 0);
+        Ok(())
+    })
+    .await
+    .expect("accept recovery timed out")
+}
+
+#[tokio::test]
+async fn shutdown_interrupts_accept_backoff() {
+    let state = state();
+    let (stop, mut shutdown) = oneshot::channel();
+    let (failed, mut failure) = tokio::sync::mpsc::unbounded_channel();
+    let task = tokio::spawn(async move {
+        let mut connections = JoinSet::new();
+        accept_connections(
+            || async {
+                failed.send(()).expect("report injected failure");
+                Err(io::Error::from(io::ErrorKind::OutOfMemory))
+            },
+            &state.claude_desktop,
+            None,
+            &mut shutdown,
+            &mut connections,
+        )
+        .await;
+    });
+    timeout(Duration::from_secs(2), failure.recv())
+        .await
+        .expect("injected failure");
+    let _ = stop.send(());
+    timeout(Duration::from_millis(250), task)
+        .await
+        .expect("shutdown must not wait for the retry delay")
+        .expect("accept task");
+    assert!(failure.try_recv().is_err(), "must not retry in a hot loop");
+}
+
 fn settings(claude: Option<&str>, chatgpt: Option<&str>) {
     wizard_settings::save_to_file(&wizard_settings::Settings {
         claude_proxy_enabled: claude.map(str::to_owned),
@@ -153,6 +271,72 @@ async fn accepted_reload_survives_disconnect_and_shutdown() -> io::Result<()> {
     Ok(())
 }
 
+async fn failed_reload_keeps_working_configuration() -> io::Result<()> {
+    settings(Some("working"), Some("working-chatgpt"));
+    let state = state();
+    assert!(state.reload_with_lock().await.error.is_none());
+    let original = state
+        .claude_desktop
+        .current
+        .read()
+        .await
+        .clone()
+        .expect("generation");
+    let claude = TcpListener::bind("127.0.0.1:0").await?;
+    let chatgpt = TcpListener::bind("127.0.0.1:0").await?;
+    let address = claude.local_addr()?.to_string();
+    let (stop, shutdown) = oneshot::channel();
+    let task = tokio::spawn(run_listeners(Arc::clone(&state), claude, chatgpt, async {
+        let _ = shutdown.await;
+    }));
+    fs::write(
+        wizard_settings::paths::settings_path().expect("settings path"),
+        "invalid = [",
+    )?;
+    let failed: control::ReloadResponse =
+        control::request(&address, control::Command::Reload).await?;
+    assert!(failed.error.is_some());
+    assert!(failed.status.claude_desktop_enabled && failed.status.chatgpt_enabled);
+
+    // Preserve an invalid slot's generation; the other slot may still update.
+    settings(Some(""), Some("updated-chatgpt"));
+    let failed: control::ReloadResponse =
+        control::request(&address, control::Command::Reload).await?;
+    assert!(failed.error.is_some());
+    assert!(Arc::ptr_eq(
+        &original,
+        state
+            .claude_desktop
+            .current
+            .read()
+            .await
+            .as_ref()
+            .expect("retained generation")
+    ));
+    assert!(!*original.cancelled.borrow());
+    assert_eq!(
+        state
+            .chatgpt
+            .current
+            .read()
+            .await
+            .as_ref()
+            .expect("updated generation")
+            .token,
+        "updated-chatgpt"
+    );
+
+    settings(Some("repaired"), Some("updated-chatgpt"));
+    let repaired: control::ReloadResponse =
+        control::request(&address, control::Command::Reload).await?;
+    assert!(repaired.error.is_none());
+    let status: control::Status = control::request(&address, control::Command::Status).await?;
+    assert!(status.claude_desktop_enabled && status.chatgpt_enabled);
+    let _ = stop.send(());
+    task.await.map_err(io::Error::other)??;
+    Ok(())
+}
+
 // Windows Known Folder resolution ignores HOME; never use a real user profile.
 // The test and all shared protocol code still compile on Windows.
 #[test]
@@ -175,6 +359,9 @@ fn isolated_http_management() {
             .expect("test runtime")
             .block_on(async {
                 timeout(Duration::from_secs(30), async {
+                    failed_reload_keeps_working_configuration()
+                        .await
+                        .expect("failed Reload preserves service and permits recovery");
                     disabling_reload_replies_before_exit()
                         .await
                         .expect("disabling Reload response");

From 3ccc6d5304a970bb7ce525c8a1e5b024fef9651f Mon Sep 17 00:00:00 2001
From: Odrian 
Date: Fri, 30 Oct 2026 14:44:44 +0300
Subject: [PATCH 6/8] system proxy backend

---
 Cargo.lock                                    |  23 ++
 Cargo.toml                                    |   4 +
 crates/wizard-system-proxy/Cargo.toml         |  23 ++
 crates/wizard-system-proxy/src/lib.rs         | 152 ++++++++
 crates/wizard-system-proxy/src/macos.rs       | 325 ++++++++++++++++++
 crates/wizard-system-proxy/src/tests.rs       | 178 ++++++++++
 crates/wizard-system-proxy/src/windows.rs     | 163 +++++++++
 .../wizard-system-proxy/src/windows_config.rs | 128 +++++++
 8 files changed, 996 insertions(+)
 create mode 100644 crates/wizard-system-proxy/Cargo.toml
 create mode 100644 crates/wizard-system-proxy/src/lib.rs
 create mode 100644 crates/wizard-system-proxy/src/macos.rs
 create mode 100644 crates/wizard-system-proxy/src/tests.rs
 create mode 100644 crates/wizard-system-proxy/src/windows.rs
 create mode 100644 crates/wizard-system-proxy/src/windows_config.rs

diff --git a/Cargo.lock b/Cargo.lock
index 4f9bff0..909094b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2598,6 +2598,17 @@ dependencies = [
  "objc2-security",
 ]
 
+[[package]]
+name = "objc2-system-configuration"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2-core-foundation",
+ "objc2-security",
+]
+
 [[package]]
 name = "objc2-ui-kit"
 version = "0.3.2"
@@ -5531,6 +5542,18 @@ dependencies = [
  "toml_edit 0.25.15+spec-1.1.0",
 ]
 
+[[package]]
+name = "wizard-system-proxy"
+version = "0.2.10"
+dependencies = [
+ "anyhow",
+ "objc2-core-foundation",
+ "objc2-security",
+ "objc2-system-configuration",
+ "windows",
+ "wizard-proxy",
+]
+
 [[package]]
 name = "writeable"
 version = "0.6.4"
diff --git a/Cargo.toml b/Cargo.toml
index 65faf6e..ecae8b1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,6 +59,10 @@ objc2 = "0.6.4"
 objc2-service-management = { version = "0.3.2", default-features = false, features = ["std", "SMAppService", "objc2", "objc2-foundation", "block2"] }
 objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSRunningApplication"] }
 objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
+objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
+objc2-security = { version = "0.3.2", default-features = false, features = ["std", "Authorization"] }
+objc2-system-configuration = { version = "0.3.2", default-features = false, features = ["std", "SCNetworkConfiguration", "SCPreferences", "SCSchemaDefinitions", "objc2-security"] }
+windows = "0.61.3"
 plist = "1.10.1"
 regex = "1.13.1"
 reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider"] }
diff --git a/crates/wizard-system-proxy/Cargo.toml b/crates/wizard-system-proxy/Cargo.toml
new file mode 100644
index 0000000..b50bf3f
--- /dev/null
+++ b/crates/wizard-system-proxy/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "wizard-system-proxy"
+version.workspace = true
+authors.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow.workspace = true
+wizard-proxy.workspace = true
+
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2-core-foundation.workspace = true
+objc2-security.workspace = true
+objc2-system-configuration.workspace = true
+
+[target.'cfg(target_os = "windows")'.dependencies]
+windows = { workspace = true, features = ["Win32_Networking_WinInet", "Win32_Foundation"] }
diff --git a/crates/wizard-system-proxy/src/lib.rs b/crates/wizard-system-proxy/src/lib.rs
new file mode 100644
index 0000000..3ffdd2f
--- /dev/null
+++ b/crates/wizard-system-proxy/src/lib.rs
@@ -0,0 +1,152 @@
+//! System HTTP/HTTPS proxy settings, independent of the listener's lifecycle.
+//!
+//! Blocking API: callers must use a worker thread. macOS mutations may request
+//! authorization. No API starts the proxy, changes its token, or checks its health.
+//! Settings are identified by endpoint, not proof of ownership: another program
+//! using exactly the same endpoint is indistinguishable from Wizard.
+
+use anyhow::{Context as _, Result, ensure};
+use std::sync::Mutex;
+
+#[cfg(target_os = "macos")]
+mod macos;
+#[cfg(target_os = "windows")]
+mod windows;
+mod windows_config;
+
+#[cfg(target_os = "macos")]
+use macos::Native;
+#[cfg(target_os = "windows")]
+use windows::Native;
+
+#[cfg(test)]
+mod tests;
+
+static LOCK: Mutex<()> = Mutex::new(());
+
+/// Configuration state, not listener availability or application traffic routing.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ProxyState {
+    Disabled,
+    Wizard,
+    Foreign,
+}
+
+/// No endpoints, PAC URLs, credentials or complete OS settings are exposed.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ServiceStatus {
+    pub id: String,
+    pub http: ProxyState,
+    pub https: ProxyState,
+    pub conflict: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SystemProxyStatus {
+    pub services: Vec,
+}
+
+/// Read supported services. Does not request authorization.
+pub fn status() -> Result {
+    let _guard = LOCK
+        .lock()
+        .map_err(|_| anyhow::anyhow!("system proxy lock poisoned"))?;
+    native_status()
+}
+
+/// Set HTTP and HTTPS to the Claude Desktop endpoint, refusing active conflicts.
+/// Existing bypass rules and inactive settings are preserved except for the HTTP/
+/// HTTPS endpoints being replaced. There is no persistent backup or rollback.
+pub fn enable() -> Result<()> {
+    update(true)
+}
+
+/// Disable only HTTP/HTTPS entries pointing to Wizard. Preserve foreign entries.
+pub fn disable() -> Result<()> {
+    update(false)
+}
+
+fn update(enabled: bool) -> Result<()> {
+    let _guard = LOCK
+        .lock()
+        .map_err(|_| anyhow::anyhow!("system proxy lock poisoned"))?;
+    native_update(enabled)
+}
+
+#[cfg(any(target_os = "macos", target_os = "windows"))]
+fn native_status() -> Result {
+    let snapshot = Native::read()?;
+    Ok(SystemProxyStatus {
+        services: Native::services(&snapshot)?,
+    })
+}
+
+#[cfg(any(target_os = "macos", target_os = "windows"))]
+fn native_update(enabled: bool) -> Result<()> {
+    change::(enabled)
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+fn native_status() -> Result {
+    anyhow::bail!("system proxy configuration is supported only on macOS and Windows")
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+fn native_update(_: bool) -> Result<()> {
+    anyhow::bail!("system proxy configuration is supported only on macOS and Windows")
+}
+
+// The boundary lets regression tests exercise the entire mutation flow without
+// touching the network configuration, authorization UI or native APIs.
+#[cfg_attr(
+    not(any(target_os = "macos", target_os = "windows", test)),
+    expect(dead_code)
+)]
+trait Backend {
+    type Snapshot: PartialEq;
+    fn read() -> Result;
+    fn services(snapshot: &Self::Snapshot) -> Result>;
+    fn prepare(snapshot: &Self::Snapshot, enabled: bool) -> Result;
+    fn write(before: &Self::Snapshot, after: &Self::Snapshot) -> Result<()>;
+}
+
+#[cfg_attr(
+    not(any(target_os = "macos", target_os = "windows", test)),
+    expect(dead_code)
+)]
+fn change(enabled: bool) -> Result<()> {
+    let before = B::read()?;
+    let services = B::services(&before)?;
+    ensure!(!services.is_empty(), "no supported network services");
+    ensure!(
+        !enabled || !services.iter().any(|s| s.conflict),
+        "active system proxy, PAC or auto-discovery conflicts with Wizard"
+    );
+    let after = B::prepare(&before, enabled)?;
+    if before == after {
+        return Ok(());
+    }
+
+    ensure!(
+        B::read()? == before,
+        "system proxy settings changed; try again"
+    );
+    B::write(&before, &after)
+        .context("system proxy update failed; settings may be partially applied")?;
+    ensure!(
+        B::read().context("cannot verify system proxy update; settings may already be applied")?
+            == after,
+        "system proxy verification failed; settings may be partially applied or changed externally"
+    );
+    Ok(())
+}
+
+fn endpoint_state(enabled: bool, endpoint: &str) -> ProxyState {
+    if !enabled {
+        ProxyState::Disabled
+    } else if endpoint == wizard_proxy::CLAUDE_DESKTOP_ADDRESS {
+        ProxyState::Wizard
+    } else {
+        ProxyState::Foreign
+    }
+}
diff --git a/crates/wizard-system-proxy/src/macos.rs b/crates/wizard-system-proxy/src/macos.rs
new file mode 100644
index 0000000..47589bc
--- /dev/null
+++ b/crates/wizard-system-proxy/src/macos.rs
@@ -0,0 +1,325 @@
+//! Native `SystemConfiguration` adapter. Only this module crosses the FFI boundary.
+#![expect(
+    unsafe_code,
+    reason = "CoreFoundation and authorization APIs require checked FFI calls"
+)]
+
+use crate::{Backend, ProxyState, ServiceStatus, endpoint_state};
+use anyhow::{Context as _, Result, ensure};
+use core::{net::SocketAddr, ptr};
+use objc2_core_foundation::{CFDictionary, CFNumber, CFRetained, CFString, CFType, Type as _};
+use objc2_security::{
+    AuthorizationCreate, AuthorizationFlags, AuthorizationFree, AuthorizationItem,
+    AuthorizationItemSet, AuthorizationRef,
+};
+use objc2_system_configuration::{
+    SCNetworkService, SCNetworkSet, SCPreferences, kSCEntNetProxies,
+    kSCNetworkInterfaceTypeEthernet, kSCNetworkInterfaceTypeIEEE80211,
+};
+
+pub(super) struct Native;
+
+#[derive(Clone, PartialEq)]
+pub(super) struct Snapshot {
+    location: String,
+    services: Vec,
+}
+
+#[derive(Clone, PartialEq)]
+struct Service {
+    id: String,
+    config: CFRetained,
+}
+
+fn preferences() -> Result> {
+    SCPreferences::new(None, &CFString::from_str("Wizard system proxy"), None)
+        .context("cannot open system network preferences")
+}
+
+fn read_preferences(prefs: &SCPreferences) -> Result {
+    let set = SCNetworkSet::current(prefs).context("no current network location")?;
+    let location = set
+        .set_id()
+        .context("network location has no identifier")?
+        .to_string();
+    let all = set
+        .services()
+        .context("cannot enumerate network services")?;
+    // SystemConfiguration returns a CFArray of SCNetworkService objects.
+    let all = unsafe { all.cast_unchecked::() };
+    let mut services = Vec::new();
+    for service in all.iter() {
+        if !service.enabled() {
+            continue;
+        }
+        let interface = service
+            .interface()
+            .context("network service has no interface")?;
+        let kind = interface
+            .interface_type()
+            .context("network interface has no type")?;
+        // These exported constants are immutable framework-owned CFStrings.
+        if unsafe {
+            &*kind != kSCNetworkInterfaceTypeEthernet && &*kind != kSCNetworkInterfaceTypeIEEE80211
+        } {
+            continue;
+        }
+        let id = service
+            .service_id()
+            .context("network service has no identifier")?
+            .to_string();
+        let protocol = service
+            .protocol(unsafe { kSCEntNetProxies })
+            .context("network service has no proxy protocol")?;
+        ensure!(
+            protocol.enabled(),
+            "network service proxy protocol is disabled"
+        );
+        let config = protocol
+            .configuration()
+            .unwrap_or_else(|| CFDictionary::::empty().as_opaque().retain());
+        services.push(Service { id, config });
+    }
+    services.sort_by(|a, b| a.id.cmp(&b.id));
+    Ok(Snapshot { location, services })
+}
+
+fn value(config: &CFDictionary, key: &str) -> Option> {
+    // SC proxy dictionaries contain CF property-list keys and values.
+    unsafe { config.cast_unchecked::() }.get(&CFString::from_str(key))
+}
+
+fn number(config: &CFDictionary, key: &str) -> Result {
+    let Some(value) = value(config, key) else {
+        return Ok(0);
+    };
+    value
+        .downcast_ref::()
+        .and_then(CFNumber::as_i64)
+        .context("invalid numeric proxy setting")
+}
+
+fn state(config: &CFDictionary, prefix: &str) -> Result {
+    if number(config, &format!("{prefix}Enable"))? == 0 {
+        return Ok(ProxyState::Disabled);
+    }
+    let host = value(config, &format!("{prefix}Proxy")).context("enabled proxy has no host")?;
+    let host = host
+        .downcast_ref::()
+        .context("invalid proxy host")?;
+    let port = number(config, &format!("{prefix}Port"))?;
+    Ok(endpoint_state(true, &format!("{host}:{port}")))
+}
+
+fn service_status(service: &Service) -> Result {
+    let config = &service.config;
+    let http = state(config, "HTTP")?;
+    let https = state(config, "HTTPS")?;
+    let mut conflict = http == ProxyState::Foreign || https == ProxyState::Foreign;
+    // FTP/RTSP/Gopher are legacy, but must not silently coexist with our setting.
+    for key in [
+        "SOCKSEnable",
+        "FTPEnable",
+        "RTSPEnable",
+        "GopherEnable",
+        "ProxyAutoConfigEnable",
+        "ProxyAutoDiscoveryEnable",
+    ] {
+        conflict |= number(config, key)? != 0;
+    }
+    Ok(ServiceStatus {
+        id: service.id.clone(),
+        http,
+        https,
+        conflict,
+    })
+}
+
+fn replacing(config: &CFDictionary, key: &str, value: &CFType) -> CFRetained {
+    // A fresh dictionary preserves every unrelated property-list value and never
+    // mutates the original snapshot returned by SystemConfiguration.
+    let dict = unsafe { config.cast_unchecked::() };
+    let (mut keys, mut values) = dict.to_vecs();
+    let key = CFString::from_str(key);
+    let key_type: &CFType = &key;
+    if let Some(index) = keys.iter().position(|k| **k == *key_type) {
+        values[index] = value.retain();
+    } else {
+        keys.push(key_type.retain());
+        values.push(value.retain());
+    }
+    CFDictionary::from_slices(
+        &keys.iter().map(|k| &**k).collect::>(),
+        &values.iter().map(|v| &**v).collect::>(),
+    )
+    .as_opaque()
+    .retain()
+}
+
+impl Backend for Native {
+    type Snapshot = Snapshot;
+
+    fn read() -> Result {
+        let prefs = preferences()?;
+        read_preferences(&prefs)
+    }
+
+    fn services(snapshot: &Snapshot) -> Result> {
+        snapshot.services.iter().map(service_status).collect()
+    }
+
+    fn prepare(snapshot: &Snapshot, enabled: bool) -> Result {
+        let address: SocketAddr = wizard_proxy::CLAUDE_DESKTOP_ADDRESS.parse()?;
+        let host = CFString::from_str(&address.ip().to_string());
+        let port = CFNumber::new_i32(i32::from(address.port()));
+        let mut after = snapshot.clone();
+        for service in &mut after.services {
+            for prefix in ["HTTP", "HTTPS"] {
+                if enabled {
+                    service.config = replacing(&service.config, &format!("{prefix}Proxy"), &host);
+                    service.config = replacing(&service.config, &format!("{prefix}Port"), &port);
+                    service.config = replacing(
+                        &service.config,
+                        &format!("{prefix}Enable"),
+                        &CFNumber::new_i32(1),
+                    );
+                } else if state(&service.config, prefix)? == ProxyState::Wizard {
+                    service.config = replacing(
+                        &service.config,
+                        &format!("{prefix}Enable"),
+                        &CFNumber::new_i32(0),
+                    );
+                }
+            }
+        }
+        Ok(after)
+    }
+
+    fn write(before: &Snapshot, after: &Snapshot) -> Result<()> {
+        // No authorization is requested until common preflight has succeeded.
+        let authorization = Authorization::new()?;
+        // Authorization stays alive until after preferences and its lock drop.
+        let prefs = unsafe {
+            SCPreferences::with_authorization(
+                None,
+                &CFString::from_str("Wizard system proxy"),
+                None,
+                authorization.0,
+            )
+        }
+        .context("cannot open authorized network preferences")?;
+        ensure!(prefs.lock(false), "cannot lock network preferences");
+        let _lock = PreferencesLock(&prefs);
+        ensure!(
+            read_preferences(&prefs)? == *before,
+            "network settings changed during authorization; try again"
+        );
+        for (old, new) in before.services.iter().zip(&after.services) {
+            if old == new {
+                continue;
+            }
+            let service = SCNetworkService::copy(&prefs, &CFString::from_str(&new.id))
+                .context("network service disappeared")?;
+            let protocol = service
+                .protocol(unsafe { kSCEntNetProxies })
+                .context("proxy protocol disappeared")?;
+            // The snapshot is an owned property-list dictionary valid for this call.
+            ensure!(
+                unsafe { protocol.set_configuration(Some(&new.config)) },
+                "cannot stage proxy configuration"
+            );
+        }
+        ensure!(prefs.commit_changes(), "cannot commit network preferences");
+        ensure!(
+            prefs.apply_changes(),
+            "network preferences saved but could not be applied"
+        );
+        Ok(())
+    }
+}
+
+struct PreferencesLock<'a>(&'a SCPreferences);
+impl Drop for PreferencesLock<'_> {
+    fn drop(&mut self) {
+        self.0.unlock();
+    }
+}
+
+struct Authorization(AuthorizationRef);
+impl Authorization {
+    fn new() -> Result {
+        let mut item = AuthorizationItem {
+            name: c"system.preferences.network".as_ptr(),
+            valueLength: 0,
+            value: ptr::null_mut(),
+            flags: 0,
+        };
+        let rights = AuthorizationItemSet {
+            count: 1,
+            items: &raw mut item,
+        };
+        let mut authorization = ptr::null();
+        // All pointers refer to live stack values; the API returns an owned ref.
+        let code = unsafe {
+            AuthorizationCreate(
+                &raw const rights,
+                ptr::null(),
+                AuthorizationFlags::InteractionAllowed | AuthorizationFlags::ExtendRights,
+                &raw mut authorization,
+            )
+        };
+        ensure!(
+            code == 0 && !authorization.is_null(),
+            "network configuration authorization failed ({code})"
+        );
+        Ok(Self(authorization))
+    }
+}
+impl Drop for Authorization {
+    fn drop(&mut self) {
+        // Release our reference without revoking shared authorization rights.
+        unsafe {
+            AuthorizationFree(self.0, AuthorizationFlags::Defaults);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn preserves_unrelated_properties_and_disables_only_our_protocol() -> Result<()> {
+        let empty = CFDictionary::::empty();
+        let config = replacing(
+            empty.as_opaque(),
+            "ExceptionsList",
+            &CFString::from_str("fixture"),
+        );
+        let before = Snapshot {
+            location: "fixture".into(),
+            services: vec![Service {
+                id: "fixture".into(),
+                config,
+            }],
+        };
+        let enabled = Native::prepare(&before, true)?;
+        assert_eq!(Native::services(&enabled)?[0].http, ProxyState::Wizard);
+        assert_eq!(
+            value(&before.services[0].config, "ExceptionsList"),
+            value(&enabled.services[0].config, "ExceptionsList")
+        );
+        let mut foreign = enabled;
+        foreign.services[0].config = replacing(
+            &foreign.services[0].config,
+            "HTTPSProxy",
+            &CFString::from_str("foreign.example"),
+        );
+        let disabled = Native::prepare(&foreign, false)?;
+        let status = Native::services(&disabled)?;
+        assert_eq!(status[0].http, ProxyState::Disabled);
+        assert_eq!(status[0].https, ProxyState::Foreign);
+        assert!(status[0].conflict);
+        Ok(())
+    }
+}
diff --git a/crates/wizard-system-proxy/src/tests.rs b/crates/wizard-system-proxy/src/tests.rs
new file mode 100644
index 0000000..74d8891
--- /dev/null
+++ b/crates/wizard-system-proxy/src/tests.rs
@@ -0,0 +1,178 @@
+use super::*;
+use crate::windows_config::{Config, DIRECT, PROXY};
+use core::cell::RefCell;
+
+fn disabled() -> Config {
+    Config {
+        flags: DIRECT,
+        server: String::new(),
+        bypass: "localhost;".into(),
+        pac: "https://example.invalid/proxy.pac".into(),
+    }
+}
+
+#[derive(Default)]
+struct MockState {
+    snapshot: Vec,
+    reads: usize,
+    writes: usize,
+    concurrent_change: bool,
+    partial_failure: bool,
+    ignore_write: bool,
+}
+
+thread_local! { static MOCK: RefCell = RefCell::new(MockState::default()); }
+struct Mock;
+impl Backend for Mock {
+    type Snapshot = Vec;
+    fn read() -> Result {
+        MOCK.with_borrow_mut(|m| {
+            m.reads += 1;
+            if m.concurrent_change && m.reads == 2 {
+                m.snapshot[0].bypass = "changed".into();
+            }
+            Ok(m.snapshot.clone())
+        })
+    }
+    fn services(snapshot: &Self::Snapshot) -> Result> {
+        snapshot.iter().map(Config::status).collect()
+    }
+    fn prepare(snapshot: &Self::Snapshot, enabled: bool) -> Result {
+        snapshot.iter().map(|c| c.prepare(enabled)).collect()
+    }
+    fn write(_: &Self::Snapshot, after: &Self::Snapshot) -> Result<()> {
+        MOCK.with_borrow_mut(|m| {
+            m.writes += 1;
+            if m.partial_failure {
+                m.snapshot[0] = after[0].clone();
+                anyhow::bail!("fixture write failure");
+            }
+            if !m.ignore_write {
+                m.snapshot.clone_from(after);
+            }
+            Ok(())
+        })
+    }
+}
+
+fn setup(configs: Vec) {
+    MOCK.with_borrow_mut(|m| {
+        *m = MockState {
+            snapshot: configs,
+            ..MockState::default()
+        }
+    });
+}
+
+#[test]
+fn preflights_every_service_before_any_write() {
+    for flags in [DIRECT | 4, DIRECT | 8, DIRECT | PROXY] {
+        let mut conflict = disabled();
+        conflict.flags = flags;
+        conflict.server = "http=foreign.example:8080".into();
+        setup(vec![disabled(), conflict]);
+        assert!(change::(true).is_err());
+        MOCK.with_borrow(|m| assert_eq!(m.writes, 0));
+    }
+}
+
+#[test]
+fn no_services_and_concurrent_changes_never_write() {
+    setup(vec![]);
+    assert!(change::(true).is_err());
+    setup(vec![disabled()]);
+    MOCK.with_borrow_mut(|m| m.concurrent_change = true);
+    assert!(change::(true).is_err());
+    MOCK.with_borrow(|m| assert_eq!(m.writes, 0));
+}
+
+#[test]
+fn repeated_enable_disable_are_noops_and_preserve_bypass_and_pac() -> Result<()> {
+    setup(vec![disabled()]);
+    change::(true)?;
+    change::(true)?;
+    MOCK.with_borrow(|m| {
+        assert_eq!(m.writes, 1);
+        assert_eq!(m.snapshot[0].bypass, disabled().bypass);
+        assert_eq!(m.snapshot[0].pac, disabled().pac);
+    });
+    change::(false)?;
+    change::(false)?;
+    MOCK.with_borrow(|m| assert_eq!(m.writes, 2));
+    Ok(())
+}
+
+#[test]
+fn partial_failures_and_failed_verification_are_reported_without_retry() {
+    setup(vec![disabled(), disabled()]);
+    MOCK.with_borrow_mut(|m| m.partial_failure = true);
+    let error = change::(true).expect_err("partial application must fail");
+    assert!(error.to_string().contains("partially applied"));
+    MOCK.with_borrow(|m| {
+        assert_eq!(m.writes, 1);
+        assert!(m.snapshot[0].flags & PROXY != 0);
+        assert_eq!(m.snapshot[1].flags & PROXY, 0);
+    });
+    setup(vec![disabled()]);
+    MOCK.with_borrow_mut(|m| m.ignore_write = true);
+    assert!(change::(true).is_err());
+    MOCK.with_borrow(|m| assert_eq!(m.writes, 1));
+}
+
+#[test]
+fn disabling_preserves_foreign_protocols_and_auto_modes() -> Result<()> {
+    let mut config = disabled();
+    config.flags |= PROXY | 4 | 8;
+    config.server = format!(
+        "http={};https=foreign.example:443;socks=foreign.example:1080",
+        wizard_proxy::CLAUDE_DESKTOP_ADDRESS
+    );
+    let after = config.prepare(false)?;
+    assert_eq!(after.flags, config.flags);
+    assert_eq!(
+        after.server,
+        "https=foreign.example:443;socks=foreign.example:1080"
+    );
+    assert_eq!(after.pac, config.pac);
+    assert_eq!(after.bypass, config.bypass);
+    assert!(after.status()?.conflict);
+    assert!(after.prepare(false)? == after);
+    Ok(())
+}
+
+#[test]
+fn global_address_is_supported_but_not_confused_with_similar_endpoints() -> Result<()> {
+    for server in [
+        "proxy.example:8080",
+        "127.0.0.1:1",
+        "http://127.0.0.1:33787",
+        "127.0.0.1:337870",
+    ] {
+        let mut config = disabled();
+        config.flags |= PROXY;
+        config.server = server.into();
+        assert!(config.status()?.conflict);
+        assert!(config.prepare(false)? == config);
+    }
+    let mut config = disabled();
+    config.flags |= PROXY;
+    config.server = wizard_proxy::CLAUDE_DESKTOP_ADDRESS.into();
+    assert!(!config.status()?.conflict);
+    assert_eq!(config.prepare(false)?.flags, DIRECT);
+    Ok(())
+}
+
+#[test]
+fn malformed_or_unknown_mappings_cannot_be_silently_overwritten() -> Result<()> {
+    for server in ["http=a:1;http=b:2", "http=a:1;garbage", "a:1;b:2", "http="] {
+        let mut config = disabled();
+        config.flags |= PROXY;
+        config.server = server.into();
+        assert!(config.status().is_err());
+    }
+    let mut dormant = disabled();
+    dormant.server = "socks=example.invalid:1080".into();
+    assert!(dormant.prepare(true).is_err());
+    assert!(dormant.prepare(false)? == dormant);
+    Ok(())
+}
diff --git a/crates/wizard-system-proxy/src/windows.rs b/crates/wizard-system-proxy/src/windows.rs
new file mode 100644
index 0000000..55a0afb
--- /dev/null
+++ b/crates/wizard-system-proxy/src/windows.rs
@@ -0,0 +1,163 @@
+//! Current-user LAN `WinINet` settings; never `WinHTTP` or RAS/VPN profiles.
+#![expect(unsafe_code, reason = "WinINet uses unions and caller-owned buffers")]
+
+use crate::{Backend, ServiceStatus, windows_config::Config};
+use anyhow::{Result, ensure};
+use core::{mem::size_of, ptr};
+use windows::{
+    Win32::{
+        Foundation::{GlobalFree, HGLOBAL},
+        Networking::WinInet::{
+            INTERNET_OPTION_PER_CONNECTION_OPTION, INTERNET_OPTION_PROXY_SETTINGS_CHANGED,
+            INTERNET_OPTION_REFRESH, INTERNET_PER_CONN_AUTOCONFIG_URL, INTERNET_PER_CONN_FLAGS,
+            INTERNET_PER_CONN_FLAGS_UI, INTERNET_PER_CONN_OPTION_LISTW, INTERNET_PER_CONN_OPTIONW,
+            INTERNET_PER_CONN_OPTIONW_0, INTERNET_PER_CONN_PROXY_BYPASS,
+            INTERNET_PER_CONN_PROXY_SERVER, InternetQueryOptionW, InternetSetOptionW,
+        },
+    },
+    core::PWSTR,
+};
+
+pub(super) struct Native;
+
+fn native_result(result: windows::core::Result<()>) -> Result<()> {
+    result.map_err(|error| anyhow::anyhow!("WinINet operation failed (HRESULT {})", error.code().0))
+}
+
+fn option(kind: u32) -> INTERNET_PER_CONN_OPTIONW {
+    INTERNET_PER_CONN_OPTIONW {
+        dwOption: kind,
+        Value: INTERNET_PER_CONN_OPTIONW_0::default(),
+    }
+}
+
+fn list(options: &mut [INTERNET_PER_CONN_OPTIONW]) -> Result {
+    Ok(INTERNET_PER_CONN_OPTION_LISTW {
+        dwSize: u32::try_from(size_of::())?,
+        pszConnection: PWSTR::null(),
+        dwOptionCount: u32::try_from(options.len())?,
+        dwOptionError: 0,
+        pOptions: options.as_mut_ptr(),
+    })
+}
+
+// Query allocates the three strings with GlobalAlloc, including when a later
+// conversion fails. Never log their contents (PAC URLs may contain credentials).
+struct Query([INTERNET_PER_CONN_OPTIONW; 4]);
+impl Drop for Query {
+    fn drop(&mut self) {
+        for option in &self.0[1..] {
+            // These entries are string options, zero-initialized before querying.
+            unsafe {
+                let pointer = option.Value.pszValue.0;
+                if !pointer.is_null() {
+                    let _ = GlobalFree(Some(HGLOBAL(pointer.cast())));
+                }
+            }
+        }
+    }
+}
+
+fn string(option: &INTERNET_PER_CONN_OPTIONW) -> Result {
+    // Only called on successfully queried string options. WinINet provides a
+    // NUL-terminated UTF-16 allocation owned by Query until conversion completes.
+    unsafe {
+        let value = option.Value.pszValue;
+        if value.is_null() {
+            return Ok(String::new());
+        }
+        value
+            .to_string()
+            .map_err(|_| anyhow::anyhow!("invalid UTF-16 system proxy setting"))
+    }
+}
+
+impl Backend for Native {
+    type Snapshot = Config;
+
+    fn read() -> Result {
+        let mut query = Query([
+            option(INTERNET_PER_CONN_FLAGS_UI),
+            option(INTERNET_PER_CONN_PROXY_SERVER),
+            option(INTERNET_PER_CONN_PROXY_BYPASS),
+            option(INTERNET_PER_CONN_AUTOCONFIG_URL),
+        ]);
+        let mut list = list(&mut query.0)?;
+        let mut length = list.dwSize;
+        // Null session/connection selects current-user LAN settings. FLAGS_UI
+        // preserves the user's WPAD choice rather than its last detection result.
+        native_result(unsafe {
+            InternetQueryOptionW(
+                None,
+                INTERNET_OPTION_PER_CONNECTION_OPTION,
+                Some(ptr::from_mut(&mut list).cast()),
+                &raw mut length,
+            )
+        })?;
+        ensure!(
+            list.dwOptionError == 0,
+            "WinINet could not read a proxy option"
+        );
+        Ok(Config {
+            // The first option is the DWORD flags, not an allocated string.
+            flags: unsafe { query.0[0].Value.dwValue },
+            server: string(&query.0[1])?,
+            bypass: string(&query.0[2])?,
+            pac: string(&query.0[3])?,
+        })
+    }
+
+    fn services(snapshot: &Config) -> Result> {
+        Ok(vec![snapshot.status()?])
+    }
+
+    fn prepare(snapshot: &Config, enabled: bool) -> Result {
+        snapshot.prepare(enabled)
+    }
+
+    fn write(before: &Config, after: &Config) -> Result<()> {
+        // WinINet has no compare-and-swap API; this narrows but cannot eliminate
+        // races with other processes. Never write bypass or the retained PAC URL.
+        ensure!(
+            Self::read()? == *before,
+            "system proxy settings changed; try again"
+        );
+        let mut server: Vec = after.server.encode_utf16().chain([0]).collect();
+        let mut options = [
+            INTERNET_PER_CONN_OPTIONW {
+                dwOption: INTERNET_PER_CONN_FLAGS,
+                Value: INTERNET_PER_CONN_OPTIONW_0 {
+                    dwValue: after.flags,
+                },
+            },
+            INTERNET_PER_CONN_OPTIONW {
+                dwOption: INTERNET_PER_CONN_PROXY_SERVER,
+                Value: INTERNET_PER_CONN_OPTIONW_0 {
+                    pszValue: PWSTR(server.as_mut_ptr()),
+                },
+            },
+        ];
+        let mut list = list(&mut options)?;
+        // list, options and server remain alive through the synchronous call.
+        native_result(unsafe {
+            InternetSetOptionW(
+                None,
+                INTERNET_OPTION_PER_CONNECTION_OPTION,
+                Some(ptr::from_mut(&mut list).cast()),
+                list.dwSize,
+            )
+        })?;
+        ensure!(
+            list.dwOptionError == 0,
+            "WinINet could not apply a proxy option"
+        );
+        // Attempt both notifications even if the first fails; do not roll back
+        // successful writes or report them as an unchanged configuration.
+        let changed = native_result(unsafe {
+            InternetSetOptionW(None, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, None, 0)
+        });
+        let refresh =
+            native_result(unsafe { InternetSetOptionW(None, INTERNET_OPTION_REFRESH, None, 0) });
+        changed.and(refresh)
+    }
+}
diff --git a/crates/wizard-system-proxy/src/windows_config.rs b/crates/wizard-system-proxy/src/windows_config.rs
new file mode 100644
index 0000000..77fcae3
--- /dev/null
+++ b/crates/wizard-system-proxy/src/windows_config.rs
@@ -0,0 +1,128 @@
+//! `WinINet`'s string grammar and flags, compiled and tested on every platform.
+#![cfg_attr(not(any(target_os = "windows", test)), expect(dead_code))]
+
+use crate::{ProxyState, ServiceStatus, endpoint_state};
+use anyhow::{Result, ensure};
+
+// WinINet PROXY_TYPE_* values. Kept here so policy tests need no Windows API.
+pub(super) const DIRECT: u32 = 1;
+pub(super) const PROXY: u32 = 2;
+const AUTO_URL: u32 = 4;
+const AUTO_DETECT: u32 = 8;
+
+#[derive(Clone, PartialEq, Eq)]
+pub(super) struct Config {
+    pub flags: u32,
+    pub server: String,
+    pub bypass: String,
+    pub pac: String,
+}
+
+fn entries(server: &str) -> Result> {
+    if !server.contains('=') {
+        ensure!(
+            !server.contains(';'),
+            "unsupported system proxy server syntax"
+        );
+        return Ok(vec![("", server.trim())]);
+    }
+    let mut entries = Vec::new();
+    for entry in server.split(';').filter(|s| !s.trim().is_empty()) {
+        let (protocol, endpoint) = entry
+            .split_once('=')
+            .ok_or_else(|| anyhow::anyhow!("unsupported system proxy server syntax"))?;
+        let protocol = protocol.trim();
+        let endpoint = endpoint.trim();
+        ensure!(
+            !protocol.is_empty()
+                && !endpoint.is_empty()
+                && !entries
+                    .iter()
+                    .any(|(p, _): &(&str, &str)| p.eq_ignore_ascii_case(protocol)),
+            "invalid or duplicate system proxy protocol"
+        );
+        entries.push((protocol, endpoint));
+    }
+    ensure!(!entries.is_empty(), "empty system proxy protocol list");
+    Ok(entries)
+}
+
+impl Config {
+    pub fn status(&self) -> Result {
+        let active = self.flags & PROXY != 0;
+        let parsed = if active {
+            entries(&self.server)?
+        } else {
+            Vec::new()
+        };
+        let endpoint = |protocol: &str| {
+            parsed
+                .iter()
+                .find(|(p, _)| p.is_empty() || p.eq_ignore_ascii_case(protocol))
+                .map_or("", |(_, value)| *value)
+        };
+        let http = endpoint_state(active && !endpoint("http").is_empty(), endpoint("http"));
+        let https = endpoint_state(active && !endpoint("https").is_empty(), endpoint("https"));
+        let other = parsed.iter().any(|(p, _)| {
+            !p.is_empty() && !p.eq_ignore_ascii_case("http") && !p.eq_ignore_ascii_case("https")
+        });
+        Ok(ServiceStatus {
+            id: "current-user-lan".into(),
+            http,
+            https,
+            conflict: http == ProxyState::Foreign
+                || https == ProxyState::Foreign
+                || other
+                || (active && self.server.trim().is_empty())
+                || self.flags & (AUTO_URL | AUTO_DETECT) != 0
+                || self.flags & !(DIRECT | PROXY | AUTO_URL | AUTO_DETECT) != 0,
+        })
+    }
+
+    pub fn prepare(&self, enabled: bool) -> Result {
+        let mut after = self.clone();
+        if enabled {
+            let address = wizard_proxy::CLAUDE_DESKTOP_ADDRESS;
+            // WinINet has a single manual-proxy switch; retaining a dormant SOCKS
+            // or FTP mapping would activate it. Refuse rather than erase it.
+            if self.flags & PROXY == 0 && self.server.contains('=') {
+                ensure!(
+                    !entries(&self.server)?
+                        .iter()
+                        .any(|(p, _)| !p.eq_ignore_ascii_case("http")
+                            && !p.eq_ignore_ascii_case("https")),
+                    "inactive non-HTTP proxy mappings would be activated; clear them first"
+                );
+            }
+            after.server = format!("http={address};https={address}");
+            after.flags |= DIRECT | PROXY;
+        } else if self.flags & PROXY != 0 {
+            let parsed = entries(&self.server)?;
+            if parsed.len() == 1 && parsed[0].0.is_empty() {
+                if parsed[0].1 == wizard_proxy::CLAUDE_DESKTOP_ADDRESS {
+                    after.flags = (after.flags & !PROXY) | DIRECT;
+                }
+            } else {
+                let remaining: Vec<_> = self
+                    .server
+                    .split(';')
+                    .filter(|entry| {
+                        let Some((protocol, address)) = entry.split_once('=') else {
+                            return true;
+                        };
+                        !((protocol.trim().eq_ignore_ascii_case("http")
+                            || protocol.trim().eq_ignore_ascii_case("https"))
+                            && address.trim() == wizard_proxy::CLAUDE_DESKTOP_ADDRESS)
+                    })
+                    .collect();
+                let server = remaining.join(";");
+                if server.trim_matches(';').trim().is_empty() {
+                    after.flags = (after.flags & !PROXY) | DIRECT;
+                } else {
+                    after.server = server;
+                }
+            }
+        }
+        Ok(after)
+    }
+}

From f01866cdfacc4349b6f61a5fa31ab4657b413eda Mon Sep 17 00:00:00 2001
From: Odrian 
Date: Fri, 30 Oct 2026 18:46:16 +0300
Subject: [PATCH 7/8] system proxy frontend

---
 Cargo.lock                                    |   1 +
 Cargo.toml                                    |   1 +
 crates/wizard-gui/Cargo.toml                  |   1 +
 crates/wizard-gui/src/lib.rs                  |  46 +++-
 crates/wizard-gui/src/proxy.rs                |  10 +
 crates/wizard-gui/src/system_proxy.rs         | 236 ++++++++++++++++++
 crates/wizard-gui/src/system_proxy/tests.rs   | 122 +++++++++
 crates/wizard-gui/src/tray.rs                 | 112 ++++++++-
 .../wizard-ui/src/settings/SettingsPage.tsx   |  55 ++++
 .../wizard-ui/src/settings/system_proxy.ts    |  48 ++++
 10 files changed, 624 insertions(+), 8 deletions(-)
 create mode 100644 crates/wizard-gui/src/system_proxy.rs
 create mode 100644 crates/wizard-gui/src/system_proxy/tests.rs
 create mode 100644 crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts

diff --git a/Cargo.lock b/Cargo.lock
index 909094b..33ec032 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5491,6 +5491,7 @@ dependencies = [
  "wizard-proxy",
  "wizard-proxy-daemon",
  "wizard-settings",
+ "wizard-system-proxy",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index ecae8b1..43d1b52 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,7 @@ wizard-core = { path = "crates/wizard-core" }
 wizard-proxy = { path = "crates/wizard-proxy" }
 wizard-proxy-daemon = { path = "crates/wizard-proxy-daemon" }
 wizard-settings = { path = "crates/wizard-settings" }
+wizard-system-proxy = { path = "crates/wizard-system-proxy" }
 
 anyhow = "1.0.104"
 chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock"] }
diff --git a/crates/wizard-gui/Cargo.toml b/crates/wizard-gui/Cargo.toml
index 66aa0f2..26f1815 100644
--- a/crates/wizard-gui/Cargo.toml
+++ b/crates/wizard-gui/Cargo.toml
@@ -36,6 +36,7 @@ wizard-core.workspace = true
 wizard-proxy.workspace = true
 wizard-proxy-daemon.workspace = true
 wizard-settings.workspace = true
+wizard-system-proxy.workspace = true
 
 [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
 tauri = { workspace = true, features = ["tray-icon"] }
diff --git a/crates/wizard-gui/src/lib.rs b/crates/wizard-gui/src/lib.rs
index e9c6e56..080fa54 100644
--- a/crates/wizard-gui/src/lib.rs
+++ b/crates/wizard-gui/src/lib.rs
@@ -17,6 +17,7 @@ mod logs;
 mod proxy;
 mod revision_signal;
 mod settings;
+mod system_proxy;
 #[cfg(any(target_os = "macos", target_os = "windows"))]
 mod tray;
 mod updater;
@@ -72,6 +73,9 @@ pub fn run_application() -> Result<()> {
         .plugin(tauri_plugin_updater::Builder::new().build())
         .manage(Arc::new(claude_desktop_install::InstallManager::default()))
         .manage(proxy::ProxyState::default())
+        .manage(revision_signal::RevisionSignal::new(
+            system_proxy::State::default(),
+        ))
         .manage(Arc::new(revision_signal::RevisionSignal::new(
             updater::UpdateState::Checking,
         )))
@@ -86,6 +90,7 @@ pub fn run_application() -> Result<()> {
             hub_tokens::HubTokensManager::initialize(app.handle())?;
             #[cfg(any(target_os = "macos", target_os = "windows"))]
             let tray_enabled = {
+                app.manage(tray::TrayState::default());
                 let preferences = settings::get_state(app.handle())?.application;
                 autostart::initialize(app.handle())?;
                 let autostart = autostart::set_enabled(app.handle(), preferences.autostart_enabled);
@@ -106,6 +111,7 @@ pub fn run_application() -> Result<()> {
             }
             proxy::start_observer(app.handle());
 
+            system_proxy::start(app.handle());
             if !minimized || !tray_enabled {
                 request_main_window(app.handle());
             }
@@ -118,6 +124,8 @@ pub fn run_application() -> Result<()> {
             claude_desktop_install::check_claude_desktop_release,
             claude_desktop_install::download_claude_desktop_release,
             claude_desktop_install::install_claude_desktop_release,
+            system_proxy::get_system_proxy_state,
+            system_proxy::set_system_proxy,
             settings::get_application_settings,
             settings::set_application_setting,
             logs::open_logs,
@@ -154,23 +162,38 @@ pub fn run_application() -> Result<()> {
         }
         #[cfg(target_os = "macos")]
         tauri::RunEvent::Reopen { .. } => request_main_window(app),
-        tauri::RunEvent::Exit => proxy::stop_observer(app),
+        tauri::RunEvent::Exit => {
+            system_proxy::stop(app);
+            proxy::stop_observer(app);
+        }
         _ => {}
     });
     Ok(())
 }
 
 fn request_main_window(app: &tauri::AppHandle) {
+    request_window(app, false);
+}
+
+#[cfg_attr(
+    not(any(target_os = "macos", target_os = "windows")),
+    expect(dead_code)
+)]
+fn request_settings_window(app: &tauri::AppHandle) {
+    request_window(app, true);
+}
+
+fn request_window(app: &tauri::AppHandle, show_settings: bool) {
     let app = app.clone();
     // WebView2 creation must not block a synchronous event callback on Windows.
     tauri::async_runtime::spawn_blocking(move || {
-        if let Err(error) = open_main_window(&app) {
+        if let Err(error) = open_main_window(&app, show_settings) {
             log::error!("failed to open main window: {error:#}");
         }
     });
 }
 
-fn open_main_window(app: &tauri::AppHandle) -> Result<()> {
+fn open_main_window(app: &tauri::AppHandle, show_settings: bool) -> Result<()> {
     let _guard = MAIN_WINDOW_LOCK
         .lock()
         .map_err(|_| anyhow!("main window lock poisoned"))?;
@@ -181,9 +204,20 @@ fn open_main_window(app: &tauri::AppHandle) -> Result<()> {
         .iter()
         .find(|window| window.label == MAIN_WINDOW_NAME)
         .context("main window configuration not found")?;
-    let window = match app.get_webview_window(MAIN_WINDOW_NAME) {
-        Some(window) => window,
-        None => WebviewWindowBuilder::from_config(app, config)?.build()?,
+    let window = if let Some(window) = app.get_webview_window(MAIN_WINDOW_NAME) {
+        if show_settings {
+            // Static trusted-local navigation, never interpolate command data.
+            window.eval("window.location.hash = '/settings';")?;
+        }
+        window
+    } else {
+        let mut builder = WebviewWindowBuilder::from_config(app, config)?;
+        if show_settings {
+            // Runs before React reads the initial hash, including on a newly
+            // recreated WebView. No event can be lost before UI subscription.
+            builder = builder.initialization_script("window.location.hash = '/settings';");
+        }
+        builder.build()?
     };
 
     window.set_title(&format!("{} v{}", config.title, app.package_info().version))?;
diff --git a/crates/wizard-gui/src/proxy.rs b/crates/wizard-gui/src/proxy.rs
index 42d8f30..47061b9 100644
--- a/crates/wizard-gui/src/proxy.rs
+++ b/crates/wizard-gui/src/proxy.rs
@@ -13,6 +13,16 @@ pub(crate) struct Proxy {
     chatgpt: bool,
 }
 
+pub(crate) fn claude_desktop_running(app: &tauri::AppHandle) -> bool {
+    // Report the observed daemon state, not saved demand or a GUI-owned listener.
+    // Unknown status must not be presented as a working proxy.
+    app.state::()
+        .signal
+        .current()
+        .state
+        .is_ok_and(|proxy| proxy.claude_desktop)
+}
+
 pub(crate) struct ProxyState {
     // Serialize observations with sync so an earlier status cannot overwrite a
     // status fetched after Reload. This does not own the daemon's lifecycle.
diff --git a/crates/wizard-gui/src/system_proxy.rs b/crates/wizard-gui/src/system_proxy.rs
new file mode 100644
index 0000000..0de90d3
--- /dev/null
+++ b/crates/wizard-gui/src/system_proxy.rs
@@ -0,0 +1,236 @@
+//! OS-backed state shared by settings and tray. No saved flag or automatic writes.
+
+use crate::revision_signal::{RevisionSignal, RevisionSnapshot};
+use anyhow::Result;
+use core::time::Duration;
+use serde::Serialize;
+use std::sync::Mutex;
+use tauri::Manager as _;
+use wizard_system_proxy::{self as os, ProxyState, SystemProxyStatus};
+
+static OPERATION: Mutex<()> = Mutex::new(());
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum Status {
+    #[default]
+    Unknown,
+    Off,
+    On,
+    Partial,
+    Unavailable,
+    Unsupported,
+}
+
+#[derive(Clone, Default, PartialEq, Eq, Serialize)]
+pub(crate) struct State {
+    pub(crate) status: Status,
+    pub(crate) busy: bool,
+    pub(crate) conflict: bool,
+    pub(crate) listener_running: bool,
+    read_error: Option,
+    operation_error: Option,
+}
+
+#[cfg_attr(
+    not(any(target_os = "macos", target_os = "windows", test)),
+    expect(dead_code)
+)]
+impl State {
+    pub(crate) fn has_ours(&self) -> bool {
+        matches!(self.status, Status::On | Status::Partial)
+    }
+
+    pub(crate) fn can_toggle(&self) -> bool {
+        !self.busy && (self.has_ours() || (self.status == Status::Off && !self.conflict))
+    }
+}
+
+pub(crate) fn current(app: &tauri::AppHandle) -> State {
+    app.state::>().current().state
+}
+
+fn update(signal: &RevisionSignal, edit: impl FnOnce(&mut State)) {
+    signal.update(|current| {
+        let mut next = current.clone();
+        edit(&mut next);
+        (next != *current).then_some(next)
+    });
+}
+
+fn begin(signal: &RevisionSignal) -> Result<(), String> {
+    signal
+        .update(|current| {
+            (!current.busy).then(|| State {
+                busy: true,
+                operation_error: None,
+                ..current.clone()
+            })
+        })
+        .map(|_| ())
+        .ok_or_else(|| "Изменение системного прокси уже выполняется".into())
+}
+
+fn observe(state: &mut State, result: Result, listener_running: bool) {
+    state.listener_running = listener_running;
+    state.conflict = false;
+    state.read_error = None;
+    match result {
+        Ok(snapshot) => {
+            state.conflict = snapshot.services.iter().any(|s| s.conflict);
+            let any = snapshot
+                .services
+                .iter()
+                .any(|s| s.http == ProxyState::Wizard || s.https == ProxyState::Wizard);
+            let all = snapshot
+                .services
+                .iter()
+                .all(|s| s.http == ProxyState::Wizard && s.https == ProxyState::Wizard);
+            state.status = if snapshot.services.is_empty() {
+                Status::Unavailable
+            } else if all && !state.conflict {
+                Status::On
+            } else if any {
+                Status::Partial
+            } else {
+                Status::Off
+            };
+        }
+        Err(error) => {
+            state.status = Status::Unknown;
+            state.read_error = Some(format!("Не удалось прочитать системный прокси: {error:#}"));
+        }
+    }
+}
+
+fn sync_tray(app: &tauri::AppHandle) {
+    #[cfg(any(target_os = "macos", target_os = "windows"))]
+    if crate::tray::sync_system_proxy(app).is_err() {
+        log::error!("failed to update system proxy tray item");
+    }
+    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
+    let _ = app;
+}
+
+async fn run(app: tauri::AppHandle, enabled: Option) -> Result<(), String> {
+    let worker_app = app.clone();
+    let result = tauri::async_runtime::spawn_blocking(move || {
+        let signal = worker_app.state::>();
+        if enabled.is_some() {
+            begin(&signal)?;
+            sync_tray(&worker_app);
+        }
+        let result = (|| {
+            // Mutations wait for a preceding read. Observations skip busy work.
+            let _guard = if enabled.is_some() {
+                OPERATION.lock().map_err(|_| "Операции системного прокси недоступны; перезапустите Wizard".to_owned())?
+            } else {
+                let Ok(guard) = OPERATION.try_lock() else { return Ok(()); };
+                if signal.current().state.busy { return Ok(()); }
+                guard
+            };
+            let write = enabled.map(|enabled| if enabled { os::enable() } else { os::disable() });
+            let status = os::status(); // Always reread, even after a failed write.
+            update(&signal, |state| {
+                observe(state, status, crate::proxy::claude_desktop_running(&worker_app));
+                if let Some(write) = write {
+                    state.operation_error = write.err().map(|error| format!("Не удалось изменить системный прокси: {error:#}. Настройки могли примениться частично."));
+                }
+            });
+            let state = current(&worker_app);
+            state.operation_error.or(state.read_error).map_or(Ok(()), Err)
+        })();
+        if enabled.is_some() {
+            update(&signal, |state| {
+                state.busy = false;
+                if let Err(error) = &result
+                    && state.operation_error.is_none() && state.read_error.is_none() {
+                    state.operation_error = Some(error.clone());
+                }
+            });
+        }
+        sync_tray(&worker_app);
+        result
+    }).await;
+    // Publication stays inside the worker, even if the IPC caller disappears.
+    result.unwrap_or_else(|_| {
+        let message = "Операция системного прокси прервана. Состояние ОС неизвестно.";
+        update(&app.state::>(), |state| {
+            if enabled.is_some() {
+                state.busy = false;
+            }
+            state.status = Status::Unknown;
+            state.read_error = Some(message.into());
+        });
+        sync_tray(&app);
+        Err(message.into())
+    })
+}
+
+struct Observer(tauri::async_runtime::JoinHandle<()>);
+impl Drop for Observer {
+    fn drop(&mut self) {
+        self.0.abort();
+    }
+}
+
+pub(crate) fn start(app: &tauri::AppHandle) {
+    if !cfg!(any(target_os = "macos", target_os = "windows")) {
+        update(&app.state::>(), |state| {
+            state.status = Status::Unsupported;
+        });
+        return;
+    }
+    let worker_app = app.clone();
+    app.manage(Observer(tauri::async_runtime::spawn(async move {
+        loop {
+            let _ = run(worker_app.clone(), None).await;
+            tokio::time::sleep(Duration::from_secs(2)).await;
+        }
+    })));
+}
+
+pub(crate) fn stop(app: &tauri::AppHandle) {
+    if let Some(observer) = app.try_state::() {
+        observer.0.abort();
+    }
+}
+
+#[tauri::command]
+pub(crate) async fn get_system_proxy_state(
+    last_revision: Option,
+    signal: tauri::State<'_, RevisionSignal>,
+) -> Result, String> {
+    signal
+        .wait(last_revision)
+        .await
+        .map_err(|_| "Не удалось получить состояние системного прокси".into())
+}
+
+#[tauri::command]
+pub(crate) async fn set_system_proxy(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
+    if !cfg!(any(target_os = "macos", target_os = "windows")) {
+        return Err("Системный прокси пока доступен только на macOS и Windows".into());
+    }
+    run(app, Some(enabled)).await
+}
+
+#[cfg(any(target_os = "macos", target_os = "windows"))]
+pub(crate) fn toggle_from_tray(app: &tauri::AppHandle) {
+    let state = current(app);
+    if !state.can_toggle() {
+        return;
+    }
+    let app = app.clone();
+    tauri::async_runtime::spawn(async move {
+        if set_system_proxy(app.clone(), !state.has_ours())
+            .await
+            .is_err()
+        {
+            crate::request_settings_window(&app);
+        }
+    });
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/wizard-gui/src/system_proxy/tests.rs b/crates/wizard-gui/src/system_proxy/tests.rs
new file mode 100644
index 0000000..ee4a8f2
--- /dev/null
+++ b/crates/wizard-gui/src/system_proxy/tests.rs
@@ -0,0 +1,122 @@
+use super::*;
+use alloc::sync::Arc;
+use std::sync::Barrier;
+use wizard_system_proxy::ServiceStatus;
+
+fn snapshot(http: ProxyState, https: ProxyState, conflict: bool) -> SystemProxyStatus {
+    SystemProxyStatus {
+        services: vec![ServiceStatus {
+            id: "fixture".into(),
+            http,
+            https,
+            conflict,
+        }],
+    }
+}
+
+#[test]
+fn aggregates_os_settings_without_treating_a_stopped_listener_as_a_conflict() {
+    use ProxyState::{Disabled, Foreign, Wizard};
+    for (http, https, conflict, status, can_toggle) in [
+        (Disabled, Disabled, false, Status::Off, true),
+        (Foreign, Foreign, true, Status::Off, false),
+        (Wizard, Wizard, false, Status::On, true),
+        (Wizard, Wizard, true, Status::Partial, true),
+        (Wizard, Foreign, true, Status::Partial, true),
+        (Wizard, Disabled, false, Status::Partial, true),
+    ] {
+        let mut state = State::default();
+        observe(&mut state, Ok(snapshot(http, https, conflict)), false);
+        assert_eq!(state.status, status);
+        assert_eq!(state.can_toggle(), can_toggle);
+    }
+    let mut state = State::default();
+    let mut mixed = snapshot(Wizard, Wizard, false);
+    mixed
+        .services
+        .extend(snapshot(Disabled, Disabled, false).services);
+    observe(&mut state, Ok(mixed), true);
+    assert_eq!(state.status, Status::Partial);
+    observe(&mut state, Ok(SystemProxyStatus { services: vec![] }), true);
+    assert_eq!(state.status, Status::Unavailable);
+    assert!(!state.can_toggle());
+}
+
+#[test]
+fn failed_read_is_unknown_and_polling_preserves_operation_errors() {
+    let mut state = State {
+        operation_error: Some("fixture operation failure".into()),
+        ..State::default()
+    };
+    observe(
+        &mut state,
+        Err(anyhow::anyhow!("fixture read failure")),
+        true,
+    );
+    assert_eq!(state.status, Status::Unknown);
+    assert!(state.read_error.is_some());
+    assert!(!state.can_toggle());
+    observe(
+        &mut state,
+        Ok(snapshot(ProxyState::Wizard, ProxyState::Wizard, false)),
+        true,
+    );
+    assert_eq!(state.status, Status::On);
+    assert!(state.read_error.is_none());
+    assert!(state.operation_error.is_some());
+    let signal = RevisionSignal::new(state);
+    assert!(begin(&signal).is_ok());
+    assert!(signal.current().state.operation_error.is_none());
+}
+
+#[test]
+fn late_observation_cannot_clear_busy_or_admit_another_mutation() {
+    let signal = RevisionSignal::new(State::default());
+    let read_before_mutation = snapshot(ProxyState::Disabled, ProxyState::Disabled, false);
+    assert!(begin(&signal).is_ok());
+    update(&signal, |state| {
+        observe(state, Ok(read_before_mutation), true);
+    });
+    assert!(signal.current().state.busy);
+    assert!(!signal.current().state.can_toggle());
+    assert!(begin(&signal).is_err());
+    update(&signal, |state| state.busy = false);
+    assert!(begin(&signal).is_ok());
+}
+
+#[test]
+fn simultaneous_clicks_admit_only_one_mutation() {
+    let signal = Arc::new(RevisionSignal::new(State::default()));
+    let barrier = Arc::new(Barrier::new(4));
+    let handles: Vec<_> = (0..4)
+        .map(|_| {
+            let signal = Arc::clone(&signal);
+            let barrier = Arc::clone(&barrier);
+            std::thread::spawn(move || {
+                barrier.wait();
+                begin(&signal).is_ok()
+            })
+        })
+        .collect();
+    let accepted = handles
+        .into_iter()
+        .map(|h| h.join().expect("admission worker"))
+        .filter(|&ok| ok)
+        .count();
+    assert_eq!(accepted, 1);
+}
+
+#[test]
+fn unchanged_observations_do_not_publish() {
+    let signal = RevisionSignal::new(State::default());
+    for _ in 0..2 {
+        update(&signal, |state| {
+            observe(
+                state,
+                Ok(snapshot(ProxyState::Wizard, ProxyState::Wizard, false)),
+                true,
+            );
+        });
+    }
+    assert_eq!(signal.current().revision, 1);
+}
diff --git a/crates/wizard-gui/src/tray.rs b/crates/wizard-gui/src/tray.rs
index 3d668cc..c9b10f5 100644
--- a/crates/wizard-gui/src/tray.rs
+++ b/crates/wizard-gui/src/tray.rs
@@ -1,15 +1,29 @@
 use anyhow::{Context as _, Result};
+use std::sync::Mutex;
+use tauri::Manager as _;
 use tauri::menu::{Menu, MenuEvent, MenuItem};
 use tauri::tray::TrayIconBuilder;
 
 const TRAY_ID: &str = "main";
 const OPEN_ID: &str = "wizard-open";
 const EXIT_ID: &str = "wizard-exit";
+const SYSTEM_PROXY_ID: &str = "wizard-system-proxy";
+const PROXY_WARNING_ID: &str = "wizard-system-proxy-warning";
+
+struct ProxyMenu {
+    menu: Menu,
+    action: MenuItem,
+    warning: MenuItem,
+}
+
+#[derive(Default)]
+pub(crate) struct TrayState(Mutex>);
 
 pub(crate) fn on_menu_event(app: &tauri::AppHandle, event: &MenuEvent) {
     match event.id().as_ref() {
         OPEN_ID => crate::request_main_window(app),
         EXIT_ID => app.exit(0),
+        SYSTEM_PROXY_ID => crate::system_proxy::toggle_from_tray(app),
         _ => {}
     }
 }
@@ -34,6 +48,11 @@ fn set_enabled_on_main_thread(app: &tauri::AppHandle, enabled: bool) -> Result<(
     if !enabled {
         // The settings writer decides whether to exit after all OS effects finish.
         app.remove_tray_by_id(TRAY_ID);
+        app.state::()
+            .0
+            .lock()
+            .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))?
+            .take();
         return Ok(());
     }
     if is_enabled(app) {
@@ -42,16 +61,105 @@ fn set_enabled_on_main_thread(app: &tauri::AppHandle, enabled: bool) -> Result<(
 
     let open = MenuItem::with_id(app, OPEN_ID, "Открыть Wizard", true, None::<&str>)?;
     let exit = MenuItem::with_id(app, EXIT_ID, "Выход", true, None::<&str>)?;
-    let menu = Menu::with_items(app, &[&open, &exit])?;
+    let state = crate::system_proxy::current(app);
+    let system_proxy = MenuItem::with_id(
+        app,
+        SYSTEM_PROXY_ID,
+        system_proxy_label(&state),
+        state.can_toggle(),
+        None::<&str>,
+    )?;
+    let warning = MenuItem::with_id(
+        app,
+        PROXY_WARNING_ID,
+        "Локальный прокси Claude Desktop остановлен",
+        false,
+        None::<&str>,
+    )?;
+    let menu = Menu::with_items(app, &[&open, &system_proxy, &exit])?;
+    let items = ProxyMenu {
+        menu,
+        action: system_proxy,
+        warning,
+    };
+    update_proxy_menu(&items, &state)?;
     let icon = app
         .default_window_icon()
         .context("application icon is missing")?;
 
     TrayIconBuilder::with_id(TRAY_ID)
         .icon(icon.clone())
-        .menu(&menu)
+        .menu(&items.menu)
         .show_menu_on_left_click(true)
         .build(app)
         .context("failed to create Wizard tray icon")?;
+    *app.state::()
+        .0
+        .lock()
+        .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))? = Some(items);
     Ok(())
 }
+
+fn system_proxy_label(state: &crate::system_proxy::State) -> &'static str {
+    use crate::system_proxy::Status;
+    if state.busy {
+        return "Системный прокси — применение…";
+    }
+    match state.status {
+        Status::Unknown => "Системный прокси — состояние неизвестно",
+        Status::Unavailable => "Системный прокси — нет сетевых сервисов",
+        Status::Unsupported => "Системный прокси — недоступен",
+        Status::On | Status::Partial => "Выключить системный прокси",
+        Status::Off => "Включить системный прокси",
+    }
+}
+
+fn update_proxy_menu(items: &ProxyMenu, state: &crate::system_proxy::State) -> Result<()> {
+    let label = system_proxy_label(state);
+    if items.action.text()? != label {
+        items.action.set_text(label)?;
+    }
+    if items.action.is_enabled()? != state.can_toggle() {
+        items.action.set_enabled(state.can_toggle())?;
+    }
+    let warning_visible = items.menu.get(PROXY_WARNING_ID).is_some();
+    let show_warning = !state.listener_running
+        && matches!(
+            state.status,
+            crate::system_proxy::Status::Off
+                | crate::system_proxy::Status::On
+                | crate::system_proxy::Status::Partial
+        );
+    if show_warning && !warning_visible {
+        items.menu.insert(&items.warning, 2)?;
+    } else if !show_warning && warning_visible {
+        items.menu.remove(&items.warning)?;
+    }
+    Ok(())
+}
+
+/// Always read the newest backend state on the main thread, not a captured
+/// snapshot that could arrive after a newer operation or tray recreation.
+pub(crate) fn sync_system_proxy(app: &tauri::AppHandle) -> Result<()> {
+    let (sender, receiver) = std::sync::mpsc::sync_channel(1);
+    let handle = app.clone();
+    app.run_on_main_thread(move || {
+        let result = (|| {
+            let state = crate::system_proxy::current(&handle);
+            let tray = handle.state::();
+            let item = tray
+                .0
+                .lock()
+                .map_err(|_| anyhow::anyhow!("tray state lock poisoned"))?;
+            if let Some(items) = item.as_ref() {
+                update_proxy_menu(items, &state)?;
+            }
+            Ok(())
+        })();
+        let _ = sender.send(result);
+    })
+    .context("failed to dispatch system proxy tray update")?;
+    receiver
+        .recv()
+        .context("system proxy tray update did not complete")?
+}
diff --git a/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx b/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
index 1202753..56443cc 100644
--- a/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
+++ b/crates/wizard-gui/wizard-ui/src/settings/SettingsPage.tsx
@@ -7,6 +7,7 @@ import { Switch, SwitchField, SwitchGroup } from "../components/catalyst/switch"
 import { Text } from "../components/catalyst/text";
 import { ErrorText } from "../components/ErrorText";
 import { type ApplicationSetting, setApplicationSetting, useApplicationSettings } from "./application_settings";
+import { setSystemProxy, useSystemProxy } from "./system_proxy";
 
 export function SettingsPage() {
   const { settings, error: loadError } = useApplicationSettings();
@@ -62,6 +63,60 @@ export function SettingsPage() {
           
         )}
       
+      
     
   );
 }
+
+function SystemProxySettings() {
+  const { state, error: loadError } = useSystemProxy();
+  const inFlight = useRef(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [commandError, setCommandError] = useState();
+  const hasOurs = state?.status === "on" || state?.status === "partial";
+  const known = hasOurs || state?.status === "off";
+  const busy = submitting || state?.busy === true;
+  const disabled = busy || Boolean(loadError) || !known || (!hasOurs && state?.conflict === true);
+
+  async function change(enabled: boolean) {
+    if (inFlight.current) return;
+    inFlight.current = true;
+    setSubmitting(true);
+    setCommandError(undefined);
+    try {
+      await setSystemProxy(enabled);
+    } catch (cause: unknown) {
+      setCommandError(typeof cause === "string" ? cause : "Не удалось изменить системный прокси");
+    } finally {
+      inFlight.current = false;
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    
+ + + Wizard будет проксировать сайты OpenAI и Claude, остальной интернет будет работать как раньше. + { void change(enabled); }} /> + +
+ {busy && Применение системных настроек… На macOS может потребоваться авторизация.} + {(!state || (state.status === "unknown" && !state.read_error)) && !loadError && Определение состояния…} + {state?.status === "unsupported" && Системный прокси пока доступен только на macOS и Windows.} + {state?.status === "unavailable" && Нет поддерживаемых сетевых сервисов.} + {state?.status === "partial" && ( + Частично включён: настройки Wizard действуют не для всех протоколов или сетевых сервисов либо конфликтуют с другими настройками. Выключение уберёт только настройки Wizard. + )} + {state?.conflict && Обнаружены другие настройки прокси, PAC или автоопределение. Wizard не перезаписывает их; сначала отключите их в настройках ОС.} + {known && !state?.listener_running && ( + Локальный прокси Claude Desktop остановлен. Включение разрешено, но приложения с системным прокси могут потерять доступ к сети. + )} + {hasOurs && После выхода из Wizard приложения с системным прокси могут остаться без сети до запуска локального прокси или отключения этой настройки.} + {loadError && {loadError}} + {state?.read_error && {state.read_error}} + {(state?.operation_error || commandError) && {state?.operation_error || commandError}} +
+
+ ); +} diff --git a/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts b/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts new file mode 100644 index 0000000..c39c73a --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/settings/system_proxy.ts @@ -0,0 +1,48 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +type SystemProxyState = { + status: "unknown" | "off" | "on" | "partial" | "unavailable" | "unsupported"; + busy: boolean; + conflict: boolean; + listener_running: boolean; + read_error: string | null; + operation_error: string | null; +}; +type SystemProxySnapshot = SystemProxyState & { revision: number }; + +// Only observes the shared backend state. No saved flag, automatic mutation or +// UI-owned process state; tray actions remain visible after reopening the window. +export function useSystemProxy() { + const [state, setState] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let stopped = false; + async function observe() { + let lastRevision: number | null = null; + while (!stopped) { + try { + const next: SystemProxySnapshot = await invoke("get_system_proxy_state", { lastRevision }); + if (stopped) return; + setState(next); + setError(undefined); + lastRevision = next.revision; + } catch { + if (stopped) return; + setError("Не удалось получить состояние системного прокси"); + lastRevision = null; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + void observe(); + return () => { stopped = true; }; + }, []); + + return { state, error }; +} + +export function setSystemProxy(enabled: boolean): Promise { + return invoke("set_system_proxy", { enabled }); +} From 969fe7259c5cf633d442e62d6464399c2dd6b5bc Mon Sep 17 00:00:00 2001 From: Odrian Date: Mon, 2 Nov 2026 00:34:40 +0300 Subject: [PATCH 8/8] fix ci --- crates/wizard-proxy-daemon/src/linux.rs | 5 +++++ crates/wizard-system-proxy/src/windows.rs | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/wizard-proxy-daemon/src/linux.rs b/crates/wizard-proxy-daemon/src/linux.rs index e5f9212..cb14eed 100644 --- a/crates/wizard-proxy-daemon/src/linux.rs +++ b/crates/wizard-proxy-daemon/src/linux.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::unused_async, + reason = "Linux stubs retain the async contract shared with Windows and macOS" +)] + use std::io; pub(super) use super::unix::shutdown_signal; diff --git a/crates/wizard-system-proxy/src/windows.rs b/crates/wizard-system-proxy/src/windows.rs index 55a0afb..fed34d8 100644 --- a/crates/wizard-system-proxy/src/windows.rs +++ b/crates/wizard-system-proxy/src/windows.rs @@ -9,9 +9,9 @@ use windows::{ Foundation::{GlobalFree, HGLOBAL}, Networking::WinInet::{ INTERNET_OPTION_PER_CONNECTION_OPTION, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, - INTERNET_OPTION_REFRESH, INTERNET_PER_CONN_AUTOCONFIG_URL, INTERNET_PER_CONN_FLAGS, - INTERNET_PER_CONN_FLAGS_UI, INTERNET_PER_CONN_OPTION_LISTW, INTERNET_PER_CONN_OPTIONW, - INTERNET_PER_CONN_OPTIONW_0, INTERNET_PER_CONN_PROXY_BYPASS, + INTERNET_OPTION_REFRESH, INTERNET_PER_CONN, INTERNET_PER_CONN_AUTOCONFIG_URL, + INTERNET_PER_CONN_FLAGS, INTERNET_PER_CONN_FLAGS_UI, INTERNET_PER_CONN_OPTION_LISTW, + INTERNET_PER_CONN_OPTIONW, INTERNET_PER_CONN_OPTIONW_0, INTERNET_PER_CONN_PROXY_BYPASS, INTERNET_PER_CONN_PROXY_SERVER, InternetQueryOptionW, InternetSetOptionW, }, }, @@ -24,7 +24,7 @@ fn native_result(result: windows::core::Result<()>) -> Result<()> { result.map_err(|error| anyhow::anyhow!("WinINet operation failed (HRESULT {})", error.code().0)) } -fn option(kind: u32) -> INTERNET_PER_CONN_OPTIONW { +fn option(kind: INTERNET_PER_CONN) -> INTERNET_PER_CONN_OPTIONW { INTERNET_PER_CONN_OPTIONW { dwOption: kind, Value: INTERNET_PER_CONN_OPTIONW_0::default(), @@ -77,7 +77,7 @@ impl Backend for Native { fn read() -> Result { let mut query = Query([ - option(INTERNET_PER_CONN_FLAGS_UI), + option(INTERNET_PER_CONN(INTERNET_PER_CONN_FLAGS_UI)), option(INTERNET_PER_CONN_PROXY_SERVER), option(INTERNET_PER_CONN_PROXY_BYPASS), option(INTERNET_PER_CONN_AUTOCONFIG_URL),