Refine Settings and window startup; refresh project documentation
This commit is contained in:
1 parent
23991d5bd8
commit
c63af64d87
24 files changed
+696
-1850
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
/target/
|
/target/
|
||||||
|
/tools/
|
||||||
*.log
|
*.log
|
||||||
ChatGPT Image*.png
|
ChatGPT Image*.png
|
||||||
Generated
-39
@@ -1604,16 +1604,6 @@ dependencies = [
|
|||||||
"ferese-layout",
|
"ferese-layout",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ferese-effects-probe"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"ferese-protocols",
|
|
||||||
"tempfile",
|
|
||||||
"wayland-client",
|
|
||||||
"wayland-protocols-wlr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferese-ipc"
|
name = "ferese-ipc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1626,18 +1616,6 @@ dependencies = [
|
|||||||
name = "ferese-layout"
|
name = "ferese-layout"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "ferese-protocols"
|
name = "ferese-protocols"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1677,23 +1655,6 @@ dependencies = [
|
|||||||
"wayland-client",
|
"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]]
|
[[package]]
|
||||||
name = "feresectl"
|
name = "feresectl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ members = [
|
|||||||
"crates/ferese-protocols",
|
"crates/ferese-protocols",
|
||||||
"shell/ferese-shell",
|
"shell/ferese-shell",
|
||||||
"shell/ferese-settings",
|
"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"
|
resolver = "3"
|
||||||
|
|
||||||
|
|||||||
@@ -1,511 +1,123 @@
|
|||||||
# Ferese
|
<p align="center">
|
||||||
|
<img src="packaging/icons/ferese.svg" width="88" height="88" alt="Ferese logo">
|
||||||
|
</p>
|
||||||
|
|
||||||
Ferese is a Wayland scrolling compositor written in Rust with
|
<h1 align="center">Ferese</h1>
|
||||||
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.
|
|
||||||
|
|
||||||
For a versioned installation alongside Plasma in SDDM, see
|
<p align="center">An animation-first scrolling Wayland desktop.</p>
|
||||||
[installation and recovery](docs/installation.md). `Super+Shift+E` or
|
|
||||||
`feresectl exit` logs out immediately; save your work first.
|
|
||||||
|
|
||||||
## Live configuration
|
<p align="center">
|
||||||
|
<a href="docs/installation.md">Install</a> ·
|
||||||
|
<a href="docs/configuration.md">Configure</a> ·
|
||||||
|
<a href="#try-it">Try it</a> ·
|
||||||
|
<a href="#development">Develop</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
`ferese-settings` opens the native settings application, also available from
|
Ferese combines a scrolling window manager with its own menu bar, control center
|
||||||
Control Center → Settings after installation. It includes appearance presets,
|
and native Settings app. Built in Rust on Smithay, it brings configurable motion
|
||||||
wallpaper selection, menu bar geometry, window behavior, motion, keyboard/mouse,
|
and a cohesive desktop interface to a keyboard-driven workflow.
|
||||||
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.
|
|
||||||
|
|
||||||
The app validates changes with `ferese --check-config PATH` before atomically
|
## A desktop that moves with you
|
||||||
replacing your file, preserves comments and unrelated keys, and keeps a
|
|
||||||
`config.toml.settings-backup`. Undo restores the previous save; external edits
|
- **Scrolling columns:** a horizontal window strip with configurable widths and
|
||||||
require Reload before saving. Search filters settings categories. To preview
|
focus behavior. Tree tiling is available per workspace.
|
||||||
against a disposable config without changing your desktop config:
|
- **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
|
```sh
|
||||||
cargo build -p ferese -p ferese-settings --locked
|
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl
|
||||||
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
|
|
||||||
target/release/ferese --backend nested --grant-effects --grant-shell-control -- \
|
target/release/ferese --backend nested --grant-effects --grant-shell-control -- \
|
||||||
target/release/ferese-shell
|
target/release/ferese-shell
|
||||||
```
|
```
|
||||||
|
|
||||||
The Ferese mark enters overview, where workspace indicators become available.
|
Use a recent Rust toolchain (1.93 or newer for the shell), Smithay's native
|
||||||
The active workspace's window list appears on the left, with click-to-focus
|
build dependencies, and `foot` for the default terminal. Settings wallpaper
|
||||||
and a highlighted active window; status controls and day/time sit on the
|
browsing uses `zenity`; entering an image path works without it.
|
||||||
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.
|
|
||||||
|
|
||||||
The compositor decodes wallpaper in parallel with startup. Small shell surfaces prefer the lightweight
|
For a login-screen session alongside your existing desktop:
|
||||||
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
|
```sh
|
||||||
matching client commits with the workspace motion (300 ms maximum wait).
|
./scripts/install.sh
|
||||||
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
|
The installer preserves your configuration and keeps previous releases for
|
||||||
top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either
|
rollback. Log out and select **Ferese** at the login screen. An upgrade does
|
||||||
gradient section to restore its solid `theme.colors.accent` or `border` color.
|
not replace the running compositor: log out and back in to activate it.
|
||||||
Gradients share the existing rounded-border shader and add no animation or
|
See [installation and recovery](docs/installation.md) for prerequisites,
|
||||||
extra render pass. Configuration changes reload live.
|
session logs and rollback.
|
||||||
|
|
||||||
Overview has a horizontally scrollable workspace strip with live window
|
## Get around
|
||||||
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
|
These are built-in defaults; your configuration can override them.
|
||||||
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:
|
| 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
|
```toml
|
||||||
[input]
|
[scrolling]
|
||||||
focus_follows_mouse = false
|
default_column_width = 0.5
|
||||||
xkb_layout = "us"
|
focus_strategy = "paged"
|
||||||
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 = "[email protected]"
|
|
||||||
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]]
|
[[window_rules]]
|
||||||
app_id = "org.example.Editor"
|
app_id = "dev.ferese.Settings"
|
||||||
workspace = 3
|
|
||||||
floating = true
|
floating = true
|
||||||
width = 900.0
|
|
||||||
height = 600.0
|
|
||||||
fullscreen = false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules are finalized on the first toplevel commit, before its buffer is
|
Saving the file reloads it automatically. Run `feresectl reload-config`
|
||||||
presented, and are not re-applied when an application later changes its title.
|
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
|
## Development
|
||||||
`$XDG_RUNTIME_DIR/ferese/control.sock`. For example:
|
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
cargo run -p feresectl -- focus left
|
cargo test --workspace --locked
|
||||||
cargo run -p feresectl -- workspace 2
|
cargo fmt --all --check
|
||||||
cargo run -p feresectl -- get-workspaces
|
|
||||||
cargo run -p feresectl -- get-outputs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`,
|
Use release builds when evaluating animation and startup performance.
|
||||||
`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`,
|
`RUST_LOG=ferese=debug` enables detailed logs;
|
||||||
`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and
|
`FERESE_TRACE_PERFORMANCE=1` enables per-output performance summaries.
|
||||||
`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The
|
Development probes under `tools/` are local-only and are not part of the
|
||||||
runtime directory and socket are restricted to modes `0700` and `0600`, and the
|
published Cargo workspace.
|
||||||
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
|
- [Architecture and window-manager specification](docs/ferese-window-manager-spec.md)
|
||||||
hidden unless the session explicitly opts in, because an input method can
|
- [Shell design](docs/ferese-shell-design.md)
|
||||||
request privileged keyboard access. Enable it only when launching a trusted
|
- [Theme and UI specification](docs/ferese-theme-ui-spec.md)
|
||||||
IME for the session:
|
- [Animation validation](docs/animation-validation.md)
|
||||||
|
- [Nested resize testing](docs/nested-resize-testing.md)
|
||||||
|
- [Native-client soak testing](docs/native-soak-testing.md)
|
||||||
|
|
||||||
```bash
|
Test hardware behavior on your own setup, especially suspend/resume and locking.
|
||||||
FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese
|
Keep a working desktop session available while testing changes.
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
+31
-1
@@ -835,6 +835,9 @@ impl Ferese {
|
|||||||
self.space.elements().rev().cloned().collect::<Vec<_>>()
|
self.space.elements().rev().cloned().collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
candidates.iter().find_map(|window| {
|
candidates.iter().find_map(|window| {
|
||||||
|
if !self.window_content_ready(window) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let id = self.window_ids.get(window)?;
|
let id = self.window_ids.get(window)?;
|
||||||
if self.workspaces.workspace_for_window(*id) != Some(workspace) {
|
if self.workspaces.workspace_for_window(*id) != Some(workspace) {
|
||||||
return None;
|
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(
|
pub(crate) fn add_rule_placed_window(
|
||||||
&mut self,
|
&mut self,
|
||||||
window: Window,
|
window: Window,
|
||||||
@@ -2453,7 +2465,10 @@ impl Ferese {
|
|||||||
self.viewport_coupled_widths.remove(&id);
|
self.viewport_coupled_widths.remove(&id);
|
||||||
self.space.map_element(window.clone(), location, false);
|
self.space.map_element(window.clone(), location, false);
|
||||||
self.sync_window_stacking();
|
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) {
|
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<ClientSize> {
|
fn client_size(window: &Window) -> Option<ClientSize> {
|
||||||
|
// 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;
|
let size = window.geometry().size;
|
||||||
|
|
||||||
(size.w > 0 && size.h > 0).then_some(ClientSize {
|
(size.w > 0 && size.h > 0).then_some(ClientSize {
|
||||||
@@ -2732,6 +2752,16 @@ fn client_size(window: &Window) -> Option<ClientSize> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Default)]
|
||||||
pub struct ClientState {
|
pub struct ClientState {
|
||||||
pub compositor_state: CompositorClientState,
|
pub compositor_state: CompositorClientState,
|
||||||
|
|||||||
@@ -1108,6 +1108,11 @@ fn output_elements(
|
|||||||
let windows = candidates
|
let windows = candidates
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|window| {
|
.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)?;
|
let id = *state.window_ids.get(window)?;
|
||||||
// Scrolling columns may sit outside their monitor's rectangle.
|
// Scrolling columns may sit outside their monitor's rectangle.
|
||||||
// They must not reappear on a neighboring output just because
|
// They must not reappear on a neighboring output just because
|
||||||
|
|||||||
@@ -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 = "[email protected]"
|
||||||
|
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.
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
The M6 soak runner repeatedly creates and destroys native Wayland clients while
|
The M6 soak runner repeatedly creates and destroys native Wayland clients while
|
||||||
exercising focus, resize, column width, fullscreen, floating, and workspace
|
exercising focus, resize, column width, fullscreen, floating, and workspace
|
||||||
paths. Every tenth iteration also terminates a client abruptly and opens three
|
paths. Every tenth iteration also terminates a client abruptly. If a local
|
||||||
isolated connections that send malformed Wayland messages. The runner requires
|
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
|
Ferese to disconnect each malformed client and then checks that the compositor
|
||||||
and authenticated IPC socket remain responsive. It also records compositor RSS
|
and authenticated IPC socket remain responsive. It also records compositor RSS
|
||||||
growth.
|
growth.
|
||||||
@@ -32,11 +33,15 @@ FERESE_SOAK_SECONDS=300 \
|
|||||||
./scripts/soak-native.sh
|
./scripts/soak-native.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The malformed-client check can also be run independently against a disposable
|
Development probes are local-only, not distributed in the Cargo workspace.
|
||||||
Ferese session:
|
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
|
```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
|
target/debug/feresectl get-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ text_muted = "#AAB4C7"
|
|||||||
[[window_rules]]
|
[[window_rules]]
|
||||||
app_id = "dev.ferese.Settings"
|
app_id = "dev.ferese.Settings"
|
||||||
floating = true
|
floating = true
|
||||||
|
|
||||||
|
[[window_rules]]
|
||||||
|
title = "Choose a wallpaper"
|
||||||
|
floating = true
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
# Install already-built binaries. Run from any directory with sudo.
|
# Install already-built binaries. Run from any directory with sudo.
|
||||||
set -euo pipefail
|
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)
|
repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
release_id=${1:?usage: install-session.sh RELEASE_ID}
|
release_id=${1:?usage: install-session.sh RELEASE_ID}
|
||||||
[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2
|
[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2
|
||||||
@@ -29,7 +35,7 @@ for link in current previous; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [[ -e $session_entry ]]; then
|
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;
|
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
@@ -40,6 +46,9 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
|||||||
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
||||||
|
|
||||||
install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
|
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
|
for name in ferese ferese-shell ferese-settings feresectl; do
|
||||||
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ if [[ ! -x "$feresectl_program" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -x "$malformed_client_program" ]]; then
|
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
|
fi
|
||||||
|
|
||||||
if ! command -v "$client_program" >/dev/null 2>&1; then
|
if ! command -v "$client_program" >/dev/null 2>&1; then
|
||||||
@@ -185,7 +189,9 @@ while ((SECONDS < deadline)); do
|
|||||||
launch_client
|
launch_client
|
||||||
wait_for_focus
|
wait_for_focus
|
||||||
kill -TERM "${client_pids[-1]}" 2>/dev/null || true
|
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
|
control get-outputs
|
||||||
fi
|
fi
|
||||||
reap_clients
|
reap_clients
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod watch;
|
|||||||
use cosmic::{
|
use cosmic::{
|
||||||
ApplicationExt, Element,
|
ApplicationExt, Element,
|
||||||
app::{Core, Settings, Task},
|
app::{Core, Settings, Task},
|
||||||
iced::{Alignment, Length, Size},
|
iced::{Alignment, Length},
|
||||||
widget::{self, button, column, container, row, scrollable, slider, text_input, toggler},
|
widget::{self, button, column, container, row, scrollable, slider, text_input, toggler},
|
||||||
};
|
};
|
||||||
use schema::{Field, Kind, Page};
|
use schema::{Field, Kind, Page};
|
||||||
@@ -37,8 +37,6 @@ fn main() -> cosmic::iced::Result {
|
|||||||
let theme = visuals::native_theme(initial.as_ref().ok());
|
let theme = visuals::native_theme(initial.as_ref().ok());
|
||||||
cosmic::app::run::<App>(
|
cosmic::app::run::<App>(
|
||||||
Settings::default()
|
Settings::default()
|
||||||
.size(Size::new(960., 720.))
|
|
||||||
.size_limits(cosmic::iced::Limits::NONE.min_width(820.).min_height(560.))
|
|
||||||
.theme(theme)
|
.theme(theme)
|
||||||
.is_daemon(false)
|
.is_daemon(false)
|
||||||
.antialiasing(true)
|
.antialiasing(true)
|
||||||
@@ -289,29 +287,38 @@ impl cosmic::Application for App {
|
|||||||
}
|
}
|
||||||
Message::PickWallpaper => {
|
Message::PickWallpaper => {
|
||||||
return cosmic::task::future(async {
|
return cosmic::task::future(async {
|
||||||
use cosmic::dialog::file_chooser::{self, FileFilter};
|
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
||||||
let result = file_chooser::open::Dialog::new()
|
// Native GTK file chooser, independent of the desktop portal.
|
||||||
.title("Choose a wallpaper")
|
// Waiting for user input must never block the UI executor.
|
||||||
.filter(
|
std::thread::spawn(move || {
|
||||||
FileFilter::new("Images")
|
let result = std::process::Command::new("zenity")
|
||||||
.glob("*.png")
|
.args([
|
||||||
.glob("*.jpg")
|
"--file-selection",
|
||||||
.glob("*.jpeg")
|
"--title=Choose a wallpaper",
|
||||||
.glob("*.webp"),
|
"--file-filter=Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||||
)
|
"--file-filter=All files | *",
|
||||||
.open_file()
|
])
|
||||||
.await;
|
.output()
|
||||||
Message::WallpaperPicked(match result {
|
.map_err(|error| format!("Could not open the file chooser: {error}. Install zenity or enter an image path."))
|
||||||
Ok(response) => response
|
.and_then(|output| {
|
||||||
.url()
|
if output.status.code() == Some(1) {
|
||||||
.to_file_path()
|
return Ok(None); // User cancelled.
|
||||||
.map(|p| Some(p.to_string_lossy().into_owned()))
|
}
|
||||||
.map_err(|_| "Choose a local image.".into()),
|
if !output.status.success() {
|
||||||
Err(file_chooser::Error::Cancelled) => Ok(None),
|
return Err(format!("File chooser failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
|
||||||
Err(error) => Err(format!(
|
}
|
||||||
"Image picker unavailable: {error}. You can enter the image path instead."
|
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))) => {
|
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),
|
scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill),
|
||||||
);
|
);
|
||||||
let footer = row([])
|
let footer = row([])
|
||||||
.spacing(10)
|
.spacing(4)
|
||||||
.align_y(Alignment::Center)
|
.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(
|
self.label(
|
||||||
if self.saving {
|
if self.saving {
|
||||||
"Saving…"
|
"Saving…"
|
||||||
@@ -671,24 +686,53 @@ impl cosmic::Application for App {
|
|||||||
&self.status
|
&self.status
|
||||||
},
|
},
|
||||||
11.,
|
11.,
|
||||||
)
|
),
|
||||||
.class(cosmic::theme::Text::Color(palette.muted))
|
widget::tooltip::Position::Top,
|
||||||
.width(Length::Fill),
|
))
|
||||||
)
|
.push(widget::Space::new().width(Length::Fill))
|
||||||
.push(
|
.push(widget::tooltip(
|
||||||
button::text("Undo")
|
button::custom(visuals::action_icon(
|
||||||
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
|
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
|
||||||
)
|
if self.undo.is_some() && !self.saving {
|
||||||
.push(button::text("Reload").on_press_maybe((!self.saving).then_some(Message::Reload)));
|
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([])
|
row([])
|
||||||
.push(sidebar)
|
.push(sidebar)
|
||||||
.push(
|
.push(
|
||||||
container(
|
container(
|
||||||
container(content.push(footer))
|
container(
|
||||||
.padding(20)
|
column([])
|
||||||
.max_width(840)
|
.push(content.height(Length::Fill))
|
||||||
.width(Length::Fill)
|
.push(footer)
|
||||||
.height(Length::Fill),
|
.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)
|
.width(Length::Fill)
|
||||||
.center_x(Length::Fill)
|
.center_x(Length::Fill)
|
||||||
|
|||||||
@@ -174,6 +174,12 @@ pub fn brand_icon() -> svg_icon::Icon {
|
|||||||
.icon()
|
.icon()
|
||||||
.size(32)
|
.size(32)
|
||||||
}
|
}
|
||||||
|
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
|
||||||
|
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, hex(tint)).into_bytes())
|
||||||
|
.symbolic(false)
|
||||||
|
.icon()
|
||||||
|
.size(16)
|
||||||
|
}
|
||||||
const PRESETS: [(&str, &str, &str, &str, &str); 3] = [
|
const PRESETS: [(&str, &str, &str, &str, &str); 3] = [
|
||||||
("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"),
|
("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"),
|
||||||
("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"),
|
("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"),
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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<dyn Error>> {
|
|
||||||
let arguments = std::env::args_os().collect::<Vec<_>>();
|
|
||||||
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<wl_compositor::WlCompositor>,
|
|
||||||
shm: Option<wl_shm::WlShm>,
|
|
||||||
layer_shell: Option<ZwlrLayerShellV1>,
|
|
||||||
effects_manager: Option<FereseEffectsManagerV1>,
|
|
||||||
surface: Option<wl_surface::WlSurface>,
|
|
||||||
layer_surface: Option<ZwlrLayerSurfaceV1>,
|
|
||||||
effects: Option<FereseSurfaceEffectsV1>,
|
|
||||||
buffer: Option<wl_buffer::WlBuffer>,
|
|
||||||
preview_background_surface: Option<wl_surface::WlSurface>,
|
|
||||||
preview_background_layer: Option<ZwlrLayerSurfaceV1>,
|
|
||||||
preview_background_buffer: Option<wl_buffer::WlBuffer>,
|
|
||||||
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<Self>) -> Result<(), Box<dyn Error>> {
|
|
||||||
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<ProbeState>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
preview: bool,
|
|
||||||
) -> Result<wl_buffer::WlBuffer, Box<dyn Error>> {
|
|
||||||
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<ProbeState>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Result<wl_buffer::WlBuffer, Box<dyn Error>> {
|
|
||||||
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<wl_registry::WlRegistry, ()> for ProbeState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_data: &(),
|
|
||||||
_connection: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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<ZwlrLayerSurfaceV1, ()> for ProbeState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
layer_surface: &ZwlrLayerSurfaceV1,
|
|
||||||
event: zwlr_layer_surface_v1::Event,
|
|
||||||
_data: &(),
|
|
||||||
_connection: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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<wl_callback::WlCallback, ()> for ProbeState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &wl_callback::WlCallback,
|
|
||||||
event: wl_callback::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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);
|
|
||||||
@@ -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"] }
|
|
||||||
@@ -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<ExtSessionLockManagerV1>,
|
|
||||||
locked: bool,
|
|
||||||
rejected: bool,
|
|
||||||
}
|
|
||||||
impl Dispatch<wl_registry::WlRegistry, ()> for Probe {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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<ExtSessionLockV1, bool> for Probe {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &ExtSessionLockV1,
|
|
||||||
event: ext_session_lock_v1::Event,
|
|
||||||
competing: &bool,
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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<dyn Error>> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ferese-malformed-client"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
@@ -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<dyn Error>> {
|
|
||||||
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<PathBuf, io::Error> {
|
|
||||||
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<dyn Error>> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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<dyn Error>> {
|
|
||||||
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<Connection, Box<dyn Error>> {
|
|
||||||
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::<RawFd>()?;
|
|
||||||
// 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<FereseShellManagerV1>,
|
|
||||||
shell: Option<FereseShellV1>,
|
|
||||||
capabilities: Option<u32>,
|
|
||||||
snapshot_serial: Option<u32>,
|
|
||||||
snapshot_complete: bool,
|
|
||||||
window_count: usize,
|
|
||||||
output_count: usize,
|
|
||||||
workspace_count: usize,
|
|
||||||
overview_active: Option<bool>,
|
|
||||||
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<wl_registry::WlRegistry, ()> for ProbeState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_data: &(),
|
|
||||||
_connection: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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<FereseShellV1, ()> for ProbeState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_shell: &FereseShellV1,
|
|
||||||
event: ferese_shell_v1::Event,
|
|
||||||
_data: &(),
|
|
||||||
_connection: &Connection,
|
|
||||||
_qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
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);
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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::<ShellSpike>(settings, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ShellSpike {
|
|
||||||
core: Core,
|
|
||||||
surface_id: window::Id,
|
|
||||||
configured: bool,
|
|
||||||
focused: bool,
|
|
||||||
outputs: usize,
|
|
||||||
exit_after: Option<Duration>,
|
|
||||||
effects: Option<EffectsBinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<Self::Message>) {
|
|
||||||
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::<Self>(
|
|
||||||
|_| 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<Self::Message> {
|
|
||||||
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<Self::Message> {
|
|
||||||
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<Message> {
|
|
||||||
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<Message>> {
|
|
||||||
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<EffectsState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EffectsBinding {
|
|
||||||
fn attach(surface: &wl_surface::WlSurface) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
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::<EffectsState>(&connection)?;
|
|
||||||
let qh = queue.handle();
|
|
||||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&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<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
|
||||||
fn event(
|
|
||||||
_state: &mut Self,
|
|
||||||
_registry: &wl_registry::WlRegistry,
|
|
||||||
_event: wl_registry::Event,
|
|
||||||
_data: &GlobalListContents,
|
|
||||||
_connection: &Connection,
|
|
||||||
_qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
|
|
||||||
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
|
|
||||||
Reference in new issue
Block a user