From 125a51d81de5d27af01da1e07794aec6840f6410 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 16:43:18 +0000 Subject: [PATCH] feat(windows-installer): move driver + web install into the host exe (ASCII root fix) Port the three install-time PowerShell *files* (install-pf-vdisplay.ps1, install-gamepad-drivers.ps1, web-setup.ps1) into punktfunk-host.exe subcommands: `driver install [--gamepad] --dir ` and `web setup --app-dir [--password-file ]` (windows/install.rs). Why: PowerShell 5.1 reads a BOM-less .ps1 FILE in the machine ANSI codepage, so a stray non-ASCII byte mis-decodes and aborts on a non-English box - exactly how the pf-vdisplay driver install silently failed. A compiled subcommand drives the same external tools (certutil/pnputil/nefconc/schtasks/netsh/icacls) as fixed string literals, with no file-codepage surface. (The .iss's INLINE -Command PowerShell is a command-line string, not a file read, so it's unaffected and stays.) - windows/install.rs: faithful port - cert trust, gated nefconc node create + pnputil for pf-vdisplay; pnputil per-inf for gamepads; web-password ACL, the PunktfunkWeb task (generated UTF-16 XML), firewall rule, start. Best-effort (a hiccup warns, never aborts). - punktfunk-host.iss [Run]: call the exe instead of `powershell -File`; drop the web-setup.ps1 staging + WebSetup define; WebSetupParams emits --app-dir/--password-file. - pack-host-installer.ps1: stop copying the three install scripts into the stages. - delete the three .ps1 files. The `mod install;` + dispatch arms in main.rs landed in the preceding docs commit (swept up by a concurrent commit); this commit adds the module + installer wiring. CI-compile-validated via windows-host; the install path is on-glass-validated on the next canary install (the test box is offline). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/windows/install.rs | 399 ++++++++++++++++++ packaging/windows/install-gamepad-drivers.ps1 | 50 --- packaging/windows/install-pf-vdisplay.ps1 | 82 ---- packaging/windows/pack-host-installer.ps1 | 12 +- packaging/windows/punktfunk-host.iss | 21 +- scripts/windows/web-setup.ps1 | 110 ----- 6 files changed, 411 insertions(+), 263 deletions(-) create mode 100644 crates/punktfunk-host/src/windows/install.rs delete mode 100644 packaging/windows/install-gamepad-drivers.ps1 delete mode 100644 packaging/windows/install-pf-vdisplay.ps1 delete mode 100644 scripts/windows/web-setup.ps1 diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs new file mode 100644 index 0000000..02a92ca --- /dev/null +++ b/crates/punktfunk-host/src/windows/install.rs @@ -0,0 +1,399 @@ +//! `punktfunk-host driver install` / `web setup` - the install-time work the Windows installer's Inno +//! `[Run]` section delegates to the host EXE instead of locale-parsed PowerShell *files*. +//! +//! Why: Windows PowerShell 5.1 reads a BOM-less `.ps1` *file* in the machine's ANSI codepage, so on a +//! non-English locale a stray non-ASCII byte mis-decodes and the script aborts "unterminated string" - +//! exactly how the pf-vdisplay driver install silently failed on a German box. A compiled subcommand has +//! no such surface: the external tools it drives (`certutil`/`pnputil`/`nefconc`/`schtasks`/`netsh`/ +//! `icacls`) are fixed string literals, not a file parsed in some codepage. (The installer's *inline* +//! `-Command` PowerShell in the `.iss` is unaffected - that's a command-line string, not a file read - +//! so it stays.) Sits next to `service install` (`service.rs`), the established Rust-owns-install pattern. +//! +//! Everything here is BEST-EFFORT: a hiccup warns but returns `Ok` - a non-zero exit would abort the +//! whole installer, and a missing driver only degrades the host to a physical display. + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +// ── arg + command helpers ────────────────────────────────────────────────────────────────────── +fn flag_val(args: &[String], name: &str) -> Option { + args.iter() + .position(|a| a == name) + .and_then(|i| args.get(i + 1)) + .cloned() +} +fn flag_present(args: &[String], name: &str) -> bool { + args.iter().any(|a| a == name) +} +/// Run a command, discard output, return whether it succeeded. +fn run_quiet(cmd: &str, args: &[&str]) -> bool { + Command::new(cmd) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} +/// Run a command, capture stdout (lossy UTF-8); empty on failure. +fn run_capture(cmd: &str, args: &[&str]) -> String { + Command::new(cmd) + .args(args) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() +} + +// ── `driver install [--gamepad] --dir ` ───────────────────────────────────────────────── +pub fn driver_main(args: &[String]) -> Result<()> { + match args.first().map(String::as_str) { + Some("install") => driver_install(&args[1..]), + _ => bail!("usage: punktfunk-host driver install --dir [--gamepad]"), + } +} + +fn driver_install(args: &[String]) -> Result<()> { + let dir = + PathBuf::from(flag_val(args, "--dir").context("driver install: --dir required")?); + let gamepad = flag_present(args, "--gamepad"); + let (what, res) = if gamepad { + ("gamepad", install_gamepad(&dir)) + } else { + ("pf-vdisplay", install_pf_vdisplay(&dir)) + }; + if let Err(e) = res { + // Never abort the installer on a driver failure (matches the old best-effort PS scripts). + eprintln!("warning: {what} driver install: {e:#} (the host degrades without it)"); + } + Ok(()) +} + +/// Trust the bundled self-signed driver cert: machine `Root` (so the chain validates) + `TrustedPublisher` +/// (so PnP installs without a prompt). +fn trust_cert(dir: &Path) { + match first_with_ext(dir, "cer") { + Some(cer) => { + let cer = cer.to_string_lossy().into_owned(); + for store in ["Root", "TrustedPublisher"] { + if !run_quiet("certutil", &["-addstore", "-f", store, &cer]) { + eprintln!("warning: certutil -addstore {store} failed for {cer}"); + } + } + println!("trusted driver cert {cer} (Root + TrustedPublisher)"); + } + None => eprintln!( + "warning: no .cer in {} - driver may not install silently", + dir.display() + ), + } +} + +fn install_pf_vdisplay(dir: &Path) -> Result<()> { + let inf = dir.join("pf_vdisplay.inf"); + if !inf.exists() { + bail!("no pf_vdisplay.inf in {}", dir.display()); + } + trust_cert(dir); + // Create the ROOT device node only if absent (a blind re-create spawns a phantom duplicate, and the + // host binds interface index 0). ALWAYS nefconc (a clean ROOT\DISPLAY node), NEVER devgen (which makes + // persistent SWD\DEVGEN software devices that survive reboot + registry deletion). + if pf_vdisplay_present() { + println!("pf-vdisplay device node already present - leaving it."); + } else if let Some(nef) = first_named(dir, "nefconc.exe") { + let (class, guid) = inf_class(&inf); + let ok = run_quiet( + &nef.to_string_lossy(), + &[ + "--create-device-node", + "--hardware-id", + "root\\pf_vdisplay", + "--class-name", + &class, + "--class-guid", + &guid, + ], + ); + if ok { + println!("created root\\pf_vdisplay device node (nefconc)"); + } else { + eprintln!("warning: nefconc --create-device-node failed"); + } + } else { + eprintln!( + "warning: nefconc.exe not found in {} - cannot create the device node", + dir.display() + ); + } + // Stage + bind the driver (idempotent; re-staging the same .inf is harmless). + if run_quiet("pnputil", &["/add-driver", &inf.to_string_lossy(), "/install"]) { + println!("pnputil /add-driver pf_vdisplay.inf /install ok"); + } else { + eprintln!("warning: pnputil /add-driver /install failed (driver may not have installed)"); + } + Ok(()) +} + +fn install_gamepad(dir: &Path) -> Result<()> { + let infs: Vec = std::fs::read_dir(dir) + .with_context(|| format!("read {}", dir.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("inf"))) + .collect(); + if infs.is_empty() { + bail!("no driver .inf in {}", dir.display()); + } + trust_cert(dir); + // Add each package to the store - no /install, no device node: the host SwDeviceCreate's the + // per-session devnode when a client forwards a pad, so PnP binds the store driver on demand. + for inf in &infs { + if run_quiet("pnputil", &["/add-driver", &inf.to_string_lossy()]) { + println!("pnputil /add-driver {} ok", file_name(inf)); + } else { + eprintln!("warning: pnputil /add-driver {} failed", inf.display()); + } + } + Ok(()) +} + +/// Is a punktfunk virtual-display device already enumerated? Matches the device ID / description, which +/// are NOT localized, so the substring check is locale-safe. +fn pf_vdisplay_present() -> bool { + let lo = run_capture("pnputil", &["/enum-devices", "/class", "Display"]).to_ascii_lowercase(); + lo.contains("pf_vdisplay") || lo.contains("punktfunk virtual display") +} + +/// Read `Class` + `ClassGuid` from an INF so the node matches the shipped driver; falls back to Display. +fn inf_class(inf: &Path) -> (String, String) { + let text = std::fs::read_to_string(inf).unwrap_or_default(); + let (mut class, mut guid) = (None, None); + for line in text.lines() { + let t = line.trim(); + if let Some(eq) = t.find('=') { + let key = t[..eq].trim().to_ascii_lowercase(); + let val = t[eq + 1..] + .split(';') + .next() + .unwrap_or("") + .trim() + .to_string(); + match key.as_str() { + "class" => class = Some(val), + "classguid" => guid = Some(val), + _ => {} + } + } + } + ( + class + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "Display".into()), + guid.filter(|g| !g.is_empty()) + .unwrap_or_else(|| "{4d36e968-e325-11ce-bfc1-08002be10318}".into()), + ) +} + +// ── `web setup --app-dir [--password-file ]` ──────────────────────────────────────── +const WEB_TASK: &str = "PunktfunkWeb"; + +pub fn web_main(args: &[String]) -> Result<()> { + match args.first().map(String::as_str) { + Some("setup") => web_setup(&args[1..]), + _ => bail!("usage: punktfunk-host web setup --app-dir [--password-file ]"), + } +} + +fn web_setup(args: &[String]) -> Result<()> { + let app_dir = + PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir required")?); + let pw_file = flag_val(args, "--password-file"); + let data_dir = crate::gamestream::config_dir(); + std::fs::create_dir_all(&data_dir).ok(); + let pw_path = data_dir.join("web-password"); + let token_path = data_dir.join("mgmt-token"); + + // 1. login password + set_web_password(&pw_path, pw_file.as_deref()); + // 2. (upgrade-safe) stop any running console so the new task binds :3000 + the files unlock + stop_web_console(); + // 3. register the PunktfunkWeb scheduled task + let cmd = app_dir.join("web").join("web-run.cmd"); + if !cmd.exists() { + bail!("web launcher missing: {}", cmd.display()); + } + register_web_task(&cmd)?; + // 4. firewall: inbound TCP 3000 + if !run_quiet( + "netsh", + &[ + "advfirewall", + "firewall", + "add", + "rule", + "name=punktfunk web console (TCP 3000)", + "dir=in", + "action=allow", + "protocol=TCP", + "localport=3000", + ], + ) { + eprintln!("warning: could not add the firewall rule for TCP 3000"); + } + // 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise) + for _ in 0..30 { + if token_path.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); + println!("web console set up + started (http://:3000)"); + Ok(()) +} + +/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback. +/// Writes `PUNKTFUNK_UI_PASSWORD=\n` (LF, no BOM) + ACLs it to Administrators + SYSTEM only. +fn set_web_password(pw_path: &Path, pw_file: Option<&str>) { + let password = pw_file + .and_then(|f| std::fs::read_to_string(f).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| { + if pw_path.exists() { + println!("keeping existing web console password"); + None + } else { + Some(random_password()) + } + }); + if let Some(pw) = password { + if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() { + eprintln!("warning: could not write {}", pw_path.display()); + return; + } + // Lock down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18). + let p = pw_path.to_string_lossy(); + run_quiet( + "icacls", + &[ + &p, + "/inheritance:r", + "/grant:r", + "*S-1-5-32-544:F", + "*S-1-5-18:F", + ], + ); + } +} + +/// 20-char URL/shell-safe password (no `/ + =`), like web-init.sh / the old web-setup.ps1. +fn random_password() -> String { + use base64::Engine; + use rand::RngCore; + let mut b = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut b); + base64::engine::general_purpose::STANDARD + .encode(b) + .chars() + .filter(|c| !matches!(c, '/' | '+' | '=')) + .take(20) + .collect() +} + +/// Stop + reap a running console before re-registering (upgrade-safe): end the task AND kill the :3000 +/// listener owner (runtime-agnostic - a prior install may have run node vs the current bun). The listener +/// is identified by the wildcard foreign address (`0.0.0.0:0`/`[::]:0`), so the localized state word +/// ("LISTENING"/"ABHOEREN"/...) is never parsed. +fn stop_web_console() { + run_quiet("schtasks", &["/end", "/tn", WEB_TASK]); + for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() { + let toks: Vec<&str> = line.split_whitespace().collect(); + if toks.len() >= 5 + && toks[0].eq_ignore_ascii_case("tcp") + && toks[1].ends_with(":3000") + && (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0") + { + let pid = toks[toks.len() - 1]; + if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) { + run_quiet("taskkill", &["/PID", pid, "/F"]); + } + } + } + std::thread::sleep(std::time::Duration::from_secs(1)); +} + +/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`, +/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM. +fn register_web_task(cmd: &Path) -> Result<()> { + let xml = format!( + "\n\ +\n\ + punktfunk web management console (Nitro SSR on bun, :3000)\n\ + true\n\ + S-1-5-18HighestAvailable\n\ + \n\ + IgnoreNew\n\ + false\n\ + false\n\ + true\n\ + PT0S\n\ + PT1M10\n\ + \n\ + {}\n\ +", + xml_escape(&cmd.to_string_lossy()) + ); + let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml"); + write_utf16le_bom(&xml_path, &xml)?; + let ok = run_quiet( + "schtasks", + &[ + "/create", + "/tn", + WEB_TASK, + "/xml", + &xml_path.to_string_lossy(), + "/f", + ], + ); + let _ = std::fs::remove_file(&xml_path); + if ok { + println!("registered scheduled task {WEB_TASK} -> {}", cmd.display()); + Ok(()) + } else { + bail!("schtasks /create {WEB_TASK} failed") + } +} + +fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> { + let mut bytes = vec![0xFFu8, 0xFE]; // UTF-16LE BOM + for u in s.encode_utf16() { + bytes.extend_from_slice(&u.to_le_bytes()); + } + std::fs::write(path, bytes).with_context(|| format!("write {}", path.display())) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn first_with_ext(dir: &Path, ext: &str) -> Option { + std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case(ext))) +} +fn first_named(dir: &Path, name: &str) -> Option { + let p = dir.join(name); + p.exists().then_some(p) +} +fn file_name(p: &Path) -> String { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} diff --git a/packaging/windows/install-gamepad-drivers.ps1 b/packaging/windows/install-gamepad-drivers.ps1 deleted file mode 100644 index 6815ba8..0000000 --- a/packaging/windows/install-gamepad-drivers.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -<# -.SYNOPSIS - Install the bundled punktfunk virtual-gamepad UMDF drivers - pf_dualsense (DualSense + DualShock 4, - one type-aware HID driver) and pf_xusb (Xbox 360 XUSB companion for classic XInput). Runs ELEVATED - at setup time (invoked from the installer's [Run] section). Best-effort: warns and exits 0 on any - failure, so a driver hiccup never aborts the whole install (gamepad input degrades gracefully - a - session still streams without a pad). - -.DESCRIPTION - -Dir holds the staged payload: pf_dualsense.{inf,cat,dll}, pf_xusb.{inf,cat,dll}, and the signing - .cer. Steps: - 1. Trust the self-signed driver cert (machine Root + TrustedPublisher) so pnputil adds it silently. - 2. pnputil /add-driver each .inf - adds the package to the driver store. (No /install or device-node - creation: the host SwDeviceCreate's the per-session devnodes itself when a client forwards a pad, - so PnP binds the store driver on demand.) - - ASCII-only on purpose: this is run by the installer via Windows PowerShell 5.1, which mis-decodes a - BOM-less UTF-8 non-ASCII char (e.g. an em-dash) as a smart-quote and breaks parsing. - -.EXAMPLE - powershell -ExecutionPolicy Bypass -File install-gamepad-drivers.ps1 -Dir C:\path\to\gamepad -#> -[CmdletBinding()] -param([string]$Dir = $PSScriptRoot) -# Never abort the installer on a driver failure. -$ErrorActionPreference = 'Continue' -trap { Write-Warning "gamepad driver install error: $_"; exit 0 } - -# 1) Trust the self-signed driver cert (Root so the chain validates + TrustedPublisher so pnputil adds -# it without a prompt). -$cer = Get-ChildItem -Path $Dir -Filter *.cer -ErrorAction SilentlyContinue | Select-Object -First 1 -if ($cer) { - Write-Host "==> importing $($cer.Name) to Root + TrustedPublisher" - certutil.exe -addstore -f Root "$($cer.FullName)" | Out-Null - certutil.exe -addstore -f TrustedPublisher "$($cer.FullName)" | Out-Null -} -else { Write-Warning "no .cer in $Dir; drivers may not install silently (untrusted publisher)" } - -# 2) Add each driver package to the store (idempotent; re-adding the same .inf is harmless). -$infs = Get-ChildItem -Path $Dir -Filter *.inf -ErrorAction SilentlyContinue -if (-not $infs) { Write-Warning "no driver .inf in $Dir; skipping gamepad driver install."; exit 0 } -foreach ($inf in $infs) { - Write-Host "==> pnputil /add-driver $($inf.Name)" - & pnputil.exe /add-driver "$($inf.FullName)" - $rc = $LASTEXITCODE - if ($rc -eq 3010) { Write-Host " added; a reboot is recommended." } - elseif ($rc -ne 0) { Write-Warning "pnputil /add-driver $($inf.Name) returned $rc" } -} - -exit 0 diff --git a/packaging/windows/install-pf-vdisplay.ps1 b/packaging/windows/install-pf-vdisplay.ps1 deleted file mode 100644 index b541ff0..0000000 --- a/packaging/windows/install-pf-vdisplay.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -<# -.SYNOPSIS - Install the bundled pf-vdisplay (punktfunk) virtual-display driver - our own all-Rust UMDF IddCx - indirect-display driver, built from source per release (packaging/windows/build-pf-vdisplay.ps1). - Runs ELEVATED at setup time (invoked from the installer's [Run] section). Best-effort: warns and exits - 0 on any failure - the host degrades to a physical display without a virtual display, so a driver - hiccup must never abort the whole install. - -.DESCRIPTION - -Dir holds the staged payload (pf_vdisplay.inf/.cat/.dll + signing .cer + nefconc.exe). Steps: - 1. Trust the self-signed driver cert (machine Root + TrustedPublisher) so PnP installs it silently - (the punktfunk-driver cert the build signs the driver + catalog with). - 2. Create the ROOT device node IF ABSENT (gated - a blind re-create spawns a phantom duplicate, and - the host's open_device() binds interface index 0; crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs). - ALWAYS via nefconc (a clean ROOT\DISPLAY node) - NEVER devgen, which makes persistent SWD\DEVGEN - software devices that survive reboot + registry deletion and resurrect on every driver install. - 3. Stage + bind the driver (pnputil /add-driver /install - modern, in-box, idempotent). - - Class/ClassGuid are read from the .inf so they always match the shipped driver. - -.EXAMPLE - powershell -ExecutionPolicy Bypass -File install-pf-vdisplay.ps1 -Dir C:\path\to\pf-vdisplay -#> -[CmdletBinding()] -param( - [string]$Dir = $PSScriptRoot, - [string]$HardwareId = 'root\pf_vdisplay' # matches pf_vdisplay.inf [Standard.NTamd64] -) -# Never abort the installer on a driver failure. -$ErrorActionPreference = 'Continue' -trap { Write-Warning "pf-vdisplay install error: $_"; exit 0 } - -function Test-PfVdisplayPresent { - $devs = Get-PnpDevice -Class Display -PresentOnly -ErrorAction SilentlyContinue - foreach ($d in $devs) { - $hw = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName 'DEVPKEY_Device_HardwareIds' ` - -ErrorAction SilentlyContinue).Data - if ($hw -and ($hw | Where-Object { $_ -ieq $HardwareId })) { return $true } - } - return $false -} - -$inf = Get-ChildItem -Path $Dir -Filter pf_vdisplay.inf -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -$cer = Get-ChildItem -Path $Dir -Filter *.cer -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -$nef = Get-ChildItem -Path $Dir -Filter 'nefconc.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -if (-not $inf) { Write-Warning "no pf_vdisplay.inf in $Dir; skipping driver install."; exit 0 } -Write-Host "pf-vdisplay inf: $($inf.FullName)" - -# 1) Trust the self-signed driver cert (a self-signed driver needs the cert in BOTH the machine Root -# store, so the chain validates, and TrustedPublisher, so PnP installs without a prompt). -if ($cer) { - Write-Host "==> importing $($cer.Name) to Root + TrustedPublisher" - certutil.exe -addstore -f Root "$($cer.FullName)" | Out-Null - certutil.exe -addstore -f TrustedPublisher "$($cer.FullName)" | Out-Null -} -else { Write-Warning "no .cer in $Dir - driver may not install silently (untrusted publisher)" } - -# 2) Create the root device node only if it isn't already there. nefconc, NEVER devgen. -if (Test-PfVdisplayPresent) { - Write-Host "pf-vdisplay device node already present - leaving it as-is." -} -elseif ($nef) { - $infText = Get-Content -Raw $inf.FullName - $classGuid = ([regex]::Match($infText, '(?im)^\s*ClassGuid\s*=\s*(\{[0-9A-Fa-f-]+\})')).Groups[1].Value - $className = ([regex]::Match($infText, '(?im)^\s*Class\s*=\s*([^\s;]+)')).Groups[1].Value - if (-not $classGuid) { $classGuid = '{4d36e968-e325-11ce-bfc1-08002be10318}'; $className = 'Display' } # Display class - Write-Host "==> nefconc --create-device-node hwid=$HardwareId class=$className $classGuid" - & $nef.FullName --create-device-node --hardware-id $HardwareId --class-name $className --class-guid $classGuid - if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { - Write-Warning "nefconc --create-device-node returned $LASTEXITCODE" - } -} -else { Write-Warning "nefconc.exe not found in $Dir - cannot create the pf-vdisplay device node." } - -# 3) Stage + bind the driver (idempotent; re-staging the same .inf is harmless). -Write-Host "==> pnputil /add-driver $($inf.Name) /install" -& pnputil.exe /add-driver "$($inf.FullName)" /install -$rc = $LASTEXITCODE -if ($rc -eq 3010) { Write-Host " driver installed; a reboot is recommended." } -elseif ($rc -ne 0) { Write-Warning "pnputil /add-driver returned $rc" } - -exit 0 diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 07b99fb..3264d94 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -152,7 +152,7 @@ if (-not $NoDriver) { & (Join-Path $here 'build-pf-vdisplay.ps1') -Out $built $stage = Join-Path $OutDir 'stage' & (Join-Path $here 'stage-pf-vdisplay.ps1') -OutDir $stage -VendorDir $built - Copy-Item (Join-Path $here 'install-pf-vdisplay.ps1') (Join-Path $stage 'install-pf-vdisplay.ps1') -Force + # The installer runs `punktfunk-host.exe driver install --dir {tmp}\pfvdisplay` (not a staged .ps1). $defines += "/DStageDir=$stage" } else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" } @@ -160,8 +160,9 @@ else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay # --- build (from source) + stage the punktfunk virtual-gamepad UMDF drivers -------------------- # pf-dualsense (DualSense / DualShock 4) + pf-xusb (Xbox 360 / XInput) are members of the same drivers # workspace as pf-vdisplay, built from source per release (build-gamepad-drivers.ps1) - same anti-stale -# reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. install- -# gamepad-drivers.ps1 adds each to the store (the host SwDeviceCreate's the per-session devnodes). +# reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The +# installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host +# SwDeviceCreate's the per-session devnodes). if (-not $NoDriver) { $gpBuilt = Join-Path $OutDir 'gamepad-built' # -SkipBuild: build-pf-vdisplay.ps1 above already `cargo build`s the WHOLE drivers workspace (incl. @@ -171,7 +172,6 @@ if (-not $NoDriver) { if (Test-Path $gpStage) { Remove-Item -Recurse -Force $gpStage } New-Item -ItemType Directory -Force -Path $gpStage | Out-Null Copy-Item (Join-Path $gpBuilt '*') $gpStage -Force - Copy-Item (Join-Path $here 'install-gamepad-drivers.ps1') (Join-Path $gpStage 'install-gamepad-drivers.ps1') -Force $defines += "/DGamepadStageDir=$gpStage" Write-Host "==> built + staged gamepad UMDF drivers -> $gpStage" } @@ -208,13 +208,11 @@ if ($WebDir -and (Test-Path $WebDir) -and $BunExe -and (Test-Path $BunExe)) { $bunStage = Join-Path $OutDir 'bun.exe' Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force $webRun = Join-Path $OutDir 'web-run.cmd' - $webSetup = Join-Path $OutDir 'web-setup.ps1' Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force - Copy-Item (Join-Path $repoRoot 'scripts\windows\web-setup.ps1') -Destination $webSetup -Force + # The console is provisioned by `punktfunk-host.exe web setup` (not a staged web-setup.ps1). $defines += "/DWebDir=$webStage" $defines += "/DBunExe=$bunStage" $defines += "/DWebRunCmd=$webRun" - $defines += "/DWebSetup=$webSetup" Write-Host "bundling the web console from $WebDir (+ bun $BunExe)" } else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" } diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 0ec3161..2170b76 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -33,9 +33,6 @@ #ifndef WebRunCmd #define WebRunCmd "..\..\scripts\windows\web-run.cmd" #endif -#ifndef WebSetup - #define WebSetup "..\..\scripts\windows\web-setup.ps1" -#endif ; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional. #ifdef StageDir #define WithDriver @@ -114,12 +111,11 @@ Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion #ifdef WithWeb ; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps ; bundled in, no node_modules) -> {app}\web\.output, a portable bun runtime -> {app}\bun\bun.exe, and -; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. web-setup.ps1 (the provisioner) -; goes to {tmp} and is removed after install. +; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. (`punktfunk-host.exe web setup` +; provisions the console at install time - no staged provisioner script.) Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion -Source: "{#WebSetup}"; DestDir: "{tmp}"; DestName: "web-setup.ps1"; Flags: deleteafterinstall #endif #ifdef WithDriver ; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install. @@ -148,14 +144,12 @@ Root: HKLM64; Subkey: "SOFTWARE\Khronos\Vulkan\ImplicitLayers"; ValueType: dword [Run] #ifdef WithDriver -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\pfvdisplay\install-pf-vdisplay.ps1"" -Dir ""{tmp}\pfvdisplay"""; \ +Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --dir ""{tmp}\pfvdisplay"""; WorkingDir: "{app}"; \ StatusMsg: "Installing the pf-vdisplay virtual display driver..."; \ Flags: runhidden waituntilterminated; Tasks: installdriver #endif #ifdef WithGamepad -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\gamepad\install-gamepad-drivers.ps1"" -Dir ""{tmp}\gamepad"""; \ +Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --gamepad --dir ""{tmp}\gamepad"""; WorkingDir: "{app}"; \ StatusMsg: "Installing the virtual gamepad drivers..."; \ Flags: runhidden waituntilterminated; Tasks: installgamepad #endif @@ -169,8 +163,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: " ; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd ; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure), ; open TCP 3000, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install. -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\web-setup.ps1"" {code:WebSetupParams}"; \ +Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \ StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated #endif @@ -265,9 +258,9 @@ function WebSetupParams(Param: String): String; begin { Pass the password to web-setup.ps1 via a temp file, not the cmdline (which lands in the install log). Only on a fresh install - on upgrade web-setup keeps the existing file. } - Result := '-AppDir "' + ExpandConstant('{app}') + '"'; + Result := '--app-dir "' + ExpandConstant('{app}') + '"'; if FreshWebInstall then - Result := Result + ' -PasswordFile "' + ExpandConstant('{tmp}\webpw.txt') + '"'; + Result := Result + ' --password-file "' + ExpandConstant('{tmp}\webpw.txt') + '"'; end; #endif diff --git a/scripts/windows/web-setup.ps1 b/scripts/windows/web-setup.ps1 deleted file mode 100644 index b33a5c3..0000000 --- a/scripts/windows/web-setup.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# - Provision the punktfunk web console after the host installer has laid down its payload - ({app}\web\.output, {app}\bun\bun.exe, {app}\web\web-run.cmd). Invoked elevated from the - installer's [Run] section; idempotent (safe to re-run on upgrade). - - 1. Sets the console login password file %ProgramData%\punktfunk\web-password - (PUNKTFUNK_UI_PASSWORD=...), ACL'd to Administrators + SYSTEM only: - - if -PasswordFile points at a non-empty temp file (a FRESH install collected one on the - wizard page), use that; - - else if the file already exists (UPGRADE), keep it untouched; - - else generate a random one (fallback, so the console never boots auth-misconfigured). - 2. Registers the PunktfunkWeb scheduled task: at boot, as SYSTEM/Highest, restart-on-failure, - no execution time limit (a long-running server), running {app}\web\web-run.cmd. - 3. Opens inbound TCP 3000 (the console port) on all profiles. - 4. Waits briefly for the host's mgmt token, then starts the task. - - The mgmt bearer token is NOT managed here - the host owns %ProgramData%\punktfunk\mgmt-token - (crates/punktfunk-host/src/mgmt_token.rs writes it on `serve`); web-run.cmd sources it. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)][string]$AppDir, # the installer's {app} - [string]$PasswordFile # temp file with the chosen password (fresh install) -) -$ErrorActionPreference = 'Stop' - -$TaskName = 'PunktfunkWeb' -$dataDir = Join-Path $env:ProgramData 'punktfunk' -$pwFile = Join-Path $dataDir 'web-password' -$tokenFile = Join-Path $dataDir 'mgmt-token' -New-Item -ItemType Directory -Force -Path $dataDir | Out-Null - -function New-RandomPassword { - # URL/shell-safe (no /+=) so it's a clean env-file value and cmd-token, like scripts/web-init.sh. - $bytes = New-Object byte[] 24 - ([System.Security.Cryptography.RandomNumberGenerator]::Create()).GetBytes($bytes) - $s = [Convert]::ToBase64String($bytes) -replace '[/+=]', '' - return $s.Substring(0, [Math]::Min(20, $s.Length)) -} - -function Stop-WebConsole { - # On an upgrade a console is already running. Stop + reap it before re-registering so (a) the new - # task can bind :3000 (else the old server keeps it and the new one restart-loops on EADDRINUSE) and - # (b) the installer can overwrite .output / web-run.cmd / bun.exe (a held file blocks the copy). A - # prior install may have run a DIFFERENT runtime (node vs bun), so kill by the script it serves AND - # by the :3000 owner - the latter is runtime-agnostic and future-proofs the next runtime swap. - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - Get-CimInstance Win32_Process -Filter "Name='bun.exe' OR Name='node.exe'" -ErrorAction SilentlyContinue | - Where-Object { $_.CommandLine -match 'index\.mjs' } | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } - Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty OwningProcess -Unique | - ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } - Start-Sleep -Seconds 1 -} - -# --- 1. login password ----------------------------------------------------------------------- -$password = $null -if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) { - $password = (Get-Content -LiteralPath $PasswordFile -Raw).Trim() -} -if (-not $password) { - if (Test-Path -LiteralPath $pwFile) { - Write-Host "keeping existing web console password ($pwFile)" - } - else { - $password = New-RandomPassword - Write-Host "no password supplied - generated a random web console password" - } -} -if ($password) { - # LF, no BOM (UTF8) so web-run.cmd's `for /f` reads a clean value. - [IO.File]::WriteAllText($pwFile, "PUNKTFUNK_UI_PASSWORD=$password`n") - # Lock it down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18). - & icacls $pwFile /inheritance:r /grant:r '*S-1-5-32-544:F' '*S-1-5-18:F' | Out-Null -} - -# --- 2. PunktfunkWeb scheduled task ---------------------------------------------------------- -Stop-WebConsole # reap any running (possibly old-runtime) console before re-registering (upgrade-safe) -$cmd = Join-Path $AppDir 'web\web-run.cmd' -if (-not (Test-Path -LiteralPath $cmd)) { throw "web launcher missing: $cmd" } -$action = New-ScheduledTaskAction -Execute $cmd -$trigger = New-ScheduledTaskTrigger -AtStartup -$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest -# RestartCount/Interval cover transient crashes + the brief post-install race before the host has -# written the mgmt token (web-run.cmd exits non-zero until then). No time limit: it's a server. -$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -StartWhenAvailable -RestartInterval (New-TimeSpan -Minutes 1) -RestartCount 10 ` - -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal ` - -Settings $settings -Description 'punktfunk web management console (Nitro SSR on bun, :3000)' ` - -Force | Out-Null -Write-Host "registered scheduled task $TaskName -> $cmd" - -# --- 3. firewall: inbound TCP 3000 ----------------------------------------------------------- -try { - $fwName = 'PunktfunkWeb-TCP-3000' - Get-NetFirewallRule -Name $fwName -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue - New-NetFirewallRule -Name $fwName -DisplayName 'punktfunk web console (TCP 3000)' ` - -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -Profile Any | Out-Null - Write-Host "firewall: allowed inbound TCP 3000" -} -catch { Write-Warning "could not add the firewall rule for TCP 3000: $($_.Exception.Message)" } - -# --- 4. wait for the host's mgmt token, then start ------------------------------------------- -# The host service was installed+started just before this; give it a moment to write the token so -# the first start serves immediately (otherwise restart-on-failure picks it up within a minute). -for ($i = 0; $i -lt 30 -and -not (Test-Path -LiteralPath $tokenFile); $i++) { Start-Sleep -Seconds 1 } -Start-ScheduledTask -TaskName $TaskName -Write-Host "started $TaskName (console on http://:3000)"