Commit Graph
2544 Commits
Author SHA1 Message Date
enricobuehler 5922cbe325 fix(host/audio): the minted mic pair declares stereo on BOTH sides
Second measurement round: the driver render pin is STEREO-ONLY — the
mono render stamp turned the endpoint unopenable (0x88890008 on every
open, the incoherent-stamp signature the pad program documented). Since
the crossing is raw, the coherent choice inverts: the CAPTURE side now
declares the stereo float stream that actually crosses (fixing the
octave-low voice), and the render has its stereo float default stamped
explicitly — pinning the pair AND healing any endpoint a previous build
left mono-stamped.
2026-08-07 16:01:39 +02:00
enricobuehler ed98814145 fix(host/audio): the minted microphone renders MONO — voice was an octave low
Measured with the new pitch probe: 440 Hz into the minted mic render came
back as 220 Hz off its capture side. The driver forwards the render
stream RAW into its mono capture, so a stereo-declared render (the
driver-default we inherited) turns every stereo frame into two mono
samples — half speed, octave down, exactly the field report. The mic
render now gets a coherent MONO 48 kHz format set stamped alongside its
name (PCM16 device format + float mix/host formats), making the engine
downmix before the driver crossing. The mic pump keeps pushing stereo;
shared-mode autoconvert handles the rest.
2026-08-07 15:57:19 +02:00
enricobuehler 0cf76af0c2 feat(host/devtest): audio-probe measures pitch, not just peaks
Field report through the minted microphone: voice plays back an octave
low. Peaks are pitch-blind — S3 passed while a potential half-rate link
hid in the numbers (288k samples fits both the honest and the half-speed
story). Every probe measurement now estimates the dominant frequency by
zero crossings over the signal span, and `audio-probe micpitch` runs the
decisive experiment against the LIVE minted pair: 440 Hz in, frequency
out — ~440 = pair innocent, ~220 = the stereo render stream is forwarded
raw into the mono capture.
2026-08-07 15:53:00 +02:00
enricobuehler 7993386900 feat(host/audio): the minted endpoints get their names — 'Punktfunk Speakers/Microphone'
Field-measured necessity, not cosmetics: unstamped, the minted instances
read 'Lautsprecher (2- Steam Streaming Microphone)' and even the box's
owner picked the wrong device out of the Sound settings zoo (as did the
S1 probe's name match before it). The provider now stamps device-desc +
device-name through the pad program's proven machinery — write_stamps/
stamps_served, extracted from the pad-only stamp functions — with the
same store-first/registry-fallback routes and settle/re-pass discipline.
Names only: a wider stamp set makes AudioEndpointBuilder re-mint the
endpoint under a new GUID (measured on pads). Stamping is best-effort
(SYSTEM ACL route); the wiring never depends on names — identity stays
the recorded id.
2026-08-07 15:33:52 +02:00
enricobuehler 507ea58da8 fix(host/audio): the silent-sink check recognizes the minted Speakers
Observed in the first real session on the substrate: the loopback ran on
the minted 'Punktfunk Speakers' (silent on the host by construction), but
have_silent name-matches only the Streaming Microphone — so the capture
open logged 'desktop audio will also play on the host' (false) and
re-attempted the Steam-pair install it doesn't need. The minted sink is
recognized by id; its name honestly says Speakers, which the name rule
must keep refusing for FOREIGN instances.
2026-08-07 12:59:15 +02:00
enricobuehler 99a59fb5c7 fix(host/audio): the mic pump's first resolve waits for the minted endpoints
Measured on the target box: the pump wired 2 s before the provisioning
worker latched, took the cable as its write target, and the next wiring
pass would then have paired the default recording with the minted
microphone — which nothing writes into: dead mic-air until a pump reopen.
resolve_target now provisions synchronously (instant once latched; the
opt-out env is honoured), so the pump's held device and the plan's verdict
can never disagree.
2026-08-07 12:02:48 +02:00
enricobuehler 15dddf7ec0 feat(web): the Dashboard shows the audio wiring verdict
An Audio wiring card (Windows hosts) below the status tiles: a readiness
badge (Ready / No microphone / No game audio / Not wired), the friendly
names carrying each role, and the degradation notes that were previously
visible only in the host log — mic withheld for game audio, the known-
degraded last resort, a narrowing endpoint. api/openapi.json regenerated
from the host build (AudioWiring + RuntimeStatus.audio); en+de messages.
2026-08-07 12:00:13 +02:00
enricobuehler 3870cdd1da feat(host/mgmt): the audio wiring verdict joins /status
RuntimeStatus gains an 'audio' object (Windows hosts): readiness
(full/audio_only/mic_only/none), the friendly names carrying each role,
and the three degradation flags (mic_withheld, last_resort, narrowing) —
the verdicts that previously lived only in tracing logs. Snapshot of the
last wiring pass (the mic pump wires at host start and on every reopen);
a status poll never triggers COM work or IPolicyConfig writes.
2026-08-07 11:53:15 +02:00
enricobuehler 79c72fa64d fix(host/audio): capture endpoints carry the {0.0.1.…} id prefix
The capture-direction lookup built its endpoint id with the RENDER prefix
{0.0.0.00000000}., but WASAPI's enumeration returns capture ids as
{0.0.1.00000000}.{guid} — so the minted microphone's capture side never
string-matched the enumeration and the wiring plan paired no recording
device (audio-probe plan on the target box: mic_capture = '-'). Measured;
IMMDeviceEnumerator::GetDevice tolerated the wrong prefix, which is why
the S3 spike's direct open still passed.
2026-08-07 11:37:39 +02:00
enricobuehler 64655c5275 fix(host/devtest): audio-probe plan provisions synchronously
A fresh CLI process has no startup worker to have finished, so the plan
devtest raced its own background provisioning thread and printed the name
ladder instead of tier-0. ensure_blocking() re-resolves existing marker
devnodes in milliseconds before the wiring pass runs.
2026-08-07 11:32:05 +02:00
enricobuehler 4a621de6b1 chore(packaging): retire VB-Cable — audio's substrate is Steam's drivers
The other half of the audio-substrate decision (spikes S2+S3 green, minted
endpoints landed in the previous commit): stop bundling a third-party
kernel driver the host no longer needs.

  installer      the VB-CABLE task, payload, silent-install run and the
                 donationware notice are gone; a suppressible notice tells
                 a Steam-less box that audio needs Steam INSTALLED (never
                 running) and that installing it later just works. A cable
                 from an older install is still deliberately not removed.
  packer + CI    -VbCableDir/VBCABLE_DIR, the staged-payload check and the
                 runner provisioning download are gone; SBOM drops the
                 redistributed-driver component.
  winget         the VB-Audio bundling-grant agreement becomes the honest
                 Steam requirement (surfaced on the unattended path where
                 no wizard is on screen).
  docs           windows-host/uninstall/security/echo say what actually
                 ships: no kernel-mode driver of our own, endpoints minted
                 from Valve's vendor-signed drivers, VB-CABLE mentioned
                 only as the historical fallback that keeps working.
  host wording   the mic-open guidance and module headers lead with Steam;
                 the NAME ladder itself is untouched — demoting 'cable
                 input' was considered and rejected (on a box where minting
                 transiently fails, the SSM would outrank an installed
                 cable, steal the silent sink, and make audio host-audible).
