Compare commits

...

4 Commits

Author SHA1 Message Date
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
9 changed files with 238 additions and 27 deletions

View File

@ -477,12 +477,19 @@ jobs:
working-directory: ostp-gui
run: |
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 }}
- name: Package Portable Tarball
run: |
set -euo pipefail
mkdir 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 }}
- name: Upload to GitHub Release

View File

@ -2,5 +2,5 @@
"target_version": "0.4.3",
"branch": "beta",
"alpha_iteration": 0,
"beta_iteration": 2
"beta_iteration": 3
}

View File

@ -23,6 +23,12 @@ use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
/// candidate address is tried.
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();
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.
forced_reconnect_pending: bool,
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 {
@ -185,6 +196,7 @@ impl Bridge {
last_valid_recv: Instant::now(),
forced_reconnect_pending: false,
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()
))).await;
self.forced_reconnect_pending = true;
self.forced_reconnect_started = Some(SystemTime::now());
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.
// The first attempt fires within half a second of waking, when
// the NIC is typically still reassociating, so treating it as
@ -286,6 +334,7 @@ impl Bridge {
// success check rather than "we tried".
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
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
# 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.
version: 0.4.3+27
version: 0.4.3+28
environment:
sdk: ^3.11.4

View File

@ -229,9 +229,47 @@ fn set_autostart(enable: bool) -> Result<(), String> {
.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(())
}
/// 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.
#[tauri::command]
fn get_autostart() -> bool {
@ -246,6 +284,12 @@ fn get_autostart() -> bool {
return o.status.success();
}
}
#[cfg(target_os = "linux")]
{
if let Some(path) = linux_autostart_path() {
return path.exists();
}
}
false
}
@ -625,13 +669,18 @@ async fn start_tun_via_helper(
raw: &ClientConfigRaw,
app: tauri::AppHandle,
) -> 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 listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
listener.local_addr().unwrap().port()
};
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))?;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
@ -705,21 +754,32 @@ struct HelperPipeState {
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> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
// 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); }
// 2. Tauri target directory fallback
// e.g. from ostp-gui/src-tauri/target/debug/deps/
let mut parent = dir;
while let Some(p) = parent.parent() {
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); }
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); }
}
parent = p;
@ -729,13 +789,13 @@ fn find_helper_exe() -> Option<PathBuf> {
// 3. Current working directory target fallback
let cwd = std::env::current_dir().unwrap_or_default();
let candidates = [
cwd.join("ostp-tun-helper.exe"),
cwd.join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("..").join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join("..").join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("..").join("..").join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join(HELPER_EXE_NAME),
cwd.join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("release").join(HELPER_EXE_NAME),
];
for path in &candidates {
if path.exists() { return Some(path.clone()); }
@ -797,8 +857,50 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
#[cfg(target_os = "linux")]
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")]
fn show_error_dialog(msg: &str) {

View File

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

View File

@ -660,6 +660,13 @@ function loadSettingsIntoForm() {
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() {
const s = {
tun: inTun.checked,
@ -686,19 +693,41 @@ function collectAndSaveSettings() {
fragChunk: parseInt(inFragChunk.value) || 2,
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
};
// Cheap and local: safe to run on every debounced keystroke.
saveClientSettings(s);
updateClientVisibility();
// Set autostart
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
// Everything below talks to the OS or restarts the tunnel. Running it per
// 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') {
const cfg = buildConfig();
if (cfg) {
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
.then(() => invoke('reload_tunnel'))
.catch(() => {});
const tunnelRelevant = JSON.stringify([
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
]);
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 {
position: relative;
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%;
display: flex;
flex-direction: column;

View File

@ -4,6 +4,21 @@
// or launched via ShellExecuteW("runas").
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)]
{
let mut res = winres::WindowsResource::new();