diff --git a/Cargo.lock b/Cargo.lock index 6fe35f0..d276400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,6 +1717,7 @@ dependencies = [ "ferese-config", "ferese-ipc", "ferese-theme", + "ferese-theme-client", "image", "jiff", "libc", @@ -1736,6 +1737,7 @@ dependencies = [ "ferese-config", "ferese-ipc", "ferese-theme", + "ferese-theme-client", "glib", "libc", "libcosmic", @@ -1766,6 +1768,7 @@ dependencies = [ "ferese-config", "ferese-ipc", "ferese-theme", + "ferese-theme-client", "image", "libcosmic", "notify", @@ -1785,6 +1788,7 @@ dependencies = [ "ferese-ipc", "ferese-protocols", "ferese-theme", + "ferese-theme-client", "iced_accessibility", "jiff", "libc", @@ -1803,12 +1807,22 @@ name = "ferese-theme" version = "0.1.0" dependencies = [ "ferese-config", - "ferese-ipc", - "ferese-protocols", + "ferese-theme-model", "iced_accessibility", "jiff", "libcosmic", +] + +[[package]] +name = "ferese-theme-client" +version = "0.1.0" +dependencies = [ + "ferese-config", + "ferese-ipc", + "ferese-protocols", + "libcosmic", "serde_json", + "tempfile", "tokio", "wayland-client", ] @@ -7457,6 +7471,7 @@ dependencies = [ "ferese-ipc", "ferese-protocols", "ferese-theme", + "ferese-theme-client", "futures-util", "gstreamer", "image", diff --git a/Cargo.toml b/Cargo.toml index ba17ad2..dd66ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ferese-core", "crates/ferese-config", "crates/ferese-theme", + "crates/ferese-theme-client", "crates/ferese-theme-model", "crates/ferese-ipc", "crates/ferese-layout", diff --git a/crates/ferese-theme-client/Cargo.toml b/crates/ferese-theme-client/Cargo.toml new file mode 100644 index 0000000..ed6f2d6 --- /dev/null +++ b/crates/ferese-theme-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ferese-theme-client" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ferese-config = { path = "../ferese-config" } +ferese-ipc = { path = "../ferese-ipc" } +ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] } +libcosmic.workspace = true +wayland-client.workspace = true +tokio = { version = "1", features = ["rt", "sync", "time"] } + +[dev-dependencies] +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/ferese-theme-client/src/lib.rs b/crates/ferese-theme-client/src/lib.rs new file mode 100644 index 0000000..b5360ab --- /dev/null +++ b/crates/ferese-theme-client/src/lib.rs @@ -0,0 +1,4 @@ +//! Theme subscriptions and window-scoped material bindings for Ferese clients. +//! Applications own subscription cancellation and keep bindings within window lifetimes. +pub mod material; +pub mod service; diff --git a/crates/ferese-theme/src/material.rs b/crates/ferese-theme-client/src/material.rs similarity index 58% rename from crates/ferese-theme/src/material.rs rename to crates/ferese-theme-client/src/material.rs index 549b38b..c54be1b 100644 --- a/crates/ferese-theme/src/material.rs +++ b/crates/ferese-theme-client/src/material.rs @@ -111,3 +111,65 @@ impl Dispatch for MaterialState { } delegate_noop!(MaterialState: ignore FereseMaterialManagerV1); + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::window::raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, + WindowHandle, + }; + use std::ptr::NonNull; + use wayland_client::protocol::wl_compositor; + + // Supplies the same borrowed Wayland handles used by Iced, backed by a real client. + struct Window { + connection: Connection, + surface: wl_surface::WlSurface, + } + impl HasDisplayHandle for Window { + fn display_handle(&self) -> Result, HandleError> { + let pointer = NonNull::new(self.connection.backend().display_ptr().cast()).unwrap(); + // The connection outlives the returned handle. + Ok(unsafe { DisplayHandle::borrow_raw(WaylandDisplayHandle::new(pointer).into()) }) + } + } + impl HasWindowHandle for Window { + fn window_handle(&self) -> Result, HandleError> { + let pointer = NonNull::new(self.surface.id().as_ptr().cast()).unwrap(); + // The surface outlives the returned handle. + Ok(unsafe { WindowHandle::borrow_raw(WaylandWindowHandle::new(pointer).into()) }) + } + } + delegate_noop!(MaterialState: ignore wl_compositor::WlCompositor); + delegate_noop!(MaterialState: ignore wl_surface::WlSurface); + + #[test] + #[ignore = "requires a private nested Ferese compositor"] + fn attachment_acknowledgement_and_last_clone_release() { + assert_eq!(std::env::var("FERESE_TEST_MATERIAL").as_deref(), Ok("1")); + let connection = Connection::connect_to_env().unwrap(); + let (globals, mut queue) = registry_queue_init::(&connection).unwrap(); + let qh = queue.handle(); + let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 1..=6, ()).unwrap(); + let window = Window { + surface: compositor.create_surface(&qh, ()), + connection, + }; + let binding = ModalMaterial::attach(&window).expect("compositor acknowledges attachment"); + let retained = binding.clone(); + let weak = Arc::downgrade(&binding._binding); + drop(binding); + assert!(weak.upgrade().is_some()); + drop(retained); + assert!(weak.upgrade().is_none()); + queue.roundtrip(&mut MaterialState::default()).unwrap(); + // The compositor rejects duplicate materials. Reattachment proves the final + // drop destroyed the first material while preserving the borrowed display. + let replacement = ModalMaterial::attach(&window).expect("last clone released the surface material"); + drop(replacement); + queue.roundtrip(&mut MaterialState::default()).unwrap(); + window.surface.destroy(); + queue.roundtrip(&mut MaterialState::default()).unwrap(); + } +} diff --git a/crates/ferese-theme-client/src/service.rs b/crates/ferese-theme-client/src/service.rs new file mode 100644 index 0000000..34ec09d --- /dev/null +++ b/crates/ferese-theme-client/src/service.rs @@ -0,0 +1,154 @@ +use cosmic::iced::futures::SinkExt; +use ferese_ipc::theme::Snapshot; + +pub fn current() -> Snapshot { + ferese_ipc::theme::Connection::connect() + .ok() + .and_then(|mut connection| connection.get(ferese_config::families::builtins).ok()) + .unwrap_or_else(fallback) +} + +/// Standalone appearance; it does not represent compositor acceptance. +pub fn fallback() -> Snapshot { + let theme = ferese_config::theme::default_theme(); + Snapshot { + version: ferese_ipc::theme::SCHEMA_VERSION, + revision: 0, + mode: ferese_config::theme::Mode::Dark, + presented: theme.clone(), + theme, + warnings: Vec::new(), + error: None, + families: ferese_config::families::builtins(), + fallback_note: None, + } +} + +pub fn subscription() -> cosmic::iced::Subscription { + cosmic::iced::Subscription::run(stream) +} + +fn stream() -> impl cosmic::iced::futures::Stream { + cosmic::iced::stream::channel(1, async |mut output| { + loop { + let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; + let Ok(Ok(mut connection)) = connection else { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + }; + let Ok(cancellation) = connection.cancellation() else { + return; + }; + let (send, mut receive) = tokio::sync::watch::channel(None); + tokio::task::spawn_blocking(move || { + let Ok(mut snapshot) = connection.get(ferese_config::families::builtins) else { + return; + }; + loop { + let revision = snapshot.revision; + if send.send(Some(snapshot)).is_err() { + return; + } + match connection.watch(revision, ferese_config::families::builtins) { + Ok(next) => snapshot = next, + Err(_) => return, + } + } + }); + while receive.changed().await.is_ok() { + let snapshot = receive.borrow_and_update().clone(); + if let Some(snapshot) = snapshot + && output.send(snapshot).await.is_err() + { + return; + } + } + drop(cancellation); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::futures::StreamExt; + use ferese_ipc::{Request, Response, read_frame, write_frame}; + use std::os::unix::net::UnixListener; + use std::time::Duration; + + #[test] + fn subscription_reconnects_and_drop_cancels_a_pending_watch() { + const CHILD: &str = "FERESE_THEME_SUBSCRIPTION_TEST"; + if std::env::var_os(CHILD).is_none() { + let root = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "service::tests::subscription_reconnects_and_drop_cancels_a_pending_watch", + "--nocapture", + ]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", root.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + // No compositor: return the same standalone values without connecting elsewhere. + assert_eq!(current(), fallback()); + let path = ferese_ipc::theme::socket_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let listener = UnixListener::bind(path).unwrap(); + let (watch_send, watch_receive) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + for revision in [1, 2] { + let (mut connection, _) = listener.accept().unwrap(); + connection.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let get: Request = read_frame(&mut connection).unwrap(); + assert_eq!(get.command, "theme-get"); + let mut snapshot = fallback(); + snapshot.revision = revision; + write_frame( + &mut connection, + &Response::success(get.id, serde_json::to_value(snapshot).unwrap()), + ) + .unwrap(); + let watch: Request = read_frame(&mut connection).unwrap(); + assert_eq!(watch.command, "theme-watch"); + assert_eq!(watch.args["since"], revision); + if revision == 2 { + watch_send.send(()).unwrap(); + use std::io::Read; + // EOF, not the read deadline: dropping the stream must shut down the watch. + assert_eq!(connection.read(&mut [0]).unwrap(), 0); + } + // First connection closes while watched; the stream must reconnect. + } + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + let mut subscription = Box::pin(stream()); + for revision in [1, 2] { + let snapshot = tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await + .unwrap() + .unwrap(); + assert_eq!(snapshot.revision, revision); + } + tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(5)).unwrap()) + .await + .unwrap(); + drop(subscription); + }); + server.join().unwrap(); + } +} diff --git a/crates/ferese-theme/tests/fixtures/theme-v2.json b/crates/ferese-theme-client/tests/fixtures/theme-v2.json similarity index 100% rename from crates/ferese-theme/tests/fixtures/theme-v2.json rename to crates/ferese-theme-client/tests/fixtures/theme-v2.json diff --git a/crates/ferese-theme/tests/theme_wire.rs b/crates/ferese-theme-client/tests/theme_wire.rs similarity index 93% rename from crates/ferese-theme/tests/theme_wire.rs rename to crates/ferese-theme-client/tests/theme_wire.rs index a50661c..4d3a508 100644 --- a/crates/ferese-theme/tests/theme_wire.rs +++ b/crates/ferese-theme-client/tests/theme_wire.rs @@ -1,5 +1,5 @@ use ferese_ipc::theme::Snapshot; -use ferese_theme::service::fallback; +use ferese_theme_client::service::fallback; use serde_json::{Value, json}; fn fixture() -> Value { @@ -59,5 +59,8 @@ fn complete_snapshots_do_not_resolve_fallbacks_and_future_versions_are_rejected( assert_eq!(serde_json::to_value(snapshot).unwrap(), value); let mut future = value; future["version"] = json!(ferese_ipc::theme::SCHEMA_VERSION + 1); - assert_eq!(Snapshot::decode(future, Vec::new).unwrap_err(), "Unsupported theme snapshot version"); + assert_eq!( + Snapshot::decode(future, Vec::new).unwrap_err(), + "Unsupported theme snapshot version" + ); } diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml index 2fe753a..ae71748 100644 --- a/crates/ferese-theme/Cargo.toml +++ b/crates/ferese-theme/Cargo.toml @@ -7,12 +7,10 @@ rust-version.workspace = true [dependencies] jiff.workspace = true -ferese-config = { path = "../ferese-config" } +ferese-theme-model = { path = "../ferese-theme-model" } libcosmic = { workspace = true, features = ["a11y"] } -ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] } -wayland-client.workspace = true -ferese-ipc = { path = "../ferese-ipc" } -serde_json.workspace = true -tokio = { version = "1", features = ["rt", "sync", "time"] } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } + +[dev-dependencies] +ferese-config = { path = "../ferese-config" } diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index 9c599e0..a09f040 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -355,7 +355,7 @@ mod tests { use cosmic::widget::button::Catalog; let palette = Palette { - appearance: ferese_config::theme::Appearance::Dark, + appearance: ferese_theme_model::Appearance::Dark, high_contrast: false, background: Color::from_rgb8(17, 24, 33), sidebar: Color::from_rgb8(17, 24, 33), @@ -387,8 +387,8 @@ mod tests { #[test] fn switch_thumb_remains_readable_in_every_preset_and_state() { - for preset in crate::PRESETS { - let resolved = ferese_config::theme::ResolvedTheme { + for preset in ferese_config::presets::PRESETS { + let resolved = ferese_theme_model::ResolvedTheme { appearance: preset.appearance, tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(), ..ferese_config::theme::default_theme() diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs index 3a5d983..f7b830e 100644 --- a/crates/ferese-theme/src/gallery.rs +++ b/crates/ferese-theme/src/gallery.rs @@ -2,8 +2,8 @@ use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree}; use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer}; use cosmic::iced::{Event, Length, Rectangle, Size, keyboard}; use cosmic::{Element, Renderer, Theme}; -use ferese_config::families::{Family, Palette}; -use ferese_config::theme::Appearance; +use ferese_theme_model::Appearance; +use ferese_theme_model::families::{Family, Palette}; use iced_accessibility::{A11yTree, accesskit}; pub struct TileOptions { @@ -184,9 +184,9 @@ pub fn svg(family: &Family, variant: Option, active: Option"#, p.accent)); diff --git a/crates/ferese-theme/src/lib.rs b/crates/ferese-theme/src/lib.rs index 09ca02d..c581d73 100644 --- a/crates/ferese-theme/src/lib.rs +++ b/crates/ferese-theme/src/lib.rs @@ -1,4 +1,4 @@ -//! Shared colors, typography, controls and compositor materials for Ferese applications. +//! Rendering helpers for supplied theme values: colors, typography and controls. mod button; pub mod calendar; mod contrast; @@ -6,16 +6,12 @@ pub mod controls; pub mod gallery; mod geometry; pub mod icons; -pub mod material; pub mod menus; mod palette; -mod presets; -pub mod service; mod typography; pub use button::accent_button; pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance}; pub use geometry::inner_radius; -pub use palette::{Palette, mix, parse_color, surface_shade}; -pub use presets::{PRESETS, Preset}; +pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade}; pub use typography::{font, text}; diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 5af2db3..63209ff 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -2,7 +2,7 @@ use cosmic::iced::Color; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Palette { - pub appearance: ferese_config::theme::Appearance, + pub appearance: ferese_theme_model::Appearance, pub high_contrast: bool, pub background: Color, pub sidebar: Color, @@ -16,7 +16,7 @@ pub struct Palette { } impl Palette { - pub fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self { + pub fn from_resolved(theme: &ferese_theme_model::ResolvedTheme) -> Self { let color = |value: &str| parse_color(value).expect("validated resolved theme color"); Self { appearance: theme.appearance, @@ -46,7 +46,7 @@ impl Palette { pub fn native_theme(self) -> cosmic::Theme { let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.); use cosmic::cosmic_theme::ThemeBuilder; - use ferese_config::theme::Appearance; + use ferese_theme_model::Appearance; let builder = match (self.appearance, self.high_contrast) { (Appearance::Light, false) => ThemeBuilder::light(), (Appearance::Dark, false) => ThemeBuilder::dark(), @@ -109,6 +109,14 @@ pub fn surface_shade(base: Color) -> Color { } } +pub fn material_opacity(theme: &ferese_theme_model::ResolvedTheme) -> f32 { + if theme.tokens.material.style == "translucent" { + theme.tokens.material.opacity as f32 + } else { + 1. + } +} + #[cfg(test)] mod tests { use super::*; @@ -116,11 +124,11 @@ mod tests { #[test] fn native_theme_preserves_the_resolved_accessibility_mode() { for appearance in [ - ferese_config::theme::Appearance::Light, - ferese_config::theme::Appearance::Dark, + ferese_theme_model::Appearance::Light, + ferese_theme_model::Appearance::Dark, ] { for high_contrast in [false, true] { - let mut resolved = ferese_config::theme::ResolvedTheme { + let mut resolved = ferese_theme_model::ResolvedTheme { appearance, ..ferese_config::theme::default_theme() }; @@ -134,8 +142,8 @@ mod tests { #[test] fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() { - for preset in crate::PRESETS { - let mut resolved = ferese_config::theme::ResolvedTheme { + for preset in ferese_config::presets::PRESETS { + let mut resolved = ferese_theme_model::ResolvedTheme { appearance: preset.appearance, tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(), ..ferese_config::theme::default_theme() @@ -145,7 +153,7 @@ mod tests { resolved.tokens.geometry.shell_radius = 0.; let palette = Palette::from_resolved(&resolved); let native = palette.native_theme(); - assert_eq!(crate::service::opacity(&resolved), 0.25); + assert_eq!(crate::material_opacity(&resolved), 0.25); assert_eq!(native.cosmic().primary(false).base.alpha, 1.); assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]); for component in [&native.cosmic().accent, &native.cosmic().accent_button] { @@ -171,7 +179,7 @@ mod tests { let mut theme = ferese_config::theme::default_theme(); theme.tokens.material.style = style.into(); theme.tokens.material.opacity = opacity; - assert_eq!(crate::service::opacity(&theme), expected); + assert_eq!(crate::material_opacity(&theme), expected); } } } diff --git a/crates/ferese-theme/src/presets.rs b/crates/ferese-theme/src/presets.rs deleted file mode 100644 index 989760e..0000000 --- a/crates/ferese-theme/src/presets.rs +++ /dev/null @@ -1 +0,0 @@ -pub use ferese_config::presets::{PRESETS, Preset}; diff --git a/crates/ferese-theme/src/service.rs b/crates/ferese-theme/src/service.rs deleted file mode 100644 index 4561e98..0000000 --- a/crates/ferese-theme/src/service.rs +++ /dev/null @@ -1,78 +0,0 @@ -use cosmic::iced::futures::SinkExt; -use ferese_ipc::theme::Snapshot; - -pub fn current() -> Snapshot { - ferese_ipc::theme::Connection::connect() - .ok() - .and_then(|mut connection| connection.get(ferese_config::families::builtins).ok()) - .unwrap_or_else(fallback) -} - -/// Standalone appearance; it does not represent compositor acceptance. -pub fn fallback() -> Snapshot { - let theme = ferese_config::theme::default_theme(); - Snapshot { - version: ferese_ipc::theme::SCHEMA_VERSION, - revision: 0, - mode: ferese_config::theme::Mode::Dark, - presented: theme.clone(), - theme, - warnings: Vec::new(), - error: None, - families: ferese_config::families::builtins(), - fallback_note: None, - } -} - -pub fn opacity(theme: &ferese_config::theme::ResolvedTheme) -> f32 { - if theme.tokens.material.style == "translucent" { - theme.tokens.material.opacity as f32 - } else { - 1. - } -} - -pub fn subscription() -> cosmic::iced::Subscription { - cosmic::iced::Subscription::run(stream) -} - -fn stream() -> impl cosmic::iced::futures::Stream { - cosmic::iced::stream::channel(1, async |mut output| { - loop { - let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; - let Ok(Ok(mut connection)) = connection else { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - continue; - }; - let Ok(cancellation) = connection.cancellation() else { - return; - }; - let (send, mut receive) = tokio::sync::watch::channel(None); - tokio::task::spawn_blocking(move || { - let Ok(mut snapshot) = connection.get(ferese_config::families::builtins) else { - return; - }; - loop { - let revision = snapshot.revision; - if send.send(Some(snapshot)).is_err() { - return; - } - match connection.watch(revision, ferese_config::families::builtins) { - Ok(next) => snapshot = next, - Err(_) => return, - } - } - }); - while receive.changed().await.is_ok() { - let snapshot = receive.borrow_and_update().clone(); - if let Some(snapshot) = snapshot - && output.send(snapshot).await.is_err() - { - return; - } - } - drop(cancellation); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - }) -} diff --git a/portal/Cargo.toml b/portal/Cargo.toml index 0f33b49..c9c9ded 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -14,6 +14,7 @@ name = "ferese-record" path = "src/record_main.rs" [dependencies] +ferese-theme-client = { path = "../crates/ferese-theme-client" } reis = { version = "0.7.1", features = ["calloop"] } calloop.workspace = true ferese-protocols = { path = "../crates/ferese-protocols", default-features = false, features = ["client"] } diff --git a/portal/src/consent.rs b/portal/src/consent.rs index cc12700..88c1bb3 100644 --- a/portal/src/consent.rs +++ b/portal/src/consent.rs @@ -5,8 +5,8 @@ use cosmic::Element; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::{Alignment, Length}; use cosmic::widget::{button, column, container, image, row, scrollable, text_input}; -use ferese_theme::material::ModalMaterial; use ferese_theme::{Palette, accent_button, controls, text}; +use ferese_theme_client::material::ModalMaterial; use serde::{Deserialize, Serialize}; #[derive(Clone, Serialize, Deserialize)] @@ -101,7 +101,7 @@ impl cosmic::Application for Consent { prompt, material: None, parent: None, - palette: Palette::from_resolved(&ferese_theme::service::current().presented), + palette: Palette::from_resolved(&ferese_theme_client::service::current().presented), font, background, }; @@ -110,7 +110,7 @@ impl cosmic::Application for Consent { fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::batch([ - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)), cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { @@ -128,7 +128,7 @@ impl cosmic::Application for Consent { self.palette = Palette::from_resolved(&snapshot.presented).flat(); self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family)); self.background = cosmic::iced::Color { - a: ferese_theme::service::opacity(&snapshot.presented), + a: ferese_theme::material_opacity(&snapshot.presented), ..self.palette.sidebar }; return cosmic::command::set_theme(self.palette.native_theme()); diff --git a/portal/src/parent.rs b/portal/src/parent.rs index 9da8f42..81935de 100644 --- a/portal/src/parent.rs +++ b/portal/src/parent.rs @@ -1,7 +1,7 @@ pub(crate) type Attachment = Result< ( Option>, - Result, + Result, ), String, >; diff --git a/portal/src/picker.rs b/portal/src/picker.rs index bf371a4..ab42448 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -63,7 +63,7 @@ enum Message { struct Picker { core: Core, - material: Option, + material: Option, parent: Option>, prompt: Prompt, selected: Vec, @@ -153,7 +153,7 @@ impl cosmic::Application for Picker { fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::batch([ - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { Some(Message::WindowOpened(id)) @@ -169,7 +169,7 @@ impl cosmic::Application for Picker { self.palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat(); self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family)); self.background = cosmic::iced::Color { - a: ferese_theme::service::opacity(&snapshot.presented), + a: ferese_theme::material_opacity(&snapshot.presented), ..self.palette.sidebar }; return cosmic::command::set_theme(self.palette.native_theme()); @@ -178,7 +178,7 @@ impl cosmic::Application for Picker { let parent = self.prompt.parent.clone(); return cosmic::iced::window::run(id, move |window| { let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new); - Ok((parent, ferese_theme::material::ModalMaterial::attach(window))) + Ok((parent, ferese_theme_client::material::ModalMaterial::attach(window))) }) .map(|result| cosmic::Action::App(Message::Attached(result))); } @@ -387,14 +387,14 @@ pub(crate) fn appearance() -> ( cosmic::iced::Color, ferese_theme::Palette, ) { - let snapshot = ferese_theme::service::current(); + let snapshot = ferese_theme_client::service::current(); let palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat(); let family = &snapshot.presented.tokens.typography.font_family; ( palette.native_theme(), ferese_theme::font(Some(family)), cosmic::iced::Color { - a: ferese_theme::service::opacity(&snapshot.presented), + a: ferese_theme::material_opacity(&snapshot.presented), ..palette.sidebar }, palette, diff --git a/scripts/tests/test_component_boundaries.py b/scripts/tests/test_component_boundaries.py index d270a43..f91f25a 100644 --- a/scripts/tests/test_component_boundaries.py +++ b/scripts/tests/test_component_boundaries.py @@ -6,15 +6,24 @@ import unittest ROOT = pathlib.Path(__file__).resolve().parents[2] +def production_dependencies(manifest, workspace_dependencies): + dependencies = set() + for section in [manifest, *manifest.get("target", {}).values()]: + for kind in ("dependencies", "build-dependencies"): + for alias, specification in section.get(kind, {}).items(): + if isinstance(specification, dict) and specification.get("workspace"): + specification = workspace_dependencies[alias] + name = specification.get("package", alias) if isinstance(specification, dict) else alias + dependencies.add(name) + return dependencies + + def dependency_graph(): - workspace = tomllib.loads((ROOT / "Cargo.toml").read_text()) + workspace = tomllib.loads((ROOT / "Cargo.toml").read_text())["workspace"] graph = {} - for member in workspace["workspace"]["members"]: + for member in workspace["members"]: manifest = tomllib.loads((ROOT / member / "Cargo.toml").read_text()) - dependencies = set(manifest.get("dependencies", {})) - for target in manifest.get("target", {}).values(): - dependencies.update(target.get("dependencies", {})) - graph[manifest["package"]["name"]] = dependencies + graph[manifest["package"]["name"]] = production_dependencies(manifest, workspace.get("dependencies", {})) return graph @@ -43,6 +52,26 @@ class ComponentBoundaries(unittest.TestCase): def test_ipc_does_not_depend_on_configuration(self): self.assertNotIn("ferese-config", reachable(dependency_graph(), "ferese-ipc")) + def test_rendering_does_not_load_configuration_or_own_clients(self): + dependencies = reachable(dependency_graph(), "ferese-theme") + self.assertTrue(dependencies.isdisjoint({ + "ferese-config", "ferese-ipc", "ferese-theme-client", "ferese-protocols", + }), dependencies) + + def test_client_integration_does_not_depend_on_rendering_or_core(self): + dependencies = reachable(dependency_graph(), "ferese-theme-client") + self.assertTrue(dependencies.isdisjoint({"ferese-theme", "ferese-core"}), dependencies) + + def test_aliases_build_dependencies_and_target_dependencies_are_checked(self): + manifest = { + "dependencies": {"alias": {"workspace": True}}, + "build-dependencies": {"builder": {"package": "ferese-config"}}, + "target": {"cfg(unix)": {"dependencies": {"client": {"package": "ferese-theme-client"}}}}, + "dev-dependencies": {"ferese-theme": {}}, + } + self.assertEqual(production_dependencies(manifest, {"alias": {"package": "ferese-core"}}), + {"ferese-core", "ferese-config", "ferese-theme-client"}) + def test_transitive_dependencies_are_checked(self): self.assertIn("core", reachable({"shell": {"helper"}, "helper": {"core"}}, "shell")) diff --git a/scripts/tests/test_material_isolated.py b/scripts/tests/test_material_isolated.py new file mode 100644 index 0000000..371bef5 --- /dev/null +++ b/scripts/tests/test_material_isolated.py @@ -0,0 +1,71 @@ +"""Check client material lifetimes on a disposable nested compositor. + +FERESE_TEST_MATERIAL=1 python3 scripts/tests/test_material_isolated.py +Requires built debug compositor binaries, a Wayland host and dbus-daemon. +""" +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_MATERIAL") == "1", "requires a Wayland host") +class MaterialLifetime(unittest.TestCase): + def test_attachment_and_release(self): + with tempfile.TemporaryDirectory(prefix="ferese-material-") as directory: + root = Path(directory) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text("") + host = Path(os.environ["WAYLAND_DISPLAY"]) + if not host.is_absolute(): + host = Path(os.environ["XDG_RUNTIME_DIR"]) / host + env = dict(os.environ, WAYLAND_DISPLAY=str(host), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config"), XDG_DATA_HOME=str(root / "data"), + XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache")) + children = [] + with (root / "compositor.log").open("w") as log: + try: + bus = subprocess.Popen(["dbus-daemon", "--session", "--nofork", "--print-address=1"], + env=env, stdout=subprocess.PIPE, text=True, start_new_session=True) + children.append(bus) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + compositor = subprocess.Popen([str(ROOT / "target/debug/ferese"), "--backend", "nested"], + env=env, stdout=log, stderr=log, start_new_session=True) + children.append(compositor) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + self.assertIsNone(compositor.poll(), (root / "compositor.log").read_text()) + sockets = [p for p in runtime.glob("wayland-*") if p.is_socket()] + if sockets and (runtime / "ferese/control.sock").is_socket(): + break + time.sleep(.05) + else: + self.fail("Nested compositor did not become ready") + env["WAYLAND_DISPLAY"] = str(sockets[0]) + result = subprocess.run([ + "cargo", "test", "--workspace", "--locked", + "material::tests::attachment_acknowledgement_and_last_clone_release", + "--", "--ignored", "--exact", + ], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("1 passed", result.stdout) + self.assertIsNone(compositor.poll()) + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + child.wait(timeout=5) + if child.stdout: + child.stdout.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-lock/Cargo.toml b/shell/ferese-lock/Cargo.toml index 7a9a551..ad0d899 100644 --- a/shell/ferese-lock/Cargo.toml +++ b/shell/ferese-lock/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-ipc = { path = "../../crates/ferese-ipc" } serde_json.workspace = true ferese-theme = { path = "../../crates/ferese-theme" } diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index 42fc915..09891f0 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -29,7 +29,7 @@ impl Appearance { let doc = path .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|source| Document::parse(&source).ok()); - let mut theme = ferese_theme::service::current().presented; + let mut theme = ferese_theme_client::service::current().presented; if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path)) && let Some(document) = &doc && let Ok(mut connection) = ferese_ipc::theme::Connection::connect() diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index 8f3742e..9a12d8c 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -192,7 +192,7 @@ impl cosmic::Application for Locker { if self.preview { Subscription::none() } else { - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))) + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))) }, event::listen_with(|event, _, _| match event { Event::PlatformSpecific(PlatformSpecific::Wayland( diff --git a/shell/ferese-polkit-agent/Cargo.toml b/shell/ferese-polkit-agent/Cargo.toml index f4148c6..a12efd9 100644 --- a/shell/ferese-polkit-agent/Cargo.toml +++ b/shell/ferese-polkit-agent/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-config = { path = "../../crates/ferese-config" } ferese-theme = { path = "../../crates/ferese-theme" } diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 5b1f35f..6658e57 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -26,7 +26,7 @@ struct Appearance { impl Appearance { fn load() -> Self { - let snapshot = ferese_theme::service::current(); + let snapshot = ferese_theme_client::service::current(); Self::from_resolved(&snapshot.presented) } @@ -37,7 +37,7 @@ impl Appearance { appearance: theme.appearance, high_contrast: theme.accessibility.increase_contrast, surface: Color { - a: ferese_theme::service::opacity(theme), + a: ferese_theme::material_opacity(theme), ..palette.sidebar }, text: palette.text, @@ -74,7 +74,7 @@ impl Appearance { enum Message { ThemeChanged(Box), WindowOpened(cosmic::iced::window::Id), - MaterialAttached(Result), + MaterialAttached(Result), Tick, Input(String), Submit, @@ -91,7 +91,7 @@ impl std::fmt::Debug for Message { struct Prompt { core: Core, - material: Option, + material: Option, appearance: Appearance, incoming: Arc>>, request: AuthenticationRequest, @@ -302,7 +302,7 @@ impl cosmic::Application for Prompt { fn subscription(&self) -> Subscription { Subscription::batch([ - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick), cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { @@ -320,7 +320,7 @@ impl cosmic::Application for Prompt { return cosmic::command::set_theme(self.appearance.theme()); } Message::WindowOpened(id) => { - return cosmic::iced::window::run(id, ferese_theme::material::ModalMaterial::attach) + return cosmic::iced::window::run(id, ferese_theme_client::material::ModalMaterial::attach) .map(|result| cosmic::Action::App(Message::MaterialAttached(result))); } Message::MaterialAttached(result) => { diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index 61a956c..29f8d3a 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 6a46d95..b70b68a 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -214,7 +214,7 @@ impl cosmic::Application for App { .into(); let error = initial.as_ref().err().cloned(); let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); - let resolved = ferese_theme::service::current(); + let resolved = ferese_theme_client::service::current(); let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let mut app = Self { @@ -815,7 +815,7 @@ impl cosmic::Application for App { fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)), - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), if self.page == Page::Displays { cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays) } else { diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index a1d063a..42ac468 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -22,7 +22,7 @@ fn hex(c: Color) -> String { } pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme { - Palette::from_resolved(&ferese_theme::service::current().presented).native_theme() + Palette::from_resolved(&ferese_theme_client::service::current().presented).native_theme() } pub fn icon(page: Page, tint: Color) -> svg_icon::Icon { @@ -41,7 +41,7 @@ pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon { } #[cfg(test)] -pub use ferese_theme::PRESETS; +pub use ferese_config::presets::PRESETS; pub fn split(snapshot: &Snapshot) -> bool { snapshot diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index b0465dd..52ad02d 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index de22067..023728d 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -341,7 +341,7 @@ pub(crate) fn load() -> ShellConfig { pub(crate) fn parse_source(source: &str) -> Result { let document = ferese_config::Document::parse(source)?; - let snapshot = ferese_theme::service::current(); + let snapshot = ferese_theme_client::service::current(); let mut config = parse_document( &document.with_theme(&snapshot.presented), snapshot.presented.appearance, diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 0ed5d53..1eaa276 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -354,7 +354,7 @@ impl cosmic::Application for FereseShell { self.control.as_ref().map_or_else(Subscription::none, |control| { control.subscription().map(|_| Message::ControlReady) }), - ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), + ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), if self .system_modal .as_ref()