Compare commits

..

10 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
ospab c5e703c144 chore: release v0.4.3-beta.4 on beta 2026-08-07 17:35:15 +03:00
ospab 05f25155bd feat(gui): one UAC prompt per machine instead of one per connect
Enabling TUN raised a consent dialog every single time, because the GUI
elevated the helper with ShellExecuteW("runas") on each connect.

Registers a Scheduled Task with RunLevel=HighestAvailable the first time TUN
is used — that registration is the one prompt — and triggers the task on every
later connect. Running a task is not an elevation request, so Windows shows no
dialog for it. If the task is missing or cannot be triggered, the code falls
back to the original direct elevated launch, so this can only improve on the
old behaviour, never break it.

A task stores a FIXED command line, so the per-launch port and token cannot be
arguments. The helper gained --args-file and the GUI writes them to
%LOCALAPPDATA%\OSTP\helper-args.json immediately before triggering; the helper
deletes it after reading. That path keeps the token inside the trust boundary
it already had — the helper runs elevated but as the same user, and no other
user can read it, which a shared location would not guarantee.

Registered from an XML definition rather than /TR: the exe and args paths would
otherwise need quoting inside an already-quoted /TR value, escaped again
through ShellExecuteW, which breaks as soon as either contains a space — and
both do by default (Program Files, usernames with spaces). Validated that the
generated XML parses with a path containing both a space and an ampersand.
XML also lets DisallowStartIfOnBatteries=false and ExecutionTimeLimit=PT0S be
stated explicitly, without which a laptop would refuse to start the tunnel on
battery and Windows would kill it after three days.

Deliberately NOT a Windows service, contrary to what I suggested earlier. The
helper is one-shot by design: it force-exits after teardown because WinTun's
blocking receive otherwise keeps the adapter and its default route alive and
breaks the next connect. A persistent LocalSystem service would mean
restructuring that lifecycle for the same end-user result. The task also runs
with the user's own token rather than LocalSystem, which is less privilege for
the same outcome. A service is still the better answer if the tunnel should
come up before login — that is the one thing this cannot do.
2026-08-07 17:19:36 +03:00
ospab e6e0a7b28c chore: release v0.4.3-beta.3 on beta 2026-08-05 00:11:33 +03:00
ospab 8f0ffd08c0 feat(gui): TUN mode and autostart on Linux
Brings the Linux GUI up to parity with Windows. Four things blocked it, each
independently sufficient:

  - build.rs keyed the Windows-manifest step off cfg(windows), which in a build
    script describes the HOST. Cross-compiling the helper from Windows to Linux
    therefore took that branch and died with "Can only compile resource file
    when target_env is gnu or msvc". Now keyed off CARGO_CFG_TARGET_OS, with
    the cfg(windows) gate kept as a second check because winres is a host-
    resolved build-dependency and simply does not exist on a Linux host.
  - launch_as_admin was bail!("Windows only.") outside Windows. Implemented for
    Linux via pkexec, polkit's front-end, which raises a graphical auth prompt;
    sudo is unusable from a GUI with no terminal. Missing pkexec now names the
    package to install instead of failing opaquely.
  - The release workflow only built ostp-tun-helper in the Windows job, so the
    Linux package shipped without it. It is now built and placed next to the
    GUI binary, where find_helper_exe looks first.
  - set_autostart/get_autostart were no-ops off Windows. Implemented via XDG
    autostart (~/.config/autostart/ostp.desktop, honouring XDG_CONFIG_HOME),
    the direct equivalent of the HKCU Run key.

The helper's own code needed no changes — it already compiled for Linux once
the build script stopped rejecting it. list_running_processes already had a
Linux branch.

The token file is created 0600 on Linux: /tmp is world-readable there, unlike
the Windows temp dir, and that token authorises control of the privileged
helper.

Not verified on a live Linux desktop from here — the Tauri backend cannot be
compiled for Linux on a Windows host (GTK dev libraries), so the cfg(linux)
paths are reviewed rather than built. CI compiles them.
2026-08-04 01:08:33 +03:00
ospab 8a1426ecf5 fix(gui): honest TUN error on Linux, and a window that can be resized
Reported from the Linux GUI: it asked for "helper.exe" on Linux, and the
window was tiny.

