diff --git a/.gitignore b/.gitignore index 37872fd..6eb793b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target/ +/tools/ *.log ChatGPT Image*.png diff --git a/Cargo.lock b/Cargo.lock index 9f25ad0..94316a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,16 +1604,6 @@ dependencies = [ "ferese-layout", ] -[[package]] -name = "ferese-effects-probe" -version = "0.1.0" -dependencies = [ - "ferese-protocols", - "tempfile", - "wayland-client", - "wayland-protocols-wlr", -] - [[package]] name = "ferese-ipc" version = "0.1.0" @@ -1626,18 +1616,6 @@ dependencies = [ name = "ferese-layout" version = "0.1.0" -[[package]] -name = "ferese-lock-probe" -version = "0.1.0" -dependencies = [ - "wayland-client", - "wayland-protocols", -] - -[[package]] -name = "ferese-malformed-client" -version = "0.1.0" - [[package]] name = "ferese-protocols" version = "0.1.0" @@ -1677,23 +1655,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "ferese-shell-control-probe" -version = "0.1.0" -dependencies = [ - "ferese-protocols", - "wayland-client", -] - -[[package]] -name = "ferese-shell-spike" -version = "0.1.0" -dependencies = [ - "ferese-protocols", - "libcosmic", - "wayland-client", -] - [[package]] name = "feresectl" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dd53f8a..fa9e931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,6 @@ members = [ "crates/ferese-protocols", "shell/ferese-shell", "shell/ferese-settings", - "tools/ferese-effects-probe", - "tools/ferese-malformed-client", - "tools/ferese-shell-control-probe", - "tools/ferese-lock-probe", - "tools/ferese-shell-spike", ] resolver = "3" diff --git a/README.md b/README.md index 79af0e7..fd4c12a 100644 --- a/README.md +++ b/README.md @@ -1,511 +1,123 @@ -# Ferese +

+ Ferese logo +

-Ferese is a Wayland scrolling compositor written in Rust with -Smithay. Scrolling columns are the default, tree tiling remains available as an -optional per-workspace mode, and geometry changes use timestamp-driven -animation. See the -[window-manager specification](docs/ferese-window-manager-spec.md) for the -current architecture and milestones. +

Ferese

-For a versioned installation alongside Plasma in SDDM, see -[installation and recovery](docs/installation.md). `Super+Shift+E` or -`feresectl exit` logs out immediately; save your work first. +

An animation-first scrolling Wayland desktop.

-## Live configuration +

+ Install · + Configure · + Try it · + Develop +

-`ferese-settings` opens the native settings application, also available from -Control Center → Settings after installation. It includes appearance presets, -wallpaper selection, menu bar geometry, window behavior, motion, keyboard/mouse, -custom shortcut editing, login items, and existing display profiles. Changes save -automatically; text fields save on Enter or when leaving the field. Sliders save -when released. Settings also refreshes after external config edits, preserving -active drafts. Display profiles, touchpad options, and login items update live. +Ferese combines a scrolling window manager with its own menu bar, control center +and native Settings app. Built in Rust on Smithay, it brings configurable motion +and a cohesive desktop interface to a keyboard-driven workflow. -The app validates changes with `ferese --check-config PATH` before atomically -replacing your file, preserves comments and unrelated keys, and keeps a -`config.toml.settings-backup`. Undo restores the previous save; external edits -require Reload before saving. Search filters settings categories. To preview -against a disposable config without changing your desktop config: +## A desktop that moves with you + +- **Scrolling columns:** a horizontal window strip with configurable widths and + focus behavior. Tree tiling is available per workspace. +- **Coordinated motion:** animated focus, resize, workspace transitions and + overview, with adjustable speed and reduced-motion support. +- **An integrated shell:** workspace selector, status popovers, notification + controls and a compact control center. +- **Native Settings:** appearance, wallpaper, input, motion and more, with + automatic saving, validation and undo. +- **Your own look:** solid or translucent shell surfaces, backdrop blur, + rounded corners, soft shadows and optional gradient focus rings. +- **Multiple displays:** per-output workspaces, fractional scaling, monitor + hotplug and laptop-lid workspace migration. +- **Live configuration:** update TOML without restarting the desktop. + Invalid changes keep the last working configuration. +- **Session locking:** `ext-session-lock-v1` with swaylock or swaylock-effects + through `ferese-lock`. + +## Try it + +Preview Ferese inside your current Wayland desktop: ```sh -cargo build -p ferese -p ferese-settings --locked -target/debug/ferese-settings --config /tmp/ferese-settings-preview.toml -``` - -The GUI is not a replacement for every advanced config option: adding display -profiles, arranging outputs, new shortcut definitions, and window rules remain -in `config.toml`. Theme presets update palette and focus-gradient colors without -replacing custom rules, bindings, wallpaper, or layout settings. - -New installations float Settings by default. To do the same in an existing -config, add a `[[window_rules]]` entry with `app_id = "dev.ferese.Settings"` -and `floating = true`. - -Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`) -reloads it automatically. Atomic editor saves are supported and bursts are -debounced. Invalid edits keep the last working compositor and shell config; -the rejection is logged. Idle watching does not reread the file. - -Use `feresectl reload-config` to reload manually and report validation errors. -Live sources are limited to 60 KiB for safe delivery over the private protocol. - -Appearance, fonts, wallpaper, animations, keybindings, keyboard layout/repeat, -gaps, scrolling focus policy/default widths, and bar geometry reload live. -Wallpaper decoding stays off the render thread and keeps the old image until -the replacement is ready. Layout-mode changes convert existing workspaces; -default-width changes update columns using the previous default, preserving -custom widths. Changed window rules are reapplied to existing matching windows. -Output profiles and touchpad changes apply immediately. Display changes retain -output/workspace identity; configurations with no usable output are rejected. -The compositor and shell need one initial update/restart to enable this feature. - -## Session services and locking - -`[[autostart]]` entries run after the compositor's public socket is ready: - -```toml -[[autostart]] -command = ["awari"] -enabled = true -restart = true - -[[autostart]] -command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] -restart = true -``` - -Services are session-owned, reaped, and restarted with a five-second retry delay. -They receive the correct public Wayland display without private shell privileges. -Nested previews skip them unless `nested = true` is explicitly configured. -Autostart changes reconcile live: unchanged commands keep their processes, -new/enabled items start, and disabled/removed commands stop without blocking rendering. -Run foreground daemons here, not commands that fork themselves into the background. - -Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an -opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and -screencopy is unavailable. DRM confirmation waits for safe page flips on active -outputs. A missing/crashed locker leaves the session locked; it is not an unlock. -`ferese-lock` runs swaylock/swaylock-effects with optional -`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing. -The compositor authenticates no passwords itself; authentication belongs to the locker. -Validate password unlock in a disposable nested session before enabling automatic -locking on a primary session. Crash recovery currently requires ending that session -from another VT; there is deliberately no unauthenticated IPC unlock. - -## Development - -Install the native dependencies required by Smithay's winit and GLES backends, -then run: - -```bash -cargo run -p ferese -``` - -Pass a client and its arguments after `--` to launch it inside Ferese: - -```bash -cargo run -p ferese -- foot -``` - -Set `RUST_LOG=ferese=debug` for detailed compositor logging. - -`Super+F` toggles decorated maximization inside the workspace (bar and outer -gaps remain visible). In scrolling mode this expands the column to full width, -keeps it in the strip, and restores its previous width when toggled off. New -windows open to its right and scroll into focus. -`Super+Shift+F` toggles true fullscreen (no bar or window -decorations). Leaving fullscreen restores the previous maximized/tiled state. - -Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under -the `ferese::render` tracing target. Summaries include rendered frames, damaged -pixels, average and longest render time, and DRM missed-deadline totals. - -The native-client soak runner and its nested/DRM procedure are documented in -[docs/native-soak-testing.md](docs/native-soak-testing.md). - -The M7 semantic-effects probe must use a compositor-created private connection. -Build it, then grant only the `effects` capability to that launched process: - -```bash -cargo build -p ferese-effects-probe -cargo run -p ferese -- --grant-effects -- target/debug/ferese-effects-probe -``` - -Add `--preview` to keep a solid popover visible in the nested compositor: - -```bash -cargo build -p ferese-effects-probe -cargo run -p ferese -- --backend nested --grant-effects -- \ - target/debug/ferese-effects-probe --preview -``` - -Press `Super+Enter` in the Ferese window to open `foot`, then move or update the -terminal beneath the popover to inspect stacking, rounded clipping, and shadows. Stop the preview with `Ctrl+C` in the host terminal. - -To verify that the private global is absent from the public Wayland socket: - -```bash -cargo run -p ferese -- target/debug/ferese-effects-probe --expect-hidden -``` - -Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or -`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling -columns use half the viewport by default. To make every newly opened column use -the full viewport width: - -```toml -[scrolling] -default_column_width = "full" -``` - -`default_column_width = 1.0` is equivalent. `Super+M` switches the active -workspace between scrolling and tree modes. - -Outer gaps remain visible with one window by default. To intentionally remove -them for a lone tiled window: - -```toml -[layout] -outer_gap = 4.0 -smart_gaps = true -``` - -Managed-window borders use theme tokens and overlay the window edge without -adding inner padding or changing client geometry: - -```toml -[theme.colors] -border = "#FFFFFF18" -accent = "#5B8CFF" -shadow = "#00000055" - -[theme.geometry] -border_width = 1.0 -focus_ring_width = 2.0 -window_radius = 14.0 - -[theme.shadow.soft] -offset_y = 4.0 -blur = 18.0 -opacity = 0.20 - -[theme.material] -style = "solid" # solid or translucent -blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur - -[appearance.inactive_dim] -enabled = true # opt-in; disabled by default -amount = 0.15 # dark tint over unfocused windows, 0–1; 0 disables -duration_ms = 150 # 0 snaps immediately; reduced motion also snaps -``` - -Inactive dimming follows window focus with a smooth, interruption-safe fade. -Focus-ring paint (including gradients) and width use the same `duration_ms`, -even when dimming is disabled. Focus changes never scale or move the window. -Shell buttons fade their hover tint over 120 ms. Both transitions respect -`animations.speed`, `animations.enabled`, and `animations.reduced_motion`; -click actions remain immediate and settled hover states do not animate. -It keeps window opacity intact, respects animated geometry and rounded corners, -and leaves shell bars/popovers untouched. Overview temporarily removes dimming. - -Material roles use the configured surface color and role-specific shadows. -Solid is the default. Translucent adds backdrop blur behind the same surface -color, without grain, glossy lighting, saturation boosts, or inset highlights. -The radius is clamped to 32 logical pixels. A shader/allocation failure falls -back to a plain translucent fill. Shell popovers report rounded card regions; -their outer padding and gaps stay transparent. Each surface shares one capture -across its cards, so cards do not sample already blurred neighboring cards. -Use `--preview --regions` with the effects probe to inspect two cards over a -patterned background, including the sharp gap between them. - -```toml -[appearance] -corner_radius = 14.0 -``` - -Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover -and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%` -(vertical offset / blur / opacity). Private effects capabilities -remain required; public background-effect requests cannot select these materials. - -Fullscreen windows are borderless and shadowless. Other managed windows use GPU -rounded clipping, matching rounded focus rings, and an analytic soft shadow. -These effects overlay or surround the allocation without adding client padding. - -Scrolling keeps windows in stable workspace coordinates and animates the -viewport only when focus must be revealed. Set -`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused -columns instead. - -Use `focus_strategy = "paged"` to pack consecutive columns into viewport-sized -pages. Half-width columns form pairs, thirds form triples, and full-width -columns occupy their own page. Mixed widths and client minimum sizes use their -actual allocated widths, including gaps. Focusing within a page keeps it still; -crossing a page boundary scrolls to that page. Explicit centering remains in -effect until focus changes or the output resizes. - -Scrolling controls use `Super+R` to cycle configured width presets, `Super+C` -to center the focused column, `Super+[` to consume the focused window into an -adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the -corresponding direction. - -`Super+Tab` toggles the compositor overview. Its live window previews retain -their client surfaces, `Super+H/J/K/L` moves the selection using the presented -preview geometry, and clicking a preview activates it without forwarding the -selection click to the application. - -The shell provides an edge-to-edge libcosmic menu bar backed -by the private shell-control connection. Build and preview it nested with: - -```bash -cargo build --release -p ferese -p ferese-shell +cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl target/release/ferese --backend nested --grant-effects --grant-shell-control -- \ target/release/ferese-shell ``` -The Ferese mark enters overview, where workspace indicators become available. -The active workspace's window list appears on the left, with click-to-focus -and a highlighted active window; status controls and day/time sit on the -right. The 28px bar has square screen edges, 19px icons, and no border or drop -shadow. Window spacing uses the workspace outer gap; `top_bar_window_gap` under -`[theme.geometry]` adds optional extra space (default 0). Solid and translucent -shell surfaces also omit outlines. The dark startup fallback matches the material palette. +Use a recent Rust toolchain (1.93 or newer for the shell), Smithay's native +build dependencies, and `foot` for the default terminal. Settings wallpaper +browsing uses `zenity`; entering an image path works without it. -The compositor decodes wallpaper in parallel with startup. Small shell surfaces prefer the lightweight -software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU -rendering for shell content. Use release builds when evaluating startup and -animation performance; debug image processing is considerably slower. +For a login-screen session alongside your existing desktop: -Resize presentation configures the destination once and coordinates the -matching client commits with the workspace motion (300 ms maximum wait). -Application content stays at native pixel size; animated bounds crop/reveal -it rather than stretching text. A resize-only previous-frame snapshot fades -out over 80 ms, with a 64 MiB total snapshot budget and automatic release. -All monitors share one elapsed-time animation clock, and window content, -clips, borders and shadows use the same physical-pixel edges. - -Window borders and focus rings use solid colors by default. Optional linear -gradients can be configured independently: - -```toml -[theme.focus_ring.gradient] -from = "#e5c890" -to = "#b98d58" -angle = 135.0 - -# Optional unfocused-window gradient: -[theme.border.gradient] -from = "#3e3e43" -to = "#242429" -angle = 90.0 +```sh +./scripts/install.sh ``` -Angles are clockwise in window coordinates: 0 is left-to-right and 90 is -top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either -gradient section to restore its solid `theme.colors.accent` or `border` color. -Gradients share the existing rounded-border shader and add no animation or -extra render pass. Configuration changes reload live. +The installer preserves your configuration and keeps previous releases for +rollback. Log out and select **Ferese** at the login screen. An upgrade does +not replace the running compositor: log out and back in to activate it. +See [installation and recovery](docs/installation.md) for prerequisites, +session logs and rollback. -Overview has a horizontally scrollable workspace strip with live window -thumbnails and an accent outline on the workspace shown by each output. -Click a workspace to change the window grid without leaving overview; click -a window to focus it and exit, or press Escape to dismiss. Only the workspace -strip has a tinted surface; the window grid sits directly over the wallpaper. -Strip labels use `[theme.typography].font_family` and rasterize at output scale. -The strip fades independently and preserves its opacity on rapid reversals, -including when the selected workspace is empty. +## Get around -Ferese renders `[theme.background]` wallpapers directly: `path` selects the -image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded -image and one texture per GPU context serve every output; resizing requires -no image reload or full-screen shell buffers. The launched shell receives -`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces. -When no valid image path is configured, the shell's existing fallback remains. +These are built-in defaults; your configuration can override them. -Keyboard and direct-session touchpad settings reload live: +| Shortcut | Action | +| --- | --- | +| Super + Enter | Open a terminal | +| Super + H / J / K / L | Focus a window | +| Super + R | Cycle column width | +| Super + C | Center the focused column | +| Super + F | Maximize, keeping the bar and decorations | +| Super + Shift + F | True fullscreen | +| Super + Tab | Open workspace and window overview | +| Super + M | Switch scrolling / tree layout | +| Super + Shift + E | Log out immediately—save your work first | + +## Make it yours + +Open **Control Center → Settings**, or run `ferese-settings`. +Advanced options live in `~/.config/ferese/config.toml` (or under +`$XDG_CONFIG_HOME`). Start with the [example config](packaging/config.toml). ```toml -[input] -focus_follows_mouse = false -xkb_layout = "us" -xkb_variant = "" -xkb_options = [] -repeat_rate = 25 -repeat_delay_ms = 600 +[scrolling] +default_column_width = 0.5 +focus_strategy = "paged" -[input.touchpad] -tap = true -natural_scroll = true -disable_while_typing = true -``` - -Touchpad settings apply to libinput devices in the direct DRM session. In the -nested backend, the host compositor remains responsible for physical touchpad -configuration. - -The direct DRM backend selects the first output profile whose listed monitors -are all connected. Match either the connector name reported by `get-outputs` -or its persistent EDID-derived identity: - -```toml -[[output_profiles]] -name = "docked" - -[[output_profiles.outputs]] -match = "HDMI-A-1" -mode = "3840x2160@119.998" -scale = 1.6 -position = [0, 0] - -[[output_profiles.outputs]] -match = "eDP-1" -enabled = false - -[[output_profiles]] -name = "laptop" - -[[output_profiles.outputs]] -match = "eDP-1" -mode = "2880x1800@120" -scale = 1.6 -position = [0, 0] -``` - -Unspecified connected outputs remain enabled with their preferred mode, scale -`1.0`, normal transform, and automatic horizontal placement. Supported -transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, -and their rotated flipped variants. Output configuration is applied at startup, -on hotplug, and live reload, including modes, scale, transform, and position. - -On DRM, closing the laptop lid disables internal panels when a usable external -output is available. With no usable external output, the panel stays enabled; -Ferese does not change the system's suspend policy. Workspaces are evacuated -to a remaining output without merging their windows, and return to their -original output when the lid reopens or a disconnected monitor returns. -Normal use of an evacuated workspace does not cancel its automatic return. - -Commands are argv arrays and are started directly, without a shell. The -default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: - -```toml -[commands] -terminal = ["foot", "--app-id", "terminal"] - -[[bindings]] -keys = "Super+Enter" -action = "spawn" -argument = "terminal" -``` - -A configured binding replaces the built-in binding with the same normalized -chord and match mode. Disable a default explicitly with no action: - -```toml -[[bindings]] -keys = "Super+Q" -disabled = true -``` - -Bindings follow the active layout by default. To bind a keyboard position -instead, set `match = "physical"` and use an XKB physical key name such as -`AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing -command references stop startup with an error. - -Initial window rules match `app_id`, exact title, and/or transient status. All -matching rules are applied in declaration order; later rules override only the -fields they specify. Width or height implies floating placement when `floating` -is omitted: - -With `floating = true` and no explicit dimensions, the application chooses its -initial size and Ferese centers that size on its workspace's output. Small -dialogs are not forced into a workspace-sized container. - -```toml [[window_rules]] -app_id = "org.example.Editor" -workspace = 3 +app_id = "dev.ferese.Settings" floating = true -width = 900.0 -height = 600.0 -fullscreen = false ``` -Rules are finalized on the first toplevel commit, before its buffer is -presented, and are not re-applied when an application later changes its title. +Saving the file reloads it automatically. Run `feresectl reload-config` +for an explicit reload with validation feedback. The +[configuration reference](docs/configuration.md) covers themes, window rules, +bindings, output profiles, session services and locking. -`feresectl` controls the running compositor through the same-user socket at -`$XDG_RUNTIME_DIR/ferese/control.sock`. For example: +## Development -```bash -cargo run -p feresectl -- focus left -cargo run -p feresectl -- workspace 2 -cargo run -p feresectl -- get-workspaces -cargo run -p feresectl -- get-outputs +```sh +cargo test --workspace --locked +cargo fmt --all --check ``` -The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, -`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, -`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and -`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The -runtime directory and socket are restricted to modes `0700` and `0600`, and the -server rejects peers whose effective UID differs from the compositor's. IPC has -a bounded request queue and accepts at most 64 concurrent client connections. -`get-outputs` includes disabled-but-connected monitors, connector and persistent -identity matchers, current and available modes with refresh rates, fractional -scale, transform, logical position and size, and physical dimensions. +Use release builds when evaluating animation and startup performance. +`RUST_LOG=ferese=debug` enables detailed logs; +`FERESE_TRACE_PERFORMANCE=1` enables per-output performance summaries. +Development probes under `tools/` are local-only and are not part of the +published Cargo workspace. -Text-input-v3 is available to applications by default. Input-method-v2 is -hidden unless the session explicitly opts in, because an input method can -request privileged keyboard access. Enable it only when launching a trusted -IME for the session: +- [Architecture and window-manager specification](docs/ferese-window-manager-spec.md) +- [Shell design](docs/ferese-shell-design.md) +- [Theme and UI specification](docs/ferese-theme-ui-spec.md) +- [Animation validation](docs/animation-validation.md) +- [Nested resize testing](docs/nested-resize-testing.md) +- [Native-client soak testing](docs/native-soak-testing.md) -```bash -FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese -``` - -`Ctrl+Alt+Escape` always releases an active client shortcut inhibitor. - -Keyboard-shortcut inhibition is hidden unless a trusted VM or remote-desktop -session explicitly enables it: - -```bash -FERESE_ENABLE_SHORTCUT_INHIBIT=1 cargo run -p ferese -``` - -The emergency `Ctrl+Alt+Escape` binding remains available while inhibition is -active. - -Screen capture is hidden by default. A session that has already made an -explicit capture-policy decision can expose screencopy-v1 to its clients with: - -```bash -FERESE_ENABLE_SCREENCOPY=1 cargo run -p ferese -``` - -The current M5 implementation supports one-shot output and region capture into -ARGB8888 shared-memory buffers. Cursor overlays are included only when the -capture client requests them. - -### Direct DRM session - -Run the hardware backend from a spare TTY, outside another graphical session: - -```bash -RUST_LOG=ferese=debug cargo run -p ferese -- --backend=drm -- foot -``` - -Do not run that command from a terminal inside the active desktop: the direct -backend requests control of the seat and primary DRM device. Use -Ctrl+Alt+F1–F12 to switch virtual terminals. - -M4 validation requires the direct session to: - -- start `foot` and render the same animated tiling workload as the nested path; -- retain keyboard and pointer input; -- pause when switching away and redraw after switching back; and -- log page-flip timing and any missed presentation deadlines on the reference - integrated GPU. - -The direct path creates an independent render pipeline per connected output and -rescans connectors on hotplug. Physical unplug/replug acceptance testing on the -reference hardware is still pending. +Test hardware behavior on your own setup, especially suspend/resume and locking. +Keep a working desktop session available while testing changes. diff --git a/compositor/src/state.rs b/compositor/src/state.rs index e3898d3..1acb709 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -835,6 +835,9 @@ impl Ferese { self.space.elements().rev().cloned().collect::>() }; candidates.iter().find_map(|window| { + if !self.window_content_ready(window) { + return None; + } let id = self.window_ids.get(window)?; if self.workspaces.workspace_for_window(*id) != Some(workspace) { return None; @@ -928,6 +931,15 @@ impl Ferese { )) } + pub(crate) fn window_content_ready(&self, window: &Window) -> bool { + window_has_buffer(window) + && self + .window_ids + .get(window) + .and_then(|id| self.window_geometry.get(id)) + .is_some_and(|geometry| geometry.client.committed_size.is_some()) + } + pub(crate) fn add_rule_placed_window( &mut self, window: Window, @@ -2453,7 +2465,10 @@ impl Ferese { self.viewport_coupled_widths.remove(&id); self.space.map_element(window.clone(), location, false); self.sync_window_stacking(); - crate::backends::direct::render_all(self); + // Move/resize/cancel callbacks run with Smithay's pointer mutex held. + // Cursor rendering reads current_location(), which would lock it again. + // The event-loop epilogue coalesces this redraw after the grab returns. + self.cursor_redraw_pending = true; } pub fn switch_workspace(&mut self, index: u32) { @@ -2724,6 +2739,11 @@ fn centered_transient_rect(parent: Rect) -> Rect { } fn client_size(window: &Window) -> Option { + // XDG geometry alone is metadata, not a painted application frame. + // Initial empty commits must not make the placeholder presentation ready. + if !window_has_buffer(window) { + return None; + } let size = window.geometry().size; (size.w > 0 && size.h > 0).then_some(ClientSize { @@ -2732,6 +2752,16 @@ fn client_size(window: &Window) -> Option { }) } +fn window_has_buffer(window: &Window) -> bool { + window.toplevel().is_some_and(|toplevel| { + smithay::backend::renderer::utils::with_renderer_surface_state( + toplevel.wl_surface(), + |state| state.buffer().is_some(), + ) + .unwrap_or(false) + }) +} + #[derive(Default)] pub struct ClientState { pub compositor_state: CompositorClientState, diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 36b33a2..efbbe6d 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1108,6 +1108,11 @@ fn output_elements( let windows = candidates .iter() .filter_map(|window| { + // Configure immediately, but present the frame/shadow only after + // the first buffer commit has settled the actual client geometry. + if !state.window_content_ready(window) { + return None; + } let id = *state.window_ids.get(window)?; // Scrolling columns may sit outside their monitor's rectangle. // They must not reappear on a neighboring output just because diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..503fc14 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,443 @@ +# Configuration and session reference + +## Live configuration + +`ferese-settings` opens the native settings application, also available from +Control Center → Settings after installation. It includes appearance presets, +wallpaper selection, menu bar geometry, window behavior, motion, keyboard/mouse, +custom shortcut editing, login items, and existing display profiles. Changes save +automatically; text fields save on Enter or when leaving the field. Sliders save +when released. Settings also refreshes after external config edits, preserving +active drafts. Display profiles, touchpad options, and login items update live. +Wallpaper browsing uses Zenity's native GTK file chooser (`zenity` must be +installed); entering an image path directly remains available. + +The app validates changes with `ferese --check-config PATH` before atomically +replacing your file, preserves comments and unrelated keys, and keeps a +`config.toml.settings-backup`. Undo restores the previous save; external edits +require Reload before saving. Search filters settings categories. To preview +against a disposable config without changing your desktop config: + +```sh +cargo build -p ferese -p ferese-settings --locked +target/debug/ferese-settings --config /tmp/ferese-settings-preview.toml +``` + +The GUI is not a replacement for every advanced config option: adding display +profiles, arranging outputs, new shortcut definitions, and window rules remain +in `config.toml`. Theme presets update palette and focus-gradient colors without +replacing custom rules, bindings, wallpaper, or layout settings. + +New installations float Settings by default. To do the same in an existing +config, add a `[[window_rules]]` entry with `app_id = "dev.ferese.Settings"` +and `floating = true`. + +Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`) +reloads it automatically. Atomic editor saves are supported and bursts are +debounced. Invalid edits keep the last working compositor and shell config; +the rejection is logged. Idle watching does not reread the file. + +Use `feresectl reload-config` to reload manually and report validation errors. +Live sources are limited to 60 KiB for safe delivery over the private protocol. + +Appearance, fonts, wallpaper, animations, keybindings, keyboard layout/repeat, +gaps, scrolling focus policy/default widths, and bar geometry reload live. +Wallpaper decoding stays off the render thread and keeps the old image until +the replacement is ready. Layout-mode changes convert existing workspaces; +default-width changes update columns using the previous default, preserving +custom widths. Changed window rules are reapplied to existing matching windows. +Output profiles and touchpad changes apply immediately. Display changes retain +output/workspace identity; configurations with no usable output are rejected. +The compositor and shell need one initial update/restart to enable this feature. + +## Session services and locking + +`[[autostart]]` entries run after the compositor's public socket is ready: + +```toml +[[autostart]] +command = ["awari"] +enabled = true +restart = true + +[[autostart]] +command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] +restart = true +``` + +Services are session-owned, reaped, and restarted with a five-second retry delay. +They receive the correct public Wayland display without private shell privileges. +Nested previews skip them unless `nested = true` is explicitly configured. +Autostart changes reconcile live: unchanged commands keep their processes, +new/enabled items start, and disabled/removed commands stop without blocking rendering. +Run foreground daemons here, not commands that fork themselves into the background. + +Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an +opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and +screencopy is unavailable. DRM confirmation waits for safe page flips on active +outputs. A missing/crashed locker leaves the session locked; it is not an unlock. +`ferese-lock` runs swaylock/swaylock-effects with optional +`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing. +The compositor authenticates no passwords itself; authentication belongs to the locker. +Validate password unlock in a disposable nested session before enabling automatic +locking on a primary session. Crash recovery currently requires ending that session +from another VT; there is deliberately no unauthenticated IPC unlock. + + +## Configuration reference + +Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or +`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling +columns use half the viewport by default. To make every newly opened column use +the full viewport width: + +```toml +[scrolling] +default_column_width = "full" +``` + +`default_column_width = 1.0` is equivalent. `Super+M` switches the active +workspace between scrolling and tree modes. + +Outer gaps remain visible with one window by default. To intentionally remove +them for a lone tiled window: + +```toml +[layout] +outer_gap = 4.0 +smart_gaps = true +``` + +Managed-window borders use theme tokens and overlay the window edge without +adding inner padding or changing client geometry: + +```toml +[theme.colors] +border = "#FFFFFF18" +accent = "#5B8CFF" +shadow = "#00000055" + +[theme.geometry] +border_width = 1.0 +focus_ring_width = 2.0 +window_radius = 14.0 + +[theme.shadow.soft] +offset_y = 4.0 +blur = 18.0 +opacity = 0.20 + +[theme.material] +style = "solid" # solid or translucent +blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur + +[appearance.inactive_dim] +enabled = true # opt-in; disabled by default +amount = 0.15 # dark tint over unfocused windows, 0–1; 0 disables +duration_ms = 150 # 0 snaps immediately; reduced motion also snaps +``` + +Inactive dimming follows window focus with a smooth, interruption-safe fade. +Focus-ring paint (including gradients) and width use the same `duration_ms`, +even when dimming is disabled. Focus changes never scale or move the window. +Shell buttons fade their hover tint over 120 ms. Both transitions respect +`animations.speed`, `animations.enabled`, and `animations.reduced_motion`; +click actions remain immediate and settled hover states do not animate. +It keeps window opacity intact, respects animated geometry and rounded corners, +and leaves shell bars/popovers untouched. Overview temporarily removes dimming. + +Material roles use the configured surface color and role-specific shadows. +Solid is the default. Translucent adds backdrop blur behind the same surface +color, without grain, glossy lighting, saturation boosts, or inset highlights. +The radius is clamped to 32 logical pixels. A shader/allocation failure falls +back to a plain translucent fill. Shell popovers report rounded card regions; +their outer padding and gaps stay transparent. Each surface shares one capture +across its cards, so cards do not sample already blurred neighboring cards. + +```toml +[appearance] +corner_radius = 14.0 +``` + +Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover +and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%` +(vertical offset / blur / opacity). Private effects capabilities +remain required; public background-effect requests cannot select these materials. + +Fullscreen windows are borderless and shadowless. Other managed windows use GPU +rounded clipping, matching rounded focus rings, and an analytic soft shadow. +These effects overlay or surround the allocation without adding client padding. + +Scrolling keeps windows in stable workspace coordinates and animates the +viewport only when focus must be revealed. Set +`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused +columns instead. + +Use `focus_strategy = "paged"` to pack consecutive columns into viewport-sized +pages. Half-width columns form pairs, thirds form triples, and full-width +columns occupy their own page. Mixed widths and client minimum sizes use their +actual allocated widths, including gaps. Focusing within a page keeps it still; +crossing a page boundary scrolls to that page. Explicit centering remains in +effect until focus changes or the output resizes. + +Scrolling controls use `Super+R` to cycle configured width presets, `Super+C` +to center the focused column, `Super+[` to consume the focused window into an +adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the +corresponding direction. + +`Super+Tab` toggles the compositor overview. Its live window previews retain +their client surfaces, `Super+H/J/K/L` moves the selection using the presented +preview geometry, and clicking a preview activates it without forwarding the +selection click to the application. + +The shell provides a configurable libcosmic menu bar backed +by the private shell-control connection. Build and preview it nested with: + +```bash +cargo build --release -p ferese -p ferese-shell +target/release/ferese --backend nested --grant-effects --grant-shell-control -- \ + target/release/ferese-shell +``` + +The Ferese mark enters overview. A workspace selector sits on the left; +status controls and day/time sit on the right. Bar geometry, spacing, colors +and material style are configurable and reload live. + +The compositor decodes wallpaper in parallel with startup. Small shell surfaces prefer the lightweight +software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU +rendering for shell content. Use release builds when evaluating startup and +animation performance; debug image processing is considerably slower. + +Resize presentation configures the destination once and coordinates the +matching client commits with the workspace motion (300 ms maximum wait). +Application content stays at native pixel size; animated bounds crop/reveal +it rather than stretching text. A resize-only previous-frame snapshot fades +out over 80 ms, with a 64 MiB total snapshot budget and automatic release. +All monitors share one elapsed-time animation clock, and window content, +clips, borders and shadows use the same physical-pixel edges. + +Window borders and focus rings use solid colors by default. Optional linear +gradients can be configured independently: + +```toml +[theme.focus_ring.gradient] +from = "#e5c890" +to = "#b98d58" +angle = 135.0 + +# Optional unfocused-window gradient: +[theme.border.gradient] +from = "#3e3e43" +to = "#242429" +angle = 90.0 +``` + +Angles are clockwise in window coordinates: 0 is left-to-right and 90 is +top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either +gradient section to restore its solid `theme.colors.accent` or `border` color. +Gradients share the existing rounded-border shader and add no animation or +extra render pass. Configuration changes reload live. + +Overview has a horizontally scrollable workspace strip with live window +thumbnails and an accent outline on the workspace shown by each output. +Click a workspace to change the window grid without leaving overview; click +a window to focus it and exit, or press Escape to dismiss. Only the workspace +strip has a tinted surface; the window grid sits directly over the wallpaper. +Strip labels use `[theme.typography].font_family` and rasterize at output scale. +The strip fades independently and preserves its opacity on rapid reversals, +including when the selected workspace is empty. + +Ferese renders `[theme.background]` wallpapers directly: `path` selects the +image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded +image and one texture per GPU context serve every output; resizing requires +no image reload or full-screen shell buffers. The launched shell receives +`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces. +When no valid image path is configured, the shell's existing fallback remains. + +Keyboard and direct-session touchpad settings reload live: + +```toml +[input] +focus_follows_mouse = false +xkb_layout = "us" +xkb_variant = "" +xkb_options = [] +repeat_rate = 25 +repeat_delay_ms = 600 + +[input.touchpad] +tap = true +natural_scroll = true +disable_while_typing = true +``` + +Touchpad settings apply to libinput devices in the direct DRM session. In the +nested backend, the host compositor remains responsible for physical touchpad +configuration. + +The direct DRM backend selects the first output profile whose listed monitors +are all connected. Match either the connector name reported by `get-outputs` +or its persistent EDID-derived identity: + +```toml +[[output_profiles]] +name = "docked" + +[[output_profiles.outputs]] +match = "HDMI-A-1" +mode = "3840x2160@119.998" +scale = 1.6 +position = [0, 0] + +[[output_profiles.outputs]] +match = "eDP-1" +enabled = false + +[[output_profiles]] +name = "laptop" + +[[output_profiles.outputs]] +match = "eDP-1" +mode = "2880x1800@120" +scale = 1.6 +position = [0, 0] +``` + +Unspecified connected outputs remain enabled with their preferred mode, scale +`1.0`, normal transform, and automatic horizontal placement. Supported +transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, +and their rotated flipped variants. Output configuration is applied at startup, +on hotplug, and live reload, including modes, scale, transform, and position. + +On DRM, closing the laptop lid disables internal panels when a usable external +output is available. With no usable external output, the panel stays enabled; +Ferese does not change the system's suspend policy. Workspaces are evacuated +to a remaining output without merging their windows, and return to their +original output when the lid reopens or a disconnected monitor returns. +Normal use of an evacuated workspace does not cancel its automatic return. + +Commands are argv arrays and are started directly, without a shell. The +default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: + +```toml +[commands] +terminal = ["foot", "--app-id", "terminal"] + +[[bindings]] +keys = "Super+Enter" +action = "spawn" +argument = "terminal" +``` + +A configured binding replaces the built-in binding with the same normalized +chord and match mode. Disable a default explicitly with no action: + +```toml +[[bindings]] +keys = "Super+Q" +disabled = true +``` + +Bindings follow the active layout by default. To bind a keyboard position +instead, set `match = "physical"` and use an XKB physical key name such as +`AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing +command references stop startup with an error. + +Initial window rules match `app_id`, exact title, and/or transient status. All +matching rules are applied in declaration order; later rules override only the +fields they specify. Width or height implies floating placement when `floating` +is omitted: + +With `floating = true` and no explicit dimensions, the application chooses its +initial size and Ferese centers that size on its workspace's output. Small +dialogs are not forced into a workspace-sized container. + +```toml +[[window_rules]] +app_id = "org.example.Editor" +workspace = 3 +floating = true +width = 900.0 +height = 600.0 +fullscreen = false +``` + +Rules are finalized on the first toplevel commit, before its buffer is +presented, and are not re-applied when an application later changes its title. + +`feresectl` controls the running compositor through the same-user socket at +`$XDG_RUNTIME_DIR/ferese/control.sock`. For example: + +```bash +cargo run -p feresectl -- focus left +cargo run -p feresectl -- workspace 2 +cargo run -p feresectl -- get-workspaces +cargo run -p feresectl -- get-outputs +``` + +The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, +`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, +`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and +`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The +runtime directory and socket are restricted to modes `0700` and `0600`, and the +server rejects peers whose effective UID differs from the compositor's. IPC has +a bounded request queue and accepts at most 64 concurrent client connections. +`get-outputs` includes disabled-but-connected monitors, connector and persistent +identity matchers, current and available modes with refresh rates, fractional +scale, transform, logical position and size, and physical dimensions. + +Text-input-v3 is available to applications by default. Input-method-v2 is +hidden unless the session explicitly opts in, because an input method can +request privileged keyboard access. Enable it only when launching a trusted +IME for the session: + +```bash +FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese +``` + +`Ctrl+Alt+Escape` always releases an active client shortcut inhibitor. + +Keyboard-shortcut inhibition is hidden unless a trusted VM or remote-desktop +session explicitly enables it: + +```bash +FERESE_ENABLE_SHORTCUT_INHIBIT=1 cargo run -p ferese +``` + +The emergency `Ctrl+Alt+Escape` binding remains available while inhibition is +active. + +Screen capture is hidden by default. A session that has already made an +explicit capture-policy decision can expose screencopy-v1 to its clients with: + +```bash +FERESE_ENABLE_SCREENCOPY=1 cargo run -p ferese +``` + +The current M5 implementation supports one-shot output and region capture into +ARGB8888 shared-memory buffers. Cursor overlays are included only when the +capture client requests them. + +### Direct DRM session + +Run the hardware backend from a spare TTY, outside another graphical session: + +```bash +RUST_LOG=ferese=debug cargo run -p ferese -- --backend=drm -- foot +``` + +Do not run that command from a terminal inside the active desktop: the direct +backend requests control of the seat and primary DRM device. Use +Ctrl+Alt+F1–F12 to switch virtual terminals. + +M4 validation requires the direct session to: + +- start `foot` and render the same animated tiling workload as the nested path; +- retain keyboard and pointer input; +- pause when switching away and redraw after switching back; and +- log page-flip timing and any missed presentation deadlines on the reference + integrated GPU. + +The direct path creates an independent render pipeline per connected output and +rescans connectors on hotplug. Physical unplug/replug acceptance testing on the +reference hardware is still pending. diff --git a/docs/native-soak-testing.md b/docs/native-soak-testing.md index 799892d..0ebd43c 100644 --- a/docs/native-soak-testing.md +++ b/docs/native-soak-testing.md @@ -2,8 +2,9 @@ The M6 soak runner repeatedly creates and destroys native Wayland clients while exercising focus, resize, column width, fullscreen, floating, and workspace -paths. Every tenth iteration also terminates a client abruptly and opens three -isolated connections that send malformed Wayland messages. The runner requires +paths. Every tenth iteration also terminates a client abruptly. If a local +malformed-client probe is available, it opens three isolated connections that +send malformed Wayland messages. The runner requires Ferese to disconnect each malformed client and then checks that the compositor and authenticated IPC socket remain responsive. It also records compositor RSS growth. @@ -32,11 +33,15 @@ FERESE_SOAK_SECONDS=300 \ ./scripts/soak-native.sh ``` -The malformed-client check can also be run independently against a disposable -Ferese session: +Development probes are local-only, not distributed in the Cargo workspace. +Supply an existing executable with `FERESE_MALFORMED_CLIENT` to enable protocol +checks. An explicitly configured missing probe is an error; without a probe, +the runner reports that protocol checks are skipped. + +The local probe can also be run independently against a disposable Ferese session: ```bash -WAYLAND_DISPLAY=wayland-1 cargo run -p ferese-malformed-client +WAYLAND_DISPLAY=wayland-1 /path/to/ferese-malformed-client target/debug/feresectl get-outputs ``` diff --git a/packaging/config.toml b/packaging/config.toml index 718f952..406aeb2 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -23,3 +23,7 @@ text_muted = "#AAB4C7" [[window_rules]] app_id = "dev.ferese.Settings" floating = true + +[[window_rules]] +title = "Choose a wallpaper" +floating = true diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 0fa9cb9..3037df1 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -2,6 +2,12 @@ # Install already-built binaries. Run from any directory with sudo. set -euo pipefail +session_entry_matches() { + # App-icon metadata can change between managed releases. All other fields, + # especially Exec/TryExec, must still match before replacing an entry. + cmp -s -- <(sed '/^Icon=/d' "$1") <(sed '/^Icon=/d' "$2") +} + repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) release_id=${1:?usage: install-session.sh RELEASE_ID} [[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2 @@ -29,7 +35,7 @@ for link in current previous; do fi done if [[ -e $session_entry ]]; then - cmp -s -- "$repo_dir/packaging/ferese.desktop" "$session_entry" || { + session_entry_matches "$repo_dir/packaging/ferese.desktop" "$session_entry" || { echo "Different session entry exists: $session_entry" >&2; exit 1; } fi @@ -40,6 +46,9 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop" desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions +if [[ -e $session_entry ]]; then + install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" +fi for name in ferese ferese-shell ferese-settings feresectl; do install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" done diff --git a/scripts/soak-native.sh b/scripts/soak-native.sh index 1d7375b..0f58c07 100755 --- a/scripts/soak-native.sh +++ b/scripts/soak-native.sh @@ -35,7 +35,11 @@ if [[ ! -x "$feresectl_program" ]]; then fi if [[ ! -x "$malformed_client_program" ]]; then - cargo build -p ferese-malformed-client + if [[ -n "${FERESE_MALFORMED_CLIENT:-}" ]]; then + echo "configured malformed-client probe is not executable: $malformed_client_program" >&2 + exit 2 + fi + echo "Local malformed-client probe unavailable; skipping protocol fuzz checks" >&2 fi if ! command -v "$client_program" >/dev/null 2>&1; then @@ -185,7 +189,9 @@ while ((SECONDS < deadline)); do launch_client wait_for_focus kill -TERM "${client_pids[-1]}" 2>/dev/null || true - "$malformed_client_program" >>"$log_path" 2>&1 + if [[ -x "$malformed_client_program" ]]; then + "$malformed_client_program" >>"$log_path" 2>&1 + fi control get-outputs fi reap_clients diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 737775d..1e8d1a1 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -6,7 +6,7 @@ mod watch; use cosmic::{ ApplicationExt, Element, app::{Core, Settings, Task}, - iced::{Alignment, Length, Size}, + iced::{Alignment, Length}, widget::{self, button, column, container, row, scrollable, slider, text_input, toggler}, }; use schema::{Field, Kind, Page}; @@ -37,8 +37,6 @@ fn main() -> cosmic::iced::Result { let theme = visuals::native_theme(initial.as_ref().ok()); cosmic::app::run::( Settings::default() - .size(Size::new(960., 720.)) - .size_limits(cosmic::iced::Limits::NONE.min_width(820.).min_height(560.)) .theme(theme) .is_daemon(false) .antialiasing(true) @@ -289,29 +287,38 @@ impl cosmic::Application for App { } Message::PickWallpaper => { return cosmic::task::future(async { - use cosmic::dialog::file_chooser::{self, FileFilter}; - let result = file_chooser::open::Dialog::new() - .title("Choose a wallpaper") - .filter( - FileFilter::new("Images") - .glob("*.png") - .glob("*.jpg") - .glob("*.jpeg") - .glob("*.webp"), - ) - .open_file() - .await; - Message::WallpaperPicked(match result { - Ok(response) => response - .url() - .to_file_path() - .map(|p| Some(p.to_string_lossy().into_owned())) - .map_err(|_| "Choose a local image.".into()), - Err(file_chooser::Error::Cancelled) => Ok(None), - Err(error) => Err(format!( - "Image picker unavailable: {error}. You can enter the image path instead." - )), - }) + let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); + // Native GTK file chooser, independent of the desktop portal. + // Waiting for user input must never block the UI executor. + std::thread::spawn(move || { + let result = std::process::Command::new("zenity") + .args([ + "--file-selection", + "--title=Choose a wallpaper", + "--file-filter=Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP", + "--file-filter=All files | *", + ]) + .output() + .map_err(|error| format!("Could not open the file chooser: {error}. Install zenity or enter an image path.")) + .and_then(|output| { + if output.status.code() == Some(1) { + return Ok(None); // User cancelled. + } + if !output.status.success() { + return Err(format!("File chooser failed: {}", String::from_utf8_lossy(&output.stderr).trim())); + } + let path = String::from_utf8(output.stdout) + .map_err(|_| "The image path is not valid UTF-8.".to_owned())?; + let path = path.trim_end_matches(['\r', '\n']); + Ok((!path.is_empty()).then(|| path.to_owned())) + }); + let _ = send.send(result); + }); + Message::WallpaperPicked( + receive + .await + .unwrap_or_else(|_| Err("File chooser worker stopped.".into())), + ) }); } Message::WallpaperPicked(Ok(Some(path))) => { @@ -661,9 +668,17 @@ impl cosmic::Application for App { scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill), ); let footer = row([]) - .spacing(10) + .spacing(4) .align_y(Alignment::Center) - .push( + .push(widget::tooltip( + visuals::action_icon( + if self.saving { + "M12 3v9l5 3 M21 12a9 9 0 1 1-9-9" + } else { + "M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0" + }, + palette.muted, + ), self.label( if self.saving { "Saving…" @@ -671,24 +686,53 @@ impl cosmic::Application for App { &self.status }, 11., - ) - .class(cosmic::theme::Text::Color(palette.muted)) - .width(Length::Fill), - ) - .push( - button::text("Undo") - .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), - ) - .push(button::text("Reload").on_press_maybe((!self.saving).then_some(Message::Reload))); + ), + widget::tooltip::Position::Top, + )) + .push(widget::Space::new().width(Length::Fill)) + .push(widget::tooltip( + button::custom(visuals::action_icon( + "M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7", + if self.undo.is_some() && !self.saving { + palette.text + } else { + palette.muted + }, + )) + .padding(4) + .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), + self.label("Undo last change", 11.), + widget::tooltip::Position::Top, + )) + .push(widget::tooltip( + button::custom(visuals::action_icon( + "M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6", + palette.text, + )) + .padding(4) + .on_press_maybe((!self.saving).then_some(Message::Reload)), + self.label("Reload configuration", 11.), + widget::tooltip::Position::Top, + )); row([]) .push(sidebar) .push( container( - container(content.push(footer)) - .padding(20) - .max_width(840) - .width(Length::Fill) - .height(Length::Fill), + container( + column([]) + .push(content.height(Length::Fill)) + .push(footer) + .spacing(6), + ) + .padding(cosmic::iced::Padding { + top: 20., + right: 20., + bottom: 8., + left: 20., + }) + .max_width(840) + .width(Length::Fill) + .height(Length::Fill), ) .width(Length::Fill) .center_x(Length::Fill) diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 477d54a..a4c1ebf 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -174,6 +174,12 @@ pub fn brand_icon() -> svg_icon::Icon { .icon() .size(32) } +pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon { + svg_icon::from_svg_bytes(format!(r##""##, hex(tint)).into_bytes()) + .symbolic(false) + .icon() + .size(16) +} const PRESETS: [(&str, &str, &str, &str, &str); 3] = [ ("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"), ("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"), diff --git a/tools/ferese-effects-probe/Cargo.toml b/tools/ferese-effects-probe/Cargo.toml deleted file mode 100644 index 322b7c9..0000000 --- a/tools/ferese-effects-probe/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "ferese-effects-probe" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] -ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } -tempfile.workspace = true -wayland-client.workspace = true -wayland-protocols-wlr.workspace = true diff --git a/tools/ferese-effects-probe/src/main.rs b/tools/ferese-effects-probe/src/main.rs deleted file mode 100644 index 4dde1ae..0000000 --- a/tools/ferese-effects-probe/src/main.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::error::Error; -use std::io::{Seek, SeekFrom, Write}; -use std::os::fd::AsFd; -use std::time::Duration; - -use ferese_protocols::effects::v1::client::{ - ferese_effects_manager_v1::FereseEffectsManagerV1, - ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, -}; -use wayland_client::{ - Connection, Dispatch, Proxy, QueueHandle, delegate_noop, - protocol::{ - wl_buffer, wl_callback, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface, - }, -}; -use wayland_protocols_wlr::layer_shell::v1::client::{ - zwlr_layer_shell_v1::{self, ZwlrLayerShellV1}, - zwlr_layer_surface_v1::{self, ZwlrLayerSurfaceV1}, -}; - -const WIDTH: u32 = 640; -const HEIGHT: u32 = 64; - -fn main() -> Result<(), Box> { - let arguments = std::env::args_os().collect::>(); - let expect_hidden = arguments - .iter() - .any(|argument| argument == "--expect-hidden"); - let preview = arguments.iter().any(|argument| argument == "--preview"); - let callbacks_only = arguments - .iter() - .any(|argument| argument == "--frame-callbacks"); - let connection = Connection::connect_to_env()?; - let mut queue = connection.new_event_queue(); - let qh = queue.handle(); - connection.display().get_registry(&qh, ()); - let mut state = ProbeState { - preview, - callbacks_only, - ..ProbeState::default() - }; - - if expect_hidden { - queue.roundtrip(&mut state)?; - if state.effects_manager.is_some() { - return Err("public client could see the private effects global".into()); - } - println!("PASS public client cannot see ferese_effects_manager_v1"); - return Ok(()); - } - - while (!callbacks_only && state.effects.is_none()) || !state.configured { - queue.blocking_dispatch(&mut state)?; - state.try_initialize(&qh)?; - if state.closed { - return Err("layer surface was closed before its first configure".into()); - } - } - - if callbacks_only { - // Request frames without attaching a new buffer or damaging content. - // A compositor which ties callbacks to damage deadlocks this sequence. - for expected in 1..=8 { - state.surface.as_ref().unwrap().frame(&qh, ()); - state.surface.as_ref().unwrap().commit(); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while state.frame_count < expected { - queue.roundtrip(&mut state)?; - if std::time::Instant::now() >= deadline { - return Err("frame callback stalled on an unchanged layer buffer".into()); - } - std::thread::sleep(Duration::from_millis(2)); - } - } - println!("PASS eight frame callbacks delivered for an unchanged layer buffer"); - return Ok(()); - } - - let effects = state.effects.clone().expect("effects initialized"); - let initial_configures = state.configure_count; - for role in [ - ferese_surface_effects_v1::Role::PanelElevated, - ferese_surface_effects_v1::Role::Popover, - ferese_surface_effects_v1::Role::Menu, - ferese_surface_effects_v1::Role::Notification, - ferese_surface_effects_v1::Role::Hud, - ferese_surface_effects_v1::Role::Modal, - ferese_surface_effects_v1::Role::Panel, - ] { - effects.set_role(role); - queue.roundtrip(&mut state)?; - // Exercise each role in an actual presented frame, including changes - // to the material geometry and shadow bounds. - std::thread::sleep(Duration::from_millis(50)); - if state.configure_count != initial_configures { - return Err(format!("semantic role {role:?} changed layer geometry").into()); - } - } - - if preview { - if std::env::args().any(|arg| arg == "--regions") { - if effects.version() < 2 { - return Err("rounded material regions require effects v2".into()); - } - let regions = [[20_i32, 20, 200, 180, 14], [240, 20, 200, 180, 14]]; - effects.set_regions( - regions - .iter() - .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) - .collect(), - ); - effects.set_role(ferese_surface_effects_v1::Role::Popover); - } else { - effects.set_role(ferese_surface_effects_v1::Role::Panel); - } - queue.roundtrip(&mut state)?; - println!("PREVIEW material is mapped over the patterned card; press Ctrl+C to stop"); - - while !state.closed { - queue.blocking_dispatch(&mut state)?; - } - - return Ok(()); - } - - // Keep the final solid role mapped long enough for the compositor to run - // at least one presentation. This turns shader and capture failures into - // probe failures instead of disconnecting before the first rendered frame. - std::thread::sleep(Duration::from_millis(100)); - - effects.clear_role(); - queue.roundtrip(&mut state)?; - if state.configure_count != initial_configures { - return Err("clearing the semantic role changed layer geometry".into()); - } - - println!( - "PASS surface.panel and all semantic roles preserved {}x{} layer geometry", - state.width, state.height - ); - Ok(()) -} - -#[derive(Default)] -struct ProbeState { - compositor: Option, - shm: Option, - layer_shell: Option, - effects_manager: Option, - surface: Option, - layer_surface: Option, - effects: Option, - buffer: Option, - preview_background_surface: Option, - preview_background_layer: Option, - preview_background_buffer: Option, - preview: bool, - callbacks_only: bool, - frame_count: u32, - configured: bool, - closed: bool, - configure_count: u32, - width: u32, - height: u32, -} - -impl ProbeState { - fn try_initialize(&mut self, qh: &QueueHandle) -> Result<(), Box> { - if self.surface.is_some() { - return Ok(()); - } - let (Some(compositor), Some(_shm), Some(layer_shell)) = ( - self.compositor.as_ref(), - self.shm.as_ref(), - self.layer_shell.as_ref(), - ) else { - return Ok(()); - }; - if !self.callbacks_only && self.effects_manager.is_none() { - return Ok(()); - } - - let surface = compositor.create_surface(qh, ()); - let layer = if self.preview { - zwlr_layer_shell_v1::Layer::Overlay - } else { - zwlr_layer_shell_v1::Layer::Top - }; - let layer_surface = layer_shell.get_layer_surface( - &surface, - None, - layer, - "ferese-effects-probe".to_owned(), - qh, - (), - ); - if self.preview { - layer_surface.set_anchor( - zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Right, - ); - layer_surface.set_margin(32, 32, 0, 0); - layer_surface.set_size(480, 220); - layer_surface.set_exclusive_zone(0); - } else { - layer_surface.set_anchor( - zwlr_layer_surface_v1::Anchor::Top - | zwlr_layer_surface_v1::Anchor::Left - | zwlr_layer_surface_v1::Anchor::Right, - ); - layer_surface.set_size(WIDTH, HEIGHT); - layer_surface.set_exclusive_zone(HEIGHT as i32); - } - - let effects = (!self.callbacks_only).then(|| { - let effects = - self.effects_manager - .as_ref() - .unwrap() - .get_surface_effects(&surface, qh, ()); - effects.set_role(ferese_surface_effects_v1::Role::Panel); - effects - }); - - surface.commit(); - - if self.preview { - let background_surface = compositor.create_surface(qh, ()); - let background_layer = layer_shell.get_layer_surface( - &background_surface, - None, - zwlr_layer_shell_v1::Layer::Top, - "ferese-effects-preview-background".to_owned(), - qh, - (), - ); - background_layer.set_anchor( - zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Right, - ); - background_layer.set_margin(12, 12, 0, 0); - background_layer.set_size(520, 260); - background_layer.set_exclusive_zone(0); - background_surface.commit(); - - self.preview_background_surface = Some(background_surface); - self.preview_background_layer = Some(background_layer); - } - - self.surface = Some(surface); - self.layer_surface = Some(layer_surface); - self.effects = effects; - Ok(()) - } -} - -fn create_buffer( - shm: &wl_shm::WlShm, - qh: &QueueHandle, - width: u32, - height: u32, - preview: bool, -) -> Result> { - let stride = width * 4; - let size = stride * height; - let mut file = tempfile::tempfile()?; - file.set_len(u64::from(size))?; - let pixel = if preview { - [0_u8; 4] - } else { - [0x28, 0x24, 0x20, 0xd8] - }; - for _ in 0..width * height { - file.write_all(&pixel)?; - } - file.seek(SeekFrom::Start(0))?; - - let pool = shm.create_pool(file.as_fd(), size as i32, qh, ()); - let buffer = pool.create_buffer( - 0, - width as i32, - height as i32, - stride as i32, - wl_shm::Format::Argb8888, - qh, - (), - ); - pool.destroy(); - Ok(buffer) -} - -fn create_preview_background_buffer( - shm: &wl_shm::WlShm, - qh: &QueueHandle, - width: u32, - height: u32, -) -> Result> { - let stride = width * 4; - let size = stride * height; - let mut file = tempfile::tempfile()?; - file.set_len(u64::from(size))?; - - for y in 0..height { - for x in 0..width { - let checker = (x / 11 + y / 11) % 2 == 0; - let stripe = (x / 53) % 3; - let pixel = match (checker, stripe) { - (true, 0) => [0xf0, 0xf0, 0xf0, 0xff], - (false, 0) => [0x08, 0x08, 0x08, 0xff], - (true, 1) => [0x30, 0xe8, 0x40, 0xff], - (false, 1) => [0x10, 0x18, 0x08, 0xff], - (true, _) => [0x30, 0x60, 0xf0, 0xff], - (false, _) => [0x18, 0x08, 0x20, 0xff], - }; - file.write_all(&pixel)?; - } - } - file.seek(SeekFrom::Start(0))?; - - let pool = shm.create_pool(file.as_fd(), size as i32, qh, ()); - let buffer = pool.create_buffer( - 0, - width as i32, - height as i32, - stride as i32, - wl_shm::Format::Argb8888, - qh, - (), - ); - pool.destroy(); - Ok(buffer) -} - -impl Dispatch for ProbeState { - fn event( - state: &mut Self, - registry: &wl_registry::WlRegistry, - event: wl_registry::Event, - _data: &(), - _connection: &Connection, - qh: &QueueHandle, - ) { - let wl_registry::Event::Global { - name, - interface, - version, - } = event - else { - return; - }; - - match interface.as_str() { - "wl_compositor" => state.compositor = Some(registry.bind(name, version.min(6), qh, ())), - "wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())), - "zwlr_layer_shell_v1" => { - state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())) - } - "ferese_effects_manager_v1" => { - state.effects_manager = Some(registry.bind(name, version.min(2), qh, ())) - } - _ => {} - } - } -} - -impl Dispatch for ProbeState { - fn event( - state: &mut Self, - layer_surface: &ZwlrLayerSurfaceV1, - event: zwlr_layer_surface_v1::Event, - _data: &(), - _connection: &Connection, - qh: &QueueHandle, - ) { - match event { - zwlr_layer_surface_v1::Event::Configure { - serial, - width, - height, - } => { - layer_surface.ack_configure(serial); - - if state.preview_background_layer.as_ref() == Some(layer_surface) { - let width = if width == 0 { 520 } else { width }; - let height = if height == 0 { 260 } else { height }; - let surface = state - .preview_background_surface - .clone() - .expect("preview background surface initialized"); - let shm = state.shm.as_ref().expect("shm initialized"); - let buffer = create_preview_background_buffer(shm, qh, width, height) - .expect("create preview background buffer"); - surface.attach(Some(&buffer), 0, 0); - surface.damage_buffer(0, 0, width as i32, height as i32); - surface.commit(); - state.preview_background_buffer = Some(buffer); - return; - } - - state.configure_count = state.configure_count.saturating_add(1); - state.width = if width == 0 { WIDTH } else { width }; - state.height = if height == 0 { HEIGHT } else { height }; - if !state.configured { - let surface = state.surface.clone().expect("surface initialized"); - let shm = state.shm.as_ref().expect("shm initialized"); - let buffer = create_buffer(shm, qh, state.width, state.height, state.preview) - .expect("create configured shared-memory buffer"); - surface.attach(Some(&buffer), 0, 0); - surface.damage_buffer(0, 0, state.width as i32, state.height as i32); - surface.commit(); - state.buffer = Some(buffer); - state.configured = true; - } - } - zwlr_layer_surface_v1::Event::Closed => state.closed = true, - _ => {} - } - } -} - -impl Dispatch for ProbeState { - fn event( - state: &mut Self, - _: &wl_callback::WlCallback, - event: wl_callback::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - if matches!(event, wl_callback::Event::Done { .. }) { - state.frame_count += 1; - } - } -} - -delegate_noop!(ProbeState: ignore wl_compositor::WlCompositor); -delegate_noop!(ProbeState: ignore wl_shm::WlShm); -delegate_noop!(ProbeState: ignore wl_shm_pool::WlShmPool); -delegate_noop!(ProbeState: ignore wl_buffer::WlBuffer); -delegate_noop!(ProbeState: ignore wl_surface::WlSurface); -delegate_noop!(ProbeState: ignore ZwlrLayerShellV1); -delegate_noop!(ProbeState: ignore FereseEffectsManagerV1); -delegate_noop!(ProbeState: ignore FereseSurfaceEffectsV1); diff --git a/tools/ferese-lock-probe/Cargo.toml b/tools/ferese-lock-probe/Cargo.toml deleted file mode 100644 index 075e66b..0000000 --- a/tools/ferese-lock-probe/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "ferese-lock-probe" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] -wayland-client.workspace = true -wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } diff --git a/tools/ferese-lock-probe/src/main.rs b/tools/ferese-lock-probe/src/main.rs deleted file mode 100644 index c4fdc2a..0000000 --- a/tools/ferese-lock-probe/src/main.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Deliberately has no password authentication. Nested test sessions only. -use std::{error::Error, time::Duration}; -use wayland_client::{Connection, Dispatch, QueueHandle, protocol::wl_registry}; -use wayland_protocols::ext::session_lock::v1::client::{ - ext_session_lock_manager_v1::ExtSessionLockManagerV1, - ext_session_lock_v1::{self, ExtSessionLockV1}, -}; - -#[derive(Default)] -struct Probe { - manager: Option, - locked: bool, - rejected: bool, -} -impl Dispatch for Probe { - fn event( - state: &mut Self, - registry: &wl_registry::WlRegistry, - event: wl_registry::Event, - _: &(), - _: &Connection, - qh: &QueueHandle, - ) { - if let wl_registry::Event::Global { - name, interface, .. - } = event - { - if interface == "ext_session_lock_manager_v1" { - state.manager = Some(registry.bind(name, 1, qh, ())); - } - } - } -} -wayland_client::delegate_noop!(Probe: ignore ExtSessionLockManagerV1); -impl Dispatch for Probe { - fn event( - state: &mut Self, - _: &ExtSessionLockV1, - event: ext_session_lock_v1::Event, - competing: &bool, - _: &Connection, - _: &QueueHandle, - ) { - match event { - ext_session_lock_v1::Event::Locked if !competing => state.locked = true, - ext_session_lock_v1::Event::Finished if *competing => state.rejected = true, - ext_session_lock_v1::Event::Finished => panic!("lock rejected"), - _ => {} - } - } -} - -fn main() -> Result<(), Box> { - if std::env::var_os("FERESE_LOCK_TEST").is_none() { - return Err( - "set FERESE_LOCK_TEST=1; this probe is only for disposable nested sessions".into(), - ); - } - let mode = std::env::args().nth(1).unwrap_or_else(|| "unlock".into()); - let connection = Connection::connect_to_env()?; - let mut queue = connection.new_event_queue(); - let qh = queue.handle(); - connection.display().get_registry(&qh, ()); - let mut state = Probe::default(); - queue.roundtrip(&mut state)?; - let manager = state - .manager - .clone() - .ok_or("session lock protocol unavailable")?; - let lock = manager.lock(&qh, false); - if mode == "invalid-unlock" { - lock.unlock_and_destroy(); - assert!( - queue.roundtrip(&mut state).is_err(), - "premature unlock accepted" - ); - println!("PASS premature unlock disconnected"); - return Ok(()); - } - while !state.locked { - queue.blocking_dispatch(&mut state)?; - } - println!("PASS locked event received after safe frame"); - match mode.as_str() { - "crash" => return Ok(()), - "hold" => std::thread::sleep(Duration::from_secs(10)), - "competing" | "attack" => { - let other = manager.lock(&qh, true); - queue.roundtrip(&mut state)?; - assert!(state.rejected, "competing lock not rejected"); - if mode == "attack" { - other.unlock_and_destroy(); - assert!( - queue.roundtrip(&mut state).is_err(), - "rejected lock unlocked owner" - ); - println!("PASS rejected owner cannot unlock"); - return Ok(()); - } - other.destroy(); - } - "unlock" => {} - _ => return Err("use unlock, hold, crash, competing, attack, or invalid-unlock".into()), - } - lock.unlock_and_destroy(); - queue.roundtrip(&mut state)?; - println!("PASS owner unlocked"); - Ok(()) -} diff --git a/tools/ferese-malformed-client/Cargo.toml b/tools/ferese-malformed-client/Cargo.toml deleted file mode 100644 index ee23b85..0000000 --- a/tools/ferese-malformed-client/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "ferese-malformed-client" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] diff --git a/tools/ferese-malformed-client/src/main.rs b/tools/ferese-malformed-client/src/main.rs deleted file mode 100644 index 0c4ab47..0000000 --- a/tools/ferese-malformed-client/src/main.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::env; -use std::error::Error; -use std::io::{self, Read, Write}; -use std::net::Shutdown; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::time::Duration; - -const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2); -const UNKNOWN_OBJECT_FRAME: [u8; 8] = frame_header(u32::MAX, 0, 8); -const UNDERSIZED_FRAME: [u8; 8] = frame_header(1, 0, 4); -const UNALIGNED_FRAME: [u8; 9] = { - let header = frame_header(1, 0, 9); - [ - header[0], header[1], header[2], header[3], header[4], header[5], header[6], header[7], 0, - ] -}; - -struct MalformedCase { - name: &'static str, - bytes: &'static [u8], -} - -fn main() -> Result<(), Box> { - let socket = display_socket()?; - let cases = malformed_cases(); - - for case in cases { - exercise_case(&socket, &case)?; - println!("PASS {}", case.name); - } - - Ok(()) -} - -fn display_socket() -> Result { - let display = env::var_os("WAYLAND_DISPLAY") - .filter(|value| !value.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "WAYLAND_DISPLAY is not set"))?; - let display = PathBuf::from(display); - if display.is_absolute() { - return Ok(display); - } - - env::var_os("XDG_RUNTIME_DIR") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .map(|directory| directory.join(display)) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) -} - -fn malformed_cases() -> [MalformedCase; 3] { - [ - MalformedCase { - name: "unknown-object", - bytes: &UNKNOWN_OBJECT_FRAME, - }, - MalformedCase { - name: "undersized-frame", - bytes: &UNDERSIZED_FRAME, - }, - MalformedCase { - name: "unaligned-frame", - bytes: &UNALIGNED_FRAME, - }, - ] -} - -const fn frame_header(object: u32, opcode: u16, size: u16) -> [u8; 8] { - let object = object.to_ne_bytes(); - let size_and_opcode = ((size as u32) << 16 | opcode as u32).to_ne_bytes(); - [ - object[0], - object[1], - object[2], - object[3], - size_and_opcode[0], - size_and_opcode[1], - size_and_opcode[2], - size_and_opcode[3], - ] -} - -fn exercise_case(socket: &PathBuf, case: &MalformedCase) -> Result<(), Box> { - let mut stream = UnixStream::connect(socket)?; - stream.set_read_timeout(Some(DISCONNECT_TIMEOUT))?; - stream.write_all(case.bytes)?; - stream.shutdown(Shutdown::Write)?; - - let mut response = [0_u8; 256]; - loop { - match stream.read(&mut response) { - Ok(0) => return Ok(()), - Ok(_) => continue, - Err(error) - if matches!( - error.kind(), - io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe - ) => - { - return Ok(()); - } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut - ) => - { - return Err(format!( - "compositor did not disconnect malformed case {:?} within {:?}", - case.name, DISCONNECT_TIMEOUT - ) - .into()); - } - Err(error) => return Err(error.into()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_native_wayland_headers() { - let header = UNKNOWN_OBJECT_FRAME; - - assert_eq!( - u32::from_ne_bytes(header[..4].try_into().unwrap()), - u32::MAX - ); - assert_eq!(u32::from_ne_bytes(header[4..].try_into().unwrap()), 8 << 16); - } - - #[test] - fn malformed_sizes_match_the_transmitted_byte_counts() { - let undersized = UNDERSIZED_FRAME; - let unaligned = UNALIGNED_FRAME; - - assert_eq!( - u32::from_ne_bytes(undersized[4..].try_into().unwrap()) >> 16, - 4 - ); - assert_eq!( - u32::from_ne_bytes(unaligned[4..8].try_into().unwrap()) >> 16, - 9 - ); - assert_eq!(unaligned.len(), 9); - } -} diff --git a/tools/ferese-shell-control-probe/Cargo.toml b/tools/ferese-shell-control-probe/Cargo.toml deleted file mode 100644 index 5f4df81..0000000 --- a/tools/ferese-shell-control-probe/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "ferese-shell-control-probe" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] -ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } -wayland-client.workspace = true diff --git a/tools/ferese-shell-control-probe/src/main.rs b/tools/ferese-shell-control-probe/src/main.rs deleted file mode 100644 index 4aed044..0000000 --- a/tools/ferese-shell-control-probe/src/main.rs +++ /dev/null @@ -1,187 +0,0 @@ -use std::{ - error::Error, - os::{ - fd::{FromRawFd, RawFd}, - unix::net::UnixStream, - }, -}; - -use ferese_protocols::shell::v1::client::{ - ferese_shell_manager_v1::FereseShellManagerV1, - ferese_shell_v1::{self, FereseShellV1}, -}; -use wayland_client::{ - Connection, Dispatch, QueueHandle, WEnum, delegate_noop, protocol::wl_registry, -}; - -fn main() -> Result<(), Box> { - let expect_hidden = std::env::args_os().any(|argument| argument == "--expect-hidden"); - let connection = control_connection()?; - let mut queue = connection.new_event_queue(); - let qh = queue.handle(); - connection.display().get_registry(&qh, ()); - let mut state = ProbeState::default(); - - queue.roundtrip(&mut state)?; - if expect_hidden { - if state.manager.is_some() { - return Err("public client could see ferese_shell_manager_v1".into()); - } - - println!("PASS public client cannot see ferese_shell_manager_v1"); - return Ok(()); - } - - let manager = state - .manager - .clone() - .ok_or("trusted client could not see ferese_shell_manager_v1")?; - state.shell = Some(manager.get_shell(&qh, ())); - - while !state.initialized() { - queue.blocking_dispatch(&mut state)?; - } - - let shell = state.shell.clone().expect("shell initialized"); - shell.enter_overview(); - while state.overview_active != Some(true) { - queue.blocking_dispatch(&mut state)?; - } - - shell.exit_overview(); - while state.overview_active != Some(false) || state.overview_events < 3 { - queue.blocking_dispatch(&mut state)?; - } - - shell.activate_window(u32::MAX, u32::MAX); - while !state.invalid_window_rejected { - queue.blocking_dispatch(&mut state)?; - } - - println!( - "PASS shell-control snapshot={} outputs={} workspaces={} windows={} overview round-trip and stale-id rejection", - state.snapshot_serial.unwrap_or_default(), - state.output_count, - state.workspace_count, - state.window_count - ); - Ok(()) -} - -fn control_connection() -> Result> { - let Some(raw_fd) = std::env::var_os("FERESE_SHELL_CONTROL_SOCKET") else { - return Ok(Connection::connect_to_env()?); - }; - let fd = raw_fd - .to_str() - .ok_or("FERESE_SHELL_CONTROL_SOCKET is not valid UTF-8")? - .parse::()?; - // SAFETY: Ferese passes ownership of this inherited descriptor to the - // launched shell process. This function consumes it exactly once. - let socket = unsafe { UnixStream::from_raw_fd(fd) }; - - Ok(Connection::from_socket(socket)?) -} - -#[derive(Default)] -struct ProbeState { - manager: Option, - shell: Option, - capabilities: Option, - snapshot_serial: Option, - snapshot_complete: bool, - window_count: usize, - output_count: usize, - workspace_count: usize, - overview_active: Option, - overview_events: usize, - invalid_window_rejected: bool, -} - -impl ProbeState { - fn initialized(&self) -> bool { - self.capabilities == Some(1) - && self.snapshot_complete - && self.output_count > 0 - && self.workspace_count > 0 - && self.overview_active == Some(false) - } -} - -impl Dispatch for ProbeState { - fn event( - state: &mut Self, - registry: &wl_registry::WlRegistry, - event: wl_registry::Event, - _data: &(), - _connection: &Connection, - qh: &QueueHandle, - ) { - let wl_registry::Event::Global { - name, - interface, - version, - } = event - else { - return; - }; - - if interface == "ferese_shell_manager_v1" { - state.manager = Some(registry.bind(name, version.min(1), qh, ())); - } - } -} - -impl Dispatch for ProbeState { - fn event( - state: &mut Self, - _shell: &FereseShellV1, - event: ferese_shell_v1::Event, - _data: &(), - _connection: &Connection, - _qh: &QueueHandle, - ) { - match event { - ferese_shell_v1::Event::Capabilities { version } => { - state.capabilities = Some(version); - } - ferese_shell_v1::Event::SnapshotBegin { serial } => { - state.snapshot_serial = Some(serial); - state.snapshot_complete = false; - state.window_count = 0; - state.output_count = 0; - state.workspace_count = 0; - } - ferese_shell_v1::Event::Window { .. } => { - state.window_count += 1; - } - ferese_shell_v1::Event::Output { .. } => { - state.output_count += 1; - } - ferese_shell_v1::Event::Workspace { .. } => { - state.workspace_count += 1; - } - ferese_shell_v1::Event::SnapshotEnd { serial } => { - state.snapshot_complete = state.snapshot_serial == Some(serial); - } - ferese_shell_v1::Event::OverviewState { active } => { - state.overview_active = Some(active != 0); - state.overview_events += 1; - } - ferese_shell_v1::Event::RequestFailed { - request, - object_hi, - object_lo, - .. - } => { - state.invalid_window_rejected = request - == WEnum::Value(ferese_shell_v1::FailedRequest::ActivateWindow) - && object_hi == u32::MAX - && object_lo == u32::MAX; - } - _ => {} - } - } -} - -delegate_noop!(ProbeState: ignore FereseShellManagerV1); diff --git a/tools/ferese-shell-spike/Cargo.toml b/tools/ferese-shell-spike/Cargo.toml deleted file mode 100644 index c16c15e..0000000 --- a/tools/ferese-shell-spike/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "ferese-shell-spike" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version = "1.93" - -[dependencies] -ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } -libcosmic.workspace = true -wayland-client.workspace = true diff --git a/tools/ferese-shell-spike/README.md b/tools/ferese-shell-spike/README.md deleted file mode 100644 index 099156d..0000000 --- a/tools/ferese-shell-spike/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Ferese libcosmic shell spike - -This disposable M8 probe validates libcosmic's Wayland lifecycle before Ferese -uses it for production shell components. It creates one top layer-shell surface -on the active output and reports: - -- output discovery and updates; -- the initial configure and later resizes; -- keyboard focus changes; and -- compositor close or application shutdown. - -Run it inside Ferese with: - -```sh -cargo run -p ferese -- --backend nested -- cargo run -p ferese-shell-spike -``` - -Click the surface to exercise on-demand keyboard focus. Press Escape or click -`Exit spike` to exercise clean shutdown. - -For an unattended shutdown check, set an exit delay in milliseconds: - -```sh -FERESE_SHELL_SPIKE_EXIT_AFTER_MS=1000 \ - FERESE_SHELL_SPIKE_EXCLUSIVE_FOCUS=1 \ - cargo run -p ferese -- --backend nested --grant-effects -- \ - target/debug/ferese-shell-spike -``` - -The spike intentionally uses public layer-shell. The production shell must use -Ferese's private protocols only for control and semantic effects, never as a -replacement surface role. - -The spike can attach the semantic `panel` role to libcosmic's own `wl_surface` -through a supported `wayland-client` connection handle. Run with -`--grant-effects` and `FERESE_SHELL_SPIKE_EXCLUSIVE_FOCUS=1` to make libcosmic -expose the surface deterministically through its public focus event. An -attachment failure is reported without replacing the public layer-shell role. - -Source and runtime testing found that libcosmic does not currently forward its -layer-surface creation event to the application. A non-focusable top bar -therefore cannot attach the private effect before mapping through this API. -Production must add a supported upstream creation hook or use the thin native -surface adapter described in the toolkit ADR. diff --git a/tools/ferese-shell-spike/src/main.rs b/tools/ferese-shell-spike/src/main.rs deleted file mode 100644 index 9432ff0..0000000 --- a/tools/ferese-shell-spike/src/main.rs +++ /dev/null @@ -1,293 +0,0 @@ -use cosmic::Element; -use cosmic::app::{Core, Settings, Task}; -use cosmic::iced::event::{self, PlatformSpecific, wayland}; -use cosmic::iced::keyboard::{self, Key, key::Named}; -use cosmic::iced::platform_specific::{ - runtime::wayland::layer_surface::{IcedOutput, SctkLayerSurfaceSettings}, - shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, -}; -use cosmic::iced::{Event, Length, Subscription, window}; -use cosmic::widget::{button, column, container, text}; -use ferese_protocols::effects::v1::client::{ - ferese_effects_manager_v1::FereseEffectsManagerV1, - ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, -}; -use std::time::Duration; -use wayland_client::{ - Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, - globals::{GlobalListContents, registry_queue_init}, - protocol::{wl_registry, wl_surface}, -}; - -const APP_ID: &str = "dev.ferese.ShellSpike"; -const BAR_HEIGHT: u32 = 48; - -fn main() -> cosmic::iced::Result { - let settings = Settings::default() - .no_main_window(true) - .client_decorations(false) - .transparent(true) - .is_daemon(true); - - cosmic::app::run::(settings, ()) -} - -struct ShellSpike { - core: Core, - surface_id: window::Id, - configured: bool, - focused: bool, - outputs: usize, - exit_after: Option, - effects: Option, -} - -#[derive(Clone, Debug)] -enum Message { - Event(Event, window::Id), - Exit, -} - -impl cosmic::Application for ShellSpike { - type Executor = cosmic::executor::Default; - type Flags = (); - type Message = Message; - - const APP_ID: &'static str = APP_ID; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - fn init(core: Core, _flags: Self::Flags) -> (Self, Task) { - let surface_id = window::Id::unique(); - let force_focus = std::env::var_os("FERESE_SHELL_SPIKE_EXCLUSIVE_FOCUS").is_some(); - let app = Self { - core, - surface_id, - configured: false, - focused: false, - outputs: 0, - exit_after: std::env::var("FERESE_SHELL_SPIKE_EXIT_AFTER_MS") - .ok() - .and_then(|value| value.parse().ok()) - .map(Duration::from_millis), - effects: None, - }; - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: surface_id, - layer: Layer::Top, - keyboard_interactivity: if force_focus { - KeyboardInteractivity::Exclusive - } else { - KeyboardInteractivity::OnDemand - }, - anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, - output: IcedOutput::Active, - namespace: "ferese-shell-spike".to_owned(), - size: Some((None, Some(BAR_HEIGHT))), - exclusive_zone: BAR_HEIGHT as i32, - ..Default::default() - }, - Some(Box::new(Self::view_layer)), - ); - - println!( - "PASS startup requested active-output layer surface focus={}", - if force_focus { - "exclusive" - } else { - "on-demand" - } - ); - - (app, cosmic::task::message(cosmic::Action::Surface(action))) - } - - fn subscription(&self) -> Subscription { - let events = event::listen_with(|event, _status, id| Some(Message::Event(event, id))); - let mut subscriptions = vec![events]; - if let Some(delay) = self.exit_after { - subscriptions.push(cosmic::iced::time::every(delay).map(|_| Message::Exit)); - } - Subscription::batch(subscriptions) - } - - fn update(&mut self, message: Self::Message) -> Task { - match message { - Message::Event(event, id) => self.handle_event(event, id), - Message::Exit => { - println!("PASS clean shutdown requested"); - cosmic::iced::exit() - } - } - } - - fn view(&self) -> Element<'_, Self::Message> { - text("").into() - } - - fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { - text("").into() - } -} - -impl ShellSpike { - fn handle_event(&mut self, event: Event, id: window::Id) -> Task { - match event { - Event::Window(window::Event::Opened { size, .. }) if id == self.surface_id => { - self.configured = true; - println!( - "PASS configure surface={} size={}x{}", - id, size.width, size.height - ); - } - Event::Window(window::Event::Resized(size)) if id == self.surface_id => { - println!( - "PASS reconfigure surface={} size={}x{}", - id, size.width, size.height - ); - } - Event::Window(window::Event::Focused) if id == self.surface_id => { - self.focused = true; - println!("PASS keyboard focus acquired surface={id}"); - } - Event::Window(window::Event::Unfocused) if id == self.surface_id => { - self.focused = false; - println!("PASS keyboard focus released surface={id}"); - } - Event::Window(window::Event::Closed) if id == self.surface_id => { - println!("PASS compositor closed layer surface={id}"); - return cosmic::iced::exit(); - } - Event::Keyboard(keyboard::Event::KeyPressed { - key: Key::Named(Named::Escape), - .. - }) if id == self.surface_id => return cosmic::task::message(Message::Exit), - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( - output_event, - output, - ))) => match output_event { - wayland::OutputEvent::Created(info) => { - self.outputs += 1; - println!( - "PASS output discovered output={output:?} info={info:?} count={}", - self.outputs, - ); - } - wayland::OutputEvent::InfoUpdate(info) => { - println!("PASS output updated output={output:?} info={info:?}"); - } - wayland::OutputEvent::Removed => { - self.outputs = self.outputs.saturating_sub(1); - println!( - "PASS output removed output={output:?} count={}", - self.outputs, - ); - } - }, - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( - _, - surface, - frame_id, - ))) if frame_id == self.surface_id => self.attach_effects(&surface), - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( - layer_event, - surface, - layer_id, - ))) if layer_id == self.surface_id => { - self.attach_effects(&surface); - println!( - "PASS layer event={layer_event:?} surface={surface:?} configured={} focused={}", - self.configured, self.focused - ); - } - _ => {} - } - - Task::none() - } - - fn attach_effects(&mut self, surface: &wl_surface::WlSurface) { - if self.effects.is_some() { - return; - } - - match EffectsBinding::attach(surface) { - Ok(binding) => { - self.effects = Some(binding); - println!("PASS ferese-effects-v1 attached role=panel"); - } - Err(error) => eprintln!("FAIL ferese-effects-v1 attachment: {error}"), - } - } - - fn view_layer(&self) -> Element<'_, cosmic::Action> { - let status = format!( - "Ferese shell spike | configured: {} | focused: {} | outputs: {}", - self.configured, self.focused, self.outputs - ); - let content = column![ - text(status), - button::standard("Exit spike").on_press(cosmic::Action::App(Message::Exit)) - ] - .spacing(4) - .padding([4, 16]); - - container(content) - .width(Length::Fill) - .height(Length::Fill) - .into() - } -} - -struct EffectsBinding { - _manager: FereseEffectsManagerV1, - _surface: FereseSurfaceEffectsV1, - _queue: EventQueue, -} - -impl EffectsBinding { - fn attach(surface: &wl_surface::WlSurface) -> Result> { - let backend = surface - .backend() - .upgrade() - .ok_or("libcosmic Wayland connection is no longer alive")?; - let connection = Connection::from_backend(backend); - let (globals, queue) = registry_queue_init::(&connection)?; - let qh = queue.handle(); - let manager = globals.bind::(&qh, 1..=1, ())?; - let effects = manager.get_surface_effects(surface, &qh, ()); - effects.set_role(ferese_surface_effects_v1::Role::Panel); - connection.flush()?; - - Ok(Self { - _manager: manager, - _surface: effects, - _queue: queue, - }) - } -} - -struct EffectsState; - -impl Dispatch for EffectsState { - fn event( - _state: &mut Self, - _registry: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _qh: &QueueHandle, - ) { - } -} - -delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); -delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);