Commit Graph

492 Commits

Author SHA1 Message Date
enricobuehler 24dc8ee60b Merge remote-tracking branch 'origin/main' 2026-06-20 16:30:32 +00:00
enricobuehler 6e83895fe4 feat(host/gamescope): custom-resolution Game-Mode streaming on the Steam Deck
The Steam Deck (SteamOS) ships its OWN gaming session — `gamescope-session.target`
driven by `/usr/lib/steamos/gamescope-session`, not Bazzite's `gamescope-session-plus`.
That script `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O
'*',eDP-1`) and launches Steam via a SEPARATE `steam-launcher.service`, so the existing
managed-session path (which assumes session-plus) couldn't honor the client's mode — an
attach captured the panel's native 1280x800 instead.

Add a SteamOS branch to the managed-session path: detect it, write a `gamescope` PATH-shim
that rewrites the hardcoded args to `--backend headless -W <client> -H <client> -r <hz>`,
drop a transient user `gamescope-session.service.d` override pointing PATH at the shim +
the mode, then RESTART the whole target so `steam-launcher.service` brings Steam up IN the
headless gamescope at the client's resolution. Attach to the one fresh node (the restart
kills any prior gamescope, so no stale-node attach). Restore-on-disconnect removes the
override + restarts the target back to the physical panel (debounced; skipped if the user
switched to a desktop session). All user-level (`systemctl --user`) — no root.

Also widen `build_pipeline_with_retry` to 8 attempts (~90s): a host-managed gamescope
session cold-starting Steam Big Picture takes 30-60s to first frame, and a first-connect
timeout would tear down the warm session (forcing another cold start on reconnect).
Permanent failures still fail fast via `is_permanent_build_error`.

Validated live on a Steam Deck: Game Mode auto-detected, host takes over headless at the
client's mode (720p / 1080p), Steam Big Picture streamed glass-to-glass to the Mac at the
requested resolution. Single-tenant (concurrent clients at different modes still thrash —
a follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:30:24 +00:00
enricobuehler a4099ca450 feat(docs-site): read footer from the per-tenant CMS collection
The shared unom CMS is now multi-tenant; the footer global became a per-tenant
collection. Query footers scoped to tenant.slug = punktfunk instead of the
removed /globals/footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:11:42 +02:00
enricobuehler 44093ce1c2 Merge remote-tracking branch 'origin/main' 2026-06-20 14:50:09 +00:00
enricobuehler 1e71bfea12 fix(core/transport): treat ENOBUFS as a transient drop, not a fatal error
WiFi drivers (e.g. ath11k on the Steam Deck) return ENOBUFS — not
EAGAIN/EWOULDBLOCK — when the tx queue is momentarily full. Rust maps
ENOBUFS to ErrorKind::Uncategorized, so `is_transient_io` (which only
matched WouldBlock/ConnRefused/ConnReset) treated it as a real error and
tore the whole stream down on a single transient burst.

This presented as a vicious Heisenbug on the Deck: the native host
streamed flawlessly on loopback and under a debugger (anything slow
enough not to fill the small ~416 KB wlan0 buffer), but died at full rate
cross-machine over WiFi — flaky hang-or-SIGKILL because tx-queue-full is
probabilistic. Diagnosed live via a forced core dump (gdb on the hung
core): the data-plane thread had bailed on a fatal send error.

Treat ENOBUFS (and asynchronous network-path blips ENETUNREACH /
EHOSTUNREACH / ENETDOWN / EHOSTDOWN) as a lossy drop like WouldBlock —
FEC + the next frame recover. Validated: 6/6 back-to-back cross-machine
streams over the Deck's WiFi, host stable, p50 ~4.4 ms (one run dropped
4/300 frames *gracefully*, 0 mismatched — the fix working as intended).

Also surface a data-plane bind/hole-punch failure directly in punktfunk1
(it was previously only reported after teardown, which a stall could
swallow entirely).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:49:59 +00:00
enricobuehler 9b83b6a235 feat(docs-site): add a Gitea repo link to the nav
Link to https://git.unom.io/unom/punktfunk in the top bar, alongside the
Docs and Website links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:08:44 +02:00
enricobuehler 15697467ee feat(docs-site): add the site-wide footer, shared with the marketing site
Pull the same footer from the shared unom CMS global (cms.unom.io) and render
it globally under both the home and docs layouts. Read-only typed fetch in a
server-side root loader (falls back to null on a CMS hiccup). Root-relative
links target the marketing site, so they're resolved against its origin (the
docs don't host /legal/* etc.); themed with Fumadocs tokens for light/dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:42:39 +02:00
enricobuehler 5d0abdd63c refactor(docs-site): use the vector SVG wordmark instead of the raster
Replace the CSS-mask/webp wordmark with the inline vector from
Export/Punktfunk_Logo-Text_No-Border_Dark.svg (white export background
dropped), painted via currentColor — deep-violet on light, light-violet on
dark. Crisp at any size; drops the now-unused funk-wordmark.{webp,png}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:34:19 +02:00
enricobuehler bb0bd25929 feat(docs-site): use the funk wordmark logo instead of "punktfunk" text
Swap the plain "punktfunk" text in the nav and landing hero for the real
brand wordmark from the marketing site. The source asset is a single
light-violet variant (made for dark surfaces), so it's painted as a CSS
mask and coloured per theme — deep-violet on light, light-violet on dark —
to stay legible with the docs' light/dark toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:23 +02:00
enricobuehler dc183ac898 feat(docs-site): brand the docs to match the punktfunk website
Theme the Fumadocs docs site with the punktfunk identity, mirroring the
marketing site:

