Compare commits

...

7 Commits

Author SHA1 Message Date
ospab 1ab55fcdd0 chore: release v0.4.5-beta.3 on beta 2026-08-11 17:43:24 +03:00
ospab 234497759b fix(gui): config went to the working directory; installer wrote unusable XML
Three defects the first installer build exposed.

Settings could not be read or saved, "os error 5". With no config beside the
executable — which is the case for every fresh install — get_config_path fell
back to a bare relative "config.json", resolved against the process working
directory. Launched from a Start Menu shortcut that is whatever Windows chose,
frequently C:\Windows\System32. On a writable working directory the silent
outcome would have been worse than the error: settings persisting somewhere
unrelated and appearing to vanish. The config now lives beside the executable
only where that directory actually accepts writes, and otherwise under the
user's own profile, carrying an existing read-only copy across once.
Writability is measured, not inferred from the path: an install onto a data
drive may well be writable where Program Files is not.

The installer could not register the task: "The task XML is malformed.
(1,2)::ERROR: incorrect document syntax". Writing it from NSIS emitted a UTF-16
byte-order mark ahead of content whose encoding depends on whether makensis was
built in Unicode mode. Replaced with the ScheduledTasks cmdlets, which take the
same settings as arguments — no file, so no encoding to get wrong. Verified the
invocation reaches Register-ScheduledTask and fails only on "Access is denied"
when unelevated, which is exactly what the elevated installer supplies.

That command is delimited with backticks, NSIS's third quote character. As a
single-quoted string it would have ended at PowerShell's first quote.

"Copy failed" on wintun.dll: CopyFiles takes a destination directory, and it
was given a file path. It is also guarded now, so a missing resource says so
instead of failing mutely.

Finally, per request, the app no longer registers the task itself — that is the
installer's job alone. Without a task it goes straight to the direct elevated
launch, which prompts per connect as it always did, rather than spending a
prompt on a registration attempt and then another on the launch.
2026-08-11 17:43:04 +03:00
ospab a63c34669b chore: release v0.4.5-beta.2 on beta 2026-08-11 17:08:08 +03:00
ospab b18de0379c fix(ci): staging script was CommonJS in an ES-module package
ostp-gui/package.json sets "type": "module", so a .js file is loaded as an ES
module and `require` is not defined — the installer build died on the first
line of stage-sidecar.js. Renamed to .cjs, which opts that one file back into
CommonJS.

The failure was also reported in the wrong place. pwsh does not abort a run
block when a native command exits non-zero, so the build carried on past the
dead script and failed several steps later complaining about a sidecar that
nothing had staged. The two commands are chained now, so staging failures
surface as themselves.

Verified locally this time: the script resolves the host triple, copies the
helper to the name Tauri expects, and warns about a missing wintun.dll rather
than failing silently.
2026-08-11 17:07:55 +03:00
ospab 96d6bb61d2 chore: release v0.4.5-beta.1 on beta 2026-08-11 16:54:34 +03:00
ospab 8af4be9b0a fix(gui): confine the installer's sidecar to the installer build
Naming the file tauri.windows.conf.json made Tauri merge it into every Windows
build automatically, and externalBin is resolved by the build script — so a
bare `cargo check` in src-tauri started failing with "resource path
binaries\ostp-tun-helper-x86_64-pc-windows-msvc.exe doesn't exist" unless the
sidecar had been staged first. That broke the release script's own cargo check
and would have broken the portable zip build too.

Renamed to tauri.installer.conf.json, which Tauri does not pick up on its own,
and passed explicitly with --config from the one step that wants it. Plain
builds are back to exactly what they were; only the installer needs staging.
2026-08-11 16:54:11 +03:00
ospab 8b5c0a3a8c feat(gui): register the helper task from an installer, not from the app
Elevation belongs to install time. Registering a task that runs elevated is
itself privileged, so an unprivileged GUI can only obtain one by raising the
very prompt we are trying to remove. There was nowhere to put it: the Windows
GUI ships as a portable zip built with --no-bundle, so the project had no
installer at all. Adds an NSIS one, whose POSTINSTALL hook registers the task
while already elevated. Connecting then prompts zero times.

