Compare commits

..

No commits in common. "v0.4.5-beta.3" and "master" have entirely different histories.

14 changed files with 168 additions and 294 deletions

View File

@ -414,32 +414,9 @@ 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:
@ -449,9 +426,7 @@ 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
ostp-windows-gui-${{ matrix.arch }}-setup.exe
files: ostp-windows-gui-${{ matrix.arch }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@ -57,6 +57,3 @@ 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.5",
"branch": "beta",
"target_version": "0.4.4",
"branch": "master",
"alpha_iteration": 0,
"beta_iteration": 3
"beta_iteration": 0
}

12
Cargo.lock generated
View File

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

View File

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

View File

@ -1,15 +1,14 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.4.5",
"version": "0.4.4",
"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 && 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"
"build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build",
"build:dist": "npm run build && node build_dist.js"
},
"devDependencies": {
"@tauri-apps/cli": "^2"

View File

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

View File

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

View File

@ -134,84 +134,16 @@ 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 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;
let path = parent.join("config.json");
if path.exists() {
return path;
}
}
}
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,
}
PathBuf::from("config.json")
}
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
@ -904,8 +836,21 @@ fn helper_args_file() -> PathBuf {
base.join("OSTP").join("helper-args.json")
}
/// Undoes XML entity escaping. `&amp;` must be handled last, or `&amp;lt;`
/// would come back as `<`.
/// 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;", "\"")
@ -919,12 +864,8 @@ 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.
///
/// 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.
/// 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")
@ -978,6 +919,126 @@ 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
@ -993,12 +1054,11 @@ 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 {
// 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) {
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])

View File

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

View File

@ -1,13 +0,0 @@
{
"$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

@ -1,71 +0,0 @@
; 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

@ -1,73 +0,0 @@
// 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');
}