Add native appearance, screenshot and wallpaper portals
This commit is contained in:
1 parent
d8a23748c8
commit
915bc8afd6
20 files changed
+1575
-33
No files matched your search
+6
-3
@@ -14,6 +14,11 @@ name = "ferese-record"
|
||||
path = "src/record_main.rs"
|
||||
|
||||
[dependencies]
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
url = "2.5"
|
||||
png = "0.18"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
tempfile.workspace = true
|
||||
ferese-theme = { path = "../crates/ferese-theme" }
|
||||
gstreamer = "0.24"
|
||||
futures-util = "0.3"
|
||||
@@ -28,8 +33,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
|
||||
wayland-client.workspace = true
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
wayland-protocols-wlr.workspace = true
|
||||
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
+29
-19
@@ -25,8 +25,7 @@ use zbus::{
|
||||
object_server::SignalEmitter,
|
||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
type Options = HashMap<String, OwnedValue>;
|
||||
type Reply = (u32, Options);
|
||||
pub(crate) type Options = HashMap<String, OwnedValue>;
|
||||
#[derive(Debug)]
|
||||
enum StartError {
|
||||
Cancelled,
|
||||
@@ -45,16 +44,16 @@ impl From<&str> for StartError {
|
||||
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
#[derive(Default)]
|
||||
struct Cancel {
|
||||
stopped: AtomicBool,
|
||||
pub(crate) struct Cancel {
|
||||
pub(crate) stopped: AtomicBool,
|
||||
changed: Notify,
|
||||
}
|
||||
impl Cancel {
|
||||
fn stop(&self) {
|
||||
pub(crate) fn stop(&self) {
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
async fn wait(&self) {
|
||||
pub(crate) async fn wait(&self) {
|
||||
if !self.stopped.load(Ordering::SeqCst) {
|
||||
self.changed.notified().await;
|
||||
}
|
||||
@@ -147,7 +146,10 @@ fn shell_recorder(pid: u32) -> bool {
|
||||
parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true))
|
||||
}
|
||||
|
||||
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
|
||||
pub(crate) async fn authorize(
|
||||
connection: &Connection,
|
||||
header: &Header<'_>,
|
||||
) -> zbus::fdo::Result<String> {
|
||||
let sender = header
|
||||
.sender()
|
||||
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
|
||||
@@ -155,7 +157,7 @@ async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::R
|
||||
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
|
||||
if sender.as_str() != owner.as_str() {
|
||||
return Err(zbus::fdo::Error::AccessDenied(
|
||||
"Use the ScreenCast desktop portal".into(),
|
||||
"Use the desktop portal".into(),
|
||||
));
|
||||
}
|
||||
Ok(sender.to_string())
|
||||
@@ -230,7 +232,7 @@ impl Backend {
|
||||
_options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<Reply> {
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let owner = authorize(connection, &header).await?;
|
||||
if !session_handle
|
||||
.as_str()
|
||||
@@ -281,7 +283,7 @@ impl Backend {
|
||||
options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<Reply> {
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let owner = authorize(connection, &header).await?;
|
||||
let settings = source_options(&options);
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
@@ -318,7 +320,7 @@ impl Backend {
|
||||
_options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<Reply> {
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let owner = authorize(connection, &header).await?;
|
||||
if !handle
|
||||
.as_str()
|
||||
@@ -413,9 +415,9 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Request {
|
||||
cancel: Arc<Cancel>,
|
||||
owner: String,
|
||||
pub(crate) struct Request {
|
||||
pub(crate) cancel: Arc<Cancel>,
|
||||
pub(crate) owner: String,
|
||||
}
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
|
||||
impl Request {
|
||||
@@ -449,8 +451,12 @@ impl SessionObject {
|
||||
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
||||
}
|
||||
// All helpers die with the service, including a picker awaiting user input.
|
||||
fn child_command() -> Result<Command, String> {
|
||||
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
|
||||
pub(crate) fn child_command() -> Result<Command, String> {
|
||||
child_command_for(std::env::current_exe().map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
pub(crate) fn child_command_for(program: impl AsRef<std::ffi::OsStr>) -> Result<Command, String> {
|
||||
let mut command = Command::new(program);
|
||||
let parent = std::process::id() as libc::pid_t;
|
||||
// SAFETY: only async-signal-safe libc calls run between fork and exec.
|
||||
unsafe {
|
||||
@@ -585,21 +591,24 @@ async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
|
||||
}
|
||||
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let backend = Backend::default();
|
||||
let settings = crate::settings::Settings::new();
|
||||
let requests = crate::desktop::Requests::default();
|
||||
let connection = zbus::connection::Builder::session()?
|
||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||
.serve_at(PATH, backend.clone())?
|
||||
.serve_at(PATH, settings.clone())?
|
||||
.serve_at(PATH, crate::desktop::Screenshot(requests.clone()))?
|
||||
.serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))?
|
||||
.serve_at(
|
||||
"/org/ferese/ScreenRecorder",
|
||||
RecorderControl(backend.clone()),
|
||||
)?
|
||||
.build()
|
||||
.await?;
|
||||
tokio::spawn(settings.watch(connection.clone()));
|
||||
// Frontend death must revoke every stream, even if Session.Close never arrives.
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if backend.sessions.lock().await.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let owner = match zbus::fdo::DBusProxy::new(&connection).await {
|
||||
Ok(proxy) => proxy
|
||||
.get_name_owner(FRONTEND.try_into().unwrap())
|
||||
@@ -608,6 +617,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|name| name.to_string()),
|
||||
Err(_) => None,
|
||||
};
|
||||
requests.revoke_stale(owner.as_deref()).await;
|
||||
let stale: Vec<_> = backend
|
||||
.sessions
|
||||
.lock()
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
use cosmic::{
|
||||
Element,
|
||||
app::{Core, Settings, Task},
|
||||
iced::{Alignment, Length},
|
||||
widget::{button, column, container, image, row, scrollable},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct Prompt {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub accept: String,
|
||||
pub parent: String,
|
||||
pub image: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
Opened(cosmic::iced::window::Id),
|
||||
Attached(
|
||||
Result<
|
||||
(
|
||||
Option<std::sync::Arc<crate::parent::Parent>>,
|
||||
Result<ferese_theme::material::ModalMaterial, String>,
|
||||
),
|
||||
String,
|
||||
>,
|
||||
),
|
||||
Accept,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
struct Consent {
|
||||
core: Core,
|
||||
prompt: Prompt,
|
||||
material: Option<ferese_theme::material::ModalMaterial>,
|
||||
parent: Option<std::sync::Arc<crate::parent::Parent>>,
|
||||
palette: ferese_theme::Palette,
|
||||
font: cosmic::font::Font,
|
||||
background: cosmic::iced::Color,
|
||||
}
|
||||
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.take(64 * 1024)
|
||||
.read_to_string(&mut input)?;
|
||||
let prompt: Prompt = serde_json::from_str(&input)?;
|
||||
let (theme, font, background, _) = crate::picker::appearance();
|
||||
let height = if prompt.image.is_some() { 430. } else { 220. };
|
||||
cosmic::app::run::<Consent>(
|
||||
Settings::default()
|
||||
.size(cosmic::iced::Size::new(440., height))
|
||||
.client_decorations(false)
|
||||
.transparent(true)
|
||||
.theme(theme)
|
||||
.default_font(font)
|
||||
.default_text_size(14.)
|
||||
.is_daemon(false),
|
||||
(prompt, font, background),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl cosmic::Application for Consent {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (Prompt, cosmic::font::Font, cosmic::iced::Color);
|
||||
type Message = Message;
|
||||
const APP_ID: &'static str = "dev.ferese.PortalDialog";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
fn init(mut core: Core, (prompt, font, background): Self::Flags) -> (Self, Task<Message>) {
|
||||
core.window.show_headerbar = false;
|
||||
core.window.border_padding = Some(0);
|
||||
core.window.content_container = false;
|
||||
let app = Self {
|
||||
core,
|
||||
prompt,
|
||||
material: None,
|
||||
parent: None,
|
||||
palette: {
|
||||
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());
|
||||
ferese_theme::Palette::from_document(document.as_ref())
|
||||
},
|
||||
font,
|
||||
background,
|
||||
};
|
||||
(app, Task::none())
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
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 {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => Some(Message::Cancel),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Opened(id) => {
|
||||
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),
|
||||
))
|
||||
})
|
||||
.map(|result| cosmic::Action::App(Message::Attached(result)));
|
||||
}
|
||||
Message::Attached(Ok((parent, material))) => {
|
||||
self.parent = parent;
|
||||
self.material = material.ok();
|
||||
}
|
||||
Message::Attached(Err(error)) => {
|
||||
eprintln!("ferese portal: {error}");
|
||||
return cosmic::iced::exit();
|
||||
}
|
||||
Message::Accept => {
|
||||
println!("true");
|
||||
let _ = std::io::stdout().flush();
|
||||
return cosmic::iced::exit();
|
||||
}
|
||||
Message::Cancel => return cosmic::iced::exit(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn style(&self) -> Option<cosmic::iced::theme::Style> {
|
||||
Some(self.palette.application_style(if self.material.is_some() {
|
||||
cosmic::iced::Color::TRANSPARENT
|
||||
} else {
|
||||
self.background
|
||||
}))
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let text = |value: String| ferese_theme::text(value, self.font);
|
||||
let mut content = column![
|
||||
text(self.prompt.title.clone()).size(22),
|
||||
scrollable(text(self.prompt.description.clone())).height(Length::Fill)
|
||||
]
|
||||
.spacing(16);
|
||||
if let Some(path) = &self.prompt.image {
|
||||
content = content.push(
|
||||
image(image::Handle::from_path(path))
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.content_fit(cosmic::iced::ContentFit::Contain),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
row![
|
||||
button::custom(text("Cancel".into()))
|
||||
.class(cosmic::theme::Button::Text)
|
||||
.on_press(Message::Cancel),
|
||||
button::custom(text(self.prompt.accept.clone()))
|
||||
.class(ferese_theme::accent_button())
|
||||
.on_press(Message::Accept)
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
container(content).padding(24).width(Length::Fill).into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
use crate::{
|
||||
backend::{Cancel, Options, Request, authorize},
|
||||
consent::Prompt,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::Read,
|
||||
os::unix::{fs::PermissionsExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{Arc, atomic::Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
use zbus::{
|
||||
Connection,
|
||||
message::Header,
|
||||
zvariant::{OwnedObjectPath, Value},
|
||||
};
|
||||
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
type Reply = (u32, Options);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>);
|
||||
|
||||
impl Requests {
|
||||
pub(crate) async fn revoke_stale(&self, owner: Option<&str>) {
|
||||
for (request_owner, cancel) in self.0.lock().await.values() {
|
||||
if Some(request_owner.as_str()) != owner {
|
||||
cancel.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn begin(
|
||||
&self,
|
||||
connection: &Connection,
|
||||
header: &Header<'_>,
|
||||
handle: &OwnedObjectPath,
|
||||
) -> zbus::fdo::Result<Arc<Cancel>> {
|
||||
let owner = authorize(connection, header).await?;
|
||||
if !handle.as_str().starts_with(&format!("{PATH}/request/")) {
|
||||
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
|
||||
}
|
||||
let mut pending = self.0.lock().await;
|
||||
if pending.len() >= 8 || pending.contains_key(handle.as_str()) {
|
||||
return Err(zbus::fdo::Error::LimitsExceeded(
|
||||
"Request limit reached".into(),
|
||||
));
|
||||
}
|
||||
let cancel = Arc::new(Cancel::default());
|
||||
if !connection
|
||||
.object_server()
|
||||
.at(
|
||||
handle,
|
||||
Request {
|
||||
owner: owner.clone(),
|
||||
cancel: cancel.clone(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(zbus::fdo::Error::InvalidArgs(
|
||||
"Request already exists".into(),
|
||||
));
|
||||
}
|
||||
pending.insert(handle.to_string(), (owner, cancel.clone()));
|
||||
Ok(cancel)
|
||||
}
|
||||
|
||||
pub(crate) async fn end(&self, connection: &Connection, handle: &OwnedObjectPath) {
|
||||
self.0.lock().await.remove(handle.as_str());
|
||||
let _ = connection
|
||||
.object_server()
|
||||
.remove::<Request, _>(handle)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Screenshot(pub(crate) Requests);
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Screenshot")]
|
||||
impl Screenshot {
|
||||
#[zbus(property, name = "version")]
|
||||
fn version(&self) -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
#[zbus(property)]
|
||||
fn available_targets(&self) -> u32 {
|
||||
1 | 4
|
||||
}
|
||||
|
||||
async fn screenshot(
|
||||
&self,
|
||||
handle: OwnedObjectPath,
|
||||
app_id: String,
|
||||
parent_window: String,
|
||||
options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let cancel = self.0.begin(connection, &header, &handle).await?;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.wait() => (1, Options::new()),
|
||||
result = screenshot(&app_id, &parent_window, &options) => reply(result),
|
||||
};
|
||||
self.0.end(connection, &handle).await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn pick_color(
|
||||
&self,
|
||||
handle: OwnedObjectPath,
|
||||
app_id: String,
|
||||
parent_window: String,
|
||||
_options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let cancel = self.0.begin(connection, &header, &handle).await?;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.wait() => (1, Options::new()),
|
||||
result = pick_color(&app_id, &parent_window) => reply(result),
|
||||
};
|
||||
self.0.end(connection, &handle).await;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Wallpaper(pub(crate) Requests);
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Wallpaper")]
|
||||
impl Wallpaper {
|
||||
#[zbus(name = "SetWallpaperURI")]
|
||||
async fn set_wallpaper_uri(
|
||||
&self,
|
||||
handle: OwnedObjectPath,
|
||||
app_id: String,
|
||||
parent_window: String,
|
||||
uri: String,
|
||||
options: Options,
|
||||
#[zbus(connection)] connection: &Connection,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<u32> {
|
||||
let cancel = self.0.begin(connection, &header, &handle).await?;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.wait() => 1,
|
||||
result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0,
|
||||
};
|
||||
self.0.end(connection, &handle).await;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reply(result: Result<Option<Options>, String>) -> Reply {
|
||||
match result {
|
||||
Ok(Some(values)) => (0, values),
|
||||
Ok(None) => (1, Options::new()),
|
||||
Err(error) => {
|
||||
eprintln!("ferese portal: {error}");
|
||||
(2, Options::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn consent(
|
||||
app: &str,
|
||||
parent: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
accept: &str,
|
||||
image: Option<PathBuf>,
|
||||
) -> Result<bool, String> {
|
||||
if app.len() > 512 {
|
||||
return Err("Application ID is too long".into());
|
||||
}
|
||||
let prompt = Prompt {
|
||||
parent: parent.into(),
|
||||
title: title.into(),
|
||||
description: if app.is_empty() {
|
||||
description.into()
|
||||
} else {
|
||||
format!("{app}\n{description}")
|
||||
},
|
||||
accept: accept.into(),
|
||||
image,
|
||||
};
|
||||
let mut child = crate::backend::child_command()?
|
||||
.arg("--consent")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let pid = child.id().ok_or("Missing consent process ID")?;
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input
|
||||
.write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(input);
|
||||
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| "Consent timed out")?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if output.status.success() && output.stdout == b"true\n" {
|
||||
wait_for_surface_removal(pid).await?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ipc(
|
||||
command: &'static str,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.ok_or("Missing runtime directory")?
|
||||
.join("ferese/control.sock");
|
||||
let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(15)))
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let request = ferese_ipc::Request {
|
||||
version: ferese_ipc::VERSION,
|
||||
id: 1,
|
||||
kind: "command".into(),
|
||||
command: command.into(),
|
||||
args,
|
||||
};
|
||||
ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?;
|
||||
let response: ferese_ipc::Response =
|
||||
ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(error.message);
|
||||
}
|
||||
response.result.ok_or("Missing IPC result".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
async fn select_geometry(point: bool, windows: Option<String>) -> Result<Option<String>, String> {
|
||||
let mut command = crate::backend::child_command_for("slurp")?;
|
||||
if point {
|
||||
command.arg("-p");
|
||||
}
|
||||
if windows.is_some() {
|
||||
command.arg("-r");
|
||||
}
|
||||
command.stdin(if windows.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
});
|
||||
let mut child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let pid = child.id().ok_or("Missing selection process ID")?;
|
||||
if let Some(windows) = windows {
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input
|
||||
.write_all(windows.as_bytes())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(input);
|
||||
}
|
||||
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| "Selection timed out")?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let geometry = String::from_utf8(output.stdout)
|
||||
.map_err(|error| error.to_string())?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if geometry.len() > 128 || geometry.is_empty() {
|
||||
return Err("Invalid selection".into());
|
||||
}
|
||||
wait_for_surface_removal(pid).await?;
|
||||
Ok(Some(geometry))
|
||||
}
|
||||
|
||||
async fn wait_for_surface_removal(pid: u32) -> Result<(), String> {
|
||||
for _ in 0..100 {
|
||||
if ipc("has-client-surfaces", serde_json::json!({"pid": pid})).await? == false {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Err("Screenshot helper is still visible".into())
|
||||
}
|
||||
|
||||
async fn capture(geometry: Option<String>) -> Result<Vec<u8>, String> {
|
||||
let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?;
|
||||
let path = result["path"]
|
||||
.as_str()
|
||||
.ok_or("Missing screenshot path")?
|
||||
.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file = std::fs::File::open(&path).map_err(|error| error.to_string())?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_IMAGE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if bytes.len() as u64 > MAX_IMAGE_BYTES {
|
||||
return Err("Screenshot is too large".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option<Options>, String> {
|
||||
let target = options
|
||||
.get("target")
|
||||
.map(u32::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid screenshot target")?
|
||||
.unwrap_or(
|
||||
if options
|
||||
.get("interactive")
|
||||
.map(bool::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid interactive hint")?
|
||||
.unwrap_or(false)
|
||||
{
|
||||
4
|
||||
} else {
|
||||
1
|
||||
},
|
||||
);
|
||||
if !matches!(target, 1 | 4) {
|
||||
return Err("Unsupported screenshot target".into());
|
||||
}
|
||||
if !consent(
|
||||
app,
|
||||
parent,
|
||||
"Take a screenshot?",
|
||||
"Allow this app to capture the selected screen content.",
|
||||
"Continue",
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let geometry = match target {
|
||||
1 => None,
|
||||
4 => {
|
||||
let Some(geometry) = select_geometry(false, None).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Some(geometry)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let bytes = capture(geometry).await?;
|
||||
let directory = private_directory("screenshots")?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("screenshot-")
|
||||
.suffix(".png")
|
||||
.tempfile_in(directory)
|
||||
.map_err(|error| error.to_string())?;
|
||||
std::io::Write::write_all(&mut file, &bytes).map_err(|error| error.to_string())?;
|
||||
let (_, path) = file.keep().map_err(|error| error.to_string())?;
|
||||
Ok(Some(HashMap::from([(
|
||||
"uri".into(),
|
||||
Value::from(file_uri(&path)?)
|
||||
.try_to_owned()
|
||||
.map_err(|error| error.to_string())?,
|
||||
)])))
|
||||
}
|
||||
|
||||
async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String> {
|
||||
if !consent(
|
||||
app,
|
||||
parent,
|
||||
"Pick a screen color?",
|
||||
"Choose a pixel to share its color with this app.",
|
||||
"Choose color",
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(geometry) = select_geometry(true, None).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = capture(Some(geometry)).await?;
|
||||
let color = png_color(&bytes)?;
|
||||
Ok(Some(HashMap::from([(
|
||||
"color".into(),
|
||||
Value::from(color)
|
||||
.try_to_owned()
|
||||
.map_err(|error| error.to_string())?,
|
||||
)])))
|
||||
}
|
||||
|
||||
fn png_color(bytes: &[u8]) -> Result<(f64, f64, f64), String> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().map_err(|error| error.to_string())?;
|
||||
let size = reader
|
||||
.output_buffer_size()
|
||||
.filter(|size| *size <= 16 * 1024)
|
||||
.ok_or("Unexpected pixel dimensions")?;
|
||||
let mut pixels = vec![0; size];
|
||||
let info = reader
|
||||
.next_frame(&mut pixels)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if info.width == 0 || info.height == 0 {
|
||||
return Err("Empty image".into());
|
||||
}
|
||||
match info.color_type {
|
||||
png::ColorType::Rgb | png::ColorType::Rgba => Ok((
|
||||
pixels[0] as f64 / 255.,
|
||||
pixels[1] as f64 / 255.,
|
||||
pixels[2] as f64 / 255.,
|
||||
)),
|
||||
_ => Err("Unexpected screenshot pixel format".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn private_directory(name: &str) -> Result<PathBuf, String> {
|
||||
let directory = dirs::data_local_dir()
|
||||
.ok_or("Missing data directory")?
|
||||
.join("ferese/portal")
|
||||
.join(name);
|
||||
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
|
||||
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn file_uri(path: &Path) -> Result<String, String> {
|
||||
url::Url::from_file_path(path)
|
||||
.map(String::from)
|
||||
.map_err(|_| "Invalid file path".into())
|
||||
}
|
||||
|
||||
fn local_path(uri: &str) -> Result<PathBuf, String> {
|
||||
url::Url::parse(uri)
|
||||
.map_err(|error| error.to_string())?
|
||||
.to_file_path()
|
||||
.map_err(|_| "Wallpaper must be a local file URI".into())
|
||||
}
|
||||
|
||||
async fn wallpaper(
|
||||
app: &str,
|
||||
parent: &str,
|
||||
uri: &str,
|
||||
options: &Options,
|
||||
cancel: Arc<Cancel>,
|
||||
) -> Result<Option<Options>, String> {
|
||||
let target = options
|
||||
.get("set-on")
|
||||
.map(<&str>::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid wallpaper target")?
|
||||
.unwrap_or("background");
|
||||
if !matches!(target, "background" | "lockscreen" | "both") {
|
||||
return Err("Unsupported wallpaper target".into());
|
||||
}
|
||||
let input = local_path(uri)?;
|
||||
let preparing = cancel.clone();
|
||||
let file = tokio::task::spawn_blocking(move || prepare_wallpaper(&input, &preparing))
|
||||
.await
|
||||
.map_err(|error| error.to_string())??;
|
||||
if !consent(
|
||||
app,
|
||||
parent,
|
||||
"Change your wallpaper?",
|
||||
"Use this picture as your wallpaper.",
|
||||
"Set wallpaper",
|
||||
Some(file.path().to_path_buf()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if cancel.stopped.load(Ordering::SeqCst) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (_, path) = file.keep().map_err(|error| error.to_string())?;
|
||||
if let Err(error) = update_wallpaper(&path, target) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Some(Options::new()))
|
||||
}
|
||||
|
||||
fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTempFile, String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let input = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(input)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !input
|
||||
.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("Wallpaper must be a regular file".into());
|
||||
}
|
||||
let directory = private_directory("wallpapers")?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("wallpaper-")
|
||||
.tempfile_in(directory)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut input = input.take(MAX_IMAGE_BYTES + 1);
|
||||
let mut size = 0u64;
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
if cancel.stopped.load(Ordering::SeqCst) {
|
||||
return Err("Wallpaper request cancelled".into());
|
||||
}
|
||||
let bytes = input.read(&mut buffer).map_err(|error| error.to_string())?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
size += bytes as u64;
|
||||
if size > MAX_IMAGE_BYTES {
|
||||
return Err("Wallpaper is too large".into());
|
||||
}
|
||||
std::io::Write::write_all(&mut file, &buffer[..bytes])
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if size == 0 {
|
||||
return Err("Wallpaper is empty".into());
|
||||
}
|
||||
let reader = image::ImageReader::open(file.path())
|
||||
.map_err(|error| error.to_string())?
|
||||
.with_guessed_format()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let (width, height) = reader
|
||||
.into_dimensions()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > 16384
|
||||
|| height > 16384
|
||||
|| u64::from(width) * u64::from(height) > MAX_IMAGE_BYTES / 4
|
||||
{
|
||||
return Err("Wallpaper dimensions exceed the image limit".into());
|
||||
}
|
||||
let mut reader = image::ImageReader::open(file.path())
|
||||
.map_err(|error| error.to_string())?
|
||||
.with_guessed_format()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_IMAGE_BYTES);
|
||||
reader.limits(limits);
|
||||
reader.decode().map_err(|error| error.to_string())?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> {
|
||||
let path = ferese_config::config_path().ok_or("Missing config path")?;
|
||||
edit_wallpaper(&path, image, target)
|
||||
}
|
||||
|
||||
fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String> {
|
||||
let source = match std::fs::read_to_string(&path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let mut document =
|
||||
ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
|
||||
if target == "background" && document.get("theme.background.lock_path").is_none() {
|
||||
let old = document
|
||||
.get("theme.background.path")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(ferese_config::default_wallpaper())
|
||||
.to_owned();
|
||||
document
|
||||
.set("theme.background.lock_path", old.into())
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if target != "lockscreen" {
|
||||
document
|
||||
.set(
|
||||
"theme.background.path",
|
||||
image.to_string_lossy().into_owned().into(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if target != "background" {
|
||||
document
|
||||
.set(
|
||||
"theme.background.lock_path",
|
||||
image.to_string_lossy().into_owned().into(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let parent = path.parent().ok_or("Invalid config path")?;
|
||||
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?;
|
||||
std::io::Write::write_all(&mut temp, document.to_string().as_bytes())
|
||||
.map_err(|error| error.to_string())?;
|
||||
temp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if std::fs::read_to_string(&path).unwrap_or_default() != source {
|
||||
return Err("Configuration changed; please try again".into());
|
||||
}
|
||||
temp.persist(path).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_uris_preserve_special_characters_and_reject_network_paths() {
|
||||
let path = Path::new("/tmp/wallpaper #1_日本.png");
|
||||
assert_eq!(local_path(&file_uri(path).unwrap()).unwrap(), path);
|
||||
for uri in [
|
||||
"https://example.com/a.png",
|
||||
"file://example.com/a.png",
|
||||
"not a URI",
|
||||
] {
|
||||
assert!(local_path(uri).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_color_is_normalized_rgb() {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[255, 128, 0, 255])
|
||||
.unwrap();
|
||||
assert_eq!(png_color(&bytes).unwrap(), (1., 128. / 255., 0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallpaper_targets_preserve_independent_paths_and_other_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.kdl");
|
||||
std::fs::write(&path, "// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n").unwrap();
|
||||
edit_wallpaper(&path, Path::new("/desktop.png"), "background").unwrap();
|
||||
let document =
|
||||
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
document.get("theme.background.path").unwrap(),
|
||||
"/desktop.png"
|
||||
);
|
||||
assert_eq!(
|
||||
document.get("theme.background.lock_path").unwrap(),
|
||||
"/old.png"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.contains("Keep my settings")
|
||||
);
|
||||
edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen").unwrap();
|
||||
let document =
|
||||
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
document.get("theme.background.path").unwrap(),
|
||||
"/desktop.png"
|
||||
);
|
||||
assert_eq!(
|
||||
document.get("theme.background.lock_path").unwrap(),
|
||||
"/lock.png"
|
||||
);
|
||||
edit_wallpaper(&path, Path::new("/both.png"), "both").unwrap();
|
||||
let document =
|
||||
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(document.get("theme.background.path").unwrap(), "/both.png");
|
||||
assert_eq!(
|
||||
document.get("theme.background.lock_path").unwrap(),
|
||||
"/both.png"
|
||||
);
|
||||
assert_eq!(
|
||||
document.get("commands.terminal").unwrap(),
|
||||
&serde_json::json!(["foot"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallpaper_rejects_nonregular_files_and_cancellation_before_copy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(prepare_wallpaper(dir.path(), &Cancel::default()).is_err());
|
||||
let path = dir.path().join("image");
|
||||
std::fs::write(&path, [0u8; 32]).unwrap();
|
||||
let cancel = Cancel::default();
|
||||
cancel.stop();
|
||||
assert!(
|
||||
prepare_wallpaper(&path, &cancel)
|
||||
.unwrap_err()
|
||||
.contains("cancelled")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
mod backend;
|
||||
mod capture;
|
||||
mod consent;
|
||||
mod desktop;
|
||||
mod parent;
|
||||
mod picker;
|
||||
mod settings;
|
||||
mod stream;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -16,6 +20,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
[flag] if flag == "--picker" => picker::run(),
|
||||
[flag] if flag == "--consent" => consent::run(),
|
||||
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
|
||||
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
|
||||
globals::{GlobalListContents, registry_queue_init},
|
||||
protocol::{wl_registry, wl_surface},
|
||||
};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::{
|
||||
zxdg_imported_v2::{self, ZxdgImportedV2},
|
||||
zxdg_importer_v2::ZxdgImporterV2,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Parent {
|
||||
connection: Connection,
|
||||
imported: ZxdgImportedV2,
|
||||
importer: ZxdgImporterV2,
|
||||
}
|
||||
|
||||
impl Parent {
|
||||
pub(crate) fn attach(
|
||||
window: &dyn cosmic::iced::window::Window,
|
||||
parent: &str,
|
||||
) -> Result<Option<Self>, String> {
|
||||
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
if parent.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let handle = parent
|
||||
.strip_prefix("wayland:")
|
||||
.filter(|handle| !handle.is_empty() && handle.len() <= 4096)
|
||||
.ok_or("Unsupported portal parent window identifier")?;
|
||||
let display = window.display_handle().map_err(|error| error.to_string())?;
|
||||
let surface = window.window_handle().map_err(|error| error.to_string())?;
|
||||
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) =
|
||||
(display.as_raw(), surface.as_raw())
|
||||
else {
|
||||
return Err("Portal parenting requires Wayland".into());
|
||||
};
|
||||
// The window lends live handles; the borrowed backend never owns the display.
|
||||
let backend = unsafe {
|
||||
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
|
||||
};
|
||||
let connection = Connection::from_backend(backend);
|
||||
let id = unsafe {
|
||||
wayland_client::backend::ObjectId::from_ptr(
|
||||
wl_surface::WlSurface::interface(),
|
||||
surface.surface.as_ptr().cast(),
|
||||
)
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
let surface =
|
||||
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
|
||||
let (globals, mut queue) =
|
||||
registry_queue_init::<State>(&connection).map_err(|error| error.to_string())?;
|
||||
let qh = queue.handle();
|
||||
let importer = globals
|
||||
.bind::<ZxdgImporterV2, _, _>(&qh, 1..=1, ())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let imported = importer.import_toplevel(handle.into(), &qh, ());
|
||||
let mut state = State::default();
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if state.invalid {
|
||||
imported.destroy();
|
||||
importer.destroy();
|
||||
let _ = connection.flush();
|
||||
return Err("Portal parent window no longer exists".into());
|
||||
}
|
||||
imported.set_parent_of(&surface);
|
||||
connection.flush().map_err(|error| error.to_string())?;
|
||||
Ok(Some(Self {
|
||||
connection,
|
||||
imported,
|
||||
importer,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Parent {
|
||||
fn drop(&mut self) {
|
||||
self.imported.destroy();
|
||||
self.importer.destroy();
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &wl_registry::WlRegistry,
|
||||
_: wl_registry::Event,
|
||||
_: &GlobalListContents,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgImportedV2, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZxdgImportedV2,
|
||||
event: zxdg_imported_v2::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if matches!(event, zxdg_imported_v2::Event::Destroyed) {
|
||||
state.invalid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(State: ignore ZxdgImporterV2);
|
||||
@@ -258,7 +258,7 @@ impl Picker {
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance() -> (
|
||||
pub(crate) fn appearance() -> (
|
||||
cosmic::Theme,
|
||||
cosmic::font::Font,
|
||||
cosmic::iced::Color,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use ferese_config::Document;
|
||||
use ferese_theme::Palette;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tokio::sync::RwLock;
|
||||
use zbus::{
|
||||
Connection,
|
||||
object_server::SignalEmitter,
|
||||
zvariant::{OwnedValue, Value},
|
||||
};
|
||||
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
type Values = HashMap<String, HashMap<String, OwnedValue>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Appearance {
|
||||
scheme: u32,
|
||||
accent: (f64, f64, f64),
|
||||
reduced_motion: u32,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn from_document(document: Option<&Document>) -> Self {
|
||||
let palette = Palette::from_document(document);
|
||||
let animations = document
|
||||
.and_then(|document| document.get("animations.enabled"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
Self {
|
||||
scheme: if ferese_theme::luminance(palette.sidebar) > 0.5 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
accent: (
|
||||
palette.accent.r as f64,
|
||||
palette.accent.g as f64,
|
||||
palette.accent.b as f64,
|
||||
),
|
||||
reduced_motion: u32::from(
|
||||
!animations
|
||||
|| document
|
||||
.and_then(|doc| doc.get("animations.reduced_motion"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn values(self) -> HashMap<String, OwnedValue> {
|
||||
HashMap::from([
|
||||
("color-scheme".into(), self.scheme.into()),
|
||||
(
|
||||
"accent-color".into(),
|
||||
Value::from(self.accent).try_to_owned().unwrap(),
|
||||
),
|
||||
("contrast".into(), 0u32.into()),
|
||||
("reduced-motion".into(), self.reduced_motion.into()),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Settings(Arc<RwLock<Appearance>>);
|
||||
|
||||
impl Settings {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(Arc::new(RwLock::new(
|
||||
load().unwrap_or_else(|| Appearance::from_document(None)),
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn watch(self, connection: Connection) {
|
||||
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let Some(next) = load() else { continue };
|
||||
let mut current = self.0.write().await;
|
||||
if *current == next {
|
||||
continue;
|
||||
}
|
||||
let previous = current.values();
|
||||
*current = next;
|
||||
drop(current);
|
||||
for (key, value) in next.values() {
|
||||
if previous.get(&key) != Some(&value) {
|
||||
let _ = Self::setting_changed(&emitter, APPEARANCE, &key, value).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Settings")]
|
||||
impl Settings {
|
||||
#[zbus(property, name = "version")]
|
||||
fn version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn read_all(&self, namespaces: Vec<String>) -> Values {
|
||||
if matches_namespace(&namespaces, APPEARANCE) {
|
||||
HashMap::from([(APPEARANCE.into(), self.0.read().await.values())])
|
||||
} else {
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result<OwnedValue> {
|
||||
if namespace == APPEARANCE {
|
||||
if let Some(value) = self.0.read().await.values().remove(key) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(zbus::fdo::Error::InvalidArgs("Unknown setting".into()))
|
||||
}
|
||||
|
||||
#[zbus(signal)]
|
||||
async fn setting_changed(
|
||||
emitter: &SignalEmitter<'_>,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
value: OwnedValue,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
fn matches_namespace(filters: &[String], namespace: &str) -> bool {
|
||||
filters.is_empty()
|
||||
|| filters.iter().any(|filter| {
|
||||
filter.is_empty()
|
||||
|| filter == namespace
|
||||
|| filter.strip_suffix(".*").is_some_and(|prefix| {
|
||||
namespace
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|suffix| suffix.starts_with('.'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn load() -> Option<Appearance> {
|
||||
let path = ferese_config::config_path()?;
|
||||
let source = match std::fs::read_to_string(path) {
|
||||
Ok(source) if source.len() <= 1024 * 1024 => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Some(Appearance::from_document(None));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let document = Document::parse(&source).ok()?;
|
||||
Some(Appearance::from_document(Some(&document)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn namespaces_support_only_trailing_section_wildcards() {
|
||||
for filter in [
|
||||
"",
|
||||
"org.freedesktop.appearance",
|
||||
"org.freedesktop.*",
|
||||
"org.*",
|
||||
] {
|
||||
assert!(matches_namespace(&[filter.into()], APPEARANCE));
|
||||
}
|
||||
for filter in [
|
||||
"org.freedesktop*",
|
||||
"org.*.appearance",
|
||||
"org.freedesktop.appearance.*",
|
||||
"other",
|
||||
] {
|
||||
assert!(!matches_namespace(&[filter.into()], APPEARANCE));
|
||||
}
|
||||
assert!(matches_namespace(&[], APPEARANCE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_ferese_theme_and_reduced_motion() {
|
||||
let doc = Document::parse("theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }").unwrap();
|
||||
let appearance = Appearance::from_document(Some(&doc));
|
||||
assert_eq!(appearance.scheme, 2);
|
||||
assert_eq!(appearance.reduced_motion, 1);
|
||||
assert_eq!(appearance.accent.0, 1.);
|
||||
assert_eq!(appearance.accent.2, 0.);
|
||||
assert_eq!(
|
||||
u32::try_from(appearance.values().remove("contrast").unwrap()).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user