fix(gui): the UAC-once change did not work and flashed consoles

Reported from v0.4.3: a consent prompt for schtasks, then 10-20 console windows
opening and closing, then STILL a prompt for the helper. Two defects of mine,
both in the change that was supposed to remove the repeated prompt.

Registration never succeeded. ShellExecuteW returns as soon as the elevated
process is LAUNCHED, not when it finishes, so the generated XML was deleted
while schtasks was still starting — it then had nothing to read. The task was
never created, so the code fell through to the direct elevated launch and the
user paid for two prompts to get what one used to do. Registration now goes
through PowerShell's Start-Process -Verb RunAs -Wait -PassThru, which actually
waits, lets the XML be deleted safely afterwards, and surfaces the real exit
code instead of it being inferred by polling. Arguments are passed as an array,
so the task name and XML path never touch a command line; verified the
generated script parses with a path containing an apostrophe, an ampersand and
spaces at once.

The flashing was every schtasks/reg/tasklist invocation: the GUI is a
windowed-subsystem binary, so each console child pops a window, and the
registration polled up to twenty times in a row. All of them now go through a
wrapper that sets CREATE_NO_WINDOW. This also silences flashes that predate
this feature — `tasklist` runs whenever the exclusions screen opens, and `reg`
on autostart changes.

Polling is gone with it: the exit code is authoritative, and the task's
presence is confirmed once rather than up to twenty times.

Also drops shell_execute_elevated, which this change had left with no callers.
This commit is contained in:
ospab 2026-08-07 20:51:14 +03:00
parent 5a33ed69c4
commit bc61b47817
1 changed files with 67 additions and 82 deletions

View File