NSIS over WiX because installerHooks is an NSIS feature; the MSI equivalent
needs a custom action, which is more bespoke machinery, not less. installMode
is perMachine — the default, currentUser, does not run elevated, and the hook
would fail exactly as the in-app attempt did.

The task's principal is the SID S-1-5-32-545 (BUILTIN\Users) with
InteractiveToken rather than the installing user, so a machine-wide install
serves every account instead of only whoever ran the installer; the name is
localized and would not resolve. %LOCALAPPDATA% in the arguments is left
unexpanded for the same reason — Task Scheduler expands it per running user.

Also fixes the in-app fallback, which the portable zip still needs and which
had never once worked. It trusted the exit code of an elevated schtasks, but
-Verb RunAs launches through ShellExecute and a non-elevated parent generally
cannot read the child's exit code: $p.ExitCode yields $null, and `exit $null`
leaves PowerShell reporting 0 (measured, not assumed). Failure was arriving
disguised as success. -Wait does not reliably block either, so deleting the
task XML afterwards raced schtasks reading it. It now waits for the task to
actually appear before deleting anything, and treats the exit code as advisory
except for 1223, a declined prompt, which is worth failing fast on.

Corrects one comment that asserted the opposite of the truth: schtasks writes
UTF-16 to a console but UTF-8 with no BOM into a redirected pipe, which is the
case that matters here. Only the fallback made the path check work at all.

wintun.dll rides along as a bundled resource and the hook copies it beside the
executables, since the helper loads it with a plain LoadLibrary. The uninstall
hook removes both it and the task, so no stale registration is left pointing at
a deleted binary.
2026-08-11 16:43:49 +03:00
14 changed files with 294 additions and 168 deletions

View File

@ -417,6 +417,29 @@ jobs:
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
# The installer is what removes the per-connect consent prompt: it runs
# elevated, so its hook can register the helper's Scheduled Task once.
# The portable zip above cannot, and falls back to asking on first connect.
# The sidecar and its config are confined to this step: declaring
# externalBin in an auto-merged tauri.windows.conf.json would force every
# Windows build, down to a bare `cargo check`, to have the helper staged
# first, and fail the build script when it is not.
- name: Build NSIS Installer
working-directory: ostp-gui
# Chained, not two lines: pwsh does not abort a run block when a native
# command fails, so a staging failure would otherwise be reported far
# downstream as a missing sidecar rather than as itself.
run: node stage-sidecar.cjs --release --target ${{ matrix.target }} && npx tauri build --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.installer.conf.json
- name: Collect installer
shell: pwsh
run: |
$nsis = Get-ChildItem -Path "ostp-gui/src-tauri/target/${{ matrix.target }}/release/bundle/nsis" -Filter *-setup.exe -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $nsis) { Write-Error "NSIS installer was not produced"; exit 1 }
Copy-Item $nsis.FullName "ostp-windows-gui-${{ matrix.arch }}-setup.exe"
Write-Host "installer: $($nsis.Name) -> ostp-windows-gui-${{ matrix.arch }}-setup.exe"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
@ -426,7 +449,9 @@ jobs:
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-windows-gui-${{ matrix.arch }}.zip
files: |
ostp-windows-gui-${{ matrix.arch }}.zip
ostp-windows-gui-${{ matrix.arch }}-setup.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@ -57,3 +57,6 @@ ostp-control/
netstack-smoltcp/
dnstt/
ostp-web/
# Tauri sidecar staging area (copied from target/ at build time)
ostp-gui/src-tauri/binaries/

View File

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

12
Cargo.lock generated
View File

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

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "AGPL-3.0"
version = "0.4.4"
version = "0.4.5"
[workspace.dependencies]
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
# 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.4+31
version: 0.4.5+34
environment:
sdk: ^3.11.4

View File

