Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,12 @@ timings during pointer motion. Also record the process peak-RSS delta while enco
`Ctrl+A` crop. Keep these measurements with the change's manual test evidence; they are intentionally
not collected by CI.

These captures now fail when the active output does not advertise a current `wl_output` mode.
Plugging or unplugging any monitor also cancels in-flight Freeze and Zoom captures on unrelated
outputs, because output count is part of the layout identity used to reject stale frames.
Freeze and Zoom fail when the active output does not advertise a current `wl_output` mode.
Changes confined to another monitor preserve direct captures and installed active-output sources.
Portal captures discard stale desktop snapshots and retry once after complete geometry settles,
while preserving the original output identity and request. A second change during the retry or an
active-output switch is terminal. Board PDF desktop captures retain a full-desktop generation check,
including changes to other monitors even when the screenshot dimensions stay the same.

`./tools/code-health-report.sh` reports navigational maintainability metrics. Its CI artifact is
observational, not a global file/function-size gate; use the report to find code worth understanding,
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,13 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818

| Platform | Status | Notes |
|----------|--------|-------|
| Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic, Plasma/KWin |
| Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic |
| Plasma/KWin | ✅ Supported | Layer-shell overlay; Freeze and Zoom use the `xdg-desktop-portal-kde` screenshot portal |
| GNOME | ⚠️ Partial | Normal overlay and Freeze via portal when available; [light passthrough](#light-passthrough-mode) unavailable |
| X11 | ❌ | Not supported |

On Plasma/KWin, Freeze and Zoom require a working screenshot portal and may require portal consent.

The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See the notes in [Debian and Ubuntu](#debian-and-ubuntu) and [Fedora and RHEL](#fedora-and-rhel). The AUR packages and Nix are unaffected.

<details>
Expand Down
8 changes: 7 additions & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ Then use the configurator's Daemon tab, or create a GNOME custom shortcut that r
wayscriber --daemon-toggle
```

Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` when either protocol is available, then falls back to the screenshot portal. On GNOME, Freeze works when that portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than direct compositor capture. On mixed-DPI or multi-monitor layouts, Wayscriber accepts a portal image only when it can validate the active output's crop; otherwise Freeze fails instead of guessing at an origin or scale. Freeze and Zoom also require the active output to advertise a current mode. Connecting or disconnecting a monitor cancels any in-flight Freeze or Zoom capture, including on screens that did not change.
Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` when either protocol is available, then falls back to the screenshot portal. On GNOME, Freeze works when that portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than direct compositor capture. On mixed-DPI or multi-monitor layouts, Wayscriber accepts a portal image only when it can validate the active output's crop; otherwise Freeze fails instead of guessing at an origin or scale. Freeze and Zoom also require the active output to advertise a current mode.

Direct Freeze and Zoom captures remain valid when only another monitor changes.
Portal captures discard a stale desktop snapshot and retry once on the same active
output, after complete output geometry settles. A further change during the retry
ends the capture. Switching or replacing the active output also ends the request;
start a new capture on the desired output.

Light passthrough mode is not available in the regular app on stock GNOME Wayland. GNOME's xdg-shell fallback does not expose the shell-level overlay behavior needed to keep annotations visible while input goes to apps underneath, so `--light-toggle` is intentionally disabled instead of pretending to pass input through. A GNOME Shell extension companion would be the real path for that workflow.

Expand Down
2 changes: 2 additions & 0 deletions src/backend/wayland/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- `handlers/` translates Wayland/Smithay callbacks into state and input calls.
- `state.rs` is the central live runtime state root; `state/` contains state helpers for boards, buffers, damage, clipboard paste, toolbar plumbing, zoom, onboarding, and export handoff.
- `toolbar/` owns runtime toolbar layout, hit testing, rows, surfaces, rendering, and widgets.
- `portal_raster.rs` validates complete portal desktop snapshots, crops the active logical rectangle, and resamples it to transformed native output pixels. `frozen_geometry.rs` keeps active viewport validity separate from full desktop validity.
- `capture_preflight.rs` retains backend-specific admission, shared active-output/full-desktop generation bookkeeping, typed preflight failures, and the bounded portal layout settling/retry policy. Board PDF desktop contexts use the full-desktop generation; direct capture and installed sources use the active-output generation.
- `clipboard/`, `frozen/`, `zoom/`, and runtime session helpers integrate external compositor, clipboard, capture, and persistence behavior.

## Invariants
Expand Down
38 changes: 22 additions & 16 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ use crate::input::state::{InputEffect, InputEffectDrain, PendingBackendAction};
use crate::notification;

pub(super) fn poll_portal_captures(state: &mut WaylandState, now: Instant) {
let live_output_count = state.live_output_count();

// Apply any completed portal fallback captures without blocking.
state
.frozen
.poll_portal_capture(&mut state.input_state, now);
.poll_portal_capture(&mut state.input_state, now, live_output_count);
handle_pending_frozen_image(state, now);
let live_output_count = state.live_output_count();
state
.zoom
.poll_portal_capture(&mut state.input_state, now, live_output_count);
Expand All @@ -36,27 +37,32 @@ pub(super) fn poll_capture_deadlines(
qh: &wayland_client::QueueHandle<WaylandState>,
now: Instant,
) {
// Re-admit retries after dispatch, before GTK synchronization and rendering.
// This lets a fresh GTK generation be published in the same iteration.
state.poll_portal_layout_retries(now);
state.poll_overlay_capture_barrier_timeout(now);
if let Some(backend) = state.frozen.take_timed_out_direct_capture(now) {
warn!("{backend:?} frozen capture timed out; trying the next backend");
state.continue_frozen_capture_after_failure(backend, qh);
}
}

pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option<Duration> {
super::min_timeout(
state.overlay_capture_barrier_timeout(now),
super::min_timeout(
state.frozen.direct_capture_timeout(now),
super::min_timeout(
state.frozen.portal_timeout(now),
super::min_timeout(
state.zoom.portal_timeout(now),
state.surface.placement().xdg_frozen().timeout(now),
),
),
),
)
pub(super) fn capture_timeout(
state: &WaylandState,
now: Instant,
render_delay: Duration,
) -> Option<Duration> {
[
state.frozen.portal_layout_retry_timeout(now),
state.zoom.portal_layout_retry_timeout(now),
state.overlay_capture_barrier_timeout(now, render_delay),
state.frozen.direct_capture_timeout(now),
state.frozen.portal_timeout(now),
state.zoom.portal_timeout(now),
state.surface.placement().xdg_frozen().timeout(now),
]
.into_iter()
.fold(None, super::min_timeout)
}

fn handle_pending_frozen_image(state: &mut WaylandState, now: Instant) {
Expand Down
11 changes: 6 additions & 5 deletions src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ fn event_loop_timeout(
last_render_time: Option<Instant>,
) -> Option<Duration> {
let vsync_enabled = state.config.performance.enable_vsync;
let frame_cap_timeout = if vsync_enabled {
None
} else {
render::frame_rate_cap_timeout(state.config.performance.max_fps_no_vsync, last_render_time)
};
let should_block = capture_active
|| !state.surface.is_configured()
|| (vsync_enabled && state.surface.frame_callback_pending());
Expand Down Expand Up @@ -297,10 +302,6 @@ fn event_loop_timeout(
let base_timeout = if should_block {
min_timeout(autosave_timeout, focus_exit_timeout)
} else if !vsync_enabled && state.input_state.needs_redraw {
let frame_cap_timeout = render::frame_rate_cap_timeout(
state.config.performance.max_fps_no_vsync,
last_render_time,
);
let frame_timeout = match (frame_cap_timeout, animation_timeout) {
(Some(frame), Some(animation)) => Some(frame.min(animation)),
(Some(frame), None) => Some(frame),
Expand All @@ -323,7 +324,7 @@ fn event_loop_timeout(
state.toolbar_drag_handoff_timeout(now),
state.input_state.command_palette_repeat_timeout(now),
state.input_state.font_picker_repeat_timeout(now),
capture::capture_timeout(state, now),
capture::capture_timeout(state, now, frame_cap_timeout.unwrap_or(Duration::ZERO)),
interaction::interaction_timeout(state.spotlight.wheel_idle_deadline(), now),
durable_action_retry_timeout(state, now),
pending_backend_action_timeout,
Expand Down
74 changes: 74 additions & 0 deletions src/backend/wayland/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ pub(in crate::backend::wayland) struct PendingBoardPaste {
}

impl CaptureLayoutContext {
pub(in crate::backend::wayland) fn for_desktop(
target_output_id: u32,
source: &super::frozen::FrozenState,
) -> Self {
Self::new(target_output_id, source.desktop_layout_generation())
}

pub(in crate::backend::wayland) fn matches_desktop(
self,
active_output_id: Option<u32>,
source: &super::frozen::FrozenState,
) -> bool {
self.matches(active_output_id, source.desktop_layout_generation())
}

pub(in crate::backend::wayland) fn new(target_output_id: u32, layout_generation: u64) -> Self {
Self {
target_output_id,
Expand Down Expand Up @@ -619,4 +634,63 @@ mod tests {
assert!(!context.matches(Some(7), 4));
assert!(!context.matches(None, 3));
}

#[test]
fn desktop_capture_context_rejects_other_output_changes_with_unchanged_raster_size() {
use crate::backend::wayland::frozen::FrozenState;
use crate::backend::wayland::frozen_geometry::OutputGeometry;
use wayland_client::protocol::wl_output;

let mut source = FrozenState::new(None);
let mut backdrop = crate::capture::DesktopBackdropGeometry {
logical_x: 0,
logical_y: 0,
logical_width: 2,
logical_height: 1,
physical_width: Some(2),
physical_height: Some(1),
crop_x: Some(0),
crop_y: Some(0),
screenshot_width: Some(4),
screenshot_height: Some(1),
};
let geometry = OutputGeometry::update_from(
Some((0, 0)),
Some((2, 1)),
(2, 1),
1,
wl_output::Transform::Normal,
Some((2, 1)),
)
.unwrap()
.with_desktop_backdrop_geometry(Some(backdrop))
.with_known_output_count(Some(2));
source.set_active_output(None, Some(7));
source.set_active_geometry(Some(geometry.clone()));
source.set_pending_output_image(
crate::backend::wayland::frozen::FrozenImage {
width: 2,
height: 1,
stride: 8,
data: vec![7; 8],
},
7,
geometry.clone(),
);
let mut input = crate::input::state::test_support::make_test_input_state();
assert!(source.activate_pending_image(2, 1, &mut input).unwrap());
let context = CaptureLayoutContext::for_desktop(7, &source);
let direct_provenance = source.image_provenance().unwrap();

assert!(context.matches_desktop(Some(7), &source));
assert!(!context.matches_desktop(Some(8), &source));

backdrop.crop_x = Some(1);
source.set_active_geometry(Some(
geometry.with_desktop_backdrop_geometry(Some(backdrop)),
));

assert!(source.source_context_matches(direct_provenance));
assert!(!context.matches_desktop(Some(7), &source));
}
}
Loading
Loading