feat(host/windows,packaging): installer overhaul - branding, VB-CABLE, GameStream choice, driver uninstall
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 3m34s
decky / build-publish (push) Successful in 11s
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m36s
ci / web (push) Successful in 49s
apple / screenshots (push) Successful in 5m20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
windows-host / package (push) Successful in 6m41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
ci / bench (push) Successful in 4m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m13s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m0s
deb / build-publish (push) Successful in 3m6s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 3m34s
decky / build-publish (push) Successful in 11s
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m36s
ci / web (push) Successful in 49s
apple / screenshots (push) Successful in 5m20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
windows-host / package (push) Successful in 6m41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
ci / bench (push) Successful in 4m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m13s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m0s
deb / build-publish (push) Successful in 3m6s
- Modern branded wizard: WizardStyle=modern dynamic windows11 (Inno >= 6.6,
plain-modern fallback for older compilers; CI provisioning upgrades a
pre-6.6 Inno). Brand-mark wizard side panels + header tiles (100-200% DPI)
and a multi-size punktfunk.ico (SetupIconFile + Apps & Features), generated
AND committed by branding/gen-branding.ps1 from the canonical brand geometry.
Gotcha encoded in the script: ISCC rejects all-PNG icons, so entries <= 64px
are classic DIBs (PNG only at 128/256), and the ICO is load-verified.
- VB-CABLE actually ships now: windows-host.yml never set VBCABLE_DIR, so every
published installer silently omitted the virtual mic (broken mic passthrough
in the field). CI provisions the pinned, SHA-256-verified official Pack45
(provision-windows-punktfunk-extras.ps1) and the pack now FAILS on a
supplied-but-invalid dir instead of shipping mic-less again. Attribution per
VB-Audio's bundling grant surfaced in the visible wizard task text (vendor,
vb-cable.com, donationware) on top of the licenses notice.
- GameStream (Moonlight) compat is a wizard task (checked by default) ->
service install --gamestream=on|off writes PUNKTFUNK_HOST_CMD=
serve[ --gamestream] into host.env. Only the two canonical values are ever
rewritten - a hand-customized command line survives upgrades. Silent
installs: /MERGETASKS="!gamestream".
- Driver uninstall (field report: our virtual-device drivers survived
uninstall): new `driver uninstall [--gamepad]` removes the pf-vdisplay
device node(s) + the pf-vdisplay/pf-dualsense/pf-xusb driver-store packages,
wired into [UninstallRun] after service uninstall. Locale-safe by
construction: devices matched on unlocalized VALUES (never pnputil's
localized labels), packages found by INF content scan - validated against a
German-locale box ("Instanz-ID:" parse; 7/7 punktfunk INFs matched, no
foreign hits). VB-CABLE is deliberately left installed (shared third-party
component with its own uninstaller).
Installer compile, cargo check/clippy/fmt, and the ASCII locale gate are green;
the wizard look + uninstall flow still need one on-glass pass on a disposable
box (this box runs the live host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! `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*.
|
||||
//! `punktfunk-host driver install|uninstall` / `web setup` - the install-time work the Windows
|
||||
//! installer's Inno `[Run]`/`[UninstallRun]` sections delegate 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" -
|
||||
@@ -45,11 +46,15 @@ fn run_capture(cmd: &str, args: &[&str]) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── `driver install [--gamepad] --dir <stage>` ─────────────────────────────────────────────────
|
||||
// ── `driver install [--gamepad] --dir <stage>` / `driver uninstall [--gamepad]` ────────────────
|
||||
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]"),
|
||||
Some("uninstall") => driver_uninstall(&args[1..]),
|
||||
_ => bail!(
|
||||
"usage: punktfunk-host driver install --dir <stage> [--gamepad]\n\
|
||||
\x20 punktfunk-host driver uninstall [--gamepad]"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +165,121 @@ fn install_gamepad(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
|
||||
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
|
||||
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
|
||||
// package, or (--gamepad) the pf-dualsense/pf-xusb driver packages (their devnodes are per-session
|
||||
// SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we
|
||||
// never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side
|
||||
// (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf
|
||||
// CONTENT for our driver names, then passed to pnputil by file name.
|
||||
|
||||
fn driver_uninstall(args: &[String]) -> Result<()> {
|
||||
let gamepad = flag_present(args, "--gamepad");
|
||||
let (what, res) = if gamepad {
|
||||
("gamepad", uninstall_gamepad())
|
||||
} else {
|
||||
("pf-vdisplay", uninstall_pf_vdisplay())
|
||||
};
|
||||
if let Err(e) = res {
|
||||
// Same best-effort contract as install: never abort the (un)installer over a driver.
|
||||
eprintln!("warning: {what} driver uninstall: {e:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_pf_vdisplay() -> Result<()> {
|
||||
// 1. Remove the ROOT device node(s) the installer created via nefconc (leaving them would keep
|
||||
// a ghost "punktfunk virtual display" in Device Manager forever — the exact complaint).
|
||||
for id in pf_vdisplay_instance_ids() {
|
||||
if run_quiet("pnputil", &["/remove-device", &id]) {
|
||||
println!("removed device node {id}");
|
||||
} else {
|
||||
eprintln!("warning: pnputil /remove-device {id} failed");
|
||||
}
|
||||
}
|
||||
// 2. Delete the driver package from the driver store.
|
||||
delete_store_drivers(&["pf_vdisplay"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_gamepad() -> Result<()> {
|
||||
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Instance IDs of enumerated punktfunk virtual-display devices. Parses `pnputil /enum-devices`
|
||||
/// per-device blocks (blank-line separated); a block is ours if it mentions the pf_vdisplay
|
||||
/// hardware id / description, and its instance ID is the first line's VALUE (never the localized
|
||||
/// label) — pnputil prints "Instance ID:" (or its translation) first in every block.
|
||||
fn pf_vdisplay_instance_ids() -> Vec<String> {
|
||||
let out = run_capture("pnputil", &["/enum-devices", "/class", "Display"]);
|
||||
let mut ids = Vec::new();
|
||||
for block in out.split("\r\n\r\n").flat_map(|b| b.split("\n\n")) {
|
||||
let lo = block.to_ascii_lowercase();
|
||||
if !lo.contains("pf_vdisplay") && !lo.contains("punktfunk virtual display") {
|
||||
continue;
|
||||
}
|
||||
let Some(first) = block.lines().find(|l| !l.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let Some((_, value)) = first.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let id = value.trim();
|
||||
// Sanity: an instance ID is a backslashed path with no spaces (e.g. ROOT\DISPLAY\0000).
|
||||
if !id.is_empty() && id.contains('\\') && !id.contains(' ') {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of
|
||||
/// `needles` — our driver names are unique enough that a content match identifies the package
|
||||
/// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it
|
||||
/// from any remaining devnodes.
|
||||
fn delete_store_drivers(needles: &[&str]) {
|
||||
let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into());
|
||||
let inf_dir = Path::new(&windir).join("INF");
|
||||
let Ok(entries) = std::fs::read_dir(&inf_dir) else {
|
||||
eprintln!("warning: cannot read {}", inf_dir.display());
|
||||
return;
|
||||
};
|
||||
for path in entries.flatten().map(|e| e.path()) {
|
||||
let name = file_name(&path).to_ascii_lowercase();
|
||||
if !name.starts_with("oem") || !name.ends_with(".inf") {
|
||||
continue;
|
||||
}
|
||||
let text = read_inf_text(&path).to_ascii_lowercase();
|
||||
if !needles.iter().any(|n| text.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
if run_quiet(
|
||||
"pnputil",
|
||||
&["/delete-driver", &name, "/uninstall", "/force"],
|
||||
) {
|
||||
println!("deleted driver package {name}");
|
||||
} else {
|
||||
eprintln!("warning: pnputil /delete-driver {name} /uninstall /force failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// INF files in %WINDIR%\INF are ANSI or UTF-16LE(+BOM); decode either so content matching works.
|
||||
fn read_inf_text(path: &Path) -> String {
|
||||
let bytes = std::fs::read(path).unwrap_or_default();
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
let units: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -87,7 +87,7 @@ fn event_handle(ev: &OnceLock<OwnedHandle>) -> Option<HANDLE> {
|
||||
pub fn main(args: &[String]) -> Result<()> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("run") => run(),
|
||||
Some("install") => install(),
|
||||
Some("install") => install(&args[1..]),
|
||||
Some("uninstall") => uninstall(),
|
||||
Some("start") => sc(&["start", SERVICE_NAME]),
|
||||
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
||||
@@ -96,7 +96,9 @@ pub fn main(args: &[String]) -> Result<()> {
|
||||
eprintln!(
|
||||
"punktfunk-host service — Windows service control\n\n\
|
||||
USAGE:\n\
|
||||
\x20 punktfunk-host service install register the auto-start service + firewall rules\n\
|
||||
\x20 punktfunk-host service install [--gamestream=on|off]\n\
|
||||
\x20 register the auto-start service + firewall rules\n\
|
||||
\x20 (--gamestream sets host.env's PUNKTFUNK_HOST_CMD)\n\
|
||||
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
|
||||
\x20 punktfunk-host service start start the service now\n\
|
||||
\x20 punktfunk-host service stop stop the service\n\
|
||||
@@ -606,12 +608,20 @@ unsafe fn open_log_handle(path: &std::path::Path) -> Result<HANDLE> {
|
||||
|
||||
// ── install / uninstall ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn install() -> Result<()> {
|
||||
fn install(args: &[String]) -> Result<()> {
|
||||
use windows_service::service::{
|
||||
ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceType,
|
||||
};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
// `--gamestream=on|off` (the installer's wizard task): None = flag absent, keep host.env as-is.
|
||||
let gamestream = match args.iter().find_map(|a| a.strip_prefix("--gamestream=")) {
|
||||
Some("on") => Some(true),
|
||||
Some("off") => Some(false),
|
||||
Some(v) => bail!("--gamestream must be 'on' or 'off' (got '{v}')"),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
@@ -653,6 +663,9 @@ fn install() -> Result<()> {
|
||||
}
|
||||
|
||||
ensure_default_host_env()?;
|
||||
if let Some(on) = gamestream {
|
||||
apply_gamestream_choice(on);
|
||||
}
|
||||
add_firewall_rules();
|
||||
|
||||
println!(
|
||||
@@ -721,6 +734,58 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the installer's GameStream choice into host.env's `PUNKTFUNK_HOST_CMD`. Upgrade-safe:
|
||||
/// only an absent line or one of the two canonical values (`serve` / `serve --gamestream`) is
|
||||
/// rewritten — a hand-customized command line is the user's, and stays. Best-effort (warns).
|
||||
fn apply_gamestream_choice(enable: bool) {
|
||||
let path = host_env_path();
|
||||
let desired = if enable {
|
||||
"serve --gamestream"
|
||||
} else {
|
||||
"serve"
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
eprintln!(
|
||||
"warning: could not read {} to apply the GameStream choice",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
|
||||
let current = lines.iter().position(|l| {
|
||||
let t = l.trim_start();
|
||||
!t.starts_with('#') && t.starts_with("PUNKTFUNK_HOST_CMD=")
|
||||
});
|
||||
match current {
|
||||
Some(i) => {
|
||||
let value = lines[i].trim_start()["PUNKTFUNK_HOST_CMD=".len()..].trim();
|
||||
if value == desired {
|
||||
return; // already what the installer chose
|
||||
}
|
||||
if value != "serve" && value != "serve --gamestream" {
|
||||
println!(
|
||||
"host.env has a customized PUNKTFUNK_HOST_CMD ({value}) - leaving it \
|
||||
(installer GameStream choice not applied)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
lines[i] = format!("PUNKTFUNK_HOST_CMD={desired}");
|
||||
}
|
||||
None => lines.push(format!("PUNKTFUNK_HOST_CMD={desired}")),
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
out.push('\n');
|
||||
// Rewrite through write_secret_file so the SYSTEM/Administrators DACL is re-asserted.
|
||||
if let Err(e) = crate::gamestream::write_secret_file(&path, out.as_bytes()) {
|
||||
eprintln!("warning: could not write {}: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"GameStream (Moonlight) compatibility: {} (PUNKTFUNK_HOST_CMD={desired})",
|
||||
if enable { "enabled" } else { "disabled" }
|
||||
);
|
||||
}
|
||||
|
||||
// ── firewall + sc helpers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inbound firewall rules for the streaming ports (best-effort; logs but never fails the install).
|
||||
|
||||
Reference in New Issue
Block a user