Align recording dialog behavior and shell theme styling

This commit is contained in:
Samuel Onoja committed 2026-09-28 09:15:13 +01:00
1 parent 24e15f76cb
commit 8832a9bf2e
9 files changed
+269 -32

No files matched your search

+119 -14
View File
@@ -73,12 +73,80 @@ struct Session {
phase: Phase,
multiple: bool,
cursor: bool,
bar_controlled: bool,
cancel: Arc<Cancel>,
}
#[derive(Clone, Default)]
pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
// 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<bool> {
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::<u32>().ok())
})
});
parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true))
}
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
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>, Child, Vec<Ready>), StartError> {
show_indicator: bool,
) -> Result<(Vec<Child>, Option<Child>, Vec<Ready>), 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<Child>, mut indicator: Child, cancel: Arc<Cancel>) {
async fn supervise(mut children: Vec<Child>, mut indicator: Option<Child>, cancel: Arc<Cancel>) {
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<Child>, mut indicator: Child, cancel: Arc<C
for child in &mut children {
let _ = child.kill().await;
}
let _ = indicator.kill().await;
if let Some(indicator) = &mut indicator {
let _ = indicator.kill().await;
}
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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] {
+61 -5
View File
@@ -27,6 +27,8 @@ struct Picker {
core: Core,
prompt: Prompt,
selected: Vec<usize>,
background: cosmic::iced::Color,
foreground: cosmic::iced::Color,
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
@@ -40,7 +42,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
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::<Picker>(
Settings::default()
@@ -49,7 +51,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
.default_font(font)
.default_text_size(14.)
.is_daemon(false),
prompt,
(prompt, background, foreground),
)?;
Ok(())
@@ -57,7 +59,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
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<Message>) {
fn init(
mut core: Core,
(prompt, background, foreground): Self::Flags,
) -> (Self, Task<Message>) {
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<cosmic::iced::theme::Style> {
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.);
}
}
+15
View File
@@ -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,