Commit Graph
100 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 586da1451c feat(client/apple): one sheet for a profile, with the colours visible
The create/update flow was four menu items, three bare text alerts and a submenu of
colour NAMES with no colour anywhere on it. Making a "Work" profile meant: name it in
an alert, find it again in the scope menu, open a submenu, and pick "Amber" on faith.

A profile is a name and a colour, so they are decided together now — in one editor
sheet that serves create, duplicate and edit, with a live chip at the top showing
exactly what the host cards will render. The palette is swatches you can see, in a
grid, with a checkmark AND a ring on the chosen one (the tick alone washes out on the
pale hues) and a 44pt target under each 30pt dot. Default leads the row, so "no
colour" is a choice on the same shelf as the rest rather than the absence of one.

Duplicate opens the sheet rather than committing on the spot: it arrives carrying the
source's colour and overrides with a free name filled in, so it can be renamed before
it exists rather than after. Creating lands you in the new profile — being left on
the defaults is how you end up editing the wrong layer.

Delete moved into the sheet with the rest, and its warning is where the button is:
the count of hosts and pinned cards that will change sits under it before you press
it, not only in the confirmation after.

The name's uniqueness check now reports inline and in red instead of through a
disabled button and an alert message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 f50d13752d feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it
answered the one question nobody opens About to ask. It is now the shape every Mac
user already knows: the app's icon, its name, the version (selectable, because a bug
report is worth more with it), the tagline, and then the ways out — documentation,
community, source, licenses. The license wall is still `AcknowledgementsView`, one
push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation
stack to push onto.

Every platform hides the app icon somewhere different and tvOS can't hand it over at
all (layered assets, no single image), so there's a drawn fallback in the same brand
gradient and Geist monogram as the host cards — where the real icon is unavailable it
reads as a mark, not as a broken image. tvOS also has no browser: the addresses are
text to read off the screen rather than links to nowhere.

And profile colours were half-built by me: the catalog stored `accent`, the chips
tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸"
over a fixed palette — the chip is small tinted text on a tinted capsule, and a
colour picked freehand lands somewhere unreadable often enough to matter. The palette
is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform
writes still renders, which is what Android needs from this field.

The colour now shows wherever the profile is named — the scope switcher's dot, the
card chips, and the HUD's session line, which tints to the same colour the card that
launched it wore. A colour is an identifier, and an identifier that changes between
surfaces isn't one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 8b17e72af0 fix(client/settings): one marker per override, footers at footer size, air under Reset
The resolution marker hung off the Match-window toggle, which read as if that toggle
alone were overridden. Match-window, width and height are ONE override — they reset
together — so the marker belongs to the group, under the size control that ends it,
not to the first of the two rows that write it.

The new section footers inherited the app's 17pt body font, so the profile picker's
description sat visibly larger than every other description in Settings. Same style
as the footers that were already there.

And the marker carries a bordered button, which needs more air under the control
above it than a line of text does — most visible under the segmented refresh picker.
Added inside the marker, so every row that shows it is spaced alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 e9abc1a61f fix(client/settings): Reset belongs on the row's edge, not in the caption's column
The override marker sat inside the caption's width rule, so its Reset stopped where
the text stops — stranded mid-row, short of the switch it undoes. The rule is for
TEXT: Reset is a control, and it lines up with the row's own control above it. The
marker now spans the cell and only the caption is capped and inset.

The caption's reserved column widens with it (60 → 76): it should stop visibly short
of the control, not graze it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 c1d2efe112 fix(client/apple): the Name field didn't say it was the name either
Same cause as the MAC one, and it deserved the same fix rather than another
one-off: on iOS a TextField's title becomes an accessibility label the moment a
prompt exists and nothing is drawn, so "Optional — e.g. Living Room" was the whole
of what the field said about itself.

The prompt is now built per platform. macOS draws the title as a leading label, so
its prompt only hints at the value; iOS has no label to lean on, so the prompt names
the field too. One string for both leaves you either a Mac panel that says
everything twice — which is what the MAC field started doing in the last commit — or
a phone form of unlabelled boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 34a9e32bf8 fix(client/apple): put the add-host sheet back, and fix the MAC field where it's read
The footer was the wrong instrument and I should have checked what the screen
actually shows before adding one. On iOS a field's title becomes its accessibility
label once a prompt is given, so "MAC" was never on screen — the PROMPT was, and
"Wake-on-LAN — auto-filled when known" read as if that were the value being asked
for. It now says what the field is: "MAC address (for Wake-on-LAN, optional)". No
explanatory paragraph under the group.

Height and scrolling go back to what they were: a 392pt sheet sized to its content
with nothing to scroll. The edit sheet's profile rows are the only thing that can
outgrow that, and only they extend the detent and turn scrolling back on — a single
fixed number is what clipped them in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 282d691b76 fix(client/apple): give the add-host sheet back to adding a host
Adding a host is about reaching it: where it is, and whether we can wake it. Which
settings it streams with is a decision about a host you already have. Stacking the
profile binding and the pin toggles onto the add flow made the first thing a new
user meets a longer form than the one they came for — those rows are edit-only now,
and editing is one context-menu item away.

The pins lost their disclosure with it. A collapsed group had to animate its own
height AND the sheet's, and got both wrong — no transition, then a clipped list.
With a profile or three these are a couple of rows, and rows that are simply there
can't fail to expand.

"MAC" named the value and said nothing about why the field is there, while
"Wake-on-LAN" sat in the placeholder as if it were something to type in. The label
is "MAC address" and the section footer says what it buys: waking a sleeping host,
filled in by itself once the host has been seen. Same relabelling on tvOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 1d51d83acf fix(client/apple): the add-host sheet was Mac-sized on iPhone, and clipped its own pins
Two things, one cause: the sheet was written as a Mac panel and iOS inherited it.

The 12pt Geist and `.controlSize(.small)` exist because a grouped form's system text
reads oversized next to the app's typography in a Mac panel. Applied to iOS as well,
they made this the one sheet in the app you had to squint at — a touch target and a
field label there are not a Mac panel's. macOS keeps them; iOS keeps the app's body
size.

And the sheet's height was a single hardcoded number with scrolling switched off, so
expanding "Pinned cards" grew content into a height nobody recomputed and the toggles
were simply clipped — the disclosure looked broken because there was nowhere for it
to go. The height now follows what the sheet is showing (base fields, the picker when
profiles exist, the toggles while expanded, which is why the disclosure's expansion is
bound state now), scrolling is back on so nothing can be stranded, and `.large` is
offered alongside for the accessibility text sizes these estimates won't cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 cf55a11174 fix(client/settings): captions ran under the switch on iPhone
The `described` idiom capped its caption at 360pt, which was only ever about
READING — past roughly 46 characters a line stops scanning well on a wide Mac window
or an iPad detail pane. On an iPhone the cell is narrower than the cap in the first
place, so the cap did nothing and every caption laid out to the full cell width,
running its last line straight under the row's switch.

`CaptionWidth` now carries both limits: the reading cap, plus an iOS trailing inset
that reserves the control column (a `UISwitch` is 51pt, so 60 with room). Order
matters — the inset shrinks what the text is offered, then the cap applies, so a
narrow phone cell clears the switch and a wide pane still caps.

One rule in one place: the override marker and the iOS resolution wheel's caption
each carried their own copy of the 360, and neither would have grown the inset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 9385b61ac2 fix(client/apple): the scope switcher was a Mac control dropped into an iPhone list
One chrome served both platforms and only worked on one. In the iOS settings list
the macOS shape — a bordered `.menuStyle(.button)` with the caption stacked under it
— rendered as neither a row nor a control: icon only, no label, and four lines of
wrapped caption making the first thing on the screen a block of text.

The menu CONTENTS stay one definition; the chrome is now per platform. macOS keeps
the button menu heading the preferences window. iOS gets a standard value row —
"Editing" on the left, the current layer on the right, the system's up/down chevron
— with the caption where a caption belongs in a grouped list: the section footer.

The two prompts the menu can raise moved into a shared `profilePrompts` so both
chromes carry them without a second copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 afb33d510f fix(client/apple): anchor the profile chip to the card's trailing edge
On the title line it sat immediately after the host name, so it read as part of the
title rather than as a badge on the card. A spacer between them anchors it to the
trailing edge, and the text column now fills the card — without that it hugs its
content and "trailing" only ever means "just after the name".

The spacer is also what keeps the two apart: the host name truncates against the gap
instead of running into the chip, and the chip keeps layout priority because a
truncated host name still reads while a truncated profile name is the one thing the
chip exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 82f5962bec fix(client/apple): three things the first on-glass run found
Reset read as prose. Next to "Overrides Default settings" in the same tint and the
same size, the one action that can undo an override looked like the third word of
the notice. It is a bordered control now, pushed to the far edge of the caption
line with an undo glyph.

The scope switcher showed above the About tab. It sat over the whole macOS TabView,
so on the acknowledgements page — which edits nothing — it read as belonging to
them. The tabs are tagged and the switcher sits that one out.

Pinned cards were taller than their host's. The profile chip had a line of its own,
so a card with a profile and one without were different heights and a pin stuck out
of its grid row. The chip rides the title line instead, which is also where it reads
as "this card connects with that". Prominence is fill and weight only — a chip with
a bigger type size would have brought the height difference straight back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 0a32f4eab1 fix(client/apple): the iOS slice hit the type-checker wall, and tvOS couldn't be checked at all
`ContentView.body` grew two modifiers and stopped type-checking on the iOS slice —
"unable to type-check this expression in reasonable time", a failure macOS builds
never show and the one Apple CI never builds. Split into the screen plus its
lifecycle drivers, then the prompt chain, with each alert's presentation Binding
lifted out the way the deep-link one already was.

tvOS couldn't be checked from the command line at all: HomeView imports the slide
transition, which ships with the Xcode project only because its manifest breaks
SwiftPM's whole-graph validation. `canImport` instead of a bare `os(tvOS)` gate
makes the tvOS sources compile without it, and the app still gets the transition
because there the module is present. Both slices now typecheck by hand.

The scope switcher is tvOS-gated with them: a name prompt and a nested management
menu are not what a remote does well, and §5.4 keeps profile EDITING off
controller-first surfaces in v1 — they honor bindings and render pinned cards.

Also: release the session's settings latch when a connect fails, is refused, or is
abandoned mid-handshake. Only `disconnect` cleared it, so a failed dial left the
would-be session's resolution latched over the globals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 25b127803f feat(client/apple): bind a host to a profile, connect with one, pin one as its own card
The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says
what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never
rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as
its last item for the users who do want to rebind from there — plus "Pin as Card ▸"
and "Copy Link".

A pinned host+profile combo becomes its own card next to its host: same record,
same live status, the profile as the prominent subtitle, one click to connect. It
is an extra grid ENTRY, never a duplicated host record — duplicating would fork
pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions;
edit, pair, forget and remove stay on the primary card, where the thing they act on
lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole
point: focus-and-press is what those surfaces do well, and menus are not.

The edit sheet binds and pins; both rows vanish when no profiles exist, so a user
who never makes one sees exactly today's sheet. A dangling binding renders as
"Default settings (profile deleted)" and is cleaned up on save.

The speed test finally writes where the tested host reads. Unbound: the global, as
before. Bound to a profile that overrides bitrate: that override. Bound to one that
inherits it: both are offered, because either is defensible and guessing would
silently pick one. Every button names its target, and the probe now runs at the
mode that host would actually stream — the measurement is the streaming path.

Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter
on Connect, still round-tripping through the URL router rather than opening a second
connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and
tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases
provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link
observer moved out with them: an intent that posts to nobody is a shortcut that
silently does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 3ae9b83290 feat(client/apple): the settings screen edits profiles, and says which rows it changed
One settings surface, two layers. A scope switcher at the top of the Mac window and
the iOS sidebar swaps the whole screen between Default settings and one profile's
overrides; the section builders stay the single definition of every row and just
change which layer their binding reads and writes (design §5.1). A parallel profile
editor would have drifted from this one field by field, and the revamp's captions
and curation would have had to be written twice.

`SettingsFields` is where a row's three faces meet — the UserDefaults key its global
lives under, the overlay slot an override lives in, and the name a reset carries.
Keeping them in one place is what stops a row from writing an override the reset
button can't find.

Every row shows the EFFECTIVE value, so an untouched row reads as the live global.
Touching a control records the override — always, even when the new value equals
today's global, because that is a pin and the profile must keep it when the global
later moves. Nothing is inferred by diffing at save time.

Overridden rows say so (accent dot + "Overrides Default settings") and carry the
only way back: an explicit Reset. That pair is not garnish. The model deliberately
never infers "not overridden" from a value comparison, so without a reset affordance
a profile is a one-way door.

Tier-G and tier-H rows don't render in profile scope at all — this device's speaker,
microphone and channel, its pointer capture and haptics, the HUD corner, the library
switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which
is about a host and a network rather than about Game vs Work. Sections that would be
left empty (Session off macOS, Library) collapse instead of rendering a bare group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 858cf0e535 feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
The Apple half of settings profiles (design/client-settings-profiles.md §4) starts
with the thing the desktop shells got for free from the shared Rust core: a model,
and one place that resolves it.

`SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles`
field for field, unknown-key carry-through included — an older build that opens a
profile a newer one wrote must not gut it on save. The catalog lives in the App
Group suite beside the saved hosts, because the things that point at it are fields
on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both
optional and appended last, because that JSON is a widget contract.

`EffectiveSettings` is the resolution — globals with the session's overlay on top,
computed once at connect and latched in `SessionSettings` for the rest of it. That
latch is the point. Ten sites across the app AND the kit read `UserDefaults`
directly mid-session (the presenter's priority and VRR, the vsync flip, the
match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4,
the audio endpoints); a profile that reached some of them and not others would be
worse than no feature at all. They now read the latch, which off-session is the
plain globals — byte for byte what they saw before.

