Commit Graph
19 Commits
Author SHA1 Message Date
dailz 30f8fe51f2 chore: clear clippy errors, document all unsafe blocks, deny new SAFETY debt
Audit-driven cleanup pass. End state:
  - cargo clippy --release --all-targets: 0 errors (was 4)
  - undocumented_unsafe_blocks warnings: 0 (was 67)
  - Cargo.toml: undocumented_unsafe_blocks escalated warn -> deny

Clippy correctness errors fixed:
  - src/bin/{sw_encode_bench,vaapi_import_bench}.rs: receive_first_frame
    rewritten per Oracle plan with total 10s deadline + 200ms wait slice +
    while-let drain of all control events. The previous loop body always
    exited on first iteration (never_loop); the new version actually retries
    and matches production's repeated-poll semantics in state_portal.rs.
  - src/avhw.rs: hash_sampled_y_plane tests now use a row_range(row, stride,
    width) helper instead of inline stride * N. Preserves the row-index
    intent across all sibling tests without tripping erasing_op (row==0) or
    identity_op (row==1).

Machine-applicable clippy autofixes applied via 'cargo clippy --fix':
  - unnecessary_cast, manual_is_multiple_of, needless_borrows_for_generic_args
  - manual_abs_diff, derivable_impls, new_without_default
  - unnecessary_map_or, unneeded_struct_pattern, redundant_locals

webrtc_gop_formula test rewritten to wrap the (fps * 2).max(20) formula in
a runtime lambda. The previous clippy --fix pass had constant-folded the
5fps case into assert_eq!(20, 20), silently stripping the floor-case
coverage. The lambda blocks the fold while keeping the formula exercisable.

67 SAFETY comments added across 7 files (cap_portal.rs 26, sw_encode_bench
21, state_portal.rs 7, vaapi_import_bench.rs 6, avhw.rs 5, state.rs 1,
main.rs 1). Two sites carry load-bearing invariant documentation:
  - cap_portal.rs:806 process callback documents the PipeWire raw_buf
    ownership contract across all 10 exit paths (audited: every path
    correctly requeues; fd ownership via dup() is independent and also
    exactly-once closed).
  - avhw.rs:341 unsafe impl Send for EncState documents the single-thread
    exclusivity assumption referenced by AGENTS.md.

All 97 unit tests + 3 integration tests still pass; cargo build --release
finishes clean. Lint escalation to deny freezes the SAFETY baseline: any
future patch adding an unsafe block without a // SAFETY: comment will fail
clippy at compile time.
2026-06-28 13:44:27 +08:00
dailz e7accecfec chore: trim verbose docstrings on Portal timeout constants and enum
Cleanup pass after Portal resilience commits (6ccb225, 68a6eec).

Trimmed docstrings that restated information already obvious from the
type/variant names or duplicated elsewhere:

- PORTAL_SERVICE_TIMEOUT: 6 lines -> 3 lines (keep 'why 5s' rationale)
- PORTAL_USER_DIALOG_TIMEOUT: 6 lines -> 2 lines (keep 'why 30s' rationale)
- PortalPhaseTimeout enum: 5 lines -> 1 line (variants are self-documenting)
- Service variant: 2 lines -> 1 line
- TokenDependent variant: 2 lines -> 1 line

Net: -15 lines of comment overhead. No behavior change.

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
2026-06-21 11:00:29 +08:00
dailz 68a6eecfbe fix(cap_portal): phased timeouts + token-aware retry for Portal setup
Phase 2 of Portal resilience. When xdg-desktop-portal is stuck,
setup_portal now fails fast with actionable diagnostics instead of
hanging indefinitely. Auto-recovers from stale restore token case.

Phased timeouts (per Oracle review):

  Phase 1: Screencast proxy creation          5s (no user interaction)
  Phase 2: create_session                      5s (no user interaction)
  Phase 3: select_sources                      5s with token / 30s without
  Phase 4: start + response                    5s with token / 30s without
  Phase 5: open_pipe_wire_remote               5s (no user interaction)

Phase 3/4 timeout depends on whether restore token was loaded:
  - With valid token: no permission dialog expected, 5s
  - Without token: user must click Allow in dialog, allow 30s

