diff --git a/.gitea/workflows/windows-msix.yml b/.gitea/workflows/windows-msix.yml
index f36f3f5a..6e70e5da 100644
--- a/.gitea/workflows/windows-msix.yml
+++ b/.gitea/workflows/windows-msix.yml
@@ -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 }}
diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml
index 87f1e626..af6d3960 100644
--- a/clients/windows/Cargo.toml
+++ b/clients/windows/Cargo.toml
@@ -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 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")
diff --git a/clients/windows/packaging/AppxManifest.xml b/clients/windows/packaging/AppxManifest.xml
index 62429448..dc1c9de7 100644
--- a/clients/windows/packaging/AppxManifest.xml
+++ b/clients/windows/packaging/AppxManifest.xml
@@ -57,6 +57,23 @@
+
+
+
+
+
+
diff --git a/clients/windows/packaging/pack-msix.ps1 b/clients/windows/packaging/pack-msix.ps1
index 83112f66..9aac70fa 100644
--- a/clients/windows/packaging/pack-msix.ps1
+++ b/clients/windows/packaging/pack-msix.ps1
@@ -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?)" }
diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs
index 890ccdaa..8b658df5 100644
--- a/clients/windows/src/app/hosts.rs
+++ b/clients/windows/src/app/hosts.rs
@@ -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),
diff --git a/clients/windows/src/bin/punktfunk-console.rs b/clients/windows/src/bin/punktfunk-console.rs
new file mode 100644
index 00000000..06538880
--- /dev/null
+++ b/clients/windows/src/bin/punktfunk-console.rs
@@ -0,0 +1,38 @@
+//! `punktfunk-console.exe` — the couch/HTPC entry point.
+//!
+//! Exists because an MSIX `` 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() {}
diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs
index c6340683..aa256880 100644
--- a/clients/windows/src/main.rs
+++ b/clients/windows/src/main.rs
@@ -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).