- Swap the stock `neutral` preset for `purple`, then override --color-fd-*
  with the violet lens-mark palette (#6c5bf3 / #a79ff8). The brand is the
  violet, not the site's blue marketing background, so the blue is not used
  as a reading surface; dark mode tints the chrome toward the app-icon
  violet-dark (#1c1530).
- Adopt @unom/ui's token contract (--brand/--primary/--accent + bg-brand
  etc.) as the shared token source, and @source its dist.
- Load Geist (the brand typeface) via @fontsource-variable/geist.
- Add the BrandMark lens to the nav + landing hero, wire the brand
  favicon.svg, and add Docs/Website nav links.
- Keep the Fumadocs light/dark toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:52 +02:00
enricobuehler 68e772b662 fix(host/serverinfo): don't advertise an empty codec mask when the VAAPI probe finds nothing
The Phase 3 GPU-aware codec mask (e2cef91) probes VAAPI on any non-NVIDIA host.
On a GPU-less box (CI container: no /dev/nvidia* -> `auto` picks VAAPI, but there's
no VA display) the probe returns all-false, so the mask was 0 -- the host
advertised NO codecs, and the serverinfo unit test failed.

Fall back to the static superset when the probe yields nothing (VAAPI wasn't
usable, not "the GPU encodes nothing"); quiet ffmpeg's expected "No VA display"
error during the probe; and assert the test against codec_mode_support() rather
than a hardcoded 65793 so it's deterministic regardless of the build host's GPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:52:17 +00:00
enricobuehler e2cef91d3a feat(host): VAAPI codec probe + AMD/Intel packaging + neutral logs (Phase 3)
Polish for AMD/Intel support:
- GameStream serverinfo advertises only codecs the GPU can ACTUALLY encode on
  the VAAPI backend (probed once by opening a tiny encoder per codec). AV1
  encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) and an old iGPU may lack
  HEVC, so a Moonlight client never negotiates a codec the encoder can't open.
  NVENC/Windows keep the Moonlight-validated static mask. Validated on a Radeon
  780M: h264/h265/av1 all probe true -> mask unchanged (65793).
- Packaging: Recommends mesa-va-drivers + intel-media-va-driver (deb) /
  mesa-va-drivers + intel-media-driver (rpm) so the auto-selected VAAPI backend
  works out of the box on AMD/Intel; NVIDIA boxes can --no-install-recommends.
  (Fedora note: stock mesa-va-drivers disables HEVC/AV1 -- needs the freeworld
  variant from RPM Fusion.)
- De-NVIDIA-fy the user-facing encoder log/context strings ("open NVENC" ->
  "open video encoder") now that VAAPI is a first-class backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:41:37 +00:00
enricobuehler 07c9244eaf feat(host/encode): VAAPI zero-copy dmabuf import (AMD/Intel GPU CSC)
Phase 2 of AMD/Intel support: the VAAPI encoder now takes the capture dmabuf
directly and does the RGB->NV12 colour conversion on the GPU's video engine,
eliminating the host-side de-pad + swscale CSC + upload the CPU path pays.

- capture: a vendor-neutral FramePayload::Dmabuf (dup'd fd + fourcc/modifier/
  layout). When zero-copy is on, the EGL->CUDA importer is unavailable (any
  non-NVIDIA host), and the backend is VAAPI, the capturer advertises LINEAR
  dmabuf and hands the raw buffer to the encoder instead of CPU-copying it.
- encode/vaapi: the encoder self-configures from the first frame's payload (no
  open_video signature change). The dmabuf arm wraps the buffer as an
  AV_PIX_FMT_DRM_PRIME frame and pushes it through a filter graph
  buffer(drm_prime) -> hwmap(vaapi) -> scale_vaapi=nv12 -> buffersink; the
  encoder takes NV12 surfaces straight from the sink. The Phase 1 CPU-upload
  path is kept as the other arm (used when capture produces CPU frames).

Live-validated on a Radeon 780M (real Sway/xdpw desktop capture): correct,
pixel-perfect HEVC, and ~10x less host CPU at 1440p (4.2s -> 0.4s of CPU for
300 frames) -- the de-pad/CSC/upload moves to the GPU. NVIDIA unchanged
(zero-copy still imports to CUDA; the passthrough path only engages on
non-NVIDIA hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:57:00 +00:00
enricobuehler 330574e058 fix(host/capture): mmap the buffer fd ourselves — xdpw MemFd over-reads MAP_BUFFERS
The CPU de-pad path trusted PipeWire's MAP_BUFFERS slice (`d.data()`, length =
`data.maxsize`). xdg-desktop-portal-wlr hands MemFd ScreenCast buffers whose
maxsize exceeds the bytes PipeWire actually maps into our process, so reading to
maxsize ran off the end of the mapping and SIGSEGV'd the capture thread —
crashing every CPU-path capture on Sway/wlroots (and thus any non-NVIDIA host,
which has no CUDA zero-copy importer and always falls back to this path).