Token-aware retry (Oracle B'):

On timeout in phase 3 or 4 IF restore token was in use:
  1. Log warning explaining auto-recovery
  2. Delete cached token (~/.cache/wl-webrtc/portal-restore-token)
  3. Retry whole setup_portal once with no_persist=true behavior
  4. On second failure: exit with diagnostic

Retry is whole-flow (new Screencast proxy, new session). Does NOT
reuse half-created objects — Oracle warned this can leak state.

Diagnostic messages:

Service-side timeout (phases 1, 2, 5, or phase 3/4 without token):
  'Portal service did not respond within timeout while <phase>.
   Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde,
   then re-run wl-webrtc.'

Token-side timeout (phase 3/4 with token, after auto-retry exhausted):
  Same message + ' If this recurs, try: wl-webrtc --no-persist'

Implementation:

- PortalPhaseTimeout enum distinguishes Service vs TokenDependent failures
  (only TokenDependent triggers retry)
- _setup_portal_inner does the actual phased work with timeouts
- setup_portal wraps inner, handles retry on TokenDependent
- log_portal_phase_timeout helper for consistent diagnostics
- delete_restore_token for safe token removal (concurrent-instance safe)

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
- All 7 existing token tests pass unchanged
- SAFETY comments preserved verbatim
- 1 file changed, +199/-22 lines

Out of scope (Oracle deferred):
- --doctor diagnostic CLI subcommand
- Runtime watchdog (Portal going bad mid-session)
- systemd auto-restart (disrupts other Portal clients)
- Phased diagnostics for the optional PipeWire first-frame wait

Combined with Phase 1 (backend_detect.rs, commit 6ccb225), Portal
service issues now fail fast with clear recovery instructions instead
of hanging indefinitely.
2026-06-21 10:44:15 +08:00
dailz 3e60258627 feat(portal): BWE-driven resolution adaptation + duplicate frame skipping
WebRTC client bandwidth estimate now drives both encoder bitrate and
resolution tier selection, replacing the previous static-target encoder.

- webrtc.rs: enable str0m BWE (seeded at 5 Mbps), surface
  EgressBitrateEstimate + KeyframeRequest events, expose
  get_bwe_estimate() / set_need_keyframe()
- state_portal.rs: wire bitrate/resolution channels between the WebRTC
  thread and the encode thread; tier ladder [1440p, 1080p, 720p] with
  downscale at 60% budget and upscale hysteresis (120% sustained 10s)
- avhw.rs: SwEncImport::poll_resolution_commands() rebuilds the import
  filter graph on UpdateResolution; SwEncEncode::recreate_encoder()
  rebuilds sws/enc_video/yuv_frame atomically; hash_sampled_y_plane()
  skips duplicate frames; VBV x264opts cap IDR bursts; H.264 level 4.0
  (muxer) / 4.2 (WebRTC)
- state.rs: sync wlr-screencopy GOP to fps*2 max 20 for parity
- fix: drain bitrate_rx + resolution_rx BEFORE the stride check in
  encode_cpu_frame() so the new (smaller-stride) frame produced after
  a resolution change does not hit the stale (larger) enc_width and
  crash the encode thread
- WebRTC GOP widened to fps*2 max 20 (was fps/2 max 10)
2026-06-13 22:46:33 +08:00
dailz 503e4dbc22 feat(portal): independent WebRTC thread + channel tuning for 60fps mouse latency
- Move WebRTC send to dedicated wl-webrtc-webrtc thread (was inline in main loop)
- Reduce frame_rx 16→1, input_tx 2→1 (drop-on-full), webrtc_tx 32→2
- Recv_timeout 10ms→2ms to reduce pipeline latency
- Fix sent_gap_p95 stats bug: compute gap at actual send time in WebRTC
  thread instead of batch-draining at snapshot time (was always 0.0ms)
- High profile via AVCodecContext.profile, veryfast preset, 5x bitrate
- Stats drain via sent_gap channel with record_send_from_thread()
- Shutdown: drop input_tx → join encode → drop webrtc_tx → join webrtc
2026-06-07 18:30:09 +08:00
dailz caccfec44e fix(portal): compositor stall detection + filler frames + PipeWire state logging
P0: Detect compositor frame delivery stalls (>100ms no frames) and log
    stall/resume events with duration. Rate-limited to 1 warn/sec.

P1: Insert duplicate raw CpuNv12Frame filler during stalls at target fps.
    Keeps WebRTC stream smooth (sent_fps 20-40 instead of 3-5 during
    compositor pauses). Stops after 2s max stale. WebRTC mode only.

P2: Replace silent _ => {} in PipeWire state_changed callback with
    explicit Paused/Streaming/Connecting log messages.

P4: Add PwCtrlEvent::FormatChanged for mid-stream dimension changes.
    param_changed detects resolution renegotiation (skips first call).
    Logs warning in poll_and_encode; full encoder reinit deferred.

Verified: cargo check 0 errors, 70/70 tests, release build, --stats live.
2026-06-07 17:20:54 +08:00
dailz aae030f309 fix(webrtc): SO_SNDBUF 2MB + VBV rate limiting + stats integration
P0 - UDP send buffer: set SO_SNDBUF=2MB to prevent EAGAIN on large IDR
frames (218KB/256KB keyframes caused 18+ EAGAIN bursts). Actual Linux
buffer 4096KB confirmed.

P1 - VBV rate limiting: cap rc_max_rate=bitrate and rc_buffer_size=
bitrate/4 for WebRTC encode path, preventing oversized IDR frames.

Stats: integrate PipelineStats into cap_portal (dropped_count), state.rs
(wlroots path), webrtc.rs (browser getStats enhancement + stats panel).
2026-06-07 16:55:07 +08:00
dailz 9a5b09cd7f fix(security): harden token file permissions (closes #2)
- save_restore_token: use create_new(true) + mode(0o600) for exclusive
  atomic file creation, preventing symlink attacks and predictable
  temp file exploitation
- token_path: return Option, eliminate insecure /tmp fallback
- load_restore_token: reject insecure files (symlinks, wrong owner,
  group/world-readable permissions)
- Directory creation uses DirBuilderExt::mode(0o700) bypassing umask
- Added verify_secure_dir and ensure_secure_parent with full metadata
  validation (owner, permissions, symlink rejection)
- Added 11 regression tests covering all security scenarios
2026-06-06 11:05:00 +08:00
dailz b0ed6548a6 feat: add WebRTC streaming via str0m + portal session persistence
- Add src/webrtc.rs: HTTP signaling server + str0m Sans-IO WebRTC transport
  with H.264 Annex-B → RTP packetization and key-frame request handling
- avhw: introduce FrameOutput enum (Muxer | Channel) so SwEncState can
  output to either MP4 muxer or crossbeam channel for WebRTC
- cap_portal: support portal session restore tokens (PersistMode::ExplicitlyRevoked)
  to skip re-authorization dialog; add --no-persist flag to force fresh dialog
- args: make --output optional when --port is used for WebRTC mode
- state_portal: integrate WebRTC pipeline (encoder channel → RTP forwarding)
  with shorter GOP for WebRTC (fps/2, min 10)
- main: redirect tracing to stderr; validate --output or --port required
- Add dependencies: str0m 0.20, serde_json 1, dirs 6
2026-06-04 20:54:16 +08:00
dailz 74f4dc826d perf(portal): achieve 58-60fps PipeWire screen capture
- Force PipeWire quantum=512 via NODE_FORCE_QUANTUM (48000/512=93Hz scheduling)
- Switch to libx264 ultrafast/zerolatency with 6 threads
- Use two-phase poll_and_encode: blocking recv_timeout for first frame,
  non-blocking try_recv drain for subsequent frames
- Remove fps_limit from portal path (PW already rate-limits via quantum/KWin;
  fps_limit's min_interval was silently dropping ~10% of valid frames)
- Remove diagnostic instrumentation (TIMING/PIPEWIRE logs, timing fields,
  pw_stats counters)
- Add lightweight production stats: per-10s fps log + shutdown summary
- Prefer libx264 over libopenh264 (better quality at same speed)
2026-05-30 08:44:15 +08:00
dailz a83d146ed3 fix: FPS limiter never passes frames when input > target rate
The old FpsLimit compared timestamps between CONSECUTIVE frames.
When PipeWire delivers at 60fps (16ms intervals) and target is 30fps
(33ms min_interval), the gap between consecutive frames is always
16ms < 33ms, so EVERY frame was rejected after the first.

Fix: track last_output_time and compare against that instead of the
previous frame's timestamp. Now frames pass when enough time has
elapsed since the last OUTPUT, not since the last INPUT.

Also adds PipeWire process callback counter logging and frame
diagnostic STATS in state_portal.rs for debugging.
2026-05-29 22:09:35 +08:00
dailz d80b34f44f feat: GPU-downscale + software H.264 encode pipeline (WIP)
Add SwEncState in avhw.rs: GPU pipeline using scale_vaapi to downscale
4K BGRA -> 2K NV12 on AMD iGPU, then software encode with libopenh264.

- import_dma_buf_to_vaapi: av_hwframe_map based DMA-BUF import
- SwEncState: GPU filter graph (scale_vaapi) + NV12->YUV420P + libopenh264
- state_portal.rs: integrated SwEncState, auto DRM device detection
- vaapi_import_bench.rs: CPU vs GPU pipeline benchmark
- sw_encode_bench.rs: software encode benchmark

Benchmark results: GPU pipeline ~91 FPS theoretical (10.95ms/frame)
vs CPU pipeline ~33 FPS (30.21ms/frame).

Known issue: only 1 frame encoded in production recording,
diagnostic STATS logging added to debug frame flow.
2026-05-29 22:04:12 +08:00
dailz 715a9c0bab refactor(cap_portal): split PwEvent into separate ctrl/frame channels
- Rename PwEvent to PwCtrlEvent, separate frame data into its own channel
- Add null chunk check to prevent crash on malformed PipeWire buffer
- Remove redundant inline comments and signal handlers
- Use try_send for error events to avoid blocking on full channel
2026-05-27 09:25:00 +08:00
dailz 460a3ee711 fix(cap_portal): remove unsafe pw::deinit() to prevent global state corruption
pw::init() is guarded by an internal OnceCell (process-global one-shot).
pw::deinit() is unsafe and requires 'only called once per process lifetime
after all PipeWire use has permanently stopped'. Since CapPortal can be
created/destroyed multiple times, calling deinit() from a function-local
scope would prevent re-initialization (OnceCell already consumed) and
violate the unsafe contract.

The 5 early-return error paths in pipewire_thread() that previously
leaked global state are now consistent with the success path — neither
calls pw::deinit(). Process exit reclaims global PipeWire state.
2026-05-25 14:32:19 +08:00
dailz dcf8d1affb docs: add Chinese documentation comments to core modules
Add comprehensive Chinese documentation comments to cap_portal,
main, and state_portal modules covering architecture, lifecycle,
and data flow for each component.
2026-05-25 08:56:43 +08:00
dailz b9e62d6830 fix(portal): reject frames before format negotiation to prevent zero-dimension encoder init 2026-05-22 15:17:14 +08:00
dailz a09a4235d3 fix(portal): replace blocking send with try_send in PW process callback
PipeWire .process callback called frame_tx.send() on a bounded(3)
channel. If the encoder stalled, this blocked the PipeWire data loop,
delaying buffer recycling and potentially causing XRUNs.

Replace with try_send + AtomicU64 drop counter. Frames are silently
dropped when the channel is full (preferred for screen capture: latest
frame wins). A warning is logged every 30 dropped frames.

Fixes #2 from BUGS.md.
2026-05-22 11:42:42 +08:00
dailz e40ef9eba2 fix(cap_portal): eliminate PipeWire mainloop UAF by replacing detached helper thread with eventfd + loop-integrated add_io
The detached helper thread that called pw_main_loop_quit() through a raw
pointer cast to usize could outlive the mainloop if run() returned on its
own (event loop error, panic in callback, etc.), causing use-after-free.

Replace with an eventfd registered on the PipeWire loop via add_io(). The
shutdown callback runs on the loop thread during mainloop.run(), where the
mainloop is guaranteed alive. Drop order (reverse declaration) ensures the
IO source is unregistered before mainloop is destroyed.

Fixes: #1 (Critical UAF)
2026-05-22 11:30:12 +08:00
dailz d7fbb5256c feat: add KWin/KDE Plasma screen capture via xdg-desktop-portal ScreenCast + PipeWire
Add a second capture backend for compositors without wlr-screencopy
(KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface
and PipeWire DMA-BUF streaming.

New files:
- src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend
- src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread
- src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI)

Changes:
- Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9,
  crossbeam-channel 0.5
- src/args.rs: add --backend CLI flag
- src/avhw.rs: extract create_encoder() from inline State code
- src/main.rs: route to portal or wlr-screencopy based on backend
- src/state.rs: fix params.destroy() on dup failure, cleanup
  in_flight_surface on copy fail, use create_encoder()
- tests/integration_test.rs: add --backend flag tests
2026-05-11 08:49:08 +08:00