The helper name had ".exe" hardcoded in every lookup path, so on Linux the
search could only ever fail. Fixing the name alone would have been misleading
though, because TUN mode does not work on Linux for a deeper reason:
launch_as_admin is `bail!("Windows only.")` outside Windows, and the release
workflow only builds ostp-tun-helper in the Windows GUI job. So the feature is
Windows-only, and the message now says exactly that and points at proxy mode,
instead of surfacing as a missing file named after a Windows executable —
which reads like a packaging mistake rather than an unimplemented feature. The
name is still resolved per-platform for when Linux elevation does land.

The window was 360x680 and `resizable: false`. Windows scales that by DPI, but
WebKitGTK on a HiDPI Linux display renders it close to raw pixels, giving a
postage-stamp window the user then could not resize. It is now resizable with
a sensible minimum, and .app-root caps and centres the column so a wider
window keeps the intended narrow layout instead of stretching the controls.
2026-08-04 00:56:28 +03:00
ospab e483af541f fix: post-suspend reconnect no longer strands the machine without internet
Two separate problems reported after waking a laptop: the app sits on
"connecting" forever, and there is NO working internet at all — not just no
tunnel. Plus typing in the GUI's exclusion fields lagged by seconds.

1. Resume reconnect retried forever (a regression I introduced when making the
   resume reconnect retry instead of firing once). handle_keepalive(force=true)
   deliberately skips the hard-timeout branch, and that branch is the one that
   releases the SystemProxyGuard. So a resume campaign that never succeeded
   also never gave up, and the system proxy stayed pointed at our local
   listener indefinitely — which kills all browser traffic, tunnel or not, and
   explains "no internet even from my ISP".

   Now bounded: after 45s of failed resume reconnects, hand back to the
   ordinary stall path, which restores the proxy (or, with kill switch on,
   keeps blocking deliberately). Measured on the wall clock, because Instant
   does not advance across suspend on Windows — QPC stops — so a monotonic
   deadline cannot bound anything that starts at wake. That same property is
   why the pre-existing 25s/180s stall checks never fired here either.

2. GUI froze while typing. Every debounced save (400ms, so it fires during
   natural pauses in typing) called set_autostart — a Windows registry write —
   even when the checkbox had not changed, and, while connected, wrote the
   config and ran reload_tunnel, tearing down and rebuilding the tunnel. Worst
   in the exclusion fields, which is exactly where it was reported.

   Autostart now applies only on change; the tunnel hot-reload only when a
   setting the tunnel actually reads has changed, on a 1.5s debounce so it
   lands after editing rather than between keystrokes. The cheap local save
   still runs on every keystroke.
2026-08-04 00:34:12 +03:00
15 changed files with 568 additions and 50 deletions

View File

@ -477,12 +477,19 @@ jobs:
working-directory: ostp-gui working-directory: ostp-gui
run: | run: |
npm install npm install
# TUN mode shells out to this helper, elevated via pkexec. Only the
# Windows job used to build it, so the Linux package shipped without
# it and TUN could never start.
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
npx tauri build --no-bundle --target ${{ matrix.target }} npx tauri build --no-bundle --target ${{ matrix.target }}
- name: Package Portable Tarball - name: Package Portable Tarball
run: | run: |
set -euo pipefail
mkdir ostp-linux-gui-${{ matrix.arch }} mkdir ostp-linux-gui-${{ matrix.arch }}
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/ cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
# The GUI looks for the helper next to its own executable first.
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }} tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
- name: Upload to GitHub Release - name: Upload to GitHub Release

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": 2 "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