mmap the fd ourselves, sized to its real length (fstat), for any fd-backed
buffer (MemFd SHM or DmaBuf); fall back to `d.data()` then drop. The existing
`needed > avail` guard now drops cleanly instead of over-reading. This also
subsumes the original "MAP_BUFFERS didn't map a Vulkan dmabuf" fallback.

Verified: fixes real Sway-desktop portal capture -> VAAPI HEVC on a Radeon 780M
(correct image + colours); the NVIDIA zero-copy path (returns before this code)
and the NVIDIA/KWin CPU path (self-mmap, fd_len == maxsize) both still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:48:49 +00:00
enricobuehler 405eb7731d refactor(host/zerocopy): dlopen libcuda instead of a link-time #[link]
The host hard-linked libcuda.so.1 on Linux (`#[link(name="cuda")]` in
`zerocopy::cuda`), so the binary wouldn't even *start* on a non-NVIDIA box —
the dynamic loader can't resolve the NEEDED libcuda. That blocked running the
new VAAPI (AMD/Intel) path on a machine without the NVIDIA driver.

Resolve the 18 CUDA Driver API symbols at runtime via `libloading` instead.
Same-named wrapper fns forward to the dlopen'd table (call sites unchanged);
when libcuda is absent they return a non-zero CUresult so `context()` fails
cleanly and the capturer falls back to the CPU path. The library handle is
leaked (process-lifetime, like the shared context).

One Linux binary now runs on NVIDIA (CUDA zero-copy -> NVENC) and on AMD/Intel
(VAAPI, no NVIDIA driver). Verified: the NVIDIA dev box still does dmabuf->CUDA
zero-copy; on a Radeon 780M box the host builds with no libcuda present, the
binary has no NEEDED libcuda entry, and VAAPI encode runs with no stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:44:57 +00:00
enricobuehler 2823785bb6 feat(host/encode): VAAPI encode backend for AMD/Intel GPUs (Linux)
The Linux host was NVENC/CUDA-only. Add a VAAPI encoder — one libavcodec
backend (h264/hevc/av1_vaapi) covering both AMD (Mesa radeonsi) and Intel
(iHD) — behind the existing `Encoder` trait, and turn `open_video`'s Linux
arm into a vendor dispatcher: `PUNKTFUNK_ENCODER=auto|nvenc|vaapi` (default
auto: NVENC when a CUDA frame or /dev/nvidia* is present, else VAAPI). The
NVIDIA path is unchanged — auto resolves to NVENC on an NVIDIA box and the
bitrate-probe loop moved verbatim into `open_nvenc_probed`.

`VaapiEncoder` mirrors the NVENC hwframes pattern with AV_HWDEVICE_TYPE_VAAPI.
The CPU-input path swscales packed RGB -> NV12 (BT.709 limited, VUI signalled)
and uploads into a pooled VA surface (av_hwframe_transfer_data), preserving the
low-latency model (infinite GOP, on-demand forced IDR, async_depth=1, CBR when
the driver supports it). It works on a non-NVIDIA box with no capture changes:
the capturer already falls back to CPU frames when its EGL->CUDA importer can't
initialise (no libcuda).

Live-validated on a Radeon 780M (RDNA3): hevc/h264/av1_vaapi all encode,
HEVC/H264 decode cleanly with correct BT.709-limited colours, infinite GOP
preserved. Zero-copy dmabuf import (the high-res perf lever) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:35:49 +00:00
enricobuehler f48c739a9c fix build
improve iOS & iPadOS UI
2026-06-19 15:49:48 +02:00
enricobuehler 9d6ff77a02 docs(packaging/windows): note the host is x64-only (no ARM64)
The host is NVIDIA/NVENC + SudoVDA coupled; Windows ARM64 has neither an NVIDIA
driver nor an ARM64 SudoVDA, so an ARM64 host would install but couldn't encode
or make a virtual display. Document the deliberate x64-only scope so it doesn't
get re-litigated. ARM64 stays client-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:23:30 +00:00
enricobuehler 6e0507a9ee fix(packaging/windows): dodge WOW64 redirection — run ISCC on copies under C:\t
Root cause of the persistent ISCC "path not found": ISCC.exe is 32-bit, and the
self-hosted runner runs as SYSTEM, so the checkout lives under
C:\Windows\System32\config\systemprofile\.cache\... . WOW64 file-system
redirection rewrites a 32-bit process's System32 reads to SysWOW64 (where nothing
exists), so ISCC died opening the .iss before it even printed its version line.
(The smoke-test diagnostic compiled fine precisely because it lived at C:\t\out.)

