From 8832a9bf2e8aa46ada38b740cc0dcb9cd1890e02 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 28 Sep 2026 09:15:13 +0100 Subject: [PATCH] Align recording dialog behavior and shell theme styling --- crates/ferese-theme/src/lib.rs | 56 ++++++++- docs/configuration.md | 5 +- docs/screen-sharing.md | 11 +- portal/src/backend.rs | 133 +++++++++++++++++++--- portal/src/picker.rs | 66 ++++++++++- portal/src/recorder.rs | 15 +++ scripts/tests/test_portal_isolated.py | 6 + shell/ferese-settings/src/visuals.rs | 6 +- shell/ferese-shell/src/notification_ui.rs | 3 +- 9 files changed, 269 insertions(+), 32 deletions(-) diff --git a/crates/ferese-theme/src/lib.rs b/crates/ferese-theme/src/lib.rs index 8c03d23..fde67a4 100644 --- a/crates/ferese-theme/src/lib.rs +++ b/crates/ferese-theme/src/lib.rs @@ -47,6 +47,32 @@ pub fn foreground(background: Color, preferred: Color) -> Color { } } +/// Prefer the theme's foreground for filled controls. A small shade adjustment +/// is less disruptive than flipping light text to black when selecting a row. +/// Very bright/dark accents still use a contrasting foreground rather than +/// changing the user's accent into a substantially different color. +pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) { + let on = composite(preferred, background); + let shade = if luminance(on) > luminance(background) { + Color::BLACK + } else { + Color::WHITE + }; + for step in 0..=20 { + let fill = composite( + Color { + a: step as f32 / 100., + ..shade + }, + background, + ); + if contrast(fill, on) >= 4.5 { + return (fill, on); + } + } + (background, foreground(background, preferred)) +} + /// Resolve configured accent transparency before passing an opaque color to a toolkit. pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb { rgba(composite(accent, surface)).color @@ -56,8 +82,7 @@ pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::pale pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) { let surface: Color = native.primary(false).base.into(); for component in [&mut native.accent, &mut native.accent_button] { - let base = composite(component.base.into(), surface); - let on = foreground(base, preferred); + let (base, on) = accent_pair(composite(component.base.into(), surface), preferred); component.hover = rgba(readable_fill( composite(component.hover.into(), surface), base, @@ -68,7 +93,11 @@ pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) { base, on, )); - component.selected = rgba(composite(component.selected.into(), surface)); + component.selected = rgba(readable_fill( + composite(component.selected.into(), surface), + base, + on, + )); component.base = rgba(base); component.on = rgba(on); component.selected_text = rgba(foreground( @@ -170,7 +199,7 @@ mod tests { for surface in [Color::BLACK, Color::WHITE] { for alpha in [0., 0.2, 0.5, 1.] { let fill = composite(Color::from_rgba8(r, g, b, alpha), surface); - let on = foreground(fill, Color::from_rgb8(120, 130, 140)); + let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140)); assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}"); } } @@ -179,6 +208,25 @@ mod tests { } } #[test] + fn blue_accent_preserves_light_theme_text_in_every_state() { + let preferred = Color::from_rgb8(244, 247, 251); + let mut native = cosmic::cosmic_theme::ThemeBuilder::dark() + .accent(rgba(Color::from_rgb8(61, 123, 230)).color) + .build(); + apply(&mut native, preferred); + let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); + for state in [ + State::Active, + State::Hovered, + State::Pressed, + State::Disabled, + ] { + let actual = style(&theme, state, false); + assert_eq!(actual.text_color, Some(preferred)); + assert_eq!(actual.icon_color, Some(preferred)); + } + } + #[test] fn native_button_states_keep_text_and_icons_readable() { for accent in [ Color::BLACK, diff --git a/docs/configuration.md b/docs/configuration.md index bbbaff9..9e96f8c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,8 +146,9 @@ these six visually distinct. | `shadow` | `"#00000055"` | Window shadow color | Ferese derives the text and icon color for filled accent controls from the -configured theme. It keeps the configured text color when it reaches a 4.5:1 -contrast ratio; otherwise it chooses black or white. The same rule is shared by +configured theme. It keeps the configured text color and, when necessary, shades +the accent fill slightly to reach a 4.5:1 contrast ratio. If that would require a +large color change, it chooses black or white instead. The same rule is shared by native dialogs, settings selections, and notification badges. Hover, pressed, and disabled accent-button states use the same policy. Small filled controls resolve accent transparency against their theme surface so wallpaper cannot diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index a3823b2..fe46285 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -27,10 +27,15 @@ may remain at the path shown in the error. Use a Wayland screen-sharing or recording source in your application. Ferese opens a display picker. Select a display and press **Share**. Nothing is selected by default. Applications that request multiple displays can share up to four. -The picker uses your Ferese theme colors, font, and shell corner radius. +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. -A **screen sharing** window remains visible while sharing. Press **Stop sharing** -or close that window to revoke the session. Closing the requesting application, +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 +windows accessible after display selection. The command-line recorder keeps the +sharing window when it is not launched by the shell. Press **Stop sharing** or +close the sharing window to revoke an ordinary application’s session. Closing the requesting application, locking Ferese, disconnecting the selected display, or changing its capture size also ends sharing. A new request needs fresh consent; selections are not saved. Everything visible on a shared display, including notifications, is included. diff --git a/portal/src/backend.rs b/portal/src/backend.rs index a2db2a5..11aef24 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -73,12 +73,80 @@ struct Session { phase: Phase, multiple: bool, cursor: bool, + bar_controlled: bool, cancel: Arc, } #[derive(Clone, Default)] pub struct Backend { sessions: Arc>>, } +// This private interface only transfers stop controls to the shell's own recorder. +// The ordinary portal picker and all session revocation rules still apply. +struct RecorderControl(Backend); +#[zbus::interface(name = "org.ferese.ScreenRecorder")] +impl RecorderControl { + async fn use_bar_controls( + &self, + session_handle: OwnedObjectPath, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + let sender = header + .sender() + .ok_or_else(|| error("Missing recorder caller"))?; + if !owns_session(sender.as_str(), session_handle.as_str()) { + return Err(zbus::fdo::Error::AccessDenied( + "Not your recording session".into(), + )); + } + let dbus = zbus::fdo::DBusProxy::new(connection).await?; + let pid = dbus + .get_connection_unix_process_id(sender.clone().into()) + .await?; + if !shell_recorder(pid) { + return Err(zbus::fdo::Error::AccessDenied( + "The shell must own the recording controls".into(), + )); + } + let mut sessions = self.0.sessions.lock().await; + let session = sessions + .get_mut(session_handle.as_str()) + .ok_or_else(|| error("Unknown session"))?; + if session.phase != Phase::Created { + return Err(error("Recording already configured")); + } + session.bar_controlled = true; + Ok(()) + } +} +fn owns_session(sender: &str, path: &str) -> bool { + let Some(sender) = sender.strip_prefix(':') else { + return false; + }; + path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_"))) +} +fn shell_recorder(pid: u32) -> bool { + use std::os::unix::fs::MetadataExt; + let matches_binary = |pid: u32, name: &str| -> Option { + let expected = std::env::current_exe().ok()?.parent()?.join(name); + let expected = std::fs::metadata(expected).ok()?; + let actual = std::fs::metadata(format!("/proc/{pid}/exe")).ok()?; + Some(expected.dev() == actual.dev() && expected.ino() == actual.ino()) + }; + if matches_binary(pid, "ferese-record") != Some(true) { + return false; + } + let parent = std::fs::read_to_string(format!("/proc/{pid}/status")) + .ok() + .and_then(|status| { + status.lines().find_map(|line| { + line.strip_prefix("PPid:") + .and_then(|s| s.trim().parse::().ok()) + }) + }); + parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true)) +} + async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result { let sender = header .sender() @@ -199,6 +267,7 @@ impl Backend { phase: Phase::Created, multiple: false, cursor: false, + bar_controlled: false, cancel: Arc::default(), }, ); @@ -257,7 +326,7 @@ impl Backend { { return Err(error("Invalid request path")); } - let (multiple, cursor, cancel) = { + let (multiple, cursor, bar_controlled, cancel) = { let mut sessions = self.sessions.lock().await; let session = sessions .get_mut(session_handle.as_str()) @@ -266,7 +335,12 @@ impl Backend { return Err(error("Select sources before starting a session")); } session.phase = Phase::Starting; - (session.multiple, session.cursor, session.cancel.clone()) + ( + session.multiple, + session.cursor, + session.bar_controlled, + session.cancel.clone(), + ) }; let inserted = connection .object_server() @@ -290,7 +364,7 @@ impl Backend { } let result = tokio::select! { _ = cancel.wait() => Err(StartError::Cancelled), - result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))), + result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, multiple, cursor, !bar_controlled)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))), }; let _ = connection .object_server() @@ -414,7 +488,8 @@ async fn start_streams( app: &str, multiple: bool, cursor: bool, -) -> Result<(Vec, Child, Vec), StartError> { + show_indicator: bool, +) -> Result<(Vec, Option, Vec), StartError> { let sources = tokio::task::spawn_blocking(|| { Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources()) }) @@ -469,13 +544,19 @@ async fn start_streams( streams.push(ready); children.push(child); } - let indicator = picker(&Prompt { - app: app.to_owned(), - sources: selected, - multiple, - indicator: true, - }) - .await?; + let indicator = if show_indicator { + Some( + picker(&Prompt { + app: app.to_owned(), + sources: selected, + multiple, + indicator: true, + }) + .await?, + ) + } else { + None + }; Ok((children, indicator, streams)) } fn validate_selection( @@ -501,10 +582,12 @@ fn validate_selection( } Ok(selected) } -async fn supervise(mut children: Vec, mut indicator: Child, cancel: Arc) { +async fn supervise(mut children: Vec, mut indicator: Option, cancel: Arc) { loop { if cancel.stopped.load(Ordering::SeqCst) - || !matches!(indicator.try_wait(), Ok(None)) + || indicator + .as_mut() + .is_some_and(|child| !matches!(child.try_wait(), Ok(None))) || children .iter_mut() .any(|c| !matches!(c.try_wait(), Ok(None))) @@ -517,13 +600,19 @@ async fn supervise(mut children: Vec, mut indicator: Child, cancel: Arc Result<(), Box> { let backend = Backend::default(); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? + .serve_at( + "/org/ferese/ScreenRecorder", + RecorderControl(backend.clone()), + )? .build() .await?; // Frontend death must revoke every stream, even if Session.Close never arrives. @@ -557,6 +646,22 @@ pub async fn run() -> Result<(), Box> { mod tests { use super::*; #[test] + fn bar_control_claims_are_bound_to_the_recorder_connection() { + assert!(owns_session( + ":1.42", + "/org/freedesktop/portal/desktop/session/1_42/record" + )); + assert!(!owns_session( + ":1.4", + "/org/freedesktop/portal/desktop/session/1_42/record" + )); + assert!(!owns_session( + ":1.43", + "/org/freedesktop/portal/desktop/session/1_42/record" + )); + assert!(!shell_recorder(std::process::id())); + } + #[test] fn rejects_unimplemented_sources_and_cursor_modes() { assert_eq!(source_options(&Options::new()).unwrap(), (false, false)); for bits in [0u32, 2, 3, 4] { diff --git a/portal/src/picker.rs b/portal/src/picker.rs index f21f4c1..8b58e65 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -27,6 +27,8 @@ struct Picker { core: Core, prompt: Prompt, selected: Vec, + background: cosmic::iced::Color, + foreground: cosmic::iced::Color, } pub fn run() -> Result<(), Box> { @@ -40,7 +42,7 @@ pub fn run() -> Result<(), Box> { return Err("No shareable displays".into()); } - let (theme, font) = appearance(); + let (theme, font, background, foreground) = appearance(); let height = 156. + (prompt.sources.len().min(4) as f32 * 56.); cosmic::app::run::( Settings::default() @@ -49,7 +51,7 @@ pub fn run() -> Result<(), Box> { .default_font(font) .default_text_size(14.) .is_daemon(false), - prompt, + (prompt, background, foreground), )?; Ok(()) @@ -57,7 +59,7 @@ pub fn run() -> Result<(), Box> { impl cosmic::Application for Picker { type Executor = cosmic::executor::Default; - type Flags = Prompt; + type Flags = (Prompt, cosmic::iced::Color, cosmic::iced::Color); type Message = Message; const APP_ID: &'static str = "dev.ferese.ScreenShare"; @@ -69,14 +71,20 @@ impl cosmic::Application for Picker { &mut self.core } - fn init(mut core: Core, prompt: Prompt) -> (Self, Task) { + fn init( + mut core: Core, + (prompt, background, foreground): Self::Flags, + ) -> (Self, Task) { core.window.show_headerbar = false; core.window.border_padding = Some(0); + core.window.content_container = false; // No preselected source: clicking Share must be a deliberate choice. let mut app = Self { core, prompt, selected: Vec::new(), + background, + foreground, }; let title = if app.prompt.indicator { "Ferese — screen sharing" @@ -122,6 +130,14 @@ impl cosmic::Application for Picker { Task::none() } + fn style(&self) -> Option { + Some(cosmic::iced::theme::Style { + background_color: self.background, + text_color: self.foreground, + icon_color: self.foreground, + }) + } + fn view(&self) -> Element<'_, Message> { let app = if self.prompt.app.is_empty() { "this application" @@ -234,7 +250,12 @@ fn glyph(source: &'static [u8], size: u16) -> icon::Icon { .size(size) } -fn appearance() -> (cosmic::Theme, cosmic::font::Font) { +fn appearance() -> ( + cosmic::Theme, + cosmic::font::Font, + cosmic::iced::Color, + cosmic::iced::Color, +) { let document = ferese_config::config_path() .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|s| ferese_config::Document::parse(&s).ok()); @@ -296,6 +317,15 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) { .corner_radii(corners) .build(); ferese_theme::apply(&mut native, foreground.into()); + let mut material: cosmic::iced::Color = background.into(); + material.a = material_opacity( + &string("theme.material.style", "solid"), + document + .as_ref() + .and_then(|d| d.get("theme.surface.popover.opacity")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.96), + ); let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); let family: &'static str = Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str()); @@ -306,5 +336,31 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) { family: cosmic::iced::font::Family::Name(family), ..cosmic::font::default() }, + material, + cosmic::iced::Color { + a: 1., + ..foreground.into() + }, ) } + +// Only the surface tint is translucent; control fills and foregrounds stay opaque. +fn material_opacity(style: &str, opacity: f64) -> f32 { + if style == "translucent" && opacity.is_finite() { + opacity.clamp(0., 1.) as f32 + } else { + 1. + } +} + +#[cfg(test)] +mod tests { + #[test] + fn material_preserves_solid_and_bounds_translucent_opacity() { + assert_eq!(super::material_opacity("solid", 0.4), 1.); + assert_eq!(super::material_opacity("translucent", 0.4), 0.4); + assert_eq!(super::material_opacity("translucent", 2.), 1.); + assert_eq!(super::material_opacity("translucent", -1.), 0.); + assert_eq!(super::material_opacity("translucent", f64::NAN), 1.); + } +} diff --git a/portal/src/recorder.rs b/portal/src/recorder.rs index f13745d..68141f6 100644 --- a/portal/src/recorder.rs +++ b/portal/src/recorder.rs @@ -136,6 +136,21 @@ pub async fn run() -> Result<(), String> { .map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; + // Older/foreign backends keep their normal sharing indicator. Only Ferese + // can verify this helper's identity and that its parent owns a bar stop button. + let _ = tokio::time::timeout(Duration::from_secs(3), async { + let control = Proxy::new( + &connection, + "org.freedesktop.impl.portal.desktop.ferese", + "/org/ferese/ScreenRecorder", + "org.ferese.ScreenRecorder", + ) + .await?; + control + .call::<_, _, ()>("UseBarControls", &(handle.clone(),)) + .await + }) + .await; let session = Proxy::new( &connection, DESKTOP, diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py index 79ad131..bbf1bcf 100644 --- a/scripts/tests/test_portal_isolated.py +++ b/scripts/tests/test_portal_isolated.py @@ -126,6 +126,12 @@ class PortalTest(unittest.TestCase): None, Gio.DBusCallFlags.NONE, 5000, None) def create(token): session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"] + # An ordinary portal client cannot suppress its stop indicator. + with self.assertRaises(GLib.Error) as denied: + connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder", + "org.ferese.ScreenRecorder", "UseBarControls", GLib.Variant("(o)", (session,)), + None, Gio.DBusCallFlags.NONE, 5000, None) + self.assertIn("AccessDenied", str(denied.exception)) request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)}))) return session def helpers(): diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 2dd94c7..c9507b5 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -146,10 +146,10 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button } else { p.card }; - let on = if selected { - ferese_theme::foreground(background, p.text) + let (background, on) = if selected { + ferese_theme::accent_pair(background, p.text) } else { - p.text + (background, p.text) }; button::Style { background: Some(Background::Color(background)), diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index e620477..0c8bcc2 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -341,7 +341,8 @@ impl FereseShell { palette.surface_popover }), ); - let badge_text = ferese_theme::foreground(badge_fill, color(palette.text_primary)); + let (badge_fill, badge_text) = + ferese_theme::accent_pair(badge_fill, color(palette.text_primary)); let badge = container( text(count.to_string()) .size(10)