@ -1,14 +1,15 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.4.4",
"version": "0.4.5",
"type": "module",
"scripts": {
"tauri": "tauri",
"dev": "cargo build -p ostp-tun-helper && npx tauri dev",
"build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle",
"build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build",
"build:dist": "npm run build && node build_dist.js"
"build:installer": "cargo build -p ostp-tun-helper --release && node stage-sidecar.cjs --release && npx tauri build --bundles nsis --config src-tauri/tauri.installer.conf.json",
"build:dist": "npm run build && node build_dist.js",
"sidecar": "node stage-sidecar.cjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2"

View File

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

View File

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

View File

@ -134,16 +134,84 @@ struct AppState(Mutex<AppStateInner>);
// ── Config helpers ────────────────────────────────────────────────────────────
/// Per-user config location, used whenever the config cannot live next to the
/// executable.
fn user_config_path() -> PathBuf {
let base = std::env::var_os(if cfg!(windows) { "APPDATA" } else { "HOME" })
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let dir = if cfg!(windows) { base.join("OSTP") } else { base.join(".config").join("ostp") };
dir.join("config.json")
}
/// Where the GUI reads and writes its configuration.
///
/// Portable installs keep the config beside the executable, which is what the
/// zip has always done, and that is preserved wherever the directory is
/// actually writable.
///
/// What it must never do again is fall back to a bare relative `config.json`.
/// That resolves against the process working directory, which for a Start Menu
/// shortcut is whatever Windows chose — often `C:\Windows\System32`. Reading
/// and saving settings then failed with "Access is denied" (os error 5), and on
/// a writable working directory it would have been worse still: settings would
/// silently persist somewhere unrelated and appear to vanish.
///
/// Writability is measured rather than inferred from the install location. An
/// installer can put the app anywhere — a per-machine install onto a data drive
/// may well be writable, while Program Files is not — so the location alone
/// says nothing.
fn get_config_path() -> PathBuf {
if let Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() {
let path = parent.join("config.json");
if path.exists() {
return path;
let portable = parent.join("config.json");
if portable.exists() {
if is_file_writable(&portable) {
return portable;
}
// Read-only beside the exe: unusable as the live file, but its
// contents are still worth carrying over once.
let user = user_config_path();
if !user.exists() {
if let Some(dir) = user.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::copy(&portable, &user);
}
} else if is_dir_writable(parent) {
// No config yet and the directory takes writes: a portable
// unzip, so keep the config travelling with the folder.
return portable;
}
}
}
PathBuf::from("config.json")
let path = user_config_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
path
}
/// Whether an existing file can actually be written to.
///
/// Answered by opening it, not by reading permission bits: on Windows the
/// effective answer depends on the ACL and on virtualization, and `readonly()`
/// reflects neither.
fn is_file_writable(path: &std::path::Path) -> bool {
std::fs::OpenOptions::new().append(true).open(path).is_ok()
}
/// Whether new files can be created in a directory, tested by doing it.
fn is_dir_writable(dir: &std::path::Path) -> bool {
let probe = dir.join(format!(".ostp-write-test-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
@ -836,21 +904,8 @@ fn helper_args_file() -> PathBuf {
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 `<`.
/// Undoes XML entity escaping. `&amp;` must be handled last, or `&amp;lt;`
/// would come back as `<`.
#[cfg(target_os = "windows")]
fn xml_unescape(s: &str) -> String {
s.replace("&quot;", "\"")
@ -864,8 +919,12 @@ fn xml_unescape(s: &str) -> String {
///
/// 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.
/// whereas XML tag names are fixed.
///
/// Encoding depends on where the output goes, which is measured rather than
/// assumed: to a console schtasks writes UTF-16LE with a BOM, but into a
/// redirected pipe — our case — it writes UTF-8 with no BOM. Both are handled,
/// keyed off the BOM, so this keeps working if that ever flips.
#[cfg(target_os = "windows")]
fn helper_task_command() -> Option<String> {
let out = quiet_command("schtasks")
@ -919,126 +978,6 @@ fn helper_task_matches(exe: &std::path::Path) -> bool {
}
}
/// 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")]
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
@ -1054,11 +993,12 @@ 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();
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");
}
}
// Deliberately does NOT create the task when it is missing. Registering
// one is privileged, so the app could only do it by raising the very
// prompt this exists to avoid — and it would then charge the user two
// prompts for the privilege. Creating it belongs to the installer,
// which is already elevated. Without it we simply fall through to the
// direct elevated launch, which prompts once per connect as before.
if helper_task_matches(exe) {
let run = quiet_command("schtasks")
.args(["/Run", "/TN", HELPER_TASK_NAME])

View File

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

View File

@ -0,0 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"externalBin": ["binaries/ostp-tun-helper"],
"resources": { "binaries/wintun.dll": "wintun.dll" },
"windows": {
"nsis": {
"installMode": "perMachine",
"installerHooks": "./windows/hooks.nsh"
}
}
}
}

View File

@ -0,0 +1,71 @@
; Registers the Scheduled Task that lets the GUI start the TUN helper elevated
; without a consent prompt.
;
; This belongs in the installer, not in the app. Registering a task that runs
; elevated is itself a privileged operation, so an unprivileged GUI could only
; obtain one by raising the very prompt we are trying to remove. The installer
; already runs elevated (installMode is perMachine), so here it costs nothing:
; the user consents once, to the install, and never again per connect.
;
; The task carries no trigger at all — it exists solely to be started on demand.
!macro NSIS_HOOK_POSTINSTALL
; Bundled resources land in $INSTDIR\resources, but the helper loads wintun
; with a plain LoadLibrary, which searches its own directory — so put a copy
; beside the executables. The destination is the directory, not a file path:
; CopyFiles takes a target directory, and naming the file made it fail.
${If} ${FileExists} "$INSTDIR\resources\wintun.dll"
DetailPrint "Placing wintun.dll next to the helper..."
CopyFiles /SILENT "$INSTDIR\resources\wintun.dll" "$INSTDIR"
${Else}
DetailPrint "WARNING: resources\wintun.dll is missing; TUN mode will not start."
${EndIf}
; Registered through PowerShell's ScheduledTasks module rather than
; `schtasks /XML`. Generating the XML from NSIS wrote a UTF-16 byte-order mark
; ahead of content whose encoding depended on whether makensis was built in
; Unicode mode, and schtasks rejected the result outright:
; "The task XML is malformed. (1,2)::ERROR: incorrect document syntax"
; The cmdlets take the same settings as arguments, so no file is written and
; there is no encoding to get wrong.
;
; The command is delimited with backticks, NSIS's third quote character, so
; that PowerShell's own single quotes and the shell's double quotes can both
; appear literally — inside a single-quoted NSIS string the first PowerShell
; quote would have terminated the argument early.
;
; $$ is an escaped literal dollar for PowerShell's variables; a bare $ would
; be read by NSIS as one of its own. The helper argument is assembled with
; [char]34 instead of nested quotes so that a username containing a space
; still yields a correctly quoted path, without three levels of escaping.
;
; The principal is the SID S-1-5-32-545 (BUILTIN\Users) rather than the
; installing user, so a per-machine install serves every account instead of
; only whoever ran the installer. The SID is used because the name is
; localized and would not resolve. %LOCALAPPDATA% is likewise left unexpanded
; for Task Scheduler to resolve per running user.
DetailPrint "Registering the OSTP TUN helper task..."
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$act = New-ScheduledTaskAction -Execute '$INSTDIR\ostp-tun-helper.exe' -Argument ('--args-file ' + [char]34 + '%LOCALAPPDATA%\OSTP\helper-args.json' + [char]34); $$prn = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Highest; $$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances Parallel; Register-ScheduledTask -TaskName 'OSTP TUN Helper' -Action $$act -Principal $$prn -Settings $$set -Force | Out-Null"`
Pop $R0
${If} $R0 == 0
DetailPrint "Helper task registered; connecting will not ask for consent."
${Else}
; Not fatal: the app still works, it just falls back to an elevated launch
; that asks for consent on each connect.
DetailPrint "Could not register the helper task (exit $R0)."
DetailPrint "OSTP will still work, but every connect will ask for consent."
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; Leaving the task behind would point it at a deleted executable, and
; `schtasks /Run` reports success for merely accepting such a request — the
; app would wait on a helper that never starts.
DetailPrint "Removing the OSTP TUN helper task..."
nsExec::ExecToLog 'schtasks.exe /Delete /TN "OSTP TUN Helper" /F'
Pop $R0
; Copied by the install hook, so the uninstaller has no record of it.
Delete "$INSTDIR\wintun.dll"
!macroend

View File

@ -0,0 +1,73 @@
// Stages ostp-tun-helper where Tauri expects a sidecar.
//
// tauri.installer.conf.json declares `externalBin: ["binaries/ostp-tun-helper"]`,
// and Tauri resolves that to `binaries/ostp-tun-helper-<target-triple>.exe` at
// build time, failing the build outright when the file is absent. Cargo writes
// the plain name instead, so it has to be copied across first.
//
// Only the installer build needs this. That config is passed explicitly with
// --config rather than being named tauri.windows.conf.json, which Tauri would
// merge into every Windows build automatically — and then even a bare
// `cargo check` would fail on the missing sidecar.
//
// A no-op off Windows: the Linux and macOS GUI builds have no helper sidecar.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
if (process.platform !== 'win32') {
process.exit(0);
}
// --target may be passed through; fall back to the host triple rustc reports.
const targetFlag = process.argv.indexOf('--target');
const triple =
targetFlag !== -1 && process.argv[targetFlag + 1]
? process.argv[targetFlag + 1]
: execFileSync('rustc', ['-vV'], { encoding: 'utf8' })
.split('\n')
.find((l) => l.startsWith('host:'))
.slice('host:'.length)
.trim();
const profile = process.argv.includes('--release') ? 'release' : 'debug';
const repoRoot = path.resolve(__dirname, '..');
// Cargo drops a --target build under target/<triple>/, and a host build
// straight into target/. CI always passes --target; local builds usually do not.
const candidates = [
path.join(repoRoot, 'target', triple, profile, 'ostp-tun-helper.exe'),
path.join(repoRoot, 'target', profile, 'ostp-tun-helper.exe'),
];
const src = candidates.find((p) => fs.existsSync(p));
if (!src) {
console.error(
'stage-sidecar: ostp-tun-helper.exe not found. Looked in:\n ' +
candidates.join('\n ') +
`\nBuild it first: cargo build -p ostp-tun-helper${profile === 'release' ? ' --release' : ''}`
);
process.exit(1);
}
const destDir = path.join(__dirname, 'src-tauri', 'binaries');
fs.mkdirSync(destDir, { recursive: true });
const dest = path.join(destDir, `ostp-tun-helper-${triple}.exe`);
fs.copyFileSync(src, dest);
console.log(`stage-sidecar: ${path.relative(repoRoot, src)} -> ${path.relative(repoRoot, dest)}`);
// wintun.dll rides along as a bundled resource. It is only fetched by the
// release workflow, so a local build without it should warn rather than fail —
// the installer just ends up unable to bring a tunnel up.
const dllSrc = [
path.join(repoRoot, 'target', triple, profile, 'wintun.dll'),
path.join(repoRoot, 'target', profile, 'wintun.dll'),
].find((p) => fs.existsSync(p));
if (dllSrc) {
fs.copyFileSync(dllSrc, path.join(destDir, 'wintun.dll'));
console.log(`stage-sidecar: ${path.relative(repoRoot, dllSrc)} -> binaries/wintun.dll`);
} else if (fs.existsSync(path.join(destDir, 'wintun.dll'))) {
console.log('stage-sidecar: reusing the previously staged binaries/wintun.dll');
} else {
console.warn('stage-sidecar: WARNING wintun.dll not found; a bundle build will fail on the missing resource');
}