Fix: copy every file ISCC reads (the .iss + host.env.example + README.md) into
the non-redirected build dir C:\t\out and compile from there; BinDir, StageDir,
and OutputDir already live under C:\t. Removed the now-spent smoke diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:27:19 +00:00
enricobuehler ca6ee4dd7f fix(packaging/windows): make the .iss pure ASCII (ISCC encoding failure)
The smoke-test diagnostic proved Inno itself is healthy (a trivial ASCII script
compiled), while the real .iss died before the "Compiler engine version" line —
i.e. at script open, not during compile. The difference: the real .iss was UTF-8
with non-ASCII chars (→, —) in comments, which ISCC 6.4+ rejects without a UTF-8
BOM (and the German-locale runner misreads). Replace them with ASCII (->, -).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:22:00 +00:00
enricobuehler 0d94944c41 diag(packaging/windows): isolate the ISCC "path not found" failure
All [Files] sources are validated-present yet ISCC still errors before any
"Compiling" output (no line number) — so it's startup/[Setup]-internal, not a
source path. Add an explicit [Languages] (compiler:Default.isl) to rule out the
auto-added default language, and on ISCC failure dump the Inno install dir +
run a trivial [Setup]-only smoke script to tell "Inno broken" from "my script".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:13:29 +00:00
enricobuehler 23ac174a50 fix(packaging/windows): pass installer source files as validated absolute defines
The {#SourcePath} relative-traversal for host.env.example/README kept tripping
ISCC ("path not found", error 2) regardless of the separator, so drop it: compute
the two paths absolutely in pack-host-installer.ps1, Test-Path them (clear PS error
if missing), and pass /DHostEnv + /DReadme. The .iss [Files] now reference the
absolute defines — no {#SourcePath}, no ..\.. traversal. Also prints "source ok"
for each so a future failure is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:44:30 +00:00
enricobuehler 67d58bfde3 feat(windows-client): cross-compile + ship ARM64 (aarch64) off the x64 runner
windows.yml + windows-msix.yml gain an x86_64/aarch64 target matrix. ARM64 is
cross-compiled on the one x64 Windows runner — the x64 MSVC toolset ships the
ARM64 cross compiler, aarch64-pc-windows-msvc is tier-2 with host tools, and
SDL3/libopus (build-from-source) cross-compile cleanly. The only arch-specific
external dep is FFmpeg's import libs: the matrix points FFMPEG_DIR at a per-arch
tree (x64 C:\Users\Public\ffmpeg, arm64 C:\Users\Public\ffmpeg-arm64, both
FFmpeg 7.x / avcodec-61). Per-arch short CARGO_TARGET_DIR avoids a shared target
dir; fmt + test run only for x64 (aarch64 can't execute on the x64 host).

pack-msix.ps1 gains -Arch x64|arm64 (stamps the manifest ProcessorArchitecture,
arch-suffixes the .msix/.cer); windows-msix.yml matrixes both arches and
publishes ..._x64.msix / ..._arm64.msix. setup-windows-runner.ps1 provisions the
rustup target + the ARM64 FFmpeg tree (idempotent).

Verified live on the runner (home-windows-1): debug+release cross-build green,
clippy -D warnings green, and MSIX pack produces a valid arm64 package (manifest
arch=arm64; bundled exe/SDL3/avcodec/reactor-bootstrap all PE machine 0xAA64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:24 +00:00
enricobuehler 60531a423c feat(host/windows): HDR scRGB→P010 in a shader — NVENC native P010, off the SM
On the Windows WGC HDR path the FP16 scRGB capture was fed to NVENC as
R10G10B10A2 (BT.2020 PQ), and NVENC did the RGB→YUV CSC internally on the
contended SM — adding to the encode_ms wall under a GPU-saturating game.
(NVIDIA's D3D11 VideoProcessor can't do RGB→P010 for HDR; that path renders
green, confirmed live — so the convert must be ours.)

New `HdrP010Converter` fuses the tone-map with the BT.2020 RGB→YUV matrix and
emits P010 (10-bit limited range) directly: a luma pass → an R16_UNORM plane
RTV (full-res) and a chroma pass → an R16G16_UNORM plane RTV (half-res, 2x2
box average) of a DXGI_FORMAT_P010 texture. NVENC then takes native P010 and
skips its SM-side convert.

Gated behind PUNKTFUNK_HDR_SHADER_P010 (default OFF → the existing
R10→NVENC path is byte-for-byte unchanged). Colour validated by a new
`hdr-p010-selftest` subcommand: a synthetic scRGB pattern → P010 → readback,
compared to a BT.2020 PQ 10-bit reference — max abs error Y=0.99 / Cb=0.82 /
Cr=0.75 codes on an RTX 4090. Live-validated HDR colours correct (no green).
Build + clippy (--features nvenc -D warnings) green on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:54:00 +00:00
enricobuehler 088c60cae1 fix(android): invoke cargo by absolute path in cargoNdk task
Gradle's Exec resolves command[0] via the JVM/daemon's inherited PATH, not
the environment("PATH", …) set on the task (that only reaches the spawned
child). A GUI Android Studio launch — and any daemon it starts — has no
~/.cargo/bin on its PATH, so a bare "cargo" fails with "A problem occurred
starting process 'command 'cargo''". Use the already-computed cargoBin
absolute path; the env PATH still lets cargo/cargo-ndk find their subtools.

Also refresh the README prereqs: add the missing cmake;3.22.1 SDK package
(the cmake crate builds libopus with it) and drop the broken
`brew --prefix openjdk@21` JAVA_HOME hint in favour of `java_home -v 21`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:09:44 +02:00
enricobuehler 1c67ce7a93 fix(rpm): derive the libcuda link stub from source (fixes undefined cu* symbols)
The Fedora RPM build linked punktfunk-host against a synthesized libcuda stub
with a FROZEN symbol list baked into ci/fedora-rpm.Dockerfile. The priority-
stream work added cuCtxGetStreamPriorityRange / cuStreamCreateWithPriority /
cuStreamSynchronize / cuMemcpy2DAsync_v2, which weren't in that list, so the
link failed with "undefined symbol".

build-rpm.sh now regenerates /usr/lib64/libcuda.so.1 from every cu* symbol the
host source references (grep of crates/punktfunk-host/src), before rpmbuild — so
a new cu* call can never silently break the link again. Self-maintaining and
needs no builder-image rebuild (it supersedes the Dockerfile's frozen stub).
Verified the 23 extracted symbols compile and cover the 4 that were undefined.

Also fix the bogus %changelog weekday (Sun -> Mon, Jun 15 2026 is a Monday) that
rpmbuild warned on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:29:16 +00:00
enricobuehler 778a11a76a fix(android): settings dropdowns trapped D-pad/controller focus
ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:20:55 +00:00
enricobuehler 693eedd314 ci(runner): cap the act_runner cache + 30-min prune (fix recurring disk-full)
The hourly docker-prune could never reclaim the real disk filler: the act_runner
cache server's blob store (cache.dir:"" -> /root/.cache/actcache/cache) lives in
the long-running runner container's WRITABLE LAYER, which docker prune can't see.
It grew to ~66 GB and filled the 125 GB disk on its own.

- New docker-prune.sh holds the logic (inline ExecStart= broke under systemd's
  own $-expansion, which emptied $SZ/$(...) before sh ran them — silently no-oping
  the burst guard). The unit now just calls the script.
- Caps the actcache: clears the blobs once they exceed ~20 GB (act_runner
  repopulates; keys are content-hashed, so only stale entries drop).
- Burst guard lowered 85%->80% and now also clears the actcache.
- Timer hourly -> every 30 min; image/cache `until` 12h -> 6h.

Live: cleared 66 GB on home-runner-1 (93% -> 20%), deployed + verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:53:08 +00:00
enricobuehler def681c54c fix(packaging/windows): add '\' after {#SourcePath} in the .iss [Files]
ISPP's {#SourcePath} has no trailing backslash, so {#SourcePath}..\..\scripts
resolved to ...\packaging\windows..\..\scripts (invalid component "windows..")
-> ISCC error 2 "path not found". Add the explicit separator (a double backslash
is harmless on Windows if a future ISPP ever adds the trailing one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:36:01 +00:00
enricobuehler 5e6b271410 fix(packaging/windows): vendor SudoVDA driver (no upstream release) + real nefcon URL
The first CI run failed only on the SudoVDA download: SudoMaker/SudoVDA has no
releases (source-only repo; Apollo embeds the driver in its installer), so there
was nothing to fetch. Vendor the prebuilt SIGNED driver in-repo instead.

- packaging/windows/sudovda/: SudoVDA.inf/.cat/.dll + sudovda.cer (derived from
  the .cat signer CN=sudovda@su.mk), pulled from the dev-box driver store.
  v1.10.9.289, Class=Display, HWID Root\SudoMaker\SudoVDA, MIT/CC0.
- fetch-sudovda.ps1 -> stage-sudovda.ps1: stage the vendored driver + fetch
  nefcon from its real pinned release (v1.17.40, sha256 812bae7e…, x64/nefconc.exe).
- pack-host-installer.ps1: call stage-sudovda.ps1; README updated with the
  driver-refresh recipe.

The rest of the pipeline already passed on the first run (host built --features
nvenc via the llvm-dlltool import lib; ISCC + signtool found; signed with the
real CN=unom cert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:22:52 +00:00
enricobuehler d186d88b34 perf(host/linux): NV12 GPU convert — feed NVENC native YUV, off the contended SM (Tier 2A)
The Linux zero-copy tiled-GL path can now produce NV12 (BT.709 limited range)
on the GPU and feed NVENC native YUV, deleting NVENC's internal RGB->YUV CSC —
which runs on the SM/3D-compute engine a saturating game pins at 100% (the
game-vs-encode contention headache). Windows already does this via the D3D11
video processor; this closes the Linux gap. See docs/host-latency-plan.md §2A.

Gated behind PUNKTFUNK_NV12 (default OFF → the RGB/BGRx path is byte-for-byte
unchanged; zero regression). Only the tiled EGL/GL path converts; the
LINEAR/Vulkan-bridge (gamescope) path stays RGB.

- zerocopy/egl.rs: Nv12Blit — BT.709 limited Y pass (R8, full-res) + UV pass
  (RG8, half-res, GL_LINEAR 2x2 average); both CUDA-registered; import_nv12.
- zerocopy/cuda.rs: two-plane DeviceBuffer (Y W*H@1B + interleaved UV
  (W/2)*2 x H/2), paired Y+UV pool, copy_mapped_nv12 + copy_nv12_to_device,
  on the per-thread priority stream (dmabuf-recycle sync preserved).
- encode/linux.rs: nvenc_input(Nv12)->NV12; submit_cuda copies two planes into
  NVENC's surface; VUI signalled BT.709 limited (colorspace/range/primaries/trc).
- capture/linux.rs: gate (PUNKTFUNK_NV12 && tiled), report format Nv12.
- main.rs + zerocopy/mod.rs: `nv12-selftest` subcommand.

Validated on RTX 5070 Ti two ways: (1) nv12-selftest — synthetic RGBA->NV12
round-trip vs a BT.709 reference, max abs error Y=0.56/U=0.33/V=0.26 LSB;
(2) live capture->NV12->NVENC->decode of animated red content matches the RGB
path's colour (avg RGB 230,18,18 vs 231,18,20). build/clippy/fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:39:11 +00:00
enricobuehler d364a4ee7c fix(windows-client): clear clippy -D warnings on MSVC
The cfg(windows) code can't be lint-checked on the Linux dev box, so three
-D warnings slipped through (caught by windows.yml; the FFI + shaders compiled
fine):
- gpu.rs: SetMultithreadProtected returns a must-use BOOL -> `let _ =`.
- video.rs: drop the unused GpuFrame::ten_bit field (present keys off `hdr`;
  the value is still computed locally for the first-frame log).
- present.rs: GpuView::frame is an RAII keep-alive (its Drop returns the decoder
  surface to the pool), never read -> #[allow(dead_code)].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:21:18 +00:00
enricobuehler ead3f8a306 feat(windows-client): D3D11VA zero-copy hw decode + HDR10 present + GUI polish
The client was pure software HEVC decode + CPU swscale->RGBA + a full-frame
dynamic-texture upload every frame -- the reason performance was poor on a GPU
box (the GPU sat idle while the CPU churned). This adds a hardware path, HDR,
and a GUI pass.

Performance -- D3D11VA zero-copy:
- gpu.rs (new): one D3D11 device (hardware + VIDEO_SUPPORT, WARP fallback,
  multithread-protected) shared by decoder and presenter via a Send/Sync
  OnceLock. Sharing is mandatory -- a decoded texture is only bindable on the
  device that created it. windows-rs COM interfaces are !Send/!Sync, so the
  unsafe impl is sound only under the multithread protection + disjoint
  decode(video ctx)/present(immediate ctx) split.
- video.rs: D3d11vaDecoder (raw FFI mirroring the Linux VAAPI module). The
  COM-typed AVD3D11VA{Device,Frames}Context are declared here (stable FFmpeg
  ABI) to avoid ffmpeg-sys binding the d3d11 headers; get_format builds a frames
  ctx with BindFlags=SHADER_RESOURCE so the NV12/P010 array slices are
  sampleable. av_frame_clone guard keeps each surface out of the reuse pool
  until the presenter drops it. Software decode stays as the fallback
  (DecoderPref Auto/Hardware/Software; auto falls back on init/decode error).
- present.rs: shared device; per-plane SRVs over the array slice
  (NV12->R8/R8G8, P010->R16/R16G16) + three pixel shaders (RGBA passthrough,
  NV12/BT.709, P010/BT.2020-PQ). present() now takes the frame by value so the
  GPU surface survives re-presents.

HDR:
- Detected in-band (transfer == SMPTE2084), same signal as the other clients.
  Swapchain flips to R10G10B10A2 + ST.2084 + HDR10 metadata. New Settings toggle
  gates advertising VIDEO_CAP_10BIT|HDR; host still gates 10-bit behind its own
  PUNKTFUNK_10BIT + actual-HDR-content checks.

GUI (windows-reactor):
- Host cards with accent-monogram avatars + colored status pills, InfoBar for
  errors/pairing hints, ToggleSwitch settings (+ HDR, decoder, bitrate), button
  icons, a richer connecting screen, and a stream HUD with GPU/CPU-decode + HDR
  status chips.

Not yet on-glass validated: the Linux dev box can't compile the cfg(windows)
code (ffmpeg/windows crates unfetched; WARP has no hw decode) -- only
cargo fmt checks it here. API shapes verified against the windows-rs/reactor
source and the YUV->RGB coefficients checked by hand, but D3D11VA + shaders +
the GUI need a real build (Windows CI / build VM) and on-glass test on the RTX
box. The host-side HDR encode path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:16:07 +00:00
enricobuehler dc1e1bbc72 feat(android): D-pad / game-controller focus navigation (TV + phone)
Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
  understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
  focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
  stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
  D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
  subtle across a room on TV).

All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:14:29 +00:00
enricobuehler ea84c83785 perf(host): latency hardening for the game-vs-encode GPU contention collapse
Verified, prioritized analysis in docs/host-latency-plan.md (multi-agent
investigation + adversarial verification). Lands the two low-risk tiers:

Tier 2B — Linux scheduling hygiene:
- boost_thread_priority now nices the capture/encode (-10) and send (-5)
  threads on Linux (setpriority, best-effort; no-op without CAP_SYS_NICE),
  and the wrong "gamescope caps the game" doc-comment is corrected.
- CUDA context created with CU_CTX_SCHED_BLOCKING_SYNC (frees a core on the
  shared box instead of busy-spinning on completion).
- Copies moved off the default stream onto a per-thread highest-priority
  CUDA stream (cuStreamCreateWithPriority, graceful NULL-stream fallback)
  with a per-stream sync that no longer blocks on the other worker thread's
  in-flight copies. Stream priority is measure-then-keep (NVIDIA Linux may
  ignore it); never regresses.

Tier 3A — Windows session tuning (new session_tuning.rs, raw C-ABI FFI,
no-op off Windows): once-per-process 1ms timer + DwmEnableMMCSS + HIGH
priority class; per-thread MMCSS "Games" + keep-display-awake. Wired into
both the native (boost_thread_priority) and GameStream (stream.rs) paths.
We had zero session tuning before (Apollo streaming_will_start parity).

Tier 2A (Linux NV12 convert) is specified but intentionally not landed:
it is colour-correctness-critical and needs A/B validation on a GPU box
with a display (green-screen risk). Builds + clippy + fmt green on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:05:57 +00:00
enricobuehler c75fcbd416 feat(packaging): signed Inno Setup installer for the Windows host + CI
MSIX (the client's format) can't install the host's LocalSystem secure-desktop
service or the SudoVDA kernel driver, so the host ships as a signed Inno Setup
setup.exe that runs elevated and delegates to the existing idempotent
`punktfunk-host service install`.

- packaging/windows/punktfunk-host.iss: lay exe into Program Files, optional
  SudoVDA driver task, run service install/start; [Code] stops+waits the service
  before file copy on upgrade; uninstall runs service uninstall.
- pack-host-installer.ps1: cert (reuses MSIX_CERT_PFX_B64 / self-signed CN=unom),
  sign inner exe + setup.exe, fetch/stage SudoVDA, run ISCC, export public .cer.
- fetch-sudovda.ps1 / install-sudovda.ps1: pinned SudoVDA + nefcon download, cert
  import, gated device-node create (no phantom dup), pnputil install (warn-not-abort).
- nvenc/: synthesize nvencodeapi.lib via llvm-dlltool from a 2-export .def so
  --features nvenc links with no GPU/SDK at build time.
- .gitea/workflows/windows-host.yml: build (nvenc) -> clippy -> ISCC -> sign ->
  publish setup.exe + .cer to the generic registry pkg punktfunk-host-windows.
  Tag host-win-v* -> X.Y.Z (+ latest/ alias); main push -> rolling 0.2.<run>.
- setup-windows-runner.ps1: provision Inno Setup; docs: installer instructions.

SudoVDA/nefcon release URLs+SHA-256s in fetch-sudovda.ps1 are placeholders
(baseline v0.2.1) — fetch warns + prints the computed hash until pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:05:20 +00:00
enricobuehler ae14b13dd5 fix(decky): scrub PyInstaller LD_LIBRARY_PATH before spawning system flatpak
Decky Loader is a PyInstaller binary; it puts its bundled (older) libssl/libcrypto
on LD_LIBRARY_PATH via its /tmp/_MEI* unpack dir, and that env leaked into the
backend's `flatpak run`/`flatpak kill` subprocess. The SYSTEM flatpak's libcurl
+ libostree need newer OPENSSL symbols (3.2/3.3/3.4), so pairing failed with
"libssl.so.3: version OPENSSL_3.3.0 not found". _flatpak_env() now restores
each LD_*_ORIG PyInstaller saved, or drops the var, so the system loader uses
system libs. Reproduced + verified on the Deck (SteamOS 3.8.10, Flatpak 1.16.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:03:39 +00:00
enricobuehler 9aef991b54 polish(android): grouped Settings cards + ConnectScreen error banner & search indicator
- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
  cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
  (was plain red text lost in the layout), and a "Searching the local network..."
  spinner appears while discovery is active but nothing's turned up yet.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:58:35 +00:00
enricobuehler e6988edd0d fix(android): crash on back-while-streaming (UAF) + Material You theme & connect polish
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).

Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
  brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
  neutral spinner while connecting, red reserved for actual errors.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:51 +00:00
enricobuehler 1d78c29129 fix(android): DataSpace impls Display not Debug — use {ds} in HDR logs
ndk's DataSpace derives Copy/PartialEq/Eq and impls Display (no Debug), so the
{ds:?} in the HDR dataspace log statements wouldn't compile under cargo-ndk.
Host clippy can't catch it — decode.rs is android-gated. Switch to {ds}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:13:20 +00:00
enricobuehler f4c0e7e805 fix(flatpak): sign the OSTree commit, not just the summary
Install failed with "GPG verification enabled, but no signatures found" on the
commit: the deploy step only ran build-update-repo (signs the summary). Add
`flatpak build-sign` to sign the commit objects too — clients with
gpg-verify=true verify the commit, so summary-only signing isn't enough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:10:07 +00:00
enricobuehler 8da8037b35 feat(android): HDR (Main10 / BT.2020 PQ) + fix ndk feature gating
Mirrors the Apple client's HDR path so the Android client can display HDR from a
Windows HDR host:
- nativeConnect now advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR (was 0), so the
  host upgrades to a Main10 / BT.2020 PQ encode.
- decode.rs detects HDR reactively from the decoder's reported output colour
  (color-transfer ST2084=6 / HLG=7, color-range) -- the AMediaCodec analogue of
  VideoToolbox's format description on Apple -- and signals the Surface dataspace
  (Bt2020[Itu]Pq / Bt2020[Itu]Hlg) so the compositor/display switch to HDR.
  AMediaCodec decodes Main10 from the in-band SPS; no profile override needed.

Also fixes the Android build: set_frame_rate (added in e9de730) is gated on the
ndk `nativewindow` + `api-level-30` features, which weren't enabled -- so that
commit could not compile under cargo-ndk. Enable
features = ["media","audio","nativewindow","api-level-31"] (minSdk 31): covers
set_frame_rate (api-30), set_buffers_data_space + the DataSpace module (api-28),
and ANativeWindow (nativewindow).

Verified host-side: fmt --all + clippy --workspace (the caps advertise + JNI
surface). The android-gated decode + NDK gating verified against the ndk 0.9
sources; android.yml (cargo-ndk) is the compile gate, and real HDR display needs
an HDR device + Windows HDR host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:09:54 +00:00
enricobuehler 5d2a1c64e4 docs(install-client): real TestFlight + Google Play links
Apple is TestFlight-only (no App Store) — link the join URL; drop the App Store
placeholder. Add the live Google Play listing for io.unom.punktfunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:07:08 +00:00
enricobuehler 86abd5b298 docs: add an "Install a Client" page covering every client + install path
Per-device install steps in one place: Linux (Flatpak via flatpak.unom.io +
native apt/rpm/Arch), Steam Deck, Windows (signed MSIX from the registry),
macOS (notarized DMG from releases), and iOS/Android (store/beta links). Adds
it to the Connecting nav and cross-links clients.md, whose Linux/Flatpak bullet
now points at the hosted flatpak.unom.io repo instead of the bundle README.

Mobile store/TestFlight URLs are placeholders pending the public listings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:02:02 +00:00
enricobuehler e9de730377 feat(android): live stats HUD + low-latency decode tuning
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.

Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
  (no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
  background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.

Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:49:29 +00:00
enricobuehler 8b87274958 fix(flatpak): pass stable branch to build-bundle (matches --default-branch)
The CI added --default-branch=stable, so the repo ref is
app/io.unom.Punktfunk/x86_64/stable. build-bundle defaults to `master` when no
branch is given → "Refspec app/io.unom.Punktfunk/x86_64/master not found". Pass
`stable` explicitly in both flatpak.yml and the local build-flatpak.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:48:17 +00:00
enricobuehler 26084969a9 fix(apple): disarm CHHapticEngine handlers with no-ops, not nil
stoppedHandler/resetHandler are non-optional closures on the CI SDK
((StoppedReason)->() and ()->()), so assigning nil fails to compile
(apple.yml). Assign no-op closures to disarm them before engine.stop()
-- same re-entrancy guard intent, type-correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:20:37 +00:00
enricobuehler c358b510e5 feat(flatpak): host a signed OSTree repo at flatpak.unom.io for flatpak update
The CI only shipped a single-file .flatpak bundle, which has no remote — users
couldn't `flatpak update`. Keep the bundle (Decky fallback) but also sign the
OSTree repo flatpak-builder already produces and publish it to a shared,
reusable unom-wide remote.

- flatpak.yml: pin --default-branch=stable; import the signing key and
  build-update-repo --gpg-sign; generate unom.flatpakrepo + the app .flatpakref
  + index.html; rsync the repo to unom-1 and bring up a static Caddy container.
  The step no-ops until FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* exist (build stays green).
- packaging/flatpak/server/: compose.production.yml + Caddyfile (static file
  server on :3230, mirrors docker.yml deploy-docs).
- unom-flatpak.gpg: committed public signing key (base64 -> GPGKey= in the descriptors).
- README: hosted repo is now the recommended install; documents the one-time
  infra (edge Caddy vhost, infra port 3230, DNS, the GPG secret).

Edge Caddy vhost + infra port allowlist + the secret are applied out-of-band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:07:27 +00:00
enricobuehler 2f2147b093 refactor: drop milestone names + consolidate clients; loss-recovery & rumble fixes
Two bodies of work in one commit (the rename moved files the fixes also touched).

Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
  m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
  Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
  clients/probe (crate punktfunk-probe), client-linux->clients/linux,
  client-windows->clients/windows, punktfunk-android->clients/android/native
  (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
  contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
  kept only in docs/implementation-plan.md. docs/m2-plan.md->
  docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.

Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
  tracked it for the client keyframe-recovery loop; it was never reachable from
  the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
  frames_dropped and request a keyframe when it climbs -- the same loss-driven
  recovery Linux/Windows already had. Under infinite GOP the decoder silently
  conceals reference-missing frames, so the decode-error trigger rarely fires.

Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
  interruption / server reset) and drop the permanent `broken` latch on a
  transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.

Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:58 +00:00
enricobuehler 24de523916 ci(android): replace r0adkll with a direct Play Publishing-API upload
r0adkll/upload-google-play hides real API errors behind "Unknown error
occurred." Proved the full upload sequence (insert edit -> upload bundle ->
track update -> validate) succeeds with the service account, so the failure was
r0adkll's opaque error handling and/or a base64-encoded SERVICE_ACCOUNT_JSON
secret.

clients/android/ci/play-upload.py does the same sequence with stdlib + openssl
(no pip), reuses the SERVICE_ACCOUNT_JSON secret, tolerates it being raw JSON or
base64, auto-retries commit with changesNotSentForReview, and prints Google's
actual error. Locally dry-run-validated against the live app (both secret forms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:38:20 +00:00