The branch's Windows-host code never ran through the fleet Windows clippy (kept off CI); the merge
to main exposed it. Fix the 4 -D warnings failures in cfg(windows) code:
- manager.rs: 3 unsafe blocks (isolate_displays_ccd / force_extend_topology / set_virtual_primary_ccd)
had one "both arms" SAFETY comment on the `match` line — clippy::undocumented_unsafe_blocks wants it
immediately before each block. Split into per-block SAFETY comments.
- win_display.rs: `.then(|| …)` on a POD u32 union read → `.then_some(…)` (eager is fine, discarded
when false) for clippy::unnecessary_lazy_evaluations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconnect-preempt (e8531a9, preempt_same_identity) reads the process-global admission table
and signals same-identity live sessions. The three in-process-host tests each bind a fixed loopback
port and share that ONE table, so running them concurrently let one test's connection preempt +
close another's live session — an intermittent `next_au: Closed` in c_abi_connection_roundtrip
(surfaced under full-workspace load; a lucky pass hid it at e8531a9). Serialize them on a
poison-tolerant lock. Test-isolation only — in production a host is one process with unique client
certs, so same-identity preempt is correct. Full workspace `cargo test` now green (18 suites, 3× clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- HostWaker + WakeOverlay: after sending the Wake-on-LAN packet, wait until the host
is really back (resend + mDNS poll, timeout, cancel/retry) before connecting.
macOS-only in practice — WoL stays gated off on iOS/tvOS pending the multicast
entitlement.
- Add/Edit host sheet gains a Wake-on-LAN MAC field, prefilled from the stored MAC
or the live mDNS advert; parseMacs validates aa:bb:cc:dd:ee:ff.
- Gamepad chrome/home and glass-style polish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the Android client to parity with Apple's gamepad experience and finish
Wake-on-LAN.
- Console/gamepad home: host carousel, aurora chrome, mTLS game-library coverflow,
and an input-aware legend that switches between gamepad face buttons and a
TV-remote select-ring + arrows based on the last-used input.
- Wake-on-LAN: the fire-and-forget send is upgraded to wait-until-up
(WakeController/WakeOverlay: resend + mDNS poll, 90s timeout, cancel/retry,
fingerprint-matched so a host that cold-boots onto a new DHCP IP still connects),
plus host edit (touch dialog + console form) with an auto-filled MAC.
- Android TV: brand banner (android:banner), density-aware console scaling, D-pad/
remote nav (Up = Settings, Down or the pad Select button = host Options),
emergency stream-exit chord, and 120Hz console refresh.
- Touch UI: settings split into subpages with a tablet NavigationRail, axis-aware
tab animation (horizontal on phones, vertical on the tablet rail), animated
settings navigation, and a licenses screen with a back button + the real
workspace version (read from Cargo.toml).
- Vector Lock/controller icons (no emoji); bundled Geist font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old `..._and_forever_rejected` asserted a 400 for keep_alive=forever; now that it's accepted,
that PUT succeeded and WROTE gaming-rig into the process-global prefs, racing other tests. Rewrite
read-only: assert the surface (5 presets, effective, enforced axes) and read gaming-rig=forever off
the preset list — no write. Acceptance is covered on-glass (.116) + the pure policy tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
Bring the user-facing + project docs in line with the shipped, on-glass-validated state (Stages
0-5 §6A + keep-alive hardening + gaming-rig) ahead of a merge decision:
- docs-site/virtual-displays.md: drop the now-false "stored but not yet enforced / following
release" caveats — conflict handling, per-client identity + KDE scaling round-trip, and §6A
multi-monitor layout are all live; gaming-rig/forever ships (freed via Release); document the
reconnect-always-resumes + deliberate-quit-skips-linger behavior and the PUNKTFUNK_IDLE_TIMEOUT_MS
knob. KDE persistent scaling → ✅ today (validated); Windows primary → shipped; Sway exclusive
stays "following release".
- README: a "displays you configure, not just create" differentiator bullet.
- CLAUDE.md: the display-management invariant now reflects Stages 0-5 shipped (all axes enforced,
forever/Pinned, hardened reconnect) instead of "Stage 0 shipped".
- host.env.example: document PUNKTFUNK_IDLE_TIMEOUT_MS + that display policy lives in the console.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`let (mut paths, mut modes)` — `modes` is only read (`modes.as_slice()`), never mutated. A
pre-existing unused_mut (from 8fa4757) that the Linux CI never caught because win_display.rs is
#[cfg(windows)]; surfaced by a manual .173 build. Would fail the release-gated Windows clippy.
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>
Two probe test-infra fixes needed to validate the keep-alive hardening (e8531a9) on glass:
- `--seconds N` caps the receive loop (was a hardcoded 120s), so a probe against a live `serve`
host ends its session promptly and reaches the graceful `conn.close`.
- After `conn.close`, wait for the endpoint to flush the CONNECTION_CLOSE frame (bounded 2s)
before exiting — otherwise the process drops the endpoint before quinn sends the close, and the
host waits out the idle timeout instead of seeing the close CODE (which the `--quit` deliberate-
quit path and normal code-0 close both depend on).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CachyOS ships ufw enabled by default (firewalld is not installed) — verified live
on the .21 box — but the docs and shipped firewall openers claimed "CachyOS enables
firewalld by default". Correct that everywhere and ship a ufw application profile
(the one-liner analogue of the firewalld service files):
- packaging/linux/punktfunk.ufw (new): [punktfunk-native], [punktfunk-gamestream],
[punktfunk-web] profiles, installed to /etc/ufw/applications.d/punktfunk by the
Arch (CachyOS) and .deb host packages. `sudo ufw allow punktfunk-native`.
- packaging/linux/punktfunk-web.xml (new): firewalld service for the optional web
console (TCP 47992), installed by the host package on arch/deb/rpm. Neither the
native nor gamestream opener covered 47992, so a firewalld/ufw host that enabled
punktfunk-web could not reach the console over the LAN.
- Fix the "CachyOS enables firewalld" claim in arch.md, arch/README.md,
debian/README.md, both firewalld service .xml comments, and the pacman scriptlet;
firewalld now attributed to the spins that use it (EndeavourOS, Fedora/RHEL).
- Docs present both one-liners (ufw + firewalld) whichever firewall you run, plus a
console-opener step; postinst/scriptlet hints detect ufw as well as firewalld.
The native data plane stays hole-punched (ephemeral UDP, no fixed port) — its
openers correctly open only 9777/udp + mDNS; the stale "open a UDP range" note is
replaced with the accurate outbound-UDP explanation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass testing (Test 2, KWin .116) surfaced that a reconnect within the QUIC idle-timeout
window (~8s) lands on a fresh SECOND display instead of reusing the kept one: the old session
was still Active (not yet Lingering), so the registry's keep-alive reuse (which only matches
Lingering) skipped it and the old session kept streaming to nobody. Three fixes:
#3 Same-client reconnect preempt (the real fix): admission::preempt_same_identity() lists a
reconnecting client's OWN still-live session(s) (same cert fingerprint); serve_session signals
their stop + waits the release grace BEFORE acquiring, so the zombie tears down → its display
lingers → the reconnect REUSES it instead of making a second. Implements the "preempts
downstream" the admission docs already promised. Independent of the mode_conflict policy; the
pure core (same_identity_stops) is unit-tested.
#2 Deliberate quit skips linger: a client that deliberately disconnects closes the QUIC connection
with QUIT_CLOSE_CODE (0x51, shared in core::quic); the host reads the ApplicationClosed reason
and tears the display down immediately (registry release() gained force_immediate →
Linger::Immediate; multi-session-safe via the pure lifecycle machine), while a bare disconnect
still lingers for reconnect. Threaded via a session quit flag → the DisplayLease.
NativeClient::disconnect_quit() + punktfunk-probe --quit drive it; GameStream (Quit App /
h_cancel) is a documented follow-up.
#1 Configurable disconnect-detection latency: the QUIC control-connection idle timeout
(stream_transport, 8s default) is host-tunable via --idle-timeout-ms / PUNKTFUNK_IDLE_TIMEOUT_MS,
clamped >=1s with a keep-alive that scales to it so a live session never false-closes. Default
unchanged (8s stays load-bearing for the Windows IDD-push reconnect flow).
Workspace check + 63 core / 215 host / 47 vdisplay tests green; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preset options were raw <button>s — flat, no motion/material — unlike the rest of
the console. They now render as the `interactive` AnimatedCard (motion hover + specular
material, consistent with every other card), keyboard-accessible (role=button + Enter/
Space), with a 2px primary ring for the active one and a proper disabled state for
gaming-rig.
web tsc + vite build + biome-lint green; deployed on .21.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Every option in the custom form now renders through one `Field` wrapper (label →
control → help at a consistent `space-y-3`), so the label→input gap is roomier and
identical across keep-alive, the button groups, and max-displays — the first field no
longer spaces differently from the rest.
- Reworded the multi-monitor layout help: it now says Auto is side-by-side and Manual
gives a per-display X/Y editor "in the Live displays section below once two or more are
streaming" — instead of pointing at an "arrangement table" that isn't visible until
clients connect.
web tsc + vite build + biome-lint green; deployed on .21.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two UX fixes on the Virtual displays Configuration card:
- Keep-alive is no longer implicitly "on" by typing in the seconds field. It's an
explicit two-button toggle — **Off** (tear down at disconnect) vs. **Keep for** [N]
seconds — and the seconds input only appears when "Keep for" is selected. The
duration is remembered across toggles, and the help text explains both modes.
- Opened up the cramped custom form: the fields container is `space-y-6` with more
padding (`p-5`, rounded-lg), each option group is `space-y-2.5`, and the Save button
sits below a divider — so it reads as sections with room instead of a pressed stack.
web tsc + vite build + biome-lint green; deployed on .21.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the
virtual-display config surface is large enough to warrant its own home.
- New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved
DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and
`nav_displays` i18n. Removed the displays card from the Host page/view.
- On its own page the card splits into two: **Configuration** (presets + custom axes) and
**Live displays** (the live list + arrangement table) — room to breathe.
- Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger
section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned
"what it sets" badges so the cards line up.
web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Virtual displays card previously only exposed keep_alive/topology/max_displays as
editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no
controls, and the presets weren't surfaced as one-click options. Rework the card so the
whole policy is configurable WITHOUT any client connected:
- Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/
gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology ·
conflict · identity badges), highlighting the active one. A click applies it immediately.
gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet).
- Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout,
max-displays — seeded from the current effective behavior, with a Save button. A reusable
`Choice` button-group + a tolerant `tr()` label lookup keep it tidy.
- The live-display list + multi-monitor arrangement table stay below (they need a live
session); the settings above work standalone.
- en+de i18n for the new controls; refreshed the effective-preview row to show all axes.
web tsc + vite build + biome-lint green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native data plane used a random ephemeral UDP port (hole-punched), which a
strict firewall can't pre-open — so remote clients behind one couldn't connect.
Add an optional fixed data port:
- `Punktfunk1Options`/`NativeServe` gain `data_port`; `bind_data_socket` binds the
fixed port (→ direct, no hole-punch) or falls back to a random port + hole-punch
when unset or the fixed port is busy (a concurrent session already holds it).
- `UdpTransport::from_socket`/`from_socket_punch` adopt an already-bound socket, so
the host keeps the SAME data socket from handshake through streaming — no
drop-then-rebind window in which a concurrent session could steal a fixed port.
- `main.rs` wires the CLI flag through to `NativeServe`.
- Firewall docs updated (troubleshooting.md + apt/pacman/bazzite READMEs): control
plane is the fixed UDP 9777; the data plane is a separate random port that usually
needs no rule, with the fixed-port option for strict firewalls.
Unit-tested: default random+hole-punch, and fixed-port-then-fallback-when-busy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FEC/Reed-Solomon packetization ran inline on the encode loop (~3 ms/frame at 4K),
serializing behind encode and capping the GameStream frame rate below what the
encoder alone can sustain. Split it into a 3-stage pipeline, each stage on its own
thread joined by a depth-2 bounded queue:
encode loop → [raw AUs] → packetizer (FEC/RS) → [wire batch] → paced sender
- `spawn_packetizer`: turns each `RawFrame`'s access units into wire datagrams via
the stateful VideoPacketizer, off the encode loop. Above-normal priority (on the
per-frame critical path). Tallies goodput (bytes to the wire) for the stats window.
- Backpressure chains up: a slow sender blocks the packetizer, which fills the
encode→packetizer queue, which makes the encode loop drop the NEWEST frame — encode
itself never waits.
- A dropped frame now consumes no client-visible frameIndex (packetization is
downstream), so the host re-anchors the reference chain: a drop arms a keyframe on
the next iteration (`recover_after_drop`), routed through the same coalesce gate as
client IDR requests so a burst of drops (congestion) can't become an IDR storm.
- Perf/stats relabeled: `pkt` = AU drain, `send` = enqueue to the pipeline (both
should be near-zero now; nonzero = encode being stalled by pipeline backpressure).
Goodput read from the packetizer's atomic at the 1 s stats boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Stage 5's web piece (design/display-management.md §6.2): a `DisplayArrangement`
editor in the Virtual displays card. For a ≥2-display group, it renders an x/y table over
the live displays that carry a stable identity slot (the manual-layout key), seeded from
the current computed positions; Save writes `PUT /display/layout` (via the generated
`useSetDisplayLayout`), which switches the host to a manual layout applied from the next
connect. Shared/anonymous displays (no identity slot) are omitted (they can't be pinned).
Also refreshes the now-stale `display_pending_note` copy (conflict/identity/layout ARE
enforced as of Stages 3-5) in en + de.
web tsc + vite build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host-side completion of Stage 5 (§6A many-clients-as-monitors), all unit-tested;
two-session on-glass validation still pending (no GPU on the dev VM):
- Per-group topology restore (§6.1): the KWin `exclusive` restore no longer rides
the per-session StopGuard (which re-enabled the physical the moment the FIRST of
several exclusive sessions dropped, under a live sibling). KWin hands its restore
to the registry as a closure (new trait `take_topology_restore`); the registry
keeps it in the display group (`Entry.topology_restore`) and, on teardown, floats
it to a surviving same-group sibling (`hand_off_restore`) or runs it when the group
empties — outside the lock, before the last output's keepalive drops, so the
compositor never sees zero outputs. All three teardown paths (lease drop / linger
expiry / mgmt release) honor it. Single-display path byte-for-byte unchanged.
Unit-tested: float / run-on-last / non-carrier-first / never-cross-backend.
- Mutter group-aware (new trait `set_first_in_group`): the registry tells each
backend whether it's the first display of its group; a non-first Mutter session
EXTENDS into the already-exclusive desktop instead of re-applying a sole-monitor
ApplyMonitorsConfig that would disable the first session's virtual. (Mutter
connectors are un-nameable, so it can't build a keep-all-virtuals config; skipping
is the safe equivalent.) Single-session unchanged. Residual APPLY_TEMPORARY revert
documented.
- gamescope groups (§6.1): `registry::group_key` makes each gamescope spawn its own
group (independent nested session, no shared desktop) — never auto-rowed against or
restore-/topology-grouped with another gamescope. Applied in both the /display/state
assembly and the acquire-time position computation. Unit-tested.
Remaining Stage 5: the web console arrangement table, on-glass validation, and the
documented residuals (wlroots exclusive, Mutter APPLY_TEMPORARY). design doc updated.
cargo build/test (214)/clippy --all-targets/fmt green.
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>
The critical latent bug Stage 3 introduced: per-slot output names mean a 2nd
exclusive session's other_enabled_outputs() (which disabled 'everything not named
Virtual-punktfunk') would black out the 1st session's Virtual-punktfunk-<id>
output. Fix: recognise the whole managed group by the shared Virtual-punktfunk
prefix — exclusive now disables only NON-managed outputs (bootstrap/physical),
never a group sibling. Plus first-slot-wins for the group primary
(a_managed_output_is_primary): a later session joins as a secondary monitor of the
shared desktop instead of stealing the shell off the first. Unit-tested.
Start of Stage 5 (§6A many-clients-one-desktop). Remaining: Mutter/wlroots
group-aware analogues, layout (auto-row/manual + /display/layout + console),
per-group topology restore, gamescope groups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's
single-capturer IDD-push channel (newest-delivery-wins), which freezes the live
client and can wedge the driver (observed live: a concurrent-session test wedged
.173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/
Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to
REJECT — a 2nd client gets a clean 503 and the live session is protected — instead
of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps
separate (real multi-view). Centralized as admission::effective_conflict(), shared
by the native handshake + GameStream h_launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull the GameStream mode-conflict decision out of h_launch into a pure
gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over
logic is unit-tested (no live session / same-client → Serve; different client →
Reject/Join/Serve per policy; anonymous requester treated as different) — the
GameStream path can't be driven without a Moonlight client, so this covers the logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the mode-conflict admission surface deferred from the initial Stage 4:
- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
<client>' string, which the client reads from ApplicationClosed (validated on
loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
Windows native host admits a second client at the live mode instead of the old
silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
[u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
take over. Same-client re-launch is never a conflict.
Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mode_conflict policy is now enforced at ADMISSION, before the punktfunk/1
Welcome, when a DIFFERENT client connects while another client's session is live:
- separate (default, unconfigured → no change): each client its own display.
- join: admit at the live display's mode (honest-downgrade — the Welcome carries it).
- steal: signal the victim session(s)' stop flags, wait the release grace, serve.
- reject: refuse the handshake with a busy reason (live mode + client label).
New vdisplay/admission.rs: the pure decide() (unit-tested — same-client never
conflicts, anonymous clients each distinct, join targets the oldest session) + a
live-session registry (identity + mode + stop flag) sessions register in once up.
Wired into punktfunk1 serve_session: admit() before validate_dimensions, register
after the data plane binds. A same-client reconnect never conflicts.
Validated on loopback (two probes, distinct identities, differing modes) across all
four policies: separate→own mode, join→live mode, steal→victim interrupted,
reject→handshake refused.
Remaining Stage-4 surface (deferred): GameStream 503 path, Windows-specific
defaults (separate→join map, silent-reconfigure→steal), reject reason delivered to
the client as a typed message (currently host-side log + connection close).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exclusive (topology=exclusive) was fire-and-forget — a field-reported bug had a
physical monitor STAY ACTIVE. isolate_displays_ccd now re-queries after each apply
and RETRIES (up to 4x) until count_other_active()==0, never trusting rc alone;
logs SOLE-active on success, an error if a display survives all attempts. Secure
desktop correctness depends on the lock screen not landing on a stray panel.
Primary: drop the temporary per-path diagnostic; pack the kept displays left-to-
right from the virtual's right edge instead of blindly shifting each by virt_width
(which left a dead gap when extend already placed them right).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: on a headless box the IDD auto-activates as the SOLE display, so
QueryDisplayConfig sees only the virtual — the physical is already deactivated
before set_virtual_primary_ccd runs (no physical to keep). Force EXTEND first to
reactivate every connected display alongside the virtual, then reposition to make
the virtual primary, keeping the physical active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Temporary diagnostic — the physical monitor goes black in topology=primary
despite rc=0; the SSH/session-0 view can't see the real interactive-session
topology, so log the active paths the host actually operates on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
modeInfoIdx lives in the Anonymous union (windows-rs), not directly on
sourceInfo — set_virtual_primary_ccd now reads .Anonymous.modeInfoIdx.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the deferred Windows primary-only CCD (Stage 2). set_virtual_primary_ccd
repositions the virtual output's source to (0,0) = primary and shifts the physical
display(s) to its right, ALL kept active — one atomic CCD SetDisplayConfig (not GDI
CDS_SET_PRIMARY, which storms MODE_CHANGE_IN_PROGRESS with another display live).
The manager's should_isolate() becomes topology_action() (3-way): extend (skip),
primary (set_virtual_primary_ccd), exclusive (isolate_displays_ccd). Restore-on-teardown
covers both. Validates the user's two scenarios on a physical-monitor .173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The KWin backend names its output Virtual-punktfunk-<id> from the client's
stable identity slot, so KWin persists per-output config (scale/mode) by name in
kwinoutputconfig.json and reapplies that client's scaling on reconnect — the KDE
scaling ask. Also fixes the latent clash where two concurrent sessions both used
Virtual-punktfunk (topology name-matching now uses the per-slot name).
- identity::global() + resolve_slot(fp, mode, default) — the shared persisted map
(Windows manager dropped its own field; both use the global — never same-process).
Default identity is per-platform: PerClient on Windows, Shared on Linux, so
unconfigured hosts keep today's behavior (Linux = single 'punktfunk' name).
- KwinDisplay carries the client fp (set_client_identity), computes the per-slot
name, threads it through the stream_virtual_output name + the topology helpers
(set_custom_refresh / apply_virtual_primary[_only] / other_enabled_outputs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the Windows-only per-client stable-id map into vdisplay/identity.rs:
- DisplayIdentityMap keyed on a composable string (identity_key: fingerprint,
or fingerprint+resolution under per-client-mode); LRU at 15, persisted to
display-identity.json (migrated from the legacy pf-vdisplay-identity.json).
- Windows manager wired to it, picking the key from the identity policy.
- Foundation for KWin per-slot output naming (persistent KDE scaling) — the
KWin wiring is the next Stage-3 step (needs a KWin box).
- Unit-tested (stable, per-client-mode split, LRU, key composition).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three topology levels become distinct behaviors (Stage 0 only did
extend-vs-exclusive, faking primary):
- vdisplay::effective_topology() -> the concrete level (console policy > legacy
*_VIRTUAL_PRIMARY env > Auto default). Backends read it directly at create
time; apply_session_env no longer writes the boolean env (one fewer connect-
path env mutation).
- Mutter: extend (no config), primary (virtual primary + physicals kept as
secondaries — build_primary_keeping_physicals), exclusive (sole, physicals
disabled). KWin: extend (no-op), primary (kscreen primary only), exclusive
(primary + disable others).
- Windows should_isolate treats primary as isolate (the primary-only CCD variant
is a follow-up); wlroots exclusive + the physical-keep effect need a
display-attached box (headless lab boxes can't observe primary vs exclusive).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pooled entry's lifecycle transition was inside debug_assert_eq!, whose
arguments don't evaluate in release builds — so acquire() never ran, the entry
stayed Idle, and release saw Noop → immediate teardown (no keep-alive). Caught
on-glass on the CachyOS box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ownership split (design/display-management.md §3): the registry owns the
per-session virtual-display lifecycle on Linux, so a display can outlive its
session (keep-alive) and be reused on reconnect.
- registry.rs: a Linux pool driven by the pure lifecycle machine. acquire()
reuses a kept (lingering/pinned) display of the same backend+mode, else
creates one and keeps the backend's keepalive so the compositor output (and
its PipeWire node_id) survives the session. The session's capturer holds a
gen-stamped DisplayLease instead of the real keepalive; its drop drives
linger/teardown. Enabling fact: KWin/Mutter/gamescope put their node on the
DEFAULT PipeWire daemon (remote_fd=None) — reconnect re-attaches by node_id,
no fd re-open. wlroots (remote_fd=Some, xdpw portal) passes through unchanged
(teardown-on-drop) pending the fresh-portal-capture re-attach.
- Default (unconfigured) linger = Immediate → today's teardown-on-disconnect,
so no behavior change without a keep-alive policy; concurrent sessions still
each create their own output (reuse only matches LINGERING entries).
- Wired build_pipeline (punktfunk1) + gamestream through registry::acquire;
capture_virtual_output signature unchanged. Windows delegates to vd.create
(the manager already leases) — unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the Arch firewalld service definitions into the RPM spec and the Debian
host package so every Linux packager installs them, and move the two XML files
to the shared packaging/linux/ home (alongside the .desktop files both the
PKGBUILD and deb scripts already source there) so there's one source of truth
instead of three drifting copies.
- rpm: install punktfunk-{gamestream,native}.xml to /usr/lib/firewalld/services/,
list them in %files host, and print the firewalld enable command in %post
(gated on firewall-cmd). Fedora/RHEL run firewalld by default, so this is where
it matters most; Bazzite inherits it via the sysext built from the package /usr.
- deb: install both XMLs in build-deb.sh and add the same firewalld-gated hint to
the postinst. Debian/Ubuntu ship no active firewall, so it's a no-op unless the
admin runs firewalld.
- PKGBUILD + arch README updated to the packaging/linux/ path.
- Firewall docs (bazzite README now leads with --add-service; debian README gains
a firewalld block) point at the shipped services; XML comments made
distro-neutral. Never auto-enabled — packages don't touch the admin's firewall.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pacman-repo setup step used a bash heredoc (`<<'EOF'`), which fish — the
default shell on CachyOS — cannot parse ("expected a string, but found a
redirection"). Replace it with a cross-shell `printf | sudo tee -a` form in both
the Arch guide and packaging/arch/README.md; `$repo`/`$arch` stay literal for
pacman and the output is byte-identical to the old heredoc.
Firewall: stock Arch ships none (ports already open), but CachyOS enables
firewalld by default and an Arch package must never touch the running firewall.
Ship firewalld service definitions the host package installs to
/usr/lib/firewalld/services/ (punktfunk-gamestream, punktfunk-native), not
auto-enabled; the install scriptlet prints the enable command only when
firewall-cmd is present. Document it in the Arch guide (new section) and README.
The mgmt API (loopback) and web console ports are deliberately not opened.
Also fix the "GTK4 couch/Deck client" mislabel — it's the native
GTK4/libadwaita Linux client (desktop/laptop/Deck are targets; the
controller-optimized launcher is one view, not its identity) — across the Arch
PKGBUILD/README, Arch guide, and the Debian README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
App Review declined 0.4.2 (3384) under guideline 2.4.5(i): the temporary
com.apple.security.temporary-exception.mach-lookup.global-name =
com.apple.audioanalyticsd exception "is not appropriate and will not be
granted." It had been added on the theory that CoreHaptics controller
rumble (RumbleRenderer / MenuHaptics) hard-crashes under the App Sandbox
without it, since the framework reaches the audio-analytics daemon over
Mach and the sandbox denies that global-name lookup.
Tested the theory directly on macOS with a real Xbox pad: a
CHHapticEngine start + full-intensity rumble in a genuinely enforced
sandbox (NSHomeDirectory redirected into the app container) with no
exception on the codesigned binary runs fine — no crash — even with a
live AVAudioEngine stream running concurrently. CoreHaptics tolerates
the denied lookup; the exception was never load-bearing.
So just remove it: CoreHaptics session rumble and menu haptics keep
working on macOS unchanged (no source change needed). DualSense stays on
its raw-HID path — a genuine Sony-motor gap — which needs no exception
either.
Resubmit requires a new build number and clearing the App Store Connect
App Sandbox entitlement-usage justification for this exception.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the idle path (empty /display/state + released:0 /display/release) on a
unit-test host, exercising the wiring + auth without touching any global owner.
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>
Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Gitea Arch registry signs its DB + packages, so 'SigLevel = Optional TrustAll' fails
non-interactively (pacman still needs the key to verify). Document the one-time
pacman-key import instead; install is then signature-validated under pacman's default
SigLevel (verified end-to-end: clean archlinux container -> repo sync -> install,
'Validated By: Signature').
Also cache /usr/local/cargo/git in arch.yml: the workspace pulls clients/windows'
git-pinned windows-reactor/windows deps to resolve, cloning windows-rs (huge) every run
otherwise — same registry+git cache deb.yml uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>