Compare commits

...

4 Commits

Author SHA1 Message Date
ospab cf14a4243c chore: release v0.4.4 on master 2026-08-08 21:37:55 +03:00
ospab 66368c9d0f fix(gui): helper task checked only its name, not the exe it points at
A Scheduled Task stores an absolute path. Checking that a task named
"OSTP TUN Helper" exists said nothing about whether its <Command> still points
at the helper we are about to run, and the paths do drift: a dev build
registers target\debug\ostp-tun-helper.exe, an installer registers Program
Files, and moving or reinstalling the app leaves the old path behind.

That failed silently in the worst way. schtasks /Run reports success for
merely ACCEPTING the launch request — a task whose exe no longer exists fails
afterwards, out of band, with nothing returned to us. So launch_as_admin
returned Ok, and the caller then sat in its 60-second connect loop before
reporting "Timeout connecting to helper." On every connect, permanently, with
no way out except deleting the task by hand.

The check now reads the registered <Command> back and compares it to the exe,
re-registering through the existing /F overwrite when they differ: one consent
prompt, once, instead of a permanent silent breakage.

The path is read via /Query /XML rather than /FO LIST /V because the list
format's field labels are localized — "Task To Run" is "Задача для запуска" on
a Russian Windows — while XML tag names are not. schtasks emits UTF-16LE with
a BOM there, which is decoded explicitly, with UTF-8 tolerated as a fallback.
Both paths are canonicalized before comparison so casing, `..` and 8.3 short
names do not read as a mismatch; a path that cannot be canonicalized no longer
exists, which is itself grounds to re-register.
2026-08-07 22:24:40 +03:00
ospab bc61b47817 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.
2026-08-07 20:51:14 +03:00
ospab 5a33ed69c4 chore: release v0.4.3 on master 2026-08-07 17:39:19 +03:00
9 changed files with 152 additions and 107 deletions

View File

@ -1,6 +1,6 @@
{ {
"target_version": "0.4.3", "target_version": "0.4.4",
"branch": "beta", "branch": "master",
"alpha_iteration": 0, "alpha_iteration": 0,
"beta_iteration": 4 "beta_iteration": 0
} }

12
Cargo.lock generated
View File

@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "ostp" name = "ostp"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1409,7 +1409,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1440,7 +1440,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -1474,7 +1474,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-server" name = "ostp-server"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -1507,7 +1507,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@ -1519,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun-helper" name = "ostp-tun-helper"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
license = "AGPL-3.0" license = "AGPL-3.0"
version = "0.4.3" version = "0.4.4"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0" anyhow = "1.0"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.4.3+29 version: 0.4.4+31
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

@ -1,7 +1,7 @@
{ {
"name": "ostp-gui", "name": "ostp-gui",
"private": true, "private": true,
"version": "0.4.3", "version": "0.4.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tauri": "tauri", "tauri": "tauri",

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2696,7 +2696,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -2713,7 +2713,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-gui" name = "ostp-gui"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"json_comments", "json_comments",
@ -2733,7 +2733,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.4.3" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "ostp-gui" name = "ostp-gui"
version = "0.4.3" version = "0.4.4"
description = "OSTP desktop GUI" description = "OSTP desktop GUI"
authors = ["ospab"] authors = ["ospab"]
edition = "2021" edition = "2021"

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()
{ {
@ -836,15 +849,74 @@ fn xml_escape(s: &str) -> String {
.replace('\'', "&apos;") .replace('\'', "&apos;")
} }
/// Whether the elevated-launch Scheduled Task already exists. /// Reverse of [`xml_escape`]. `&amp;` must be undone last or `&amp;lt;` would
/// come back as `<`.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn helper_task_exists() -> bool { fn xml_unescape(s: &str) -> String {
use std::process::Command; s.replace("&quot;", "\"")
Command::new("schtasks") .replace("&apos;", "'")
.args(["/Query", "/TN", HELPER_TASK_NAME]) .replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
}
/// The exe path currently baked into the registered task, if any.
///
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
/// whereas XML tag names are fixed. schtasks writes UTF-16LE with a BOM here,
/// but tolerate UTF-8 in case that ever changes.
#[cfg(target_os = "windows")]
fn helper_task_command() -> Option<String> {
let out = quiet_command("schtasks")
.args(["/Query", "/TN", HELPER_TASK_NAME, "/XML"])
.output() .output()
.map(|o| o.status.success()) .ok()?;
.unwrap_or(false) if !out.status.success() {
return None;
}
let text = if out.stdout.starts_with(&[0xFF, 0xFE]) {
let units: Vec<u16> = out.stdout[2..]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(&out.stdout).into_owned()
};
let start = text.find("<Command>")? + "<Command>".len();
let end = text[start..].find("</Command>")? + start;
Some(xml_unescape(text[start..end].trim()))
}
/// Whether a task is registered AND still points at the exe we are about to run.
///
/// The path matters as much as the name. A task registered by a dev build (or
/// by an install that has since moved) keeps its original `<Command>`, and
/// `schtasks /Run` reports success merely for *accepting* the request — a task
/// whose exe no longer exists fails asynchronously and silently. Trusting the
/// name alone therefore bought a 60-second "Timeout connecting to helper" on
/// every single connect, permanently, until the task was deleted by hand.
/// Re-registering costs one consent prompt and fixes it for good.
#[cfg(target_os = "windows")]
fn helper_task_matches(exe: &std::path::Path) -> bool {
let Some(registered) = helper_task_command() else {
return false;
};
let registered = registered.trim().trim_matches('"');
// Canonicalize both sides when possible so `..`, short 8.3 names and
// casing differences do not read as a mismatch. A missing file cannot be
// canonicalized — which is itself a mismatch worth re-registering over.
match (
std::fs::canonicalize(registered),
std::fs::canonicalize(exe),
) {
(Ok(a), Ok(b)) => a == b,
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()),
}
} }
/// Register the Scheduled Task. This is the ONLY step that needs elevation, and /// Register the Scheduled Task. This is the ONLY step that needs elevation, and
@ -917,26 +989,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();
if helper_task_exists() {
return Ok(()); // schtasks has exited by now, so this is safe.
} let _ = std::fs::remove_file(&xml_path);
std::thread::sleep(std::time::Duration::from_millis(250));
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}"),
} }
anyhow::bail!("the scheduled task did not appear after the elevation prompt (it may have been declined)")
if helper_task_matches(exe) {
Ok(())
} else {
anyhow::bail!("schtasks reported success but the task does not point at {}", exe.display())
}
}
/// 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")]
@ -954,14 +1054,13 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok(); let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
if wrote_args { if wrote_args {
if !helper_task_exists() { if !helper_task_matches(exe) {
if let Err(e) = install_helper_task(exe) { if let Err(e) = install_helper_task(exe) {
eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch"); eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch");
} }
} }
if helper_task_exists() { if helper_task_matches(exe) {
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 +1134,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;

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui", "productName": "ostp-gui",
"version": "0.4.3", "version": "0.4.4",
"identifier": "com.ospab.ostp", "identifier": "com.ospab.ostp",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"