feat(client/windows): make the console UI findable, and launchable on its own

Two problems, one feature. The gamepad/couch UI existed but you had to
already know it was there:

- On the hosts page its card only rendered when a controller was CONNECTED,
  so with no pad plugged in there was no visible entry point at all -- the
  only other door being a per-host overflow menu behind a "..." nobody
  opens. The card now always shows, with copy that adapts to whether a pad
  is present, so the feature is findable before you own the hardware.
- There was no way to start it directly. An HTPC or TV box wants the couch
  interface as its first screen, not the desktop shell. "punktfunk-client
  --console" now hands straight off to it (fullscreen unless --windowed),
  which covers shortcuts, Steam entries, autostart and Task Scheduler.

The Start-menu tile needs its own executable: an MSIX <Application> cannot
pass arguments to a full-trust exe, so "punktfunk-client.exe --console" is
not expressible there. punktfunk-console.exe is a ~20-line hand-off to the
session binary's browse mode, staged into the package and given its own
tile ("Punktfunk Console").

Nothing new is implemented behind either door: a bare "--browse" was
already a complete standalone client (host list, discovery, PIN pairing,
settings, Wake-on-LAN, library). It just had no front door.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:45:14 +02:00
parent aa45757a72
commit 2508b72062
7 changed files with 116 additions and 13 deletions
+3 -2
View File
@@ -104,8 +104,9 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
# All three client binaries — the shell spawns punktfunk-session.exe (a package
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
+6
View File
@@ -12,6 +12,12 @@ repository.workspace = true
name = "punktfunk-client"
path = "src/main.rs"
# The couch/HTPC Start-menu entry. Its own executable because an MSIX <Application> cannot
# pass arguments to a full-trust exe — see the binary's own docs.
[[bin]]
name = "punktfunk-console"
path = "src/bin/punktfunk-console.rs"
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
# other native clients live in clients/linux and clients/apple); on other
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
@@ -57,6 +57,23 @@
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
<!--
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
desktop shell is the wrong first screen. Same full-trust executable, launched with
`--console`, which hands straight off to the session binary's controller-driven
browse mode (host list, pairing, settings, library) fullscreen.
-->
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Punktfunk Console"
Description="Controller-driven couch interface for TVs and HTPCs"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
+1 -1
View File
@@ -72,7 +72,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Nul
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
# adds no DLLs of its own.
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
foreach ($f in $required) {
$src = Join-Path $TargetDir $f
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
+30 -10
View File
@@ -352,10 +352,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
// A controller is connected and a paired host is REACHABLE (advertising or probed —
// an offline host would just open the console onto an error scene): offer the couch
// experience — the console (gamepad) UI on the most recently used such host.
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
// The couch entry point, for the most recently used paired + REACHABLE host (an offline
// one would just open the console onto an error scene).
//
// Deliberately NOT gated on a controller being connected any more: it used to be, which
// meant that with no pad plugged in the gamepad UI had no visible entry point on this
// page at all — its only other door is the per-host overflow menu, behind a "…" nobody
// opens. The card now always shows (the copy below adapts), so the feature is findable
// before you own the hardware for it.
if CONSOLE_UI_AVAILABLE {
let reachable = |k: &&crate::trust::KnownHost| {
hosts
.iter()
@@ -382,12 +387,27 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
card(
grid((
vstack((
text_block("Controller detected").font_size(14.0).semibold(),
text_block(format!(
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
launches stream in the same window.",
k.name
))
text_block(if props.pads > 0 {
"Controller detected"
} else {
"Console UI"
})
.font_size(14.0)
.semibold(),
text_block(if props.pads > 0 {
format!(
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
launches stream in the same window.",
k.name
)
} else {
format!(
"The couch interface for {} \u{2014} a controller- and \
remote-friendly library that launches streams in the same \
window. Works with keyboard arrows too.",
k.name
)
})
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
@@ -0,0 +1,38 @@
//! `punktfunk-console.exe` — the couch/HTPC entry point.
//!
//! Exists because an MSIX `<Application>` cannot pass ARGUMENTS to a full-trust executable:
//! a second Start-menu tile therefore cannot simply be "punktfunk-client.exe --console", it
//! needs its own executable. This is that executable, and it is deliberately nothing but a
//! hand-off — it starts the session binary's `--browse` mode (the complete controller-driven
//! client: host list, discovery, PIN pairing, settings, Wake-on-LAN, library) fullscreen and
//! mirrors its exit code, so whatever supervises this process sees the real result.
//!
//! `--windowed` keeps it in a window; everything else is the session binary's own business.
// No console window: this is launched from a Start-menu tile / shortcut, and a flashing
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(windows)]
fn main() {
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
.map(|e| e.with_file_name("punktfunk-session.exe"))
.filter(|p| p.exists())
.unwrap_or_else(|| "punktfunk-session".into());
let mut cmd = std::process::Command::new(session);
cmd.arg("--browse");
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
}
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
#[cfg(not(windows))]
fn main() {}
+21
View File
@@ -76,6 +76,27 @@ fn main() {
return;
}
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
// its exit code, so anything supervising this process sees the real result.
if flag("--console") {
let mut cmd = std::process::Command::new(spawn::session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).