2026-08-07 11:27:29 +02:00
enricobuehler 0b160a4e22 feat(host/audio): minted Punktfunk endpoints become the wiring plan's tier-0
The audio-substrate program's Phase 2 (spikes S2+S3 measured green on the
target box): the host mints its OWN instances of Valve's streaming-audio
drivers and wires by IDENTITY instead of borrowing Steam's primaries —

  minted.rs      the provider: one devnode per role ('Punktfunk Speakers'
                 from SteamStreamingSpeakers.inf, 'Punktfunk Microphone'
                 from SteamStreamingMicrophone.inf), marker-matched across
                 restarts (PunktfunkAudioRole in Device Parameters — names
                 are NOT identity, a minted instance is name-identical to
                 the primaries), provisioned on a startup worker like pad
                 audio, retried with a 60 s cool-down from wiring passes,
                 defaults restored when a fresh endpoint grabs them.
  wiring_plan    MintedIds tier-0: the mic takes its minted device outright
                 (capture side paired by the provider's id — a name search
                 cannot tell it from the primary), the loopback prefers the
                 minted sink at the head of the silent tier, an operator
                 override still beats everything, a narrowing minted sink
                 demotes below real hardware, and stale ids fall back to
                 the ladder unchanged. Plus AudioReadiness — the
                 full/audio-only/mic-only/nothing classification, logged
                 with every plan change (§C4's seed).
  audio-probe    'mint' runs the provider synchronously; 'plan' prints one
                 real wiring pass + readiness — the field-triage command.

Without Steam's drivers nothing changes: provisioning degrades to absent
ids and the plan keeps the name-based ladder (primaries → cable → real
hardware) exactly as before.
2026-08-07 11:22:06 +02:00
enricobuehler 03ee3e55d7 feat(host/devtest): audio-probe — the audio-substrate spike measurements
The S1-S3 spikes from windows-audio-endpoints-and-vbcable.md as one
runnable devtest (no game, no client, ssh-drivable):

  audio-probe ssm          S3, the decision gate: mint a SECOND devnode of
                           Valve's Steam Streaming Microphone driver and
                           prove the pair end to end (tone into its render
                           endpoint must come back out of its capture
                           endpoint). Pass = a punktfunk-owned virtual mic
                           needs no VB-Cable wherever Steam is installed.
  audio-probe sink         S2: mint a Speakers instance, park the DEFAULT
                           playback on it, tone through the default device,
                           WASAPI-loopback the instance - the desktop-audio
                           capture path minus the game.
  audio-probe sss-primary  S1: the primary Speakers' known-silent loopback,
                           re-measured, with mix format + steam.exe state.
  audio-probe cleanup      remove every probe-minted devnode (marker value
                           in Device Parameters, never name-guessing).

pad_endpoint grows the first slice of the design's §C1 shared minting
surface: create_media_devnode(desc, hwid, mark), bind_driver(hwid, inf),
find_capture_endpoint_for_devnode — the pad provisioner now calls the
same functions. The probe restores whatever default devices the minting
disturbed before it exits.
2026-08-07 10:47:00 +02:00
enricobuehler 152047051c fix(host/windows): three SID unsafe blocks get their safety proofs
install.rs (landed 2026-08-05 with the security-review remediation, while
the Windows CI runner was down) fails windows-host.yml's clippy gate:
#![deny(clippy::undocumented_unsafe_blocks)] wants the SAFETY comment on
the line preceding EACH unsafe block, and three blocks didn't have one —
two sat behind a comment anchored to the enclosing closure/neighbouring
statement, and EqualSid had none at all. Comments only; no behavior
change.
2026-08-07 10:38:03 +02:00
enricobuehler ef8214415f fix(host/audio): game audio outranks the mic on cable-less boxes
The wiring plan reserved the mic target unconditionally first, so on a box
without VB-Cable the mic took the Steam Streaming Microphone — the only
working client-only loopback sink — and desktop audio fell to the
known-silent Speakers last resort: a headless Steam-only host streamed
SILENCE (the 2026-08 field case), and the installer's 'optional (mic
passthrough)' wording never warned anyone.

The mic may now hold the Streaming Microphone only while the loopback still
gets a preferred (non-last-resort) pick without it — another silent sink or
real hardware. Otherwise the loopback takes the endpoint and the mic falls
to a lesser candidate or is honestly withheld (Wiring::mic_withheld), with
the open error naming the trade and the remedy. An operator
PUNKTFUNK_MIC_DEVICE override is exempt: an explicit choice may still
strand the loopback on the last resort.

Also: the Steam-pair auto-install latch is now once per INF-state instead
of once per process — an attempt made while Steam was absent re-arms when
its driver INFs later appear (files are invisible to the endpoint-set
fingerprint, so nothing else would ever retry), and a withheld mic skips
the pointless reinstall (the pair exists; the plan gave it to the loopback).
2026-08-07 10:29:37 +02:00
enricobuehler b5205fef52 Merge pull request 'fix(client/apple): audio stops crackling on lossy, bunching Wi-Fi' (#82) from worktree-audio-wifi-distortion into main
ci / rust-arm64 (push) Failing after 13s
arch / build-publish (push) Failing after 22s
ci / bun-nix (push) Successful in 21s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m27s
deb / build-publish-host (push) Failing after 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 8s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
deb / build-publish (push) Successful in 4m33s
android / android (push) Successful in 5m53s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 4m31s
ci / rust (push) Successful in 6m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m47s
flatpak / build-publish (push) Failing after 4m28s
windows-host / package (push) Failing after 6m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
release / apple (push) Successful in 11m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
apple / screenshots (push) Successful in 6m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 12m58s
Reviewed-on: #82
2026-08-07 07:37:19 +00:00
enricobuehler b27135308f fix(scripts): the xcframework never absorbs a Homebrew libopus
ci / docs-site (pull_request) Failing after 2s
ci / web (pull_request) Failing after 5s
android / android (pull_request) Failing after 14s
ci / bun-nix (pull_request) Successful in 22s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m52s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m45s
ci / rust (pull_request) Failing after 7m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m22s
On a Mac with brew's opus installed, audiopus_sys found it via pkg-config and
statically linked it into the aarch64 slice — a lib built for the RUNNING
macOS (minos 26.0, tripping the script's own version guard) and existing only
for the host arch, so the x86_64 slice silently fell back to the vendored
build and the two slices shipped different libopus builds. Force the vendored
CMake build for every slice (OPUS_NO_PKG_CONFIG=1), with the CMake policy
floor modern CMake (>=4) needs to accept libopus's old cmake_minimum_required.
2026-08-07 09:23:37 +02:00
enricobuehler 64c92da356 fix(client/apple): the jitter ring deepens on the sessions that actually starve
The shared JitterPolicy grew an adaptive target floor — clustered genuine
underruns raise the live target a step at a time up to max_target_ms, a long
quiet spell relaxes it back — and the three Rust rings all run it via
note_read. The Apple ring is the one hand-written mirror, and it mirrored the
shed half but not the growth half: its target was pinned at the 20 ms base
forever. On Wi-Fi that bunches arrivals (power-save is the classic; the field
MacBook report is the symptom), 20 ms is regularly shorter than one delivery
stall, so the ring re-primed through every stall for the whole session —
crackle that never got better, on exactly the client where a Moonlight with a
deeper buffer sounds fine on the same host and network.

The ring now carries the full mirror of note_read: 3 underruns inside a 5 s
window grow the target 10 ms (capped at COREAUDIO's 70), 30 s of quiet gives a
step back, and the write-side hard trim follows the grown target (including
the Rust policy's target+quantum guard, which the mirror also lacked). New
tests pin the mirror to the Rust suite's expectations — growth, relax, the
cap — plus the field scenario end to end: bunched 60 ms deliveries with every
fourth burst 30 ms late converge to a silence-free tail instead of crackling
forever.
2026-08-07 09:23:29 +02:00
enricobuehler 81d257c7fa fix(client/audio): the in-core decoder conceals lost packets like every other client
A field report: game audio on a MacBook (M1) crackles over Wi-Fi against a host
that plays clean to other clients. The Apple client is the one client whose
Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox
has no multistream path), and that decoder only ever decoded packets that
ARRIVED. The Linux, Windows and Android decode loops all feed an
AudioGapTracker and synthesize libopus packet-loss concealment for every
packet the wire lost; the in-core path had the tracker sitting unused in the
same crate. So on Apple every lost 5 ms datagram — at ~200 packets/s over
Wi-Fi, a steady trickle — landed in the playout ring as a hard time-domain
gap: a click per loss, sustained crackle under real loss. The redundant-plane
recovery (0xD2) hides single losses when the host grants it, which is exactly
why the survivors are the burstier gaps that need concealing most.

The decode now runs through the same accounting as everyone else: concealed
frames land in front of the arriving frame in one contiguous buffer (the
embedder just writes it to its ring), a DTX marker advances the accounting
without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer can never dangle.
Unit-tested against real libopus: gaps, duplicates, DTX-after-loss, and the
50 ms cap.
2026-08-07 09:23:15 +02:00
enricobuehler f34f311c69 Merge pull request 'fix(brand): capitalize "Punktfunk" in user-facing text; name the legal copyright holder' (#81) from worktree-docs-title-casing into main
apple / swift (push) Successful in 1m35s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 2m23s
ci / web (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Failing after 4m37s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
deb / build-publish-host (push) Successful in 4m21s
apple / screenshots (push) Successful in 6m3s
deb / build-publish-client-arm64 (push) Successful in 5m7s
nix / flake (push) Failing after 3m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 45s
ci / rust (push) Failing after 7m22s
deb / build-publish (push) Successful in 5m11s
android / android (push) Successful in 14m37s
docker / deploy-docs (push) Successful in 6m48s
release / apple (push) Successful in 9m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m56s
windows-drivers / probe-and-proto (push) Successful in 2m0s
windows-drivers / driver-build (push) Successful in 9m47s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 17m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 6m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 11m31s
windows-host / package (push) Failing after 49m58s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 15m1s
Reviewed-on: #81
2026-08-06 21:33:33 +00:00
enricobuehler e6bcac8770 chore(license): name the copyright holder "unom - Enrico Bühler"
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m58s
ci / rust (pull_request) Successful in 8m28s
nix / flake (pull_request) Successful in 13m20s
windows-drivers / probe-and-proto (pull_request) Successful in 2m56s
windows-drivers / driver-build (pull_request) Successful in 10m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 18m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 24m1s
"unom" alone is not the legal entity name. Updated the copyright line in
all nine first-party license files -- the root MIT/Apache pair and the
hand-maintained copies under clients/apple, clients/decky,
packaging/windows/drivers and packaging/windows/pf-vkhdr-layer (there is
no script that syncs these, so each is edited directly).

The Linux and Windows clients' About screens pick this up automatically:
both `include_str!` the root LICENSE-MIT / LICENSE-APACHE at compile time.

Deliberately untouched:
- Third-party license texts (Geist OFL, FFmpeg, VB-CABLE, the vendored
  pyrowave/Granite tree, KDE protocol XMLs, the os-icon licenses) -- those
  are other parties' copyrights.
- Publisher/author/maintainer metadata, which is identity rather than
  license text and is reported separately for a decision.
2026-08-06 23:16:16 +02:00
enricobuehler 9c7409ea40 Merge pull request 'fix(web/plugins): a plugin's interface loads again, instead of an empty panel' (#80) from worktree-plugin-ui-frame-ancestors into main
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m10s
ci / web (push) Successful in 1m10s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m33s
deb / build-publish (push) Failing after 2m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
deb / build-publish-host (push) Successful in 5m37s
ci / rust (push) Successful in 6m37s
arch / build-publish (push) Successful in 8m50s
docker / deploy-docs (push) Failing after 6m26s
nix / flake (push) Canceled after 15m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m28s
windows-host / package (push) Canceled after 17m47s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #80
2026-08-06 21:15:58 +00:00
enricobuehler b0ec759fad fix(brand): capitalize "Punktfunk" in user-facing text
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).

User-visible fixes:

- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
  description and the branded bar's aria-label; the BrandMark/Wordmark SVG
  accessible names (the web console already had these capitalized -- the
  docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
  dialog (x2), the connect-screen error banner, and the no-controller
  explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
  a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
  grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
  gen-third-party-notices.py) and applied to the three checked-in outputs
  so they match what a regeneration now produces.

Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
2026-08-06 23:10:25 +02:00
enricobuehler 185a69c806 fix(web/plugins): a plugin's interface loads again, instead of an empty panel
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m49s
ci / rust (pull_request) Successful in 6m23s
nix / flake (pull_request) Failing after 12m37s
Two separate things had to be wrong for this, and both were.

The frame's own policy locked it out. Plugin UIs moved to their own origin so a
plugin cannot act as the logged-in operator, and the plugin origin names the
console as the only page allowed to frame it. It built that name from the
incoming request — but Nitro hands the app a synthetic request with no TLS
socket, so an HTTPS console reads back as `http:`. The header said
`frame-ancestors http://host:47992` while the operator was on
`https://host:47992`, and the browser refused the frame outright
(ERR_BLOCKED_BY_RESPONSE). Nothing on screen said so; the reason was only in
devtools. The scheme now comes from the listener's own TLS state, stamped at
bind time, with x-forwarded-proto winning when something in front terminated
TLS for us — the one case where the browser's scheme is not ours.

And the port was shut. 47993 was added to the firewall definitions, but an
already-open firewall does not pick a new port up: ufw expands an app profile
into rules when you allow it and keeps those, so editing the profile on upgrade
changes nothing, and firewalld needs a reload. Every upgraded Linux host kept a
47992-only rule, silently. The packages now notice on upgrade and print the one
command that fixes it, without touching the running firewall. The NixOS module
and the container image never learned the port at all; both now open it.

Also: the console no longer mounts the frame while it is still checking whether
that origin is reachable. A firewalled port drops rather than refuses, so the
check hangs for the browser's whole connect timeout, and mounting meanwhile is
exactly the empty panel with no explanation. The card that follows now names
both causes it can be — an untrusted certificate for that port, or a closed one
— because from a browser the two are indistinguishable.

The rule is now a pure function with tests, since its failure mode is a
well-formed header that only a browser rejects.

Verified on glass against home-worker-5 (.21) and its ROM Manager plugin: the
frame was refused before, renders the plugin's UI after.
2026-08-06 22:33:57 +02:00
enricobuehler 50fa228bab Merge pull request 'feat(client/android): copy a host's punktfunk:// link' (#79) from worktree-android-copy-link into main
ci / web (push) Failing after 28s
ci / bun-nix (push) Successful in 23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 13s
ci / docs-site (push) Successful in 1m15s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
android / android (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 6m17s
ci / rust (push) Failing after 6m15s
docker / deploy-docs (push) Successful in 6m29s
Reviewed-on: #79
2026-08-06 19:32:31 +00:00
enricobuehler f33e442b43 feat(client/android): copy a host's punktfunk:// link
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 3m22s
ci / rust (pull_request) Failing after 7m33s
Android was the one app that could open a punktfunk:// link but never hand
one out, so every Android link had to be typed by hand — and the host's
stable record id, which is the part that keeps a link working after the box
changes address, isn't shown anywhere in the UI to type.

Both homes now offer Copy link: the touch grid's card overflow menu, and the
controller home's host options (Up on a tile). A pinned card copies its own
profile with it, matching Linux and Apple; a host card copies none and so
keeps honouring the host's binding, exactly like tapping it does.

The URL is the shared self-emitted form (DeepLinks.forHost), already covered
by the cross-language vector tests, so the three emitters stay in step.

Android 13+ draws its own clipboard confirmation and we add nothing on top of
it; below that we say so ourselves, as a toast in the console home, which
renders neither banner.
2026-08-06 21:20:36 +02:00
enricobuehler 442558e503 Merge pull request 'fix(plugin-kit): the SQLite reader never opened anything on Linux' (#78) from worktree-plugin-kit-sqlite-uri-fix into main
ci / bun-nix (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m25s
ci / web (push) Successful in 1m36s
plugin-kit-publish / publish (push) Successful in 33s
ci / rust (push) Successful in 8m0s
nix / flake (push) Successful in 11m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #78
plugin-kit-v0.3.1
2026-08-06 18:24:03 +00:00
enricobuehler 4bcb3794c5 fix(plugin-kit): the SQLite reader never opened anything on Linux
nix / flake (pull_request) Failing after 1s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 58s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust-arm64 (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 6m15s
Found while running the lutris plugin's own release gate against a live host on
.21: `parity --compare` reported `1 missing` — the plugin produced NO entry for
the one game Lutris had, while the host's built-in scanner produced it fine.
`scan` agreed: "present: 0 games". `detect` still said "present", because detect
only stats the file.

`openReadOnly` builds a `file:<path>?immutable=1` URI — the right idea, since
`immutable=1` is what makes this a pure read that cannot lock a running
launcher's database or spawn WAL sidecars next to it. But it opened that name
with `{ readonly: true }`, and the options object does NOT enable SQLite's URI
filename parsing. Without SQLITE_OPEN_URI the name is taken literally, no such
file exists, and the open throws:

    SQLiteError: unable to open database file

Every path here then degrades that to silence by design: `openReadOnly` returns
`undefined` for "this launcher isn't installed", `withReadOnlyDb` passes the
`undefined` through, and callers write `withReadOnlyDb(...) ?? []`. So a total,
permanent failure to read ANY database was indistinguishable from an empty
library. The lutris plugin reported 0 games on every Linux box, always.

It is platform-split, which is why it survived review and local runs: macOS
links Apple's system SQLite, which is built with URI filenames enabled, so the
same call succeeds there. Linux uses Bun's bundled SQLite, which is not. MEASURED
both ways on bun 1.3.14.

Fixed by passing the flags explicitly — SQLITE_OPEN_READONLY | SQLITE_OPEN_URI.
(`{ readonly: true, uri: true }` is not a supported option shape; also measured.)

And the second half, which is why nothing caught it: `parity --compare` sets
`process.exitCode = 1` on a mismatch and then returns NORMALLY — a red parity is
a finished comparison, not a crashed command. `runPluginCli` then assigned
`process.exitCode = 0` unconditionally after the effect resolved, overwriting it.
So the one verb both plugin READMEs document as the release gate — "exits
non-zero on any difference", "do not publish a version whose parity run is red" —
always exited 0, and any scripted use of it passed. Now `??= 0`, so a code a
command set deliberately survives.

Tests: the sqlite helper had NO coverage at all, which is the whole reason a
total failure shipped looking like an empty library. Added five cases against a
REAL database file — reads rows back, handles a path needing URI escaping,
withReadOnlyDb round-trips, an absent file is `undefined` not a throw, and a bad
query still degrades to []. Verified they actually catch it: against the shipped
code on Linux, 4 of the 5 fail; with the fix, 21/21 in that file and 61/61 across
the suite, typecheck and build clean.

End to end on .21 with the fix: lutris `scan` goes 0 -> 1 games and
`parity --compare` reports "parity OK — 1 entries identical". A deliberately
doctored baseline now exits 1 instead of 0.

0.3.0 -> 0.3.1.
2026-08-06 20:14:04 +02:00
enricobuehler 214e846984 Merge pull request 'fix(ci/nix): the flake job could never start its container' (#77) from worktree-nix-flake-container-fix into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13s
ci / bun-nix (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 47s
ci / rust-arm64 (push) Successful in 1m23s
docker / builders-arm64cross (push) Skipped
ci / docs-site (push) Successful in 1m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 6m30s
ci / rust (push) Successful in 8m11s
nix / flake (push) Successful in 11m42s
Reviewed-on: #77
2026-08-06 17:45:35 +00:00
enricobuehler e78906fb06 fix(ci/nix): the flake job could never start its container
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 16s
ci / rust (pull_request) Failing after 6m12s
nix / flake (pull_request) Failing after 4m54s
The nix gate landed in fb707b49 on `nixos/nix:latest`, and it has never run a
single step. That image carries nix and essentially nothing else — including no
`/bin/sleep` — and Gitea's act_runner starts every job container with
`entrypoint=["/bin/sleep", "10800"]`:

  failed to create shim task: OCI runtime create failed: runc create failed:
  unable to start container process: exec: "/bin/sleep":
  stat /bin/sleep: no such file or directory

The expensive part is how it reports: with the container dead, every step is
marked `cancelled` rather than `failed`, which reads exactly like a run that was
superseded by a newer push. Run 15907 looked skipped, not broken.

Switch to `node:22-bookworm` and install Nix in a step:

  * full Debian, so the entrypoint exists and coreutils are present;
  * a real node, so `actions/checkout` works with no pre-checkout install dance
    (the reason flatpak.yml's fedora job installs node before its checkout);
  * audit.yml already pulls this image on this fleet, so it is known to resolve.

Nix comes from the Determinate installer with `--init none` — the container mode:
no systemd, no daemon. That distribution is also what the hand-verification Nix
box (.21) runs, so CI and it stay on the same Nix.

MEASURED in a real `node:22-bookworm` container rather than assumed, since the
last version of this file shipped on an untested assumption and cost a red run:

  * `/bin/sleep` present — the entrypoint failure is gone;
  * the installer completes and `nix --version` runs from the absolute path;
  * `nix build` of a trivial derivation SUCCEEDS, `nix store ping` reports
    `Store URL: local, Trusted: 1`, and flakes are enabled.

That last one is the trap this change also pins. `--init none` runs no daemon,
but the installer still writes a profile script exporting `NIX_REMOTE=daemon`;
anything sourcing it (any `-l` login shell) then dies on "cannot connect to
socket at '/nix/var/nix/daemon-socket/socket'". It is why the installer's own
self-test fails, harmlessly, in the middle of this step's log. The steps here
never source that profile — they invoke `$NIX` by absolute path — but `NIX_REMOTE`
is now pinned empty at job level so a later step cannot reintroduce it.

Also records `df -h` before the build: this fleet ran a runner out of disk today
(ci.yml's `web` job died with "no space left on device" mid-`bun install`), a Nix
build is the heaviest thing that would run here, and a future failure should be
attributable at a glance rather than guessed.

Still unproven, and only the first green run can settle it: whether `nix flake
check` evaluates this flake cleanly under CI, and whether the runner has the disk
to build punktfunk-web. Both now produce a real diagnostic instead of a container
that never started.
2026-08-06 19:31:16 +02:00
enricobuehler d167e90ea1 Merge pull request 'A launcher tile's session stops depending on invisible state' (#76) from worktree-launcher-lease into main
arch / build-publish (push) Failing after 16s
ci / bun-nix (push) Successful in 42s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m24s
apple / swift (push) Successful in 1m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
android / android (push) Failing after 3m51s
ci / rust (push) Failing after 3m48s
docker / builders-arm64cross (push) Failing after 19s
deb / build-publish (push) Successful in 3m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 5m32s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 7m57s
windows-host / package (push) Failing after 11m43s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m49s
Reviewed-on: #76
2026-08-06 17:25:44 +00:00
enricobuehler e9da37aaf6 feat(host/gamelease): a launcher tile's session stops depending on invisible state
ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:

  launcher NOT already running   the spawned child stays alive -> `Child` lease
                                 -> quitting the launcher ends the session
  launcher ALREADY running       the command forwards to the live instance and exits
                                 inside SHIM_WINDOW, with no detect signals behind it
                                 -> `Untracked` -> the session persists

Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).

So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.

`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.

Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.

The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.

Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).

Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
2026-08-06 18:35:09 +02:00
enricobuehler 453b9850fa Merge pull request 'Gamepad UI: multi-tone palettes, and a UI that takes its colours from them' (#74) from worktree-gamepad-ui-polish into main
ci / bun-nix (push) Successful in 19s
ci / docs-site (push) Successful in 1m19s
apple / swift (push) Successful in 1m25s
deb / build-publish (push) Successful in 3m43s
ci / web (push) Successful in 5m20s
deb / build-publish-host (push) Successful in 4m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 6m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 9s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m14s
arch / build-publish (push) Successful in 7m7s
docker / deploy-docs (push) Failing after 9s
android / android (push) Failing after 10m28s
ci / rust (push) Failing after 10m7s
release / apple (push) Successful in 9m49s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / screenshots (push) Successful in 5m55s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m13s
flatpak / build-publish (push) Successful in 13m21s
Reviewed-on: #74
2026-08-06 14:48:24 +00:00
enricobuehler eb4b33ca49 Merge pull request 'An xbox launch kind a de-privileged plugin can publish' (#75) from worktree-xbox-launch-kind into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Failing after 11m41s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #75
2026-08-06 14:48:03 +00:00
enricobuehler 8e02f3a8e1 Merge pull request 'ci(nix): a stale bun.nix can no longer reach main unnoticed' (#73) from worktree-nix-bun2nix-drift-gate into main
apple / swift (push) Successful in 1m26s
ci / bun-nix (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m27s
android / android (push) Canceled after 1m44s
deb / build-publish-host (push) Failing after 3s
apple / screenshots (push) Canceled after 10s
arch / build-publish (push) Canceled after 1m47s
ci / rust (push) Canceled after 1m49s
ci / web (push) Canceled after 1m45s
ci / docs-site (push) Canceled after 1m45s
deb / build-publish (push) Canceled after 26s
deb / build-publish-client-arm64 (push) Canceled after 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
nix / flake (push) Failing after 18s
decky / build-publish (push) Successful in 50s
Reviewed-on: #73
2026-08-06 14:46:25 +00:00
enricobuehler 4a9a1c3ed4 feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:55:26 +02:00
enricobuehler 166e158afe feat(host/library): an xbox launch kind a de-privileged plugin can publish
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m9s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 3m20s
ci / rust (pull_request) Failing after 9m9s
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:

  NT AUTHORITY\LocalService   UnauthorizedAccessException   <- the plugin runner
  LocalSystem                 count=348                     <- the host service

So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.

The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.

That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.

`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.

The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.

Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
2026-08-06 15:43:26 +02:00
enricobuehler fb707b4956 ci(nix): a stale bun.nix can no longer reach main unnoticed
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Failing after 2m19s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 3m10s
ci / bun-nix (pull_request) Successful in 1m6s
nix / flake (pull_request) Failing after 1m6s
ci / rust (pull_request) Failing after 6m2s
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.

That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.

Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.

  * scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
    bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
    check out of packages.nix's own `bunNix = src + ...` lines rather than a
    hardcoded list, so a third bun package is covered the day it is added, and
    an empty list is a hard error — a gate that checks nothing passes exactly
    like a clean tree. It also enforces the bun2nix version pin that flake.nix
    and the README only stated: bun.nix has no schema stability across releases,
    so the flake input ref and both package.json devDependencies must agree, and
    generation always uses the pinned version rather than a floating `bunx`.

  * ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
    nix.yml: it needs no Nix, takes under a minute, and the whole point is that
    this drift arrives through commits that look unrelated to Nix.

  * nix.yml is new and is the first CI that evaluates the flake at all —
    `nix flake check --no-build` over every output, then a real build of
    punktfunk-web + punktfunk-scripting, which are the two derivations whose
    inputs churn and the only ones that do not compile Rust. That leg is the
    end-to-end proof the drift gate cannot give: it catches a tarball the
    registry no longer serves, or the codegen going quietly message-less. It is
    path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
    packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
    manifest breakage reach main invisibly for weeks.

The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
2026-08-06 15:28:14 +02:00
enricobuehler 65996621d8 Merge pull request 'fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate' (#72) from worktree-pyrowave-gpu-priority into main
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m26s
deb / build-publish-host (push) Failing after 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 10s
apple / swift (push) Successful in 1m27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
deb / build-publish-client-arm64 (push) Successful in 2m55s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 42s
arch / build-publish (push) Successful in 10m31s
ci / rust-arm64 (push) Failing after 8m55s
apple / screenshots (push) Successful in 5m51s
windows-host / package (push) Failing after 6m15s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m13s
ci / rust (push) Successful in 20m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
Reviewed-on: #72
2026-08-06 13:15:06 +00:00
enricobuehler c48e60fbb7 Merge pull request 'Two July fixes that were never merged: rpm FFmpeg modules + the released-pointer double cursor' (#71) from worktree-july-rpm-and-cursor into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
release / apple (push) Successful in 9m31s
flatpak / build-publish (push) Successful in 10m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
Reviewed-on: #71
2026-08-06 13:13:33 +00:00
enricobuehler 70684e5079 fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m41s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / web (pull_request) Successful in 2m27s
ci / docs-site (pull_request) Successful in 3m35s
ci / rust (pull_request) Successful in 8m25s
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.

The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.

Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.

The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.

Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
2026-08-06 15:06:49 +02:00
enricobuehler 3f8a70dd45 Merge pull request 'Launcher tiles reach the clients, and Playnite can publish again' (#70) from worktree-library-clients into main
apple / swift (push) Successful in 1m34s
ci / web (push) Successful in 1m7s
deb / build-publish-host (push) Failing after 4s
ci / docs-site (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 5m47s
arch / build-publish (push) Successful in 7m36s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
android / android (push) Canceled after 9m43s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 9m54s
deb / build-publish (push) Canceled after 8m14s
release / apple (push) Canceled after 8m21s
flatpak / build-publish (push) Canceled after 5m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / package (push) Failing after 6m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #70
2026-08-06 13:03:42 +00:00
enricobuehler 2a67c02f7e fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.

Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:

    cursor diag: client_draws=false blended=true live=Some((-1, 622, true))

x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.

The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
2026-08-06 15:03:11 +02:00
enricobuehler 79ee308a7a build(rpm): declare all seven FFmpeg pkg-config modules, not three
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.

RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:

    The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
2026-08-06 15:03:05 +02:00
enricobuehler 58ee74cb58 fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
2026-08-06 14:50:18 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler 00d4026054 Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #69
2026-08-06 12:41:28 +00:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00