The deep-link grammar grows up with it: `DeepLink` becomes a full port of
`deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`,
and every refusal code — and the Swift suite now runs
`clients/shared/deeplink-vectors.json` itself, read from the source tree so there
is no second copy to drift. All 44 cases green. The router refuses what it can't
honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that
contradicts the pin. A shortcut that streams with the wrong settings is worse than
one that explains itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehler 9b00ad6658 fix(client/windows): prove the deep-link module the new deny caught
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m1s
apple / swift (push) Successful in 4m46s
android / android (push) Failing after 6m31s
deb / build-publish-host (push) Failing after 5m29s
ci / rust (push) Failing after 7m33s
arch / build-publish (push) Failing after 7m35s
ci / bench (push) Failing after 7m30s
deb / build-publish (push) Failing after 6m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
ci / rust-arm64 (push) Successful in 10m5s
deb / build-publish-client-arm64 (push) Successful in 7m48s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m5s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 8m30s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 5m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 14m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 14m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m49s
release / apple (push) Successful in 29m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m58s
apple / screenshots (push) Successful in 23m16s
`deeplink.rs` arrived with c27065c2 (punktfunk:// handling) while this branch was in flight, so its
eight `unsafe` blocks predate the crate's `deny(clippy::undocumented_unsafe_blocks)` and only became
visible on the rebase. This is the ratchet doing its job on brand-new code, and it is also the whole
argument for turning the convention into a lint: the module was written correctly and documented
prosaically, but nothing had required a proof at each block.

The one worth reading is the `WM_COPYDATA` handler, which dereferences an `lparam` from ANOTHER
PROCESS and builds a `u16` slice from the sender's pointer and length. What makes that sound is not
that the sender is trusted — anyone can post `WM_COPYDATA` — but that the OS marshals both the
struct and its buffer into this process and keeps them valid for the handler's duration, and that
`len` is `cbData / 2` so the slice cannot outrun the copy even for an odd `cbData`. The proof says
that, so the next reader knows which half of it is a guarantee and which is just a tag check.

⚠ Caught only because Windows was re-verified AFTER the rebase. A Linux-only check was clean —
`deeplink.rs` does not exist there — so pushing on that evidence would have re-broken Windows CI,
which is the same mistake as the `warn`-that-was-really-`deny`, one rebase later.

Verified: Windows .47 full CI clippy set + the Windows-only crates, rc=0, pf-capture's 18 tests
pass; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 08:54:12 +02:00
enricobuehler bcfb833ff7 fix(abi): the panic boundary was documented as universal and wasn't
`abi.rs`'s header states "panics never cross the boundary: every entry point is wrapped in
`catch_unwind`". Of its 78 `extern "C"` entry points, 16 were not. Ten of those are fine and were
always fine — `punktfunk_abi_version` returns a constant, and the nine `punktfunk_connect*` shims
forward every argument unchanged into a guarded implementation — but five ran real code bare:
`session_free`, `connection_close`, `connection_disconnect_quit`, `reanchor_gate_free`,
`reanchor_gate_arm`.

The three `*_free`/`close` ones are the point. They run `Drop` for an entire `Session` or
`Connection` — transports, threads, mutexes — and a `Drop` impl that unwraps a poisoned lock panics.
Since Rust 1.81 that unwind is a hard abort rather than undefined behaviour, so this is not a
soundness hole; it is worse-behaved than it looks. Aborting the CALLER'S process because one of our
teardown paths hit a poisoned mutex is not an acceptable failure mode for a library, and it would
present as "the app died in punktfunk_session_free" with no Rust backtrace to explain it.

Adds `guard_void`, the sibling of `guard` for entry points with no status to report through: it
catches, logs, and returns — right for teardown, where the object is going away regardless. The five
are wrapped in it.

The header now says what is actually true, including WHICH entry points are deliberately bare and
why, so the next reader can check the claim instead of trusting it. That is the same failure this
sweep found in `MappedView`'s `Sync` proof: a stated invariant that a reviewer would rely on and that
had quietly stopped holding.

Verified: Linux .21 fmt + both CI clippy steps rc=0, and the C ABI harness — which exercises the
`*_free`/`close` paths it touches — still passes, 4 frames byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehler aa070f2d55 feat(ffi): hand-mirrored C structs are now layout-checked at compile time
The sharpest memory-safety risk left in this codebase is not an `unsafe` block — it is a
hand-written `#[repr(C)]` mirror of an external C struct. Get a field offset wrong and nothing fails
to compile and nothing reliably crashes: the library reads a pointer, a length or a pitch out of the
wrong bytes. Eleven such structs across five files had NO check at all.

Guarded here, each next to the struct it protects:

* `AVCUDADeviceContext`, and `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext`. `ffmpeg-sys-next`
  binds none of them, so these mirrors are the only definitions — and we WRITE through them
  (`cuda_ctx`, `device`, `bind_flags`). ⚠ The D3D11VA pair is duplicated VERBATIM in two crates
  (pf-encode's `ffmpeg_win.rs`, pf-client-core's `video_d3d11.rs`) because neither can depend on the
  other; they must agree with libav and with each other, and now a drift in either is a build error.
* The six cuda.h structs. Three were already asserted — but only in `#[cfg(test)]`, so the check ran
  when someone ran the tests and never in a release build. They are `const` now. The other three,
  including `CUDA_MEMCPY2D` which is filled on EVERY zero-copy frame, had nothing.
* `MsghdrX`, Darwin's `msghdr_x`, which `libc` does not expose. Its layout is not reviewable by eye:
  the 32-bit fields force padding before each following pointer, so `msg_iov` sits at 16 and not 12.
  `sendmsg_x`/`recvmsg_x` take the pointer and length from it.
* `IPolicyConfigVtbl` — the sharpest of the set. It mirrors an UNDOCUMENTED COM interface, and
  `set_default_endpoint` is called by SLOT INDEX through a ten-entry `_reserved` gap that carries no
  names to anchor a review. A field added or resized above it does not break the build; it calls a
  different function pointer through a mismatched signature.

Every assertion is `const _: () = assert!(..)`, so it holds on every build including release and
cannot be skipped. The compiler verified the numbers — the sizes and offsets asserted here are the
ones the target actually produces, on each platform that compiles the struct.

Verified: Linux .21 fmt + both CI clippy steps rc=0 (CUDA + libav CUDA mirrors); Windows .47 full CI
clippy set rc=0 + pf-capture tests (D3D11VA pair, COM vtable); macOS `cargo check -p punktfunk-core`
(MsghdrX — the only platform that compiles it).
2026-07-29 08:48:42 +02:00
enricobuehler 8a5a5edc37 fix(small crates): the proof lint now covers every first-party crate but one
Six crates were still unguarded, and all six are OURS — none vendored: pf-console-ui, pf-gpu,
punktfunk-tray, clients/windows, tools/display-disturb, wdk-probe. Two of them ship (the tray and
the Windows client), so "small" was about item count, not exposure.

Five are closed here. Only 17 of their 75 unsafe items actually lacked a proof — pf-gpu,
punktfunk-tray and display-disturb were already fully documented and needed nothing but the deny,
which is the good case: the convention was being followed, just not enforced.

The 17 that were missing are the usual Win32/COM shapes, and two were worth stating properly.
`clients/windows`'s `GetCurrentPackageFullName` is called with `len = 0` and no buffer — that is the
documented identity PROBE, which writes nothing, and reading it as a normal query would be a
mistake. `pf-console-ui`'s two `destroy_image_view` calls are the load-bearing ones: the comment
above one already argued that in-flight sampling of that slot ended two presents ago (the ring
alternates and the presenter waits its fence before each record), which is exactly the kind of
reasoning a `// SAFETY:` should carry and it was sitting there unlabelled.

Also fixes a real Windows-only clippy error this uncovered: `pf-gpu` had a
`#[cfg(target_os = "windows")]` fn AFTER its `mod tests`, tripping `items_after_test_module`. It
never fired on Linux (the item does not exist there) and no CI job clippies pf-gpu on Windows, so it
sat unseen. Moved above the test module.

Remaining: `wdk-probe` (26 items) alone, and only because it needs the WDK to build — .47 cannot,
so nothing here can verify a deny on it.

Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 the four Windows-relevant crates at
`-D warnings` rc=0.
2026-07-29 08:48:42 +02:00
enricobuehler 65cd388a52 fix(presenter,core): close the last of the proof-lint hole — the Vulkan contract, stated once
pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.

The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.

punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.

⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.

All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
2026-07-29 08:48:42 +02:00
enricobuehler dda9ed1aa2 fix(core): close the proof-lint hole in punktfunk-core — the ABI contract, stated once
146 sites, 141 of them in `abi.rs`, the `extern "C"` surface `cbindgen` turns into
`punktfunk_core.h`. Writing 141 independent arguments would have been the wrong answer and would
have read like it: they are instances of ONE contract in six shapes — opaque handles reached only
through `as_mut()`/`as_ref()` (so null becomes a status, never a dereference), caller-owned
out-params (null-checked exactly where the header calls them optional), NUL-terminated-or-null C
strings through `opt_cstr`, unchanged forwarding to a versioned entry point, fixed-length output
buffers, and `addr_of!` reads.

So the contract is stated once at the top of the file, the way `pf-win-display`'s CCD contract is,
and each site says which instance it is. Two properties that hold everywhere are stated there and
not repeated 141 times: no pointer is retained past the call that received it, and every entry point
runs inside `guard`'s `catch_unwind`, so a panic becomes a status code instead of unwinding into C.

The `addr_of!` sites got a real proof rather than a shape, because that one is load-bearing and
non-obvious: it forms a raw pointer WITHOUT creating a reference precisely because the caller's
struct may be an older, smaller version, so the field must be read by offset and not through a `&`.

`transport/udp/linux.rs`'s five are genuinely individual — two `zeroed()` POD initialisations, and
the `sendmmsg`/`recvmmsg`/`CMSG` trio, where the argument worth writing down is that
`msg_controllen` is set to `CMSG_SPACE(size_of::<u16>())` and the `CmsgBuf` is 64 bytes, so the
kernel cannot write past the control buffer.

punktfunk-core now denies `undocumented_unsafe_blocks`. Verified: Linux .21 fmt + both CI clippy
steps rc=0, and — since this is the C surface and comments alone should not change it — the C ABI
harness still passes, 4 frames round-tripped byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehler e1ddd49e37 fix(client-core,ffvk): close the proof-lint hole in two of the three unguarded crates
`clippy::undocumented_unsafe_blocks` is what makes the SAFETY convention a rule rather than a habit,
and three crates had never adopted it — pf-client-core (91 unsafe items), pf-presenter (123) and
punktfunk-core (167) — while every other subsystem crate denied it. That gap is why the decoders'
`unsafe impl Send`s carried a one-line aside instead of an argument: nothing required one.

pf-client-core and pf-ffvk now deny it, with a proof written for all 58 + 3 sites they had.

⚠️ 44 of those 58 were WINDOWS-ONLY — `clipboard.rs` 24 and `video_d3d11.rs` 20 — and invisible to
the Linux measurement that sized this work at 14. Same trap as the E0133 sweep: a Linux-only survey
of a cross-platform crate undercounts by whatever the `cfg` hides, here by 3x. Landing the deny on
the strength of that number alone would have re-broken Windows CI, which is exactly the mistake this
session already made once with the `warn`-that-was-really-`deny`.

The proofs say what is actually load-bearing rather than restating the call. In `clipboard.rs` that
is the ownership split Win32 requires and nothing in the code stated: `GetClipboardData` returns a
handle BORROWED from the clipboard (never freed here), while `GlobalAlloc` + `SetClipboardData`
TRANSFERS ownership to it (which is why nothing frees that one either) — two opposite rules, three
lines apart. In `video_d3d11.rs` the recurring one is that libav's `get_format` list is
NUL-terminated by `AV_PIX_FMT_NONE`, which is what keeps the walk in bounds.

Remaining: punktfunk-core (~146, of which `abi.rs` is 141) and pf-presenter (~108). Both want the
"state the contract once" treatment — abi.rs's sites are a handful of repeating shapes (`opt_cstr`
on caller C strings, null-guarded out-param writes, forwarding calls), not 141 distinct arguments.
Note the vendored `fec-rs` (18 sites) is a separate path-dependency crate, so it is out of scope
rather than something to prove.

Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 `-p pf-client-core` clippy
`-D warnings` rc=0 (the only place the 44 are visible), plus the full Windows CI clippy set and
pf-capture's 18 tests.
2026-07-29 08:48:42 +02:00
enricobuehlerandClaude Opus 5 192635545d fix(cli): the lockfile never learned about the new crate
audit / bun-audit (plugin-kit) (push) Successful in 55s
audit / bun-audit (sdk) (push) Successful in 54s
audit / bun-audit (web) (push) Successful in 54s
audit / docs-site-audit (push) Successful in 54s
audit / pnpm-audit (push) Successful in 23s
audit / cargo-audit (push) Successful in 2m30s
ci / docs-site (push) Successful in 1m2s
ci / web (push) Successful in 3m12s
apple / swift (push) Successful in 5m16s
android / android (push) Failing after 6m58s
ci / bench (push) Successful in 5m36s
audit / license-gate (push) Successful in 8m53s
ci / rust-arm64 (push) Failing after 8m52s
ci / rust (push) Failing after 8m53s
deb / build-publish (push) Failing after 5m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 23s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 22s
decky / build-publish (push) Successful in 29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / build-push-arm64cross (push) Successful in 18s
docker / deploy-docs (push) Successful in 1m0s
windows-host / package (push) Successful in 12m44s
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) Failing after 1m47s
arch / build-publish (push) Successful in 15m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m50s
deb / build-publish-client-arm64 (push) Successful in 7m37s
deb / build-publish-host (push) Successful in 10m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
flatpak / build-publish (push) Failing after 9m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m55s
release / apple (push) Successful in 29m42s
apple / screenshots (push) Successful in 22m31s
Adding `clients/cli` as a workspace member without regenerating `Cargo.lock` breaks every
packaging script — deb, rpm, arch, nix and flatpak all build with `--locked`, which refuses
to resolve a member the lock doesn't know. It slipped through because the CLI was only ever
built on the remote boxes, and their lock updates stayed on those boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:19:34 +02:00
enricobuehlerandClaude Opus 5 bf98102714 feat(packaging): ship punktfunk everywhere, and deprecate the session's stray pairing flag
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m5s
android / android (push) Canceled after 3m17s
apple / swift (push) Canceled after 3m18s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m18s
ci / rust (push) Canceled after 3m19s
ci / rust-arm64 (push) Canceled after 3m20s
ci / bench (push) Canceled after 3m18s
deb / build-publish (push) Canceled after 2m23s
deb / build-publish-host (push) Canceled after 2m13s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 2s
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) Canceled after 3m25s
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 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The rest of C1. A CLI that only exists in a dev tree is not a door anyone can use, so it
now builds and installs in deb, rpm, arch, nix, flatpak and MSIX alongside the client and
the session. On Windows it gets its own `AppListEntry="none"` application with the alias
`punktfunk.exe` — a command, not a Start-menu tile — which is what the Playnite importer
will shell to for `punktfunk library <host> --json`.

`punktfunk-session --pair` prints a deprecation notice and forwards. Pairing is a trust
ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell; a renderer
owning one is precisely the mixing of concerns this split exists to undo. It keeps working
for a release, because someone's provisioning script is calling it today.

Decky's invocations are untouched and verified still working (`--list-hosts` JSON shape
unchanged, `--reachable` exit codes unchanged): those flags are a frozen compat contract
until Decky migrates to the verbs at its own pace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Opus 5 f32c3aaa71 feat(session): spec mode — the renderer stops resolving policy and stops writing settings
C2 of design/client-architecture-split.md, and the last of the session's overreach.

`--resolved-spec <path>` hands the session everything it needs already resolved —
effective settings, the host's clipboard decision, the profile's name — and in that mode it
performs ZERO store reads. It had been re-deriving all three, which meant policy was being
evaluated inside the thing that draws pixels, and that the spawner and the child could
disagree about a file either of them might have written in between. First-party spawns
(the shells, the CLI) always pass one now.

The compat path stays for hand-run `punktfunk-session --connect` and old Decky scripts —
but it calls the SAME helper, so the two modes cannot drift; it is the identical function
invoked in-process instead of by the parent. A spec that is named but unreadable fails
loudly rather than quietly falling back: a spawner that asked for exact settings must not
get store-derived ones instead.

The match-window write-back is gone too. The callback used to load-modify-save the shared
settings file from inside the renderer — one of that file's five concurrent writers, for a
value only the parent needs. It now reports `{"window":{w,h}}` on stdout and the spawner
persists it, on a real change only. A hand-run session still persists its own window,
because nobody is listening to its stdout there and the event alone would drop the value.

Verified on .21: a spec naming a profile that doesn't exist in the catalog is honoured
(proving no lookup happened), a missing spec errors instead of falling back, and the CLI's
spec file is written and cleaned up per launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Opus 5 0594056e02 feat(cli): punktfunk — one headless front-end over the brain
C1 of design/client-architecture-split.md. There were four overlapping, none-complete CLIs:
the Linux shell's rich headless verbs, the Windows shell's near-none (it could not start a
stream at all), the session's own, and punktfunk-probe's diagnostics. This is the one a
script or a plugin should reach for, and it is a FRONT-END, not the brain — policy stays in
`pf-client-core` and the GUI shells keep calling it in-process. Shelling out for connects
would have traded duplicated code for a duplicated IPC protocol.

    punktfunk pair | hosts list/add/forget | wake | library | launch | open
              reachable | speed-test | profiles list | reset

Because it runs the same plan builder and the same wake machine as a card click, `launch`
and `open` WAKE A SLEEPING HOST — which the Linux shell's exec-style `--connect` never did;
it fired a packet at best and dialled into the void. Host references resolve through the
shared `resolve_host`, so `punktfunk launch desk` and `punktfunk://connect/desk` cannot
disagree about which box "desk" is.

Exit codes extend the session's contract so a consumer can branch without parsing prose:
0 ok, 2 connect, 3 trust, 4 renderer, 5 nothing matched what you named, 6 refused because
it needs a person. That last one is why `pair` and `reset` check for a terminal before
prompting — a CLI that blocks a CI job on a hidden question is a hang, not a UX.

Two deliberate non-features. `speed-test` measures and prints but does NOT apply: which
layer a bitrate belongs in is a decision the GUI makes with the user (global vs the bound
profile), and a CLI silently rewriting a profile is exactly the surprise that rule exists
to prevent. And `open` refuses an unknown host rather than pairing it — a URL may never
pair or trust on its own, at any surface.

`library` keeps TSV as its default output because that is what Decky's consumer parses;
`--json` is the door for tools, and the Playnite importer shells to exactly that. The
existing shell flags are untouched: they are a frozen compat contract until Decky migrates.

