Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
152aac291e | ||
|
|
a48321ee27 | ||
|
|
8e396b8391 | ||
|
|
ff01db67ff | ||
|
|
5c0cb84fda | ||
|
|
ba3add85b5 |
@@ -368,17 +368,14 @@ jobs:
|
|||||||
}
|
}
|
||||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
# The UMDF drivers build IN-TREE inside the pack step (a relocated CARGO_TARGET_DIR
|
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
|
||||||
# breaks wdk-build's manifest walk), and checkout's clean wipes that tree every run —
|
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
|
||||||
# ~1 min of rebuild for crates that rarely change. Cache the in-tree target; cargo's
|
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
|
||||||
# own fingerprints decide what's still fresh after a restore.
|
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
|
||||||
- name: Cache drivers workspace target (built in-tree by the pack step)
|
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
|
||||||
uses: actions/cache@v4
|
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||||
with:
|
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||||
path: packaging/windows/drivers/target
|
# workspace-relative target.
|
||||||
key: drivers-target-${{ hashFiles('packaging/windows/drivers/**', 'crates/pf-driver-proto/**') }}
|
|
||||||
restore-keys: drivers-target-
|
|
||||||
|
|
||||||
- name: Pack + sign installer
|
- name: Pack + sign installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -139,16 +139,20 @@ jobs:
|
|||||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||||
# for the shell, which has no features).
|
# for the shell, which has no features).
|
||||||
|
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
|
||||||
|
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
|
||||||
|
# RUN the binary (help contract), as the session's contract_smoke runs the session —
|
||||||
|
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Clippy (-D warnings)
|
- name: Clippy (-D warnings)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||||
$sf = @()
|
$sf = @()
|
||||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||||
@@ -156,9 +160,9 @@ jobs:
|
|||||||
- name: Rustfmt check
|
- name: Rustfmt check
|
||||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||||
|
|||||||
@@ -947,7 +947,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-probe"
|
name = "cursor-probe"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-capture",
|
"pf-capture",
|
||||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "display-disturb"
|
name = "display-disturb"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
]
|
]
|
||||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2896,7 +2896,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2914,7 +2914,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2935,7 +2935,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2959,7 +2959,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2980,7 +2980,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2994,11 +2994,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3027,14 +3027,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3082,7 +3082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3094,7 +3094,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3302,7 +3302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-cli"
|
name = "punktfunk-cli"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
@@ -3313,7 +3313,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3329,7 +3329,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3346,18 +3346,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
|
||||||
"glib-build-tools",
|
|
||||||
"gtk4",
|
|
||||||
"libadwaita",
|
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"pf-console-ui",
|
"pf-console-ui",
|
||||||
"pf-presenter",
|
"pf-presenter",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"relm4",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -3366,7 +3361,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3386,7 +3381,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3418,7 +3413,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3502,7 +3497,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3516,7 +3511,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3539,7 +3534,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.22.0"
|
version = "0.22.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
|
|||||||
|
|
||||||
A <host-ref> is a saved host's id, its name, or an address — the same reference a
|
A <host-ref> is a saved host's id, its name, or an address — the same reference a
|
||||||
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
|
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
|
||||||
6 needs a person.";
|
6 needs a person.
|
||||||
|
|
||||||
|
\"punktfunk help <command>\" (or any command with --help) explains that command.";
|
||||||
|
|
||||||
|
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
|
||||||
|
/// Each entry documents its flags and the behaviour a script would need to know
|
||||||
|
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||||
|
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||||
|
Some(match verb {
|
||||||
|
"pair" => {
|
||||||
|
"\
|
||||||
|
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||||
|
|
||||||
|
--pin N the PIN the host is showing; without it the command asks, and
|
||||||
|
refuses (exit 6) when there is no terminal to ask on
|
||||||
|
--name LABEL the label the host files this device under
|
||||||
|
(default: this machine's name)
|
||||||
|
|
||||||
|
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
|
||||||
|
store, so every later connect — here, in the desktop client or the console — is
|
||||||
|
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
|
||||||
|
success; exit 3 if the host refuses or the PIN is wrong."
|
||||||
|
}
|
||||||
|
"hosts" => {
|
||||||
|
"\
|
||||||
|
punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||||
|
|
||||||
|
punktfunk hosts list [--probe] [--json]
|
||||||
|
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
|
||||||
|
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
|
||||||
|
too); --json emits one object with per-host detail, profiles included.
|
||||||
|
|
||||||
|
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||||
|
Save a host by address — the door for a box mDNS never sees (Tailscale,
|
||||||
|
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||||
|
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||||
|
|
||||||
|
punktfunk hosts forget <host-ref>
|
||||||
|
Remove a saved host, its pinned fingerprint included. A later connect
|
||||||
|
must pair or trust it again."
|
||||||
|
}
|
||||||
|
"wake" => {
|
||||||
|
"\
|
||||||
|
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
|
||||||
|
|
||||||
|
Sends a magic packet to a saved host's MAC (learned from its advert while it
|
||||||
|
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
|
||||||
|
and polls presence every second for up to 90 s, exiting 0 the moment the host
|
||||||
|
answers — the same cadence every graphical shell uses."
|
||||||
|
}
|
||||||
|
"library" => {
|
||||||
|
"\
|
||||||
|
punktfunk library <host-ref> [--json] — the host's game library
|
||||||
|
|
||||||
|
TSV on stdout by default (id TAB store TAB title), one game per line; --json
|
||||||
|
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
|
||||||
|
this. Needs a paired host (exit 6 otherwise)."
|
||||||
|
}
|
||||||
|
"launch" => {
|
||||||
|
"\
|
||||||
|
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||||
|
|
||||||
|
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||||
|
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||||
|
and relays its lifecycle to stderr.
|
||||||
|
|
||||||
|
--game ID ask the host to launch this library title into the stream
|
||||||
|
--profile REF use a settings profile (id or name) for this connect only;
|
||||||
|
without it the host's own binding applies
|
||||||
|
--fullscreen start the stream window fullscreen
|
||||||
|
--exec become the session process instead of supervising it — the
|
||||||
|
gamescope-wrapper mode, where the launched process must BE
|
||||||
|
the streaming one for focus and lifecycle to work
|
||||||
|
|
||||||
|
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||||
|
trusts this device (re-pair), 4 the renderer could not start."
|
||||||
|
}
|
||||||
|
"open" => {
|
||||||
|
"\
|
||||||
|
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
|
||||||
|
|
||||||
|
Same parser and same refusal rules as clicking the link in a shell: a
|
||||||
|
contradicted fingerprint refuses and says so, an ambiguous name refuses
|
||||||
|
rather than guessing, and an unknown host is never trusted from a URL —
|
||||||
|
that is a decision for a person, at a surface that can show the fingerprint
|
||||||
|
(exit 6 points at `punktfunk pair`). --exec as in launch."
|
||||||
|
}
|
||||||
|
"reachable" => {
|
||||||
|
"\
|
||||||
|
punktfunk reachable <host-ref> — one bounded reachability probe
|
||||||
|
|
||||||
|
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
|
||||||
|
reference may be an unsaved address — this verb answers \"can I reach it\",
|
||||||
|
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
|
||||||
|
}
|
||||||
|
"speed-test" => {
|
||||||
|
"\
|
||||||
|
punktfunk speed-test <host-ref> [--json] — measure the real data plane
|
||||||
|
|
||||||
|
Runs the host's bandwidth probe over an actual session connect and prints the
|
||||||
|
measured throughput, loss, and the bitrate it recommends. Deliberately does
|
||||||
|
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
|
||||||
|
global default) is a decision the GUI makes with the user, and a CLI silently
|
||||||
|
rewriting settings would be exactly the surprise that rule exists to prevent."
|
||||||
|
}
|
||||||
|
"profiles" => {
|
||||||
|
"\
|
||||||
|
punktfunk profiles list [--json] — the settings profiles on this device
|
||||||
|
|
||||||
|
One line per profile: id TAB name TAB how many settings it overrides.
|
||||||
|
Profiles are created and edited in the desktop client; a connect uses one via
|
||||||
|
`punktfunk launch --profile` or a punktfunk:// link that names it."
|
||||||
|
}
|
||||||
|
"reset" => {
|
||||||
|
"\
|
||||||
|
punktfunk reset — forget every saved host and reset stream settings
|
||||||
|
|
||||||
|
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
|
||||||
|
on. This device's identity keypair is deliberately kept, so hosts that knew
|
||||||
|
this machine still recognise it after re-pairing; delete the identity files
|
||||||
|
from the config directory for a true factory reset."
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The value after `--flag`, if any.
|
/// The value after `--flag`, if any.
|
||||||
fn value(args: &[String], flag: &str) -> Option<String> {
|
fn value(args: &[String], flag: &str) -> Option<String> {
|
||||||
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
return OK;
|
return OK;
|
||||||
};
|
};
|
||||||
let rest: Vec<String> = args[1..].to_vec();
|
let rest: Vec<String> = args[1..].to_vec();
|
||||||
|
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
|
||||||
|
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
|
||||||
|
if rest.iter().any(|a| a == "--help" || a == "-h") {
|
||||||
|
println!("{}", verb_help(&verb).unwrap_or(USAGE));
|
||||||
|
return OK;
|
||||||
|
}
|
||||||
match verb.as_str() {
|
match verb.as_str() {
|
||||||
"pair" => pair(&rest),
|
"pair" => pair(&rest),
|
||||||
"hosts" => hosts(&rest),
|
"hosts" => hosts(&rest),
|
||||||
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
"speed-test" => speed_test(&rest),
|
"speed-test" => speed_test(&rest),
|
||||||
"profiles" => profiles(&rest),
|
"profiles" => profiles(&rest),
|
||||||
"reset" => reset(),
|
"reset" => reset(),
|
||||||
"-h" | "--help" | "help" => {
|
"-h" | "--help" | "help" => match positional(&rest, 0) {
|
||||||
println!("{USAGE}");
|
None => {
|
||||||
OK
|
println!("{USAGE}");
|
||||||
}
|
OK
|
||||||
|
}
|
||||||
|
Some(topic) => match verb_help(&topic) {
|
||||||
|
Some(h) => {
|
||||||
|
println!("{h}");
|
||||||
|
OK
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
|
||||||
|
UNRESOLVED
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
"--version" | "version" => {
|
"--version" | "version" => {
|
||||||
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
|
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
|
||||||
OK
|
OK
|
||||||
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
return UNRESOLVED;
|
return UNRESOLVED;
|
||||||
};
|
};
|
||||||
// An address that isn't saved is still a legitimate thing to probe — this verb answers
|
// An address that isn't saved is still a legitimate thing to probe — this verb answers
|
||||||
// "can I reach this?", not "do I know this?".
|
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
|
||||||
let (addr, port) = match resolve(&reference) {
|
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
|
||||||
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
// it here would scold the exact usage this verb documents.
|
||||||
Err(_) => split_host_port(&reference),
|
let known = KnownHosts::load();
|
||||||
|
let link = DeepLink {
|
||||||
|
host_ref: reference.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||||
|
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||||
|
_ => split_host_port(&reference),
|
||||||
};
|
};
|
||||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||||
println!("reachable {addr}:{port}");
|
println!("reachable {addr}:{port}");
|
||||||
@@ -769,15 +918,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
|
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
|
||||||
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
|
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
|
||||||
fn is_tty() -> bool {
|
fn is_tty() -> bool {
|
||||||
#[cfg(unix)]
|
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||||
{
|
|
||||||
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
|
|
||||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -820,6 +961,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
|
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every advertised verb documents itself — a USAGE line without a help entry is a
|
||||||
|
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
|
||||||
|
#[test]
|
||||||
|
fn every_usage_verb_has_help() {
|
||||||
|
for verb in [
|
||||||
|
"pair",
|
||||||
|
"hosts",
|
||||||
|
"wake",
|
||||||
|
"library",
|
||||||
|
"launch",
|
||||||
|
"open",
|
||||||
|
"reachable",
|
||||||
|
"speed-test",
|
||||||
|
"profiles",
|
||||||
|
"reset",
|
||||||
|
] {
|
||||||
|
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
|
||||||
|
assert!(
|
||||||
|
h.starts_with(&format!("punktfunk {verb}")),
|
||||||
|
"help for {verb} must lead with its own invocation"
|
||||||
|
);
|
||||||
|
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
|
||||||
|
}
|
||||||
|
assert!(verb_help("bogus").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn value_reads_the_argument_after_its_flag() {
|
fn value_reads_the_argument_after_its_flag() {
|
||||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
|
||||||
|
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
|
||||||
|
//!
|
||||||
|
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
|
||||||
|
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
|
||||||
|
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
|
||||||
|
//! the thing catches the class. The help paths are the right probe: they touch no config
|
||||||
|
//! stores and no network, so they are safe on any runner.
|
||||||
|
#![cfg(any(target_os = "linux", windows))]
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn punktfunk(args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run punktfunk")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_and_help_print_the_overview_on_stdout() {
|
||||||
|
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
|
||||||
|
let out = punktfunk(args);
|
||||||
|
assert!(out.status.success(), "{args:?} must exit 0");
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("punktfunk pair"),
|
||||||
|
"{args:?} overview lists the verbs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("help <command>"),
|
||||||
|
"{args:?} overview points at per-verb help"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_verb_help_answers_both_spellings() {
|
||||||
|
for args in [
|
||||||
|
&["help", "launch"][..],
|
||||||
|
&["launch", "--help"][..],
|
||||||
|
&["launch", "-h"][..],
|
||||||
|
] {
|
||||||
|
let out = punktfunk(args);
|
||||||
|
assert!(out.status.success(), "{args:?} must exit 0");
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("--exec"),
|
||||||
|
"{args:?} documents launch's flags"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_prints_the_crate_version() {
|
||||||
|
let out = punktfunk(&["--version"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||||
|
let out = punktfunk(&["frobnicate"]);
|
||||||
|
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
|
||||||
|
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
|
||||||
|
|
||||||
|
let out = punktfunk(&["help", "frobnicate"]);
|
||||||
|
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||||
|
}
|
||||||
@@ -41,37 +41,16 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
# The GTK4/libadwaita session shell (app.rs, cli.rs, ui_*.rs, shortcuts.rs, spawn.rs). Those
|
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
|
||||||
# modules are `#[cfg(target_os = "linux")]` in main.rs, so their dependencies belong in a
|
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
|
||||||
# linux-only block — the Windows leg of this crate is a stub binary and must not pull GTK.
|
# belong to `clients/linux` (the shell) and must never appear here — a stray copy of the shell's
|
||||||
#
|
# modules landed in src/ once (b0ea1e6b) and dragging its dependencies in behind it is what let
|
||||||
# Versions and features track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5"):
|
# the wrong `main.rs` build and ship as `punktfunk-session`.
|
||||||
# the two crates compile the same widget vocabulary, and a version skew between them would show up
|
|
||||||
# as type mismatches through relm4 rather than as anything legible.
|
|
||||||
#
|
|
||||||
# glib / gio / gdk / pango are NOT listed on purpose — the sources reach them as `gtk::glib`,
|
|
||||||
# `gtk::gio`, `gtk::gdk` and `gtk::pango`, gtk4's own re-exports. Declaring them separately would
|
|
||||||
# risk resolving a DIFFERENT version of the same sys crate than the one gtk4 links against.
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
|
||||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
|
||||||
relm4 = { version = "0.11", features = ["libadwaita"] }
|
|
||||||
async-channel = "2"
|
|
||||||
# NOT optional here, unlike the shared block above where it hangs off `ui`. cli.rs's `--json` host
|
|
||||||
# listing is a CLI feature, not a console-UI one, so on Linux it is needed whether or not `ui` is
|
|
||||||
# on — and `--no-default-features` is a documented Linux build ("same streaming, stats on stdout
|
|
||||||
# only"), which without this simply did not compile.
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
|
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
|
||||||
# the SDK); same pattern as clients/windows.
|
# the SDK); same pattern as clients/windows.
|
||||||
[target.'cfg(windows)'.build-dependencies]
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
winresource = "0.1"
|
winresource = "0.1"
|
||||||
|
|
||||||
# Compiles data/ (the OS-mark symbolic icons) into the embedded gresource (build.rs) —
|
|
||||||
# needs `glib-compile-resources`, which ships with the GTK dev stack this crate needs anyway.
|
|
||||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
|
||||||
glib-build-tools = "0.22"
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st
|
|||||||
presenter of the Linux client re-architecture (punktfunk-planning:
|
presenter of the Linux client re-architecture (punktfunk-planning:
|
||||||
`linux-client-rearchitecture.md`).
|
`linux-client-rearchitecture.md`).
|
||||||
|
|
||||||
|
This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell
|
||||||
|
(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the
|
||||||
|
same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings,
|
||||||
|
wake) and hands the result down, normally as a `--resolved-spec` file. It reads the
|
||||||
|
shared stores only as the compat fallback for a bare hand-launched invocation.
|
||||||
|
|
||||||
```
|
```
|
||||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||||
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||||
@@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
|||||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||||
|
|
||||||
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
Pairing is `punktfunk pair <host>` — the CLI, which ships alongside this binary in every
|
||||||
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
package and needs no window and no toolkit either. `punktfunk-session --pair` still works
|
||||||
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
for one release (someone's provisioning script calls it today) but prints a deprecation
|
||||||
label the host files this client under, defaulting to the hostname. It is in the
|
notice: pairing is a trust ceremony and belongs to the brain, not a renderer.
|
||||||
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
|
||||||
to pull in Skia.
|
|
||||||
|
|
||||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||||
|
|||||||
@@ -4,17 +4,8 @@
|
|||||||
//! icon is the generic default).
|
//! icon is the generic default).
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Linux: compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic
|
// No Linux leg: this binary carries no toolkit, so it has no gresource to compile. The
|
||||||
// icons) into a gresource bundle, registered at startup via
|
// OS-mark icons belong to the GTK shell (`clients/linux/data`), which builds its own.
|
||||||
// `gio::resources_register_include!`. Host-gated like the Windows leg below.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
|
||||||
glib_build_tools::compile_resources(
|
|
||||||
&["data"],
|
|
||||||
"data/resources.gresource.xml",
|
|
||||||
"punktfunk-client.gresource",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
|
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
|
||||||
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
|
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="#000000"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 635 B |
@@ -1,2 +0,0 @@
|
|||||||
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 836 B |
@@ -1,2 +0,0 @@
|
|||||||
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 875 B |
@@ -1,2 +0,0 @@
|
|||||||
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="#000000"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 932 B |
@@ -1,2 +0,0 @@
|
|||||||
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 339 B |
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
|
||||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
|
||||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
|
||||||
<gresources>
|
|
||||||
<gresource prefix="/io/unom/Punktfunk">
|
|
||||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-steam-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-ubuntu-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-fedora-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-arch-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
|
|
||||||
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
|
|
||||||
</gresource>
|
|
||||||
</gresources>
|
|
||||||
@@ -1,699 +0,0 @@
|
|||||||
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
|
||||||
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
|
||||||
//! scenes.
|
|
||||||
|
|
||||||
use crate::app::AppModel;
|
|
||||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
|
||||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
|
||||||
use gtk::glib;
|
|
||||||
use gtk::prelude::*;
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use relm4::prelude::*;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
|
||||||
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
|
||||||
pub struct ShotCtx {
|
|
||||||
pub window: adw::ApplicationWindow,
|
|
||||||
pub nav: adw::NavigationView,
|
|
||||||
pub hosts: relm4::Sender<HostsMsg>,
|
|
||||||
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
|
||||||
pub gamepad: crate::gamepad::GamepadService,
|
|
||||||
pub identity: (String, String),
|
|
||||||
pub sender: ComponentSender<AppModel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The value following `flag` in argv, if present (`--flag value`).
|
|
||||||
pub fn arg_value(flag: &str) -> Option<String> {
|
|
||||||
std::env::args()
|
|
||||||
.skip_while(|a| a != flag)
|
|
||||||
.nth(1)
|
|
||||||
.filter(|v| !v.starts_with("--"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True if argv contains `flag` (a valueless switch).
|
|
||||||
pub fn arg_flag(flag: &str) -> bool {
|
|
||||||
std::env::args().any(|a| a == flag)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
|
|
||||||
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
|
|
||||||
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
|
|
||||||
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
|
|
||||||
/// this only decides whether argv contains something addressed to us.
|
|
||||||
pub fn deep_link_arg() -> Option<String> {
|
|
||||||
std::env::args().skip(1).find(|a| {
|
|
||||||
let lower = a.to_ascii_lowercase();
|
|
||||||
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
|
||||||
/// console library exec the session binary, which handles its own fullscreen).
|
|
||||||
pub fn fullscreen_mode() -> bool {
|
|
||||||
arg_flag("--fullscreen")
|
|
||||||
|| std::env::var_os("SteamDeck").is_some()
|
|
||||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable
|
|
||||||
/// port yields `None` for it (callers decide whether to default or bail).
|
|
||||||
fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
|
||||||
match target.rsplit_once(':') {
|
|
||||||
Some((a, p)) => (a.to_string(), p.parse().ok()),
|
|
||||||
None => (target.to_string(), Some(9777)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--connect` / `--browse`: streams and the console library live in the
|
|
||||||
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
|
||||||
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
|
||||||
/// these flags) working unchanged until it's repointed at the session binary.
|
|
||||||
pub fn exec_session() -> glib::ExitCode {
|
|
||||||
use std::os::unix::process::CommandExt as _;
|
|
||||||
let forward = [
|
|
||||||
"--connect",
|
|
||||||
"--browse",
|
|
||||||
"--fp",
|
|
||||||
"--launch",
|
|
||||||
"--mgmt",
|
|
||||||
"--connect-timeout",
|
|
||||||
];
|
|
||||||
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
|
||||||
while let Some(a) = args.next() {
|
|
||||||
if a == "--fullscreen" || a == "--stats" {
|
|
||||||
cmd.arg(a);
|
|
||||||
} else if forward.contains(&a.as_str()) {
|
|
||||||
cmd.arg(&a);
|
|
||||||
if let Some(v) = args.peek() {
|
|
||||||
if !v.starts_with("--") {
|
|
||||||
cmd.arg(args.next().unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let err = cmd.exec(); // only returns on failure
|
|
||||||
eprintln!("exec punktfunk-session: {err}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
|
||||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
|
||||||
/// store the streaming path uses, so pairing here makes the stream work.
|
|
||||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
|
||||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
|
||||||
let Some(target) = arg_value("--connect") else {
|
|
||||||
eprintln!("--pair requires --connect host[:port]");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
};
|
|
||||||
let (addr, port) = parse_host_port(&target);
|
|
||||||
let port = port.unwrap_or(9777);
|
|
||||||
// The label the HOST stores this client under (its paired-devices list).
|
|
||||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
|
||||||
|
|
||||||
let identity = match crate::trust::load_or_create_identity() {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("client identity: {e:#}");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
|
||||||
Ok(fp) => {
|
|
||||||
let fp_hex = crate::trust::hex(&fp);
|
|
||||||
crate::trust::persist_host(
|
|
||||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
|
||||||
&addr,
|
|
||||||
port,
|
|
||||||
&fp_hex,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
// A host manually added via `--add-host` (no fingerprint yet) is stored as an
|
|
||||||
// addr-keyed placeholder; now that the ceremony yielded the real fingerprint,
|
|
||||||
// `persist_host` created the fp-keyed entry — drop the placeholder so the list
|
|
||||||
// shows this host once, not twice.
|
|
||||||
forget_placeholder(&addr, port);
|
|
||||||
println!("paired {addr}:{port} fp={fp_hex}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"pairing failed: {} ({e:?})",
|
|
||||||
crate::trust::pair_error_message(&e)
|
|
||||||
);
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
|
||||||
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
|
||||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
|
||||||
pub fn cli_wake() -> glib::ExitCode {
|
|
||||||
let Some(target) = arg_value("--wake") else {
|
|
||||||
eprintln!("--wake requires host[:port]");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
};
|
|
||||||
let (addr, port) = parse_host_port(&target);
|
|
||||||
let port = port.unwrap_or(9777);
|
|
||||||
let mac = crate::trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.addr == addr && h.port == port)
|
|
||||||
.map(|h| h.mac.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if mac.is_empty() {
|
|
||||||
eprintln!(
|
|
||||||
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
|
||||||
advertised MAC is learned"
|
|
||||||
);
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
crate::wol::wake(&mac, addr.parse().ok());
|
|
||||||
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
|
||||||
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
|
||||||
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
|
||||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
|
||||||
let (addr, port) = match target.rsplit_once(':') {
|
|
||||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
|
||||||
_ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT),
|
|
||||||
};
|
|
||||||
let identity = match crate::trust::load_or_create_identity() {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("client identity: {e:#}");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pin = arg_value("--fp")
|
|
||||||
.as_deref()
|
|
||||||
.and_then(crate::trust::parse_hex32)
|
|
||||||
.or_else(|| {
|
|
||||||
crate::trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.addr == addr)
|
|
||||||
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
|
||||||
});
|
|
||||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
|
||||||
Ok(games) => {
|
|
||||||
for g in &games {
|
|
||||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
|
||||||
}
|
|
||||||
println!("{} game(s)", games.len());
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("library: {e}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`)
|
|
||||||
// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan
|
|
||||||
// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/
|
|
||||||
// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client
|
|
||||||
// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts
|
|
||||||
// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached
|
|
||||||
// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline.
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The per-probe budget: a cold host on a routed link answers in well under this, and every
|
|
||||||
/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum.
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
|
||||||
|
|
||||||
/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP
|
|
||||||
/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint).
|
|
||||||
enum Selector {
|
|
||||||
Fp(String),
|
|
||||||
Addr(String, u16),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_selector(s: &str) -> Selector {
|
|
||||||
if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
||||||
Selector::Fp(s.to_lowercase())
|
|
||||||
} else {
|
|
||||||
let (addr, port) = parse_host_port(s);
|
|
||||||
Selector::Addr(addr, port.unwrap_or(9777))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selector {
|
|
||||||
fn matches(&self, h: &KnownHost) -> bool {
|
|
||||||
match self {
|
|
||||||
Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp),
|
|
||||||
Selector::Addr(addr, port) => h.addr == *addr && h.port == *port,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips).
|
|
||||||
fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
|
||||||
crate::trust::probe_reachable_many(
|
|
||||||
hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(),
|
|
||||||
PROBE_TIMEOUT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
|
||||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
|
||||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
|
||||||
/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`.
|
|
||||||
pub fn headless_list_hosts() -> glib::ExitCode {
|
|
||||||
let known = KnownHosts::load();
|
|
||||||
let online: Option<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
|
|
||||||
let hosts: Vec<serde_json::Value> = known
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, h)| {
|
|
||||||
serde_json::json!({
|
|
||||||
"name": h.name,
|
|
||||||
"addr": h.addr,
|
|
||||||
"port": h.port,
|
|
||||||
"fp_hex": h.fp_hex,
|
|
||||||
"paired": h.paired,
|
|
||||||
"mac": h.mac,
|
|
||||||
"os": h.os,
|
|
||||||
"last_used": h.last_used,
|
|
||||||
"online": online.as_ref().map(|v| serde_json::Value::Bool(v[i]))
|
|
||||||
.unwrap_or(serde_json::Value::Null),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) {
|
|
||||||
Ok(s) => {
|
|
||||||
println!("{s}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list-hosts: {e}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap
|
|
||||||
/// "is it online / test this address" check that never touches mDNS.
|
|
||||||
pub fn headless_reachable(target: &str) -> glib::ExitCode {
|
|
||||||
let (addr, port) = parse_host_port(target);
|
|
||||||
let port = port.unwrap_or(9777);
|
|
||||||
if NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
|
||||||
println!("reachable {addr}:{port}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
} else {
|
|
||||||
eprintln!("unreachable {addr}:{port}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can
|
|
||||||
/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the
|
|
||||||
/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair`
|
|
||||||
/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert)
|
|
||||||
/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added <addr>:<port>`.
|
|
||||||
pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
|
||||||
let (addr, port) = parse_host_port(target);
|
|
||||||
let port = port.unwrap_or(9777);
|
|
||||||
let name = arg_value("--host-label")
|
|
||||||
.map(|n| n.trim().to_string())
|
|
||||||
.filter(|n| !n.is_empty())
|
|
||||||
.unwrap_or_else(|| addr.clone());
|
|
||||||
if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) {
|
|
||||||
// Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN
|
|
||||||
// ceremony happened; a later `--pair` upgrades it).
|
|
||||||
crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false);
|
|
||||||
forget_placeholder(&addr, port);
|
|
||||||
println!("added {addr}:{port} fp={}", fp_hex.to_lowercase());
|
|
||||||
return glib::ExitCode::SUCCESS;
|
|
||||||
}
|
|
||||||
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
if let Some(h) = known
|
|
||||||
.hosts
|
|
||||||
.iter_mut()
|
|
||||||
.find(|h| h.addr == addr && h.port == port)
|
|
||||||
{
|
|
||||||
h.name = name;
|
|
||||||
} else {
|
|
||||||
known.hosts.push(KnownHost {
|
|
||||||
name,
|
|
||||||
addr: addr.clone(),
|
|
||||||
port,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
match known.save() {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("added {addr}:{port}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("add-host: {e:#}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--set-host <fp|host[:port]> [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved
|
|
||||||
/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or
|
|
||||||
/// current address. Prints `updated <name>`; fails if nothing matched.
|
|
||||||
pub fn headless_set_host(selector: &str) -> glib::ExitCode {
|
|
||||||
let sel = parse_selector(selector);
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else {
|
|
||||||
eprintln!("set-host: no saved host matches {selector:?}");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
};
|
|
||||||
if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) {
|
|
||||||
if !name.is_empty() {
|
|
||||||
h.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) {
|
|
||||||
if !addr.is_empty() {
|
|
||||||
h.addr = addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::<u16>().ok()) {
|
|
||||||
h.port = port;
|
|
||||||
}
|
|
||||||
let label = h.name.clone();
|
|
||||||
match known.save() {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("updated {label}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("set-host: {e:#}");
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--forget-host <fp|host[:port]>` — remove a saved host (drops the pinned fingerprint; a later
|
|
||||||
/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent).
|
|
||||||
pub fn headless_forget_host(selector: &str) -> glib::ExitCode {
|
|
||||||
let sel = parse_selector(selector);
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
let before = known.hosts.len();
|
|
||||||
known.hosts.retain(|h| !sel.matches(h));
|
|
||||||
let removed = before - known.hosts.len();
|
|
||||||
if removed > 0 {
|
|
||||||
if let Err(e) = known.save() {
|
|
||||||
eprintln!("forget-host: {e:#}");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("forgot {removed}");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--reset` — clear this device's client state: the saved known-hosts and the stream
|
|
||||||
/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately
|
|
||||||
/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts);
|
|
||||||
/// a caller wanting a true factory reset removes those separately. Missing files are fine.
|
|
||||||
pub fn headless_reset() -> glib::ExitCode {
|
|
||||||
let Ok(dir) = crate::trust::config_dir() else {
|
|
||||||
eprintln!("reset: could not resolve config dir (HOME unset?)");
|
|
||||||
return glib::ExitCode::FAILURE;
|
|
||||||
};
|
|
||||||
let mut ok = true;
|
|
||||||
for name in ["client-known-hosts.json", "client-gtk-settings.json"] {
|
|
||||||
match std::fs::remove_file(dir.join(name)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("reset: {name}: {e}");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
println!("reset");
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
} else {
|
|
||||||
glib::ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
|
||||||
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
|
||||||
/// dispatch in `app.rs` is a single line.
|
|
||||||
pub fn headless_host_command() -> Option<glib::ExitCode> {
|
|
||||||
if arg_flag("--list-hosts") {
|
|
||||||
return Some(headless_list_hosts());
|
|
||||||
}
|
|
||||||
if let Some(t) = arg_value("--reachable") {
|
|
||||||
return Some(headless_reachable(&t));
|
|
||||||
}
|
|
||||||
if let Some(t) = arg_value("--add-host") {
|
|
||||||
return Some(headless_add_host(&t));
|
|
||||||
}
|
|
||||||
if let Some(s) = arg_value("--set-host") {
|
|
||||||
return Some(headless_set_host(&s));
|
|
||||||
}
|
|
||||||
if let Some(s) = arg_value("--forget-host") {
|
|
||||||
return Some(headless_forget_host(&s));
|
|
||||||
}
|
|
||||||
if arg_flag("--reset") {
|
|
||||||
return Some(headless_reset());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
|
||||||
pub fn shot_scene() -> Option<String> {
|
|
||||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render one mock-populated, host-free scene over the already-presented window, then
|
|
||||||
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
|
||||||
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
|
||||||
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
|
||||||
/// the session binary now).
|
|
||||||
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
|
||||||
let sender = &ctx.sender;
|
|
||||||
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
|
||||||
let mock_req = || ConnectRequest {
|
|
||||||
name: "Living Room PC".to_string(),
|
|
||||||
addr: "192.168.1.42".to_string(),
|
|
||||||
port: 9777,
|
|
||||||
fp_hex: Some(
|
|
||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
|
||||||
),
|
|
||||||
pair_optional: true,
|
|
||||||
launch: None,
|
|
||||||
mac: Vec::new(),
|
|
||||||
profile: None,
|
|
||||||
};
|
|
||||||
let mock_advert =
|
|
||||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
|
||||||
key: key.to_string(),
|
|
||||||
fullname: format!("{key}._punktfunk._udp.local."),
|
|
||||||
name: name.to_string(),
|
|
||||||
addr: addr.to_string(),
|
|
||||||
port: 9777,
|
|
||||||
fp_hex: fp.to_string(),
|
|
||||||
pair: "required".to_string(),
|
|
||||||
mgmt_port: None,
|
|
||||||
mac: Vec::new(),
|
|
||||||
os: "linux/arch/steamos".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// What the self-capture renders: the main window, except for scenes that open their
|
|
||||||
// own toplevel (the shortcuts window).
|
|
||||||
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
|
||||||
let hosts = &ctx.hosts;
|
|
||||||
match scene {
|
|
||||||
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
|
||||||
// adverts through the same path the mDNS stream feeds.
|
|
||||||
"hosts" | "02-hosts" => {
|
|
||||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
|
||||||
"mock-online",
|
|
||||||
"Living Room PC",
|
|
||||||
"192.168.1.42",
|
|
||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
|
||||||
)));
|
|
||||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
|
||||||
"mock-new",
|
|
||||||
"steamdeck",
|
|
||||||
"192.168.1.77",
|
|
||||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
"settings" | "03-settings" => {
|
|
||||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
|
||||||
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
};
|
|
||||||
let probes = crate::ui_settings::DeviceProbes {
|
|
||||||
adapters: vec![
|
|
||||||
"NVIDIA GeForce RTX 4070".to_string(),
|
|
||||||
"AMD Radeon 780M".to_string(),
|
|
||||||
],
|
|
||||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
|
||||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
|
||||||
};
|
|
||||||
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
|
|
||||||
// PROFILE scope — the second half of the settings surface (design
|
|
||||||
// client-settings-profiles.md §5.1), where only profileable rows render.
|
|
||||||
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.and_then(|reference| {
|
|
||||||
pf_client_core::profiles::ProfilesFile::load()
|
|
||||||
.resolve(&reference)
|
|
||||||
.0
|
|
||||||
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
|
|
||||||
})
|
|
||||||
.unwrap_or(crate::ui_settings::Scope::Defaults);
|
|
||||||
let dialog = crate::ui_settings::show_scoped(
|
|
||||||
&ctx.window,
|
|
||||||
ctx.settings.clone(),
|
|
||||||
&ctx.gamepad,
|
|
||||||
&probes,
|
|
||||||
scope,
|
|
||||||
|_| {},
|
|
||||||
|| {},
|
|
||||||
);
|
|
||||||
// Optional page for the capture (general/display/input/audio/controllers);
|
|
||||||
// the dialog opens on General otherwise.
|
|
||||||
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
|
||||||
if !page.is_empty() {
|
|
||||||
use adw::prelude::PreferencesDialogExt as _;
|
|
||||||
dialog.set_visible_page_name(&page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
|
||||||
"pair" | "05-pair" => {
|
|
||||||
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
|
||||||
}
|
|
||||||
"addhost" | "06-addhost" => {
|
|
||||||
let _ = hosts.send(HostsMsg::ShowAddHost);
|
|
||||||
}
|
|
||||||
"shortcuts" | "07-shortcuts" => {
|
|
||||||
let w = crate::app::shortcuts_window(&ctx.window);
|
|
||||||
w.present();
|
|
||||||
target = w.upcast();
|
|
||||||
}
|
|
||||||
// The library page with injected entries: mixed stores exercising the badge set,
|
|
||||||
// no-art placeholders, and one solid-color texture standing in for a poster.
|
|
||||||
"library" | "08-library" => {
|
|
||||||
let (games, art) = mock_library();
|
|
||||||
crate::ui_library::open_mock(
|
|
||||||
&ctx.nav,
|
|
||||||
ctx.identity.clone(),
|
|
||||||
sender,
|
|
||||||
mock_req(),
|
|
||||||
games,
|
|
||||||
art,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(900);
|
|
||||||
let scene = scene.to_string();
|
|
||||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
|
||||||
use std::io::Write as _;
|
|
||||||
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
|
||||||
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
|
||||||
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
|
||||||
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
|
||||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
|
||||||
.ok()
|
|
||||||
.filter(|p| !p.is_empty());
|
|
||||||
if let Some(out) = &self_capture {
|
|
||||||
if let Err(e) = save_png(&target, out) {
|
|
||||||
eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("PF_SHOT_READY scene={scene}");
|
|
||||||
let _ = std::io::stdout().flush();
|
|
||||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
|
||||||
// don't stack windows on a live desktop.
|
|
||||||
if self_capture.is_some() {
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
|
||||||
/// plus one solid-colour poster texture.
|
|
||||||
fn mock_library() -> (
|
|
||||||
Vec<crate::library::GameEntry>,
|
|
||||||
Vec<(String, gtk::gdk::Texture)>,
|
|
||||||
) {
|
|
||||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
|
||||||
id: id.to_string(),
|
|
||||||
store: store.to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
art: crate::library::Artwork::default(),
|
|
||||||
platform: None,
|
|
||||||
};
|
|
||||||
let games = vec![
|
|
||||||
game("steam:570", "steam", "Dota 2"),
|
|
||||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
|
||||||
game("custom:emu-1", "custom", "RetroArch"),
|
|
||||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
|
||||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
|
||||||
game("lutris:osu", "lutris", "osu!"),
|
|
||||||
];
|
|
||||||
let art = vec![(
|
|
||||||
"steam:570".to_string(),
|
|
||||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
|
||||||
)];
|
|
||||||
(games, art)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
|
||||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
|
||||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
|
||||||
gtk::gdk::MemoryTexture::new(
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
gtk::gdk::MemoryFormat::R8g8b8a8,
|
|
||||||
&glib::Bytes::from_owned(px),
|
|
||||||
(w * 4) as usize,
|
|
||||||
)
|
|
||||||
.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable →
|
|
||||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
|
||||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
|
||||||
use anyhow::Context as _;
|
|
||||||
let (w, h) = (widget.width(), widget.height());
|
|
||||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
|
||||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
|
||||||
let snapshot = gtk::Snapshot::new();
|
|
||||||
paintable.snapshot(&snapshot, f64::from(w), f64::from(h));
|
|
||||||
let node = snapshot.to_node().context("empty snapshot")?;
|
|
||||||
let renderer = widget
|
|
||||||
.native()
|
|
||||||
.context("widget not realized")?
|
|
||||||
.renderer()
|
|
||||||
.context("no gsk renderer")?;
|
|
||||||
let texture = renderer.render_texture(node, None);
|
|
||||||
texture
|
|
||||||
.save_to_png(path)
|
|
||||||
.with_context(|| format!("save {path}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,645 @@
|
|||||||
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
|
||||||
|
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
|
||||||
|
//! the power-user CLI build).
|
||||||
//!
|
//!
|
||||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||||
|
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||||
|
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||||
|
//! with no window and no toolkit, for machines that have only a shell.
|
||||||
|
//!
|
||||||
|
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||||
|
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||||
|
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||||
|
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
mod console;
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol};
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
mod app;
|
mod session_main {
|
||||||
#[cfg(target_os = "linux")]
|
use pf_client_core::gamepad::GamepadService;
|
||||||
mod cli;
|
use pf_client_core::session::SessionParams;
|
||||||
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
use pf_client_core::trust;
|
||||||
#[cfg(target_os = "linux")]
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
mod shortcuts;
|
use std::sync::atomic::AtomicBool;
|
||||||
#[cfg(target_os = "linux")]
|
use std::sync::Arc;
|
||||||
mod spawn;
|
use std::time::Duration;
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_hosts;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_library;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_settings;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_trust;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
pub const EXIT_CONNECT_FAILED: u8 = 2;
|
||||||
fn main() -> gtk::glib::ExitCode {
|
pub const EXIT_TRUST_REJECTED: u8 = 3;
|
||||||
app::run()
|
pub const EXIT_PRESENTER_FAILED: u8 = 4;
|
||||||
|
|
||||||
|
/// The value following `flag` in argv, if present (`--flag value`).
|
||||||
|
pub(crate) fn arg_value(flag: &str) -> Option<String> {
|
||||||
|
std::env::args()
|
||||||
|
.skip_while(|a| a != flag)
|
||||||
|
.nth(1)
|
||||||
|
.filter(|v| !v.starts_with("--"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn arg_flag(flag: &str) -> bool {
|
||||||
|
std::env::args().any(|a| a == flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||||
|
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||||
|
/// gated with `mod browse`, its one caller.)
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
pub(crate) fn fullscreen_mode() -> bool {
|
||||||
|
arg_flag("--fullscreen")
|
||||||
|
|| std::env::var_os("SteamDeck").is_some()
|
||||||
|
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||||
|
/// shell passes its own position so the session opens on the same monitor); absent or
|
||||||
|
/// unparsable = centered on the primary display.
|
||||||
|
pub(crate) fn window_pos() -> Option<(i32, i32)> {
|
||||||
|
let v = arg_value("--window-pos")?;
|
||||||
|
let (x, y) = v.split_once(',')?;
|
||||||
|
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||||
|
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||||
|
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||||
|
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||||
|
/// later stream connect silently.
|
||||||
|
///
|
||||||
|
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||||
|
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||||
|
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||||
|
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||||
|
fn headless_pair(pin: &str) -> u8 {
|
||||||
|
let Some(target) = arg_value("--connect") else {
|
||||||
|
eprintln!("--pair requires --connect host[:port]");
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
};
|
||||||
|
let (addr, port) = parse_host_port(&target);
|
||||||
|
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||||
|
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||||
|
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||||
|
|
||||||
|
let identity = match trust::load_or_create_identity() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("client identity: {e:#}");
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||||
|
Ok(fp) => {
|
||||||
|
let fp_hex = trust::hex(&fp);
|
||||||
|
trust::persist_host(
|
||||||
|
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||||
|
&addr,
|
||||||
|
port,
|
||||||
|
&fp_hex,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
trust::forget_placeholder(&addr, port);
|
||||||
|
println!("paired {addr}:{port} fp={fp_hex}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||||
|
EXIT_TRUST_REJECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host[:port]`, port defaulting to the native 9777.
|
||||||
|
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||||
|
match target.rsplit_once(':') {
|
||||||
|
Some((a, p)) => match p.parse() {
|
||||||
|
Ok(port) => (a.to_string(), port),
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("unparsable port in '{target}', using default 9777");
|
||||||
|
(a.to_string(), 9777)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => (target.to_string(), 9777),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
|
||||||
|
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
|
||||||
|
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
|
||||||
|
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
|
||||||
|
/// how "Connect with ▸ Default settings" reaches a bound host.
|
||||||
|
fn profile_arg() -> Option<String> {
|
||||||
|
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
|
||||||
|
/// shell's request-access flow passes ~185 s because the host PARKS the connection
|
||||||
|
/// until the operator clicks Approve.
|
||||||
|
pub(crate) fn connect_timeout() -> Duration {
|
||||||
|
Duration::from_secs(
|
||||||
|
arg_value("--connect-timeout")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(15),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
|
||||||
|
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
||||||
|
/// window's display (the GTK client reads the monitor under its window — same
|
||||||
|
/// contract).
|
||||||
|
///
|
||||||
|
/// `settings` is what [`trust::effective_settings`] returned, never a raw
|
||||||
|
/// `Settings::load()`: both callers resolve the host's profile first, so the two
|
||||||
|
/// construction sites cannot drift (they historically did — touching one and not the
|
||||||
|
/// other is a Windows-only build break). `profile` is that profile's name, for the
|
||||||
|
/// stats overlay's first line.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn session_params(
|
||||||
|
settings: &trust::Settings,
|
||||||
|
profile: Option<String>,
|
||||||
|
clipboard_override: Option<bool>,
|
||||||
|
addr: String,
|
||||||
|
port: u16,
|
||||||
|
pin: [u8; 32],
|
||||||
|
identity: (String, String),
|
||||||
|
launch: Option<String>,
|
||||||
|
gamepad: &GamepadService,
|
||||||
|
native: Mode,
|
||||||
|
force_software: Arc<AtomicBool>,
|
||||||
|
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||||
|
) -> SessionParams {
|
||||||
|
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec
|
||||||
|
// mode the spawner already resolved it; otherwise this looks it up itself, which is
|
||||||
|
// the last store read the compat path still owes. `addr` is moved into the struct
|
||||||
|
// below, so read it first.
|
||||||
|
let clipboard = clipboard_override.unwrap_or_else(|| {
|
||||||
|
trust::KnownHosts::load()
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
|
||||||
|
});
|
||||||
|
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||||
|
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||||
|
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||||
|
// it lands before the session attaches. Empty = automatic (most recent).
|
||||||
|
if !settings.forward_pad.is_empty() {
|
||||||
|
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||||
|
}
|
||||||
|
let mode = Mode {
|
||||||
|
width: if settings.width == 0 {
|
||||||
|
native.width
|
||||||
|
} else {
|
||||||
|
settings.width
|
||||||
|
},
|
||||||
|
height: if settings.width == 0 {
|
||||||
|
native.height
|
||||||
|
} else {
|
||||||
|
settings.height
|
||||||
|
},
|
||||||
|
refresh_hz: if settings.refresh_hz == 0 {
|
||||||
|
native.refresh_hz.max(30)
|
||||||
|
} else {
|
||||||
|
settings.refresh_hz
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||||
|
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||||
|
// Native/explicit resolution so it composes uniformly with both.
|
||||||
|
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||||
|
mode.width,
|
||||||
|
mode.height,
|
||||||
|
settings.render_scale,
|
||||||
|
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||||
|
);
|
||||||
|
let mode = Mode {
|
||||||
|
width: sw,
|
||||||
|
height: sh,
|
||||||
|
..mode
|
||||||
|
};
|
||||||
|
SessionParams {
|
||||||
|
host: addr,
|
||||||
|
port,
|
||||||
|
mode,
|
||||||
|
compositor: CompositorPref::from_name(&settings.compositor)
|
||||||
|
.unwrap_or(CompositorPref::Auto),
|
||||||
|
gamepad: {
|
||||||
|
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||||
|
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||||
|
// session default for a pad that never declares one, so an explicit choice that
|
||||||
|
// stopped here would be undone the moment a controller connected.
|
||||||
|
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||||
|
gamepad.set_kind_override(chosen);
|
||||||
|
match chosen {
|
||||||
|
GamepadPref::Auto => gamepad.auto_pref(),
|
||||||
|
explicit => explicit,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bitrate_kbps: settings.bitrate_kbps,
|
||||||
|
audio_channels: settings.audio_channels,
|
||||||
|
preferred_codec: settings.preferred_codec(),
|
||||||
|
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||||
|
video_caps: if settings.hdr_enabled {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||||
|
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||||
|
// pump) pins one manually.
|
||||||
|
display_hdr: None,
|
||||||
|
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
||||||
|
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
||||||
|
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
||||||
|
// compositors only).
|
||||||
|
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||||
|
mic_enabled: settings.mic_enabled,
|
||||||
|
clipboard,
|
||||||
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
|
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||||
|
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||||
|
decoder: settings.decoder.clone(),
|
||||||
|
launch,
|
||||||
|
vulkan,
|
||||||
|
pin: Some(pin),
|
||||||
|
identity,
|
||||||
|
connect_timeout: connect_timeout(),
|
||||||
|
force_software,
|
||||||
|
profile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window's starting size under Match-window: the persisted last size, so the
|
||||||
|
/// first connect's mode already matches the glass; `None` (policy off / never
|
||||||
|
/// stored) = the 1280×720 default.
|
||||||
|
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
|
||||||
|
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
|
||||||
|
.then_some((settings.last_window_w, settings.last_window_h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Match-window policy hook for the presenter loop
|
||||||
|
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
|
||||||
|
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||||
|
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||||
|
/// next launch opens at it.
|
||||||
|
/// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The
|
||||||
|
/// callback used to load-modify-save the shared settings file from inside the renderer —
|
||||||
|
/// one of that file's five concurrent writers, for a value only the parent needs. It now
|
||||||
|
/// REPORTS the size on stdout and the spawner persists it
|
||||||
|
/// (design/client-architecture-split.md §5).
|
||||||
|
///
|
||||||
|
/// `persist_locally` keeps a hand-run session remembering its own window: nobody is
|
||||||
|
/// listening to stdout there, so the event alone would drop the value. A spawned session
|
||||||
|
/// leaves the write to its parent, which is the whole point.
|
||||||
|
pub(crate) fn match_window(
|
||||||
|
settings: &trust::Settings,
|
||||||
|
persist_locally: bool,
|
||||||
|
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||||
|
settings.match_window.then(|| {
|
||||||
|
Box::new(move |w: u32, h: u32| {
|
||||||
|
println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}");
|
||||||
|
if persist_locally {
|
||||||
|
pf_client_core::orchestrate::persist_window_size(w, h);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(u32, u32)>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||||
|
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||||
|
/// failure through the same contract when spawned with `--json-status`.
|
||||||
|
pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
||||||
|
let escaped: String = msg
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| match c {
|
||||||
|
'"' => vec!['\\', '"'],
|
||||||
|
'\\' => vec!['\\', '\\'],
|
||||||
|
'\n' => vec!['\\', 'n'],
|
||||||
|
c if (c as u32) < 0x20 => vec![' '],
|
||||||
|
c => vec![c],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match trust_rejected {
|
||||||
|
Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"),
|
||||||
|
None => println!("{{\"{key}\":\"{escaped}\"}}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
|
||||||
|
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
|
||||||
|
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
|
||||||
|
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
|
||||||
|
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
|
||||||
|
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
|
||||||
|
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
|
||||||
|
///
|
||||||
|
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||||
|
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||||
|
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn enable_radv_video_decode() {
|
||||||
|
const TOKEN: &str = "video_decode";
|
||||||
|
match std::env::var("RADV_PERFTEST") {
|
||||||
|
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||||
|
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||||
|
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||||
|
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() -> u8 {
|
||||||
|
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
||||||
|
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
||||||
|
if arg_flag("--list-adapters") {
|
||||||
|
return match pf_presenter::vk::list_adapters() {
|
||||||
|
Ok(names) => {
|
||||||
|
for n in names {
|
||||||
|
println!("{n}");
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("list-adapters: {e:#}");
|
||||||
|
EXIT_PRESENTER_FAILED
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
||||||
|
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
||||||
|
// same enumeration the GTK shell probes.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if arg_flag("--list-audio") {
|
||||||
|
return match pf_client_core::audio::devices() {
|
||||||
|
Ok((sinks, sources)) => {
|
||||||
|
for d in sinks {
|
||||||
|
println!("sink\t{}\t{}", d.name, d.description);
|
||||||
|
}
|
||||||
|
for d in sources {
|
||||||
|
println!("source\t{}\t{}", d.name, d.description);
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("list-audio: {e:#}");
|
||||||
|
EXIT_PRESENTER_FAILED
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
|
||||||
|
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
|
||||||
|
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
|
||||||
|
// release this needs; a renderer owning a trust ceremony is exactly the mixing of
|
||||||
|
// concerns the split exists to undo.
|
||||||
|
if let Some(pin) = arg_value("--pair") {
|
||||||
|
eprintln!(
|
||||||
|
"note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \
|
||||||
|
<host[:port]>` instead (same store, same result)."
|
||||||
|
);
|
||||||
|
return headless_pair(&pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||||
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||||
|
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
enable_radv_video_decode();
|
||||||
|
|
||||||
|
// The Settings device picks → env, unless the user already forced one by hand:
|
||||||
|
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||||
|
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||||
|
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||||
|
// the RADV knob (covers --connect and --browse).
|
||||||
|
{
|
||||||
|
let s = trust::Settings::load();
|
||||||
|
for (var, value) in [
|
||||||
|
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
||||||
|
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
||||||
|
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||||
|
] {
|
||||||
|
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||||
|
std::env::set_var(var, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||||
|
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
||||||
|
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||||
|
for var in [
|
||||||
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||||
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||||
|
] {
|
||||||
|
if let Ok(v) = std::env::var(var) {
|
||||||
|
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||||
|
std::env::remove_var(var);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if arg_flag("--browse") {
|
||||||
|
// Bare `--browse` opens the console home (hosts, pairing, settings);
|
||||||
|
// `--browse host[:port]` opens straight into that host's library.
|
||||||
|
let target = arg_value("--browse");
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
return crate::console::run(target.as_deref());
|
||||||
|
#[cfg(not(feature = "ui"))]
|
||||||
|
{
|
||||||
|
let _ = target;
|
||||||
|
eprintln!(
|
||||||
|
"--browse needs the console UI — this is the minimal build \
|
||||||
|
(rebuild without --no-default-features)"
|
||||||
|
);
|
||||||
|
return EXIT_PRESENTER_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(target) = arg_value("--connect") else {
|
||||||
|
eprintln!(
|
||||||
|
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
|
||||||
|
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||||
|
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||||
|
\n\
|
||||||
|
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||||
|
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||||
|
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||||
|
library. --profile picks a settings profile by id or name for this session\n\
|
||||||
|
only (\"\" = the global defaults); without it the host's own profile applies.\n\
|
||||||
|
--connect never dials a host it has no pinned fingerprint for —\n\
|
||||||
|
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||||
|
client."
|
||||||
|
);
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
};
|
||||||
|
let (addr, port) = parse_host_port(&target);
|
||||||
|
|
||||||
|
let identity = match trust::load_or_create_identity() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
json_line("error", &format!("client identity: {e:#}"), None);
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// `--resolved-spec <path>`: the spawner already did the resolving, so this process
|
||||||
|
// performs ZERO store reads (design/client-architecture-split.md §5) — no Settings
|
||||||
|
// load, no known-hosts lookup, no profile resolution. Without it (a hand-run
|
||||||
|
// `--connect`, an old Decky script) the session resolves for itself through the SAME
|
||||||
|
// helper, so the two modes cannot drift.
|
||||||
|
let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from);
|
||||||
|
let (settings, profile_name, clipboard_override) = match &spec {
|
||||||
|
Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
tracing::info!(path = %path.display(), "running from a resolved spec");
|
||||||
|
(s.settings, s.profile, Some(s.clipboard))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
json_line("error", &format!("resolved spec: {e}"), None);
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let (settings, profile) =
|
||||||
|
trust::effective_settings(&addr, port, profile_arg().as_deref());
|
||||||
|
(settings, profile.map(|p| p.name), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(name) = &profile_name {
|
||||||
|
tracing::info!(profile = %name, "streaming with a settings profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
||||||
|
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
|
||||||
|
// silent TOFU would defeat the pinning model. Pair via the desktop client.
|
||||||
|
let known = trust::KnownHosts::load();
|
||||||
|
let known_host = known
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.addr == addr && h.port == port);
|
||||||
|
let pin = arg_value("--fp")
|
||||||
|
.as_deref()
|
||||||
|
.and_then(trust::parse_hex32)
|
||||||
|
.or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex)));
|
||||||
|
let Some(pin) = pin else {
|
||||||
|
json_line(
|
||||||
|
"error",
|
||||||
|
&format!(
|
||||||
|
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||||
|
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||||
|
),
|
||||||
|
Some(true),
|
||||||
|
);
|
||||||
|
return EXIT_TRUST_REJECTED;
|
||||||
|
};
|
||||||
|
|
||||||
|
let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone());
|
||||||
|
let launch = arg_value("--launch");
|
||||||
|
let title = launch
|
||||||
|
.clone()
|
||||||
|
.map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}"));
|
||||||
|
|
||||||
|
let fullscreen = arg_flag("--fullscreen")
|
||||||
|
|| std::env::var_os("SteamDeck").is_some()
|
||||||
|
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some();
|
||||||
|
|
||||||
|
let opts = pf_presenter::SessionOpts {
|
||||||
|
window_title: format!("Punktfunk · {title}"),
|
||||||
|
fullscreen,
|
||||||
|
window_pos: window_pos(),
|
||||||
|
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||||
|
// demoting an explicitly chosen richer tier.
|
||||||
|
stats_verbosity: match settings.stats_verbosity() {
|
||||||
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||||
|
v => v,
|
||||||
|
},
|
||||||
|
touch_mode: settings.touch_mode(),
|
||||||
|
mouse_mode: settings.mouse_mode(),
|
||||||
|
invert_scroll: settings.invert_scroll,
|
||||||
|
json_status: true,
|
||||||
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
|
})),
|
||||||
|
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||||
|
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||||
|
#[cfg(not(feature = "ui"))]
|
||||||
|
overlay: None,
|
||||||
|
window_size: window_size(&settings),
|
||||||
|
// A spawned session (spec mode) reports its window; a hand-run one persists it.
|
||||||
|
match_window: match_window(&settings, spec.is_none()),
|
||||||
|
render_scale: settings.render_scale,
|
||||||
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome =
|
||||||
|
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||||
|
session_params(
|
||||||
|
&settings,
|
||||||
|
profile_name,
|
||||||
|
clipboard_override,
|
||||||
|
addr,
|
||||||
|
port,
|
||||||
|
pin,
|
||||||
|
identity,
|
||||||
|
launch,
|
||||||
|
gamepad,
|
||||||
|
native,
|
||||||
|
force_software,
|
||||||
|
vulkan,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(pf_presenter::Outcome::Ended(None)) => 0,
|
||||||
|
Ok(pf_presenter::Outcome::Ended(Some(reason))) => {
|
||||||
|
// The host ending the session (game quit, host shutdown) is a normal end
|
||||||
|
// for a one-shot stream binary — report the reason, exit clean.
|
||||||
|
json_line("ended", &reason, None);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Ok(pf_presenter::Outcome::ConnectFailed {
|
||||||
|
msg,
|
||||||
|
trust_rejected,
|
||||||
|
}) => {
|
||||||
|
json_line("error", &msg, Some(trust_rejected));
|
||||||
|
if trust_rejected {
|
||||||
|
EXIT_TRUST_REJECTED
|
||||||
|
} else {
|
||||||
|
EXIT_CONNECT_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
json_line("error", &format!("presenter: {e:#}"), None);
|
||||||
|
EXIT_PRESENTER_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
/// (the Mac client lives in clients/apple).
|
fn main() -> std::process::ExitCode {
|
||||||
#[cfg(not(target_os = "linux"))]
|
std::process::ExitCode::from(session_main::run())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in
|
||||||
|
/// clients/apple).
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
eprintln!(
|
||||||
|
"punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple"
|
||||||
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
|
|
||||||
//! profile or a game), design/client-deep-links.md §5.
|
|
||||||
//!
|
|
||||||
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
|
|
||||||
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
|
|
||||||
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
|
|
||||||
//! host store changes, because the URL itself carries the stable id, the address and the pin.
|
|
||||||
//!
|
|
||||||
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
|
|
||||||
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
|
|
||||||
//! exactly this, with its own confirmation) is the intended upgrade there.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
|
|
||||||
/// nowhere else — the standard check, and the one the portal docs use.
|
|
||||||
pub fn sandboxed() -> bool {
|
|
||||||
std::path::Path::new("/.flatpak-info").exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
|
|
||||||
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
|
|
||||||
/// works from a file manager, it just won't show up in search straight away.
|
|
||||||
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
|
|
||||||
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
|
|
||||||
let dir = PathBuf::from(home).join(".local/share/applications");
|
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
|
||||||
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
|
|
||||||
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
|
|
||||||
// key and turn the rest into an unparsable line (or, worse, another key).
|
|
||||||
let name = one_line(label);
|
|
||||||
let entry = format!(
|
|
||||||
"[Desktop Entry]\n\
|
|
||||||
Type=Application\n\
|
|
||||||
Name={name}\n\
|
|
||||||
Comment=Stream from this Punktfunk host\n\
|
|
||||||
Exec=punktfunk-client \"{url}\"\n\
|
|
||||||
Icon=io.unom.Punktfunk\n\
|
|
||||||
Terminal=false\n\
|
|
||||||
Categories=Game;Network;\n\
|
|
||||||
StartupNotify=true\n"
|
|
||||||
);
|
|
||||||
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
|
|
||||||
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
|
|
||||||
}
|
|
||||||
let _ = std::process::Command::new("update-desktop-database")
|
|
||||||
.arg(&dir)
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.status();
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
|
|
||||||
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
|
|
||||||
fn file_slug(label: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for c in label.chars() {
|
|
||||||
if c.is_ascii_alphanumeric() {
|
|
||||||
out.push(c.to_ascii_lowercase());
|
|
||||||
} else if !out.ends_with('-') {
|
|
||||||
out.push('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let trimmed = out.trim_matches('-');
|
|
||||||
let capped: String = trimmed.chars().take(48).collect();
|
|
||||||
if capped.is_empty() {
|
|
||||||
"host".to_string()
|
|
||||||
} else {
|
|
||||||
capped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
|
|
||||||
fn one_line(s: &str) -> String {
|
|
||||||
s.replace(['\n', '\r'], " ").trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
|
|
||||||
#[test]
|
|
||||||
fn labels_are_sanitised_both_ways() {
|
|
||||||
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
|
|
||||||
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
|
|
||||||
assert_eq!(file_slug("Desk · Work"), "desk-work");
|
|
||||||
assert_eq!(file_slug("////"), "host");
|
|
||||||
assert_eq!(file_slug(""), "host");
|
|
||||||
assert!(file_slug(&"x".repeat(200)).len() <= 48);
|
|
||||||
// The classic injection: a newline would end the Name key and start a new one.
|
|
||||||
assert_eq!(
|
|
||||||
one_line("Desk\nExec=rm -rf ~"),
|
|
||||||
"Desk Exec=rm -rf ~".to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
|
||||||
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
|
||||||
//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the
|
|
||||||
//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed
|
|
||||||
//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until
|
|
||||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 +
|
|
||||||
//! `trust_rejected` routed to the re-pair PIN ceremony.
|
|
||||||
//!
|
|
||||||
//! Spawning, the argv, the stdout contract and the child handle live in
|
|
||||||
//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell
|
|
||||||
//! and the coming CLI spawn through the same code, so "what flags does a stream get" and
|
|
||||||
//! "what does ready mean" have exactly one answer.
|
|
||||||
|
|
||||||
use crate::app::AppMsg;
|
|
||||||
use crate::ui_hosts::ConnectRequest;
|
|
||||||
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
|
||||||
use pf_client_core::trust::Settings;
|
|
||||||
|
|
||||||
/// Spawn tunables beyond a plain connect.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct SpawnOpts {
|
|
||||||
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
|
||||||
/// ~185 s because the host PARKS the connection until the operator approves.
|
|
||||||
pub connect_timeout_secs: Option<u64>,
|
|
||||||
/// Persist the host as *paired* once the child reports ready (request-access: the
|
|
||||||
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
|
||||||
pub persist_paired: bool,
|
|
||||||
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
|
||||||
/// the only abort a parked connect has.
|
|
||||||
pub cancel: Option<CancelHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use orchestrate::{session_binary, CancelHandle};
|
|
||||||
|
|
||||||
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
|
||||||
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
|
||||||
/// rather than the store — the app persists it once the child reports ready (the child
|
|
||||||
/// connects pinned to it, so ready proves the host really holds that identity).
|
|
||||||
///
|
|
||||||
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
|
||||||
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
|
||||||
pub fn spawn_session(
|
|
||||||
sender: relm4::Sender<AppMsg>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
fp_hex: String,
|
|
||||||
tofu: bool,
|
|
||||||
fullscreen_on_stream: bool,
|
|
||||||
opts: SpawnOpts,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
|
|
||||||
// the host's own binding, which the session resolves through the same helper this shell
|
|
||||||
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
|
|
||||||
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
|
|
||||||
// `profile=`) sets one, and it applies to this session alone.
|
|
||||||
//
|
|
||||||
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
|
|
||||||
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
|
|
||||||
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
|
|
||||||
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
|
|
||||||
let plan = ConnectPlan {
|
|
||||||
host: HostTarget {
|
|
||||||
name: req.name.clone(),
|
|
||||||
addr: req.addr.clone(),
|
|
||||||
port: req.port,
|
|
||||||
fp_hex: Some(fp_hex.clone()),
|
|
||||||
mac: req.mac.clone(),
|
|
||||||
id: None,
|
|
||||||
},
|
|
||||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
|
||||||
profile: None,
|
|
||||||
// A one-off pick rides the flag; without one the session resolves the host's own
|
|
||||||
// binding through the same helper this shell would have used.
|
|
||||||
profile_override: req.profile.clone(),
|
|
||||||
settings: Settings {
|
|
||||||
fullscreen_on_stream,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
wake: false,
|
|
||||||
connect_timeout_secs: opts.connect_timeout_secs,
|
|
||||||
tofu,
|
|
||||||
// The per-host clipboard decision, resolved here so the child doesn't look it up
|
|
||||||
// again — matched by address, the way every other per-host lookup matches.
|
|
||||||
clipboard: pf_client_core::trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync),
|
|
||||||
};
|
|
||||||
|
|
||||||
let persist_paired = opts.persist_paired;
|
|
||||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
|
||||||
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
|
||||||
SessionEvent::Ready => {
|
|
||||||
let _ = sender.send(AppMsg::SessionReady {
|
|
||||||
req: req.clone(),
|
|
||||||
fp_hex: fp_hex.clone(),
|
|
||||||
tofu,
|
|
||||||
persist_paired,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
SessionEvent::Error {
|
|
||||||
msg,
|
|
||||||
trust_rejected,
|
|
||||||
} => error = Some((msg, trust_rejected)),
|
|
||||||
SessionEvent::Ended(msg) => ended = Some(msg),
|
|
||||||
// The brain persists the window size; the shell has nothing to do with it.
|
|
||||||
SessionEvent::Window { .. } => {}
|
|
||||||
SessionEvent::Exited(code) => {
|
|
||||||
let _ = sender.send(AppMsg::SessionExited {
|
|
||||||
req: req.clone(),
|
|
||||||
code,
|
|
||||||
error: error.take(),
|
|
||||||
ended: ended.take(),
|
|
||||||
tofu,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's
|
|
||||||
//! unified library fetched over the management API (`library.rs`), pushed onto the nav
|
|
||||||
//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously
|
|
||||||
//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping
|
|
||||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
|
||||||
//! Hello via `ConnectRequest::launch`).
|
|
||||||
|
|
||||||
use crate::app::{AppModel, AppMsg};
|
|
||||||
use crate::library::{self, GameEntry};
|
|
||||||
use crate::trust;
|
|
||||||
use crate::ui_hosts::ConnectRequest;
|
|
||||||
use adw::prelude::*;
|
|
||||||
use gtk::{gdk, glib};
|
|
||||||
use relm4::prelude::*;
|
|
||||||
use std::cell::{Cell, RefCell};
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
|
||||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
|
||||||
/// art consumer (its weak upgrade fails).
|
|
||||||
struct State {
|
|
||||||
sender: ComponentSender<AppModel>,
|
|
||||||
identity: (String, String),
|
|
||||||
/// The advertised mgmt port when the host was live at open time (else the default).
|
|
||||||
mgmt_port: u16,
|
|
||||||
/// The host this library belongs to — cards clone it and add `launch`.
|
|
||||||
req: ConnectRequest,
|
|
||||||
stack: gtk::Stack,
|
|
||||||
flow: gtk::FlowBox,
|
|
||||||
error_page: adw::StatusPage,
|
|
||||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
|
||||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
|
||||||
/// The Picture each entry currently renders into (rebuilt per render), so async art
|
|
||||||
/// results land on the right card.
|
|
||||||
pics: RefCell<HashMap<String, gtk::Picture>>,
|
|
||||||
/// Screenshot mode: render injected entries only, never touch the network.
|
|
||||||
mock: Cell<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
|
||||||
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
|
||||||
pub fn open(
|
|
||||||
app: &AppModel,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
mgmt_port: Option<u16>,
|
|
||||||
) {
|
|
||||||
let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port);
|
|
||||||
load(&state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
|
||||||
/// entry id) with no host and no network — the CI `library` scene.
|
|
||||||
pub fn open_mock(
|
|
||||||
nav: &adw::NavigationView,
|
|
||||||
identity: (String, String),
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
games: Vec<GameEntry>,
|
|
||||||
art: Vec<(String, gdk::Texture)>,
|
|
||||||
) {
|
|
||||||
let state = build(nav, identity, sender, req, None);
|
|
||||||
state.mock.set(true);
|
|
||||||
state.art.borrow_mut().extend(art);
|
|
||||||
if games.is_empty() {
|
|
||||||
state.stack.set_visible_child_name("empty");
|
|
||||||
} else {
|
|
||||||
render(&state, &games);
|
|
||||||
state.stack.set_visible_child_name("grid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
|
||||||
fn build(
|
|
||||||
nav: &adw::NavigationView,
|
|
||||||
identity: (String, String),
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
mgmt_port: Option<u16>,
|
|
||||||
) -> Rc<State> {
|
|
||||||
let flow = gtk::FlowBox::builder()
|
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
|
||||||
.activate_on_single_click(true)
|
|
||||||
.homogeneous(true)
|
|
||||||
.min_children_per_line(2)
|
|
||||||
.max_children_per_line(6)
|
|
||||||
.column_spacing(12)
|
|
||||||
.row_spacing(18)
|
|
||||||
.valign(gtk::Align::Start)
|
|
||||||
.build();
|
|
||||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
|
||||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
|
||||||
flow.connect_child_activated(|_, child| {
|
|
||||||
child.activate();
|
|
||||||
});
|
|
||||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
||||||
content.set_margin_top(24);
|
|
||||||
content.set_margin_bottom(24);
|
|
||||||
content.set_margin_start(12);
|
|
||||||
content.set_margin_end(12);
|
|
||||||
content.append(&flow);
|
|
||||||
let clamp = adw::Clamp::builder()
|
|
||||||
.maximum_size(1100)
|
|
||||||
.child(&content)
|
|
||||||
.build();
|
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
||||||
.child(&clamp)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let loading = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
|
||||||
loading.set_valign(gtk::Align::Center);
|
|
||||||
let spinner = gtk::Spinner::new();
|
|
||||||
spinner.set_size_request(32, 32);
|
|
||||||
spinner.start();
|
|
||||||
spinner.set_halign(gtk::Align::Center);
|
|
||||||
loading.append(&spinner);
|
|
||||||
let loading_label = gtk::Label::new(Some("Loading library…"));
|
|
||||||
loading_label.add_css_class("dim-label");
|
|
||||||
loading.append(&loading_label);
|
|
||||||
|
|
||||||
let error_page = adw::StatusPage::builder()
|
|
||||||
.icon_name("dialog-error-symbolic")
|
|
||||||
.title("Couldn't load the library")
|
|
||||||
.build();
|
|
||||||
let retry = gtk::Button::with_label("Retry");
|
|
||||||
retry.add_css_class("pill");
|
|
||||||
retry.add_css_class("suggested-action");
|
|
||||||
retry.set_halign(gtk::Align::Center);
|
|
||||||
error_page.set_child(Some(&retry));
|
|
||||||
|
|
||||||
let empty = adw::StatusPage::builder()
|
|
||||||
.icon_name("applications-games-symbolic")
|
|
||||||
.title("No games found")
|
|
||||||
.description(
|
|
||||||
"No games found on this host. Install Steam titles or add custom \
|
|
||||||
entries in the host's web console.",
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let stack = gtk::Stack::new();
|
|
||||||
stack.add_named(&loading, Some("loading"));
|
|
||||||
stack.add_named(&error_page, Some("error"));
|
|
||||||
stack.add_named(&empty, Some("empty"));
|
|
||||||
stack.add_named(&scrolled, Some("grid"));
|
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
|
||||||
let reload = gtk::Button::from_icon_name("view-refresh-symbolic");
|
|
||||||
reload.set_tooltip_text(Some("Reload"));
|
|
||||||
header.pack_end(&reload);
|
|
||||||
|
|
||||||
let toolbar = adw::ToolbarView::new();
|
|
||||||
toolbar.add_top_bar(&header);
|
|
||||||
toolbar.set_content(Some(&stack));
|
|
||||||
|
|
||||||
let page = adw::NavigationPage::builder()
|
|
||||||
.title(format!("{} — Library", req.name))
|
|
||||||
.child(&toolbar)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let state = Rc::new(State {
|
|
||||||
sender: sender.clone(),
|
|
||||||
identity,
|
|
||||||
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
|
||||||
req,
|
|
||||||
stack,
|
|
||||||
flow,
|
|
||||||
error_page,
|
|
||||||
art: RefCell::new(HashMap::new()),
|
|
||||||
pics: RefCell::new(HashMap::new()),
|
|
||||||
mock: Cell::new(false),
|
|
||||||
});
|
|
||||||
{
|
|
||||||
let state = state.clone();
|
|
||||||
reload.connect_clicked(move |_| load(&state));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let state = state.clone();
|
|
||||||
retry.connect_clicked(move |_| load(&state));
|
|
||||||
}
|
|
||||||
nav.push(&page);
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the library off the main thread and route the result into the grid or the
|
|
||||||
/// error/empty states.
|
|
||||||
fn load(state: &Rc<State>) {
|
|
||||||
if state.mock.get() {
|
|
||||||
return; // screenshot scene renders injected entries only
|
|
||||||
}
|
|
||||||
state.stack.set_visible_child_name("loading");
|
|
||||||
let port = state.mgmt_port;
|
|
||||||
let addr = state.req.addr.clone();
|
|
||||||
let identity = state.identity.clone();
|
|
||||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("punktfunk-library".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin));
|
|
||||||
})
|
|
||||||
.expect("spawn library thread");
|
|
||||||
let weak = Rc::downgrade(state);
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let Ok(result) = rx.recv().await else { return };
|
|
||||||
let Some(state) = weak.upgrade() else { return };
|
|
||||||
match result {
|
|
||||||
Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"),
|
|
||||||
Ok(games) => {
|
|
||||||
render(&state, &games);
|
|
||||||
state.stack.set_visible_child_name("grid");
|
|
||||||
load_art(&state, &games);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
state.error_page.set_description(Some(&e.to_string()));
|
|
||||||
state.stack.set_visible_child_name("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (Re)build the poster grid from one library snapshot. Cached textures apply
|
|
||||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
|
||||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
|
||||||
state.flow.remove_all();
|
|
||||||
state.pics.borrow_mut().clear();
|
|
||||||
for game in games {
|
|
||||||
state.flow.append(&game_card(state, game));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
|
||||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
|
||||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
|
||||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
|
||||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
|
||||||
monogram.add_css_class("pf-poster-monogram");
|
|
||||||
monogram.set_halign(gtk::Align::Center);
|
|
||||||
monogram.set_valign(gtk::Align::Center);
|
|
||||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
||||||
placeholder.append(&monogram);
|
|
||||||
monogram.set_vexpand(true);
|
|
||||||
|
|
||||||
let pic = gtk::Picture::new();
|
|
||||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
|
||||||
if let Some(tex) = state.art.borrow().get(&game.id) {
|
|
||||||
pic.set_paintable(Some(tex));
|
|
||||||
}
|
|
||||||
state.pics.borrow_mut().insert(game.id.clone(), pic.clone());
|
|
||||||
|
|
||||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
|
||||||
badge.add_css_class("pf-pill");
|
|
||||||
badge.add_css_class("pf-store-badge");
|
|
||||||
badge.set_halign(gtk::Align::Start);
|
|
||||||
badge.set_valign(gtk::Align::Start);
|
|
||||||
badge.set_margin_start(6);
|
|
||||||
badge.set_margin_top(6);
|
|
||||||
|
|
||||||
let poster = gtk::Overlay::new();
|
|
||||||
poster.set_child(Some(&placeholder));
|
|
||||||
poster.add_overlay(&pic);
|
|
||||||
poster.add_overlay(&badge);
|
|
||||||
poster.add_css_class("pf-poster");
|
|
||||||
poster.set_overflow(gtk::Overflow::Hidden);
|
|
||||||
poster.set_size_request(150, 225);
|
|
||||||
poster.set_halign(gtk::Align::Center);
|
|
||||||
|
|
||||||
let title = gtk::Label::new(Some(&game.title));
|
|
||||||
title.add_css_class("caption");
|
|
||||||
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
|
||||||
title.set_max_width_chars(16);
|
|
||||||
title.set_tooltip_text(Some(&game.title));
|
|
||||||
|
|
||||||
let card = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
||||||
card.append(&poster);
|
|
||||||
card.append(&title);
|
|
||||||
|
|
||||||
let child = gtk::FlowBoxChild::new();
|
|
||||||
child.set_child(Some(&card));
|
|
||||||
let sender = state.sender.clone();
|
|
||||||
let mut req = state.req.clone();
|
|
||||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
|
||||||
child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone())));
|
|
||||||
child
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch poster art for every uncached entry on a small worker pool, walking each
|
|
||||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
|
||||||
/// texturing the first that loads on the main loop.
|
|
||||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
|
||||||
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
|
||||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
|
||||||
let cache = state.art.borrow();
|
|
||||||
games
|
|
||||||
.iter()
|
|
||||||
.filter(|g| !cache.contains_key(&g.id))
|
|
||||||
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
|
||||||
.filter(|(_, candidates)| !candidates.is_empty())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
if jobs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let identity = state.identity.clone();
|
|
||||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
|
||||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
|
||||||
let weak = Rc::downgrade(state);
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
while let Ok((id, bytes)) = rx.recv().await {
|
|
||||||
let Some(state) = weak.upgrade() else { break };
|
|
||||||
// Texture decode happens here on the main loop — posters are small (tens of
|
|
||||||
// KB), and `from_bytes` handles jpeg/png alike.
|
|
||||||
match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) {
|
|
||||||
Ok(tex) => {
|
|
||||||
if let Some(pic) = state.pics.borrow().get(&id) {
|
|
||||||
pic.set_paintable(Some(&tex));
|
|
||||||
}
|
|
||||||
state.art.borrow_mut().insert(id, tex);
|
|
||||||
}
|
|
||||||
Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
|
||||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
|
||||||
/// Shared with the gamepad launcher's posters.
|
|
||||||
pub fn store_label(store: &str) -> &'static str {
|
|
||||||
match store {
|
|
||||||
"steam" => "Steam",
|
|
||||||
"custom" => "Custom",
|
|
||||||
"heroic" => "Heroic",
|
|
||||||
"lutris" => "Lutris",
|
|
||||||
"epic" => "Epic",
|
|
||||||
"gog" => "GOG",
|
|
||||||
"xbox" => "Xbox",
|
|
||||||
_ => "Game",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
|
||||||
/// Shared with the gamepad launcher's posters.
|
|
||||||
pub fn initials(title: &str) -> String {
|
|
||||||
title
|
|
||||||
.split_whitespace()
|
|
||||||
.take(2)
|
|
||||||
.filter_map(|w| w.chars().next())
|
|
||||||
.flat_map(char::to_uppercase)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initials_take_two_words() {
|
|
||||||
assert_eq!(initials("Dota 2"), "D2");
|
|
||||||
assert_eq!(initials("half-life"), "H");
|
|
||||||
assert_eq!(initials("The Witness III"), "TW");
|
|
||||||
assert_eq!(initials(""), "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
|
||||||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
|
||||||
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
|
||||||
//! each resolving into typed [`AppMsg`]s.
|
|
||||||
|
|
||||||
use crate::app::{AppModel, AppMsg};
|
|
||||||
use crate::spawn::{CancelHandle, SpawnOpts};
|
|
||||||
use crate::trust;
|
|
||||||
use crate::ui_hosts::ConnectRequest;
|
|
||||||
use adw::prelude::*;
|
|
||||||
use gtk::glib;
|
|
||||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
|
||||||
use relm4::prelude::*;
|
|
||||||
|
|
||||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
|
||||||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
|
||||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
|
||||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
|
||||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
|
||||||
/// lets the user cancel.
|
|
||||||
///
|
|
||||||
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
|
|
||||||
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
|
|
||||||
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
|
|
||||||
pub fn wake_and_connect(
|
|
||||||
window: &adw::ApplicationWindow,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
) {
|
|
||||||
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
|
||||||
let waiting = adw::AlertDialog::new(
|
|
||||||
Some("Waking Host"),
|
|
||||||
Some(&format!(
|
|
||||||
"Sent a wake signal to “{}”. Waiting for it to come online…",
|
|
||||||
req.name
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
|
||||||
waiting.set_close_response("cancel");
|
|
||||||
{
|
|
||||||
let cancel = cancel.clone();
|
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
|
||||||
}
|
|
||||||
waiting.present(Some(window));
|
|
||||||
|
|
||||||
let sender = sender.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
use std::time::Duration;
|
|
||||||
let events = crate::discovery::browse();
|
|
||||||
let mut wait = WakeWait::new();
|
|
||||||
loop {
|
|
||||||
if cancel.get() {
|
|
||||||
waiting.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
|
|
||||||
// and carries the address it came back on.
|
|
||||||
let mut seen: Option<(String, u16)> = None;
|
|
||||||
while let Ok(ev) = events.try_recv() {
|
|
||||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let matched = match &req.fp_hex {
|
|
||||||
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
|
|
||||||
None => h.addr == req.addr && h.port == req.port,
|
|
||||||
};
|
|
||||||
if matched {
|
|
||||||
seen = Some((h.addr, h.port));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let tick = wait.tick(seen.is_some());
|
|
||||||
if tick.send_packet {
|
|
||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
|
||||||
}
|
|
||||||
match tick.outcome {
|
|
||||||
Some(WakeOutcome::Online) => {
|
|
||||||
waiting.close();
|
|
||||||
let mut req = req.clone();
|
|
||||||
// Re-key on a new DHCP lease so this + future connects dial the
|
|
||||||
// live address.
|
|
||||||
if let Some((addr, port)) =
|
|
||||||
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
|
|
||||||
{
|
|
||||||
if let Some(fp) = &req.fp_hex {
|
|
||||||
trust::rekey_addr(fp, &addr, port);
|
|
||||||
}
|
|
||||||
req.addr = addr;
|
|
||||||
req.port = port;
|
|
||||||
}
|
|
||||||
sender.input(AppMsg::Connect(req));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Some(WakeOutcome::TimedOut) => {
|
|
||||||
waiting.close();
|
|
||||||
sender.input(AppMsg::Toast(format!(
|
|
||||||
"Couldn't reach “{}” — is it powered and on the network?",
|
|
||||||
req.name
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
glib::timeout_future(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
|
|
||||||
/// far easier to compare against the host's log than one 64-char run.
|
|
||||||
fn grouped_fingerprint(fp: &str) -> String {
|
|
||||||
let groups: Vec<&str> = fp
|
|
||||||
.as_bytes()
|
|
||||||
.chunks(4)
|
|
||||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
|
||||||
.collect();
|
|
||||||
groups
|
|
||||||
.chunks(8)
|
|
||||||
.map(|line| line.join(" "))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First contact with a discovered host that opted into TOFU: show the advertised
|
|
||||||
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
|
||||||
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
|
||||||
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
|
||||||
pub fn tofu_dialog(
|
|
||||||
window: &adw::ApplicationWindow,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
) {
|
|
||||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
|
||||||
let dialog = adw::AlertDialog::new(
|
|
||||||
Some("New Host"),
|
|
||||||
Some(&format!(
|
|
||||||
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
|
|
||||||
trusting accepts it as-is.",
|
|
||||||
req.name, req.addr, req.port
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
|
|
||||||
fp_label.add_css_class("monospace");
|
|
||||||
fp_label.set_selectable(true);
|
|
||||||
fp_label.set_justify(gtk::Justification::Center);
|
|
||||||
fp_label.set_halign(gtk::Align::Center);
|
|
||||||
dialog.set_extra_child(Some(&fp_label));
|
|
||||||
dialog.add_responses(&[
|
|
||||||
("cancel", "Cancel"),
|
|
||||||
("pair", "Pair with PIN…"),
|
|
||||||
("trust", "Trust & Connect"),
|
|
||||||
]);
|
|
||||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
|
||||||
dialog.set_default_response(Some("trust"));
|
|
||||||
dialog.set_close_response("cancel");
|
|
||||||
let sender = sender.clone();
|
|
||||||
dialog.connect_response(None, move |_, response| match response {
|
|
||||||
"trust" => sender.input(AppMsg::StartSession {
|
|
||||||
req: req.clone(),
|
|
||||||
fp_hex: fp.clone(),
|
|
||||||
tofu: true,
|
|
||||||
opts: SpawnOpts::default(),
|
|
||||||
}),
|
|
||||||
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
dialog.present(Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
|
||||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
|
||||||
/// transcript. Success persists the host as paired and connects.
|
|
||||||
pub fn pin_dialog(
|
|
||||||
window: &adw::ApplicationWindow,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
identity: (String, String),
|
|
||||||
req: ConnectRequest,
|
|
||||||
) {
|
|
||||||
let entry = gtk::Entry::builder()
|
|
||||||
.input_purpose(gtk::InputPurpose::Digits)
|
|
||||||
.placeholder_text("4-digit PIN shown by the host")
|
|
||||||
.activates_default(true)
|
|
||||||
.build();
|
|
||||||
// The label the HOST stores this client under — prefilled with the hostname.
|
|
||||||
let name_entry = gtk::Entry::builder()
|
|
||||||
.text(glib::host_name().as_str())
|
|
||||||
.activates_default(true)
|
|
||||||
.build();
|
|
||||||
let name_caption = gtk::Label::new(Some("This device"));
|
|
||||||
name_caption.add_css_class("caption");
|
|
||||||
name_caption.add_css_class("dim-label");
|
|
||||||
name_caption.set_halign(gtk::Align::Start);
|
|
||||||
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
||||||
fields.append(&name_caption);
|
|
||||||
fields.append(&name_entry);
|
|
||||||
let pin_caption = gtk::Label::new(Some("PIN"));
|
|
||||||
pin_caption.add_css_class("caption");
|
|
||||||
pin_caption.add_css_class("dim-label");
|
|
||||||
pin_caption.set_halign(gtk::Align::Start);
|
|
||||||
fields.append(&pin_caption);
|
|
||||||
fields.append(&entry);
|
|
||||||
let dialog = adw::AlertDialog::new(
|
|
||||||
Some("Pair with PIN"),
|
|
||||||
Some(&format!(
|
|
||||||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
|
||||||
req.name
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
dialog.set_extra_child(Some(&fields));
|
|
||||||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
|
||||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
|
||||||
dialog.set_default_response(Some("pair"));
|
|
||||||
dialog.set_close_response("cancel");
|
|
||||||
let sender = sender.clone();
|
|
||||||
dialog.connect_response(Some("pair"), move |_, _| {
|
|
||||||
let pin = entry.text().to_string();
|
|
||||||
let req = req.clone();
|
|
||||||
let identity = identity.clone();
|
|
||||||
let sender = sender.clone();
|
|
||||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
|
||||||
let device = name_entry.text().trim().to_string();
|
|
||||||
let name = if device.is_empty() {
|
|
||||||
glib::host_name().to_string()
|
|
||||||
} else {
|
|
||||||
device
|
|
||||||
};
|
|
||||||
let (host, port) = (req.addr.clone(), req.port);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
|
||||||
// rejection) — never blame the PIN for a dead network path.
|
|
||||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
|
||||||
.map_err(|e| trust::pair_error_message(&e));
|
|
||||||
let _ = tx.send_blocking(result);
|
|
||||||
});
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(Ok(fp)) => {
|
|
||||||
let fp_hex = trust::hex(&fp);
|
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
|
||||||
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
|
||||||
sender.input(AppMsg::StartSession {
|
|
||||||
req,
|
|
||||||
fp_hex,
|
|
||||||
tofu: false,
|
|
||||||
opts: SpawnOpts::default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
dialog.present(Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
|
||||||
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
|
||||||
/// ceremony.
|
|
||||||
pub fn approval_dialog(
|
|
||||||
window: &adw::ApplicationWindow,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
) {
|
|
||||||
let dialog = adw::AlertDialog::new(
|
|
||||||
Some("Pairing Required"),
|
|
||||||
Some(&format!(
|
|
||||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
|
||||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
|
||||||
req.name
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
dialog.add_responses(&[
|
|
||||||
("cancel", "Cancel"),
|
|
||||||
("pin", "Use a PIN instead…"),
|
|
||||||
("request", "Request Access"),
|
|
||||||
]);
|
|
||||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
|
||||||
dialog.set_default_response(Some("request"));
|
|
||||||
dialog.set_close_response("cancel");
|
|
||||||
let parent = window.clone();
|
|
||||||
let window = window.clone();
|
|
||||||
let sender = sender.clone();
|
|
||||||
dialog.connect_response(None, move |_, response| match response {
|
|
||||||
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
|
||||||
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
dialog.present(Some(&parent));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
|
||||||
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
|
||||||
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
|
||||||
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
|
||||||
///
|
|
||||||
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
|
||||||
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
|
||||||
/// the session binary refuses pinless connects, so this path requires the advert; a
|
|
||||||
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
|
||||||
fn request_access(
|
|
||||||
window: &adw::ApplicationWindow,
|
|
||||||
sender: &ComponentSender<AppModel>,
|
|
||||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
|
||||||
req: ConnectRequest,
|
|
||||||
) {
|
|
||||||
let Some(fp_hex) = req.fp_hex.clone() else {
|
|
||||||
// No fingerprint to pin (manual entry): the strict child can't do a
|
|
||||||
// trust-on-approval connect — route to the PIN ceremony instead.
|
|
||||||
sender.input(AppMsg::Toast(
|
|
||||||
"No advertised identity for this host — pair with a PIN instead.".into(),
|
|
||||||
));
|
|
||||||
sender.input(AppMsg::Pair(req));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let cancel = CancelHandle::default();
|
|
||||||
let waiting = adw::AlertDialog::new(
|
|
||||||
Some("Waiting for Approval"),
|
|
||||||
Some(&format!(
|
|
||||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
|
||||||
connects automatically once you approve it.",
|
|
||||||
glib::host_name(),
|
|
||||||
req.name
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
|
||||||
waiting.set_close_response("cancel");
|
|
||||||
{
|
|
||||||
let sender = sender.clone();
|
|
||||||
let cancel = cancel.clone();
|
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
|
||||||
cancel.kill();
|
|
||||||
sender.input(AppMsg::CancelPending);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
waiting.present(Some(window));
|
|
||||||
*waiting_slot.borrow_mut() = Some(waiting);
|
|
||||||
|
|
||||||
sender.input(AppMsg::StartSession {
|
|
||||||
req,
|
|
||||||
fp_hex,
|
|
||||||
tofu: false,
|
|
||||||
opts: SpawnOpts {
|
|
||||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
|
||||||
// operator approval still lands on this connection.
|
|
||||||
connect_timeout_secs: Some(185),
|
|
||||||
persist_paired: true,
|
|
||||||
cancel: Some(cancel),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Runs the REAL `punktfunk-session` binary and asserts it speaks the stdout contract.
|
||||||
|
//!
|
||||||
|
//! 0.22.0 shipped a stub as `punktfunk-session` — a stray copy of the GTK shell replaced
|
||||||
|
//! this crate's `main.rs`, and every build gate stayed green because the wrong program
|
||||||
|
//! compiled perfectly. Nothing in CI ever *ran* the binary; the failure only existed at
|
||||||
|
//! runtime, as a connect that silently bounced back to the host list. This test is the
|
||||||
|
//! gate that would have caught it: whatever the binary does, it must SAY so on stdout.
|
||||||
|
#![cfg(any(target_os = "linux", windows))]
|
||||||
|
|
||||||
|
use std::io::Read as _;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A failing connect must still produce a contract line. Port 1 on loopback refuses
|
||||||
|
/// instantly and the all-zero pin keeps trust logic out of the way; whatever fails first
|
||||||
|
/// on this machine — presenter init on a headless runner, the dial on a box with a
|
||||||
|
/// display — the contract requires one `{"error"…}` JSON line on stdout saying so. (On a
|
||||||
|
/// desktop this may flash a window for under a second; the connect refuses immediately.)
|
||||||
|
#[test]
|
||||||
|
fn a_failing_connect_still_speaks_the_contract() {
|
||||||
|
let mut child = Command::new(env!("CARGO_BIN_EXE_punktfunk-session"))
|
||||||
|
.args([
|
||||||
|
"--connect",
|
||||||
|
"127.0.0.1:1",
|
||||||
|
"--fp",
|
||||||
|
&"0".repeat(64),
|
||||||
|
"--connect-timeout",
|
||||||
|
"5",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn punktfunk-session");
|
||||||
|
|
||||||
|
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let _ = stdout.read_to_string(&mut buf);
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generous bound: presenter init on a cold CI runner can be slow, but a healthy
|
||||||
|
// binary answers in seconds. Only a hang (or a stub that inherited our stdout and
|
||||||
|
// blocked) gets anywhere near it.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(120);
|
||||||
|
loop {
|
||||||
|
match child.try_wait().expect("wait on punktfunk-session") {
|
||||||
|
Some(_) => break,
|
||||||
|
None if Instant::now() > deadline => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
panic!("punktfunk-session neither exited nor spoke within 120 s");
|
||||||
|
}
|
||||||
|
None => std::thread::sleep(Duration::from_millis(200)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = reader.join().expect("stdout reader");
|
||||||
|
let spoke = out.lines().map(str::trim).any(|l| {
|
||||||
|
l.starts_with('{')
|
||||||
|
&& (l.contains("\"error\"") || l.contains("\"ready\"") || l.contains("\"ended\""))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
spoke,
|
||||||
|
"no stdout-contract line — the wrong program may be wearing this binary's name. \
|
||||||
|
stdout was:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -287,7 +287,7 @@ fn connect_spawn(
|
|||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||||
SpawnEvent::Exited { error, ended } => {
|
SpawnEvent::Exited { error, ended, code } => {
|
||||||
match error {
|
match error {
|
||||||
Some((msg, true)) => {
|
Some((msg, true)) => {
|
||||||
// Pinned-fingerprint mismatch / pairing required → re-pair via
|
// Pinned-fingerprint mismatch / pairing required → re-pair via
|
||||||
@@ -311,8 +311,14 @@ fn connect_spawn(
|
|||||||
}
|
}
|
||||||
// `ended` = the host ended the session (banner); a clean exit
|
// `ended` = the host ended the session (banner); a clean exit
|
||||||
// (user closed the stream window / Disconnect) returns silently.
|
// (user closed the stream window / Disconnect) returns silently.
|
||||||
|
// A child that said nothing AND failed gets the exit code, so the
|
||||||
|
// return to the host list is never unexplained.
|
||||||
None => {
|
None => {
|
||||||
st.call(ended.unwrap_or_default());
|
st.call(
|
||||||
|
ended
|
||||||
|
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,11 +373,18 @@ pub(crate) fn open_console(
|
|||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||||
SpawnEvent::Exited { error, ended } => {
|
SpawnEvent::Exited { error, ended, code } => {
|
||||||
crate::shell_window::restore();
|
crate::shell_window::restore();
|
||||||
// Quit from the library (B / closing the window) returns silently;
|
// Quit from the library (B / closing the window) returns silently;
|
||||||
// a failed start surfaces its error line.
|
// a failed start surfaces its error line, or the exit code when it
|
||||||
st.call(error.map(|(msg, _)| msg).or(ended).unwrap_or_default());
|
// died without producing one.
|
||||||
|
st.call(
|
||||||
|
error
|
||||||
|
.map(|(msg, _)| msg)
|
||||||
|
.or(ended)
|
||||||
|
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ pub(crate) enum SpawnEvent {
|
|||||||
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
||||||
Stats(String),
|
Stats(String),
|
||||||
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
||||||
/// carry the contract lines seen on the way out, when any (the exit code is logged
|
/// carry the contract lines seen on the way out, when any — routing keys off those,
|
||||||
/// by the reader; routing keys off the lines, which say strictly more).
|
/// which say strictly more than a number. `code` is the process exit status (-1 = no
|
||||||
|
/// code, i.e. killed) and exists for the case where there were NO lines at all: a
|
||||||
|
/// child that dies before it can speak the contract would otherwise be indistinguishable
|
||||||
|
/// from a clean user-initiated quit, and the shell would bounce to the host list with a
|
||||||
|
/// blank banner. That is exactly how the 0.22.0 session-binary regression presented.
|
||||||
Exited {
|
Exited {
|
||||||
error: Option<(String, bool)>,
|
error: Option<(String, bool)>,
|
||||||
ended: Option<String>,
|
ended: Option<String>,
|
||||||
|
code: i32,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +80,19 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The banner for a child that exited having said NOTHING on stdout — no `ready`, no
|
||||||
|
/// `error`, no `ended`. `None` keeps the silent return the UI has always given a clean
|
||||||
|
/// quit: code 0 is the user closing the stream window, and -1 is our own Disconnect/Cancel
|
||||||
|
/// kill (no exit code). Anything else is the session dying before it could speak its
|
||||||
|
/// contract — a missing runtime DLL, a crash, or the wrong binary sitting next to the
|
||||||
|
/// shell — and reporting the code is the difference between a diagnosable failure and a
|
||||||
|
/// connect that silently drops back to the host list.
|
||||||
|
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||||
|
(code != 0 && code != -1).then(|| {
|
||||||
|
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The session binary: installed next to the shell (the MSIX layout and dev
|
/// The session binary: installed next to the shell (the MSIX layout and dev
|
||||||
/// `target\…` runs both land on the sibling), else `PATH`.
|
/// `target\…` runs both land on the sibling), else `PATH`.
|
||||||
pub(crate) fn session_binary() -> std::path::PathBuf {
|
pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||||
@@ -220,7 +238,7 @@ fn spawn_with(
|
|||||||
.and_then(|s| s.code())
|
.and_then(|s| s.code())
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
tracing::info!(code, "session binary exited");
|
tracing::info!(code, "session binary exited");
|
||||||
on_event(SpawnEvent::Exited { error, ended });
|
on_event(SpawnEvent::Exited { error, ended, code });
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -262,4 +280,17 @@ mod tests {
|
|||||||
assert!(parse_line("").is_none());
|
assert!(parse_line("").is_none());
|
||||||
assert!(parse_line("{\"other\":1}").is_none());
|
assert!(parse_line("{\"other\":1}").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_silent_failing_exit_is_never_blank() {
|
||||||
|
// Clean quit (stream window closed) and our own kill stay silent.
|
||||||
|
assert!(silent_exit_banner(0).is_none());
|
||||||
|
assert!(silent_exit_banner(-1).is_none());
|
||||||
|
// A child that died without speaking the contract names its code — the 0.22.0
|
||||||
|
// regression (a stub session binary exiting 2) showed as a blank bounce to the
|
||||||
|
// host list precisely because nothing filled this in.
|
||||||
|
let banner = silent_exit_banner(2).expect("failing exit must say something");
|
||||||
|
assert!(banner.contains('2'), "{banner}");
|
||||||
|
assert!(silent_exit_banner(101).is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Wire-compatible with 0.21.x and 0.22.0 — hosts are untouched by this release, and everything already paired keeps working.
|
||||||
|
|
||||||
|
**If you installed 0.22.0 on Windows or Linux, update now:** this fixes connecting, which 0.22.0 broke on exactly those clients. Coming from 0.21.0, you get everything 0.22.0 added — settings profiles, `punktfunk://` links, the `punktfunk` command, Gaming-Mode HDR — plus these fixes.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- **Connecting works again on Windows and Linux.** 0.22.0 accidentally shipped the wrong program as the piece that renders your stream. On Windows, every connect immediately bounced you back to the host list with no message; on Linux, the stream window simply never appeared. Mac, iPhone, iPad, Apple TV and Android were not affected. Nothing about how streams work has changed — 0.22.1 delivers what 0.22.0's notes promised.
|
||||||
|
- **A stream that can't start now tells you why.** If the stream window exits without reporting anything, the Windows client now shows its exit code instead of silently returning to your hosts — a broken install is a message, not a mystery.
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
|
||||||
|
- **Every `punktfunk` command now explains itself.** `punktfunk help launch` — or `--help` after any command — prints that command's flags, what lands where, and what each exit code means, so a script author never has to read the source. `punktfunk help` still lists everything.
|
||||||
|
- **`punktfunk reachable` stopped scolding.** Probing an address you haven't saved is exactly what the command is for; it no longer suggests pairing first before answering.
|
||||||
|
|
||||||
|
## Under the hood (for developers)
|
||||||
|
|
||||||
|
- The 0.22.0 breakage: a stray copy of the GTK shell's sources landed in the session crate and overwrote its `main.rs` — the `[[bin]]` entry point — so `punktfunk-session` was built from the wrong file. On Windows that file's non-Linux arm is a three-line stub (exit 2, no stdout contract); on Linux it was the shell itself, whose `--connect` handling execs `punktfunk-session` — that is, itself. Every compile gate stayed green, because the wrong program compiled perfectly.
|
||||||
|
- CI now *runs* what it ships: an integration test spawns the built `punktfunk-session` against a refusing port and fails unless a stdout-contract line answers (proven against the 0.22.0 stub, which fails it); another runs `punktfunk` over its help surface. `punktfunk-cli` also joined the Windows build/clippy/fmt/test gates — it was packaged in the MSIX but only the release workflow ever compiled it.
|
||||||
|
- The Windows shell's `SpawnEvent::Exited` now carries the child's exit code; a nonzero exit with no contract line gets a banner naming it. Codes 0 (window closed) and −1 (our own kill) stay silent as before.
|
||||||
|
- No wire, ABI or driver changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0.
|
||||||
@@ -63,3 +63,35 @@ if (-not (Test-Path $sccacheExe)) {
|
|||||||
}
|
}
|
||||||
& $sccacheExe --version
|
& $sccacheExe --version
|
||||||
if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $sccacheDir }
|
if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $sccacheDir }
|
||||||
|
|
||||||
|
# --- zstd: what actions/cache compresses with. Without it the toolkit falls back to gzip,
|
||||||
|
# and Git's GNU tar then shells out to a `gzip` that is NOT on the runner's PATH — every
|
||||||
|
# cache SAVE died with "Child returned status 127" / "Cannot write: Broken pipe" / exit 2,
|
||||||
|
# reported only as a ::warning:: so runs stayed green while nothing was ever cached
|
||||||
|
# (measured 2026-07-30 on windows-host). Own directory on purpose: putting Git's usr\bin on
|
||||||
|
# PATH would shadow Windows' find/sort/echo and break unrelated steps.
|
||||||
|
$zstdDir = 'C:\Users\Public\zstd'
|
||||||
|
$zstdExe = Join-Path $zstdDir 'zstd.exe'
|
||||||
|
if (-not (Test-Path $zstdExe)) {
|
||||||
|
try {
|
||||||
|
$v = '1.5.6'
|
||||||
|
$zip = Join-Path $env:TEMP "zstd-$v.zip"
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/facebook/zstd/releases/download/v$v/zstd-v$v-win64.zip" -OutFile $zip
|
||||||
|
$tmp = Join-Path $env:TEMP 'zstd-extract'
|
||||||
|
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||||
|
Expand-Archive -Path $zip -DestinationPath $tmp -Force
|
||||||
|
New-Item -ItemType Directory -Force -Path $zstdDir | Out-Null
|
||||||
|
Get-ChildItem -Path $tmp -Recurse -Filter 'zstd.exe' | Select-Object -First 1 |
|
||||||
|
ForEach-Object { Copy-Item $_.FullName $zstdExe -Force }
|
||||||
|
$gitGzip = 'C:\Program Files\Git\usr\bin\gzip.exe'
|
||||||
|
if ((Test-Path $gitGzip) -and -not (Test-Path (Join-Path $zstdDir 'gzip.exe'))) {
|
||||||
|
Copy-Item $gitGzip (Join-Path $zstdDir 'gzip.exe') -Force
|
||||||
|
}
|
||||||
|
Remove-Item $zip, $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
catch { Write-Warning "zstd install failed (cache saves will fall back to gzip): $_" }
|
||||||
|
}
|
||||||
|
if (Test-Path $zstdExe) {
|
||||||
|
& $zstdExe --version
|
||||||
|
if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $zstdDir }
|
||||||
|
}
|
||||||
|
|||||||