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.
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>
GameStream and native (punktfunk/1) clients pair into separate stores, and
`/status` only ever reported the GameStream certificate count. Native is the
DEFAULT plane, so a host whose clients had all paired normally — and which
Moonlight had never touched — showed "0 paired" on the dashboard.
Report the native count alongside it, the way the tray's own summary route
already does, and sum the two in the card.
Closes#10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.
Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.
`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.
Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.
Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`2c69cbda` rewrote StatsSample::mbps's doc comment (goodput → attempted
sealed wire bytes) without regenerating the snapshot, so
`openapi_document_is_complete_and_checked_in` fails on main today. Pure
regeneration, no API surface change: the description and the version stamp
are the only differences.
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.
- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
field, since update replaces the entry), the poster tile a platform
badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.
It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.
`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.
Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.
Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.
Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.
The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:
nested gamescope owns it; its display teardown ends the game
child the host spawned it, in its own process group
matched a launcher owns it; recognized by its store's signals
untracked nothing identifies it — both behaviors stay off, and the host
says so once rather than guessing
A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.
Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.
Also here, because they are the same decision seen from other angles:
- A management stop is now a deliberate stop. `DELETE /session` sets quit before
stop, so it behaves like a client pressing Stop — the display skips its
keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
gone and which is waiting out its window, so the console can show it and offer
`POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.
Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.
Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something —
unset/auto stay null), and the console's GPU card shows it: a muted note when
the pin is compatible, an amber warning naming the pinned vendor and the next
session's GPU when it contradicts the selection (the host overrides such a pin
at session open — without this field the selection just looked broken). Docs
updated to say the adapter wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tray sat at "Idle" through an entire stream, and its Windows context menu
could come up without its main action. Two separate causes.
`GET /api/v1/local/summary` reported `video_streaming`/`audio_streaming` straight
from `AppState.streaming` — flags only the GameStream media pipeline raises. The
native punktfunk/1 plane is the DEFAULT (GameStream is opt-in) and never touches
them, so every native session read as idle: idle icon, and a tooltip that printed
that session's own resolution next to the word "idle" (the `session` field was
already native-aware). This is the blind spot `/status` was fixed for — see the
`session_status` module doc — which the summary kept. Both flags now OR in a live
native session, and the tray additionally treats a reported session as streaming,
so a new tray reads an older host correctly too.
The menu built "Open web console" (and the pairing entry) only while a live
loopback probe of the console had just succeeded. A console still starting, or an
SSR render slower than the 2 s probe timeout, therefore deleted the tray's
most-wanted action outright — indistinguishable from a tray that never had one,
with left-clicking the icon as the only, undiscoverable, way in. The entry is now
always present and the default item; a failed probe changes its label to
"(not responding)" instead of hiding it, and takes two consecutive misses to say
so, since one timeout is not "down".
While here: a "Release kept display…" entry when displays are held (the summary
field's own doc promised a one-click release that did not exist), and entries
deep-link to /pairing and /displays instead of all landing on the dashboard. The
Linux SNI menu mirrors all of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 0.17.2 placeholder underestimated this cycle: Android mouse & keyboard
(physical mouse + capture, TV remote-as-pointer, IME text, clipboard), the new
committed-text input plane (InputKind::TextInput / HOST_CAP_TEXT_INPUT), and
the ChaCha20-Poly1305 session cipher are feature work — a minor, not a patch.
Wire/driver/C-ABI markers all unchanged (2 / 4 / 10): every addition is
negotiated or ignored by older peers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.
Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.
Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
own framework: it listed as installed and would have been imported as a
unit. Both now key off the plugins dir's top-level dependencies, with an
emptied dependency list meaning 'nothing installed' rather than falling
back to the naming convention
- the store must not pass new flags to the runner: the scripting package
ships separately and an older one reads an unknown flag's value as a
package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
as Ok with an empty body — handled as an error it made every refresh
after the first verify a signature over zero bytes and sit stale
Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MINOR, not the 0.15.1 originally planned: the 24 commits since v0.15.0 carry 7
features, and they are not incidental — they change the plugin runner's privilege
model. It stops holding the console's full-admin token (scoped plugin-token lane),
the Windows runner drops from SYSTEM to LocalService, public-registry plugin
installs are gated behind an explicit flag, and the systemd user unit is sandboxed.
Operators have workflow-visible changes to absorb (a plugin genuinely needing the
admin surface must now set PUNKTFUNK_MGMT_TOKEN; installing outside the @punktfunk
scope needs --allow-public-registry). A patch label would understate all of that,
and sdk-publish pushes this version straight to consumers.
The other 12 fixes are largely the pf-encode/zerocopy/capture/inject quality sweep
landing: teardown deadlocks, error-path leaks, a GL->CUDA copy race, an odd-height
NV12 overrun, a cursor-meta OOB read, plus the two loss-recovery gaps 0.15.0 left
open (Vulkan Video never got the LTR taint sweep; QSV's sweep skipped its modal
single-swept-slot case).
`WIRE_VERSION` stays 2 and the driver protocol stays v4 — pairings, clients and
installed drivers keep working. The C ABI moves 8 -> 9 (PunktfunkFrame grows
`received_ns`, so receipt is stamped at the session boundary rather than at the
embedder's pull); the gitignored Apple xcframework needs a rebuild for that to
reach the Apple clients. No OpenAPI path changes (45 before and after) — the new
plugin-token lane is auth-layer only — so api/openapi.json moves info.version only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MINOR, not patch: 44 commits since v0.14.0 split 21 features / 19 fixes, and
/api/v1 gained the per-scanner library-toggle endpoints (additive, already
regenerated into api/openapi.json by c2bba134). sdk-publish pushes this version
to consumers, so a patch bump would understate a new API surface to anyone
pinning ~0.14.
Headline work is the desktop clients drawing level with the Apple revamp: the
Windows client picked up settings parity, a findable console UI in the header,
the shared clipboard (with a per-host toggle), PyroWave decode in the codec
picker, and D3D11VA-first decode + HDR pass-through on Intel; the Linux GTK4
client got the same category-map settings rebuild. Apple landed the intent-based
presenter rebuild and the Dynamic Island redesign. Also: the punktfunk-host
plugins CLI, per-scanner library toggles in the console, and PyroWave raw-dmabuf
zero-copy capture on the Linux NVIDIA host.
Notable fixes: LTR-RFI loss recovery under sustained loss, two encode teardown
memory-safety holes, the audio first-open retry that was leaving sessions
silent, and the GameStream stream-marker announcement on the compat plane.
Every workspace crate is on version.workspace = true, so this stayed a one-line
bump plus the lock sync. (fec-rs, pf-driver-proto, usbip-sim, the Windows driver
crates and pf-vkhdr-layer are deliberately versioned independently and stay put.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every installed-store scanner (Steam; Lutris+Heroic on Linux; Epic/GOG/
Xbox on Windows) was hardwired on. New library-scanners.json persists the
operator's disabled set (default all on; absent/malformed = all on);
all_games() gates each provider, so disabling one hides its titles from
every surface (console grid, native clients, GameStream app list, launch
resolve). GET /library/scanners lists this platform's scanners + state;
PUT /library/scanners/{id} toggles and emits library.changed — admin lane
only (the cert allowlist's exact-path /library match keeps both off the
LAN surface). The console's Library page grows a "Game sources" card with
one chip per scanner (platform-shaped by the API), EN+DE strings, story.
The scanners are slated to become plugins; the stable per-scanner ids are
the migration seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MINOR, not patch: 31 commits since v0.13.0 carry 10 features, including a new
/api/v1/plugins surface with a regenerated OpenAPI spec + SDK client. Since
sdk-publish pushes this version to consumers, a patch bump would understate a
new API surface for anyone pinning ~0.13.
Headline work: GNOME 50 HDR screencast capture + Linux Main10 encode, PyroWave
4:4:4 + HDR end to end (Linux/Windows/Apple), the console-hosted plugin UI
surface, and the pyrowave perf work that lifted the 2.5 Gbps wall. Notable
fixes: the RSA host-identity keygen that broke every fresh Windows install on
0.13.0, and SDR negotiation on the Windows IDD-push path.
pf-clipboard / pf-inject / pf-vdisplay were still hardcoding 0.12.0 — the three
the 22b352c1 sweep missed. Switched to version.workspace = true so the next
bump stays one line. (fec-rs, pf-driver-proto and usbip-sim are deliberately
versioned independently and stay put.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerated api/openapi.json (host built on Linux) with the plugins tag —
registerPlugin/listPlugins/deregisterPlugin/getPluginUiCredential + the
PluginRegistration/PluginSummary/PluginUi/PluginUiPublic/UiCredential schemas —
and the SDK's generated client. Drift test + 44 mgmt/events host tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings origin's release prep (widgets App Store profile mapping + workspace
version 0.13.0) into the local line that landed the Linux stream sink, the
Windows client-only audio rework, the July security-audit fixes, cross-client
render-scale, and the widgets scheme. release.yml was byte-identical on both
sides; no other file overlap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The low/medium findings from the July host+Windows security review, as
implemented in the audit session's working tree:
- Webhooks and library-art fetches refuse loopback/link-local/metadata
targets and no longer follow redirects (SSRF pivots from the privileged
host process).
- The paired-client rosters (/clients, /native/clients) move off the
streaming-client auth lane — one paired device could enumerate every other
device's name + fingerprint; only the bearer/loopback console keeps them.
- Device-name sanitizing extends to bidi/format control characters
(spoofable rendering) via the shared native_pairing::is_spoofy_char;
stream-marker quoting uses the same set.
- The SYSTEM service resolves powershell by its full System32 path —
CreateProcess checks the launching EXE's own directory first, so a planted
powershell.exe beside the host binary would have run as SYSTEM.
- The pf-vdisplay driver's opt-in file log moves from world-writable
C:\Users\Public to WUDFHost's own temp dir.
- GameStream pairing sessions are single-use (removed whatever the outcome).
- Uninstall also removes the pf_mouse driver-store entry (rider from the
virtual-HID-mouse work).
- openapi.json regenerated (hardened-config-dir doc wording).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eight W6 leaf crates hardcoded 0.12.0 instead of inheriting the
workspace version — switched to version.workspace = true so the next bump
is one line again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the first-frame-latency branch (P0.1 transition tracing, P1.1/P1.2
Welcome-time display prep, P2 in-place resize; pf-driver-proto v3 -> v4 with
IOCTL_UPDATE_MODES) onto current main. The branch predates the W6.2/W7 splits,
so git's rename detection carried most of it into the moved crates
(pf-capture idd_push, pf-vdisplay manager/pf_vdisplay, pf-win-display,
pf-driver-proto, the driver workspace) and the punktfunk1.rs remainder was
re-homed by hand:
- native/handshake.rs: welcome/start trace marks + the Welcome-time display
prep spawn (the prep thread BECOMES the stream thread; hand-off via a
SyncSender<SessionContext>). negotiate() gains bringup/quit/stop and returns
the PrepHandle.
- native.rs: bringup/resize_ms creation + the stop/quit flags hoisted BEFORE
the handshake (the close watcher splits: flags pre-handshake, lifecycle
events post-handshake where `hello` exists); punch_done stamp; the data
plane adopts the prep thread's result or builds inline.
- native/stream.rs: SessionContext/SendStats carry the trace; send_loop
finishes it on the first video packet; the resize path gains the in-place
fast path (try_inplace_resize) with the full rebuild as fallback, restructured
so both share the post-rebuild bookkeeping; prepare_display/PreparedDisplay/
PrepHandle; build_pipeline(+retry) thread the stage marks.
- session_status/mgmt: ttff_ms + last_resize_ms per session (union with the
lifecycle-events fields main added to the same spots).
- pf-capture: Capturer gains capture_target_id() + resize_output() defaults.
- pf-vdisplay manager: perf's faster activation poll (60x50ms) + the settle
floor before the PnP sweep, on main's knobs/no-trait shape.
Also: packaging/windows/build-gamepad-drivers.ps1 is ASCII again (an em-dash
from the pf-mouse work tripped windows-host.yml's locale-safety gate on main).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.
- CustomEntry gains `provider` + `external_id` (API-set only; never on
manual entries). GameEntry surfaces `provider` for console attribution
and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
on the provider's `external_id` — host ids stay stable across syncs,
orphans drop, manual entries and other providers are never touched,
an empty array clears the set. Validated: provider id [a-z0-9._-]
(`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
provider-plugin skeleton.
347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`prep: [{"do": …, "undo": …}]` arrays on GameStream apps.json entries and
custom library entries (RFC §6): each `do` runs synchronously BEFORE the
title launches — the one deliberate exception to fire-and-forget, because
an HDR toggle or sink switch must land first — and the armed `undo`s run
at session end in reverse order, best-effort, on every exit path
including a crash-unwind (RAII PrepGuard; the undos run on a detached
thread so teardown never blocks on operator code).
- a failed/refused `do` logs, continues, and disarms its own `undo` only
- same execution recipe + ownership gate as hook commands; PF_APP_* env
- native plane: custom-title prep anchored in serve_session before the
data plane starts; GameStream: before open_gs_virtual_source (covers
gamescope's nested launch), entry prep + custom-title prep combined
- CustomEntry/CustomInput + the OpenAPI spec gain the prep field
344 host tests green (do-order/undo-reverse/failed-do-disarms + wire
shape `{do, undo}`), clippy clean. On-glass with a real client session
owed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hooks.json (RFC §6): commands and webhooks fired on host lifecycle events,
managed over GET|PUT /api/v1/hooks (validated, applied immediately) and
dispatched fire-and-forget by a bus-subscriber runner — hooks observe,
never veto, and no operator code sits in any streaming path.
- exec: detached sh -c with the event JSON on stdin + flat PF_EVENT_* env
(the PF_STREAM_* vocabulary's sibling), per-hook timeout (default 30 s)
with process-group kill, off-thread reap, per-hook debounce, bounded
concurrency (8 in flight, excess dropped loudly). Windows runs hooks in
the interactive user session (temp-file JSON argument; console-mode dev
hosts get env + stdin like Unix).
- webhook: POST the event JSON, TLS-verified, redirects never followed, no
punktfunk credentials outbound; optional per-hook secret file yields
X-Punktfunk-Signature: sha256=<hex HMAC> (fails closed if unreadable).
- filters: exact-match client/fingerprint/plane/app + the same kind
patterns as the SSE ?kinds= filter (shared crate::events::kind_matches).
- hardening (RFC §9.1): hooks.json via the private-dir/secret-file
helpers; a hook script path must be operator/root-owned and not
group/world-writable or it is refused loudly (the sshd rule).
- env mirrors PUNKTFUNK_ON_CONNECT_CMD / PUNKTFUNK_ON_DISCONNECT_CMD for
the zero-config cases, beside PUNKTFUNK_RECOVER_SESSION_CMD.
Live-verified on Linux: PUT config via API → library.changed fired a real
script (env + stdin observed) and an HMAC webhook (receiver-verified
signature); a chmod-777 script was refused. 342 host tests green
(store/validation/filter/env-flatten/exec-timeout/ownership + routes),
clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The checked-in spec drifted from the served document — the conflicting-host
detection work added the `conflicts` field on LocalSummary (+ a pnp doc reword),
so mgmt::tests::openapi_document_is_complete_and_checked_in was failing on main.
Regenerated with `cargo run -p punktfunk-host -- openapi`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds StreamInfo.time_to_first_frame_ms / last_resize_ms (latency plan P0.1)
and folds in the drift the spec already owed from the held working-tree
consolidation (version 0.12.0, pnp_disable_monitors description, the
conflicting-host 'conflicts' summary field) — the drift test was already
red before this branch; it is green at this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web console Dashboard read AppState.{streaming,launch,stream}, which only the
GameStream path writes, so a native punktfunk/1 session (the DEFAULT plane) showed
"Idle / no session" while actively streaming — only the Stats page (shared recorder)
reflected it. Add a plane-neutral per-session registry (session_status.rs) the native
video loop publishes to; /status now merges both planes, reports active_sessions, and
the Stop / Request-IDR buttons reach native sessions too (so surfacing them doesn't
leave dead buttons). LocalSummary (tray) gets the same fix.
Also on the management API:
- /host codecs derive from Codec::host_wire_caps() instead of a hardcoded
[H264,H265,AV1], so codecs the GPU can't encode no longer appear.
- ApiCodec serializes HEVC as "hevc" (matching the wire/SDP/stats label) so the same
codec reads identically across console pages.
- HostInfo.gamestream reports whether the GameStream planes run (--gamestream), so the
console can hide the Moonlight-only pairing UI on the native-only default host.
- StatsStatus.elapsed_ms (host-monotonic) so the capture timer doesn't mix host/browser
clocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2a+2c of design/pyrowave-codec-plan.md.
Core: CODEC_PYROWAVE = 0x08 on Hello::video_codecs/Welcome::codec.
Deliberately absent from resolve_codec's precedence ladder (plan §3 —
a 100-400 Mbps codec must never win a negotiation by mere mutual
support): reachable exclusively through the client's explicit
preferred_codec. Invariant tests cover never-auto-selected (even as the
only shared codec), preferred-path selection, and graceful fallback.
ABI mirror PUNKTFUNK_CODEC_PYROWAVE + lockstep assert for the
Apple/Android embedders.
Host: Codec::PyroWave variant threaded through the wire mappings; a
negotiated PyroWave session routes straight to the backend ahead of the
PUNKTFUNK_ENCODER pref dispatch (which stays a lab override). The
advertisement bit rides host_wire_caps only when the capture side would
actually deliver ingestible frames — linux_zero_copy_is_vaapi(), i.e.
AMD/Intel auto or an explicit operator pref on NVIDIA; per-session
raw-dmabuf OutputFormat plumbing is recorded as the Phase-3 item. The
libavcodec name helpers are dispatch-guarded unreachable; the web
console gains ApiCodec::PyroWave (api/openapi.json regenerated).
Validated on .21: 308 host tests green with and without the feature,
145 core tests green with quic, clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second experiment against the connected-but-dark-head stutter (field-proven
on the reporter's box: unplugging his standby HDMI TV removes a metronomic
~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP
0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors
from the CCD topology; their PnP devnodes stay live, so every standby wake
(auto input scan, Instant-On HPD cycling) still triggers the full Windows
reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the
suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature).
- New `pnp_disable_monitors` display-policy axis (default off): orthogonal
to presets like game_session/ddc_power_off, surfaced in GET/PUT
/display/settings + the enforced list, carried through the layout
transform.
- windows/monitor_devnode.rs: after the isolate takes, disable exactly the
deactivated monitors' devnodes — CCD target → monitor device path
(DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode
with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that
persistence is the whole point). Teardown re-enables BEFORE the CCD
restore (+300 ms re-arrival settle) so restored paths have their monitors
back. Precise selection — co-installed third-party virtual displays are
never touched.
- Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json
before disabling; serve startup re-enables leftovers from a crashed host.
Worst case is documented in the console help (Device Manager re-enable).
- Web console: second Experimental-badged toggle (the DDC block refactored
into a shared ExperimentalToggle), EN/DE strings, preset-switch carry.
Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sole-virtual-display stutter investigation's active experiment: when the
Exclusive isolate deactivates a physical monitor, the dark-but-connected head
keeps getting serviced (monitor standby auto-input-scan / DP link churn) at a
seconds-scale cadence — the leading suspect for the periodic double-jolt. A
panel commanded off over DDC/CI (the VESA monitor-control channel in the video
cable) believes it has an owner and, on cooperating firmware, stops probing.
- New `ddc_power_off` display-policy axis (default off): orthogonal to presets
like game_session, stored in display-settings.json, surfaced in GET/PUT
/display/settings + the enforced list, carried through the layout transform.
- windows/ddc.rs: VCP 0xD6 power-mode control via the dxva2 Physical Monitor
API. Deliberately DPMS-off (0x04, DDC stays responsive, signal return wakes)
and never power-button-off (0x05, bricks-until-button on many monitors).
Probe-before-write; every failure is skip-and-log — monitors without DDC/CI,
OSD-disabled, or behind docks/KVMs degrade to a logged no-op.
- Manager wiring: panels commanded off immediately BEFORE the Exclusive CCD
isolate (an HMONITOR — and with it the DDC channel — only exists while the
display is active); teardown wakes them right after the CCD restore, where
returning signal alone already wakes most firmware.
- Web console: an Experimental-badged on/off control on the display card,
applied immediately like the game-session axis and preserved across preset
switches; EN/DE strings incl. the wake-failure escape hatch (press the
monitor's power button once, turn the option off).
Diagnostic value on top of the fix: if this kills a reporter's stutter, the
churn is monitor-firmware-initiated; if only topology=primary/extend does, the
driver services dark heads regardless — the two remaining root-cause classes.
Verified: Linux 258 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release 0.9.1 — a patch release led by three field-reported crash/brick
fixes, a security-review hardening pass, and the tiered stats overlay
landing on every platform.
The regression that forces the release: 0.9.0's stats-HUD display stage
hard-linked an API-33 NDK symbol believed API-26, so the native library
failed to load on every Android < 13 device — "Identity unavailable:
…NativeBridge", dead pair button, no discovery. The callback is now
dlsym-resolved (pre-13 devices simply lose that one HUD row), and a new CI
guard fails the APK build if the native lib ever imports a symbol beyond
the API-28 floor.
Two more crash-class host fixes ride along. GNOME: one console Approve
admitted every parked knock a retrying client had stacked up — three
instant Mutter virtual monitors segfaulted gnome-shell down to the GDM
greeter; knock generations now admit exactly one, Mutter topology
mutations are serialized process-wide, stale session-env is cleared, and
an opt-in PUNKTFUNK_RECOVER_SESSION_CMD hook can revive a dead session on
the next connect. Windows: twin identical GPUs died in a TEX_FAIL retry
loop when the IDD-push ring opened on the wrong twin — the ring now
rebinds once onto the render LUID the driver reports, and GPU inventory
ordering is LUID-sorted and stable for the boot.
Input: held gamepad state is rock-solid — Android pins one qualified pad
device (motion-sensor siblings zeroed axes on every event interleave), and
pad state now travels as sequence-gated idempotent snapshots
(HOST_CAP_GAMEPAD_STATE, negotiated; either end older falls back to the
per-transition path).
Security review findings addressed: sliding-window anti-replay on the
AEAD-authenticated data-plane sequence, 0600 client key + 0700 config dir
(existing stores re-locked), the web console's post-login open redirect
closed, the Windows installer's FFmpeg DLLs SHA-256-pinned, and a fork
guard on PR jobs sharing signing-runner labels.
Features: the 3-tier stats overlay (Compact/Normal/Detailed) ships on
every platform with cycle shortcuts and settings pickers; Apple gains the
experimental stage-3 glass-gated presenter (fixes the hidden 29-30 ms
display-stage queue on 120 Hz ProMotion; stage 2 stays default) and a
morphing, device-corner-concentric overlay card. Plus repo-wide docs sync
and the flatpak CI network hardening.
The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; the 14 workspace entries in
Cargo.lock and the api/openapi.json info.version are refreshed to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest
stable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release 0.9.0 — the Windows client grows up, bitrate goes adaptive, and the
console shell arrives.
The headline: Windows now runs the same Vulkan session client as Linux
(session-always), with a single-window handoff from the shell, a shared
settings store, a game library page, and D3D11VA hardware decode slotted into
the chain (vulkan → d3d11va → software) — plus polish like hiding the local
cursor while captured and a Shortcuts help screen. Alongside it, adaptive
bitrate lands end to end: set to Automatic, the client's controller re-targets
the host encoder mid-stream (rebuild-in-place, the fresh encoder opens on an
IDR) instead of riding a fixed rate into congestion. And the console UI grows
into a full gamepad shell — host list, PIN pairing, settings, on-screen
keyboard, screen transitions.
Apple clients gain AV1 decode (hardware-gated advertisement), user-configurable
VRR, Game Mode across platforms, cross-client shortcuts, an opt-in V-Sync, and
a presenter rework; a stream error now returns to windowed like a disconnect
does. Codec selection is real everywhere: clients advertise what their GPU
actually decodes and the host picks accordingly — and AV1 encode on Windows
NVENC actually opens now: the init stamped HEVC HIGH tier and (on 10-bit
sessions) the HEVC Main10 profile GUID onto the AV1 config, so every AV1
session died at the encoder with INVALID_PARAM. AV1 now rides its preset
defaults where they are the only accepted values (Main tier, autoselect
level — 0 means Level 2.0 on AV1, not autoselect), sets its bit depths on
its own config, and a rejected split-encode mode falls back cleanly from
AUTO too. Verified live: 10-bit AV1 macOS → RTX 4090. Android's stats HUD headline
becomes true capture→displayed via OnFrameRendered, custom resolutions land in
both Android UIs (typed W×H in touch settings, kept selectable on the pad —
the Apple pattern), and the 3-finger stats tap actually fires now (the
two-finger scroll centroid read the third finger's landing as a scroll notch
and disqualified the tap). The console shell gates input the instant A starts
a Wake-on-LAN connect — the "Waking…" card shows immediately instead of the
cursor drifting ungated until the stream abruptly appeared. The web console
gets a favicon, Punktfunk-cased branding, a save toast for the auto-saved
display policy, and a stop-session button that only goes red when there is a
session to stop.
Reliability work on the host closes out a long field investigation into
periodic virtual-display stutter: the IDD capture path no longer runs CCD
display queries on the capture thread (a dedicated poller samples them,
measures them, and warns when something is holding the Windows display-config
lock), transient descriptor blips can no longer tear the ring down (last-
known-good + a two-strikes debounce), and a metronomic keyframe-recovery
cycle now names itself in the host log instead of hiding in "nothing in the
logs". The GPU inventory filters out IddCx ghost adapters — every host used
to list its render GPU twice (pf-vdisplay's adapter mirrors the GPU's whole
DXGI identity), with the picker offering a dead twin. Also: per-client display
scaling on GNOME hosts (Mutter virtual monitors), the shared-VkQueue race +
swapchain recreate-in-use fix behind the intermittent device-lost stream
killer, per-family MTU shard sizing (1388 over IPv6 — the v6 blackhole gate),
mDNS discovery pinned to IPv4 on every client, reachability-probed presence
with shareable Decky host management, --no-mdns/--pairing-pin for headless
testing, and an offline-flatpak build fix.
The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; the 14 workspace entries in
Cargo.lock and the api/openapi.json info.version are refreshed to match (CI
builds --locked). Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so cutting 0.9.0 advances canary to 0.10.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release 0.8.4 — reconnect reliability and streaming polish over 0.8.3.
The headline fix: a reconnect to a saved host that had stopped advertising on
mDNS (but still had a learned Wake-on-LAN MAC) was routed into a wait-for-mDNS
wake flow instead of being dialed — so a host reached over a routed network
(Tailscale/VPN/another subnet) could never be reconnected, reading as "Offline"
with no connection attempt in the host log. All four full clients now dial
first and only fall into the visible "Waking…" wait when the dial actually
fails. On the host side, the same investigation surfaced Windows-only gaps: a
deliberate quit (⌘D) now tears the pf-vdisplay virtual monitor down immediately
(no 10 s linger churning the reconnect's REMOVE→ADD), while keep_alive=forever
(the gaming-rig preset) correctly outranks the quit on both platforms so a
pinned screen stays alive. Silent deaths are no longer invisible: a panic hook
tees panics into the web console's Logs, a Windows SEH filter names the faulting
module on a native GPU-driver crash, and a watchdog flags a wedged monitor
teardown.
Also in this release: the presenter switches to MAILBOX present, pushes HDR10
mastering metadata, and tags HDR honestly; DualSense rumble accepts the v2
vibration valid-flag and bounds a stale buzz; the core caps the QUIC datagram
send buffer so audio/rumble go latest-wins; the host folds a Steam Deck to a
DualSense on Windows (split-pad touch + pad clicks) and applies input motion on
arrival rather than after the 4 ms tick; Android gains a tiered stats HUD with
windowed lost/skipped/FEC counters; and Windows surfaces a Help/shortcuts
screen and exits fullscreen when the stream stops.
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; the 14 workspace entries in Cargo.lock and the
api/openapi.json info.version are refreshed to match (CI builds --locked).
Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so cutting 0.8.4 keeps canary at 0.9.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release 0.8.3: the native Linux client is re-architected — the GTK4 shell + GL
presenter give way to a two-binary design (phases 0–6). A relm4/libadwaita
desktop shell (host discovery, pairing, library) now hands desktop connects to a
dedicated Vulkan session binary, punktfunk-session: an SDL3 window + ash
presenter carrying a Skia "console" UI — stats OSD, capture HUD, and a
gamepad-navigable coverflow library with --browse. The split lands the streaming
wins the GL path couldn't: VAAPI dmabuf → Vulkan zero-copy import + CSC (no GL
round-trip), Vulkan Video decode on the presenter's own device for NVIDIA
hardware decode (via a new pf-ffvk bindgen shim over FFmpeg's Vulkan hwcontext),
and HDR10/P010 end to end. The console background is now an animated mesh gradient
matching the Apple client's MeshGradient. Along the way: a resize crash fixed
(never vkDeviceWaitIdle while the pump decodes), keyframe recovery when the
decoder produces no output, decode-stage stats that measure GPU completion, an
Alt+Enter fullscreen alias, pointer-lock + per-iteration motion-flush input
fixes, and the host-card hover highlight made concentric.
Packaging + Decky: the flatpak now builds and ships BOTH client binaries (shell +
session) so the Deck's stream/browse paths survive the split — the session
binary's Skia is fetched offline from a pinned, sha256-verified archive; the
mgmt/library port 47990 is opened on the LAN firewall profiles; and the plugin's
install-from-URL uses the unom.io/pf-decky short link.
Other clients + core: Windows gains the two missing stream shortcuts
(Ctrl+Alt+Shift+S stats, F11 fullscreen) plus an in-stream shortcut reference on
both clients; Android promotes the low-latency pipeline to default and attaches
its APK to CI; the host coalesces keyframe-request storms into one IDR per
cooldown; the core jumps to live on a standing receive backlog instead of
ratcheting latency; and two security bumps clear the audits (crossbeam-epoch
RUSTSEC-2026-0204, vulnerable web transitive deps).
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 14 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so with 0.8.3 the newest stable it stays at 0.9.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 0.8.2: native AMF encoder reconnect reliability on Windows AMD — a
second connection no longer comes up black (the encoder now Flushes before
Terminate so a reconnect's overlapping teardown can't strand AMD's VCN encode
session, self-heals with a full context rebuild when a reconnect still wedges,
and logs a per-context bring-up number + first-AU line so a silent wedge is
visible); a native data-plane hardening pass that keeps the stream alive across
real Wi-Fi links; Android streaming wake/Wi-Fi locks that actually engage plus a
console-UI polish pass (per-controller glyphs, scrollable dialogs, animated
forms); and gamescope-takeover survival on Bazzite's SDDM session supervisor.
Also a Linux-client crash fix — the FlowBox activation cycle that stack-
overflowed on every host-card click.
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 9 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag (scripts/ci/pf-version.sh), so
cutting v0.8.2 auto-advances canary to 0.9.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0.8.1 version bump (6c1e6ad) bumped Cargo.toml but not the generated
OpenAPI doc, leaving info.version stale at 0.8.0. Regenerated — version
string only, no API surface change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Save named bundles of the display-management policy (the six behavior axes
plus the game-session axis) as custom presets, alongside the built-ins. A
custom preset is data — stored in <config>/display-presets.json — not a Preset
enum variant, so DisplayPolicy::effective() stays pure and the built-in set is
untouched; applying one writes a Custom policy via the existing PUT
/display/settings.
- policy.rs: CustomPreset/CustomPresetInput + load/add/update/delete store
- mgmt.rs: GET/POST /display/presets + PUT/DELETE /display/presets/{id},
surfaced on GET /display/settings
- web console: custom-preset cards with save-as / edit / delete + i18n
- regenerated api/openapi.json; docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 8 polish. `GET /api/v1/local/summary` (the tray's loopback-only unauthenticated status
source) gains `kept_displays` — the count of lingering/pinned virtual displays (held with no live
session), over the already-validated `registry::snapshot()`. The tray shows it in the idle tooltip
("idle · 1 display kept"), so a user knows a display — and, under exclusive topology, their physical
monitors — is being held (e.g. a gaming-rig `forever` pin). Release stays via the console: a
state-changing release can't be an unauthenticated endpoint, and the non-elevated Windows tray
can't read the SYSTEM-DACL'd mgmt token, so a tray release button isn't cleanly cross-platform.
`#[serde(default)]` on the tray side keeps it compatible with an older host. Tray tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the last §6A-era preset. The Linux registry already resolved forever→Pinned (pure
lifecycle machine); the blockers were the Windows manager, the mgmt reject, and the console tag:
- Windows manager: new `MgrState::Pinned { mon }` — the last-released monitor under keep_alive=forever
is kept indefinitely (like Lingering but the linger timer never fires). A reconnect preempts +
recreates it (same as Lingering — a reused IddCx swap-chain is dead), snapshot reports "pinned",
and `force_release` (POST /display/release, the §8 escape hatch) frees a pinned monitor. release()
branches on the new `keep_alive_forever()`; all MgrState matches made exhaustive over Pinned.
- mgmt PUT /display/settings: stop rejecting keep_alive=forever (now honored on both platforms with a
release path). OpenAPI regenerated.
- web: un-disable the gaming-rig preset (DISABLED_PRESETS now empty) — one-click applies.
Linux paths + web/tsc/openapi green; 47 vdisplay tests pass. The Windows manager.rs is #[cfg(windows)]
(not compilable on the Linux dev box) — build-verified + on-glass validation on .173 to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§6A layout, riding the Stages 1-3 registry with no protocol change:
- vdisplay/layout.rs: pure arrangement engine — auto-row (left-to-right in
acquire order, top-aligned) + manual (per-identity-slot offsets, auto-row
fallback for unpinned members). Unit-tested.
- Registry group model (Linux): group = backend (one desktop per compositor
session). /display/state groups entries, orders by acquire (gen), and computes
each member's position via the engine (pure `assemble_displays`, unit-tested).
DisplayInfo carries group/display_index/position/identity_slot/topology. The
backend reports its resolved slot via the new VirtualDisplay::last_identity_slot
(KWin only), so the arrangement + state key on per-client identity.
- Registry-driven position apply: new VirtualDisplay::apply_position(x,y) (default
no-op; KWin drives kscreen-doctor). Right after create the registry computes the
new display's position over its whole group (pure `position_for_new`, unit-tested)
and applies it — one seam for BOTH deterministic auto-row AND manual placement.
Guarded: the origin (0,0) is skipped, so a single-display / first-of-group session
(and every non-KWin backend) issues no positioning — the historical single-display
path is unchanged. On-glass-validation-pending.
- PUT /api/v1/display/layout: persists the console's manual arrangement via the pure
EffectivePolicy::with_manual_layout transform (locks current effective behavior
into explicit Custom fields + sets a manual layout, so arranging is orthogonal to
the other axes). OpenAPI regenerated.
- /display/settings `enforced` now lists all five axes (keep_alive, topology,
mode_conflict [Stage 4], identity [Stage 3], layout [Stage 5]) — was stale at
keep_alive+topology; the console reads it to know which controls are live.
Still Stage-5 TODO (design/display-management.md §11): Mutter/wlroots group-aware
analogues, per-group topology restore, the web arrangement table, gamescope decline.
cargo build/test/clippy/fmt green; OpenAPI in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:
- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
Lingering{until}/Pinned) with acquire/release/expiry/force-release
transitions. No I/O, no OS types — the platform-neutral distillation of the
Windows manager's model. Unit + a 200k-iteration seeded property walk
(no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
keep-alive (a per-session pool) lands in a following increment (needs GPU-box
validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
(tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
per-row + release-all buttons and a linger countdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A user-configurable policy layer above the per-compositor VirtualDisplay
backends: keep-alive, topology, conflict, identity, layout, max-displays —
persisted to display-settings.json, editable from the web console, applied
per connect. Design: design/display-management.md.
Stage 0 stands up the surface and wires the two behaviors the existing code
can already express — the Windows monitor linger duration and the
"make the streamed output the sole desktop" topology — through it; every
other option is stored + echoed but not yet enforced (later stages). An
unconfigured host (no display-settings.json) keeps today's exact behavior.
- vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings
pattern) + EffectivePolicy; 9 unit tests.
- vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY
from the policy only when a settings file exists.
- windows/manager.rs: linger_ms() + should_isolate() read the policy when configured.
- mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive
forever until the lifecycle stage. OpenAPI regenerated.
- web console: Host → Virtual displays card (preset picker + custom fields); en+de.
- docs-site: virtual-displays.md + configuration.md cross-links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.
Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).
Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.
Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot comparison now normalizes info.version on both sides and
compares structurally — a version bump alone can never invalidate the
checked-in spec again (the 0.5.0 release tripped on exactly this; the
API surface is what drift-control protects). Snapshot regenerated so
the docs-site copy shows the current version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>