diff --git a/Cargo.lock b/Cargo.lock index 1172727..8c77d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1721,6 +1721,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ferese-polkit-agent" +version = "0.1.0" +dependencies = [ + "ferese-config", + "glib", + "libc", + "libcosmic", + "libloading", + "serde", + "serde_json", + "tokio", + "zbus", + "zeroize", +] + [[package]] name = "ferese-protocols" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b52d898..22c255e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "shell/ferese-shell", "shell/ferese-settings", "shell/ferese-lock", + "shell/ferese-polkit-agent", "portal", ] resolver = "3" diff --git a/README.md b/README.md index eaf6c14..0949742 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ cohesive experience—beautiful by default and unmistakably yours. - **Flexible layouts:** scrolling columns, tree tiling, floating windows and a workspace overview. - **A native shell:** menu bar, control center, settings, notifications and - desktop widgets designed to belong together. + desktop widgets designed to belong together, including themed authentication prompts. - **Personal by design:** themes, wallpapers, accent colors, blur, borders and motion that update without restarting your session. - **Ready for real setups:** gestures, multi-monitor support, fractional @@ -84,6 +84,9 @@ These are the built-in defaults; every shortcut and gesture can be changed. | Super + M | Switch scrolling / tree layout | | Super + Shift + S | Select a screenshot area | +Click the bar date for a calendar. Click the battery for power modes and +confirmed Restart, Power off and Suspend actions. + Three-finger swipes move between workspaces and windows. Open **Control Center → Settings → Shortcuts** to change them or assign gestures to other actions. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index f024c5c..9e35c52 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -190,7 +190,7 @@ pub struct ThemeSettings { pub focus_ring_gradient: Option, pub shadow_color: RgbaColor, pub surface_base_color: RgbaColor, - pub panel_opacity: f64, + pub shell_opacity: f64, pub inactive_dim: InactiveDimSettings, pub window_radius: f64, pub shadow_offset_y: f64, @@ -253,8 +253,6 @@ struct ThemeConfig { #[serde(default)] background: crate::wallpaper::WallpaperConfig, #[serde(default)] - surface: ThemeSurfaceConfig, - #[serde(default)] colors: ThemeColorsConfig, #[serde(default)] geometry: ThemeGeometryConfig, @@ -331,19 +329,10 @@ struct OverviewTypographyConfig { font_family: Option, } -#[derive(Debug, Default, Deserialize)] -struct ThemeSurfaceConfig { - #[serde(default)] - bar: ThemeBarConfig, -} - -#[derive(Debug, Default, Deserialize)] -struct ThemeBarConfig { - opacity: Option, -} - #[derive(Debug, Deserialize)] struct ThemeMaterialConfig { + #[serde(default = "default_shell_opacity")] + opacity: f64, #[serde(default)] style: MaterialStyle, #[serde(default = "default_backdrop_blur")] @@ -354,11 +343,16 @@ impl Default for ThemeMaterialConfig { fn default() -> Self { Self { style: MaterialStyle::Solid, + opacity: default_shell_opacity(), blur_radius: default_backdrop_blur(), } } } +fn default_shell_opacity() -> f64 { + ferese_config::DEFAULT_MATERIAL_OPACITY +} + fn default_backdrop_blur() -> f64 { 12.0 } @@ -1095,10 +1089,7 @@ impl Config { &self.theme.colors.surface_base, "colors.surface_base", )?, - panel_opacity: unit_theme_value( - self.theme.surface.bar.opacity.unwrap_or(0.78), - "surface.bar.opacity", - )?, + shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?, inactive_dim: InactiveDimSettings { enabled: self.appearance.inactive_dim.enabled, amount: unit_theme_value( @@ -2034,7 +2025,7 @@ mod tests { focus_ring_gradient: None, shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), - panel_opacity: 0.78, + shell_opacity: 0.78, inactive_dim: InactiveDimSettings { enabled: false, amount: 0.15, @@ -2125,26 +2116,22 @@ mod tests { } #[test] - fn bar_opacity_uses_config_and_rejects_invalid_values() { - assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78); + fn shell_opacity_uses_config_and_rejects_invalid_values() { + assert_eq!(parse("").theme_settings().unwrap().shell_opacity, 0.78); for opacity in [0.0, 0.65, 1.0] { assert_eq!( - parse(&format!( - "theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}" - )) - .theme_settings() - .unwrap() - .panel_opacity, + parse(&format!("theme {{ material {{ opacity {opacity}; }} }}")) + .theme_settings() + .unwrap() + .shell_opacity, opacity ); } for opacity in ["-0.1", "1.1", "#nan", "#inf"] { assert!( - Config::parse_source(&format!( - "theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}" - )) - .and_then(|config| config.theme_settings()) - .is_err() + Config::parse_source(&format!("theme {{ material {{ opacity {opacity}; }} }}")) + .and_then(|config| config.theme_settings()) + .is_err() ); } } diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index 3075b1d..5860fb6 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -37,7 +37,11 @@ pub(crate) struct ResolvedMaterial { pub shadow: [f64; 3], } -pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial { +pub(crate) fn resolve_material( + role: SemanticRole, + style: MaterialStyle, + opacity: f32, +) -> ResolvedMaterial { let shadow = match role { SemanticRole::Panel => [1.0, 5.0, 0.04], SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => { @@ -46,11 +50,9 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> Reso SemanticRole::Hud => [1.0, 4.0, 0.04], SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09], }; - let opacity = match (style, role) { - (MaterialStyle::Solid, _) => 1.0, - (_, SemanticRole::Panel) => 0.78, - (_, SemanticRole::Hud | SemanticRole::Modal) => 0.88, - _ => 0.84, + let opacity = match style { + MaterialStyle::Solid => 1.0, + MaterialStyle::Translucent => opacity.clamp(0.0, 1.0), }; ResolvedMaterial { style, @@ -430,10 +432,10 @@ mod tests { SemanticRole::Notification, SemanticRole::Modal, ] { - let solid = resolve_material(role, MaterialStyle::Solid); - let translucent = resolve_material(role, MaterialStyle::Translucent); + let solid = resolve_material(role, MaterialStyle::Solid, 0.6); + let translucent = resolve_material(role, MaterialStyle::Translucent, 0.6); assert_eq!(solid.opacity, 1.0); - assert!(translucent.opacity < 1.0); + assert_eq!(translucent.opacity, 0.6); assert_eq!(solid.shadow, translucent.shadow); } } diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index 262786e..7cadaa5 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -71,6 +71,9 @@ pub fn resolve( ) -> WindowRuleResult { let app_id = app_id.map(normalize_app_id); let mut result = WindowRuleResult::default(); + if app_id.as_deref() == Some("dev.ferese.Authentication") { + result.floating = Some(true); + } for rule in rules { let app_id_matches = rule diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index a92cd17..0020287 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1532,7 +1532,11 @@ fn overview_strip_elements( } } let mut color = state.theme_settings.surface_base_color.0; - color[3] = alpha * state.theme_settings.panel_opacity as f32; + color[3] = alpha * crate::effects::resolve_material( + crate::effects::SemanticRole::Panel, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ).opacity; if let Some(strip) = overview_chrome_element( state, renderer, @@ -2306,13 +2310,12 @@ fn material_element( capture_geometry: Rectangle, ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { let (role, generation) = crate::effects::surface_role(surface)?; - let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style); + let material = crate::effects::resolve_material( + role, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ); let presentation_alpha = crate::effects::surface_opacity(surface); - if role == crate::effects::SemanticRole::Panel - && material.style == crate::config::MaterialStyle::Translucent - { - material.opacity = state.theme_settings.panel_opacity as f32; - } let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); let transform = output.current_transform().invert(); @@ -2330,11 +2333,7 @@ fn material_element( (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), geometry.size, ); - let background_opacity = if role == crate::effects::SemanticRole::Panel { - material.opacity - } else { - 1.0 - }; + let background_opacity = material.opacity; let blur = material_blur_radius( material.style, material.opacity, diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index fcc2a70..9b574b5 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -3,6 +3,8 @@ use serde::de::DeserializeOwned; use serde_json::{Map, Value}; use std::{fmt, path::PathBuf}; +pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78; + #[derive(Debug)] pub struct Error(String); diff --git a/docs/configuration.md b/docs/configuration.md index 9e96f8c..77fff77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,6 +134,7 @@ these six visually distinct. | `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default | | `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox | | `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material | +| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and the sharing dialog. Text/icons stay opaque; 0 and 1 skip blur. Solid mode is always opaque | | `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables | | `theme.colors` key | Default | Meaning | @@ -172,10 +173,8 @@ The target follows [normal-text contrast guidance](https://www.w3.org/WAI/WCAG22 | Section / key | Type | Default | Meaning | | --- | --- | --- | --- | | `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface_base` | -| `theme.surface.bar.opacity` | number 0–1 | omitted | Translucent bar background opacity, including blur; defaults to `0.78`. Text/icons stay opaque; 0 and 1 skip blur. Fallback retains background alpha | | `theme.surface.bar.text_primary` | color | `"#F0F3FA"` | Bar text/icons | | `theme.surface.bar.text_muted` | color | `"#AAB4C7"` | Inactive bar foreground | -| `theme.surface.popover.opacity` | number 0–1 | `0.96` | Shell popover fill alpha | | `theme.shadow.soft.offset_y` | number | `4` | Window shadow vertical offset | | `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness | | `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength | diff --git a/docs/installation.md b/docs/installation.md index 70d6a4a..a55827f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device. Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the native development libraries below. The installer builds Ferese, its shell, -Settings, command-line tools, locker, and ScreenCast portal together. It does not install system +Settings, command-line tools, locker, polkit agent, and ScreenCast portal together. It does not install system packages or enable services for you. ### Fedora @@ -23,7 +23,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \ wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \ mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \ freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \ - clang clang-devel pipewire-devel pipewire wireplumber xdg-desktop-portal xdg-desktop-portal-gtk \ + clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \ + xdg-desktop-portal xdg-desktop-portal-gtk \ gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer ``` @@ -32,7 +33,7 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \ ```sh sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \ libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \ - desktop-file-utils foot pam clang pipewire libpipewire wireplumber \ + desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \ xdg-desktop-portal xdg-desktop-portal-gtk \ gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire ``` @@ -45,7 +46,7 @@ sudo apt install build-essential git curl cmake pkg-config libwayland-dev \ libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \ libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \ dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \ - libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \ + libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber polkitd libpolkit-agent-1-0 \ xdg-desktop-portal xdg-desktop-portal-gtk \ libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \ gstreamer1.0-plugins-good @@ -94,7 +95,7 @@ The installer adds: - Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and `previous` links for upgrades and rollback. - Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`, - `feresectl`, `ferese-lock`, `ferese-screenshot`, + `feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and `xdg-desktop-portal-ferese`, and `ferese-record`. - D-Bus activation and Ferese-specific ScreenCast portal registration. - A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`. @@ -105,6 +106,12 @@ The installer adds: Your existing configuration and other desktop sessions are preserved. The installer does not change your default session or login manager. +Ferese starts its own polkit authentication agent with the shell. Password +prompts follow the configured font, accent, surface color, and corner radius. +Authentication still uses polkit's system helper and policy. The agent starts +only inside a Ferese session; logging into another desktop leaves its agent +unchanged. Restart your Ferese session after upgrading to use the new prompt. + ### Initial configuration Ferese has built-in defaults. To start from the example without replacing an @@ -255,7 +262,7 @@ Installer options: `--dry-run` prints the planned commands. `--offline` requires dependencies to be cached already. `--skip-build` installs the binaries already in `target/release/`; -use it only after building all session components from the intended revision. +use it only after building all six components from the intended revision. Run `./scripts/install.sh --help` for all options. ## Logout and recovery diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index fe46285..b91ecdc 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -29,7 +29,7 @@ opens a display picker. Select a display and press **Share**. Nothing is selecte by default. Applications that request multiple displays can share up to four. The picker uses your Ferese theme colors, font, and shell corner radius. Its background follows `theme.material.style`: opaque in solid mode, or translucent -using `theme.surface.popover.opacity`. Text and icons remain opaque. +using the shared **Appearance → Shell opacity** setting (`theme.material.opacity`). Text and icons remain opaque. Other applications keep a **screen sharing** window with a **Stop sharing** button. Ferese’s built-in recorder uses the top-bar stop control instead, leaving your diff --git a/packaging/config.kdl b/packaging/config.kdl index 3eeda1e..2bb6cfb 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -18,10 +18,12 @@ layout { } theme { + material { + opacity 0.95 + } surface { bar { background "#111821" - opacity 0.95 text-primary "#F4F7FB" text-muted "#8793A2" } diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell index b7c5df6..4013d7e 100755 --- a/packaging/ferese-session-shell +++ b/packaging/ferese-session-shell @@ -9,4 +9,17 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true fi release_dir=$(dirname -- "$(readlink -f -- "$0")") -exec "$release_dir/ferese-shell" +env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ + "$release_dir/ferese-polkit-agent" & +agent_pid=$! +"$release_dir/ferese-shell" & +shell_pid=$! +trap 'kill "$shell_pid" "$agent_pid" 2>/dev/null || true' EXIT INT TERM +if wait "$shell_pid"; then + status=0 +else + status=$? +fi +kill "$agent_pid" 2>/dev/null || true +wait "$agent_pid" 2>/dev/null || true +exit "$status" diff --git a/portal/src/picker.rs b/portal/src/picker.rs index 8b58e65..8ee7846 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -322,9 +322,9 @@ fn appearance() -> ( &string("theme.material.style", "solid"), document .as_ref() - .and_then(|d| d.get("theme.surface.popover.opacity")) + .and_then(|d| d.get("theme.material.opacity")) .and_then(|v| v.as_f64()) - .unwrap_or(0.96), + .unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY), ); let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); let family: &'static str = diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 0570135..f08cee5 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } # Refuse to overwrite unrelated installations. -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do link=/usr/local/bin/$name if [[ -e $link || -L $link ]]; then [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { @@ -39,9 +39,10 @@ if [[ -e $session_entry ]]; then echo "Different session entry exists: $session_entry" >&2; exit 1; } fi -for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } done +"$repo_dir/target/release/ferese-polkit-agent" --check for name in ferese.png ferese.svg; do [[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; } done @@ -79,7 +80,7 @@ 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 ferese-lock xdg-desktop-portal-ferese ferese-record; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" @@ -101,7 +102,7 @@ if [[ -L $install_root/current ]]; then fi ln -s -- "releases/$release_id" "$install_root/current-$release_id" mv -Tf -- "$install_root/current-$release_id" "$install_root/current" -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" done install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" diff --git a/scripts/install.sh b/scripts/install.sh index 9e16995..5e3928d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop" build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked - -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese) + -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese) if $offline; then build+=(--offline); fi if ! $skip_build; then ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' @@ -75,7 +75,7 @@ fi cd -- "$repo_dir" if ! $skip_build; then "${build[@]}"; fi -for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent; do [[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" done "${installer[@]}" diff --git a/shell/ferese-polkit-agent/Cargo.toml b/shell/ferese-polkit-agent/Cargo.toml new file mode 100644 index 0000000..0b11284 --- /dev/null +++ b/shell/ferese-polkit-agent/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ferese-polkit-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version = "1.95" + +[dependencies] +ferese-config = { path = "../../crates/ferese-config" } +glib = "0.21.5" +libc.workspace = true +libcosmic = { workspace = true, features = ["wgpu"] } +libloading = "0.8.9" +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +zbus = "5.19.0" +zeroize = "1.9" diff --git a/shell/ferese-polkit-agent/src/main.rs b/shell/ferese-polkit-agent/src/main.rs new file mode 100644 index 0000000..09158fa --- /dev/null +++ b/shell/ferese-polkit-agent/src/main.rs @@ -0,0 +1,172 @@ +mod prompt; +mod session; + +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; +use zbus::zvariant::{OwnedValue, Value}; + +const AGENT_PATH: &str = "/dev/ferese/PolkitAgent"; +const AUTHORITY: &str = "org.freedesktop.PolicyKit1"; +const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority"; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PromptEvent { + Start { message: String, user: String }, + Request { prompt: String, echo: bool }, + Info { text: String }, + Error { text: String }, + Response { value: String }, + Cancel, +} + +type Identity = (String, HashMap); + +#[derive(Default)] +struct Agent { + active: Mutex>>, +} + +#[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] +impl Agent { + async fn begin_authentication( + &self, + _action_id: String, + message: String, + _icon_name: String, + _details: HashMap, + cookie: String, + identities: Vec, + ) -> zbus::fdo::Result<()> { + let uid = choose_identity(&identities).ok_or_else(|| { + zbus::fdo::Error::Failed("No supported authentication identity".into()) + })?; + let cancelled = Arc::new(AtomicBool::new(false)); + + { + let mut active = self.active.lock().unwrap(); + if active.contains_key(&cookie) { + return Err(zbus::fdo::Error::Failed("Duplicate request".into())); + } + active.insert(cookie.clone(), cancelled.clone()); + } + + let result = tokio::task::spawn_blocking({ + let cookie = cookie.clone(); + move || { + session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled) + } + }) + .await + .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?; + + self.active.lock().unwrap().remove(&cookie); + match result { + Ok(true) => Ok(()), + Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())), + Err(error) => Err(zbus::fdo::Error::Failed(error)), + } + } + + fn cancel_authentication(&self, cookie: String) { + if let Some(cancelled) = self.active.lock().unwrap().get(&cookie) { + cancelled.store(true, Ordering::Release); + } + } +} + +fn choose_identity(identities: &[Identity]) -> Option { + let available: Vec<_> = identities + .iter() + .filter(|(kind, _)| kind == "unix-user") + .filter_map(|(_, details)| { + details + .get("uid") + .and_then(|uid| u32::try_from(uid.clone()).ok()) + .filter(|uid| *uid <= i32::MAX as u32) + }) + .collect(); + let current = unsafe { libc::geteuid() }; + + available + .iter() + .copied() + .find(|uid| *uid == current) + .or_else(|| available.first().copied()) +} + +fn session_subject() -> Result<(String, HashMap>), String> { + let id = std::env::var("XDG_SESSION_ID") + .map_err(|_| "XDG_SESSION_ID is required for polkit registration".to_owned())?; + + if id.is_empty() { + return Err("XDG_SESSION_ID is empty".into()); + } + + let mut details = HashMap::new(); + details.insert("session-id".to_owned(), Value::from(id)); + + Ok(("unix-session".into(), details)) +} + +async fn run_agent() -> Result<(), Box> { + let subject = session_subject()?; + let connection = zbus::connection::Builder::system()? + .serve_at(AGENT_PATH, Agent::default())? + .build() + .await?; + let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY).await?; + let locale = std::env::var("LANG").unwrap_or_default(); + + authority + .call::<_, _, ()>( + "RegisterAuthenticationAgent", + &(subject, locale, AGENT_PATH), + ) + .await?; + std::future::pending::<()>().await; + + Ok(()) +} + +fn main() -> Result<(), Box> { + unsafe { + libc::prctl(libc::PR_SET_DUMPABLE, 0); + } + + match std::env::args().nth(1).as_deref() { + None => tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(run_agent()), + Some("--prompt") => prompt::run(), + Some("--check") => session::check().map_err(Into::into), + _ => Err("Usage: ferese-polkit-agent [--check]".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooses_current_identity_when_available() { + let current = unsafe { libc::geteuid() }; + let identity = |uid: u32| { + ( + "unix-user".to_owned(), + HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]), + ) + }; + assert_eq!( + choose_identity(&[identity(0), identity(current)]), + Some(current) + ); + } +} diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs new file mode 100644 index 0000000..363200e --- /dev/null +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -0,0 +1,489 @@ +use crate::PromptEvent; +use cosmic::{ + Element, + app::{Core, Settings, Task}, + iced::{Alignment, Background, Border, Color, Length, Size, Subscription, window}, + theme, + widget::{self, button, column, container, row, text}, +}; +use std::{ + collections::VecDeque, + io::{BufRead, BufReader, Write}, + sync::{Arc, Mutex}, + time::Duration, +}; +use zeroize::{Zeroize, Zeroizing}; + +#[derive(Clone)] +struct Appearance { + surface: Color, + translucent: bool, + text: Color, + muted: Color, + accent: Color, + radius: f32, + font: cosmic::font::Font, +} + +impl Appearance { + fn load() -> Self { + let document = ferese_config::config_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|source| ferese_config::Document::parse(&source).ok()); + let setting = |key: &str, fallback: &str| { + document + .as_ref() + .and_then(|doc| doc.get(key)) + .and_then(|value| value.as_str()) + .unwrap_or(fallback) + .to_owned() + }; + let color = |key, fallback| parse_color(&setting(key, fallback), fallback); + let family: &'static str = + Box::leak(setting("theme.typography.font_family", "Inter").into_boxed_str()); + let translucent = setting("theme.material.style", "solid") == "translucent"; + let shell_opacity = document + .as_ref() + .and_then(|doc| doc.get("theme.material.opacity")) + .and_then(|value| value.as_f64()) + .unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY) + .clamp(0., 1.) as f32; + let mut surface = color("theme.colors.surface_base", "#111821"); + surface.a = if translucent { shell_opacity } else { 1. }; + + Self { + surface, + translucent, + text: color("theme.colors.text_primary", "#F4F7FB"), + muted: color("theme.colors.text_muted", "#8793A2"), + accent: color("theme.colors.accent", "#3D7BE6"), + radius: document + .as_ref() + .and_then(|doc| doc.get("theme.geometry.shell_radius")) + .and_then(|value| value.as_f64()) + .unwrap_or(14.) + .clamp(8., 36.) as f32, + font: cosmic::font::Font { + family: cosmic::iced::font::Family::Name(family), + ..cosmic::font::default() + }, + } + } + + fn theme(&self) -> cosmic::Theme { + use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba}; + + let rgba = |color: Color| Srgba::new(color.r, color.g, color.b, color.a); + let builder = if self.surface.r + self.surface.g + self.surface.b > 1.5 { + ThemeBuilder::light() + } else { + ThemeBuilder::dark() + }; + + let mut corners = cosmic::cosmic_theme::CornerRadii::default(); + corners.radius_xs = [self.radius.min(4.); 4]; + corners.radius_s = [self.radius.min(8.); 4]; + corners.radius_m = [self.radius; 4]; + corners.radius_l = [self.radius; 4]; + corners.radius_xl = [self.radius; 4]; + + cosmic::Theme::custom(Arc::new( + builder + .corner_radii(corners) + .bg_color(rgba(Color { + a: if self.translucent { 0. } else { 1. }, + ..self.surface + })) + .primary_container_bg(rgba(self.surface)) + .text_tint(rgba(self.text).color) + .accent(rgba(self.accent).color) + .build(), + )) + } +} + +fn parse_color(input: &str, fallback: &str) -> Color { + let parse = |source: &str| { + let value = u32::from_str_radix(source.strip_prefix('#')?, 16).ok()?; + (source.len() == 7) + .then(|| Color::from_rgb8((value >> 16) as u8, (value >> 8) as u8, value as u8)) + }; + parse(input) + .or_else(|| parse(fallback)) + .unwrap_or(Color::BLACK) +} + +#[derive(Clone)] +enum Message { + Tick, + Input(String), + Submit, + Cancel, +} + +impl std::fmt::Debug for Message { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("AuthenticationPromptMessage") + } +} + +struct Prompt { + core: Core, + appearance: Appearance, + incoming: Arc>>, + description: String, + user: String, + question: String, + echo: bool, + answer: Zeroizing, + waiting: bool, + error: Option, + info: Option, + height: f32, +} + +fn content_height(description: &str, status: Option<&str>) -> f32 { + let lines: usize = description + .lines() + .map(|line| line.chars().count().div_ceil(48).max(1)) + .sum(); + let status_height = status.map_or(0, |message| { + 14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3) + }); + (214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32 +} + +fn input_style(palette: &Appearance) -> theme::TextInput { + let text = palette.text; + let muted = palette.muted; + let accent = palette.accent; + let radius = palette.radius.min(10.); + let appearance = move |focused: bool| widget::text_input::Appearance { + background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(), + border_radius: radius.into(), + border_width: 1., + border_offset: None, + border_color: if focused { + accent.scale_alpha(0.82) + } else { + muted.scale_alpha(0.26) + }, + icon_color: Some(muted), + text_color: Some(text), + placeholder_color: muted, + selected_text_color: text, + selected_fill: accent.scale_alpha(0.35), + label_color: text, + }; + theme::TextInput::Custom { + active: Box::new(move |_| appearance(false)), + hovered: Box::new(move |_| appearance(true)), + focused: Box::new(move |_| appearance(true)), + error: Box::new(move |_| appearance(false)), + disabled: Box::new(move |_| appearance(false)), + } +} + +pub fn run() -> Result<(), Box> { + let stdin = std::io::stdin(); + let mut lines = BufReader::new(stdin).lines(); + let Some(Ok(first)) = lines.next() else { + return Err("Missing authentication request".into()); + }; + let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else { + return Err("Invalid authentication request".into()); + }; + let incoming = Arc::new(Mutex::new(VecDeque::new())); + let queue = incoming.clone(); + std::thread::spawn(move || { + for line in lines { + let Ok(line) = line else { break }; + if let Ok(event) = serde_json::from_str(&line) { + queue.lock().unwrap().push_back(event); + } + } + queue.lock().unwrap().push_back(PromptEvent::Cancel); + }); + let appearance = Appearance::load(); + let height = content_height(&message, None); + cosmic::app::run::( + Settings::default() + .size(Size::new(434., height)) + .client_decorations(false) + .transparent(true) + .theme(appearance.theme()) + .default_font(appearance.font) + .default_text_size(14.) + .antialiasing(true), + (incoming, message, user, appearance), + )?; + Ok(()) +} + +fn reply(mut event: PromptEvent) { + if let Ok(mut line) = serde_json::to_string(&event) { + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + line.zeroize(); + } + if let PromptEvent::Response { value } = &mut event { + value.zeroize(); + } +} + +fn focus() -> Task { + widget::text_input::focus(widget::Id::new("auth-response")) +} + +impl Prompt { + fn resize_to_content(&mut self) -> Task { + let height = content_height( + &self.description, + self.error.as_deref().or(self.info.as_deref()), + ); + if (height - self.height).abs() < 1.0 { + return Task::none(); + } + self.height = height; + self.core + .main_window_id() + .map(|id| window::resize(id, Size::new(434., height))) + .unwrap_or_else(Task::none) + } +} + +impl cosmic::Application for Prompt { + type Executor = cosmic::executor::Default; + type Flags = ( + Arc>>, + String, + String, + Appearance, + ); + type Message = Message; + const APP_ID: &'static str = "dev.ferese.Authentication"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init( + mut core: Core, + (incoming, description, user, appearance): Self::Flags, + ) -> (Self, Task) { + core.window.show_headerbar = false; + let height = content_height(&description, None); + let bounds = core + .main_window_id() + .map(|id| window::set_max_size(id, Some(Size::new(434., 360.)))) + .unwrap_or_else(Task::none); + ( + Self { + core, + appearance, + incoming, + description, + user, + question: "Waiting for authentication…".into(), + echo: false, + answer: Zeroizing::new(String::new()), + waiting: true, + error: None, + info: None, + height, + }, + Task::batch([focus(), bounds]), + ) + } + + fn subscription(&self) -> Subscription { + cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick) + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::Tick => { + let events: Vec<_> = self.incoming.lock().unwrap().drain(..).collect(); + let mut refocus = false; + for event in events { + match event { + PromptEvent::Request { prompt, echo } => { + self.answer.zeroize(); + self.question = prompt; + self.echo = echo; + self.waiting = false; + refocus = true; + } + PromptEvent::Info { text } => self.info = Some(text), + PromptEvent::Error { text } => self.error = Some(text), + PromptEvent::Cancel => return cosmic::iced::exit(), + _ => {} + } + } + let resize = self.resize_to_content(); + if refocus { + return Task::batch([resize, focus()]); + } + return resize; + } + Message::Input(mut value) => { + if !self.waiting && value.len() <= 4096 { + self.answer.zeroize(); + *self.answer = std::mem::take(&mut value); + self.error = None; + } + value.zeroize(); + } + Message::Submit if !self.waiting && !self.answer.is_empty() => { + let value = std::mem::take(&mut *self.answer); + reply(PromptEvent::Response { value }); + self.waiting = true; + } + Message::Cancel => { + self.answer.zeroize(); + reply(PromptEvent::Cancel); + return cosmic::iced::exit(); + } + _ => {} + } + Task::none() + } + + fn on_escape(&mut self) -> Task { + self.update(Message::Cancel) + } + + fn view(&self) -> Element<'_, Message> { + let palette = &self.appearance; + let mut input = widget::text_input(&self.question, self.answer.as_str()) + .id(widget::Id::new("auth-response")) + .font(palette.font) + .padding([7, 12]) + .style(input_style(palette)); + if !self.echo { + input = input.password(); + } + if !self.waiting { + input = input.on_input(Message::Input).on_submit(|mut value| { + value.zeroize(); + Message::Submit + }); + } + let action = button::custom(text("Authenticate").font(palette.font).size(13)) + .class(theme::Button::Suggested) + .height(Length::Fixed(36.)) + .padding([8, 16]) + .on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit)); + let lock_svg = std::str::from_utf8(include_bytes!( + "../../ferese-shell/assets/icons/status/lock.svg" + )) + .expect("embedded lock icon is valid UTF-8") + .replace( + "currentColor", + &format!( + "#{:02x}{:02x}{:02x}", + (palette.accent.r * 255.) as u8, + (palette.accent.g * 255.) as u8, + (palette.accent.b * 255.) as u8, + ), + ); + let lock_icon = widget::icon::from_svg_bytes(lock_svg.into_bytes()) + .symbolic(false) + .icon() + .size(20); + let heading = row![ + container(lock_icon) + .width(40) + .height(40) + .center_x(40) + .center_y(40) + .class(theme::Container::custom(move |_| { + widget::container::Style { + background: Some(Background::Color(palette.accent.scale_alpha(0.13))), + border: Border { + radius: 12.0.into(), + ..Default::default() + }, + ..Default::default() + } + })), + column![ + text("Authentication required").font(palette.font).size(18), + text(format!("Confirm as {}", self.user)) + .font(palette.font) + .size(12) + .class(theme::Text::Color(palette.muted)), + ] + .spacing(2), + ] + .spacing(12) + .align_y(Alignment::Center); + let mut body = column![ + heading, + text(&self.description).font(palette.font).size(13), + input + ] + .spacing(14) + .width(Length::Fill); + if let Some(error) = &self.error { + body = body.push( + text(error) + .font(palette.font) + .size(12) + .class(theme::Text::Color(Color::from_rgb8(235, 98, 98))), + ); + } else if let Some(info) = &self.info { + body = body.push( + text(info) + .font(palette.font) + .size(12) + .class(theme::Text::Color(palette.muted)), + ); + } + body = body.push( + row![ + button::custom(text("Cancel").font(palette.font).size(13)) + .class(theme::Button::Text) + .padding([8, 14]) + .on_press(Message::Cancel), + action + ] + .spacing(8) + .align_y(Alignment::Center), + ); + container(body) + .padding(20) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(move |_| { + widget::container::Style { + background: Some(Background::Color(palette.surface)), + text_color: Some(palette.text), + border: Border { + radius: palette.radius.into(), + ..Default::default() + }, + ..Default::default() + } + })) + .into() + } +} + +#[cfg(test)] +mod tests { + use super::content_height; + + #[test] + fn dialog_grows_for_long_messages_and_caps_its_height() { + assert_eq!(content_height("Short request", None), 214.); + assert!(content_height(&"Long message ".repeat(20), None) > 214.); + assert_eq!(content_height(&"Long message ".repeat(100), None), 360.); + assert!(content_height("Short request", Some("Password not accepted")) > 214.); + } +} diff --git a/shell/ferese-polkit-agent/src/session.rs b/shell/ferese-polkit-agent/src/session.rs new file mode 100644 index 0000000..dfceb13 --- /dev/null +++ b/shell/ferese-polkit-agent/src/session.rs @@ -0,0 +1,325 @@ +use crate::PromptEvent; +use glib::gobject_ffi; +use libloading::Library; +use std::{ + ffi::{CStr, CString, c_char, c_int, c_void}, + io::{BufRead, BufReader, BufWriter, Write}, + process::{Child, ChildStdin, Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Sender}, + }, + time::Duration, +}; +use zeroize::Zeroize; + +enum SessionEvent { + Request(String, bool), + Info(String), + Error(String), + Completed(bool), +} + +type Object = *mut c_void; + +struct Api { + _agent: Library, + _polkit: Library, + user_new: unsafe extern "C" fn(c_int) -> Object, + session_new: unsafe extern "C" fn(Object, *const c_char) -> Object, + initiate: unsafe extern "C" fn(Object), + response: unsafe extern "C" fn(Object, *const c_char), + cancel: unsafe extern "C" fn(Object), +} + +impl Api { + fn load() -> Result { + unsafe { + let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?; + let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?; + let user_new = *polkit + .get(b"polkit_unix_user_new\0") + .map_err(|e| e.to_string())?; + let session_new = *agent + .get(b"polkit_agent_session_new\0") + .map_err(|e| e.to_string())?; + let initiate = *agent + .get(b"polkit_agent_session_initiate\0") + .map_err(|e| e.to_string())?; + let response = *agent + .get(b"polkit_agent_session_response\0") + .map_err(|e| e.to_string())?; + let cancel = *agent + .get(b"polkit_agent_session_cancel\0") + .map_err(|e| e.to_string())?; + Ok(Self { + _agent: agent, + _polkit: polkit, + user_new, + session_new, + initiate, + response, + cancel, + }) + } + } +} + +pub fn check() -> Result<(), String> { + Api::load().map(|_| ()) +} + +fn username(uid: u32) -> String { + let output = Command::new("getent") + .args(["passwd", &uid.to_string()]) + .output(); + output + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|entry| entry.split(':').next().map(str::to_owned)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| uid.to_string()) +} + +fn send(writer: &mut BufWriter, event: &PromptEvent) -> Result<(), String> { + serde_json::to_writer(&mut *writer, event).map_err(|e| e.to_string())?; + writer.write_all(b"\n").map_err(|e| e.to_string())?; + writer.flush().map_err(|e| e.to_string()) +} + +fn prompt_process( + message: String, + user: String, +) -> Result<(Child, BufWriter, mpsc::Receiver), String> { + let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?) + .arg("--prompt") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string())?; + let stdin = child.stdin.take().ok_or("Prompt has no input")?; + let stdout = child.stdout.take().ok_or("Prompt has no output")?; + let mut writer = BufWriter::new(stdin); + send(&mut writer, &PromptEvent::Start { message, user })?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(mut line) = line else { break }; + if let Ok(event) = serde_json::from_str(&line) { + line.zeroize(); + if tx.send(event).is_err() { + break; + } + } else { + line.zeroize(); + } + } + }); + Ok((child, writer, rx)) +} + +unsafe extern "C" fn request( + _session: Object, + prompt: *const c_char, + echo: c_int, + data: *mut c_void, +) { + let tx = unsafe { &*(data as *const Sender) }; + let prompt = unsafe { CStr::from_ptr(prompt) } + .to_string_lossy() + .into_owned(); + let _ = tx.send(SessionEvent::Request(prompt, echo != 0)); +} + +unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) { + let tx = unsafe { &*(data as *const Sender) }; + let text = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + let _ = tx.send(SessionEvent::Info(text)); +} + +unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) { + let tx = unsafe { &*(data as *const Sender) }; + let text = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + let _ = tx.send(SessionEvent::Error(text)); +} + +unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_void) { + let tx = unsafe { &*(data as *const Sender) }; + let _ = tx.send(SessionEvent::Completed(success != 0)); +} + +unsafe fn connect( + session: Object, + name: &'static CStr, + callback: gobject_ffi::GCallback, + data: *mut c_void, +) { + unsafe { + gobject_ffi::g_signal_connect_data( + session.cast(), + name.as_ptr(), + callback, + data, + None, + gobject_ffi::G_CONNECT_DEFAULT, + ); + } +} + +fn run_session( + api: &Api, + uid: u32, + cookie: &CString, + context: &glib::MainContext, + writer: &mut BufWriter, + input: &mpsc::Receiver, + cancelled: &AtomicBool, + child: &mut Child, +) -> Result, String> { + let identity = unsafe { (api.user_new)(uid as c_int) }; + if identity.is_null() { + return Err("Could not create polkit identity".into()); + } + let session = unsafe { (api.session_new)(identity, cookie.as_ptr()) }; + unsafe { gobject_ffi::g_object_unref(identity.cast()) }; + if session.is_null() { + return Err("Could not create polkit session".into()); + } + + let (tx, rx) = mpsc::channel(); + let signal_data = Box::into_raw(Box::new(tx)).cast(); + unsafe { + connect( + session, + c"request", + Some(std::mem::transmute( + request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), + )), + signal_data, + ); + connect( + session, + c"show-info", + Some(std::mem::transmute( + info as unsafe extern "C" fn(Object, *const c_char, *mut c_void), + )), + signal_data, + ); + connect( + session, + c"show-error", + Some(std::mem::transmute( + error as unsafe extern "C" fn(Object, *const c_char, *mut c_void), + )), + signal_data, + ); + connect( + session, + c"completed", + Some(std::mem::transmute( + completed as unsafe extern "C" fn(Object, c_int, *mut c_void), + )), + signal_data, + ); + (api.initiate)(session); + } + + let mut outcome = None; + let mut stopped = false; + while outcome.is_none() { + while context.pending() { + context.iteration(false); + } + while let Ok(event) = rx.try_recv() { + match event { + SessionEvent::Request(prompt, echo) => { + send(writer, &PromptEvent::Request { prompt, echo })?; + } + SessionEvent::Info(text) => send(writer, &PromptEvent::Info { text })?, + SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?, + SessionEvent::Completed(success) => outcome = Some(success), + } + } + if outcome.is_some() { + break; + } + while let Ok(event) = input.try_recv() { + match event { + PromptEvent::Response { mut value } => { + if let Ok(response) = CString::new(value.as_str()) { + unsafe { (api.response)(session, response.as_ptr()) }; + } + value.zeroize(); + } + PromptEvent::Cancel => cancelled.store(true, Ordering::Release), + _ => {} + } + } + if !stopped + && (cancelled.load(Ordering::Acquire) + || child.try_wait().map_err(|e| e.to_string())?.is_some()) + { + unsafe { (api.cancel)(session) }; + stopped = true; + } + std::thread::sleep(Duration::from_millis(10)); + } + + unsafe { + gobject_ffi::g_object_unref(session.cast()); + drop(Box::from_raw(signal_data as *mut Sender)); + } + Ok(outcome.filter(|_| !stopped)) +} + +pub fn authenticate( + uid: u32, + message: String, + cookie: String, + cancelled: Arc, +) -> Result { + let api = Api::load()?; + let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?; + let context = glib::MainContext::new(); + context + .with_thread_default(|| { + let (mut child, mut writer, input) = prompt_process(message, username(uid))?; + let result = loop { + match run_session( + &api, + uid, + &cookie, + &context, + &mut writer, + &input, + &cancelled, + &mut child, + ) { + Ok(Some(true)) => break Ok(true), + Ok(Some(false)) if !cancelled.load(Ordering::Acquire) => { + if let Err(error) = send( + &mut writer, + &PromptEvent::Error { + text: "Password not accepted. Try again.".into(), + }, + ) { + break Err(error); + } + } + Ok(_) => break Ok(false), + Err(error) => break Err(error), + } + }; + let _ = child.kill(); + let _ = child.wait(); + result + }) + .map_err(|error| error.to_string())? +} diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 009198c..b9f02b2 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -623,6 +623,17 @@ pub fn fields(page: Page) -> Vec { "solid", &[("solid", "Solid"), ("translucent", "Translucent")], ), + range( + "theme.material.opacity", + "Shell opacity", + "Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.", + ferese_config::DEFAULT_MATERIAL_OPACITY, + 0.0, + 1.0, + 0.01, + "", + false, + ), range( "theme.material.blur_radius", "Background blur", @@ -727,17 +738,6 @@ pub fn fields(page: Page) -> Vec { " px", true, ), - range( - "theme.surface.bar.opacity", - "Opacity", - "Background opacity for translucent bars, with or without blur. Text and icons stay opaque.", - 0.78, - 0.0, - 1.0, - 0.01, - "", - false, - ), range( "theme.geometry.top_bar_window_gap", "Window clearance", diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index c9507b5..9717d39 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -151,6 +151,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button } else { (background, p.text) }; + button::Style { background: Some(Background::Color(background)), text_color: Some(on), @@ -196,6 +197,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { .text_tint(cosmic_color(palette.text).color) .accent(ferese_theme::accent_color(palette.accent, palette.card)) .build(); + ferese_theme::apply(&mut theme, palette.text); cosmic::Theme::custom(std::sync::Arc::new(theme)) } @@ -204,16 +206,20 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font { static FONTS: std::sync::OnceLock< std::sync::Mutex>, > = std::sync::OnceLock::new(); + let family = snapshot.string("theme.typography.font_family", "Inter"); let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap(); if let Some(font) = fonts.get(&family) { return *font; } + if fonts.len() >= 32 { return cosmic::font::default(); } + let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str())); fonts.insert(family, font); + font } @@ -331,6 +337,7 @@ pub fn preset(index: usize) -> Vec { let Some(preset) = PRESETS.get(index) else { return Vec::new(); }; + vec![ set("theme.colors.accent", preset.accent), set("theme.colors.surface_base", preset.base), @@ -351,6 +358,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool { let Some(preset) = PRESETS.get(index) else { return false; }; + [ ("theme.colors.accent", preset.accent, "#3D7BE6"), ("theme.colors.surface_base", preset.base, "#111821"), @@ -442,7 +450,11 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> { let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.; let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65; let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65; - let opacity = snapshot.number("theme.surface.bar.opacity", 0.78); + let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" { + snapshot.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY).clamp(0., 1.) + } else { + 1. + }; let right = 340. + gap / 2.; let left_width = 288. - gap / 2.; let svg = format!( @@ -465,6 +477,7 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> { bar_y + bar_height / 2., right + 16. ); + svg_icon::from_svg_bytes(svg.into_bytes()) .symbolic(false) .icon() diff --git a/shell/ferese-settings/src/watch.rs b/shell/ferese-settings/src/watch.rs index af7ffb3..0882a62 100644 --- a/shell/ferese-settings/src/watch.rs +++ b/shell/ferese-settings/src/watch.rs @@ -6,6 +6,7 @@ use std::{path::PathBuf, time::Duration}; pub fn changes(path: &PathBuf) -> impl Stream + use<> { let path = path.clone(); + cosmic::iced::stream::channel(1, async move |mut output| { let (send, mut events) = tokio::sync::mpsc::channel(1); let target = path.clone(); @@ -26,6 +27,7 @@ pub fn changes(path: &PathBuf) -> impl Stream + use<> { return; } }; + if let Err(error) = watcher.watch( path.parent().unwrap_or(std::path::Path::new(".")), RecursiveMode::NonRecursive, @@ -35,6 +37,7 @@ pub fn changes(path: &PathBuf) -> impl Stream + use<> { .await; return; } + // Read once after installing the watch to cover the startup race. loop { let file = path.clone(); diff --git a/shell/ferese-shell/assets/icons/status/calendar.svg b/shell/ferese-shell/assets/icons/status/calendar.svg new file mode 100644 index 0000000..fbb89ed --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/calendar.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/power-balanced.svg b/shell/ferese-shell/assets/icons/status/power-balanced.svg new file mode 100644 index 0000000..34abccc --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/power-balanced.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/power-off.svg b/shell/ferese-shell/assets/icons/status/power-off.svg new file mode 100644 index 0000000..1c4d4ab --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/power-off.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/power-performance.svg b/shell/ferese-shell/assets/icons/status/power-performance.svg new file mode 100644 index 0000000..d6df9af --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/power-performance.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/power-saver.svg b/shell/ferese-shell/assets/icons/status/power-saver.svg new file mode 100644 index 0000000..ce35792 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/power-saver.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/restart.svg b/shell/ferese-shell/assets/icons/status/restart.svg new file mode 100644 index 0000000..2e2e8e2 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/restart.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/suspend.svg b/shell/ferese-shell/assets/icons/status/suspend.svg new file mode 100644 index 0000000..a9d3990 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/suspend.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 8930a57..2b3a102 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -62,11 +62,11 @@ pub(crate) struct ShellTheme { impl Default for ShellTheme { fn default() -> Self { Self { - bar_background: [28, 32, 46, 242], + bar_background: [28, 32, 46, 255], bar_text_primary: [240, 243, 250, 255], bar_text_muted: [170, 180, 199, 255], surface_base: [17, 24, 33, 255], - surface_popover: [17, 24, 33, 245], + surface_popover: [17, 24, 33, 255], text_primary: [244, 247, 251, 255], text_muted: [135, 147, 162, 255], accent: [61, 123, 230, 255], @@ -148,6 +148,8 @@ struct AppearanceConfig { #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + material: MaterialConfig, #[serde(default)] surface: SurfaceConfig, #[serde(default)] @@ -166,16 +168,12 @@ struct ThemeConfig { struct SurfaceConfig { #[serde(default)] bar: BarConfig, - #[serde(default)] - popover: PopoverConfig, } #[derive(Debug, Deserialize)] #[serde(default)] struct BarConfig { background: String, - // If omitted, preserve the alpha supplied in `background`. - opacity: Option, text_primary: String, text_muted: String, } @@ -184,29 +182,17 @@ impl Default for BarConfig { fn default() -> Self { Self { background: "#1C202EF2".to_owned(), - opacity: None, text_primary: "#F0F3FA".to_owned(), text_muted: "#AAB4C7".to_owned(), } } } -#[derive(Debug, Deserialize)] -struct PopoverConfig { - #[serde(default = "default_popover_opacity")] - opacity: f32, -} - -impl Default for PopoverConfig { - fn default() -> Self { - Self { - opacity: default_popover_opacity(), - } - } -} - -const fn default_popover_opacity() -> f32 { - 0.96 +#[derive(Debug, Default, Deserialize)] +struct MaterialConfig { + #[serde(default)] + style: String, + opacity: Option, } #[derive(Debug, Default, Deserialize)] @@ -364,17 +350,26 @@ pub(crate) fn parse_source(source: &str) -> Result ShellTheme { let defaults = ShellTheme::default(); + let opacity = if theme.material.style == "translucent" { + finite_or( + theme + .material + .opacity + .unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY as f32), + ferese_config::DEFAULT_MATERIAL_OPACITY as f32, + ) + .clamp(0.0, 1.0) + } else { + 1.0 + }; + let alpha = (opacity * 255.0).round() as u8; ShellTheme { material_radius: defaults.material_radius, bar_background: { let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background); - if let Some(opacity) = theme.surface.bar.opacity { - background[3] = - (finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0) - .round() as u8; - } + background[3] = alpha; background }, bar_text_primary: parse_color(&theme.surface.bar.text_primary) @@ -385,10 +380,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme { surface_popover: { let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base); - color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity()) - .clamp(0.0, 1.0) - * 255.0) - .round() as u8; + color[3] = alpha; color }, text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary), @@ -617,7 +609,7 @@ theme { assert_eq!(theme.bar_height, 28.0); assert_eq!(theme.bar_margin_horizontal, 0); assert_eq!(theme.bar_radius, 14.0); - assert_eq!(theme.bar_background, ShellTheme::default().bar_background); + assert_eq!(theme.bar_background[3], 255); assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary); assert_ne!(theme.for_bar().text_primary, theme.text_primary); } @@ -638,21 +630,17 @@ theme { ) .unwrap(); let theme = shell_theme(&config.theme); - assert_eq!(theme.bar_background, [234, 236, 238, 221]); + assert_eq!(theme.bar_background, [234, 236, 238, 255]); assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]); assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]); assert_eq!(theme.text_primary, ShellTheme::default().text_primary); } #[test] - fn bar_transparency_and_shadow_opacity_are_independent() { + fn shell_opacity_is_shared_and_solid_remains_opaque() { let config: FereseConfig = ferese_config::from_str( r#"theme { - surface { - bar { - opacity 0.6 - } - } + material { style "translucent"; opacity 0.6; } shadow { soft { opacity 0.07 @@ -664,6 +652,12 @@ theme { .unwrap(); let theme = shell_theme(&config.theme); assert_eq!(theme.bar_background[3], 153); + assert_eq!(theme.surface_popover[3], 153); assert_eq!(theme.shadow_opacity, 0.07); + let mut config = config; + config.theme.material.style = "solid".into(); + let solid = shell_theme(&config.theme); + assert_eq!(solid.bar_background[3], 255); + assert_eq!(solid.surface_popover[3], 255); } } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index d321578..3165a25 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -59,12 +59,15 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font { std::sync::Mutex>, > = std::sync::OnceLock::new(); let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap(); + if let Some(font) = fonts.get(family) { return *font; } + if fonts.len() >= 64 { return cosmic::font::default(); } + let family = Box::leak(family.to_owned().into_boxed_str()); let font = cosmic::font::Font::with_name(family); fonts.insert(family.to_owned(), font); @@ -113,14 +116,17 @@ impl From for BarMetrics { fn stacked_clock_digits(label: &str) -> Option<[String; 2]> { let mut parts = label.split_whitespace(); let time = parts.next()?; + if let Some(period) = parts.next() { if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") { return None; } } + if parts.next().is_some() { return None; } + let (hour, minute) = time.split_once(':')?; if !(1..=2).contains(&hour.len()) || minute.len() != 2 @@ -131,6 +137,7 @@ fn stacked_clock_digits(label: &str) -> Option<[String; 2]> { { return None; } + Some([format!("{hour:0>2}"), minute.to_owned()]) } @@ -142,6 +149,7 @@ fn main() -> cosmic::iced::Result { // toolkit threads are started. unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") }; } + cosmic::iced::advanced::graphics::text::font_system() .write() .unwrap() @@ -154,6 +162,7 @@ fn main() -> cosmic::iced::Result { .load_font(std::borrow::Cow::Borrowed(include_bytes!( "../../../assets/fonts/Cantarell-ExtraBold.otf" ))); + let mut config = config::load(); motion::configure(config.animations, config.theme.material_radius); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); @@ -207,6 +216,7 @@ struct FereseShell { status_service: status::Service, status: status::Snapshot, recorder: recording::Recorder, + calendar_offset: i32, status_error: Option, menu: Option, note_editor: Option, @@ -284,6 +294,8 @@ enum Message { Control(status::Action), ConfirmPower(status::Action), CancelPower, + CalendarMonth(i32), + CalendarToday, } impl cosmic::Application for FereseShell { @@ -316,6 +328,7 @@ impl cosmic::Application for FereseShell { status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), recorder: recording::Recorder::default(), + calendar_offset: 0, status_error: None, menu: None, note_editor: None, @@ -348,6 +361,7 @@ impl cosmic::Application for FereseShell { }), None => Task::none(), }; + (app, wallpaper_task) } @@ -420,22 +434,28 @@ impl cosmic::Application for FereseShell { if let Err(error) = effects.set_opacity(menu.progress()) { eprintln!("ferese-shell: could not update popup opacity: {error}"); } + let regions = menu.regions.lock().unwrap().clone(); + if let Err(error) = effects.set_regions(®ions) { eprintln!("ferese-shell: could not update card materials: {error}"); } } + if let Some(surface) = &self.notification_surface && let Some(effects) = &surface.effects { let regions = surface.regions.lock().unwrap().clone(); + if let Err(error) = effects.set_regions(®ions) { eprintln!("ferese-shell: could not update notification materials: {error}"); } } + match message { Message::BeginNoteEdit(id) => { let mut tasks = vec![self.finish_note_edit()]; + if let Some(note) = self .config .desktop_widgets @@ -459,6 +479,7 @@ impl cosmic::Application for FereseShell { { let edited = action.is_edit(); editor.content.perform(action); + if edited { editor.revision = editor.revision.wrapping_add(1); let revision = editor.revision; @@ -525,6 +546,7 @@ impl cosmic::Application for FereseShell { ), } } + if let Some(menu) = &mut self.menu && menu.id == id && menu.effects.is_none() @@ -539,6 +561,7 @@ impl cosmic::Application for FereseShell { eprintln!("ferese-shell: popover material unavailable: {error}") } } + if let Some(effects) = &menu.effects { let _ = effects.set_opacity(menu.progress()); } @@ -566,6 +589,7 @@ impl cosmic::Application for FereseShell { self.status_error = update.error; } self.notifications.tick(); + if self.notifications.ready { self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), @@ -636,6 +660,14 @@ impl cosmic::Application for FereseShell { } Task::none() } + Message::CalendarMonth(delta) => { + self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200); + Task::none() + } + Message::CalendarToday => { + self.calendar_offset = 0; + Task::none() + } Message::CancelPower => { if let Some(menu) = &mut self.menu { menu.confirm = None; @@ -643,6 +675,18 @@ impl cosmic::Application for FereseShell { Task::none() } Message::Control(action) => { + if let status::Action::PowerProfile(profile) = action { + self.status_error = self + .status_service + .send(status::Action::PowerProfile(profile)) + .err(); + if self.status_error.is_none() { + if let Some(profiles) = &mut self.status.power_profiles { + profiles.active = profile.to_owned(); + } + } + return Task::none(); + } if self.notifications.ready { match action { status::Action::Dnd(value) => { @@ -774,9 +818,11 @@ impl FereseShell { return Task::none(); } }; + if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() { config.wallpaper.path = None; } + if self.config.font_family != config.font_family { *SHELL_FONT .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) @@ -784,6 +830,7 @@ impl FereseShell { .unwrap() = configured_font(config.font_family.as_deref()); } motion::configure(config.animations, config.theme.material_radius); + if self.config.animations != config.animations && let Some(menu) = &mut self.menu { @@ -791,6 +838,7 @@ impl FereseShell { } self.status_service .update_settings(config.status.settings_command.clone()); + let old = self.config.theme; let old_clock = &self.config.desktop_widgets.clock; let old_notes = &self.config.desktop_widgets.notes; @@ -826,6 +874,7 @@ impl FereseShell { } else { Task::none() }]; + if clock_changed && self .note_drag @@ -834,6 +883,7 @@ impl FereseShell { { tasks.push(self.finish_note_drag(false)); } + if notes_changed { tasks.push(self.finish_note_drag(false)); if self.note_editor.as_ref().is_some_and(|editor| { @@ -848,6 +898,7 @@ impl FereseShell { } tasks.push(self.rebuild_notes(true)); } + for entry in self.outputs.iter().filter(|_| geometry_changed) { tasks.push(set_size( entry.bar, @@ -866,6 +917,7 @@ impl FereseShell { (theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap), )); } + Task::batch(tasks) } @@ -882,6 +934,7 @@ impl FereseShell { } else { Task::none() }; + let mut tasks = vec![destroy_layer_surface(entry.bar), menu]; if self.note_drag.as_ref().is_some_and(|drag| { entry.clock == Some(drag.source) @@ -889,20 +942,26 @@ impl FereseShell { }) { tasks.push(self.finish_note_drag(false)); } + if let Some(wallpaper) = entry.wallpaper { tasks.push(destroy_layer_surface(wallpaper)); } + if let Some(clock) = entry.clock { tasks.push(destroy_layer_surface(clock)); } + for (_, id) in entry.notes { self.note_pointer.remove(&id); tasks.push(destroy_layer_surface(id)); } + return Task::batch(tasks); } + return Task::none(); } + let size = match &event { wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size), wayland::OutputEvent::InfoUpdate(info) => info.logical_size, @@ -913,20 +972,24 @@ impl FereseShell { wayland::OutputEvent::InfoUpdate(info) => info.name, _ => None, }; + if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { if size.is_some() { entry.size = size; } + let changed = name.is_some() && entry.name != name; if name.is_some() { entry.name = name; } + return if changed { Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)]) } else { Task::none() }; } + let bar_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique(); let shell_theme = self.config.theme; @@ -1005,6 +1068,7 @@ impl FereseShell { if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() { surfaces.push(wallpaper_action); } + let tasks = surfaces .into_iter() .map(cosmic::Action::Surface) @@ -1021,6 +1085,7 @@ impl FereseShell { if self.note_saving || self.note_pending.is_empty() { return Task::none(); } + let Some(path) = config::config_path() else { self.note_error = Some("Configuration path unavailable.".into()); return Task::none(); @@ -1109,6 +1174,7 @@ impl FereseShell { let output = entry.output.clone(); let mut obstacles = Vec::new(); let clock = &self.config.desktop_widgets.clock; + if id.is_some() && clock.on_output(entry.name.as_deref()) { let position = widget_origin( clock.anchor, @@ -1122,6 +1188,7 @@ impl FereseShell { cosmic::iced::Size::new(clock.width as f32, clock.height as f32), )); } + for note in &self.config.desktop_widgets.notes { if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { obstacles.push(cosmic::iced::Rectangle::new( @@ -1130,6 +1197,7 @@ impl FereseShell { )); } } + let overlay = window::Id::unique(); self.note_drag = Some(NoteDrag { id, @@ -1161,8 +1229,10 @@ impl FereseShell { size_limits: Limits::NONE, ..Default::default() }, + Some(Box::new(Self::view_note_drag)), ); + Task::batch([ self.finish_note_edit(), cosmic::task::message(cosmic::Action::Surface(action)), @@ -1173,6 +1243,7 @@ impl FereseShell { let Some(drag) = &self.note_drag else { return text("").into(); }; + if let Some(id) = &drag.id { self.view_note_content(id, drag.source) } else { @@ -1185,6 +1256,7 @@ impl FereseShell { return Task::none(); }; let mut tasks = vec![destroy_layer_surface(drag.overlay)]; + if save { if drag.id.is_none() { let clock = &mut self.config.desktop_widgets.clock; @@ -1202,6 +1274,7 @@ impl FereseShell { clock.margin_y, )); } + if let Some(note) = self .config .desktop_widgets @@ -1212,6 +1285,7 @@ impl FereseShell { note.anchor = ferese_core::desktop::Anchor::TopLeft; note.margin_x = drag.position.x.round() as i32; note.margin_y = drag.position.y.round() as i32; + for entry in &self.outputs { if let Some((_, id)) = entry .notes @@ -1222,12 +1296,14 @@ impl FereseShell { tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); } } + self.note_pending.push(note_store::Edit::Position( note.id.clone(), note.margin_x, note.margin_y, )); } + tasks.push(self.flush_note_changes()); } Task::batch(tasks) @@ -1235,6 +1311,7 @@ impl FereseShell { fn rebuild_notes(&mut self, reset: bool) -> Task { let mut tasks = Vec::new(); + for entry in &mut self.outputs { if reset { for (_, id) in entry.notes.drain(..) { @@ -1242,12 +1319,14 @@ impl FereseShell { tasks.push(destroy_layer_surface(id)); } } + for note in &self.config.desktop_widgets.notes { if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) { continue; } + let id = window::Id::unique(); entry.notes.push((note.id.clone(), id)); let note = note.clone(); @@ -1281,11 +1360,14 @@ impl FereseShell { ..Default::default() } }, + Some(Box::new(move |app| app.view_note(¬e_id, id))), ); + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } } + Task::batch(tasks) } @@ -1300,6 +1382,7 @@ impl FereseShell { .height(Length::Fill) .into(); } + self.view_note_content(id, surface) } @@ -1334,7 +1417,9 @@ impl FereseShell { .and_then(config::parse_color) .unwrap_or(self.config.theme.text_primary), ); + foreground.a *= note.opacity; + let background = if note.background.as_deref() == Some("") { None } else { @@ -1350,6 +1435,7 @@ impl FereseShell { let mut body = cosmic::widget::column([]) .spacing(note.gap) .align_x(alignment); + if note.interactive { let title = cosmic::widget::text(if note.title.is_empty() { "⋮⋮" @@ -1369,6 +1455,7 @@ impl FereseShell { .note_editor .as_ref() .is_some_and(|editor| editor.id == id); + body = body.push( row([]).push(header).push(motion::button( button::custom(text(if editing { "Done" } else { "Edit" })) @@ -1396,6 +1483,7 @@ impl FereseShell { .class(theme::Text::Color(foreground)), ); } + if let Some(editor) = &self.note_editor && editor.id == id { @@ -1425,6 +1513,7 @@ impl FereseShell { .class(theme::Text::Color(foreground)), ); } + if let Some(error) = &self.note_error { body = body.push( text(error.clone()) @@ -1432,7 +1521,9 @@ impl FereseShell { .class(theme::Text::Color(foreground)), ); } + let radius = self.config.theme.material_radius; + container(body) .padding(note.padding) .width(Length::Fill) @@ -1456,18 +1547,23 @@ impl FereseShell { .labels(&Zoned::now()) .unwrap_or_default(); let mut tasks = Vec::new(); + for entry in &mut self.outputs { if !reset && entry.clock.is_some() { continue; } + if let Some(id) = entry.clock.take() { self.note_pointer.remove(&id); tasks.push(destroy_layer_surface(id)); } + let clock = self.config.desktop_widgets.clock.clone(); + if !clock.on_output(entry.name.as_deref()) { continue; } + let id = window::Id::unique(); entry.clock = Some(id); let output = entry.output.clone(); @@ -1490,10 +1586,13 @@ impl FereseShell { ..Default::default() } }, + Some(Box::new(move |app| app.view_desktop_clock(id))), ); + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } + Task::batch(tasks) } @@ -1508,6 +1607,7 @@ impl FereseShell { .height(Length::Fill) .into(); } + cosmic::widget::mouse_area(self.view_clock_content()) .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) .interaction(cosmic::iced::mouse::Interaction::Grab) @@ -1602,6 +1702,7 @@ impl FereseShell { for (line, digits) in digits.into_iter().enumerate() { let mut digit_row = row([]).spacing(0); + for (position, digit) in digits.chars().enumerate() { digit_row = digit_row.push( cosmic::widget::text(digit.to_string()) @@ -1611,8 +1712,10 @@ impl FereseShell { .class(theme::Text::Color(digit_colors[(line + position) % 2])), ); } + stack = stack.push(digit_row); } + labels = labels.push(stack); } else { let characters = self.desktop_clock.0.chars().count().max(1) as f32; @@ -1629,6 +1732,7 @@ impl FereseShell { .time_size .min(available_width / (characters * 0.75)) .min(available_height / 1.3); + labels = labels.push( cosmic::widget::text(self.desktop_clock.0.clone()) .font(font) @@ -1662,6 +1766,7 @@ impl FereseShell { Background::Color(tint) }); let radius = self.config.theme.material_radius; + container(labels) .padding(clock.padding) .width(Length::Fill) @@ -1706,9 +1811,11 @@ impl FereseShell { }) { return Task::none(); } + match mouse { cosmic::iced::mouse::Event::CursorMoved { position } => { self.note_pointer.insert(id, *position); + if let Some(drag) = &mut self.note_drag && drag.source == id { @@ -1724,6 +1831,7 @@ impl FereseShell { let clock = &self.config.desktop_widgets.clock; Some((clock.width, clock.height)) }; + if let Some(dimensions) = dimensions { let requested = clamp_note_position( drag.origin + delta, @@ -1748,6 +1856,7 @@ impl FereseShell { } } } + cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => { if self .note_drag @@ -1770,13 +1879,16 @@ impl FereseShell { if self.note_drag.is_some() { return self.finish_note_drag(false); } + if self.note_editor.is_some() { return self.finish_note_edit(); } + if self.notifications.history_open { return self.close_notification_history(); } } + match event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( event, @@ -1800,6 +1912,7 @@ impl FereseShell { let _ = effects.set_opacity(menu.progress()); } } + window::run(id, native_wayland_surface) .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))) } @@ -1978,6 +2091,7 @@ impl FereseShell { let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) .spacing(1) .align_y(cosmic::iced::Alignment::Center); + for workspace in &self.snapshot.workspaces { let active = workspace_active_on_bar(workspace.id, focused_output); let owner = workspace @@ -2040,9 +2154,28 @@ impl FereseShell { let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock)); let clock = container( row![ - text(date) - .size(12) - .class(theme::Text::Color(color(shell_theme.text_muted))), + motion::button( + button::custom(text(date).size(12)) + .padding([4, 2]) + .name("Open calendar") + .on_press_with_rectangle(move |offset, bounds| { + cosmic::Action::App(Message::OpenMenuOn( + id, + status_ui::Menu::Calendar, + cosmic::iced::Rectangle { + x: (bounds.x - offset.x).round() as i32, + y: (bounds.y - offset.y).round() as i32, + width: bounds.width.round() as i32, + height: bounds.height.round() as i32, + }, + )) + }), + color(shell_theme.text_muted), + self.menu + .as_ref() + .is_some_and(|menu| menu.kind == status_ui::Menu::Calendar), + 1.0 + ), text(time) .size(bar.text_size) .class(theme::Text::Color(foreground)), diff --git a/shell/ferese-shell/src/recording.rs b/shell/ferese-shell/src/recording.rs index 23182bd..b93293e 100644 --- a/shell/ferese-shell/src/recording.rs +++ b/shell/ferese-shell/src/recording.rs @@ -48,6 +48,7 @@ impl Recorder { pub fn busy(&self) -> bool { self.updates.is_some() } + pub fn elapsed(&self) -> Option { let State::Recording(start) = self.state else { return None; @@ -55,15 +56,18 @@ impl Recorder { let seconds = start.elapsed().as_secs(); Some(format!("{:02}:{:02}", seconds / 60, seconds % 60)) } + pub fn start(&mut self) { if self.busy() { return; } + let program = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.join("ferese-record"))) .filter(|p| p.is_file()) .unwrap_or_else(|| PathBuf::from("ferese-record")); + match Command::new(program) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -92,6 +96,7 @@ impl Recorder { } } } + let _ = child.wait(); let _ = send.send(Event::Exited); }); @@ -103,6 +108,7 @@ impl Recorder { } } } + pub fn stop(&mut self) { if self.busy() && !matches!(self.state, State::Saving) { // EOF requests EOS/finalization. Never terminate the encoder abruptly. @@ -110,6 +116,7 @@ impl Recorder { self.state = State::Saving; } } + pub fn poll(&mut self) { let mut done = false; if let Some(updates) = &self.updates { @@ -146,6 +153,7 @@ impl Recorder { } } } + if done { match &self.state { State::Saved(path) => notify("Recording saved", path.display().to_string()), diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index c91fb85..0babe8f 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -21,6 +21,7 @@ pub struct Snapshot { pub bluetooth: Option, pub audio: Option