From 14a8df4b11cf394d5ba657e24a23716ff1b70aed Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Fri, 10 Apr 2026 13:12:04 +0300 Subject: [PATCH 1/3] Restart UI process (Tauri) after automatic update https://github.com/safing/portmaster-shadow/issues/40 --- desktop/tauri/src-tauri/src/main.rs | 14 +++ desktop/tauri/src-tauri/src/portmaster/mod.rs | 15 +++ desktop/tauri/src-tauri/src/relaunch.rs | 105 ++++++++++++++++++ desktop/tauri/src-tauri/src/traymenu.rs | 30 +++++ service/core/api.go | 7 +- service/updates/module.go | 2 +- 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 desktop/tauri/src-tauri/src/relaunch.rs diff --git a/desktop/tauri/src-tauri/src/main.rs b/desktop/tauri/src-tauri/src/main.rs index 9b4b81eb..746f2411 100644 --- a/desktop/tauri/src-tauri/src/main.rs +++ b/desktop/tauri/src-tauri/src/main.rs @@ -19,6 +19,7 @@ mod portmaster; mod traymenu; mod window; mod commands; +mod relaunch; use log::{debug, error, info}; use portmaster::PortmasterExt; @@ -61,6 +62,17 @@ impl portmaster::Handler for WsHandler { fn on_connect(&mut self, cli: portapi::client::PortAPI) { info!("connection established, creating main window"); + // If an restart-ui-process was observed before disconnect (e.g. on upgrade), + // relaunch the UI process now that the core is reachable again. + if self.handle.portmaster().consume_restart_ui_proc_requested() { + info!("restart-ui-process pending, relaunching UI process"); + if let Err(err) = relaunch::request_ui_relaunch() { + error!("failed to relaunch UI process after upgrade: {}", err); + } + self.handle.exit(0); + return; + } + // we successfully connected to Portmaster. Set is_first_connect to false // so we don't show the splash-screen when we loose connection. self.is_first_connect = false; @@ -141,6 +153,8 @@ fn show_webview_not_installed_dialog() -> i32 { } fn main() { + relaunch::run_relaunch_helper_if_requested(); + if tauri::webview_version().is_err() { std::process::exit(show_webview_not_installed_dialog()); } diff --git a/desktop/tauri/src-tauri/src/portmaster/mod.rs b/desktop/tauri/src-tauri/src/portmaster/mod.rs index 71851744..d16bd563 100644 --- a/desktop/tauri/src-tauri/src/portmaster/mod.rs +++ b/desktop/tauri/src-tauri/src/portmaster/mod.rs @@ -75,6 +75,10 @@ pub struct PortmasterInterface { // handle to the tray handler task so we can abort it when reconnecting pub tray_handler_task: Mutex>>, + + // Marks that a UI process restart event was observed (e.g. on upgrade) + // and the UI process should relaunch after the next successful reconnect. + pending_restart: AtomicBool, } impl PortmasterInterface { @@ -261,6 +265,16 @@ impl PortmasterInterface { }); } + /// Marks that a UI process restart has been requested. + pub fn mark_restart_ui_proc_requested(&self) { + self.pending_restart.store(true, Ordering::Release); + } + + /// Returns whether a UI process restart was requested and clears the flag. + pub fn consume_restart_ui_proc_requested(&self) -> bool { + self.pending_restart.swap(false, Ordering::AcqRel) + } + //// Internal functions fn start_notification_handler(&self) { if let Some(api) = self.get_api() { @@ -346,6 +360,7 @@ pub fn setup(app: AppHandle) { handle_prompts: AtomicBool::new(false), should_show_after_bootstrap: AtomicBool::new(true), tray_handler_task: Mutex::new(None), + pending_restart: AtomicBool::new(false), }; app.manage(interface); diff --git a/desktop/tauri/src-tauri/src/relaunch.rs b/desktop/tauri/src-tauri/src/relaunch.rs new file mode 100644 index 00000000..e889be3f --- /dev/null +++ b/desktop/tauri/src-tauri/src/relaunch.rs @@ -0,0 +1,105 @@ +use std::{ + ffi::OsString, + process::{Command, Stdio}, + thread, + time::Duration, +}; + +use log::{debug, error, warn}; + +const UI_RELAUNCH_HELPER_ENV: &str = "PORTMASTER_UI_RELAUNCH_HELPER"; +const RELAUNCH_RETRY_COUNT: usize = 40; +const RELAUNCH_RETRY_DELAY: Duration = Duration::from_millis(500); + +fn current_process_args() -> Vec { + std::env::args_os() + .skip(1) + .filter(|arg| { + // On upgrade-triggered relaunch we always want to show the UI, + // so do not propagate background startup flags. + arg != "--background" && arg != "-b" + }) + .collect() +} + +pub fn request_ui_relaunch() -> Result<(), String> { + let exe = std::env::current_exe() + .map_err(|err| format!("failed to get current executable path: {}", err))?; + let args = current_process_args(); + + let mut cmd = Command::new(&exe); + cmd.args(&args) + .env(UI_RELAUNCH_HELPER_ENV, "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + cmd.spawn() + .map_err(|err| format!("failed to spawn relaunch helper process: {}", err))?; + + Ok(()) +} + +pub fn run_relaunch_helper_if_requested() { + if std::env::var(UI_RELAUNCH_HELPER_ENV).ok().as_deref() != Some("1") { + return; + } + + if let Err(err) = run_relaunch_helper() { + error!("[tauri] relaunch helper failed: {}", err); + } + + std::process::exit(0); +} + +fn run_relaunch_helper() -> Result<(), String> { + let exe = std::env::current_exe() + .map_err(|err| format!("failed to get current executable path in relaunch helper: {}", err))?; + let args = current_process_args(); + + debug!("[tauri] relaunch helper started"); + + for attempt in 1..=RELAUNCH_RETRY_COUNT { + let mut cmd = Command::new(&exe); + cmd.args(&args) + .env_remove(UI_RELAUNCH_HELPER_ENV) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = cmd + .spawn() + .map_err(|err| format!("failed to spawn replacement process: {}", err))?; + + thread::sleep(RELAUNCH_RETRY_DELAY); + + match child.try_wait() { + Ok(Some(status)) => { + // Most commonly means the single-instance guard still detected a running instance. + warn!( + "[tauri] replacement process exited quickly (attempt {}/{}; status={}), retrying", + attempt, + RELAUNCH_RETRY_COUNT, + status + ); + thread::sleep(RELAUNCH_RETRY_DELAY); + } + Ok(None) => { + debug!( + "[tauri] replacement process is running (attempt {}/{})", + attempt, + RELAUNCH_RETRY_COUNT + ); + return Ok(()); + } + Err(err) => { + return Err(format!("failed to observe replacement process status: {}", err)); + } + } + } + + Err(format!( + "failed to relaunch UI process after {} attempts", + RELAUNCH_RETRY_COUNT + )) +} diff --git a/desktop/tauri/src-tauri/src/traymenu.rs b/desktop/tauri/src-tauri/src/traymenu.rs index 53c06e13..6c3dd49c 100644 --- a/desktop/tauri/src-tauri/src/traymenu.rs +++ b/desktop/tauri/src-tauri/src/traymenu.rs @@ -497,6 +497,22 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) { } }; + let mut portmaster_restart_ui_proc_event_subscription = match cli + .request(Request::Subscribe( + "query runtime:modules/core/event/restart-ui-process".to_string(), + )) + .await + { + Ok(rx) => rx, + Err(err) => { + error!( + "cancel try_handler: failed to subscribe to 'runtime:modules/core/event/restart-ui-process': {}", + err + ); + return; + } + }; + update_icon_color(&icon, IconColor::Blue); let mut system_status = SystemStatus::default(); @@ -610,6 +626,20 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) { }, _ => {}, } + }, + msg = portmaster_restart_ui_proc_event_subscription.recv() => { + let msg = match msg { + Some(m) => m, + None => { break } + }; + + debug!("Upgrade restart event received: {:?}", msg); + match msg { + Response::Ok(_, _) | Response::New(_, _) | Response::Update(_, _) => { + app.portmaster().mark_restart_ui_proc_requested(); + }, + _ => {}, + } } } } diff --git a/service/core/api.go b/service/core/api.go index b008097b..2801dffb 100644 --- a/service/core/api.go +++ b/service/core/api.go @@ -180,9 +180,14 @@ func shutdown(_ *api.Request) (msg string, err error) { } // restart restarts the Portmaster. -func restart(_ *api.Request) (msg string, err error) { +func restart(ar *api.Request) (msg string, err error) { log.Info("core: user requested restart via action") + // If the restart request came from an upgrade, also trigger a module event to restart the UI process, so that it can restart itself as well. + if ar != nil && ar.Request != nil && ar.Request.URL.Query().Get("source") == "upgrade" { + pushModuleEvent("core", "restart-ui-process", false, nil) + } + // Trigger restart module.instance.Restart() diff --git a/service/updates/module.go b/service/updates/module.go index 436154c6..2c5aab16 100644 --- a/service/updates/module.go +++ b/service/updates/module.go @@ -423,7 +423,7 @@ func (u *Updater) updateAndUpgrade(w *mgr.WorkerCtx, indexURLs []string, ignoreV Type: notifications.ActionTypeWebhook, Payload: notifications.ActionTypeWebhookPayload{ Method: "POST", - URL: "core/restart", + URL: "core/restart?source=upgrade", }, }, }, From fab4d3e68bd19ddb8c215a6b6b85377361c2456b Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Fri, 10 Apr 2026 13:30:22 +0300 Subject: [PATCH 2/3] fix: fix variable shadowing in copyAndCheckSHA256Sum Separate variable declaration from assignment in the SHA256 validation logic to prevent variable shadowing and ensure proper error handling scope. --- service/updates/index.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/updates/index.go b/service/updates/index.go index 1df694fb..76d1f314 100644 --- a/service/updates/index.go +++ b/service/updates/index.go @@ -377,7 +377,8 @@ func copyAndCheckSHA256Sum(src, dst, sha256sum string, filePermission utils.FSPe // Check expected hash. var expectedDigest []byte if sha256sum != "" { - expectedDigest, err := hex.DecodeString(sha256sum) + var err error + expectedDigest, err = hex.DecodeString(sha256sum) if err != nil { return fmt.Errorf("invalid hex encoding for expected hash %s: %w", sha256sum, err) } From ce67af81e3f2c9307f47b6bdf2426cb1fc19d9a7 Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Fri, 10 Apr 2026 16:01:13 +0300 Subject: [PATCH 3/3] fix(tauri): harden UI process restart path resolution and avoid exit on relaunch failure This fixes Linux-related issue when UI process do not start automatically after upgrade. - replace direct current_exe relaunch usage with verified launch program resolution - consider both current_exe and argv0, but only accept verified launchable file paths - fail relaunch with explicit error when no safe executable path is available - in reconnect flow, exit current UI only if relaunch spawn succeeds - if relaunch request fails, keep current UI process running and continue normal startup https://github.com/safing/portmaster-shadow/issues/40 --- desktop/tauri/src-tauri/src/main.rs | 13 ++++-- desktop/tauri/src-tauri/src/relaunch.rs | 55 +++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/desktop/tauri/src-tauri/src/main.rs b/desktop/tauri/src-tauri/src/main.rs index 746f2411..3f67f43d 100644 --- a/desktop/tauri/src-tauri/src/main.rs +++ b/desktop/tauri/src-tauri/src/main.rs @@ -66,11 +66,16 @@ impl portmaster::Handler for WsHandler { // relaunch the UI process now that the core is reachable again. if self.handle.portmaster().consume_restart_ui_proc_requested() { info!("restart-ui-process pending, relaunching UI process"); - if let Err(err) = relaunch::request_ui_relaunch() { - error!("failed to relaunch UI process after upgrade: {}", err); + match relaunch::request_ui_relaunch() { + Ok(()) => { + self.handle.exit(0); + return; + } + Err(err) => { + error!("failed to relaunch UI process after upgrade: {}", err); + error!("continuing with current UI process"); + } } - self.handle.exit(0); - return; } // we successfully connected to Portmaster. Set is_first_connect to false diff --git a/desktop/tauri/src-tauri/src/relaunch.rs b/desktop/tauri/src-tauri/src/relaunch.rs index e889be3f..e7540b51 100644 --- a/desktop/tauri/src-tauri/src/relaunch.rs +++ b/desktop/tauri/src-tauri/src/relaunch.rs @@ -1,5 +1,6 @@ use std::{ ffi::OsString, + path::Path, process::{Command, Stdio}, thread, time::Duration, @@ -11,6 +12,54 @@ const UI_RELAUNCH_HELPER_ENV: &str = "PORTMASTER_UI_RELAUNCH_HELPER"; const RELAUNCH_RETRY_COUNT: usize = 40; const RELAUNCH_RETRY_DELAY: Duration = Duration::from_millis(500); +fn current_process_argv0() -> Option { + std::env::args_os().next() +} + +fn is_usable_launch_program(program: &OsString) -> bool { + let path = Path::new(program); + + // Fail closed for command-only values (for example, "portmaster"): we cannot + // verify where they resolve to, so do not use them for relaunch. + if !path.is_absolute() && path.components().count() <= 1 { + return false; + } + + if !path.exists() || !path.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if let Ok(meta) = std::fs::metadata(path) { + return meta.permissions().mode() & 0o111 != 0; + } + return false; + } + + #[cfg(not(unix))] + { + true + } +} + +fn resolve_launch_program() -> Result { + let current_exe = std::env::current_exe().ok().map(|p| p.into_os_string()); + let argv0 = current_process_argv0(); + + if let Some(program) = current_exe.as_ref().filter(|p| is_usable_launch_program(p)) { + return Ok(program.clone()); + } + + if let Some(program) = argv0.as_ref().filter(|p| is_usable_launch_program(p)) { + return Ok(program.clone()); + } + + Err("failed to determine relaunch executable: no verified launchable file path from current_exe or argv0".to_string()) +} + fn current_process_args() -> Vec { std::env::args_os() .skip(1) @@ -23,8 +72,7 @@ fn current_process_args() -> Vec { } pub fn request_ui_relaunch() -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|err| format!("failed to get current executable path: {}", err))?; + let exe = resolve_launch_program()?; let args = current_process_args(); let mut cmd = Command::new(&exe); @@ -53,8 +101,7 @@ pub fn run_relaunch_helper_if_requested() { } fn run_relaunch_helper() -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|err| format!("failed to get current executable path in relaunch helper: {}", err))?; + let exe = resolve_launch_program()?; let args = current_process_args(); debug!("[tauri] relaunch helper started");