Compare commits

...

5 Commits

Author SHA1 Message Date
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 281 additions and 34 deletions

View File

@ -414,9 +414,32 @@ jobs:
Copy-Item "ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui.exe" $dir
Copy-Item "target/${{ matrix.target }}/release/ostp-tun-helper.exe" $dir
Copy-Item "target/${{ matrix.target }}/release/wintun.dll" $dir
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": 2
}

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+33
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

@ -864,8 +864,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")
@ -1013,24 +1017,49 @@ fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
.status();
// schtasks has exited by now, so this is safe.
// 1223 is ERROR_CANCELLED: the consent prompt was declined. Nothing was
// launched, so there is no point waiting for a task to appear.
if let Ok(s) = &status {
if s.code() == Some(1223) {
let _ = std::fs::remove_file(&xml_path);
anyhow::bail!("the consent prompt was declined");
}
}
// The exit code is advisory only, never proof of success. `-Verb RunAs`
// launches through ShellExecute, and a non-elevated parent frequently
// cannot read the elevated child's exit code — `$p.ExitCode` then yields
// $null, and `exit $null` leaves PowerShell reporting 0. A failed
// registration would sail straight through a `s.success()` check.
//
// Worse, -Wait does not reliably block until the elevated process exits.
// Deleting the XML right after the call raced schtasks reading it — the
// exact bug that made the previous attempt fail — so wait for the task
// itself to show up. These queries are windowless, so unlike the earlier
// polling loop they cost the user nothing to watch.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
let mut registered = false;
while std::time::Instant::now() < deadline {
if helper_task_matches(exe) {
registered = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
// Only now is deleting it safe.
let _ = std::fs::remove_file(&xml_path);
if registered {
return Ok(());
}
match status {
Ok(s) if s.success() => {}
Ok(s) => anyhow::bail!(
"registering the scheduled task failed (exit code {:?}). A declined consent prompt \
reports 1223.",
"the scheduled task did not appear after registration (powershell exit {:?})",
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.

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,103 @@
; 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
; with elevated rights is itself a privileged operation, so an unprivileged GUI
; can only get one by raising a UAC prompt — which is the very thing we are
; trying to remove. The installer already runs elevated, so here it costs
; nothing: the user consents once, to the install, and never again per connect.
;
; The task carries no usable trigger (a one-shot dated in the past), because it
; exists solely to be started on demand by the app.
!macro OSTP_WRITE_TASK_XML OUTFILE
; NSIS is built in Unicode mode here, so FileWrite emits UTF-16LE — which is
; what `schtasks /XML` requires. It still needs the byte-order mark itself.
FileOpen $R1 "${OUTFILE}" w
FileWriteWord $R1 65279
FileWrite $R1 '<?xml version="1.0" encoding="UTF-16"?>$\r$\n'
FileWrite $R1 '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">$\r$\n'
FileWrite $R1 ' <RegistrationInfo>$\r$\n'
FileWrite $R1 ' <Description>Starts the OSTP TUN helper elevated so connecting does not prompt for consent every time.</Description>$\r$\n'
FileWrite $R1 ' </RegistrationInfo>$\r$\n'
FileWrite $R1 ' <Principals>$\r$\n'
FileWrite $R1 ' <Principal id="Author">$\r$\n'
; S-1-5-32-545 is BUILTIN\Users by SID rather than by name: the name is
; localized ("Пользователи" on a Russian Windows) and would not resolve.
; Combined with InteractiveToken this makes the task run as whichever user
; actually launches it, so a machine-wide install still works for every
; account instead of only the one that happened to run the installer.
FileWrite $R1 ' <GroupId>S-1-5-32-545</GroupId>$\r$\n'
FileWrite $R1 ' <LogonType>InteractiveToken</LogonType>$\r$\n'
FileWrite $R1 ' <RunLevel>HighestAvailable</RunLevel>$\r$\n'
FileWrite $R1 ' </Principal>$\r$\n'
FileWrite $R1 ' </Principals>$\r$\n'
FileWrite $R1 ' <Settings>$\r$\n'
; Parallel: reconnecting before a previous helper has fully exited must not
; be silently dropped as a duplicate instance.
FileWrite $R1 ' <MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>$\r$\n'
; A VPN is most needed on battery, and a tunnel must not be killed on unplug.
FileWrite $R1 ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>$\r$\n'
FileWrite $R1 ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>$\r$\n'
FileWrite $R1 ' <StartWhenAvailable>false</StartWhenAvailable>$\r$\n'
FileWrite $R1 ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>$\r$\n'
; PT0S disables the execution time limit; the default would tear the tunnel
; down after three days.
FileWrite $R1 ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>$\r$\n'
FileWrite $R1 ' <Enabled>true</Enabled>$\r$\n'
FileWrite $R1 ' <Hidden>false</Hidden>$\r$\n'
FileWrite $R1 ' <AllowHardTerminate>true</AllowHardTerminate>$\r$\n'
FileWrite $R1 ' </Settings>$\r$\n'
FileWrite $R1 ' <Actions Context="Author">$\r$\n'
FileWrite $R1 ' <Exec>$\r$\n'
FileWrite $R1 ' <Command>$INSTDIR\ostp-tun-helper.exe</Command>$\r$\n'
; The port and auth token change per launch and a task stores a fixed command
; line, so they travel in this file instead. %LOCALAPPDATA% is deliberately
; left unexpanded: Task Scheduler expands it when the task runs, which lands
; on the profile of whoever launched it rather than the installing user's.
FileWrite $R1 ' <Arguments>--args-file "%LOCALAPPDATA%\OSTP\helper-args.json"</Arguments>$\r$\n'
FileWrite $R1 ' </Exec>$\r$\n'
FileWrite $R1 ' </Actions>$\r$\n'
FileWrite $R1 '</Task>$\r$\n'
FileClose $R1
!macroend
!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.
DetailPrint "Placing wintun.dll next to the helper..."
CopyFiles /SILENT "$INSTDIR\resources\wintun.dll" "$INSTDIR\wintun.dll"
DetailPrint "Registering the OSTP TUN helper task..."
!insertmacro OSTP_WRITE_TASK_XML "$PLUGINSDIR\ostp-helper-task.xml"
; /F overwrites an existing registration, so reinstalling or upgrading to a
; different directory repoints the task instead of leaving a stale path — the
; app verifies the registered path at runtime and would otherwise have to
; re-register it with a prompt.
nsExec::ExecToLog 'schtasks.exe /Create /TN "OSTP TUN Helper" /XML "$PLUGINSDIR\ostp-helper-task.xml" /F'
Pop $R0
Delete "$PLUGINSDIR\ostp-helper-task.xml"
${If} $R0 == 0
DetailPrint "Helper task registered; connecting will not prompt for consent."
${Else}
; Not fatal. The app keeps a fallback that registers the task itself on
; first connect, at the cost of the one prompt this was meant to avoid.
DetailPrint "Could not register the helper task (schtasks returned $R0)."
DetailPrint "OSTP will still work, but the first connect will ask for consent."
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; Leaving the task behind would point at a deleted executable, and the app
; treats a mismatched path as grounds to re-register.
DetailPrint "Removing the OSTP TUN helper task..."
nsExec::ExecToLog 'schtasks.exe /Delete /TN "OSTP TUN Helper" /F'
Pop $R0
; This copy was made by the install hook, so the uninstaller does not know
; about it and would otherwise leave it behind.
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');
}