@ -204,19 +204,34 @@ fn get_wintun_install_path() -> String {
String::new() String::new()
} }
/// A `Command` for a console program, with the console window suppressed.
///
/// The GUI is a windowed-subsystem binary, so every console child it spawns
/// pops up a console window for as long as that child runs. With `reg`,
/// `tasklist` and `schtasks` all being invoked from here, that surfaced as
/// windows flashing on screen — worst while polling for the scheduled task,
/// which could spawn twenty of them in a row.
#[cfg(target_os = "windows")]
fn quiet_command(program: &str) -> std::process::Command {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut cmd = std::process::Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
/// Sets or removes the app from Windows startup (HKCU\...\Run). /// Sets or removes the app from Windows startup (HKCU\...\Run).
#[tauri::command] #[tauri::command]
fn set_autostart(enable: bool) -> Result<(), String> { fn set_autostart(enable: bool) -> Result<(), String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::process::Command;
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let app_name = "OSTP"; let app_name = "OSTP";
if enable { if enable {
let exe = std::env::current_exe() let exe = std::env::current_exe()
.map_err(|e| format!("Cannot get exe path: {}", e))?; .map_err(|e| format!("Cannot get exe path: {}", e))?;
let exe_str = format!("\"{}\"", exe.to_string_lossy()); let exe_str = format!("\"{}\"", exe.to_string_lossy());
let out = Command::new("reg") let out = quiet_command("reg")
.args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"]) .args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"])
.output() .output()
.map_err(|e| format!("reg add failed: {}", e))?; .map_err(|e| format!("reg add failed: {}", e))?;
@ -224,7 +239,7 @@ fn set_autostart(enable: bool) -> Result<(), String> {
return Err(String::from_utf8_lossy(&out.stderr).to_string()); return Err(String::from_utf8_lossy(&out.stderr).to_string());
} }
} else { } else {
let _ = Command::new("reg") let _ = quiet_command("reg")
.args(["delete", key, "/v", app_name, "/f"]) .args(["delete", key, "/v", app_name, "/f"])
.output(); .output();
} }
@ -275,9 +290,8 @@ fn linux_autostart_path() -> Option<PathBuf> {
fn get_autostart() -> bool { fn get_autostart() -> bool {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::process::Command;
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let out = Command::new("reg") let out = quiet_command("reg")
.args(["query", key, "/v", "OSTP"]) .args(["query", key, "/v", "OSTP"])
.output(); .output();
if let Ok(o) = out { if let Ok(o) = out {
@ -298,8 +312,7 @@ fn get_autostart() -> bool {
fn list_running_processes() -> Vec<String> { fn list_running_processes() -> Vec<String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::process::Command; if let Ok(out) = quiet_command("tasklist")
if let Ok(out) = Command::new("tasklist")
.args(["/FO", "CSV", "/NH"]) .args(["/FO", "CSV", "/NH"])
.output() .output()
{ {
@ -839,8 +852,7 @@ fn xml_escape(s: &str) -> String {
/// Whether the elevated-launch Scheduled Task already exists. /// Whether the elevated-launch Scheduled Task already exists.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn helper_task_exists() -> bool { fn helper_task_exists() -> bool {
use std::process::Command; quiet_command("schtasks")
Command::new("schtasks")
.args(["/Query", "/TN", HELPER_TASK_NAME]) .args(["/Query", "/TN", HELPER_TASK_NAME])
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
@ -917,26 +929,54 @@ fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
// Registering a HighestAvailable task is itself privileged: this is the one // Registering a HighestAvailable task is itself privileged: this is the one
// prompt, and it happens once per machine. // prompt, and it happens once per machine.
let schtasks = std::path::PathBuf::from("schtasks.exe"); //
let params = format!( // Elevate through PowerShell's Start-Process -Wait rather than
"/Create /TN \"{}\" /XML \"{}\" /F", // ShellExecuteW. ShellExecuteW returns as soon as the elevated process is
HELPER_TASK_NAME, // LAUNCHED, so the XML below was being deleted while schtasks was still
xml_path.display() // starting up — registration then failed, leaving the user with a consent
// prompt that accomplished nothing, followed by a second prompt from the
// fallback path. -Wait makes the deletion safe and lets the exit code be
// checked instead of guessed at by polling.
//
// ArgumentList takes an array, so the task name and XML path never need
// quoting or escaping through a command line, only PowerShell's own
// single-quote doubling.
let ps = format!(
"$p = Start-Process -FilePath 'schtasks.exe' -Verb RunAs -Wait -PassThru \
-WindowStyle Hidden -ArgumentList @('/Create','/TN','{}','/XML','{}','/F'); \
exit $p.ExitCode",
ps_quote(HELPER_TASK_NAME),
ps_quote(&xml_path.display().to_string()),
); );
let result = shell_execute_elevated(&schtasks, &params);
// Best-effort cleanup; schtasks may still be reading it, so ignore errors.
let _ = std::fs::remove_file(&xml_path);
result?;
// schtasks runs asynchronously through ShellExecute; wait briefly for the let status = quiet_command("powershell")
// task to appear rather than reporting success before it exists. .args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
for _ in 0..20 { .status();
// schtasks has exited by now, so this is safe.
let _ = std::fs::remove_file(&xml_path);
match status {
Ok(s) if s.success() => {}
Ok(s) => anyhow::bail!(
"registering the scheduled task failed (exit code {:?}). A declined consent prompt \
reports 1223.",
s.code()
),
Err(e) => anyhow::bail!("could not run powershell to register the task: {e}"),
}
if helper_task_exists() { if helper_task_exists() {
return Ok(()); Ok(())
} else {
anyhow::bail!("schtasks reported success but the task is not present")
} }
std::thread::sleep(std::time::Duration::from_millis(250)); }
}
anyhow::bail!("the scheduled task did not appear after the elevation prompt (it may have been declined)") /// Escape a value for embedding in a PowerShell single-quoted string.
#[cfg(target_os = "windows")]
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@ -960,8 +1000,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
} }
} }
if helper_task_exists() { if helper_task_exists() {
use std::process::Command; let run = quiet_command("schtasks")
let run = Command::new("schtasks")
.args(["/Run", "/TN", HELPER_TASK_NAME]) .args(["/Run", "/TN", HELPER_TASK_NAME])
.output(); .output();
match run { match run {
@ -1035,60 +1074,6 @@ fn launch_as_admin_direct(exe: &std::path::PathBuf, token: &str, port: u16) -> a
Ok(()) Ok(())
} }
/// Run `exe` elevated with `params`, raising the UAC prompt.
///
/// Shared by the fallback launch path and by the one-time task registration, so
/// both report a declined prompt the same way instead of ShellExecuteW's
/// pseudo-HINSTANCE being interpreted twice.
#[cfg(target_os = "windows")]
fn shell_execute_elevated(exe: &std::path::Path, params: &str) -> anyhow::Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut;
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
let params_wstr: Vec<u16> = OsStr::new(params).encode_wide().chain(Some(0)).collect();
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize;
}
#[link(name = "kernel32")]
extern "system" {
fn GetLastError() -> u32;
}
let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("."));
let dir_wstr: Vec<u16> = cwd_path
.parent()
.unwrap_or(std::path::Path::new("."))
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect();
let ret = unsafe {
ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 1)
};
// 1223 is ERROR_CANCELLED, which lands in the ">32 means success" range —
// see the note in launch_as_admin_direct.
if ret == 1223 {
anyhow::bail!("UAC elevation was denied.");
}
if ret <= 32 {
let win_err = unsafe { GetLastError() };
anyhow::bail!(
"Failed to request UAC elevation (ShellExecuteW ret={}, GetLastError={}, path={})",
ret,
win_err,
exe.display()
);
}
Ok(())
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn launch_as_admin(exe: &PathBuf, token: &str, port: u16) -> Result<()> { fn launch_as_admin(exe: &PathBuf, token: &str, port: u16) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;