Verified on .21 against hand-authored stores: hosts list (TSV and JSON, with each host's
resolved profile and pins), profiles list, a launch honouring the binding ("Game"), a
one-off overriding it ("Work"), the same through `open` with `profile=`, and the refusal
codes (6 for an unknown host, 5 for `punktfunk://pair/...`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Fable 5 7fb2ad266e ci: a newer push supersedes the queued run — concurrency groups everywhere
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (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 / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (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-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-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (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
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
Every push-triggered workflow now declares
  concurrency: { group: workflow+ref, cancel-in-progress: true }

A busy push cadence on main was stacking ~10 queued runs per commit —
the fleet was 110 runs behind tonight — while only the newest commit's
canary matters. Now each new push cancels the superseded queued/running
run of the same workflow. Release tags are unaffected: every tag is its
own ref, so tag runs never cancel each other. Gitea honors this for push
triggers (PR triggers don't cancel yet: gitea#35933).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:07:00 +02:00
enricobuehlerandClaude Fable 5 cd67bd5d30 ci(audit): scope decky to shipped deps; make docs-site non-blocking deterministically
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (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
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
First real-runner dispatch (run 13627) surfaced two calibration issues:
* pnpm audit flagged 6 high advisories — all in decky's devDependencies
  (the rollup build toolchain), which never ship: the plugin bundle carries
  prod deps only. --prod scopes the gate to what users get, matching the
  locally-validated state (clean).
* docs-site relied on job-level continue-on-error, which act_runner does
  not reliably honor — replaced with a step-level '||' warning so the known
  advisories can never take the run red while staying visible in the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:59:29 +02:00
enricobuehlerandClaude Fable 5 5d018eea7c ci: the supply chain accounts for itself — per-release SBOM, full-tree audits, a real license gate
CRA Annex I Part II groundwork (see punktfunk-planning design/cra-readiness.md, Phase 1):

* sbom.yml + scripts/ci/gen-sbom.sh: every vX.Y.Z release gets a CycloneDX
  SBOM attached — syft over both Cargo.locks, all Bun/pnpm trees and the
  Swift Package.resolved (2,667 components), merged with
  compliance/sbom/manual-components.cdx.json for what no lockfile records
  (pyrowave/Granite/volk/Vulkan-Headers pins, libvpl, FFmpeg, SDL3,
  VB-CABLE, punktfunk-gamescope).
* audit.yml: bun audit now covers sdk + plugin-kit (not just web), decky's
  pnpm tree is scanned, and docs-site runs non-blocking until its known
  CMS-chain advisories are cleared. All shipping trees verified green today.
* license-gate: about.toml's allowlist claim is finally enforced —
  cargo-about 0.9.1 with --fail over BOTH workspaces. The old [crate.clarify]
  license-only syntax fails to deserialize under 0.9; migrated ring to a
  per-crate accepted extension and dropped the stale aws-lc-sys entry
  (workspace is ring-only). Both gates validated green locally.
* drivers/Cargo.lock: sync the pf-dualsense→pf-gamepad rename — the crate
  rename updated the manifest but the Windows-only lockfile was never
  regenerated; cargo-about's metadata pass caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:59:29 +02:00
enricobuehlerandClaude Opus 5 c27065c260 feat(client/windows): punktfunk:// opens the Windows client, and cards can write a shortcut
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (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
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
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
WP4 / D2. Protocol activation of a full-trust packaged app delivers the URI as the command
line, so a browser prompt, `start punktfunk://…` and a written `.lnk` all arrive the same
way — as a positional argument, the same door the Linux shell uses.

What Windows does not give us is single-instancing: unlike GApplication, a second
activation is simply a second process. So the first instance claims the named mutex
`unom.punktfunk.client` and any later one hands its URL to the winner over `WM_COPYDATA`
and exits — one window, and the link opens where the user's hosts already are. The hand-off
retries while the primary's window is still coming up (a shortcut double-clicked during
startup is the ordinary case), and a hand-off that finds nobody falls through so this
process becomes the shell that opens it. A link is never silently dropped, which is also
why the inbox is a queue: two shortcuts in quick succession are two links.

Routing is the same four lines of translation as on Linux, because the decisions belong to
`plan_from_link`: a resolved link becomes the call a tile click makes, with the same wake,
trust and error surfaces. Never preempting a live session is checked here, since only this
layer knows one is running.

The manifest declares the protocol and an app execution alias rather than writing registry
keys, so uninstall leaves nothing behind — and Windows' own "allow this app to handle
punktfunk links?" prompt is the origin friction the design wants, not duplicated in-app.
(Manifest comments are free of double hyphens: XML forbids them and makepri rejects the
whole file, which is how the console flag broke the v0.15.0 build. A test parses the
substituted manifest and asserts both declarations, so that can't rot silently.)

"Create shortcut…" writes a `.lnk` targeting the ALIAS with the URL as an argument, not a
`.url` internet shortcut: both work while the scheme is registered, only this one still
works if it isn't, and targeting the alias keeps it valid across updates when the package
path changes.

Verified on the CI VM (192.168.1.133): clippy -D warnings clean and its tests green.
Not yet exercised on glass — `start punktfunk://…` cold and with the app running, and a
written shortcut, still want a real desktop session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:49:56 +02:00
enricobuehler d2b6f5b65f docs(unsafe): audit all 49 unsafe impl — one proof was wrong, four were missing
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (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-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (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
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
`unsafe impl Send`/`Sync` is the highest-risk unsafe category here and the one this program had
never looked at: a wrong one is cross-thread UB that is invisible at every call site, with no
`unsafe` block to catch a reviewer's eye. 49 of them (41 Send, 8 Sync). Two results.

**`MappedView`'s `Sync` proof was factually wrong.** It read "only exposes accessors that are safe
under concurrent use" — they are not. `read_u8`/`write_u8`/`read_u16` are plain unaligned accesses
through `&self`, and `&MappedView` really is shared across threads: `ChannelState::data()` hands out
`&'static MappedView`, and pf-xusb, pf-mouse and pf-gamepad all dispatch
`WdfIoQueueDispatchParallel` with `NumberOfPresentedRequests = u32::MAX`. The struct's own doc had
the right story — consistency is the channel protocol's job — but the `unsafe impl` stated a
different, stronger claim, which is the one a reviewer checking that line would rely on.

The impl is still sound, for a reason worth writing down: these bytes are mapped into ANOTHER
PROCESS that writes them concurrently, so Rust-level exclusivity over them is unachievable no matter
what this type does. Sync fields go through the atomic accessors; the plain ones cover only
protocol-fenced bytes. The proof now says that, and states the rule it implies for accessors added
later — plain path only for bytes the protocol already fences.

**Four `Send` impls carried a one-line aside instead of a proof** — the pf-client-core decoders and
`DrmFrameGuard`. All four are sound, and each now says why, including the two facts that make them
work and were nowhere stated: libav permits a codec context to be used from a thread other than its
creator provided use is serialised (`&mut self` is that serialisation), and D3D11's immediate
context is thread-AGNOSTIC rather than thread-safe — it wants serialised use, not one fixed thread.
Each also records that it is deliberately not `Sync`, which is the invariant a future `impl Sync`
would silently break.

Every one of the 49 now carries reasoning. Verified: Linux .21 fmt + both CI clippy steps rc=0;
Windows .47 `-p pf-client-core` clippy `-D warnings` rc=0 (it compiles the `video_d3d11` proof the
Linux run cannot see). The pf-umdf-util edit is comment-only — confirmed by diff, since that crate
needs the WDK, which .47 does not have.
2026-07-29 00:37:14 +02:00
enricobuehlerandClaude Opus 5 f5de661c9e feat(client/windows): overridden rows are marked and resettable, and the speed test targets the right layer
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (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-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
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
The two remaining pieces of P1 on Windows, both closing the same kind of gap the Linux
client closed earlier.

Overridden rows in profile scope now say so — an accent-marked caption — and carry the
only way back to inheriting. Without it a profile is a one-way door here too, because
overrides are recorded on touch and never inferred from comparing values. Reset calls the
shared `SettingsOverlay::clear`, so the list of what can be cleared stays in one place
across both shells. It also bumps a revision counter: the catalog changes behind the
controls and nothing the page reads as state does, and root state compares before
re-rendering, so without the bump the row would keep showing the value it just dropped.

The speed test writes to the layer the TESTED host reads bitrate from, not the global —
measuring one host used to re-tune every other one. Resolved exactly the way a connect
resolves it: the one-off the test was started with (a pinned tile carries one), else the
host's binding, else the global; a dangling binding falls back. The button names its
target, so it says where it will write before you press it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:21:13 +02:00
enricobuehler 60a85a1344 refactor(encode/windows): fourth fence off — ffmpeg_win.rs, and D3d11Hw::new joins VaapiHw
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (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-host / package (push) Canceled after 0s
windows-host / winget-source (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
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
47 sites, 25 of them raw pointer dereferences: the same libav shape as `vaapi.rs`, so the same
verdict — a proof here carries an argument rather than restating a call.

`D3d11Hw::new` loses its marker for exactly the reason `VaapiHw::new` did, and the trio is now the
clearest statement of the rule in the tree: `CudaHw::new` KEEPS it (handed a `CUcontext`),
`VaapiHw::new` and `D3d11Hw::new` do not (a borrowed COM wrapper and scalars, opening what they
need themselves). Three near-identical libav context builders, sorted by whether a caller can hand
them something broken.

Also unmarked, all for the same reason — no parameter a caller can get wrong: `immediate_context`
and `ensure_staging` (`&ID3D11Device` IS the live-device guarantee), `send` and `ensure_sws` (only
scalars, operating on the `AVFrame`/`SwsContext` the struct owns from its constructor to `Drop`),
and `test_hw_device`, whose `# Safety` section described its own body — "calls the DXGI enumeration
FFI and `make_device`" — while taking a single `u32`.

`open_win_encoder` keeps its marker (raw `*mut AVBufferRef` pair) and gains the proof it lacked,
including the null case the system path relies on: both refs may be null, the `is_null` guards keep
`av_buffer_ref` off that path, and the refs it does take are new ones the codec context adopts.
`D3d11Hw::new`'s proof records the ordering the multithread-protection fix depends on — the device
store must precede `av_hwdevice_ctx_init`, which reads it.

14 fenced files -> 10. Verified on the Intel box .47: `-p pf-encode --features nvenc,amf-qsv,qsv`
clippy `-D warnings` rc=0, the full Windows CI clippy set rc=0, pf-capture's 18 tests pass, and —
because this is the live D3D11VA construct path, not dead code — `d3d11hw_alloc_drop_cycles` passes
on real Intel silicon: 8 construct/drop cycles, no abort. Linux .21: fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 8e7ba00d2d refactor(encode/linux): third fence off — vaapi.rs, and VaapiHw::new needed no marker at all
Same criterion as the last two: `vaapi.rs`'s sites are pointer dereferences and libav ctx calls,
not ash, so a proof here carries an argument. Three regions, three arguments.

`VaapiHw::new` also loses its `unsafe fn` outright, and the contrast with its CUDA twin is the whole
point: `CudaHw::new` keeps the marker because it is HANDED a `CUcontext` the caller must vouch for,
while this one takes four scalars and opens the VAAPI device itself. Two functions of near-identical
shape, opposite answers, decided by whether a caller can supply something broken.

The other two regions keep their markers (`open_vaapi_encoder`/`_mode` are handed raw
`*mut AVBufferRef`s) and gain the proofs they lacked. The encoder-config block now records the fact
that makes it sound rather than obvious: `av_buffer_ref` returns a NEW reference the codec context
adopts, so the callee shares the caller's device/frames buffers instead of consuming them — which is
also why the low-power entrypoint ladder can retry with the same two pointers after a failed attempt.
The frames-pool block reuses the `CudaHw::new` argument: alloc returns null-or-initialized and
`AvBuffer::from_raw` rejects null, so the `?` leaves before any field store can run.

One call site dropped its `unsafe {}` and its comment with it — the comment argued that libav was
initialized, which is a real precondition of the call but not one a caller can violate, so it is now
a note rather than a contract.

14 fenced files -> 11. Verified on .21 (fmt + both CI clippy steps rc=0) and, because this is the
live VAAPI construct path rather than dead code, ON THE AMD 780M (.116, pf-build distrobox):
`vaapi_cpu_encode_smoke`, `dmabuf_inner_alloc_drop_cycles` and `vaapi_probe_smoke` all pass —
3 passed / 0 failed, H265 + AV1 probes still true in both 8- and 10-bit.
2026-07-29 00:20:35 +02:00
enricobuehler ee29b3c3a9 refactor(client-core): three decoder lift methods that took nothing to get wrong
`D3d11Decoder::lift`, `VaapiDecoder::map_dmabuf` and `VulkanDecoder::extract` each take `&mut self`
and NOTHING else. They dereference `self.frame`, the `*mut AVFrame` the decoder allocates in its own
constructor and owns until `Drop` — a pointer no caller supplies, can replace, or can invalidate.
None carried a `# Safety` section, and each body was already one `unsafe {}` with its proof, so the
marker was pure ceremony and its removal moves nothing.

All three live in files the workspace `deny` already covers (`video_vulkan.rs` since the fence came
off it), so they stay fully enforced.

Worth recording why this is where the marker sweep STOPS for these two crates: every remaining
candidate in pf-encode and pf-client-core sits inside a fenced file, and there removing a marker is
not free. The fence excuses `unsafe_op_in_unsafe_fn`, which only applies to `unsafe fn` BODIES — a
safe fn always needs explicit blocks — so unmarking an ash-dense helper forces exactly the
whole-body `unsafe {}` this program rejected when it rejected `cargo fix`. The way to reach those is
to reclaim the file first, not to unmark inside it.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler d72822ced7 refactor(encode/linux): second fence off — CudaHw::new's pointer walk gets its proof
`linux/mod.rs`'s fifteen sites are the same kind as `video_vulkan.rs`'s, not the ash kind: nine raw
pointer dereferences and six libav calls, all inside `CudaHw::new`, which had no `unsafe` block and
therefore no proof of the one thing worth proving here — that the pointer chain it walks is live.

The marker STAYS (`cu_ctx: *mut c_void` is a `CUcontext` the caller must supply valid). The body is
now two blocks, one per phase, because there are two distinct arguments to make. Both turn on the
same non-obvious fact: `av_hwdevice_ctx_alloc`/`av_hwframe_ctx_alloc` return null or a ref whose
`data` libav has ALREADY initialized, and `AvBuffer::from_raw` rejects null — so the `?` leaves
before any of the field stores below it can run. That is what makes the `(*dev_ctx)`/`(*fc)` writes
in-bounds stores on live allocations rather than a hope, and it is exactly the reasoning that was
missing. The device block also records the ordering constraint that was implicit: `cuda_ctx` must be
stored BEFORE `av_hwdevice_ctx_init`, which reads it.

Two files now need no exemption: 14 fenced -> 12. Both were removable for the same reason — their
sites are pointer dereferences, where a proof carries an argument, unlike the ash backends where it
could only restate the call. That is the criterion for which fence to attack next, not file size.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode --features nvenc,vulkan-encode,pyrowave` step, all rc=0 with no allow in either file.
2026-07-29 00:20:35 +02:00
enricobuehler ed4bbc6b0b refactor(client-core): the first fence comes off — video_vulkan.rs is at zero
`video_vulkan.rs` was fenced with the other thirteen GPU/FFI backends, but it never belonged with
them: its four sites are not ash calls, they are RAW POINTER DEREFERENCES inside the two FFmpeg
`lock_queue`/`unlock_queue` callback trampolines — exactly the case where the lint pays, and where a
proof carries an argument instead of restating a signature. Both bodies had none at all.

They keep `unsafe extern "C"`: FFmpeg invokes them with a `*mut AVHWDeviceContext`, which is a real
contract, now written down. What was missing is why the dereference chain is sound, and it is worth
stating because it is not obvious — the trampoline casts between pf_ffvk's `AVHWDeviceContext` and
ffmpeg-sys's (the same C struct declared twice), reads `user_opaque`, and dereferences it as a
`QueueLock`. That pointer borrows `VkCtxStorage::_queue_lock`, an `Arc<QueueLock>` whose field doc
already says it exists to outlive every call the context can make. The proof now connects those two
facts, so a future edit to the storage's lifetime has something to contradict.

With that, the file needs no exemption and the workspace `deny` covers it: 14 fenced files -> 13.
This is what removing a fence is supposed to look like — the sites that are worth narrowing get
narrowed, and the exemption disappears rather than being renewed.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0 with no allow in the file.
2026-07-29 00:20:35 +02:00
enricobuehler 5b2be889f9 refactor(capture/windows): two # Safety sections that stated no contract
The previous commit took the markers with no `# Safety` at all. These two HAVE one, and that is
what makes them worth naming: a section can exist and still describe nothing a caller can violate.

`resolve_render_adapter` takes **no arguments**. Its section read "calls DXGI factory/adapter
enumeration; returns owned COM objects or an error" — a summary of the body. With no parameters and
no globals touched, there is no way to call it wrongly.

`create_nv12` asked that "`device` must be a live D3D11 device". It takes `&ID3D11Device`, a
borrowed reference-counted COM wrapper, so the borrow itself is that guarantee — safe Rust cannot
produce a dangling one. The remainder ("the returned texture is owned by the caller") is an
ownership note, the same distinction `service::open_log_handle` already draws in its doc. Every
other parameter is a plain scalar.

Both bodies already had their explicit block, so again nothing moved. `create_nv12`'s proof said
"on the live `device` borrow (per the contract above)" and now says the borrow is what keeps it
live — a proof that pointed at a contract being deleted had to stop pointing at it.

The other four were read and KEPT, and they are the shape of a real one: `channel::send` and
`duplicate_and_deliver` take raw `HANDLE`s; `prepare_blend_scratch` requires the caller to hold the
slot's keyed mutex before it copies; `pyro_fence_signal` must run on the thread owning the immediate
context, which is load-bearing precisely because `IddPushCapturer` is `unsafe impl Send` and so CAN
be moved. `verify_is_wudfhost` (raw `HANDLE`, and the driver-channel security check) keeps its
marker too.

pf-capture: 21 `unsafe fn` -> 8, every survivor carrying an obligation a caller can actually break.
Verified on .47: pf-capture clippy `-D warnings` rc=0 + 18 Windows tests pass, host/pf-encode/
pf-vdisplay green; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 9e8473b46a refactor(capture/windows): eleven D3D11 helpers had no caller contract
The DXGI converters (`HdrP010Converter`, `BgraToYuvPlanes`, `VideoConverter`), the cursor blend
pass, and `create_ring_slots` declared their methods `unsafe fn` because their bodies are D3D11
FFI. That is a property of the body, not an obligation on the caller, and their own proofs said so:
"`?`-checked D3D11 methods on the live `device` borrow". Every parameter is either a borrowed
windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
a plain `u32`/`bool`/`DXGI_FORMAT`; each body builds its own descriptors from those and every
interface it creates owns its reference. There is nothing a caller can pass that makes them
unsound.

Not one of the eleven carried a `# Safety` section — the marker had no stated contract to inherit,
which is the tell. Each body was already one `unsafe {}` with its proof, so this is purely
subtractive: no block moved and no proof was written. The four call sites lost `unsafe {}` wrappers
whose own comments gave the game away — "`X::new` is `unsafe` (it compiles D3D11 shaders…)",
"`create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls)" — comments explaining that
the marker described the callee's body rather than anything the caller had to guarantee.

`compile_shader` KEEPS its `unsafe fn`: it takes `PCSTR`, a raw pointer the caller must guarantee
points at a NUL-terminated literal. That is the line — a contract a caller can actually break.
pf-zerocopy was surveyed the same way and deliberately left alone: its CUDA/Vulkan helpers look
identical by signature but state real obligations ("the shared context is current", "single-threaded
use of handles this bridge owns"), so signature screening finds candidates and only reading decides.

pf-capture already denies both `unsafe_op_in_unsafe_fn` and `undocumented_unsafe_blocks`, so the
crate stays fully enforced; the reduction is in obligations, not in warnings. It now has exactly one
`unsafe fn` left. Verified on the Intel box .47: pf-capture clippy `-D warnings` rc=0 and its 18
Windows tests pass, with host/pf-encode/pf-vdisplay clippy still green; Linux .21 fmt + both CI
clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 50531c8e9e fix(host/display): a screen picker that cannot stream a screen now says so instead of saving
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (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-host / package (push) Canceled after 0s
windows-host / winget-source (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
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m56s
The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.

The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.

Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.

Three places, so the answer is consistent wherever it is asked:

* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
  the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
  flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
  why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
  will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
  coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
  not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
  every later settings save rejected over a field the operator cannot see — taking the other axes
  down with it. This way such a policy self-heals on the next write.

Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.

⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.

Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
2026-07-29 00:18:27 +02:00
enricobuehler ed021a13ee test(vdisplay): give the 3.2 case eyes — a subscriber, and the probe that splits the two candidates
§5 3.2 reproduces on glass and the shipped fix is not sufficient: the panel stays dark after
teardown. Two candidates were left undistinguished by the first on-glass run — the adopted snapshot
was already poisoned, or the dark-desk backstop never fired — because the case had no way to tell
them apart. This adds that way.

**The subscriber.** The adoption arm and `restore_displays_ccd`'s backstop announce themselves ONLY
through `tracing`, and a bare `cargo test` harness installs no subscriber, so both events went
nowhere. `tracing-subscriber` becomes a DEV-dependency of pf-vdisplay (the shipped host's closure
through this crate is unchanged) and `init_test_tracing` wires it: `try_init` so a second live case
in the same binary is a no-op rather than a panic, `with_test_writer` so it interleaves with the
harness rather than racing `println!`, `RUST_LOG` still winning over the `debug` default.

`live_inplace_resize` gets it too — its comment claimed tracing-subscriber "is not a dep of this
crate, run the host binary for traced runs", which was the same blindness written down as a
limitation. It no longer holds.

**The probe is the actual discriminator, and it needs no logs at all.** Member 1's isolate is
INJECTED to fail, so nothing of ours deactivates anything there. Sampling `active_physicals()`
immediately after member 1's create therefore answers the question directly:

* empty  -> the arriving IddCx monitor took the desktop on its own. Every snapshot from that instant
  on records "panel off", so member 2's adopted snapshot is POISONED AT BIRTH and restoring it
  faithfully restores darkness. Adoption works; the snapshot SOURCE is the defect.
* non-empty -> poisoning is excluded and the break is downstream: no adoption line means teardown's
  restore was never gated on, a backstop line with a non-zero force-EXTEND rc means the remedy
  itself failed.

The failing assertion now reads that verdict out instead of asserting one cause, so the next on-glass
run reports which link broke rather than only that one did.

⚠️ Still UNRUN on glass — this is the instrument, not the measurement. Needs .173's CONSOLE session
(ssh is session 0 and CCD sees nothing there), and it can leave the desk dark: recover with
`SDC_USE_DATABASE_CURRENT|SDC_APPLY`, not EXTEND, which returns rc=31 against a single display.

Checked while writing this, and recorded so it is not re-derived: the "backstop is structurally
inert on a one-display box" theory does NOT survive. Both real call sites restore BEFORE the virtual
is REMOVEd, so two displays are connected and the preset applies — `live_force_extend_*` measured
exactly that (1 -> 1 -> 2). The documented residual is narrower: a restore that fails once the
virtual is already gone.

Verified: `xcheck.sh windows clippy` clean (it passes `--all-targets`, so this test code really is
compiled), `cargo fmt --all --check` clean.

⚠️ `Cargo.lock` moves with this commit and must not be split from it. CI runs
`cargo clippy --workspace --all-targets --locked`, so a new dependency edge whose lockfile entry is
missing does not degrade to a re-resolve — it FAILS the build outright. The dev-dependency adds one
line (pf-vdisplay gains a `tracing-subscriber` edge; the package itself was already in the graph via
punktfunk-host and pf-encode, so nothing new is vendored).
2026-07-29 00:18:27 +02:00
enricobuehler 8381fd73ef test(vdisplay): bound the 3.2 case's creates, and what it found on glass
A hang must fail the case, not wedge the box. The first attempt at this case hung inside
`create`; killing the harness skipped every `Drop` and leaked an IddCx monitor, and a few of
those exhaust the driver's slot pool — after which every later run wedges too and only a reboot
clears it. Both creates now run on a worker thread with a 45 s budget, so the harness exits
NORMALLY, which is what lets the driver reap the session. Re-run with matched host+drivers: no
hang, no leak, and the box came back with the panel lit.

 With that guard in, §5 3.2 REPRODUCED ON GLASS for the first time (.173, LG TV attached, host
0.21.0 + drivers 9.9.728.2241, first isolate injected to fail):

    physicals before                        : [(4352, "LG TV SSCR2 [HDMI]")]
    after member 1 (isolate INJECTED to fail): [(260, "punktfunk [punktfunk-virtual]")]
    after member 2 (isolate REAL)            : [(260, …), (264, …)]
    physicals during                         : []
    physicals after teardown                 : []   <-- the operator's panel, still dark

So the shipped 3.2 fix — adopt the first SUCCESSFUL isolate as the group's restore snapshot — is
necessary but NOT sufficient. Two candidate reasons, both worth checking before changing code:

* The adopted snapshot is already POISONED. The panel went dark at member 1's create even though
  our isolate never ran (the injected failure), because the arriving IddCx monitor took the
  desktop on its own. Member 2's isolate then snapshotted a topology in which the physical was
  ALREADY inactive, so restoring it faithfully restores "TV off". This is the same
  poisoned-snapshot chain §5 already records from the field logs.
* `restore_displays_ccd`'s dark-desk backstop did not rescue it either. Worth its own look, since
  21eda37a is what first made that condition evaluable at all (before it, our own display counted
  as a lit physical and the guard could never fire).

⚠️ Not yet distinguished, because this case installs no tracing subscriber: whether the adoption
arm fired at all, and whether the backstop ran and failed or never ran. That is the next
diagnostic — `live_inplace_resize` shows the pattern for wiring a subscriber into a live case.

Also confirms 21eda37a in the field: the virtual displays report `tech=punktfunk-virtual` and are
correctly absent from the physicals list, which is what makes "physicals after teardown: []" mean
what it says rather than counting our own output as the operator's panel.
2026-07-29 00:18:27 +02:00
enricobuehler 22f2686680 feat(vdisplay/windows): the console can finally show the operator's real screens
`GET /display/monitors` returned an empty list on every Windows host, plus a LINUX error string:
`monitors::list` is a per-compositor dispatch whose non-Linux arm bails, and it never even got
that far because `detect()` was not cfg-gated — on Windows it fell through to an
`XDG_CURRENT_DESKTOP` sniff and failed with advice about setting `PUNKTFUNK_COMPOSITOR`, which the
handler puts VERBATIM into the response. So the console showed no physical screen and its only
explanation was Linux troubleshooting (sweep §13.17).

The data was there all along. `target_inventory()` already walks the CCD database; it now also
reports the geometry that walk had in hand — the driving source's position and mode, the GDI
device name, the refresh rational, and `primary`. No second CCD walker.

* `monitors::list_windows()` maps that onto `PhysicalMonitor`. Two fields cannot mean on Windows
  what they mean on Linux and are reported honestly rather than invented: `scale` is always 1.0
  (Windows scaling is per-monitor DPI applied per application, not a compositor-global logical
  scale — so the geometry is PIXELS), and an INACTIVE head gets zeroed geometry because the CCD
  mode indices are only valid for active paths. Inactive heads are still listed, per `list`'s own
  contract, so "why can't I pick it?" keeps an answer.
* `managed` is finally truthful. The doc says only KWin can tell a virtual output from a real head;
  Windows can too, via our own EDID manufacturer id in the device path (21eda37a).
* `detect()` gets an honest non-Linux arm, the operator pin gated with it — naming a Wayland
  compositor on Windows could never be honoured either.
* The handler reports `compositor: "windows"` instead of null-plus-a-Linux-error.

ON GLASS (.173, host 0.21.0, TV attached):

    connector=\\.\DISPLAY1  enabled=true  managed=false  primary=true
    3840x2160 @165Hz  pos=(0,0)  "LG TV SSCR2"

Pinned by an `#[ignore]`d hardware case that asserts at least one NON-managed head is visible —
the exact thing that was missing. Read-only, so it is safe against a live host.

This also gives `capture_monitor` a picker on Windows, where the handler currently hardcodes
`pinned: None`; wiring that is left for the per-monitor-capture work rather than smuggled in here.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay -p pf-win-display --all-targets
-- -D warnings` clean; both xcheck targets clean.
2026-07-29 00:18:27 +02:00
enricobuehler 735cd99e8f fix(ci/windows): the web install was the one CI site still running lifecycle scripts
apple / swift (push) Successful in 5m12s
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
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 12m40s
The Windows host job died in "Build + smoke-boot web console (bun)":

    $ bun2nix -o bun.nix
    error: bun is not installed in %PATH%
    error: postinstall script from "punktfunk-web" exited with 255
    bun install failed (255)

`web`'s `postinstall` is `bun2nix -o bun.nix` — Nix codegen that shells out to `bun` on PATH. This
job runs a fetched PORTABLE bun by absolute path (`$env:BUN_EXE`), so PATH has none and the script
aborted the install, taking the installer, the driver bundle and the publish steps with it.

Every other web install in CI already passes `--ignore-scripts` — ci.yml (twice),
web-screenshots.yml, sdk-publish.yml, plugin-kit-publish.yml, and the SDK install 25 lines further
down THIS SAME FILE. This one line was the lone exception: the same N-1-of-N seam asymmetry the
pf-vdisplay sweep keeps turning up. Nothing here needs those scripts — `bun run build` re-runs its
own `prebuild` codegen, and ci.yml proves the build works after an `--ignore-scripts` install —
and `bun.nix` is a Nix artifact this job neither consumes nor commits. deb.yml still regenerates
it (bun on PATH there), so the self-regenerating contract is unchanged.

⚠️ Honest about the evidence: I could NOT reproduce the failure on .173. Invoking the portable bun
by absolute path with `where bun` empty, the postinstall SUCCEEDED there — bun made itself
resolvable to its own lifecycle script, which the SYSTEM-account runner evidently does not. So this
is not a reproduced-then-fixed A/B. It is justified instead by construction: `--ignore-scripts`
removes the dependency on bun2nix (and on how bun2nix locates bun) altogether, and aligns the step
with every sibling. If the job goes green, that is the confirmation.

⚠️ Related trap for whoever deploys the console by hand: rebuilding `web/.output` does NOT take
effect until the `PunktfunkWeb` SCHEDULED TASK is restarted — restarting the host service does not
touch it. A server left running from before a rebuild serves HTML referencing the OLD asset hashes
while disk holds the new ones, so every asset 500s while `/login` still returns 200. Hit on .173
today; `Stop-ScheduledTask PunktfunkWeb; Start-ScheduledTask PunktfunkWeb` fixes it.
2026-07-29 00:18:27 +02:00
enricobuehlerandClaude Opus 5 3d25482469 feat(client/windows): pinned host+profile tiles render in the grid
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
apple / swift (push) Successful in 5m12s
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
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
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 / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
The menu could pin since the last commit but nothing appeared, which is worse than not
offering it. Each host's pins now render right after it, sharing its live status because
they read the same record, with the profile named on an accent chip. A pin whose profile
was deleted simply doesn't render.

A pinned tile carries no menu of its own: it is a shortcut, not a second host, and
pin/unpin already live on the primary tile's menu — the one place you decide it. Its
hover key is `<fp>#<profile-id>` so two tiles for one host don't light up together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:18:12 +02:00
enricobuehlerandClaude Opus 5 2a088e13a4 feat(client/windows): bind a host to a profile, connect with one, pin one, copy its link
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m25s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m43s
apple / swift (push) Successful in 5m17s
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
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (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
apple / screenshots (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
android / android (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / rust (push) Canceled after 2m30s
arch / build-publish (push) Canceled after 2m39s
The WinUI half of §5.2: `Target` carries a one-off profile into the session's `--profile`,
the host editor gains the binding picker, and the tile names its bound profile on a chip so
what a plain click will do is visible without opening anything.

Shape notes, both forced by this toolkit rather than chosen:

- The flyout has no submenus, so "Connect with", "Pin as card" and "Unpin card" are flat
  items, one per profile, matched by prefix. The prefixes end in ": " so a profile named
  "Copy link" can't collide with a fixed entry.
- The binding picker commits on change rather than at Save. The rest of that sheet is text
  boxes with draft refs, which need a Save; a ComboBox doesn't, and making it wait would be
  the odd one out.

Semantics stay identical to the Linux client, because they are the design's, not the
platform's: "Connect with" never rebinds, "Default settings" is `Some("")` so it really
does override a bound host for one session, and a binding whose profile was deleted reads
as Default settings and resolves to it.

`pf_client_core::clipboard::set_text` is the small addition the crate needed for "Copy
link" — the OS clipboard plumbing was already there, just private to the streaming bridge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:17:15 +02:00
enricobuehlerandClaude Opus 5 b220daf537 feat(client/windows): the settings screen edits profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m53s
apple / swift (push) Successful in 5m13s
ci / docs-site (push) Successful in 1m4s
ci / web (push) Successful in 3m3s
ci / bench (push) Successful in 5m43s
deb / build-publish-host (push) Failing after 6m17s
arch / build-publish (push) Failing after 9m36s
ci / rust-arm64 (push) Failing after 9m41s
ci / rust (push) Failing after 9m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 32s
decky / build-publish (push) Successful in 43s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
deb / build-publish (push) Successful in 8m52s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 13m2s
deb / build-publish-client-arm64 (push) Canceled after 6m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2m47s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m42s
P1 on the WinUI shell. The same surface now edits either layer, switched by a combo above
the section title — one settings UI, not a second editor that drifts from the first.

The interesting difference from GTK is where the override gets recorded. This shell
commits PER CONTROL, so it cannot hand over a list of touched rows the way a
commit-on-close dialog can. Instead each control hands `SettingsOverlay::absorb` the
effective settings before and after it fired, and absorb records the field that moved.
The comparison is against what the control was SHOWING, not against the globals, so
setting a value to whatever the global happens to be still records an override — the pin
the design asks for — and nothing is ever removed by inference.

⚠️ The content key becomes `(scope, section)`. Keyed on section alone, switching scope
diffs one scope's controls into the other's, which re-sets each reused ComboBox's items
(clearing WinUI's selection) while skipping `selected_index` wherever the two scopes'
values compare equal — the combos then render blank until touched. That is the same trap
the section key already existed for, one axis wider.

Only profileable rows render in profile scope: auto-wake, the library toggle, the decoder
and GPU pickers and the forwarded-pad picker are facts about this device or this network,
not about "Game vs Work".

Two shapes follow from this toolkit rather than from the design. Creating a profile takes
an auto-numbered name and is renamed from the row below, because ContentDialog here is
text-only and cannot hold a text field — the same constraint that put the host editor in a
tile. And Delete arms a root state that renders the confirmation declaratively, since
dialogs here are elements with `is_open`, not calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:12:43 +02:00
enricobuehlerandClaude Opus 5 8299151a09 fix(client-core): drop nine GTK-client files a stray copy left in the crate
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m50s
apple / swift (push) Successful in 5m17s
android / android (push) Canceled after 5m13s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m47s
ci / bench (push) Canceled after 5s
ci / rust (push) Canceled after 3m13s
ci / rust-arm64 (push) Canceled after 3m12s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 31s
decky / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
An `rsync` with the wrong destination dropped the Linux shell's sources into
`crates/pf-client-core/src/` and they rode along with the previous commit. Nothing
referenced them — `lib.rs` never declared the modules, so they were dead weight rather
than a build break — but a crate that says it is UI-agnostic must not have a GTK shell
sitting in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:57 +02:00
enricobuehlerandClaude Opus 5 ad99be2fb2 feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
Groundwork for the Windows half of P1, and a dedup of the Linux one.

The two shells commit differently: GTK writes once when the dialog closes (so it can hand
over a list of touched rows), WinUI writes on every control change (so it can't). `absorb`
serves the second shape — give it the effective settings before and after one control
fired, and it records the field that moved.

That is deliberately NOT the diff-on-save the design rejects. The comparison is against
the EFFECTIVE settings, i.e. what the control was actually showing, not against the
globals — so setting a value back to whatever the global happens to be still records an
override, which is exactly the pin the design wants. It only ever adds; removing an
override stays an explicit, separate operation.

`clear` is that operation, keyed by the overlay's own field names, with `resolution` as
the one alias for the width/height/match-window tri-state a single control drives on every
client. The GTK reset path now calls it instead of carrying its own `match` — that second
list was already a place where the two could drift, and the next client would have made it
a third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:26 +02:00
enricobuehlerandClaude Opus 5 22cb975659 feat(client/linux): "Create shortcut…" — a double-clickable entry for a host or a pinned profile
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Failing after 6m32s
arch / build-publish (push) Failing after 9m5s
ci / docs-site (push) Successful in 1m12s
deb / build-publish-host (push) Failing after 5m9s
android / android (push) Failing after 14m18s
deb / build-publish (push) Failing after 5m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
decky / build-publish (push) Successful in 26s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / bench (push) Successful in 6m30s
deb / build-publish-client-arm64 (push) Successful in 7m47s
apple / swift (push) Successful in 7m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m53s
flatpak / build-publish (push) Failing after 9m29s
ci / rust (push) Successful in 24m12s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
D1's last Linux piece (design/client-deep-links.md §5). The shortcut is a CONTAINER FOR A
URL, not a second launch mechanism: it invokes the client with a positional
`punktfunk://…`, the same door xdg-open and a browser prompt use. That is what makes it
survive things it has no control over — scheme registration being lost, the host moving to
a new address, the store being wiped — because the URL itself carries the stable id, the
address and the pin.

A pinned card writes its profile into the shortcut, so "Desktop · Work" on the app grid is
one double-click to that exact session. Two sanitisation points that are not cosmetic: the
filename is slugged to ASCII (a host called "Büro · Mac" must still produce a writable
name), and the `Name=` value is flattened to one line — desktop entries are line-oriented,
so a newline in a host name would end the key and turn the rest into another one.

Under flatpak the sandbox cannot write ~/.local/share/applications, so the user gets the
URL to place themselves — the fallback the design already sanctions. The
`org.freedesktop.portal.DynamicLauncher` route, which exists for exactly this and shows
its own confirmation, is the intended upgrade there and is not built yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:01:46 +02:00
enricobuehlerandClaude Opus 5 bd0103b1ac feat(client/linux): a host edit sheet, and with it the clipboard toggle Linux never had
deb / build-publish (push) Canceled after 0s
android / android (push) Canceled after 1m27s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 1m33s
ci / bench (push) Canceled after 0s
ci / web (push) Canceled after 47s
ci / rust (push) Canceled after 1m32s
ci / rust-arm64 (push) Canceled after 47s
ci / docs-site (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
Linux had "Rename" and nothing else, so two per-host settings that already existed in the
store and on the Apple and Windows clients had no surface here at all: the per-host
clipboard opt-in, and now the profile binding. A Linux user could not turn on a feature
they were already paying the storage for.

The sheet holds exactly the things that are properties of the HOST rather than of the
stream — its name, whether this machine shares its clipboard with it, and which settings
profile a plain click uses. A binding whose profile was deleted reads as "Default
settings" and is cleaned up on save, which is the same "dangling resolves as none" rule
the connect path follows.

The menu entry becomes "Edit…", and the function and message follow: a `rename_dialog`
that opens an "Edit Host" sheet is exactly the drift that confuses the next reader.
Pinning stays in the card menu rather than doubling as a list of toggles here — one place
to pin is enough, and it is the place you are already looking when you decide to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:54:50 +02:00
enricobuehlerandClaude Opus 5 b9a7163556 feat(client/linux): the speed test writes where the tested host reads, and cards can copy their link
android / android (push) Canceled after 4m16s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m28s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / rust (push) Canceled after 2m42s
ci / rust-arm64 (push) Canceled after 2m41s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 2s
deb / build-publish-client-arm64 (push) Canceled after 3s
decky / build-publish (push) Canceled after 2s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 46s
Two WP3 pieces.

**Speed test (§5.3).** Measuring the slow retro box downstairs used to re-tune the
desktop upstairs: Apply always wrote the GLOBAL bitrate, which the design calls the
sharpest existing symptom of the missing feature. It now writes the layer that host
actually resolves bitrate from — the bound profile's override if it has one, the global
if the host is unbound. A bound host whose profile inherits bitrate could legitimately
mean either, so it gets both buttons instead of a guess. The target depends only on the
host, so it is resolved before the test finishes and the button says where it will write
("Set in “Game”"). A one-off (a pinned card's profile) wins over the binding, and a
dangling binding falls back to the global — the same precedence a connect uses.

**Copy link.** Host and pinned cards can copy their own `punktfunk://` URL — the
self-emitted form carrying the stable id *and* host+fp, so a link pasted into a Playnite
entry or a Stream Deck macro still resolves after the host re-addresses or the store is
wiped. A pinned card copies its profile with it, which is the whole point of pinning it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:53:20 +02:00
enricobuehlerandClaude Opus 5 08ec0f4239 feat(client/linux): pin a host+profile as its own card
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Canceled after 4s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust (push) Canceled after 8s
ci / bench (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 5s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m57s
flatpak / build-publish (push) Canceled after 1m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m11s
§5.2a: a profile you reach for regularly shouldn't live behind a context menu. Pinning
one puts *Desktop · Work* in the grid next to *Desktop*, and clicking it is a plain
one-click connect with that profile.

The pin is presentation state on the host record — `pinned_profiles`, which has been in
the store since P0 and unused until now. Deliberately NOT a duplicated host entry: that
would fork pairing, WoL MACs and renames, and every host-level action would then need a
"which card owns this" rule. So a pinned card is the same record rendered again with a
profile attached: it shares the host's live status because it reads the same record, and
a pin whose profile was deleted simply doesn't render.

What each card offers follows from what it is. The primary card keeps the host actions
and gains "Pin as card ▸" (listing only profiles not already pinned; "Default settings"
isn't pinnable — the primary card is that). A pinned card offers the one-offs and its own
removal, and deliberately not pair/rename/forget — it is a shortcut, not a second host,
and offering destructive host actions there would blur the distinction. One action serves
both directions: which kind of card it sits on decides whether it pins or unpins.

Verified on .21: a host bound to "Game" renders its primary card with a neutral Game chip
and, right after it, the pinned "Work" card with an accent chip, both showing the same
online/paired state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:49:06 +02:00
enricobuehlerandClaude Opus 5 b485f91f52 feat(client/linux): overridden rows say so, and can go back to inheriting
apple / swift (push) Successful in 5m3s
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / web (push) Canceled after 6s
ci / rust (push) Canceled after 6s
ci / rust-arm64 (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 1s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
The gap in what the scope switcher shipped: a profile was a one-way door. Overrides are
recorded on touch and never inferred from comparing values — which is the right model,
and exactly why "not overridden" needs an explicit way back.

Each overridden row now carries an accent dot in its prefix and a reset button in its
suffix, so which settings a profile actually changes is legible without reading every
value against the defaults. Reset queues the field, re-opens the dialog in the same scope
and lets the close handler commit everything else first — so one code path still builds
the rows, including the rebuilt ones showing the inherited value again.

Clears are applied after the touched fields for a reason: resetting a ComboRow can fire
its change handler on the way past, and "back to inheriting" is the later, explicit
intent. An unknown field in the cleared set warns rather than silently doing nothing —
this list and the write-back list have to stay in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:34:42 +02:00
enricobuehlerandClaude Opus 5 fa430646ff fix(packaging): the Pulse shim is never the client's, and never the host's requirement
android / android (push) Failing after 5m55s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 5m22s
ci / rust-arm64 (push) Failing after 8m30s
ci / bench (push) Successful in 5m14s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
arch / build-publish (push) Successful in 15m26s
deb / build-publish (push) Successful in 8m56s
ci / rust (push) Failing after 17m59s
deb / build-publish-host (push) Failing after 8m49s
apple / screenshots (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 9m1s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4m14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6m31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 3m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 30s
Same bug as the Arch package, in the other two packagings.

The .deb CLIENT recommended `pipewire-pulse`. Apt installs Recommends by
default and the shim Conflicts with `pulseaudio`, so this proposed pulling the
user's sound server out from under them in exchange for something the client
cannot use: audio.rs drives libpipewire-0.3 directly and opens no Pulse socket.
(SDL is initialized with zero subsystems — gamepads only, on demand — so it
brings no audio backend either.)

The RPM HOST hard-Required `pipewire-pulseaudio`, which conflicts with
`pulseaudio` on Fedora too — uninstallable for anyone running real PulseAudio.
The host doesn't speak Pulse either; the shim is for the GAMES, which commonly
emit through the PulseAudio API, and real PulseAudio serves those equally well.
Demoted to Recommends, so the default Fedora box is unchanged and the informed
choice stops being blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehlerandClaude Opus 5 7d18468477 fix(packaging/arch): a client that never speaks Pulse must not demand the Pulse shim
`punktfunk-client` hard-depended on `pipewire-pulse`, which CONFLICTS with
`pulseaudio` — so anyone keeping real PulseAudio could not install the client at
all ("unresolvable package conflicts detected"), even though the client has no
use for the shim: audio.rs drives libpipewire-0.3 directly for both playback and
the mic uplink, and never opens a Pulse socket. Dropped it from the client.

The host doesn't speak Pulse either — it captures the sink monitor natively — but
the shim is genuinely useful there, for the GAMES, which commonly emit through
the PulseAudio API. That's an optdepend, and one real `pulseaudio` satisfies just
as well, so the host stops blocking that choice too.

Both now match what the .deb and the RPM already said (Recommends / "degrade
gracefully without it"); only the Arch package had hardened it into a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehler 6de325a6b6 fix(ci): the unsafe lint said warn while CI enforced it as deny, and main went red
windows-host / package (push) Failing after 13m8s
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 3m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m55s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m5s
android / android (push) Failing after 6m25s
ci / bench (push) Successful in 8m21s
deb / build-publish (push) Successful in 8m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 32s
deb / build-publish-host (push) Successful in 10m2s
ci / rust (push) Failing after 17m18s
ci / rust-arm64 (push) Successful in 16m6s
arch / build-publish (push) Failing after 17m19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m12s
deb / build-publish-client-arm64 (push) Successful in 14m5s
flatpak / build-publish (push) Failing after 8m59s
apple / swift (push) Failing after 7m20s
apple / screenshots (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 17m16s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26m3s
`unsafe_op_in_unsafe_fn = "warn"` was adopted workspace-wide in 39513528 on the assumption that
`warn` is a soft setting you can clear at leisure. It is not: ci.yml runs `cargo clippy … -D
warnings`, which promotes it to a hard error, so main has failed on EVERY commit since — Linux
`rust` and `rust-arm64` both dying on `pf-client-core` with 70 E0133 errors, and windows-host.yml
alongside them. A lint level that understates its own severity is worse than a strict one, so this
states what CI already does — `deny` — and writes the exemptions down instead.

Fourteen GPU/FFI backend files take `#![allow(unsafe_op_in_unsafe_fn)]`, each with its reason and
the workspace Cargo.toml carrying the argument in full. They are not "not done yet": measured
across them, 64% of the sites are a single third-party FFI call (ash, pyrowave-sys, libav, the
NVENC/AMF entry tables), and of the 44 `unsafe fn`s only 4 have a body containing no unsafe
operation at all. Since pf-encode also denies `undocumented_unsafe_blocks`, narrowing them means a
hand-written SAFETY comment per line that could only restate the signature — the exact noise that
made `unsafe` stop meaning anything here before. Everything else stays at zero and enforced, and
each allow is removable on its own terms.

Two smaller things this had to clear, both invisible to the job that would have caught them:

- `service.rs`: `undocumented_unsafe_blocks` wants the proof on EACH block, and a comment covering
  a group of consecutive `unsafe` statements only credits the first — so the two `OwnedHandle`
  wraps became their own statements. Windows-gated, so only the .47 gate sees it. 37a42078 fixed
  the same class of failure in pf-frame/dxgi.rs concurrently; that fix is taken as-is here.
- `clients/linux/ui_settings.rs`: `show`'s deletion left its doc comment stranded on `show_scoped`,
  which silently merged two unrelated doc blocks onto one fn. Removed.

Verified by running the real CI commands, not proxies: on .21 fmt + `clippy --workspace
--all-targets -- -D warnings` + the feature-gated `-p pf-encode --features
nvenc,vulkan-encode,pyrowave` + build + test, all rc=0; on the Intel box .47 all four
windows-host.yml clippy invocations, all rc=0. The arm64 job lints pf-client-core and
punktfunk-client-linux, both covered here.
2026-07-28 22:32:30 +02:00
enricobuehler f40c90aa23 refactor(host/inject/win-display): the Win32 unsafe surface is the FFI call, not the function
`spawn_in_active_session` existed only to launder `spawn_inner`'s `unsafe fn` marker — and its
own comment said why that marker was empty: the callee "has no caller-side preconditions, it
validates the session/token itself and owns every handle it opens". A marker that documents the
absence of a contract is not a contract. The two collapse into one safe fn whose `unsafe` now sits
on the eight Win32 calls, each with the proof it actually needs.

Kept `unsafe fn` exactly where a caller CAN break something: `merged_env_block` (a `*const u16`
block it must trust), `spawn_host` (a borrowed job `HANDLE`), `desktop_name` (an `HDESK`),
`inject_following_desktop` (a live synthetic-pointer device). Those bodies gain explicit blocks
instead — `merged_env_block`'s pointer walk is the one place here where the proof is load-bearing
rather than restating a signature, and it now says why the scan cannot run off the block's end.

With those four files at zero, all three crates take `deny(unsafe_op_in_unsafe_fn)` at the root,
next to the `undocumented_unsafe_blocks` deny they already had. The pair matters: alone, the older
deny is satisfied by an `unsafe fn` body, which needs no blocks at all and so can hide an unproven
FFI call. The workspace lint stays `warn` — it is a ratchet over the encoder backends still to be
cleared, and a crate that reaches zero should not be able to drift back silently.

Windows E0133 214 -> 182 (the remainder is the ash/AMF-dense encode backends). Verified on the
Intel box .47 and on Linux .21: no errors, no `unused_unsafe`, no dead-code fallout.
2026-07-28 22:31:11 +02:00
enricobuehlerandClaude Opus 5 808eba7292 feat(client/linux): bind a host to a profile, or connect with one just this once
ci / web (push) Successful in 1m56s
ci / rust (push) Failing after 6m15s
ci / docs-site (push) Successful in 2m1s
android / android (push) Canceled after 11m36s
ci / rust-arm64 (push) Failing after 9m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m23s
deb / build-publish-client-arm64 (push) Successful in 7m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m31s
deb / build-publish (push) Successful in 9m19s
arch / build-publish (push) Successful in 18m42s
flatpak / build-publish (push) Failing after 8m19s
deb / build-publish-host (push) Successful in 10m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m15s
apple / swift (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 29s
docker / build-push-arm64cross (push) Successful in 11m22s
apple / screenshots (push) Successful in 27m32s
The other half of P1 on GTK (design/client-settings-profiles.md §5.2): until now a
profile could be created and edited but only *used* through `--profile` or a URL. Host
cards get two menus, and they are separate on purpose — the predictability rule is that
connecting with a profile must never change what the card does next time.

- **Connect with ▸** is a one-off. It rides `ConnectRequest.profile` into the session's
  `--profile` flag and touches no stored state. "Default settings" in that menu is
  `Some("")`, not `None`: on a bound host, asking for the defaults is a real choice and
  has to survive as a value rather than degrade into "use the binding".
- **Default profile ▸** is the explicit rebinding act. It writes straight onto the host
  record — the binding IS a field there, so there is no map to keep in step — matched by
  fingerprint, else by address, like every other per-host lookup in this client.
- The card carries a **chip** naming its bound profile, so what a plain click will do is
  visible without opening a menu. A binding whose profile was deleted shows nothing and
  connects with the defaults, which is exactly what the resolver does.

A URL's `profile=` now takes the same one-off route, since it is the same kind of thing.

Verified on .21: `punktfunk://connect/Bound%20Box?profile=Work` against a host bound to
"Game" spawned a session that logged `profile=Work id=bbbbbbbbbbbb` — the one-off won,
and the binding on disk was untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:24:56 +02:00
enricobuehler 37a42078a0 fix(xcheck): mirror [workspace.lints] too — and the two things that hid behind it
windows-host / package (push) Failing after 5m0s
windows-host / winget-source (push) Skipped
ci / rust-arm64 (push) Failing after 22s
ci / web (push) Successful in 2m28s
ci / docs-site (push) Successful in 2m55s
ci / rust (push) Failing after 6m19s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 6m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
android / android (push) Successful in 12m2s
deb / build-publish-client-arm64 (push) Successful in 8m59s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m3s
deb / build-publish-host (push) Successful in 13m33s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m22s
`scripts/xcheck.sh` stopped working the moment the workspace adopted its unsafe discipline
(`52191071`). That commit added `[lints] workspace = true` to every crate manifest, and a member
inheriting a lint table the workspace ROOT does not define does not merely lose the lint — cargo
refuses to parse the manifest at all:

    error inheriting `lints` from workspace root manifest's `workspace.lints`
    `workspace.lints` was not defined

The generated root mirrored `[workspace.package]` but nothing else. It now mirrors every
`[workspace.lints.*]` table generically, so a future table (clippy, rustdoc, …) is picked up
without touching the script again.

That mattered more than a broken helper, because xcheck is the ONLY thing that lints these crates
for Windows — the Windows CI job clippies `-p punktfunk-host -p punktfunk-tray`, which does not
lint a dependency. So while it was down, two real failures sat on main unseen:

* **pf-frame `dxgi.rs`, 8 × `clippy::undocumented_unsafe_blocks`.** `307ca88b`/`0a710fdf` split
  the D3DKMT/DXGI calls into one `unsafe` block per FFI call under a single shared SAFETY comment,
  but the lint wants a proof per block — a comment two lines up with an intervening statement does
  not count. Each site now carries its own. Not "as above", which this sweep already found to be a
  proof that rots when the block it points at moves.
* **pf-win-display `input_desktop.rs`, one E0133.** `GetUserObjectInformationW` is an unsafe
  operation in an `unsafe fn` body, which `unsafe_op_in_unsafe_fn` (now warn workspace-wide, and
  `-D warnings` here) requires to sit in its own block. The SAFETY proof was already written; only
  the block was missing.

The `&&` chain in `elevate_process_gpu_priority` keeps two separate blocks rather than one around
the chain — wrapping it would destroy the short-circuit and always issue the relative-priority
call. Same trap this sweep hit in `wait_mode_settled`.

Verified: both xcheck targets clean again.
2026-07-28 22:17:43 +02:00
enricobuehler 21eda37aa3 fix(win-display): punktfunk's own display is not one of the operator's panels
The IddCx driver declares `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI` for its monitors
(`packaging/windows/drivers/pf-vdisplay/src/monitor.rs`, `IDDCX_MONITOR_INFO::MonitorType`),
and `output_tech_class`'s allowlist reads HDMI as a real external panel. So every consumer of
`target_inventory` counted OUR OWN virtual display among the operator's physical monitors.
Measured on .173: both targets came back `external_physical`, `[(4352, "LG TV SSCR2", HDMI),
(257, "punktfunk", HDMI)]`.

That is not cosmetic. `restore_displays_ccd` ends with the last-resort guarantee that the desk
is never left all-dark:

    let (connected, lit) = target_inventory().filter(external_physical) …
    if connected > 0 && lit == 0 { force_extend_topology() }

The restore runs BEFORE the virtual is REMOVEd, so our own still-active display kept `lit >= 1`
and the backstop could NEVER fire — in exactly the situation it was written for. It also made
our display a candidate "physical suspect" in the disturbance-attribution inventory, which
`TargetInventory`'s own doc rules out ("only indirect/virtual targets (our own IDD included)").

Targets carrying our EDID manufacturer id ("PNK", stamped by the driver's edid.rs) are now
classified `external_physical = false`, tech `punktfunk-virtual`. Matching on the monitor device
path rather than the friendly name — the path carries the manufacturer id the OS itself derived
— and on both spellings, since the CCD device-interface path uses `#`
(`\\?\DISPLAY#PNK0000#…`) while the PnP instance id for the same monitor uses `\`
(`DISPLAY\PNK0000\1&15ecd195&5&UID264`, observed on .173). Allowlist-shaped like
`output_tech_class`, so a third-party virtual display is never silently adopted.

ON GLASS, read-only, on .173 — and it caught the backstop's trigger live:

  target  264  active=true  external_physical=false tech=punktfunk-virtual "punktfunk"
  target 4352  active=false external_physical=true  tech=HDMI              "LG TV SSCR2"

The physical is dark while our virtual holds the desktop. Post-fix that is `connected=1, lit=0`
— the backstop fires. Pre-fix it was `connected=2, lit=1` — suppressed. The new hardware case is
deliberately READ-ONLY (creates and destroys nothing) so it is safe against a live host;
repeated IddCx create/destroy churn is what wedges the driver's slot pool.

Also adds `manager::FAIL_NEXT_ISOLATES`, a `#[cfg(test)]` seam that fails the next N
`isolate_displays_ccd` calls, with every call site in manager.rs routed through it. The Phase-3
recovery legs fire only on a failed isolate, which real hardware does not produce, so they
cannot otherwise be exercised. `#[cfg(test)]` rather than an env knob: this crate's live tests
compile under `cfg(test)`, so no production switch exists that could disable display isolation
on an operator's box. The §5 3.2 case built on it is included but is NOT yet a passing on-glass
result — see the plan for where it got to.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and -p pf-win-display
--all-targets clean; pf-vdisplay 56 pass, pf-win-display 2 pass + hardware cases; both xcheck
targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 2b2c6f045d test(vdisplay): pin the force-EXTEND backstop on glass, with a real panel attached
`force_extend_topology` does two jobs — stop a fresh IddCx monitor being CLONED onto the
existing panel, and serve as `restore_displays_ccd`'s last-resort "the desk is not left
dark" backstop. Probed directly on .173 with only the LG TV connected, its preset returns
rc=31 ERROR_GEN_FAILURE while SDC_USE_DATABASE_CURRENT returns 0, which reads like a
backstop that cannot back anything up.

On glass it is not, and this case is the measurement that settled it. Active paths went
1 -> (virtual up) 1 -> (after force-EXTEND) 2: with one connected display there is nothing
to extend across, hence rc=31; with the virtual present there are two and the preset
applies. Both real call sites run in exactly that state — the restore fires BEFORE the
REMOVE, so the virtual is still there. No defect. Recording it so nobody re-derives the
alarming half from the rc alone.

 It also caught the clone hazard live: the arriving virtual monitor did NOT get its own
active path (1 -> 1); only the forced EXTEND gave it one (-> 2). That is exactly the
"no distinct source -> no frames" case the function's own doc describes, observed rather
than assumed.

⚠️ Residual, noted not asserted: a restore that fails once the virtual is already gone is
back to one connected display, where EXTEND returns 31 and cannot re-light anything.

Context for the sweep's other on-glass claims: the earlier `live_create_drop` and
`live_inplace_resize` passes on this box were taken with the TV POWERED OFF (every monitor
devnode Code 45), so they proved the driver round trip but never exercised deactivating a
real panel. Re-run now with the TV attached, `live_inplace_resize` still reports
in_place=true (target 257 -> 257, 2356x1332), so Phase 6.2 holds with a physical present.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and
-p pf-win-display --all-targets clean, 56 pass + 3 ignored, no orphans.
2026-07-28 22:17:26 +02:00
enricobuehler ea9a25d3b1 fix(win-display): a host with nothing lit is an ANSWER, not a failed CCD query
`query_active_config` asked `QueryDisplayConfig` for the active topology even when the
sizing call had just answered `numPaths = 0`. Windows rejects a zero-count query rather
than handing back an empty set, so "nothing is active" came back as "the query failed".

Zero active paths is an ordinary state, not a broken one: every panel off or in standby, a
KVM switched away, a headless box between its adapter arriving and its first monitor. The
`None` propagated into the two places that matter — `count_other_active` reported failure
where the honest answer is `Some(0)`, and `isolate_displays_ccd` bailed at its first line,
which is exactly the teardown-gate condition whose recovery legs exist to stop the
operator's physical panels being left dark (sweep §5 3.1).

MEASURED on .173 (RTX 4090, Win11 26200) with the TV powered off — every monitor devnode
Code 45, `numPaths = 0` for `QDC_ALL_PATHS` too — in a live, logged-on CONSOLE session:
`GetDisplayConfigBufferSizes` returns 0 paths and `QueryDisplayConfig` then returns 0x57
ERROR_INVALID_PARAMETER (0x5 ERROR_ACCESS_DENIED from session 0). This is the mechanism
behind the plan's  note that `count_other_active(&[])` yields `None` on that box: not the
API refusing us, just a query nobody should have made.

Three changes, one defect:

* `query_active_config` short-circuits `numPaths == 0` to an empty-but-valid config.
* `isolate_displays_ccd` returns that empty snapshot straight away instead of entering its
  retry loop. Nothing is active, so nothing needs deactivating and nothing needs restoring;
  the loop's re-commit exists to drive the IddCx adapter's COMMIT_MODES, and with no path
  at all there is no config to commit — four attempts would each log a rejected apply and
  sleep 250 ms to reach the topology we already have. `restore_displays_ccd` already
  treated an empty config as the no-op it is, so the round trip stays consistent.
* `set_virtual_primary_ccd` goes through the shared query. It carried a verbatim copy of it
  — same flags, same shape — and was therefore the one CCD entry point that would not have
  inherited the fix. Same seam asymmetry this crate keeps producing, and two hand-written
  unsafe blocks go with it.

Also this file's first tests. The case is `#[ignore]`d hardware, so it reports `ignored`
rather than a vacuous `ok` when nobody runs it. On-glass A/B in the console session on
.173: it passes with the short-circuit and FAILS without it, naming the conflation. A box
that does have a display lit passes either way.

⚠️ The test is read-only and must stay that way — the obvious companion assertion,
`isolate_displays_ccd(&[])`, is deliberately not written: an empty keep set means "keep
nothing", so on a box with displays it would deactivate every one of them.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay --all-targets` and
`clippy -p pf-win-display --all-targets` both clean, pf-vdisplay 56 pass, pf-win-display
1 pass + 1 ignored. Both xcheck targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 5a14d2b3f4 fix(vdisplay): the helper budget must end the tree, not just the process we spawned
`Child::kill` is one `TerminateProcess`: it ends exactly the process we launched. On Unix
that is the whole story here — `kscreen-doctor`, `systemctl`, `pw-dump` are single processes
we exec directly. On Windows there is no direct exec, so every helper is reached through a
shell (`cmd /c …`, `powershell -Command "… | pnputil …"`) and the process that actually
hangs is a GRANDCHILD. Killing the shell left it running, holding the stdio handles and the
working directory it inherited from us, which means the budget bounded nothing.

Not theoretical: it is why a fully green `cargo test -p pf-vdisplay` still failed its CI job
on `33a31427`. The suite's own hung-helper case orphaned a 60-second `ping.exe`; it kept the
build step's stdout pipe open past the runner's 10 s WaitDelay ("exec: WaitDelay expired
before I/O complete") and pinned `crates\pf-vdisplay` so the workspace could not be cleaned
up. The tests reported `55 passed; 0 failed` in the same log.

Spawned children are now enrolled in a Job object. Job membership is inherited across
`CreateProcess`, so one `TerminateJobObject` ends every descendant, and
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold on paths that never reach the explicit
call — an early `?`, a panic — because closing the last handle is then itself the kill.
Best-effort: a box that cannot make a job object degrades to the single-process kill it did
before rather than failing the query, the same stance as the already-ignored `Child::kill`.

`output_within` ends the tree BEFORE `wait_with_output`. That read runs to EOF, and a
grandchild outliving the helper holds the write end — the one way this "bounded" helper
could still hang forever.

The new test is the permanent lever, and it is not vacuous: it asserts the grandchild
reached its first marker before asserting it never reached its second. Verified on .173
(Win11 26200): 56 pass, `clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean, no
`ping.exe` survivors. A/B with the job disabled: the test fails with "a grandchild outlived
the budget" and one orphan survives. On the CI runner .133 the job APIs were also checked
directly, as Administrator (already inside a job — nesting works) and as SYSTEM: both kill
the tree.

Trap for anyone editing that test: a .cmd file is read in the OEM code page, so an absolute
path baked into it is mangled the moment the temp dir holds a non-ASCII character
(`C:\Users\Enrico Bühler\…` arrives as `B?hler`) and every redirect fails with "path not
found". It uses `%~dp0` instead, so the file stays pure ASCII.
2026-07-28 22:17:26 +02:00
enricobuehlerandClaude Opus 5 cf419d8b6f feat(client/linux): the settings dialog edits profiles, not just the defaults
ci / web (push) Successful in 2m12s
ci / docs-site (push) Successful in 1m12s
android / android (push) Successful in 12m14s
ci / rust (push) Failing after 9m46s
ci / rust-arm64 (push) Failing after 9m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 7m57s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
deb / build-publish-client-arm64 (push) Successful in 7m40s
deb / build-publish (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 10m19s
flatpak / build-publish (push) Failing after 8m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m10s
arch / build-publish (push) Successful in 21m3s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
P1's core on GTK (design/client-settings-profiles.md §5.1). The same surface now edits
either layer: a scope switcher at the top of General swaps between "Default settings" and
one profile's overrides. Deliberately not a second editor — a parallel one would drift
from this one field by field, which is exactly what the "one settings UI" principle is
there to prevent.

Three decisions carry the design's semantics into this file:

- Rows always show the EFFECTIVE value — the global underneath with the profile's
  overrides applied — so a row the profile doesn't touch reads as the live global. That is
  what "inherit by default" has to look like.
- Overrides are recorded ON TOUCH, never by diffing values at save time. A profile may
  therefore pin a value that happens to equal today's global and keep it when the global
  later moves. The handlers that record this are installed after the seed pass, or opening
  a profile would override every row in it.
- Only profileable rows render in profile scope. Auto-wake, the library toggle, the
  decoder and GPU pickers, the audio endpoints, the forwarded-pad picker and the detected
  pad list are facts about this device or this network, not about "Game vs Work" (§3's
  tier G/H), so they simply aren't there.

Switching scope closes the dialog — which commits the layer being edited — and asks the
app to re-open in the new one, so there is a single code path that builds the rows and no
ambiguity about commit ordering. Both scopes read the controls through one shared closure;
the tri-state resolution row is the obvious trap if they ever diverged.

Create / Rename / Duplicate / Delete live in the switcher's group. Names are unique
case-insensitively and the prompt refuses duplicates in place rather than after the dialog
is gone. Delete counts what actually breaks — hosts that fall back to the defaults, pinned
cards that disappear — and leaves bindings dangling on purpose: they resolve as "no
profile" everywhere, and rewriting every host record here would be a second, racier source
of truth.

`PUNKTFUNK_SHOT_SETTINGS_SCOPE` puts the screenshot scene into profile scope, so both
halves of the surface are capturable. Verified on .21 under Xvfb: the switcher, the
per-profile actions and the tier filtering render, and the Statistics row seeds to the
Game profile's "Detailed" rather than the global's value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:05:29 +02:00
enricobuehlerandClaude Opus 5 6cf5d4ab87 feat(client/linux): punktfunk:// links open the GTK client
ci / web (push) Successful in 2m41s
android / android (push) Successful in 12m27s
ci / docs-site (push) Successful in 3m8s
ci / rust (push) Failing after 10m42s
ci / rust-arm64 (push) Failing after 10m44s
arch / build-publish (push) Successful in 15m31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 7m15s
deb / build-publish (push) Successful in 13m59s
deb / build-publish-host (push) Successful in 14m55s
flatpak / build-publish (push) Failing after 10m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 27s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m8s
D1's activation half (design/client-deep-links.md §4.1). The client already had a
`--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could
start a stream: no browser prompt, no xdg-open, no double-clickable shortcut.

The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and
the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry
nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u`
survives; flatpak's exported entry is renamed to the app id by the standard mechanism.)

In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the
second half is the interesting one: argv stays withheld from GApplication as before
EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it
to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that
window instead of racing a second instance, and we wrote no IPC to get it. A URL that
lands during a cold start (before the model exists) is parked rather than dropped —
silently losing a link is the one outcome that would make the feature untrustworthy.

Routing is four lines of translation, because the decisions are the brain's: parse,
resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the
message a card click raises — the same wake, trust gate, and error surfaces — so there is
no second connect path to keep in sync. A link to a host we don't know, or know but never
pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how
"a URL may never pair or trust on its own" stays true while still being useful. Never
preempting a live session is checked here, since only this layer knows one is running.

Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=`
resolved the host by name, carried the launch id through the trust gate, and spawned the
session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:37:10 +02:00
enricobuehler c262bf43df refactor(host): three more Windows unsafe fns that had no caller contract
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
android / android (push) Successful in 12m8s
windows-drivers / probe-and-proto (push) Successful in 29s
ci / rust (push) Failing after 10m31s
ci / rust-arm64 (push) Failing after 11m1s
ci / bench (push) Successful in 5m40s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 16m4s
windows-host / package (push) Failing after 5m12s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 8m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m12s
flatpak / build-publish (push) Successful in 7m0s
deb / build-publish-client-arm64 (push) Successful in 9m59s
apple / swift (push) Successful in 5m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m28s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m32s
deb / build-publish-host (push) Successful in 14m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m45s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 30m14s
release / apple (push) Successful in 39m36s
apple / screenshots (push) Successful in 23m3s
Continues the crate-by-crate `unsafe_op_in_unsafe_fn` work. Same finding as
pf-frame: the useful move is often deleting the `unsafe fn` marker, not decorating
the body.

`install_steam_audio_pair` (no arguments) and `try_install_steam_audio(&str)` are now
safe fns. The call sites had already worked this out — both carried a SAFETY comment
opening "`install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
`newdev.dll`", i.e. the obligation was never the caller's. Making them safe deletes
14 lines of that prose from two call sites and moves the real reasoning next to the
`LoadLibraryExW`/`transmute`/call chain it actually describes. wasapi_mic.rs: 8 -> 0.

`make_job` (no arguments, and it wraps its handle in an `OwnedHandle` before the
first fallible step) and `open_log_handle(&Path)` are safe fns too. The latter
returns a raw `HANDLE`, but that is an ownership obligation, not a safety one —
nothing a caller does with it can cause UB, and it is inherited by the child and
closed there. service.rs: 19 -> 14.

`spawn_host` keeps `unsafe fn`: it takes a raw `HANDLE`, and an invalid one is UB.
That is a contract worth stating, so it keeps the marker.

Windows-only note: `LoadLibraryExW` here already passes
`LOAD_LIBRARY_SEARCH_SYSTEM32`, so the newdev.dll load cannot pick up a planted DLL
from the working directory — now recorded in the SAFETY note rather than left
implicit.

Verified on Windows .47: punktfunk-host clean at EXITCODE=0 — zero errors, zero
unused-unsafe. Crate's own files now 29 E0133 (interactive.rs 15, service.rs 14),
down from 42.
2026-07-28 21:31:42 +02:00
enricobuehler 307ca88ba7 refactor(frame): dxgi.rs to zero E0133, and two of its unsafe fns didn't need to be
First crate done under `unsafe_op_in_unsafe_fn`. 23 sites -> 0, and the file's
`unsafe fn` count drops 5 -> 2, because narrowing was the wrong answer for two of
them:

`enable_inc_base_priority` takes no arguments and touches only the current process's
own token. `hags_enabled` takes a `LUID`, a plain value with no pointer a caller
could get wrong, and builds every gdi32 argument struct locally. Neither leaves a
precondition for a caller to uphold, so neither should have been `unsafe fn` at all —
they are now safe fns with the unsafe confined inside, which deletes the obligation
from their callers rather than restating it. That is a strictly better outcome than
wrapping their bodies, and it is invisible if you treat the lint as a mechanical
find-and-wrap.

`d3dkmt_set_scheduling_priority_class` stays `unsafe fn`: it takes a `HANDLE`, and an
invalid one is UB. That contract is real, so it keeps the marker and gains narrow
blocks inside instead.

The rest is narrowing. The clearest win is `elevate_process_gpu_priority`, whose
`ONCE.call_once(|| unsafe { .. })` had 25 lines inside one block — a `match` on a
config enum, three `tracing!` arms and an early return — for exactly one call that
needed it. That block is gone; only the `d3dkmt_*` call carries `unsafe` now.

Every remaining block is scoped to the FFI call it covers and carries a SAFETY note
naming what makes it hold: which handle is live, which local the callee writes,
which size matches which variable.

Verified: Windows .47 pf-frame clean at EXITCODE=0 — zero errors, zero E0133, zero
unused-unsafe. Linux .21 pf-frame zero errors (dxgi.rs is Windows-gated, checked for
regressions in the shared parts).
2026-07-28 21:31:42 +02:00
enricobuehler 0a710fdfe5 refactor(frame): narrow make_device's unsafe to the FFI calls themselves
First slice of the `unsafe_op_in_unsafe_fn` work on the Windows-gated code, and a
deliberate demonstration of the shape the rest should take.

`cargo fix` is NOT the tool here. Its suggestion for this lint wraps the entire
function body:

    pub unsafe fn make_device(..) -> Result<..> { unsafe {
        ..40 lines..
    }}

One block spanning everything, `{ unsafe {` / `}}` bracing, and — the actual problem
— zero change in how much code runs unchecked. It makes the status quo explicit
rather than smaller, which is the opposite of the goal.

Narrowed by hand instead: four blocks, each around the FFI call that needs it
(`D3D11CreateDevice`, two `SetGPUThreadPriority`, `SetMaximumFrameLatency`), each
with its own SAFETY note. The `Option` handling, the `?`, the `if let Ok(..)` casts
and the `tracing!` calls are back outside, where the compiler checks them. Roughly 40
unchecked lines become 6.

Fixed two of my own mistakes in the same pass: `elevate_process_gpu_priority` and
`auto_priority_gate` are ALREADY safe fns, and I had wrapped their calls in `unsafe`
out of momentum. Unnecessary `unsafe` is not free — it is the same "this is scary"
noise the lint exists to remove, and the compiler only warns about it. That mistake
is a good argument against doing the remaining ~590 sites mechanically.

pf-frame drops 23 -> 19 E0133. The remaining three functions in this file need a
different answer, not just narrowing: `enable_inc_base_priority` takes no arguments
and touches only the current process's own token, so it has NO caller contract and
should become a SAFE fn with unsafe blocks inside — which removes an `unsafe fn`
rather than decorating it. Same likely for `hags_enabled(LUID)`, whose only argument
is a plain value.

Verified on Windows .47: pf-frame checks clean at EXITCODE=0, no errors, no
unused-unsafe warnings.
2026-07-28 21:31:42 +02:00
enricobuehler a3843b6996 test(encode): the VAAPI dmabuf path verified on AMD silicon, and its owner-only fields say so
Closes the last unverified leg of the AvBuffer work. `DmabufInner` was the heaviest
ownership change in the crate — four owned objects (DRM device, derived VAAPI device,
DRM-PRIME frames ctx, filter graph) whose eight failure branches each repeated the
same four-line unwind, once inside a macro, plus a ninth copy in `Drop` — and it had
no coverage at all. `vaapi_cpu_encode_smoke` does not reach it: that drives the
swscale/CPU-upload path, which uses `VaapiHw` and never builds a graph.

`dmabuf_inner_alloc_drop_cycles` loops construct/drop, which is the whole contract
now that every handle releases itself. **It passes on a Radeon 780M**, as do the two
pre-existing VAAPI tests, so `VaapiHw` and `DmabufInner` are both hardware-verified
rather than compile-only.

Also fixes three more owner-only fields the AMD box surfaced: `graph`,
`vaapi_device` and `drm_device` are `never read` since the hand-written `Drop` that
used to read them is gone. Same call as the decoders and the QSV pair — annotate
rather than delete (removing `graph` would free it while `src`/`sink` still point
into it) or underscore-rename (which hides what they hold). `drm_frames` is NOT
annotated: `submit` genuinely reads it per frame.

I had missed these twice: my Linux log greps searched "never constructed|never used",
which does not match "never read". The check now includes all three spellings.

Two environment notes worth keeping, both diagnosed by testing OUTSIDE our code
first (ffmpeg's own CLI reproduced each):
  * Inside a distrobox on an immutable host, VAAPI needs
    `LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri`. The container's mesa (25.3.6) is
    older than the host's (26.0.4) and every encoder open fails with a bare ENOSYS —
    "Function not implemented", naming nothing. The test doc says so.
  * `cargo check --workspace` in that container fails on `glib-sys` (no GTK dev
    headers). Unrelated to this branch; .21 checks the full workspace clean.

Verified: AMD .116 (Radeon 780M, Mesa 26.0.4) full pf-encode suite 33 passed / 0
failed plus all 3 ignored VAAPI hardware tests green. Linux .21 workspace check exit
0 / zero errors, pf-encode 33/0, pf-client-core 34/0, and zero dead-code warnings
across all three spellings.
2026-07-28 21:31:42 +02:00
enricobuehler 4241bc0383 fix(encode/windows): enable D3D11 multithread protection before libav sees the device
libav turns on `ID3D11Multithread::SetMultithreadProtected` in
`d3d11va_device_create` — the path where it creates the device itself. We take the
other path, `d3d11va_device_init`, because we hand it the capturer's existing
`ID3D11Device`, and that path does not. Nothing enabled it on our side either, so it
was simply off. Two consequences, one of them shipping since long before this branch:

QSV zero-copy could not open at all. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)`
ends in `MFXVideoCORE_SetHandle`, and MFX rejects a device without multithread
protection as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`. libav reports that as "Error
setting child device handle", which names neither the cause nor the cure.

AMF — the DEFAULT Windows zero-copy path — has been running with a lock that does
nothing. We deliberately leave `lock`/`unlock` null so libav installs its
`d3d11va_default_lock`, and that lock is `ID3D11Multithread::Enter`/`Leave`, which
are documented no-ops while protection is off. The lock libav installs to serialise
our capture thread against its encode thread has therefore never actually serialised
anything. That is the more serious half of this fix, and it is not QSV-specific.

Measured on the Intel VM (UHD 750, FFmpeg 7.1.5 + libvpl), sweeping device flags x
adapter x protection:

    BGRA            -> QSV derive FAILED (-16)
    BGRA +MT        -> QSV derive OK
    BGRA|VIDEO +MT  -> QSV derive OK

`D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference in either direction, and the
same result holds on both Intel adapters the box enumerates. Protection is the only
variable that matters, and it read back `was=false` every time, confirming nothing
else had enabled it. The hypothesis came from ffmpeg's own CLI succeeding at the
identical derive (`-init_hw_device d3d11va=d3d -init_hw_device qsv=q@d3d`) — the only
difference being who created the device.

With this, `zerocopy_qsv_alloc_drop_cycles` passes on real Intel silicon, which also
makes the QSV arm of the AvBuffer refactor hardware-verified rather than
compile-only. Its doc comment now points back here, since a future regression will
present as that same opaque "child device handle" line.

NOT flipped: `zerocopy_active` still defaults QSV off. The derive works and the
handles construct and release cleanly, but that is not the same as a validated
streaming session, and the default should move on glass evidence, not on this.

Verified on .47 (Intel UHD 750): full pf-encode --features amf-qsv suite 39 passed /
0 failed, d3d11hw_alloc_drop_cycles green, zerocopy_qsv_alloc_drop_cycles green, and
the native VPL path (qsv_encode_live_smoke) still green. Linux .21: workspace check
exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:42 +02:00
enricobuehler 2d78d2a514 test(encode): D3d11Hw verified on real silicon; the QSV derive is broken upstream of us
Ran the QSV probe on the Intel VM (UHD 750, FFmpeg 8). It fails — and not in the
code it was written to cover:

    [AVHWDeviceContext] Error setting child device handle: -16
    ZeroCopyInner::open(QSV) failed on iteration 0: derive QSV device from D3D11VA

That is `MFX_ERR_UNDEFINED_BEHAVIOR` out of `MFXVideoCORE_SetHandle`, raised inside
`av_hwdevice_ctx_create_derived` — libav's own code, called with the same arguments
as before this branch. Confirmed pre-existing by A/B: the same probe, written against
unmodified `origin/main` with the raw-pointer struct, fails byte-identically on the
same box at the same first iteration. The ownership change is not implicated.

It is also not the hardware. `qsv::tests::qsv_encode_live_smoke` — the native VPL
backend, untouched by this branch — encodes real H.264 on that box via VPL 2.15 with
its own D3D11 zero-copy. So QSV works; libav's QSV-from-D3D11VA derive is what does
not. That matches `zerocopy_active` already defaulting QSV **off** pending Intel
validation, and is now a measured fact rather than a suspicion. The test stays,
`#[ignore]`d, with the finding in its doc comment so the next Intel driver / FFmpeg
bump re-checks it instead of the question being quietly dropped.

What that leaves is real coverage of the half that IS reachable anywhere:
`d3d11hw_alloc_drop_cycles` loops construct/drop on `D3d11Hw`, the hwdevice +
frames-pool pair both Windows zero-copy vendors share, so it covers the AMF path's
ownership without needing AMD hardware. **It passes on the Intel box: 8 cycles, no
abort.** Adapter selection is factored into `test_hw_device`, which prefers a vendor
and skips the Microsoft Basic Render Driver — a punktfunk host enumerates our own
virtual-display adapter too, so `EnumAdapters1(0)` is not a safe assumption.

Verified: QSV VM .47 full `pf-encode --features amf-qsv` suite 39 passed / 0 failed
(EXITCODE=0), with `d3d11hw_alloc_drop_cycles` green on real silicon. Linux .21
workspace check exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:41 +02:00
enricobuehler 564797a12a test(encode): a QSV construct/drop smoke test, and AvFilterGraph goes Linux-only
Two loose ends ahead of running this on real Intel silicon.

`zerocopy_qsv_alloc_drop_cycles` covers the one ownership question in the crate that
was genuinely ambiguous. `ZeroCopyInner::open` builds a `D3d11Hw` and then DERIVES a
QSV device + frames ctx from it, and the tuple it used to return handed those two
derived pointers out twice — once as the encoder's args, once as the pair moved into
`Self`. Free for raw pointers, two owners for `AvBuffer`. Looping construct/drop is
what separates the outcomes: a double-unref aborts in the CRT, a missed one leaks an
Intel device per session. Nothing else reaches this code — `zerocopy_enabled`
defaults QSV OFF, and the native VPL backend supersedes this whole file unless
`PUNKTFUNK_QSV_FFMPEG=1` — so the test calls `open` directly and sidesteps both
gates, the same shape as `cuda_hw_alloc_drop_cycles`.

`AvFilterGraph` is now `#[cfg(target_os = "linux")]`. The Windows gate had been
reporting `struct AvFilterGraph is never constructed` since a960dff8 and I had been
filtering it out of my own log greps (searching for "never read", which does not
match "never constructed"). It is true: the VAAPI dmabuf path is the only
filter-graph user, and the AMF/QSV backends build no graph. Cfg'd out rather than
`allow`ed, so it cannot outlive its last caller unnoticed.

Verified. Windows .133: `cargo test -p pf-encode --features amf-qsv --no-run` at
EXITCODE=0 with the AvFilterGraph warnings gone (107 -> 105), and the test binary
lists `ffmpeg_win::tests::zerocopy_qsv_alloc_drop_cycles`. Linux .21: workspace
check exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed
/ 0 failed, and zero dead-code warnings there (AvFilterGraph is live on Linux).

NOTE for running it: the test binary needs `C:\Users\Public\ffmpeg\bin` on PATH at
RUNTIME. `FFMPEG_DIR` is build-time only, and without the DLLs the harness exits
silently with no output at all — which reads exactly like a test that does not exist.
2026-07-28 21:31:41 +02:00
enricobuehler e60fee7da6 refactor(encode): the QSV derived pair stops being two owners of the same pointer
`ZeroCopyInner` held the QSV device + frames ctx as nullable raw pointers, where
null meant "AMF, which feeds D3D11 frames directly" — a convention documented in a
comment and enforced by three `is_null()` checks. `Option<AvBuffer>` says it in the
type, so the checks and the hand-written `Drop` both go away.

The reason this one was left out of the previous commit is the aliasing. `open`
built a five-element tuple whose QSV arm handed the SAME two pointers out twice:
once as the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`.
For raw pointers that is free; for an owning type it is two owners and a
double-unref. Ownership and borrowing are now separated — the pair is owned in
`qsv_frames`/`qsv_device`, and the encoder's arguments are `as_ptr()` views taken
from whichever owner applies. `open_win_encoder` takes its own refs of what it is
handed, so lending transfers nothing.

Net: all 13 `av_buffer_unref` calls in this file are gone (the last two hand-written
Drops with them), including the encoder-open failure arm, which collapses to `?`
now that every handle releases itself.

Field order pinned and commented, as with the others: QSV frames, QSV device, then
`enc`, then `hw` — reproducing the old `Drop`, which ran ahead of all fields and so
released the derived pair before the encoder's AddRef'd copies and the D3D11 refs.

`qsv_device` picked up an `#[allow(dead_code)]`: `qsv_frames` is still read by the
send path, but the device is now purely an owner (the frames ctx and the encoder
each hold their own ref). Same call as the decoders — deleting it would free the
device early, an underscore name would hide it.

Verified on BOTH: Windows .133 `cargo check -p pf-encode --features amf-qsv
--all-targets` EXITCODE=0, no dead-code warnings, and confirmed non-vacuous (log
shows `Removed 23 files, 36.2MiB` then a real `Checking pf-encode`). Linux .21
`cargo check --workspace --all-targets` exit 0 / zero errors, pf-encode 33 passed /
0 failed, pf-client-core 34 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` still
passing on real CUDA. The QSV path itself still wants Intel silicon to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler 5b142a7e85 refactor(encode): the Windows D3D11VA hwdevice owns its refs
`D3d11Hw::new` is the third instance of the same shape as `CudaHw` and `VaapiHw` —
alloc a hwdevice, deref to fill it, init, alloc a frames ctx, deref, init — with the
same hand-written unwind: three `av_buffer_unref` calls spread over the failure
branches plus a `Drop` repeating the pair. It gets the same treatment, so all three
libav hwdevice wrappers in the crate now share one release path.

Field order carries the semantics here as in the other two: frames declared before
device, so declaration-order dropping reproduces what the hand-written `Drop` did.

`ZeroCopyInner`'s `qsv_device`/`qsv_frames` are deliberately NOT converted in this
commit. They are nullable (null for AMF, which feeds D3D11 frames directly), and the
QSV branch hands the same two pointers out twice — once as the encoder's
`dev_ref`/`frames_ref` and once as the owned pair moved into the struct. Modelling
that needs `Option<AvBuffer>` plus a borrow/own split so the aliasing does not become
two owners, which is a different change from the mechanical one this commit makes.

Verified on the Windows runner .133: `cargo check -p pf-encode --features amf-qsv
--all-targets` at EXITCODE=0. Confirmed non-vacuous — the log shows `cargo clean -p
pf-encode` removing 23 files / 36.2 MiB followed by a real `Checking pf-encode`,
because a fast green on that box can otherwise just be cargo reusing artifacts whose
timestamps outrank the freshly-extracted sources.
2026-07-28 21:31:41 +02:00
enricobuehler 06a406adf2 refactor(client): the D3D11VA decoder owns its refs too, and the owner-only fields say so
Completes the decoder trio. `D3d11vaDecoder::new` had the same cascading unwind as
the VAAPI and Vulkan constructors — four branches each unref'ing the hwdevice by
hand — and `d3d11va_decode_supported` opened a frames context it released on one
exit path while relying on the null check for the other. Both are `AvBuffer` now.
The one surviving `av_buffer_unref` in the file is deliberate: it runs before
ownership is taken, on the `av_hwdevice_ctx_init` failure ahead of `from_raw`.

The Windows gate then caught something worth keeping: `field hw_device is never
read`, in all three decoders. It was true and it was new — the hand-written `Drop`
used to be the field's only reader, so moving the unref into the type left a field
that is *purely* an owner. The compiler cannot express that, so each one now
carries a doc comment saying what it is and an explicit `#[allow(dead_code)]`.
Deleting the field would free the device early; renaming it `_hw_device` would
silence the lint by hiding what it holds. Neither is what we mean.

Verified on BOTH platforms this time. Windows runner .133 (the toolchain env from
the runner notes, plus `PF_FFVK_VULKAN_INCLUDE` for pf-ffvk): `cargo check -p
pf-client-core --all-targets` at EXITCODE=0, clean of the dead-code warnings. Linux
.21: `cargo check --workspace --all-targets` exit 0 / zero errors, pf-client-core
34 passed / 0 failed, pf-encode 33 passed / 0 failed.
2026-07-28 21:31:41 +02:00
enricobuehler a42ab075a8 refactor(client): the hardware decoders own their hwdevice ref
`VaapiDecoder::new` and `VulkanDecoder::new` create a hwdevice and then do several
more fallible things with it — resolve `vkWaitSemaphores`, find the decoder, alloc
the context, open it — and every one of those branches unref'd the device by hand,
with a `Drop` doing it once more. Six hand-written unrefs between them, each one a
line somebody has to remember when adding a step.

`video_libav::AvBuffer` owns it instead, so an early `bail!` releases it and the
branches carry nothing. It is a deliberate second copy of `pf-encode`'s type: the
crates do not depend on each other (host encode and client decode share no code
path), and this one needs something the host's does not — see below — so hoisting
it to a shared crate would mean giving that crate an ffmpeg dependency and both
sets of semantics to save about twenty lines.

That extra piece is `into_raw`. `pick_vulkan` hands its frames context to the codec
(`(*ctx).hw_frames_ctx = fr` — the codec unrefs it when the context closes), so the
wrapper must give up ownership rather than drop. Making the transfer explicit is
the point: dropping an `AvBuffer` there too would be exactly the double-unref this
type exists to prevent.

Two `av_buffer_unref` calls survive in `video_vulkan.rs` on purpose. One runs before
ownership is taken (the `av_hwdevice_ctx_init` failure, ahead of `from_raw`); the
other releases the codec's OWN pre-existing frames ctx before we replace it, which
was never ours to model.

Field order preserved: `hw_device` stays declared after `ctx`, so it still releases
after each `Drop` frees packet/frame/context — the order the hand-written unref had.

Verified on .21 (CachyOS, FFmpeg 62): `cargo check --workspace --all-targets` clean
at exit 0 with zero errors — the workspace-wide check owed since the AvBuffer
commit — plus pf-client-core 34 passed / 0 failed and pf-encode 33 passed / 0
failed. The decoders themselves still need real VAAPI/Vulkan playback to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler eb9c5be20d refactor(encode): the VAAPI dmabuf path stops unwinding by hand
`DmabufInner::open` builds four owned objects — a DRM device, a VAAPI device
derived from it, a DRM-PRIME frames context, and a filter graph — and every one of
its eight failure branches unwound them by hand. The same four-line block
(`avfilter_graph_free` + three `av_buffer_unref`s) appeared eight times, once
*inside a macro*, plus a ninth copy in `Drop`. Adding a step to that function meant
remembering to extend the unwind at exactly the right depth; getting it wrong leaks
a device per failed session (the persistent listener accumulates them) or frees one
twice.

All eight are gone. `AvBuffer` already owned the buffer refs; `AvFilterGraph` now
does the same for the graph, so each handle is owned the moment it exists and an
early `bail!` releases whatever was built so far. `open` lost ~40 lines of cleanup
and gained none.

Field order in `DmabufInner` is load-bearing and says so: graph, frames, VAAPI
device, DRM device, then `enc` LAST. Fields drop in declaration order, and that
sequence reproduces the old hand-written `Drop` exactly — including that it ran
ahead of every field, so all four were released before ffmpeg-next dropped the
encoder. Everything here holds its own reference, so refcounting makes any order
sound; the ordering is pinned so a future reorder cannot quietly change what ships.

One subtlety preserved deliberately: the buffersrc parameters take `drm_frames`
BORROWED, not ref'd (`av_buffersrc_parameters_set` takes its own ref). That is now
`drm_frames.as_ptr()` — same borrow, same single owned ref, no new leak.

Verified on .21 (CachyOS, RTX 5070 Ti, FFmpeg 62): `cargo check -p pf-encode
--all-targets` clean at exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` still passes against real CUDA. vaapi.rs now contains
zero `av_buffer_unref` and zero `avfilter_graph_free` calls, down from 40 and 8.
The dmabuf path itself still needs AMD/Intel silicon to exercise end to end.
2026-07-28 21:31:41 +02:00
enricobuehler 7adc1db672 refactor(encode): AVBufferRef ownership moves into an RAII handle
`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it
to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand:
`av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a
hand-written `Drop` repeating the pair. That shape has two failure modes and the
compiler can see neither: add a branch and forget the cleanup (leak), or let two
cleanup paths run (double-unref, an abort inside glibc).

`libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check
each caller open-coded — and unrefs exactly once on drop, so an early `?` releases
whatever was built so far and the failure branches carry no cleanup at all. Both
hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven
`av_buffer_unref` calls to zero.

The one subtlety, called out at both structs: these two fields must stay declared
frames-BEFORE-device. Fields drop in declaration order, and the code being replaced
deliberately unref'd frames first (a frames ctx holds its own reference on its
device). Refcounting makes either order sound, but a field reorder should not
silently change what ships, so the ordering is load-bearing and commented as such.

Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the
NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI
twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the
double-unref (abort) and the leak (allocator growth) this refactor is about.

Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at
exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight
construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the
identical shape but no AMD/Intel box was reachable to run its two ignored tests.
2026-07-28 21:31:41 +02:00
enricobuehler 5219107177 chore(unsafe): the workspace adopts the drivers' unsafe discipline
`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.

Three things, all mechanical:

* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
  (`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
  tools). These were clean by accident, not by contract; now they are clean by
  contract.

* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
  the CALLER must uphold — it was never meant to switch off checking for the whole
  body. Measured fallout is 300 sites on Linux, and they are concentrated: six
  files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
  `pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
  while those six are worked down; it flips to `deny` once they are. This is also
  the Rust 2024 default, so it pays off the edition migration early.

* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
  site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
  is parameterless, always succeeds and touches no memory, so there is no contract
  for a caller to uphold and no reason for the unsafe to be visible eight times.
  One `unsafe` behind a safe wrapper, none at the call sites.

Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
2026-07-28 21:31:41 +02:00
enricobuehlerandClaude Opus 5 37edb1cc1a refactor(clients): both shells wake through the shared state machine
audit / bun-audit (push) Successful in 21s
ci / web (push) Successful in 1m39s
ci / docs-site (push) Successful in 2m7s
audit / cargo-audit (push) Successful in 2m55s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m47s
ci / rust-arm64 (push) Successful in 10m28s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
windows-host / package (push) Failing after 11m22s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
deb / build-publish-client-arm64 (push) Failing after 6m0s
deb / build-publish (push) Successful in 13m3s
arch / build-publish (push) Successful in 16m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
flatpak / build-publish (push) Successful in 6m50s
android / android (push) Successful in 19m52s
deb / build-publish-host (push) Successful in 16m54s
ci / rust (push) Successful in 22m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m33s
release / apple (push) Successful in 30m41s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
apple / screenshots (push) Successful in 22m37s
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.

Behavioural review, since a sleeping host to test against wasn't available:

- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
  like every other implementation. Up to half a second slower to notice a host coming
  back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
  and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
  packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
  budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:01:47 +02:00
enricobuehlerandClaude Opus 5 983ff8e78d fix(session/console): a profile must not advertise a cursor channel the presenter isn't drawing
Found reviewing the P0 wiring. The console builds its window and input models ONCE, from
the global defaults, and they live across every launch — but each launch now re-resolves
the host's profile. So a profile that sets `mouse_mode: desktop` on a host, while the
global stays `capture`, made the session advertise `CLIENT_CAP_CURSOR` ("this client
draws the host cursor itself") to a presenter latched in capture mode, which draws no
cursor: the host stops compositing the pointer and the stream has no visible cursor at
all — the exact failure the capability's own doc comment warns about.

The advertisement now follows the LATCHED mouse model, which is the one that is actually
true of the presenter. Everything the host is told about the stream itself — mode,
bitrate, codec, audio, pad — keeps honoring the binding, and the comment says which
fields are latched and why, so the console's presentation-tier gap is documented rather
than rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:46:29 +02:00
enricobuehlerandClaude Opus 5 eab4829630 feat(client): the brain layer — one connect plan, one wake machine, one spawn
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today —
GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment
says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the
deep-link and profile work was about to make it five. `pf-client-core` already held the
ingredients (wol, discovery, trust, library, session); what was missing was the layer
that composes them, so every front-end composed them itself.

`ConnectPlan` is a resolved intent: which host, which pin, which launch id, which
profile, the effective settings, whether to wake. One constructor per door — a card
click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in,
plan out), which is what lets the URL router be tested without a config directory;
`for_host` is the convenience that loads them.

`plan_from_link` is where the deep-link security rules actually live, rather than in
each shell: a contradicted fingerprint refuses and says which host it was about, an
ambiguous name refuses instead of picking the first, a profile the catalog can't honor
refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host
becomes a confirmation sheet carrying the claimed name and expected pin, so the first
connect is verified rather than blind TOFU. Preempting a live session stays with the
caller: only the front-end knows one is running.

`WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and
every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the
end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function
each front-end drives it from its own loop and they still agree, and the whole cadence
is testable without waiting 90 seconds.

Session spawn, its argv and its stdout contract move here too, and the GTK shell now
spawns through them — so "which flags does a stream get" and "what does ready mean" have
one answer for the shells, the console and the coming CLI. `--profile` deliberately does
NOT ride a plain card click: the session resolves the host's binding through the same
helper, and passing it would be a second source of truth for one decision.

Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the
product and the handoff wants it verified on glass with a genuinely sleeping host, which
this session couldn't do — so the state machine lands, the duplication stays until it
can be verified away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:45:07 +02:00
enricobuehlerandClaude Opus 5 3350d3cfbf feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:

    punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]

The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.

Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.

`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.

The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:32:32 +02:00
enricobuehlerandClaude Opus 5 a2be3f4de9 feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:22:33 +02:00
enricobuehlerandClaude Opus 5 33a31427ae fix(vdisplay): locks held across seconds-long work, an AB/BA inversion, and a panic that poisons the manager
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m9s
ci / bench (push) Successful in 5m30s
windows-host / package (push) Failing after 7m52s
windows-host / winget-source (push) Skipped
android / android (push) Successful in 12m10s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
ci / rust-arm64 (push) Successful in 13m14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 9m5s
docker / build-push-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 25s
arch / build-publish (push) Failing after 17m12s
deb / build-publish-client-arm64 (push) Successful in 8m56s
ci / rust (push) Failing after 17m13s
deb / build-publish-host (push) Successful in 10m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m39s
apple / swift (push) Successful in 5m32s
apple / screenshots (push) Successful in 25m53s
Phase 7's contained half. 7.1's writer split and 7.3's reservation are NOT here — see
the plan; both need more than a local edit.

**Lock-order inversion, gamescope.** `create_managed_session_steamos` held
MANAGED_SESSION while taking STEAMOS_TOOK_OVER (and `persist_takeover`'s locks), while
`do_restore_tv_session` takes STEAMOS_TOOK_OVER first and MANAGED_SESSION second —
AB/BA between a connect and the restore worker, which genuinely run concurrently
(`vd.create` runs off the registry lock; the worker is its own thread). The connect
path now drops its guard before the pair and re-acquires after `poll_managed_node`, the
shape `create_managed_session` already used. And `takeover_live` held all four statics
at once — in a `||` chain every `.lock()` temporary lives to the end of the statement —
for four reads; scoped bindings drop each guard before the next, taking it out of the
ordering graph entirely.

**`observe_session_instance` decided and acted under one lock.** It held LAST_INSTANCE
across `invalidate_backend` (which drops keep-alive displays through the registry lock)
and a `systemctl` shell-out on a 10 s budget, from three call sites including a
per-second watcher. It decides under the lock and acts outside now; the baseline is
advanced inside it, so a concurrent observer cannot run the action twice.

**`admit` held the live-session table across the budget checks** — `manager::snapshot()`
blocks on the manager `state` lock, which is itself held across DDC round trips and
three 3 s activation ladders. Every connect, disconnect and mgmt read queued behind one
slow display operation. The guard is scoped to `decide` alone.

**GameStream never registered**, so its display was invisible to both Windows budgets
(they gate on `!live.is_empty()`) and a native connect could be admitted past capacity
the box had already spent. It registers now, anonymously, for the session's lifetime.

**`ensure_exclusive_watch` panicked on a failed thread spawn** while holding BOTH
`exclusive_watch` and (via its caller) `state` — poisoning the two locks the whole
manager runs on, so every later acquire and mgmt read would panic on `.lock().unwrap()`.
It logs and degrades instead: no re-assert watchdog is a degradation, a wedged manager
is not.

Also fixes two things only a Windows build shows, both mine: Phase 2.3 left
`gamescope_route` unused there (every reader is Linux-gated), which `-D warnings` in
Windows CI would have rejected — I had only run the host clippy on Linux. And Phase
5.4's helper-safening reaches a FOURTH crate, pf-inject, which xcheck does not lint.

Verified on .173: `cargo clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean
across the whole tree; local fmt, both xcheck targets and 53 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 093c66cb1a fix(vdisplay/windows): a failed resize left a phantom monitor, and a refresh-only one silently didn't happen
Three defects in the mid-stream resize path.

**A failed re-arrival put a DEAD monitor back in the slot.** `re_add` REMOVEs the old
driver monitor before it ADDs the new one, so its only fallible step past that point
means the old monitor is gone — yet the caller answered an `Err` by re-inserting the
`Monitor` struct it had handed in, complete with a dead `key`, a departed `target_id`
and a stale `gdi_name`. Every later `acquire` then joined a monitor that did not exist.

It now rolls back: on ADD failure it re-ADDs at the OLD mode, so the session keeps
streaming what it already had. The outcome is a three-way `ReAdd` enum because the
distinction matters and a `Result` cannot carry it — `Arrived` stores the new monitor,
`RolledBack` stores the RECOVERED one (a re-arrival mints a fresh `target_id`, so the
struct the caller handed in is stale either way), and `Lost` means both ADDs failed and
the slot must be left EMPTY so the next acquire creates a monitor rather than joining a
phantom.

**A refresh-only change reported success at the old refresh.** `wait_mode_advertised`
compared resolution only, so a same-WxH new-Hz request answered "already advertised":
the fast path skipped the `IOCTL_UPDATE_MODES` that would have taught the driver the new
rate, `set_active_mode` fell back to the best advertised rate <= requested, and the
resize returned Ok. It matches `dmDisplayFrequency` now, so an unadvertised refresh
routes to UPDATE_MODES / the re-arrival instead.

And `mon.mode = mode` recorded what was ASKED FOR. `set_active_mode` deliberately falls
back to a lower advertised refresh rather than lose the client's resolution, so the
field could claim a rate the display was not running — and it is what the next resize
diffs against and what `/display/state` reports. It records what actually committed now,
read back via the new `active_mode` (`active_resolution` delegates to it), and logs when
the two differ. Deliberately NOT done by tightening `wait_mode_settled`: that gates the
re-arrival fallback, so requiring an exact refresh there would force a full re-arrival
every time the OS legitimately picked a lower rate.

**A short IOCTL_ADD reply leaked an IddCx slot.** The IOCTL had SUCCEEDED — the driver
already created the monitor and took a slot; only the reply was short — and the bail
undid none of it. The slot pool is small enough that ~16 leaks wedge every later ADD at
0x80070490, which is the wedge the ghost-reap in this same function exists to recover
from. It now issues the compensating REMOVE with the session id already in scope, and
says so at error level if that fails too.

Windows runner: clippy --all-targets clean, pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 50a153b72a refactor(win-display): the CCD helpers were unsafe fn for serialization, not soundness
`pf-win-display`'s 21 CCD/GDI helpers were `pub unsafe fn` without a memory-safety
obligation between them. Every one takes `Copy` scalars or borrowed Rust data, returns
owned values, and discharges its FFI preconditions internally —
`retry_set_display_config` even binds the input desktop itself, which is the one thing
a caller could plausibly get wrong. Their own `# Safety` sections say as much: one
reads "safe to call from any thread", and the rest say "call under the manager `state`
lock". That is a SERIALIZATION requirement — calling one unlocked races the topology
mutator and returns a stale answer, a correctness bug, not undefined behaviour.

Encoding it with `unsafe fn` cost 55 `unsafe` blocks across THREE crates whose proofs
could only restate "this takes a u32 and returns a String". That is how `unsafe` stops
meaning anything: the blocks that wrap a real FFI obligation read exactly like the ones
that wrap nothing. The requirement is now prose on the functions that have it, and
`unsafe` marks only the FFI calls inside the helpers.

win_display.rs 97 -> 63 `unsafe`, manager.rs 56 -> 30, pf_vdisplay.rs 34 -> 32, plus 10
sites in pf-capture's IDD-push paths. 55 blocks and 55 orphaned `// SAFETY:` proofs
gone; `#![allow(clippy::missing_safety_doc)]` with them, since nothing is `pub unsafe
fn` any more.

Two things the compiler handed back once the blocks went, both of which existed only to
carry a proof: the `let-else` parens in `force_mode_reenumeration` and
`apply_source_positions`, and the nested `if` in `wait_mode_settled` — which is `&&`
again, still short-circuiting, so the second CCD query on a 25 ms poll is unchanged.

Scope note: the sweep and the plan both scoped this as two crates. It is three —
pf-capture calls the same helpers from 10 sites, and `unused_unsafe` under `-D warnings`
makes that non-optional. Found by re-deriving the count, which the plan asked for
because the underlying finding had been REFUTED in verification; the refutation was
wrong about the magnitude (22 of manager.rs's 45 blocks, against its claimed 24).

Verified on the Windows runner: clippy --all-targets clean over pf-frame,
pf-win-display, pf-capture and pf-vdisplay; pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 029979e824 fix(vdisplay/windows): own the device handle, prove the ladder once, and fix two proofs that were wrong
Three of Phase 5's four items; 5.4 is held for a decision (see below).

`open_device` is a SAFE fn returning an `OwnedHandle`. It never had a caller
obligation — it takes no arguments and every precondition is internal — so the
`unsafe fn` it used to be pushed a proof burden onto four call sites with nothing to
prove, and handed back a raw `HANDLE` that two of them re-owned by hand with
`CloseHandle`. That shape has already leaked once in this file (the wrap-IMMEDIATELY
comment in `open` records it). Ownership is now a `Drop`, `probe()` is
`open_device().map(|_| ())`, `is_available()` is `open_device().is_ok()`, and
`CloseHandle` is no longer imported here at all.

`resolve_target_gdi` ran the same 60 × 50 ms poll three times verbatim, and the 2nd and
3rd copies documented themselves as "SAFETY: as the resolve loop above" — a pointer to
a proof rather than a proof, which rots silently the moment the block it points at
moves. One `poll_gdi_name`, one proof, and the three-stage ladder now reads as the
three stages its doc describes.

Two proofs were simply wrong, both discharged everything except the one obligation
that mattered:

* The SetupAPI detail buffer was a `Vec<u8>` (align 1) written through as
  `SP_DEVICE_INTERFACE_DETAIL_DATA_W` (align 4). The SAFETY comment proved bounds and
  aliasing and was silent on alignment. Now a `Vec<u64>`. Its guard also compared the
  required size against `size_of::<u32>()` while stamping
  `size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>()` into `cbSize` — checking a different
  number than the one it promises the OS.
* `GetMonitorInfoW` was handed `&mut info.monitorInfo`: `cbSize` promises 104 bytes but
  that pointer's provenance covers the 40-byte `MONITORINFO` prefix, so everything the
  OS writes past byte 40 — which is `szDevice`, the only field the caller reads — is out
  of bounds for it. A compiler may assume those bytes were untouched and fold the zeroed
  initializer into the reads, i.e. return an EMPTY device name. That name is what
  `panel_off_except`'s exclusion compares against, so an empty one darkens the panel it
  is meant to spare. The pointer now carries the full struct's provenance.

pf_vdisplay.rs 42 -> 34 `unsafe`, manager.rs 58 -> 56. Windows runner: clippy
--all-targets clean, 48 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 f94191d927 fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made
Both wlr-family backends need ONE key set in a config file the user also owns —
`chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's
`screencopy { … }`. Both did it by `fs::write`-ing a COMPLETE file over whatever was
there, so every other setting the user had in
`~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed
on first connect — silently, permanently, no backup.

They now set that one key in place and keep every other line byte-for-byte, with a
one-time `create_new` backup the first time we touch a file we did not write (once, so
a later edit cannot overwrite the user's ORIGINAL with our own earlier output).

The merge lives in its own module because a merge is only worth doing if it cannot
corrupt what it merges into: seven tests cover the three cases per grammar — block
absent, key present, key absent — plus idempotence (which is what keeps the caller
from restarting the portal on every connect) and the empty-file shape. Four of them
FAIL against the old whole-file write, so they are not vacuous. The module is declared
unconditionally though only Linux calls it: the logic is pure string handling, and
tests that guard a user's real config should run on every platform's CI, not only
where the callers compile.

Separately, seven call sites resolved the runtime dir as
`env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())` while their own doc
comments promised the opposite — "per-user, 0700 — NOT a world-writable /tmp path
another local user could pre-create or rewrite between our write and xdph's read". One
of those paths is an executable xdph then RUNS. `Ok("")` was the same defect's other
half: it yielded a path relative to the process CWD rather than any runtime dir. All
seven now go through one resolver that applies the rule `session.rs` already had
(non-empty, else `/run/user/<uid>`, never /tmp). The picker shim also gets its mode at
CREATION rather than a chmod afterwards, so it is never briefly world-readable while
being a file something else executes.

And identity.rs was the only writer in the workspace creating
`pf_paths::config_dir()` — which holds the host private key, the pairing allow-list and
the mgmt token — with a plain `create_dir_all` instead of the 0700 `create_private_dir`
every other writer uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 2c015bba30 fix(vdisplay/windows): three teardown gates that each ended with the operator's panels dark
All in the same ~120 lines, all the same shape: a recovery leg gated on the wrong
flag, so the path that turned something off had no path that turned it back on.

**The DDC wake was nested inside the CCD-restore arm.** Panels are commanded dark over
DDC/CI *before* the isolate, but `panel_on_all()` sat inside
`if let Some(saved) = ccd_saved.take()` — and `isolate_displays_ccd` returns `None`
whenever its `query_active_config` fails. So a failed isolate meant the panels were
never woken, and because the link stays live there is no returning signal to trigger a
DPMS wake either: dark for the rest of the host's life. Hoisted out of that arm, next
to the `pnp_disabled` restore which was already outside it for exactly this reason.

**A later member's isolate could deactivate the physicals with nothing able to restore
them.** If the FIRST member's isolate failed, `ccd_saved` stayed `None` and
`ccd_exclusive` `false`; a second member's isolate then succeeded, blanked the
physicals, and discarded its snapshot as "the group restores the first member's" —
except there wasn't one. Teardown's restore is gated on `ccd_saved`, and the re-assert
watchdog never started. The group now adopts the first SUCCESSFUL isolate's snapshot.

**A non-last-member teardown ran the exclusive isolate on a Primary group.** The gate
was `ccd_saved.is_some()`, but `Topology::Primary` stores a snapshot too (from
`set_virtual_primary_ccd`) — so a shrink cleared `DISPLAYCONFIG_PATH_ACTIVE` on every
non-kept path, blanking precisely the physical displays `Primary` exists to keep lit.
It keys off `ccd_exclusive` now (what the re-assert watchdog already checks), and a
Primary shrink re-promotes a survivor instead, since the departing member may have
been the one holding primary.

Also: `apply_group_layout` had only ever run from `acquire`, so a member LEAVING never
re-arranged the survivors — they kept the origins computed while the departing monitor
was still between them, leaving a dead gap in the desktop until the next acquire. Now
called at the end of `teardown_removed`, which is where the slot map has already shrunk
and after the REMOVE, so it covers all six teardown paths at once.

The shrink gate is extracted as `shrink_action` so it can be pinned without a driver or
a desktop — manager.rs had no tests at all, which is how a gate keyed on the wrong flag
survived. Verified on the Windows runner: 48 passed (was 46), and both new tests FAIL
against the old gate, so they are not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 8b076f5cc9 fix(vdisplay): the gamescope sub-mode is a per-session value, not a process-global env write
Completes what the env-lock fix started. `apply_input_env` published its decision into
PUNKTFUNK_GAMESCOPE_NODE/_SESSION and `GamescopeDisplay::create` read it back out —
with the lock released in between, and with the GameStream plane, the mid-stream
switch watcher and the capture-loss rebuild all re-running the writer. So session B's
routing decision could retarget session A's `create`: A asks for a bare spawn, B
connects, A attaches to B's session instead.

The decision now travels as a `GamescopeRoute` return value and is carried on the
backend INSTANCE via `set_gamescope_route` — the discipline `set_launch_command`
already documents two lines above it ("Carried on the backend instance — NOT a
process-global env var — so concurrent sessions can't stomp each other's launch
target"). Nothing writes the two keys any more; they are read once, as operator
overrides, and `create`/`poolable_now`/`launch_is_nested` all answer from the
session's own route.

The route carries its payload rather than just the verdict, so the managed session
flavour and the attach node id stop being separate env reads too.

Threaded through both planes: handshake -> SessionContext -> `vd`, and the GameStream
`open_source` -> `run`. Three re-resolution points needed the same treatment and each
would have silently downgraded a session to a bare spawn without it — the switch
watcher's rebuilt backend, the capture-loss rebuild (which can swap the whole
instance), and the operator-pinned path, which deliberately skips the input-backend
write but still needs a route. That last one is why the ladder is split out as
`resolve_gamescope_route`: a `PUNKTFUNK_COMPOSITOR` pin must not silently mean "bare
spawn" on a box pinned to the managed session.

`#[must_use]` on both entry points found the capture-loss site, which I had missed.

Verified on 192.168.1.21: whole workspace `cargo check -p punktfunk-host`, clippy
--all-targets clean over the host and pf-vdisplay, 100 pf-vdisplay tests, and the live
GNOME create/drop still green. Windows + Linux xcheck clean on the Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 80e57c70be fix(vdisplay): the env lock covered its writers and not its readers, and the gamescope ladder read back its own output
Two halves of the same problem — process env used as shared mutable state.

`apply_session_env` set XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY /
HYPRLAND_INSTANCE_SIGNATURE / SWAYSOCK under ENV_LOCK, while detection read those same
five keys with no lock at all, from five points spread across a `/proc` scan. That is
exactly the hazard ENV_LOCK's own doc names: glibc `setenv` can realloc `environ` and
free the old value string, so a concurrent `getenv` reads freed memory — UB documented
there as "could crash the host". It is reachable today: the host's session watcher
calls `detect_active_session` every second for a stream's lifetime while a second
client's connect runs `apply_session_env` on a spawn_blocking thread.

Detection now samples all five into an `EnvProbe` in ONE locked read and scans from
that snapshot. Taking the lock around the whole of `detect_active_session` would have
been simpler and wrong: it walks `/proc` every second, and holding a process-wide lock
across a directory walk trades this problem for the one Phase 7 is about. The readers
become pure functions of their inputs, which is also what let the tests stop mutating
process env to steer them — `without_inherited_swaysock` is gone, replaced by handing
in a probe. Empty values are now treated as absent, so `XDG_RUNTIME_DIR=""` falls back
to /run/user/<uid> instead of yielding a relative path.

The gamescope sub-mode ladder had a nastier version: `apply_input_env` WRITES
PUNKTFUNK_GAMESCOPE_NODE/_SESSION to publish the sub-mode it chose, and read the same
keys back as operator overrides. The Attach arm sets `_NODE=auto`, and `node_env` sits
at rung 2 of the ladder — above `dedicated_launch` at rung 3 — so one Attach decision
latched Attach for the rest of the host's life and silently overrode
`game_session=dedicated`. Only rung 1 could escape it, because the Spawn arm that
would have cleared the keys sits below the rung that by then always fired. The
overrides are now sampled once, before this module has written anything, which keeps
their actual meaning: "the operator set this before we ran". The live reads that
remain (`launch_is_nested`, `poolable_now`) are deliberate — those consume the
published decision.

And gamescope's `create` read both keys unguarded, racing the writer and skipping the
lock its two siblings take for the same keys; it now takes one guarded snapshot.

Verified on Linux (.25): 100 passed, 1 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:36 +02:00
enricobuehlerandClaude Opus 5 787756e68b test(vdisplay/mutter): the on-glass lever for the GNOME backend, and what it took to see the leak
`live_mutter_create_drop`, `#[ignore]`d in the same convention as the Windows
backend's live tests: the real four-step handshake, hold, then drop the keepalive and
let the RAII teardown revert it. Verified against gnome-shell on 192.168.1.21 —
node_id came back in ~5 ms, preferred_mode as asked, and the topology reverted.

Two things that would otherwise be re-derived by whoever validates this next, both of
which cost a wrong answer here first.

The virtual monitor does NOT appear in DisplayConfig's GetCurrentState just because
`create` succeeded — its size follows the PipeWire negotiation, so without a consumer
attached there is no monitor to census. A before/after monitor diff therefore proves
nothing about this backend, which is the trap the first attempt fell into.

And a short-lived process cannot exhibit the abandoned-thread leak at all: exiting
drops the D-Bus connection, which makes Mutter tear the session down no matter which
code is running. Reproducing it needs the process held alive past the failed create,
and the observable is the live RemoteDesktop session object, not the monitor:
`gdbus introspect -d org.gnome.Mutter.RemoteDesktop -o /org/gnome/Mutter/RemoteDesktop`
lists a `node Session` child per live session.

With a 1 ms create timeout forced and the process held 20 s, that probe reads 0 with
b89a36a6 and 1 without it — the orphan thread parked on a live session. Both read 0
after exit, which is precisely why this only ever bit a long-running host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 69bc92afed fix(vdisplay/mutter): a create timeout no longer abandons the session thread with the desktop dark
`create` gave up after 45 s and dropped its `stop` flag WITHOUT setting it — the
`StopGuard` that owns the only `store(true)` was built on the success path only. The
thread meanwhile discarded its own send failure with `let _ =`, so it sailed past the
dead channel, made the virtual output PRIMARY, and parked in `while !stop` for the
rest of the process's life holding the D-Bus connection that IS the monitor's
lifetime. Its teardown was unreachable.

Under the default topology that orphan applies a SOLE-monitor APPLY_TEMPORARY config.
Every physical head goes dark, and Mutter reverts such a config only once the virtual
monitor disappears — which the parked thread prevents. There is no in-process
recovery: each retry adds another orphan and another virtual monitor.

The other three portal/D-Bus backends never had this — kwin.rs, hyprland.rs and
wlroots.rs all `?` on that exact send. Mutter is now the same shape, via a shared
`report_node` that stops the session when the opener has gone. Two more layers behind
it, because the send can also LAND in the moment `recv_timeout` gives up: `create`
and `stream_existing_output` build their guards BEFORE the wait, so every error arm
signals the thread on its way out, and the thread re-checks the flag before it touches
topology — so a doomed session never applies a sole-monitor config at all, rather than
applying one and reverting it a tick later.

The fix lands once because the duplication that caused it is gone first: `connect` and
`connect_monitor` shared ~72 verbatim lines, and the mirror path repeating the session
path's `let _ = send` is precisely what that copy bought. Both now share `open_rd_sc`
(steps 1-2) and `start_and_await_node` (step 4).

Also stops a physical hotplug stealing the virtual monitor's identity.
`wait_virtual_connector` took "any connector absent from the pre-snapshot" across a
window spanning a ~10 s stream wait plus 6 s of polling; TOPOLOGY_LOCK keeps sibling
sessions out of it but a hotplug is not ours to serialise, and the session's topology
apply and scale persistence would then be aimed at the operator's new panel. It now
prefers the connector advertising the client's exact WxH — the discriminator
`make_virtual_primary` already matches on — falls back to the old behaviour when that
singles nothing out, and warns when more than one candidate appears.

Tests: `pick_virtual` split out of the polling loop so the choice is testable without
a session bus. Verified on Linux (192.168.1.25): 99 passed, and the hotplug case FAILS
against the old first-new-connector logic, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 e7dc7e9d18 fix(win-display): every CCD/GDI operation now carries a proof, not just the blocks that needed one
Companion to c16f2850, which closed the same hole in pf-vdisplay. The file header
claims "Every `unsafe` block in this file carries a `// SAFETY:` proof", and that
was true — it just did not cover the 84 unsafe OPERATIONS sitting directly in
`unsafe fn` bodies, which under edition 2021 need no block and so carry no proof.
That is the whole CCD surface: every QueryDisplayConfig, every SetDisplayConfig,
every DISPLAYCONFIG union read.

Rather than 84 restatements of the same argument, the shared contract is stated ONCE
at the top — how the buffer-size/query pair keeps pointer and count in agreement, and
why the two obligations the compiler cannot see (arrays only ever come from the OS or
from a SavedConfig this module built; SetDisplayConfig serialised under the manager
`state` lock and bound to the input desktop) hold at every site. Per-site comments
then name the site-specific fact instead of repeating the invariant.

The union reads justify differently and the header says so: `modeInfoIdx` and
`ADVANCED_COLOR_INFO.value` overlay same-sized POD, so no discriminant can be got
wrong, while `MODE_INFO.Anonymous.sourceMode` IS discriminated by `infoType` — and
every read of it is guarded by that check. Writing that down found the one place the
guard does NOT gate the read: the `then_some` in set_virtual_primary_ccd evaluates
eagerly, so POD-ness is what carries it there, not the check. The comment says so
rather than implying a guard that is not doing the work.

Two shape changes, both deliberate. `wait_mode_settled`'s two calls are nested rather
than `&&`-joined so the second CCD query stays short-circuited — it polls every 25 ms
and must not run while the target has no active path. And the eager union read in
`set_virtual_primary_ccd` is hoisted to a `let` so its proof has somewhere to attach;
`then_some` already evaluated it eagerly, so nothing changes.

Union field ASSIGNMENTS are safe in Rust — only reads are — so two of them keep no
block. Verified on the Windows CI runner: clippy -D warnings clean across pf-frame,
pf-win-display, pf-capture and pf-vdisplay, and both crates' tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 6782aa0054 fix(vdisplay): stop the suite reporting ok for tests that never ran, and let clippy see the KWin backends
Three coverage holes, all of the same shape — a gate that looks green because
nothing is behind it.

The Windows half (~3,400 lines) had no executed tests. Its only two `#[test]`s
opened with `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`, so
without the hardware they passed without running, and no CI job compiled the
crate's test targets at all — `-p punktfunk-host` builds pf-vdisplay as a
dependency, which never reaches a `#[cfg(test)]` module. Both live tests are
`#[ignore]`d now (an unrun hardware test should say `ignored`), and windows-host.yml
gains the `--all-targets` lint plus a real test step. The link question that keeps
the host and pf-encode on clippy-only does not apply: pf-encode is `default = []`
and nothing here turns on nvenc/amf-qsv/qsv. Settled on the runner to the same
standard as pf-capture's step — 46 passed, 2 ignored.

`#![allow(clippy::all, ...)]` at the top of kwin.rs and kwin_output_mgmt.rs was
meant for wayland-scanner output, but the generated modules already carry their own
copy of that attribute — so the file-level one was only ever silencing 2,366 lines
of hand-written backend, with `clippy::correctness` (deny-by-default) among the
casualties. Dropping it found a dead match arm in the `kde_output_configuration_v2`
dispatch; that one is now exhaustive on purpose, so a re-vendored XML that adds an
event fails the build instead of dropping it silently. (The sibling `_ => {}` in the
DeviceMode dispatch is NOT dead — `ModeEvent::Removed` really does reach it and get
swallowed, which is a separate defect.)

`dead_code` is now enforced on Linux, where ~10k of the ~17k lines live, instead of
allowed crate-wide behind a rationale that had stopped being true. It turned up no
genuinely dead code: four items, every one live from tests or another platform, now
annotated with which. One deserved the comment most — `Entry::keepalive` is "never
read" because it is an RAII guard whose `Drop` releases the compositor output, so
deleting it as unused would release every pooled display the moment it was created.

Two tests were not testing what they claimed. `another_uids_socket_is_ignored`
asserted the sway-socket ownership guard but never reached it — `find_sway_socket`
filters on the `sway-ipc.<uid>.` filename prefix first, so the foreign-uid file was
rejected by name and the assertion held even with `md.uid() != uid` deleted. It is
renamed to what it does cover, and the real leg is a new `#[ignore]`d root test that
chowns a correctly-named socket (querying with a non-matching pid, or the exact-path
shortcut would return before the guard and make it vacuous all over again). And
proc.rs's tests were ungated while the module is compiled everywhere, so they would
have gone red on Windows the first time anyone ran them: `#[cfg(all(test, unix))]`
now, with a `cmd /c` twin that does run there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 995cac5d03 fix(vdisplay): the three hardest FFI calls were exempt from the crate's own unsafe-proof rule
`#![deny(clippy::undocumented_unsafe_blocks)]` cannot see an unsafe operation that
sits directly in an `unsafe fn` body — in edition 2021 `unsafe_op_in_unsafe_fn` is
allow-by-default, so a bare call inside such a body needs no block and therefore
carries no proof. The file headers claim "every unsafe block carries a SAFETY
proof", and that was true; it just did not cover the calls that needed it most.

Turning the lint on named exactly three: `DeviceIoControl` and the `ioctl` wrapper
in pf_vdisplay.rs — the whole host<->driver control channel — and
`restore_displays_ccd` in manager.rs, the call the teardown path depends on to give
the operator their physical panels back. Each now carries a real proof, and `ioctl`
and `set_render_adapter` grew the `# Safety` heading their callers were owed.

Also teaches scripts/wincheck.sh to cover pf-vdisplay, which is what let the above
be compile-verified rather than reasoned: the crate's Windows half is ~3,400 lines
that Linux CI never compiles. That needed a stub pf-encode (the real one drags
ffmpeg-sys, and the admission gate calls exactly one predicate from it) and
CompositorPref on the stub core. Verified non-vacuous with a planted type error.

pf-win-display's half of the same lint is a separate change — it flags 62 further
operations, and they want proofs written one at a time, not in bulk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 9b38b6a22d feat(stats): a capture records which encoder and GPU produced it
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m4s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m17s
windows-host / package (push) Failing after 8m32s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 11m4s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 11m33s
ci / rust-arm64 (push) Successful in 12m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
android / android (push) Successful in 12m59s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 8m45s
docker / build-push-arm64cross (push) Successful in 4m42s
arch / build-publish (push) Successful in 19m34s
ci / rust (push) Successful in 22m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
release / apple (push) Successful in 31m52s
apple / screenshots (push) Successful in 23m26s
The stage split is the thing an fps-shortfall report is diagnosed from, and
it cannot be read without knowing the backend behind it: a p50 `submit` of
10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend
and something else entirely on another. The capture's meta carried the mode,
codec and client but neither the encoder nor the GPU, so every report so far
cost a round-trip asking which one it was.

Both come from `pf_gpu::active()` — the record the encoder open itself
writes — so they name the branch that really opened rather than a re-derived
guess that goes stale the moment a backend gains an internal fallback. Read
once per capture, not per frame.

The console shows them next to the mode in the recording's header. Both
fields are `serde(default)`, so a recording made before this still loads and
simply omits them.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:14:10 +02:00