@ -23,6 +23,12 @@ use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
/// candidate address is tried. /// candidate address is tried.
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4); const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
/// How long to keep retrying a resume-triggered reconnect before handing the
/// problem back to the ordinary stall path. That path is what releases the
/// system proxy, so this is really a bound on how long the machine may be left
/// with no working internet at all after waking.
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new(); static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
pub fn set_socket_protector<F>(f: F) pub fn set_socket_protector<F>(f: F)
@ -147,6 +153,11 @@ pub struct Bridge {
/// fire at all. Retrying until success removes the dependency on either. /// fire at all. Retrying until success removes the dependency on either.
forced_reconnect_pending: bool, forced_reconnect_pending: bool,
last_forced_reconnect_try: Instant, last_forced_reconnect_try: Instant,
/// Wall-clock start of the current resume-reconnect campaign, used to bound
/// it. Wall clock rather than Instant because the monotonic clock does not
/// advance across suspend on Windows, so it cannot measure anything that
/// begins at wake.
forced_reconnect_started: Option<SystemTime>,
} }
impl Bridge { impl Bridge {
@ -185,6 +196,7 @@ impl Bridge {
last_valid_recv: Instant::now(), last_valid_recv: Instant::now(),
forced_reconnect_pending: false, forced_reconnect_pending: false,
last_forced_reconnect_try: Instant::now(), last_forced_reconnect_try: Instant::now(),
forced_reconnect_started: None,
}) })
} }
@ -268,9 +280,45 @@ impl Bridge {
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs() "Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
))).await; ))).await;
self.forced_reconnect_pending = true; self.forced_reconnect_pending = true;
self.forced_reconnect_started = Some(SystemTime::now());
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60); self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
} }
// Give up if resume reconnects keep failing. Retrying forever
// looks harmless but is not: the system proxy stays pointed at
// our local listener the whole time, so the machine has NO
// working internet — not merely no tunnel — while the UI sits
// on "connecting". Handing the retry to the ordinary keepalive
// path restores the proxy through its hard-timeout branch,
// which force=true deliberately skips.
//
// Measured on the wall clock: Instant does not advance across
// suspend on Windows (QPC stops), so a monotonic deadline can
// not bound anything that starts at wake.
if self.forced_reconnect_pending {
let pending_for = self
.forced_reconnect_started
.and_then(|t| t.elapsed().ok())
.unwrap_or_default();
if pending_for > RESUME_RECONNECT_GIVE_UP {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log(format!(
"Reconnect after suspend failed for {}s — releasing the system \
proxy so normal traffic works; will keep retrying in the \
background",
pending_for.as_secs()
))).await;
// Make the ordinary stall path fire on the next
// keepalive tick: it is the one that tears the proxy
// back down (or, with kill switch on, deliberately
// keeps blocking).
self.last_valid_recv = Instant::now()
.checked_sub(Duration::from_secs(3600))
.unwrap_or_else(Instant::now);
}
}
// Keep retrying a resume-triggered reconnect until one lands. // Keep retrying a resume-triggered reconnect until one lands.
// The first attempt fires within half a second of waking, when // The first attempt fires within half a second of waking, when
// the NIC is typically still reassociating, so treating it as // the NIC is typically still reassociating, so treating it as
@ -286,6 +334,7 @@ impl Bridge {
// success check rather than "we tried". // success check rather than "we tried".
if self.last_valid_recv.elapsed() < Duration::from_secs(3) { if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
self.forced_reconnect_pending = false; self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await; let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
} }
} }

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+27 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,28 +239,71 @@ 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();
} }
} }
#[cfg(target_os = "linux")]
{
// XDG autostart: desktop environments launch every .desktop file in
// ~/.config/autostart on login. This is the portable equivalent of the
// HKCU Run key above and needs no elevation.
let path = linux_autostart_path().ok_or("Cannot determine the autostart directory")?;
if enable {
let exe = std::env::current_exe().map_err(|e| format!("Cannot get exe path: {}", e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Cannot create {}: {}", dir.display(), e))?;
}
let entry = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=OSTP\n\
Exec=\"{}\"\n\
Terminal=false\n\
X-GNOME-Autostart-enabled=true\n",
exe.display()
);
std::fs::write(&path, entry)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
} else if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Cannot remove {}: {}", path.display(), e))?;
}
}
Ok(()) Ok(())
} }
/// Path of the XDG autostart entry, honouring XDG_CONFIG_HOME.
#[cfg(target_os = "linux")]
fn linux_autostart_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("autostart").join("ostp.desktop"))
}
/// Checks if the app is currently in Windows startup. /// Checks if the app is currently in Windows startup.
#[tauri::command] #[tauri::command]
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 {
return o.status.success(); return o.status.success();
} }
} }
#[cfg(target_os = "linux")]
{
if let Some(path) = linux_autostart_path() {
return path.exists();
}
}
false false
} }
@ -254,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()
{ {
@ -625,13 +682,18 @@ async fn start_tun_via_helper(
raw: &ClientConfigRaw, raw: &ClientConfigRaw,
app: tauri::AppHandle, app: tauri::AppHandle,
) -> Result<bool, String> { ) -> Result<bool, String> {
// TUN goes through a privileged helper. Elevation is implemented for
// Windows (UAC) and Linux (polkit/pkexec); anywhere else launch_as_admin
// reports that plainly rather than letting this fail later as a confusing
// missing-file error.
let port = { let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?; let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
listener.local_addr().unwrap().port() listener.local_addr().unwrap().port()
}; };
let auth_token = rand::random::<u64>().to_string(); let auth_token = rand::random::<u64>().to_string();
let helper_exe = find_helper_exe().ok_or_else(|| "ostp-tun-helper.exe not found.".to_string())?; let helper_exe = find_helper_exe()
.ok_or_else(|| format!("{HELPER_EXE_NAME} not found next to the app or in target/."))?;
launch_as_admin(&helper_exe, &auth_token, port).map_err(|e| format!("Failed to launch helper: {}", e))?; launch_as_admin(&helper_exe, &auth_token, port).map_err(|e| format!("Failed to launch helper: {}", e))?;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await; tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
@ -705,11 +767,22 @@ struct HelperPipeState {
error_msg: Option<String>, error_msg: Option<String>,
} }
/// Executable name of the TUN helper for the current platform.
///
/// The ".exe" suffix was hardcoded, so on Linux every lookup below searched for
/// a file that cannot exist and the GUI reported the helper as missing on a
/// platform where it ships without an extension.
const HELPER_EXE_NAME: &str = if cfg!(windows) {
"ostp-tun-helper.exe"
} else {
"ostp-tun-helper"
};
fn find_helper_exe() -> Option<PathBuf> { fn find_helper_exe() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { if let Some(dir) = exe.parent() {
// 1. Release/Production adjacent // 1. Release/Production adjacent
let candidate = dir.join("ostp-tun-helper.exe"); let candidate = dir.join(HELPER_EXE_NAME);
if candidate.exists() { return Some(candidate); } if candidate.exists() { return Some(candidate); }
// 2. Tauri target directory fallback // 2. Tauri target directory fallback
@ -717,9 +790,9 @@ fn find_helper_exe() -> Option<PathBuf> {
let mut parent = dir; let mut parent = dir;
while let Some(p) = parent.parent() { while let Some(p) = parent.parent() {
if p.file_name().map(|n| n == "target").unwrap_or(false) { if p.file_name().map(|n| n == "target").unwrap_or(false) {
let deb = p.join("debug").join("ostp-tun-helper.exe"); let deb = p.join("debug").join(HELPER_EXE_NAME);
if deb.exists() { return Some(deb); } if deb.exists() { return Some(deb); }
let rel = p.join("release").join("ostp-tun-helper.exe"); let rel = p.join("release").join(HELPER_EXE_NAME);
if rel.exists() { return Some(rel); } if rel.exists() { return Some(rel); }
} }
parent = p; parent = p;
@ -729,13 +802,13 @@ fn find_helper_exe() -> Option<PathBuf> {
// 3. Current working directory target fallback // 3. Current working directory target fallback
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let candidates = [ let candidates = [
cwd.join("ostp-tun-helper.exe"), cwd.join(HELPER_EXE_NAME),
cwd.join("target").join("debug").join("ostp-tun-helper.exe"), cwd.join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("target").join("release").join("ostp-tun-helper.exe"), cwd.join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("debug").join("ostp-tun-helper.exe"), cwd.join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("release").join("ostp-tun-helper.exe"), cwd.join("..").join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("debug").join("ostp-tun-helper.exe"), cwd.join("..").join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("release").join("ostp-tun-helper.exe"), cwd.join("..").join("..").join("target").join("release").join(HELPER_EXE_NAME),
]; ];
for path in &candidates { for path in &candidates {
if path.exists() { return Some(path.clone()); } if path.exists() { return Some(path.clone()); }
@ -743,8 +816,272 @@ fn find_helper_exe() -> Option<PathBuf> {
None None
} }
/// Name of the Scheduled Task that runs the helper elevated without a prompt.
#[cfg(target_os = "windows")]
const HELPER_TASK_NAME: &str = "OSTP TUN Helper";
/// Fixed path the GUI writes launch parameters to, and the task's command line
/// reads them from.
///
/// A Scheduled Task stores a FIXED command line, so the per-launch port and
/// token cannot travel as arguments. The file lives under the user's own
/// LOCALAPPDATA: the helper runs elevated but as the SAME user, so this keeps
/// the token inside the trust boundary it already had — no other user can read
/// it, which would not be true of a shared location.
#[cfg(target_os = "windows")]
fn helper_args_file() -> PathBuf {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join("OSTP").join("helper-args.json")
}
/// Minimal XML text escaping for the values interpolated into the task
/// definition. Paths and usernames are attacker-irrelevant here but can easily
/// contain `&`, which would otherwise produce invalid XML and a confusing
/// schtasks parse failure.
#[cfg(target_os = "windows")]
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Reverse of [`xml_escape`]. `&amp;` must be undone last or `&amp;lt;` would
/// come back as `<`.
#[cfg(target_os = "windows")]
fn xml_unescape(s: &str) -> String {
s.replace("&quot;", "\"")
.replace("&apos;", "'")
.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()
.ok()?;
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
/// it happens once per machine; every later tunnel start reuses the task.
///
/// RunLevel=HIGHEST makes the task run elevated, and because a task launch is
/// not an elevation request, Windows shows no consent dialog for it.
#[cfg(target_os = "windows")]
fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
let args_file = helper_args_file();
if let Some(dir) = args_file.parent() {
std::fs::create_dir_all(dir)?;
}
// Register from an XML definition rather than /TR. The command line would
// otherwise need the exe path and the args path quoted INSIDE an already
// quoted /TR value, escaped again through ShellExecuteW — a notoriously
// brittle chain when either path contains a space, which both of these do
// by default (Program Files, and usernames with spaces). XML also lets the
// battery and time-limit settings below be stated explicitly.
let user = format!(
"{}\\{}",
std::env::var("USERDOMAIN").unwrap_or_else(|_| "%COMPUTERNAME%".into()),
std::env::var("USERNAME").unwrap_or_default()
);
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Runs the OSTP TUN helper elevated so enabling the tunnel does not prompt for consent every time.</Description>
</RegistrationInfo>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<AllowHardTerminate>true</AllowHardTerminate>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{exe}</Command>
<Arguments>--args-file "{args}"</Arguments>
</Exec>
</Actions>
</Task>
"#,
user = xml_escape(&user),
exe = xml_escape(&exe.display().to_string()),
args = xml_escape(&args_file.display().to_string()),
);
// schtasks /Create /XML expects UTF-16LE with a BOM.
let xml_path = std::env::temp_dir().join(format!("ostp_task_{}.xml", rand::random::<u32>()));
let mut utf16: Vec<u8> = vec![0xFF, 0xFE];
for unit in xml.encode_utf16() {
utf16.extend_from_slice(&unit.to_le_bytes());
}
std::fs::write(&xml_path, &utf16)?;
// Registering a HighestAvailable task is itself privileged: this is the one
// prompt, and it happens once per machine.
//
// Elevate through PowerShell's Start-Process -Wait rather than
// ShellExecuteW. ShellExecuteW returns as soon as the elevated process is
// LAUNCHED, so the XML below was being deleted while schtasks was still
// 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 status = quiet_command("powershell")
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
.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_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")]
fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> { fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
// Preferred path: hand the parameters over in a file and trigger the
// pre-registered task, which runs elevated with no prompt. Falls back to a
// direct elevated launch when the task is absent (first ever run, or the
// user removed it) — and that first run is also where the task gets created,
// so the prompt appears once rather than on every connect.
let args_file = helper_args_file();
if let Some(dir) = args_file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let payload = serde_json::json!({ "port": port, "token": token });
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
if wrote_args {
if !helper_task_matches(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");
}
}
if helper_task_matches(exe) {
let run = quiet_command("schtasks")
.args(["/Run", "/TN", HELPER_TASK_NAME])
.output();
match run {
Ok(o) if o.status.success() => return Ok(()),
Ok(o) => eprintln!(
"[OSTP] schtasks /Run failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"),
}
}
// Falling through: remove the file so a stale token is not left behind.
let _ = std::fs::remove_file(&args_file);
}
launch_as_admin_direct(exe, token, port)
}
/// The original one-prompt-per-launch path, kept as the fallback.
#[cfg(target_os = "windows")]
fn launch_as_admin_direct(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut; use std::ptr::null_mut;
@ -797,8 +1134,50 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
Ok(()) Ok(())
} }
#[cfg(not(target_os = "windows"))] #[cfg(target_os = "linux")]
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); } fn launch_as_admin(exe: &PathBuf, token: &str, port: u16) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
// Same shape as the Windows path: the token goes through a file rather than
// argv, so it never shows up in the process list.
let token_file = std::env::temp_dir().join(format!("ostp_auth_{}.tmp", rand::random::<u32>()));
std::fs::write(&token_file, token)?;
// Unlike Windows, /tmp is world-readable here, and this token authenticates
// control of the privileged tunnel helper — restrict it to the owner.
let _ = std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600));
// pkexec is polkit's front-end: in a desktop session it raises a graphical
// authentication dialog. sudo is not an option from a GUI process, which has
// no terminal to prompt on.
match Command::new("pkexec")
.arg(exe)
.arg("--port")
.arg(port.to_string())
.arg("--token-file")
.arg(&token_file)
.spawn()
{
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = std::fs::remove_file(&token_file);
anyhow::bail!(
"pkexec was not found, so the TUN helper cannot be granted the privileges it \
needs. Install polkit (package \"policykit-1\" on Debian/Ubuntu, \"polkit\" on \
Fedora/Arch), or use proxy mode, which needs no elevation."
)
}
Err(e) => {
let _ = std::fs::remove_file(&token_file);
Err(e.into())
}
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> {
anyhow::bail!("TUN mode needs a privileged helper, which is implemented on Windows and Linux only. Use proxy mode on this platform.");
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn show_error_dialog(msg: &str) { fn show_error_dialog(msg: &str) {

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"
@ -11,9 +11,11 @@
"windows": [ "windows": [
{ {
"title": "OSTP", "title": "OSTP",
"width": 360, "width": 400,
"height": 680, "height": 720,
"resizable": false "minWidth": 360,
"minHeight": 560,
"resizable": true
} }
], ],
"security": { "security": {

View File

@ -660,6 +660,13 @@ function loadSettingsIntoForm() {
updateClientVisibility(); updateClientVisibility();
} }
// Last values actually pushed to the OS / backend, so repeated saves that did
// not change them stay free. Undefined until the first save, which is correct:
// the first one should apply.
let lastAppliedAutostart;
let lastAppliedTunnelConfig;
let hotReloadTimer;
function collectAndSaveSettings() { function collectAndSaveSettings() {
const s = { const s = {
tun: inTun.checked, tun: inTun.checked,
@ -686,19 +693,41 @@ function collectAndSaveSettings() {
fragChunk: parseInt(inFragChunk.value) || 2, fragChunk: parseInt(inFragChunk.value) || 2,
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2, fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
}; };
// Cheap and local: safe to run on every debounced keystroke.
saveClientSettings(s); saveClientSettings(s);
updateClientVisibility(); updateClientVisibility();
// Set autostart // Everything below talks to the OS or restarts the tunnel. Running it per
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {}); // keystroke is what made typing in the exclusion fields lag by seconds: the
// 400ms debounce fires during natural pauses in typing, and each firing hit
// the Windows registry and then tore down and rebuilt the tunnel.
// Hot-reload exclusions if connected // Only touch autostart when it actually changed — this is a registry write.
if (s.launchStartup !== lastAppliedAutostart) {
lastAppliedAutostart = s.launchStartup;
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
}
// Hot-reload the tunnel only when something it actually reads has changed,
// and on a much longer debounce: a reload is disruptive, so it should land
// once the user has stopped editing rather than between keystrokes.
if (appState === 'connected') { if (appState === 'connected') {
const cfg = buildConfig(); const tunnelRelevant = JSON.stringify([
if (cfg) { s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }) s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
.then(() => invoke('reload_tunnel')) s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
.catch(() => {}); ]);
if (tunnelRelevant !== lastAppliedTunnelConfig) {
clearTimeout(hotReloadTimer);
hotReloadTimer = setTimeout(() => {
lastAppliedTunnelConfig = tunnelRelevant;
const cfg = buildConfig();
if (cfg) {
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
.then(() => invoke('reload_tunnel'))
.catch(() => {});
}
}, 1500);
} }
} }
} }

View File

@ -99,6 +99,13 @@ a { text-decoration: none; }
.app-root { .app-root {
position: relative; position: relative;
width: 100%; width: 100%;
/* The window is resizable so users on desktops where the toolkit does not
apply our DPI scaling (WebKitGTK on HiDPI Linux renders the configured
size as raw pixels, giving a postage-stamp window) can size it themselves.
Capping and centring the column keeps the intended narrow layout instead of
stretching controls across a wide window. */
max-width: 460px;
margin: 0 auto;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -4,6 +4,21 @@
// or launched via ShellExecuteW("runas"). // or launched via ShellExecuteW("runas").
fn main() { fn main() {
// Key off the TARGET, not the host. In a build script `cfg(windows)`
// describes the machine doing the building, so cross-compiling the helper
// from Windows to Linux took this branch and failed with "Can only compile
// resource file when target_env is gnu or msvc". CARGO_CFG_TARGET_OS is the
// target being built for, which is what actually decides whether a Windows
// manifest belongs in the binary.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" {
return;
}
// Second gate, on the HOST: winres is declared under
// [target.'cfg(windows)'.build-dependencies], and build-dependencies are
// resolved against the host triple, so the crate simply does not exist when
// building on Linux. Referencing it unconditionally would fail to compile
// there even though the target check above already passed.
#[cfg(windows)] #[cfg(windows)]
{ {
let mut res = winres::WindowsResource::new(); let mut res = winres::WindowsResource::new();

View File

@ -24,6 +24,14 @@ fn log_to_file(msg: &str) {
/// Launch parameters handed over in a file rather than on the command line.
/// See the `--args-file` handling in `main` for why.
#[derive(Deserialize)]
struct HelperArgs {
port: u16,
token: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "lowercase")] #[serde(tag = "cmd", rename_all = "lowercase")]
enum GuiCmd { enum GuiCmd {
@ -76,6 +84,28 @@ async fn main() -> Result<()> {
let _ = std::fs::remove_file(path); // securely delete after reading let _ = std::fs::remove_file(path); // securely delete after reading
} }
} }
// Both port and token from one file. A Scheduled Task stores a FIXED
// command line, so anything that varies per launch cannot be passed as
// an argument — the GUI writes this file immediately before triggering
// the task instead. That indirection is what lets the task be created
// once (a single UAC prompt) and reused for every later connect without
// prompting again.
if args[i] == "--args-file" && i + 1 < args.len() {
let path = &args[i + 1];
match std::fs::read_to_string(path) {
Ok(content) => {
let _ = std::fs::remove_file(path); // single use
match serde_json::from_str::<HelperArgs>(&content) {
Ok(parsed) => {
port = parsed.port;
expected_token = parsed.token;
}
Err(e) => log_to_file(&format!("Failed to parse --args-file: {e}")),
}
}
Err(e) => log_to_file(&format!("Failed to read --args-file {path}: {e}")),
}
}
} }
log_to_file("Helper started (TCP mode)"); log_to_file("Helper started (TCP mode)");