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>
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>
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>
Streaming the KDE *Desktop* (KWin) session failed on a real interactive
Plasma session with "KWin does not expose zkde_screencast_unstable_v1":
KWin treats the screencast/virtual-output and fake_input globals as
restricted and advertises them only to a client whose installed .desktop
lists them under X-KDE-Wayland-Interfaces (matched by /proc/<pid>/exe ->
Exec, and cached per-executable on first connect). The host shipped no
.desktop, so it was permanently denied; it only ever worked on the
headless dev box via KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.
Ship packaging/linux/io.unom.Punktfunk.Host.desktop (least-privilege:
only the host, only zkde_screencast_unstable_v1 + org_kde_kwin_fake_input)
and install it from the RPM/.deb/Arch host packaging so it is present
before the host first connects. Drop the blunt session-wide
NO_PERMISSION_CHECKS hack from kde-desktop-setup.sh (it now only seeds the
RemoteDesktop input grant) and fix the now-misleading kwin.rs docs/errors.
Validated live on a Bazzite Kinoite box (KWin 6.6.4): probe-compositor +
spike --source kwin-virtual succeed against a KWin running WITHOUT the
permission bypass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:
zerocopy/cuda.rs (26) leaked process-lifetime libcuda fn-ptr table; opaque
CUcontext never dereferenced; free-exactly-once via the
Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
zerocopy/egl.rs (18) eglGetProcAddress'd procs with the GL context current;
EGLImage liveness; the two-call modifier-query bounds
zerocopy/vulkan.rs (4) copy-bounds arithmetic (src_size>=span); Send = thread
confinement to the punktfunk-pipewire thread
dmabuf_fence.rs (4) poll/ioctl/close fd liveness + ownership
capture/linux/mod.rs (16) spa_data repr(transparent) cast; null-checked spa
derefs; single-loop-thread buffer ownership until requeue
inject/linux/gamepad.rs (10) uinput ioctl request-number ↔ struct-size match
(static-asserted); InputEventRaw no-padding for the byte cast
encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9) ffmpeg object ownership/
free ladders; VAAPI/DRM graph; Send = single-thread transfer
inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)
No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.
Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
capture/{windows,linux}/ encode/{windows,linux}/ inject/{windows,linux,proto}/
audio/{windows,linux}/ vdisplay/{windows,linux}/
src/windows/ (service, wgc_helper, win_adapter, win_display)
src/linux/ (dmabuf_fence, drm_sync, zerocopy/)
Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.
Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>