diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 353972b..649e939 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -93,6 +93,13 @@ jobs: if (-not $env:FFMPEG_DIR) { "FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } + # VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by + # provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the + # streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR + # (the daemon env only refreshes on a runner-task restart). + if (-not $env:VBCABLE_DIR) { + "VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } $v = if ($env:GITHUB_REF -like 'refs/tags/v*') { $env:GITHUB_REF_NAME -replace '^v', '' } else { diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index ed6f2f6..8b39381 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -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 ` ───────────────────────────────────────────────── +// ── `driver install [--gamepad] --dir ` / `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 [--gamepad]"), + Some("uninstall") => driver_uninstall(&args[1..]), + _ => bail!( + "usage: punktfunk-host driver install --dir [--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 { + 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 = 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 { diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index 77bee04..5afb1f9 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -87,7 +87,7 @@ fn event_handle(ev: &OnceLock) -> Option { 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 { // ── 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 = 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). diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 94de0fc..ef1c8db 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -47,16 +47,31 @@ parse breakage that silently failed installs on non-English boxes. **`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd` → `bun` on `:3000`), opens TCP 3000, and starts it. It proxies the host's loopback mgmt API with the host's own `%ProgramData%\punktfunk\mgmt-token`. +- **GameStream (Moonlight) compatibility is a wizard task** (checked by default): the choice is passed + to `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or + `serve`, the secure native-only host) into `host.env`. Upgrade-safe: a hand-customized + `PUNKTFUNK_HOST_CMD` is never overwritten. +- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style + controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with + the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico` + (`SetupIconFile` + the Apps & features entry). Assets are generated **and committed** by + `branding/gen-branding.ps1` from the canonical brand geometry (`web/src/components/brand-mark.tsx`); + re-run it only when the brand changes. - **Upgrade:** stops a running `PunktfunkHost` service and waits for `STOPPED` before replacing files (otherwise the locked exe / respawning supervisor would block the copy), then re-points the service; the existing console password is kept (the wizard page is skipped). - **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove - firewall rules) and removes the `PunktfunkWeb` task + its firewall rule. The pf-vdisplay driver and the - `%ProgramData%\punktfunk` config (incl. `web-password`) are intentionally left in place. + firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+ + `--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the + pf-vdisplay / pf-dualsense / pf-xusb driver-store packages (the field report was that they survived + uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may + use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk` + config (incl. `web-password`) is also left in place. Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver with -`/MERGETASKS="!installdriver"`). A silent fresh install uses the generated random console password — -read it from `%ProgramData%\punktfunk\web-password`. +`/MERGETASKS="!installdriver"`; disable Moonlight compat with `/MERGETASKS="!gamestream"`). A silent +fresh install uses the generated random console password — read it from +`%ProgramData%\punktfunk\web-password`. ## Prerequisites on the target box @@ -70,18 +85,24 @@ read it from `%ProgramData%\punktfunk\web-password`. Output` capture endpoint surfaces as a host mic. A Windows audio device can only be created by a **kernel-mode** driver (no UMDF path exists), so unlike our self-signed UMDF drivers we cannot ship our own — VB-CABLE is a vendor-signed cable that loads with no test-signing. It is **donationware** by - VB-Audio, redistributed under VB-Audio's bundling grant (only the single base cable); see - `licenses/VB-CABLE-NOTICE.txt`. The package binary is **not** in the repo — supply it to the packer via - `-VbCableDir` / `$env:VBCABLE_DIR` (the extracted official package, containing `VBCABLE_Setup_x64.exe`). - Absent → the installer is built without it and the host falls back to auto-installing the Steam - Streaming pair. *(Endgame: attestation-sign our own MIT virtual-audio driver to drop this dependency.)* + VB-Audio, redistributed under VB-Audio's bundling grant (only the single base cable) — the grant + requires the end user to see VB-CABLE's origin + donationware status, which the wizard task text and + `licenses/VB-CABLE-NOTICE.txt` surface. The package binary is **not** in the repo — CI provisions the + **pinned, SHA-256-verified official package** onto the runner (`scripts/ci/provision-windows-punktfunk-extras.ps1` + → `C:\Users\Public\vbcable`) and `windows-host.yml` passes it via `$env:VBCABLE_DIR`, so **published + installers always bundle it**; locally supply `-VbCableDir` / `$env:VBCABLE_DIR` (the extracted + official package, containing `VBCABLE_Setup_x64.exe`). Unset → the installer is built without it and + the host falls back to auto-installing the Steam Streaming pair; set-but-invalid → the pack **fails** + (a broken provisioning must not silently ship a mic-less installer again). *(Endgame: + attestation-sign our own MIT virtual-audio driver to drop this dependency.)* ## Files here | File | Role | |------|------| | `punktfunk-host.iss` | Inno Setup script (the installer definition). | -| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + the **web console** (`.output` + bun) + the HDR layer, run ISCC, sign setup.exe. | +| `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100–200% DPI) + `punktfunk.ico`. Re-run only on a brand change. | +| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. | | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). | diff --git a/packaging/windows/branding/gen-branding.ps1 b/packaging/windows/branding/gen-branding.ps1 new file mode 100644 index 0000000..22ce52b --- /dev/null +++ b/packaging/windows/branding/gen-branding.ps1 @@ -0,0 +1,235 @@ +<# +.SYNOPSIS + Generate the punktfunk host installer branding assets (wizard BMPs + setup .ico). + +.DESCRIPTION + Renders the punktfunk brand mark - the two overlapping circles ("lens") from + web/src/components/brand-mark.tsx (the canonical flattened geometry, shared with the Apple icon, + the marketing site and the docs) - into the assets Inno Setup consumes: + + wizard-image-*.bmp welcome/finish page side panel (164x314 base, 100..200% DPI variants); + dark violet gradient panel + the mark + the lowercase wordmark. The panel + is self-contained dark, so it reads correctly in BOTH the light and dark + (WizardStyle=dynamic) wizard appearances. + wizard-small-*.bmp header tile on the inner pages (55x55 base, 100..200% DPI variants); + the square brand tile (mark on #1C1530), matching the MSIX client tile. + punktfunk.ico multi-size icon (16..256, PNG-compressed entries - Vista+ format, we + require Windows 10) for SetupIconFile + the Apps & Features entry. + + Outputs are COMMITTED next to this script (like include/punktfunk_core.h, generated-but-checked-in); + re-run only when the brand changes. Everything is drawn 4x supersampled and downscaled + (System.Drawing regions/clips do not antialias), so edges stay clean at every size. + +.EXAMPLE + pwsh -File packaging/windows/branding/gen-branding.ps1 +#> +[CmdletBinding()] +param([string]$OutDir = $PSScriptRoot) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +# --- brand constants (colors from brand-mark.tsx; tile background from the MSIX assets) ------- +$colLight = [System.Drawing.Color]::FromArgb(255, 0xA7, 0x9F, 0xF8) # large circle +$colDeep = [System.Drawing.Color]::FromArgb(255, 0x6C, 0x5B, 0xF3) # small circle +$colHi = [System.Drawing.Color]::FromArgb(255, 0xD2, 0xC9, 0xFB) # lens overlap highlight +$colTile = [System.Drawing.Color]::FromArgb(255, 0x1C, 0x15, 0x30) # brand tile background +$colPanelTop = [System.Drawing.Color]::FromArgb(255, 0x27, 0x1E, 0x46) # wizard panel gradient +$colPanelBot = [System.Drawing.Color]::FromArgb(255, 0x11, 0x0D, 0x1F) +$colText = [System.Drawing.Color]::FromArgb(255, 0xEA, 0xE6, 0xFB) # wordmark on the panel + +# Mark geometry in the 1000-unit viewbox of brand-mark.tsx: two r=194.41 circles at (403.04,597.26) +# (light, behind) and (597.81,402.85) (deep, in front), their intersection filled as the highlight. +$R = 194.41 +$c1x = 403.037; $c1y = 597.262 +$c2x = 597.8075; $c2y = 402.8525 +# Mark bounding box -> center/span, so callers can place it by center + size. +$bbMinX = $c1x - $R; $bbMaxX = $c2x + $R +$bbMinY = $c2y - $R; $bbMaxY = $c1y + $R +$markCx = ($bbMinX + $bbMaxX) / 2.0 +$markCy = ($bbMinY + $bbMaxY) / 2.0 +$markSpan = $bbMaxX - $bbMinX # == $bbMaxY - $bbMinY (the bbox is square) + +# Draw the mark onto $g centered at ($cx,$cy) with bounding-box size $size (device pixels). +function Draw-Mark([System.Drawing.Graphics]$g, [double]$cx, [double]$cy, [double]$size) { + $s = $size / $markSpan + function ellRect([double]$ecx, [double]$ecy) { + $r = $R * $s + [System.Drawing.RectangleF]::new( + [float]($cx + ($ecx - $markCx) * $s - $r), [float]($cy + ($ecy - $markCy) * $s - $r), + [float](2 * $r), [float](2 * $r)) + } + $r1 = ellRect $c1x $c1y + $r2 = ellRect $c2x $c2y + $b = New-Object System.Drawing.SolidBrush($colLight) + $g.FillEllipse($b, $r1); $b.Dispose() + $b = New-Object System.Drawing.SolidBrush($colDeep) + $g.FillEllipse($b, $r2); $b.Dispose() + # Highlight = intersection: clip to circle 1, fill circle 2. The clip edge is not antialiased, + # but every caller renders 4x supersampled and downscales, which smooths it. + $p1 = New-Object System.Drawing.Drawing2D.GraphicsPath + $p1.AddEllipse($r1) + $g.SetClip($p1) + $b = New-Object System.Drawing.SolidBrush($colHi) + $g.FillEllipse($b, $r2); $b.Dispose() + $g.ResetClip(); $p1.Dispose() +} + +# New 32bpp canvas + antialiased Graphics. +function New-Canvas([int]$w, [int]$h) { + $bmp = New-Object System.Drawing.Bitmap($w, $h, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias + @($bmp, $g) +} + +# Downscale $src to $w x $h (high-quality bicubic) - the supersample resolve. +function Resize-Bitmap([System.Drawing.Bitmap]$src, [int]$w, [int]$h) { + $dst = New-Object System.Drawing.Bitmap($w, $h, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($dst) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $g.DrawImage($src, (New-Object System.Drawing.Rectangle(0, 0, $w, $h)), + 0, 0, $src.Width, $src.Height, [System.Drawing.GraphicsUnit]::Pixel) + $g.Dispose() + $dst +} + +# Save as 24bpp BMP (opaque - what Inno's wizard image loader expects by default). +function Save-Bmp24([System.Drawing.Bitmap]$bmp, [string]$path) { + $b24 = $bmp.Clone((New-Object System.Drawing.Rectangle(0, 0, $bmp.Width, $bmp.Height)), + [System.Drawing.Imaging.PixelFormat]::Format24bppRgb) + $b24.Save($path, [System.Drawing.Imaging.ImageFormat]::Bmp) + $b24.Dispose() + Write-Host " wrote $path ($($bmp.Width)x$($bmp.Height))" +} + +$SS = 4 # supersample factor + +# --- wizard side panel (welcome/finish page): gradient + mark + wordmark ---------------------- +# Base size 164x314 (Inno's classic canvas); DPI variants via the wizard-image-*.bmp wildcard. +foreach ($pct in 100, 125, 150, 175, 200) { + $w = [int][Math]::Round(164 * $pct / 100.0) + $h = [int][Math]::Round(314 * $pct / 100.0) + $bmp, $g = New-Canvas ($w * $SS) ($h * $SS) + $rect = New-Object System.Drawing.Rectangle(0, 0, ($w * $SS), ($h * $SS)) + $grad = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $colPanelTop, $colPanelBot, 90.0) + $g.FillRectangle($grad, $rect); $grad.Dispose() + # Mark: 58% of the panel width, centered horizontally, optical center at ~40% height. + Draw-Mark $g ($w * $SS / 2.0) ($h * $SS * 0.40) ($w * $SS * 0.58) + # Wordmark: lowercase brand name under the mark. + $font = New-Object System.Drawing.Font('Segoe UI Semibold', [float](13.0 * $SS * $pct / 100.0), [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Pixel) + $tb = New-Object System.Drawing.SolidBrush($colText) + $fmt = New-Object System.Drawing.StringFormat + $fmt.Alignment = [System.Drawing.StringAlignment]::Center + $g.DrawString('punktfunk', $font, $tb, + (New-Object System.Drawing.PointF([float]($w * $SS / 2.0), [float]($h * $SS * 0.60))), $fmt) + $fmt.Dispose(); $tb.Dispose(); $font.Dispose(); $g.Dispose() + $out = Resize-Bitmap $bmp $w $h + $bmp.Dispose() + Save-Bmp24 $out (Join-Path $OutDir ("wizard-image-{0}.bmp" -f $pct)) + $out.Dispose() +} + +# --- wizard header tile (inner pages): the square brand tile -------------------------------- +# Base size 55x55; DPI variants via the wizard-small-*.bmp wildcard. Opaque square (BMP has no +# alpha here): the same full-bleed dark tile as the client's MSIX logo assets. +foreach ($pct in 100, 125, 150, 175, 200) { + $sz = [int][Math]::Round(55 * $pct / 100.0) + $bmp, $g = New-Canvas ($sz * $SS) ($sz * $SS) + $b = New-Object System.Drawing.SolidBrush($colTile) + $g.FillRectangle($b, 0, 0, ($sz * $SS), ($sz * $SS)); $b.Dispose() + Draw-Mark $g ($sz * $SS / 2.0) ($sz * $SS / 2.0) ($sz * $SS * 0.74) + $g.Dispose() + $out = Resize-Bitmap $bmp $sz $sz + $bmp.Dispose() + Save-Bmp24 $out (Join-Path $OutDir ("wizard-small-{0}.bmp" -f $pct)) + $out.Dispose() +} + +# --- punktfunk.ico: rounded brand tile at 16..256 -------------------------------------------- +# Small sizes are classic 32bpp DIB entries (Inno's SetupIconFile resource updater and older shell +# consumers reject an all-PNG icon); only 128/256 use PNG compression (the standard Vista+ layout). +function New-IconTile([int]$sz) { + $bmp, $g = New-Canvas ($sz * $SS) ($sz * $SS) + # Rounded-rect tile (22% corner radius - the Windows 11 app-icon look). + $S = $sz * $SS; $rad = [int]($S * 0.22) + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $path.AddArc(0, 0, 2 * $rad, 2 * $rad, 180, 90) + $path.AddArc($S - 2 * $rad, 0, 2 * $rad, 2 * $rad, 270, 90) + $path.AddArc($S - 2 * $rad, $S - 2 * $rad, 2 * $rad, 2 * $rad, 0, 90) + $path.AddArc(0, $S - 2 * $rad, 2 * $rad, 2 * $rad, 90, 90) + $path.CloseFigure() + $b = New-Object System.Drawing.SolidBrush($colTile) + $g.FillPath($b, $path); $b.Dispose(); $path.Dispose() + Draw-Mark $g ($S / 2.0) ($S / 2.0) ($S * 0.74) + $g.Dispose() + $out = Resize-Bitmap $bmp $sz $sz + $bmp.Dispose() + $out +} + +# PNG-compressed entry payload (used for the 128/256 entries). The leading comma keeps the byte[] +# a single pipeline object (PowerShell would otherwise unroll it into individual bytes). +function ConvertTo-IconPng([System.Drawing.Bitmap]$tile) { + $ms = New-Object System.IO.MemoryStream + $tile.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + return , $ms.ToArray() +} + +# Classic ICO DIB entry payload: BITMAPINFOHEADER (height doubled) + bottom-up 32bpp BGRA XOR data +# + an all-zero 1bpp AND mask (32bpp icons carry transparency in the alpha channel). +function ConvertTo-IconDib([System.Drawing.Bitmap]$tile) { + $s = $tile.Width + $rect = New-Object System.Drawing.Rectangle(0, 0, $s, $s) + $data = $tile.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $px = New-Object byte[] ($data.Stride * $s) + [System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $px, 0, $px.Length) + $tile.UnlockBits($data) + $maskStride = [int][Math]::Ceiling($s / 32.0) * 4 # 1bpp rows padded to 32 bits + $ms = New-Object System.IO.MemoryStream + $w = New-Object System.IO.BinaryWriter($ms) + $w.Write([uint32]40); $w.Write([int]$s); $w.Write([int]($s * 2)) # biSize, biWidth, biHeight (XOR+AND) + $w.Write([uint16]1); $w.Write([uint16]32); $w.Write([uint32]0) # biPlanes, biBitCount, BI_RGB + $w.Write([uint32]($s * $s * 4 + $maskStride * $s)) # biSizeImage + $w.Write([int]0); $w.Write([int]0); $w.Write([uint32]0); $w.Write([uint32]0) + for ($y = $s - 1; $y -ge 0; $y--) { $w.Write($px, $y * $data.Stride, $s * 4) } # XOR, bottom-up + $w.Write((New-Object byte[] ($maskStride * $s))) # AND mask: all opaque + $w.Flush() + $bytes = $ms.ToArray() + $w.Dispose(); $ms.Dispose() + return , $bytes # leading comma: emit the byte[] as ONE object, not unrolled bytes +} + +$icoSizes = 16, 20, 24, 32, 40, 48, 64, 128, 256 +$pngs = @(foreach ($s in $icoSizes) { + $tile = New-IconTile $s + if ($s -ge 128) { ConvertTo-IconPng $tile } else { ConvertTo-IconDib $tile } + $tile.Dispose() +}) +$ico = New-Object System.IO.MemoryStream +$bw = New-Object System.IO.BinaryWriter($ico) +# ICONDIR +$bw.Write([uint16]0); $bw.Write([uint16]1); $bw.Write([uint16]$icoSizes.Count) +# ICONDIRENTRYs (width/height byte 0 means 256) +$offset = 6 + 16 * $icoSizes.Count +for ($i = 0; $i -lt $icoSizes.Count; $i++) { + $s = $icoSizes[$i] + $dim = if ($s -ge 256) { 0 } else { $s } + $bw.Write([byte]$dim); $bw.Write([byte]$dim) # width, height + $bw.Write([byte]0); $bw.Write([byte]0) # colors, reserved + $bw.Write([uint16]1); $bw.Write([uint16]32) # planes, bitcount + $bw.Write([uint32]$pngs[$i].Length); $bw.Write([uint32]$offset) + $offset += $pngs[$i].Length +} +foreach ($p in $pngs) { $bw.Write([byte[]]$p) } +$bw.Flush() +$icoPath = Join-Path $OutDir 'punktfunk.ico' +[IO.File]::WriteAllBytes($icoPath, $ico.ToArray()) +$bw.Dispose(); $ico.Dispose() +# Self-check: a malformed container here would only surface later as ISCC "Icon file is invalid". +$probe = New-Object System.Drawing.Icon($icoPath) +$probe.Dispose() +Write-Host " wrote $icoPath ($($icoSizes -join ',')) - verified loadable" +Write-Host "==> branding assets generated in $OutDir" diff --git a/packaging/windows/branding/punktfunk.ico b/packaging/windows/branding/punktfunk.ico new file mode 100644 index 0000000..1757e23 Binary files /dev/null and b/packaging/windows/branding/punktfunk.ico differ diff --git a/packaging/windows/branding/wizard-image-100.bmp b/packaging/windows/branding/wizard-image-100.bmp new file mode 100644 index 0000000..44b834b Binary files /dev/null and b/packaging/windows/branding/wizard-image-100.bmp differ diff --git a/packaging/windows/branding/wizard-image-125.bmp b/packaging/windows/branding/wizard-image-125.bmp new file mode 100644 index 0000000..87abc1a Binary files /dev/null and b/packaging/windows/branding/wizard-image-125.bmp differ diff --git a/packaging/windows/branding/wizard-image-150.bmp b/packaging/windows/branding/wizard-image-150.bmp new file mode 100644 index 0000000..5286d8e Binary files /dev/null and b/packaging/windows/branding/wizard-image-150.bmp differ diff --git a/packaging/windows/branding/wizard-image-175.bmp b/packaging/windows/branding/wizard-image-175.bmp new file mode 100644 index 0000000..c891f5a Binary files /dev/null and b/packaging/windows/branding/wizard-image-175.bmp differ diff --git a/packaging/windows/branding/wizard-image-200.bmp b/packaging/windows/branding/wizard-image-200.bmp new file mode 100644 index 0000000..45cc5d4 Binary files /dev/null and b/packaging/windows/branding/wizard-image-200.bmp differ diff --git a/packaging/windows/branding/wizard-small-100.bmp b/packaging/windows/branding/wizard-small-100.bmp new file mode 100644 index 0000000..50ffbdc Binary files /dev/null and b/packaging/windows/branding/wizard-small-100.bmp differ diff --git a/packaging/windows/branding/wizard-small-125.bmp b/packaging/windows/branding/wizard-small-125.bmp new file mode 100644 index 0000000..b336b73 Binary files /dev/null and b/packaging/windows/branding/wizard-small-125.bmp differ diff --git a/packaging/windows/branding/wizard-small-150.bmp b/packaging/windows/branding/wizard-small-150.bmp new file mode 100644 index 0000000..fdeacca Binary files /dev/null and b/packaging/windows/branding/wizard-small-150.bmp differ diff --git a/packaging/windows/branding/wizard-small-175.bmp b/packaging/windows/branding/wizard-small-175.bmp new file mode 100644 index 0000000..717b4f4 Binary files /dev/null and b/packaging/windows/branding/wizard-small-175.bmp differ diff --git a/packaging/windows/branding/wizard-small-200.bmp b/packaging/windows/branding/wizard-small-200.bmp new file mode 100644 index 0000000..999cf46 Binary files /dev/null and b/packaging/windows/branding/wizard-small-200.bmp differ diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index daafc92..0982506 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -132,6 +132,13 @@ $issLocal = Join-Path $OutDir 'punktfunk-host.iss' Copy-Item -LiteralPath $hostEnvSrc -Destination $hostEnv -Force Copy-Item -LiteralPath $readmeSrc -Destination $readme -Force Copy-Item -LiteralPath $iss -Destination $issLocal -Force +# Branding (wizard BMPs + punktfunk.ico, committed outputs of branding/gen-branding.ps1): the .iss +# references them as "branding\" relative to itself, so stage the dir next to the staged .iss. +$brandStage = Join-Path $OutDir 'branding' +if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force } +New-Item -ItemType Directory -Force -Path $brandStage | Out-Null +Copy-Item (Join-Path $here 'branding\*.bmp') $brandStage -Force +Copy-Item (Join-Path $here 'branding\punktfunk.ico') $brandStage -Force # License/attribution payload bundled into {app}\licenses: the project's own MIT/Apache texts and the # generated third-party crate notices. The FFmpeg LGPL notice + license text are added to this same @@ -198,7 +205,13 @@ if (-not $NoDriver) { # shipped intact); supply it via -VbCableDir / $env:VBCABLE_DIR pointing at the extracted official # package (must contain VBCABLE_Setup_x64.exe). Absent -> installer built WITHOUT the bundled cable; the # host then auto-installs the Steam Streaming pair as a fallback and mic passthrough needs a manual cable. -if ($VbCableDir -and (Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableDir -Filter 'VBCABLE_Setup*.exe' -ErrorAction SilentlyContinue)) { +if ($VbCableDir -and -not ((Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableDir -Filter 'VBCABLE_Setup*.exe' -ErrorAction SilentlyContinue))) { + # An explicitly-supplied dir that doesn't hold the package is a broken provisioning, not an + # opt-out - fail loudly instead of silently shipping an installer without the virtual mic + # (exactly the field regression this bundling fixes). Opt out by leaving VBCABLE_DIR unset. + throw "VbCableDir '$VbCableDir' has no VBCABLE_Setup*.exe - re-run scripts/ci/provision-windows-punktfunk-extras.ps1 (or unset VBCABLE_DIR to build without the virtual mic)" +} +if ($VbCableDir) { $vbStage = Join-Path $OutDir 'vbcable' if (Test-Path $vbStage) { Remove-Item -Recurse -Force $vbStage } New-Item -ItemType Directory -Force -Path $vbStage | Out-Null @@ -211,7 +224,7 @@ if ($VbCableDir -and (Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableD Copy-Item (Join-Path $here 'licenses\VB-CABLE-NOTICE.txt') -Destination $licStage -Force Write-Host "==> bundling VB-CABLE (virtual mic) from $VbCableDir -> $vbStage" } -else { Write-Host "no -VbCableDir/`$env:VBCABLE_DIR (or no VBCABLE_Setup*.exe in it) -> installer built WITHOUT the bundled VB-CABLE virtual mic" } +else { Write-Host "no -VbCableDir/`$env:VBCABLE_DIR -> installer built WITHOUT the bundled VB-CABLE virtual mic (CI always bundles it; see provision-windows-punktfunk-extras.ps1)" } # --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------ # A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 0f2b2a0..016db9d 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -28,6 +28,13 @@ #ifndef Readme #define Readme "README.md" #endif +; Branding assets (wizard side panel + header tile BMPs, setup/app icon), generated + committed by +; branding/gen-branding.ps1 from the canonical brand-mark geometry. Relative to this script's dir: +; works from the repo checkout AND from the staged copy (pack-host-installer.ps1 stages branding\ +; next to the staged .iss). +#ifndef BrandingDir + #define BrandingDir "branding" +#endif ; The web console launcher (the PunktfunkWeb task action) + its post-install provisioner - committed ; scripts staged next to the .iss by pack-host-installer.ps1 (absolute paths passed in). #ifndef WebRunCmd @@ -85,9 +92,23 @@ OutputDir={#OutputDir} OutputBaseFilename=punktfunk-host-setup-{#MyAppVersion} Compression=lzma2/max SolidCompression=yes +; Modern branded wizard: Windows-11-style controls that follow the system light/dark theme +; (Inno Setup >= 6.6; CI provisions current 6.x via choco). An older local compiler falls back +; to the plain modern style so a dev pack still builds. +#if VER >= EncodeVer(6,6,0) +WizardStyle=modern dynamic windows11 +#else WizardStyle=modern +#endif +; Brand assets (branding/gen-branding.ps1): the violet lens mark on a dark panel/tile - self- +; contained dark art, so it reads correctly in both the light and dark wizard appearance. The +; wildcard names carry 100..200% DPI variants; Setup picks the closest. +SetupIconFile={#BrandingDir}\punktfunk.ico +WizardImageFile={#BrandingDir}\wizard-image-*.bmp +WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp UninstallDisplayName=punktfunk host {#MyAppVersion} -UninstallDisplayIcon={app}\punktfunk-host.exe +; The branded multi-size .ico (installed below) - the host exe embeds no icon resource. +UninstallDisplayIcon={app}\punktfunk.ico [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -100,17 +121,26 @@ Name: "installdriver"; Description: "Install the pf-vdisplay virtual display dri Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)" #endif #ifdef WithAudioCable -Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio (microphone passthrough - VB-Audio donationware, www.vb-cable.com)" +; VB-Audio's bundling grant requires the end user to see VB-CABLE's origin + donationware status +; at install time - keep the vendor, URL, and donationware wording in this visible task text (the +; full notice ships in {app}\licenses\VB-CABLE-NOTICE.txt). +Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio for microphone passthrough (VB-CABLE by VB-Audio, www.vb-cable.com - donationware, all participations welcome)" #endif #ifdef WithVkLayer Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" #endif +; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD +; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified +; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only). +Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)" Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)" [Files] Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion +; The branded icon, referenced by UninstallDisplayIcon (Apps & features shows it for the entry). +Source: "{#BrandingDir}\punktfunk.ico"; DestDir: "{app}"; Flags: ignoreversion #ifdef LicensesDir ; License/attribution payload -> {app}\licenses: the project's MIT/Apache texts, the generated ; THIRD-PARTY-NOTICES (permissive crate attributions), and (on an amf-qsv build) the FFmpeg LGPL @@ -184,7 +214,8 @@ Filename: "powershell.exe"; \ #endif ; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location: ; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. -Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \ +; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD. +Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}"; WorkingDir: "{app}"; \ StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \ StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice @@ -198,6 +229,14 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam [UninstallRun] Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall" +; Remove the punktfunk drivers we installed (pf-vdisplay devnode + driver package, then the gamepad +; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional +; (not #ifdef'd on this build's bundled payload - an upgrade may have dropped a payload the original +; install laid down); `driver uninstall` is best-effort and no-ops when nothing is installed. +; VB-CABLE is deliberately NOT removed: it is a third-party shared component the user may use +; elsewhere - see licenses\VB-CABLE-NOTICE.txt for its own uninstall. +Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkVdisplayDriverUninstall" +Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkGamepadDriverUninstall" #ifdef WithWeb ; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config, ; like the host uninstall does). @@ -207,6 +246,17 @@ Filename: "powershell.exe"; \ #endif [Code] +{ The GameStream task choice, forwarded to `service install` (which writes host.env's + PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a + hand-customized command line survives upgrades). } +function GamestreamParam(Param: String): String; +begin + if WizardIsTaskSelected('gamestream') then + Result := '--gamestream=on' + else + Result := '--gamestream=off'; +end; + #ifdef WithWeb var WebPwPage: TInputQueryWizardPage; diff --git a/scripts/ci/provision-windows-punktfunk-extras.ps1 b/scripts/ci/provision-windows-punktfunk-extras.ps1 index 7b8e7b0..cffb083 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -52,22 +52,48 @@ Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' # --- Inno Setup (ISCC.exe) for the host installer build (windows-host.yml). pack-host-installer.ps1 -# locates it at its fixed Program Files path, so it need not be on PATH - just present. --- -if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) { +# locates it at its fixed Program Files path, so it need not be on PATH - just present. The .iss +# uses the 6.6+ styling (WizardStyle dark/dynamic + the windows11 style); an older 6.x compiles a +# plain-modern fallback, so upgrade a pre-6.6 install rather than silently shipping the old look. --- +$isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +$innoVer = (Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1' -ErrorAction SilentlyContinue).DisplayVersion +if (-not (Test-Path $isccPath) -or ($innoVer -and [version]$innoVer -lt [version]'6.6.0')) { if (Get-Command choco -ErrorAction SilentlyContinue) { - info "installing Inno Setup (ISCC)" - choco install innosetup -y --no-progress - } else { Write-Warning "Inno Setup not found and choco unavailable - install it for windows-host.yml." } + info "installing/upgrading Inno Setup (ISCC; found: $innoVer)" + choco upgrade innosetup -y --no-progress + } else { Write-Warning "Inno Setup missing or pre-6.6 ($innoVer) and choco unavailable - install/upgrade it for windows-host.yml." } } +# --- VB-CABLE (the streaming virtual microphone the host installer bundles). Pinned official +# package, SHA-256 verified - a silent hash change means VB-Audio shipped a new pack: verify it, +# then update BOTH the pin here and the notice if terms changed (packaging/windows/licenses/ +# VB-CABLE-NOTICE.txt). Donationware by VB-Audio (https://vb-audio.com), redistributed under +# VB-Audio's bundling grant; only the base cable, never A+B/C+D. windows-host.yml points +# VBCABLE_DIR here so pack-host-installer.ps1 bundles it. --- +$vbDir = "C:\Users\Public\vbcable" +$vbUrl = "https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip" +$vbSha = "B950E39F01AF1D04EA623C8F6D8EB9B6EA5C477C637295FABF20631C85116BFB" +if (-not (Test-Path (Join-Path $vbDir 'VBCABLE_Setup_x64.exe'))) { + info "fetching VB-CABLE (official base package, pinned)" + $vbZip = "$vbDir.zip" + Invoke-WebRequest -Uri $vbUrl -OutFile $vbZip -UseBasicParsing + $got = (Get-FileHash $vbZip -Algorithm SHA256).Hash + if ($got -ne $vbSha) { Remove-Item $vbZip -Force; throw "VB-CABLE download hash mismatch (got $got, pinned $vbSha) - vendor package changed; re-verify before re-pinning." } + if (Test-Path $vbDir) { Remove-Item -Recurse -Force $vbDir } + Expand-Archive -Path $vbZip -DestinationPath $vbDir -Force # flat zip (setup exes + signed drivers) + Remove-Item $vbZip -Force + info "VB-CABLE staged at $vbDir" +} else { info "VB-CABLE already present at $vbDir" } + # --- Drop punktfunk's env vars into the generic runner's daemon wrapper extension point (see # unom/infra's scripts/setup-gitea-runner-base.ps1) so the act_runner daemon - and therefore every # job it runs - sees FFMPEG_DIR without unom/infra needing to know punktfunk exists. --- $projectEnv = "C:\Users\Public\act-runner\project-env.ps1" @' $env:FFMPEG_DIR = "C:\Users\Public\ffmpeg" +$env:VBCABLE_DIR = "C:\Users\Public\vbcable" $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH '@ | Set-Content -Encoding UTF8 $projectEnv -info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up" +info "wrote $projectEnv (FFMPEG_DIR, VBCABLE_DIR) - restart the gitea-act-runner scheduled task to pick it up" info "punktfunk extras provisioned OK." diff --git a/scripts/windows/host.env.example b/scripts/windows/host.env.example index 2549fb9..6fda3cb 100644 --- a/scripts/windows/host.env.example +++ b/scripts/windows/host.env.example @@ -35,7 +35,9 @@ RUST_LOG=info # The host subcommand the service launches. Default: `serve --gamestream` (native punktfunk/1 host # ALWAYS on + the GameStream/Moonlight-compat planes). Use `serve` for a SECURE native-only host -# (no plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9). Uncomment to override. +# (no plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9). The installer's +# "Enable GameStream (Moonlight) compatibility" task sets this; a custom value you write here is +# never overwritten by a reinstall/upgrade. #PUNKTFUNK_HOST_CMD=serve --gamestream # Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave