feat(windows-installer): move driver + web install into the host exe (ASCII root fix)
apple / swift (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Failing after 28s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Failing after 28s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
docker / deploy-docs (push) Successful in 17s
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 <stage>` and `web setup --app-dir <app> [--password-file <f>]` (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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||||
|
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 <stage>` ─────────────────────────────────────────────────
|
||||||
|
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 <stage> [--gamepad]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn driver_install(args: &[String]) -> Result<()> {
|
||||||
|
let dir =
|
||||||
|
PathBuf::from(flag_val(args, "--dir").context("driver install: --dir <stage> 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<PathBuf> = 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 <app> [--password-file <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 <app> [--password-file <file>]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_setup(args: &[String]) -> Result<()> {
|
||||||
|
let app_dir =
|
||||||
|
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> 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://<host-ip>:3000)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
||||||
|
/// Writes `PUNKTFUNK_UI_PASSWORD=<pw>\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!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||||
|
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||||
|
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :3000)</Description></RegistrationInfo>\n\
|
||||||
|
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
|
||||||
|
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
||||||
|
<Settings>\n\
|
||||||
|
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||||
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
|
||||||
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
|
||||||
|
<StartWhenAvailable>true</StartWhenAvailable>\n\
|
||||||
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||||
|
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||||
|
</Settings>\n\
|
||||||
|
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
||||||
|
</Task>",
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -152,7 +152,7 @@ if (-not $NoDriver) {
|
|||||||
& (Join-Path $here 'build-pf-vdisplay.ps1') -Out $built
|
& (Join-Path $here 'build-pf-vdisplay.ps1') -Out $built
|
||||||
$stage = Join-Path $OutDir 'stage'
|
$stage = Join-Path $OutDir 'stage'
|
||||||
& (Join-Path $here 'stage-pf-vdisplay.ps1') -OutDir $stage -VendorDir $built
|
& (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"
|
$defines += "/DStageDir=$stage"
|
||||||
}
|
}
|
||||||
else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" }
|
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 --------------------
|
# --- 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
|
# 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
|
# 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-
|
# reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The
|
||||||
# gamepad-drivers.ps1 adds each to the store (the host SwDeviceCreate's the per-session devnodes).
|
# installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host
|
||||||
|
# SwDeviceCreate's the per-session devnodes).
|
||||||
if (-not $NoDriver) {
|
if (-not $NoDriver) {
|
||||||
$gpBuilt = Join-Path $OutDir 'gamepad-built'
|
$gpBuilt = Join-Path $OutDir 'gamepad-built'
|
||||||
# -SkipBuild: build-pf-vdisplay.ps1 above already `cargo build`s the WHOLE drivers workspace (incl.
|
# -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 }
|
if (Test-Path $gpStage) { Remove-Item -Recurse -Force $gpStage }
|
||||||
New-Item -ItemType Directory -Force -Path $gpStage | Out-Null
|
New-Item -ItemType Directory -Force -Path $gpStage | Out-Null
|
||||||
Copy-Item (Join-Path $gpBuilt '*') $gpStage -Force
|
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"
|
$defines += "/DGamepadStageDir=$gpStage"
|
||||||
Write-Host "==> built + staged gamepad UMDF drivers -> $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'
|
$bunStage = Join-Path $OutDir 'bun.exe'
|
||||||
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
|
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
|
||||||
$webRun = Join-Path $OutDir 'web-run.cmd'
|
$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-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 += "/DWebDir=$webStage"
|
||||||
$defines += "/DBunExe=$bunStage"
|
$defines += "/DBunExe=$bunStage"
|
||||||
$defines += "/DWebRunCmd=$webRun"
|
$defines += "/DWebRunCmd=$webRun"
|
||||||
$defines += "/DWebSetup=$webSetup"
|
|
||||||
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
|
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
|
||||||
}
|
}
|
||||||
else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" }
|
else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" }
|
||||||
|
|||||||
@@ -33,9 +33,6 @@
|
|||||||
#ifndef WebRunCmd
|
#ifndef WebRunCmd
|
||||||
#define WebRunCmd "..\..\scripts\windows\web-run.cmd"
|
#define WebRunCmd "..\..\scripts\windows\web-run.cmd"
|
||||||
#endif
|
#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.
|
; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional.
|
||||||
#ifdef StageDir
|
#ifdef StageDir
|
||||||
#define WithDriver
|
#define WithDriver
|
||||||
@@ -114,12 +111,11 @@ Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
|||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
|
; 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
|
; 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)
|
; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. (`punktfunk-host.exe web setup`
|
||||||
; goes to {tmp} and is removed after install.
|
; provisions the console at install time - no staged provisioner script.)
|
||||||
Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
|
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
|
||||||
Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion
|
Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion
|
||||||
Source: "{#WebSetup}"; DestDir: "{tmp}"; DestName: "web-setup.ps1"; Flags: deleteafterinstall
|
|
||||||
#endif
|
#endif
|
||||||
#ifdef WithDriver
|
#ifdef WithDriver
|
||||||
; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install.
|
; 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]
|
[Run]
|
||||||
#ifdef WithDriver
|
#ifdef WithDriver
|
||||||
Filename: "powershell.exe"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --dir ""{tmp}\pfvdisplay"""; WorkingDir: "{app}"; \
|
||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\pfvdisplay\install-pf-vdisplay.ps1"" -Dir ""{tmp}\pfvdisplay"""; \
|
|
||||||
StatusMsg: "Installing the pf-vdisplay virtual display driver..."; \
|
StatusMsg: "Installing the pf-vdisplay virtual display driver..."; \
|
||||||
Flags: runhidden waituntilterminated; Tasks: installdriver
|
Flags: runhidden waituntilterminated; Tasks: installdriver
|
||||||
#endif
|
#endif
|
||||||
#ifdef WithGamepad
|
#ifdef WithGamepad
|
||||||
Filename: "powershell.exe"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --gamepad --dir ""{tmp}\gamepad"""; WorkingDir: "{app}"; \
|
||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\gamepad\install-gamepad-drivers.ps1"" -Dir ""{tmp}\gamepad"""; \
|
|
||||||
StatusMsg: "Installing the virtual gamepad drivers..."; \
|
StatusMsg: "Installing the virtual gamepad drivers..."; \
|
||||||
Flags: runhidden waituntilterminated; Tasks: installgamepad
|
Flags: runhidden waituntilterminated; Tasks: installgamepad
|
||||||
#endif
|
#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
|
; 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),
|
; 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.
|
; open TCP 3000, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
||||||
Filename: "powershell.exe"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
|
||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\web-setup.ps1"" {code:WebSetupParams}"; \
|
|
||||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -265,9 +258,9 @@ function WebSetupParams(Param: String): String;
|
|||||||
begin
|
begin
|
||||||
{ Pass the password to web-setup.ps1 via a temp file, not the cmdline (which lands in the install
|
{ 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. }
|
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
|
if FreshWebInstall then
|
||||||
Result := Result + ' -PasswordFile "' + ExpandConstant('{tmp}\webpw.txt') + '"';
|
Result := Result + ' --password-file "' + ExpandConstant('{tmp}\webpw.txt') + '"';
|
||||||
end;
|
end;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -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://<host-ip>:3000)"
|
|
||||||
Reference in New Issue
Block a user