Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use futures_lite::{FutureExt, StreamExt, future};
|
||||
#[cfg(target_os = "linux")]
|
||||
use zbus::{
|
||||
MatchRule, MessageStream, Proxy,
|
||||
message::Type as MessageType,
|
||||
zvariant::{OwnedObjectPath, Value},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
|
||||
|
||||
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
pub const BACKGROUND_INTERFACE: &str = "org.freedesktop.portal.Background";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestOptions {
|
||||
pub reason: Option<String>,
|
||||
pub autostart: bool,
|
||||
pub commandline: Vec<String>,
|
||||
pub dbus_activatable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RequestResult {
|
||||
pub response: u32,
|
||||
pub background: bool,
|
||||
pub autostart: bool,
|
||||
}
|
||||
|
||||
impl RequestResult {
|
||||
pub fn cancelled(&self) -> bool {
|
||||
self.response != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BackgroundError {
|
||||
DbusError,
|
||||
PortalTimeout,
|
||||
InvalidReply,
|
||||
SendFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BackgroundError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::DbusError => "DbusError",
|
||||
Self::PortalTimeout => "PortalTimeout",
|
||||
Self::InvalidReply => "InvalidReply",
|
||||
Self::SendFailed => "SendFailed",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn request_background(options: RequestOptions) -> Result<RequestResult, BackgroundError> {
|
||||
future::block_on(request_background_async(options))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn request_background_async(
|
||||
options: RequestOptions,
|
||||
) -> Result<RequestResult, BackgroundError> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.map_err(|_| BackgroundError::DbusError)?;
|
||||
let unique_owned = conn
|
||||
.unique_name()
|
||||
.ok_or(BackgroundError::DbusError)?
|
||||
.to_owned();
|
||||
let unique_name = unique_owned.as_str().to_string();
|
||||
let handle_token = mint_token("fluxer_bg");
|
||||
let expected_path = request_path(&unique_name, &handle_token);
|
||||
|
||||
let rule = MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.interface(REQUEST_INTERFACE)
|
||||
.map_err(|_| BackgroundError::DbusError)?
|
||||
.member("Response")
|
||||
.map_err(|_| BackgroundError::DbusError)?
|
||||
.path(expected_path.clone())
|
||||
.map_err(|_| BackgroundError::DbusError)?
|
||||
.build();
|
||||
let mut stream = MessageStream::for_match_rule(rule, &conn, Some(8))
|
||||
.await
|
||||
.map_err(|_| BackgroundError::DbusError)?;
|
||||
|
||||
send_call(&conn, &handle_token, &options, &expected_path).await?;
|
||||
|
||||
loop {
|
||||
let timeout = async {
|
||||
async_io::Timer::after(REQUEST_TIMEOUT).await;
|
||||
None::<zbus::Result<zbus::Message>>
|
||||
};
|
||||
match stream.next().or(timeout).await {
|
||||
Some(Ok(message)) => {
|
||||
if let Some(parsed) = parse_request_response(&message) {
|
||||
return Ok(parsed);
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => return Err(BackgroundError::DbusError),
|
||||
None => return Err(BackgroundError::PortalTimeout),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_request_response(message: &zbus::Message) -> Option<RequestResult> {
|
||||
let body = message.body();
|
||||
let (response, results): (u32, HashMap<String, zbus::zvariant::OwnedValue>) =
|
||||
body.deserialize().ok()?;
|
||||
Some(RequestResult {
|
||||
response,
|
||||
background: bool_result(&results, "background").unwrap_or(false),
|
||||
autostart: bool_result(&results, "autostart").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bool_result(results: &HashMap<String, zbus::zvariant::OwnedValue>, key: &str) -> Option<bool> {
|
||||
results
|
||||
.get(key)
|
||||
.and_then(|value| bool_from_value(crate::kwin::value_of_owned(value)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bool_from_value(value: &Value<'_>) -> Option<bool> {
|
||||
match value {
|
||||
Value::Bool(v) => Some(*v),
|
||||
Value::Value(inner) => bool_from_value(inner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn send_call(
|
||||
conn: &zbus::Connection,
|
||||
handle_token: &str,
|
||||
options: &RequestOptions,
|
||||
expected_path: &str,
|
||||
) -> Result<(), BackgroundError> {
|
||||
let proxy = Proxy::new(conn, PORTAL_DESTINATION, PORTAL_PATH, BACKGROUND_INTERFACE)
|
||||
.await
|
||||
.map_err(|_| BackgroundError::DbusError)?;
|
||||
|
||||
let mut vardict: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
vardict.insert("handle_token", Value::new(handle_token));
|
||||
vardict.insert("autostart", Value::new(options.autostart));
|
||||
if let Some(reason) = options.reason.as_deref() {
|
||||
vardict.insert("reason", Value::new(reason));
|
||||
}
|
||||
if !options.commandline.is_empty() {
|
||||
let commandline: Vec<&str> = options.commandline.iter().map(String::as_str).collect();
|
||||
vardict.insert("commandline", Value::new(commandline));
|
||||
}
|
||||
if options.dbus_activatable {
|
||||
vardict.insert("dbus-activatable", Value::new(true));
|
||||
}
|
||||
|
||||
let reply_path: OwnedObjectPath = proxy
|
||||
.call("RequestBackground", &("", vardict))
|
||||
.await
|
||||
.map_err(|_| BackgroundError::SendFailed)?;
|
||||
if !reply_path.as_str().is_empty() && reply_path.as_str() != expected_path {
|
||||
return Err(BackgroundError::InvalidReply);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn request_background(_options: RequestOptions) -> Result<RequestResult, BackgroundError> {
|
||||
Err(BackgroundError::DbusError)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_request_does_not_autostart() {
|
||||
let opts = RequestOptions::default();
|
||||
assert!(!opts.autostart);
|
||||
assert!(opts.commandline.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_response_is_cancelled() {
|
||||
let result = RequestResult {
|
||||
response: 1,
|
||||
background: false,
|
||||
autostart: false,
|
||||
};
|
||||
assert!(result.cancelled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DesktopSession {
|
||||
Kde,
|
||||
Gnome,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayServer {
|
||||
X11,
|
||||
Wayland,
|
||||
WaylandWithXwayland,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn has_dbus_session() -> bool {
|
||||
has_dbus_session_from(
|
||||
std::env::var("DBUS_SESSION_BUS_ADDRESS").ok().as_deref(),
|
||||
std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
|
||||
|path| std::path::Path::new(path).exists(),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_dbus_session_from(
|
||||
bus_address: Option<&str>,
|
||||
xdg_runtime_dir: Option<&str>,
|
||||
path_exists: impl Fn(&str) -> bool,
|
||||
) -> bool {
|
||||
if bus_address.is_some_and(|v| !v.is_empty()) {
|
||||
return true;
|
||||
}
|
||||
if let Some(dir) = xdg_runtime_dir
|
||||
&& !dir.is_empty()
|
||||
{
|
||||
let candidate = format!("{}/bus", dir.trim_end_matches('/'));
|
||||
if path_exists(&candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl DisplayServer {
|
||||
pub fn x11_reachable(self) -> bool {
|
||||
matches!(self, Self::X11 | Self::WaylandWithXwayland)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WindowPidBackend {
|
||||
Kwin,
|
||||
GnomeShellEval,
|
||||
X11,
|
||||
}
|
||||
|
||||
pub fn detect_desktop_session() -> DesktopSession {
|
||||
detect_desktop_session_from(
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
std::env::var("XDG_SESSION_DESKTOP").ok().as_deref(),
|
||||
std::env::var("DESKTOP_SESSION").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn detect_desktop_session_from(
|
||||
xdg_current_desktop: Option<&str>,
|
||||
xdg_session_desktop: Option<&str>,
|
||||
desktop_session: Option<&str>,
|
||||
) -> DesktopSession {
|
||||
let candidates = [xdg_current_desktop, xdg_session_desktop, desktop_session];
|
||||
for raw in candidates.into_iter().flatten() {
|
||||
for token in raw.split(':') {
|
||||
let token = token.trim().to_ascii_lowercase();
|
||||
match token.as_str() {
|
||||
"kde" | "plasma" | "kde-plasma" => return DesktopSession::Kde,
|
||||
"gnome" | "gnome-classic" | "gnome-xorg" | "ubuntu" | "pop" => {
|
||||
return DesktopSession::Gnome;
|
||||
}
|
||||
"sway" | "hyprland" | "wlroots" | "cosmic" | "wayfire" | "river" | "niri" => {
|
||||
return DesktopSession::Other;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopSession::Other
|
||||
}
|
||||
|
||||
pub fn detect_display_server() -> DisplayServer {
|
||||
detect_display_server_from(
|
||||
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
|
||||
std::env::var("DISPLAY").ok().as_deref(),
|
||||
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn detect_display_server_from(
|
||||
xdg_session_type: Option<&str>,
|
||||
display: Option<&str>,
|
||||
wayland_display: Option<&str>,
|
||||
) -> DisplayServer {
|
||||
let has_x11 = display.is_some_and(|v| !v.is_empty());
|
||||
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
|
||||
match (has_x11, has_wayland) {
|
||||
(true, true) => DisplayServer::WaylandWithXwayland,
|
||||
(true, false) => DisplayServer::X11,
|
||||
(false, true) => DisplayServer::Wayland,
|
||||
(false, false) => match xdg_session_type {
|
||||
Some("x11") => DisplayServer::X11,
|
||||
Some("wayland") => DisplayServer::Wayland,
|
||||
_ => DisplayServer::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn window_pid_backend_precedence() -> Vec<WindowPidBackend> {
|
||||
backend_precedence_for(
|
||||
detect_desktop_session(),
|
||||
detect_display_server(),
|
||||
has_dbus_session(),
|
||||
)
|
||||
}
|
||||
|
||||
fn backend_precedence_for(
|
||||
session: DesktopSession,
|
||||
display: DisplayServer,
|
||||
dbus_available: bool,
|
||||
) -> Vec<WindowPidBackend> {
|
||||
let mut out = Vec::with_capacity(3);
|
||||
match session {
|
||||
DesktopSession::Kde => {
|
||||
if dbus_available {
|
||||
out.push(WindowPidBackend::Kwin);
|
||||
}
|
||||
if display.x11_reachable() {
|
||||
out.push(WindowPidBackend::X11);
|
||||
}
|
||||
}
|
||||
DesktopSession::Gnome => {
|
||||
if dbus_available {
|
||||
out.push(WindowPidBackend::GnomeShellEval);
|
||||
}
|
||||
if display.x11_reachable() {
|
||||
out.push(WindowPidBackend::X11);
|
||||
}
|
||||
}
|
||||
DesktopSession::Other => {
|
||||
if dbus_available {
|
||||
out.push(WindowPidBackend::Kwin);
|
||||
out.push(WindowPidBackend::GnomeShellEval);
|
||||
}
|
||||
if display.x11_reachable() {
|
||||
out.push(WindowPidBackend::X11);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_kde_from_xdg_current_desktop() {
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some("KDE"), None, None),
|
||||
DesktopSession::Kde
|
||||
);
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some("plasma"), None, None),
|
||||
DesktopSession::Kde
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gnome_handles_colon_list_and_ubuntu_pop_overrides() {
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some("ubuntu:GNOME"), None, None),
|
||||
DesktopSession::Gnome
|
||||
);
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some("pop:GNOME"), None, None),
|
||||
DesktopSession::Gnome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_other_for_xfce_and_unset() {
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some("XFCE"), None, None),
|
||||
DesktopSession::Other
|
||||
);
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(None, None, None),
|
||||
DesktopSession::Other
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_kde_session_tries_kwin_first_then_x11() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Kde, DisplayServer::X11, true),
|
||||
vec![WindowPidBackend::Kwin, WindowPidBackend::X11]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Kde, DisplayServer::Wayland, true),
|
||||
vec![WindowPidBackend::Kwin]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_gnome_session_tries_gnome_shell_first_then_x11() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(
|
||||
DesktopSession::Gnome,
|
||||
DisplayServer::WaylandWithXwayland,
|
||||
true
|
||||
),
|
||||
vec![WindowPidBackend::GnomeShellEval, WindowPidBackend::X11]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_unknown_de_tries_all_three_in_order() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Other, DisplayServer::X11, true),
|
||||
vec![
|
||||
WindowPidBackend::Kwin,
|
||||
WindowPidBackend::GnomeShellEval,
|
||||
WindowPidBackend::X11,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_unknown_de_pure_wayland_skips_x11() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Other, DisplayServer::Wayland, true),
|
||||
vec![WindowPidBackend::Kwin, WindowPidBackend::GnomeShellEval]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_without_dbus_skips_all_dbus_backends() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Kde, DisplayServer::X11, false),
|
||||
vec![WindowPidBackend::X11]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_precedence_for(
|
||||
DesktopSession::Gnome,
|
||||
DisplayServer::WaylandWithXwayland,
|
||||
false
|
||||
),
|
||||
vec![WindowPidBackend::X11]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Other, DisplayServer::X11, false),
|
||||
vec![WindowPidBackend::X11]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_headless_container_returns_empty_list() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Other, DisplayServer::Unknown, false),
|
||||
Vec::<WindowPidBackend>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
backend_precedence_for(DesktopSession::Gnome, DisplayServer::Wayland, false),
|
||||
Vec::<WindowPidBackend>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wlroots_compositors_bucket_as_other() {
|
||||
for token in [
|
||||
"sway", "Hyprland", "wlroots", "cosmic", "wayfire", "river", "niri",
|
||||
] {
|
||||
assert_eq!(
|
||||
detect_desktop_session_from(Some(token), None, None),
|
||||
DesktopSession::Other,
|
||||
"expected {token} to bucket as Other",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wlroots_compositor_precedence_skips_kwin_and_gnome_shell_when_no_dbus() {
|
||||
assert_eq!(
|
||||
backend_precedence_for(
|
||||
detect_desktop_session_from(Some("sway"), None, None),
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
),
|
||||
Vec::<WindowPidBackend>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
backend_precedence_for(
|
||||
detect_desktop_session_from(Some("Hyprland"), None, None),
|
||||
DisplayServer::WaylandWithXwayland,
|
||||
true,
|
||||
),
|
||||
vec![
|
||||
WindowPidBackend::Kwin,
|
||||
WindowPidBackend::GnomeShellEval,
|
||||
WindowPidBackend::X11,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dbus_session_detected_via_bus_address() {
|
||||
assert!(has_dbus_session_from(
|
||||
Some("unix:path=/run/user/1000/bus"),
|
||||
None,
|
||||
|_| false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dbus_session_detected_via_xdg_runtime_dir_socket() {
|
||||
assert!(has_dbus_session_from(
|
||||
None,
|
||||
Some("/run/user/1000"),
|
||||
|path| path == "/run/user/1000/bus"
|
||||
));
|
||||
assert!(has_dbus_session_from(
|
||||
None,
|
||||
Some("/run/user/1000/"),
|
||||
|path| path == "/run/user/1000/bus"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dbus_session_absent_when_neither_var_set() {
|
||||
assert!(!has_dbus_session_from(None, None, |_| false));
|
||||
assert!(!has_dbus_session_from(Some(""), Some(""), |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dbus_session_absent_when_runtime_dir_has_no_bus_socket() {
|
||||
assert!(!has_dbus_session_from(None, Some("/tmp/xdg"), |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_xwayland_counts_as_x11_reachable() {
|
||||
let ds = detect_display_server_from(Some("wayland"), Some(":0"), Some("wayland-0"));
|
||||
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
|
||||
assert!(ds.x11_reachable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_pure_wayland_blocks_x11() {
|
||||
let ds = detect_display_server_from(Some("wayland"), None, Some("wayland-0"));
|
||||
assert!(!ds.x11_reachable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::{collections::HashMap, sync::mpsc, time::Duration};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use futures_lite::{FutureExt, StreamExt, future};
|
||||
#[cfg(target_os = "linux")]
|
||||
use zbus::{
|
||||
MatchRule, MessageStream,
|
||||
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
|
||||
message::Type as MessageType,
|
||||
zvariant::{OwnedObjectPath, Value},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
|
||||
|
||||
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
pub const FILE_CHOOSER_INTERFACE: &str = "org.freedesktop.portal.FileChooser";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Open,
|
||||
Save,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FilterRule {
|
||||
pub kind: u32,
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
pub name: String,
|
||||
pub rules: Vec<FilterRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Options {
|
||||
pub parent_window: String,
|
||||
pub title: String,
|
||||
pub accept_label: Option<String>,
|
||||
pub modal: bool,
|
||||
pub multiple: bool,
|
||||
pub directory: bool,
|
||||
pub current_folder: Option<String>,
|
||||
pub current_name: Option<String>,
|
||||
pub current_file: Option<String>,
|
||||
pub filters: Vec<Filter>,
|
||||
pub current_filter: Option<Filter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileChooserResult {
|
||||
pub cancelled: bool,
|
||||
pub uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FileChooserError {
|
||||
DbusError,
|
||||
PortalTimeout,
|
||||
InvalidReply,
|
||||
SendFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FileChooserError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::DbusError => "DbusError",
|
||||
Self::PortalTimeout => "PortalTimeout",
|
||||
Self::InvalidReply => "InvalidReply",
|
||||
Self::SendFailed => "SendFailed",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn invoke(mode: Mode, options: Options) -> Result<FileChooserResult, FileChooserError> {
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|_| FileChooserError::DbusError)?
|
||||
.method_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|_| FileChooserError::DbusError)?;
|
||||
let unique_owned = conn
|
||||
.unique_name()
|
||||
.ok_or(FileChooserError::DbusError)?
|
||||
.to_owned();
|
||||
let unique_name = unique_owned.as_str().to_string();
|
||||
|
||||
let token_prefix = match mode {
|
||||
Mode::Open => "fluxer_fc_open",
|
||||
Mode::Save => "fluxer_fc_save",
|
||||
};
|
||||
let handle_token = mint_token(token_prefix);
|
||||
let expected_path = request_path(&unique_name, &handle_token);
|
||||
|
||||
let (tx, rx) = mpsc::sync_channel::<FileChooserResponse>(1);
|
||||
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stop_for_thread = stop_flag.clone();
|
||||
let expected_for_thread = expected_path.clone();
|
||||
let listener = std::thread::Builder::new()
|
||||
.name("fluxer-linux-portals-fc".to_string())
|
||||
.spawn(move || {
|
||||
response_listener(&expected_for_thread, tx, stop_for_thread);
|
||||
})
|
||||
.map_err(|_| FileChooserError::DbusError)?;
|
||||
|
||||
let send_result = send_call(&conn, mode, &handle_token, &options, &expected_path);
|
||||
|
||||
let result = match send_result {
|
||||
Ok(()) => match rx.recv_timeout(REQUEST_TIMEOUT) {
|
||||
Ok(response) => {
|
||||
if response.code != 0 {
|
||||
Ok(FileChooserResult {
|
||||
cancelled: true,
|
||||
uris: vec![],
|
||||
})
|
||||
} else {
|
||||
Ok(FileChooserResult {
|
||||
cancelled: false,
|
||||
uris: response.uris,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(_) => Err(FileChooserError::PortalTimeout),
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
stop_flag.store(true, std::sync::atomic::Ordering::Release);
|
||||
let _ = listener.join();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct FileChooserResponse {
|
||||
code: u32,
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn response_listener(
|
||||
expected_path: &str,
|
||||
tx: mpsc::SyncSender<FileChooserResponse>,
|
||||
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
let setup = future::block_on(async {
|
||||
let conn = zbus::Connection::session().await?;
|
||||
let rule = MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.interface(REQUEST_INTERFACE)?
|
||||
.member("Response")?
|
||||
.path(expected_path.to_string())?
|
||||
.build();
|
||||
let stream = MessageStream::for_match_rule(rule, &conn, Some(8)).await?;
|
||||
zbus::Result::Ok((conn, stream))
|
||||
});
|
||||
let (_conn, mut stream) = match setup {
|
||||
Ok(parts) => parts,
|
||||
Err(_) => return,
|
||||
};
|
||||
while !stop.load(std::sync::atomic::Ordering::Acquire) {
|
||||
let timeout = async {
|
||||
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
|
||||
None::<zbus::Result<zbus::Message>>
|
||||
};
|
||||
match future::block_on(stream.next().or(timeout)) {
|
||||
Some(Ok(message)) => {
|
||||
if let Some(parsed) = parse_filechooser_response(&message) {
|
||||
let _ = tx.send(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => return,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_filechooser_response(message: &zbus::Message) -> Option<FileChooserResponse> {
|
||||
let body = message.body();
|
||||
let (code, results): (u32, HashMap<String, zbus::zvariant::OwnedValue>) =
|
||||
body.deserialize().ok()?;
|
||||
let mut uris: Vec<String> = Vec::new();
|
||||
if let Some(v) = results.get("uris") {
|
||||
let val = crate::kwin::value_of_owned(v);
|
||||
if let Value::Array(arr) = val {
|
||||
for element in arr.iter() {
|
||||
let inner: &Value<'_> = match element {
|
||||
Value::Value(b) => b.as_ref(),
|
||||
other => other,
|
||||
};
|
||||
if let Value::Str(s) = inner {
|
||||
uris.push(s.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(FileChooserResponse { code, uris })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn send_call(
|
||||
conn: &BlockingConnection,
|
||||
mode: Mode,
|
||||
handle_token: &str,
|
||||
options: &Options,
|
||||
expected_path: &str,
|
||||
) -> Result<(), FileChooserError> {
|
||||
let proxy = BlockingProxy::new(
|
||||
conn,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
FILE_CHOOSER_INTERFACE,
|
||||
)
|
||||
.map_err(|_| FileChooserError::DbusError)?;
|
||||
let member = match mode {
|
||||
Mode::Open => "OpenFile",
|
||||
Mode::Save => "SaveFile",
|
||||
};
|
||||
|
||||
let mut vardict: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
vardict.insert("handle_token", Value::new(handle_token));
|
||||
vardict.insert("modal", Value::new(options.modal));
|
||||
vardict.insert("multiple", Value::new(options.multiple));
|
||||
if matches!(mode, Mode::Open) && options.directory {
|
||||
vardict.insert("directory", Value::new(true));
|
||||
}
|
||||
if let Some(label) = options.accept_label.as_deref() {
|
||||
vardict.insert("accept_label", Value::new(label));
|
||||
}
|
||||
if !options.filters.is_empty() {
|
||||
vardict.insert("filters", Value::new(serialize_filters(&options.filters)));
|
||||
}
|
||||
if let Some(cf) = options.current_filter.as_ref() {
|
||||
vardict.insert("current_filter", Value::new(serialize_filter(cf)));
|
||||
}
|
||||
if let Some(folder) = options.current_folder.as_deref() {
|
||||
vardict.insert("current_folder", Value::new(folder.as_bytes()));
|
||||
}
|
||||
if matches!(mode, Mode::Save)
|
||||
&& let Some(name) = options.current_name.as_deref()
|
||||
{
|
||||
vardict.insert("current_name", Value::new(name));
|
||||
}
|
||||
if matches!(mode, Mode::Save)
|
||||
&& let Some(file) = options.current_file.as_deref()
|
||||
{
|
||||
vardict.insert("current_file", Value::new(file.as_bytes()));
|
||||
}
|
||||
|
||||
let reply_path: OwnedObjectPath = proxy
|
||||
.call(
|
||||
member,
|
||||
&(
|
||||
options.parent_window.as_str(),
|
||||
options.title.as_str(),
|
||||
vardict,
|
||||
),
|
||||
)
|
||||
.map_err(|_| FileChooserError::SendFailed)?;
|
||||
if !reply_path.as_str().is_empty() && reply_path.as_str() != expected_path {
|
||||
return Err(FileChooserError::InvalidReply);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn serialize_filters(filters: &[Filter]) -> Vec<(&str, Vec<(u32, &str)>)> {
|
||||
filters
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let rules: Vec<(u32, &str)> = f
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| (r.kind, r.pattern.as_str()))
|
||||
.collect();
|
||||
(f.name.as_str(), rules)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn serialize_filter(filter: &Filter) -> (&str, Vec<(u32, &str)>) {
|
||||
let rules: Vec<(u32, &str)> = filter
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| (r.kind, r.pattern.as_str()))
|
||||
.collect();
|
||||
(filter.name.as_str(), rules)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn invoke(_mode: Mode, _options: Options) -> Result<FileChooserResult, FileChooserError> {
|
||||
Err(FileChooserError::DbusError)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_default_is_open_safe() {
|
||||
let opts = Options::default();
|
||||
assert!(!opts.directory);
|
||||
assert!(!opts.multiple);
|
||||
assert!(opts.parent_window.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_rule_kinds_match_typescript_union() {
|
||||
let glob = FilterRule {
|
||||
kind: 0,
|
||||
pattern: "*.png".into(),
|
||||
};
|
||||
let mime = FilterRule {
|
||||
kind: 1,
|
||||
pattern: "image/png".into(),
|
||||
};
|
||||
assert_eq!(glob.kind, 0);
|
||||
assert_eq!(mime.kind, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use futures_lite::{FutureExt, StreamExt, future};
|
||||
#[cfg(target_os = "linux")]
|
||||
use zbus::{
|
||||
MatchRule, MessageStream, Proxy,
|
||||
message::Type as MessageType,
|
||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
|
||||
|
||||
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
pub const GLOBAL_SHORTCUTS_INTERFACE: &str = "org.freedesktop.portal.GlobalShortcuts";
|
||||
pub const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
type ShortcutProperties = HashMap<String, OwnedValue>;
|
||||
#[cfg(target_os = "linux")]
|
||||
type ShortcutsChangedBody = (OwnedObjectPath, Vec<(String, ShortcutProperties)>);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ShortcutEntry {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub preferred_trigger: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoundShortcut {
|
||||
pub id: String,
|
||||
pub description: Option<String>,
|
||||
pub trigger_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigureResult {
|
||||
pub action: String,
|
||||
pub shortcuts: Vec<BoundShortcut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShortcutEvent {
|
||||
Activated { id: String },
|
||||
Deactivated { id: String },
|
||||
ShortcutsChanged { shortcuts: Vec<BoundShortcut> },
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GlobalShortcutsError {
|
||||
DbusError,
|
||||
PortalTimeout,
|
||||
InvalidReply,
|
||||
SendFailed,
|
||||
Cancelled,
|
||||
ThreadStartFailed,
|
||||
LockPoisoned,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GlobalShortcutsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::DbusError => "DbusError",
|
||||
Self::PortalTimeout => "PortalTimeout",
|
||||
Self::InvalidReply => "InvalidReply",
|
||||
Self::SendFailed => "SendFailed",
|
||||
Self::Cancelled => "Cancelled",
|
||||
Self::ThreadStartFailed => "ThreadStartFailed",
|
||||
Self::LockPoisoned => "LockPoisoned",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
type ShortcutCallback = Arc<dyn Fn(ShortcutEvent) + Send + Sync + 'static>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct Subscription {
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
thread: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Subscription {
|
||||
pub fn configure(
|
||||
entries: Vec<ShortcutEntry>,
|
||||
callback: ShortcutCallback,
|
||||
) -> Result<(Self, ConfigureResult), GlobalShortcutsError> {
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stop_for_thread = stop_flag.clone();
|
||||
let (ready_tx, ready_rx) =
|
||||
mpsc::sync_channel::<Result<ConfigureReady, GlobalShortcutsError>>(1);
|
||||
let thread = thread::Builder::new()
|
||||
.name("fluxer-linux-portals-global-shortcuts".to_string())
|
||||
.spawn(move || {
|
||||
let setup = future::block_on(async {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?;
|
||||
let rule = MatchRule::builder().msg_type(MessageType::Signal).build();
|
||||
let stream = MessageStream::for_match_rule(rule, &conn, Some(64))
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?;
|
||||
let ready = configure_session(&conn, &entries).await?;
|
||||
Ok::<_, GlobalShortcutsError>((conn, stream, ready))
|
||||
});
|
||||
let (conn, mut stream, ready) = match setup {
|
||||
Ok(parts) => parts,
|
||||
Err(err) => {
|
||||
let _ = ready_tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let session_handle = ready.session_handle.clone();
|
||||
if ready_tx.send(Ok(ready)).is_err() {
|
||||
let _ = future::block_on(close_session(&conn, &session_handle));
|
||||
return;
|
||||
}
|
||||
while !stop_for_thread.load(Ordering::Acquire) {
|
||||
let timeout = async {
|
||||
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
|
||||
None::<zbus::Result<zbus::Message>>
|
||||
};
|
||||
match future::block_on(stream.next().or(timeout)) {
|
||||
Some(Ok(message)) => {
|
||||
if let Some(event) = parse_signal(&message, &session_handle) {
|
||||
let closed = matches!(event, ShortcutEvent::Closed);
|
||||
callback(event);
|
||||
if closed {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => break,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let _ = future::block_on(close_session(&conn, &session_handle));
|
||||
})
|
||||
.map_err(|_| GlobalShortcutsError::ThreadStartFailed)?;
|
||||
|
||||
match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) {
|
||||
Ok(Ok(ready)) => Ok((
|
||||
Self {
|
||||
stop_flag,
|
||||
thread: Mutex::new(Some(thread)),
|
||||
},
|
||||
ready.result,
|
||||
)),
|
||||
Ok(Err(err)) => {
|
||||
let _ = thread.join();
|
||||
Err(err)
|
||||
}
|
||||
Err(err) => {
|
||||
stop_flag.store(true, Ordering::Release);
|
||||
if matches!(err, mpsc::RecvTimeoutError::Disconnected) {
|
||||
let _ = thread.join();
|
||||
}
|
||||
Err(GlobalShortcutsError::ThreadStartFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.stop_flag.store(true, Ordering::Release);
|
||||
if let Ok(mut thread) = self.thread.lock()
|
||||
&& let Some(t) = thread.take()
|
||||
{
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct ConfigureReady {
|
||||
session_handle: String,
|
||||
result: ConfigureResult,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct PortalResponse {
|
||||
code: u32,
|
||||
results: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn configure_session(
|
||||
conn: &zbus::Connection,
|
||||
entries: &[ShortcutEntry],
|
||||
) -> Result<ConfigureReady, GlobalShortcutsError> {
|
||||
let session_handle = create_session(conn).await?;
|
||||
let persisted = list_shortcuts(conn, &session_handle)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if shortcut_ids_match(entries, &persisted) {
|
||||
return Ok(ConfigureReady {
|
||||
session_handle,
|
||||
result: ConfigureResult {
|
||||
action: "listed".to_string(),
|
||||
shortcuts: persisted,
|
||||
},
|
||||
});
|
||||
}
|
||||
let bound = bind_shortcuts(conn, &session_handle, entries).await?;
|
||||
Ok(ConfigureReady {
|
||||
session_handle,
|
||||
result: bound,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn shortcut_ids_match(entries: &[ShortcutEntry], persisted: &[BoundShortcut]) -> bool {
|
||||
if entries.is_empty() || persisted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let requested: HashSet<&str> = entries.iter().map(|entry| entry.id.as_str()).collect();
|
||||
let existing: HashSet<&str> = persisted
|
||||
.iter()
|
||||
.map(|shortcut| shortcut.id.as_str())
|
||||
.collect();
|
||||
requested == existing
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn create_session(conn: &zbus::Connection) -> Result<String, GlobalShortcutsError> {
|
||||
let handle_token = mint_token("fluxer_gs_create");
|
||||
let session_handle_token = mint_token("fluxer_gs_session");
|
||||
let mut stream = request_stream(conn, &handle_token).await?;
|
||||
let proxy = global_shortcuts_proxy(conn).await?;
|
||||
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
options.insert("handle_token", Value::new(handle_token.as_str()));
|
||||
options.insert(
|
||||
"session_handle_token",
|
||||
Value::new(session_handle_token.as_str()),
|
||||
);
|
||||
let _reply_path: OwnedObjectPath = proxy
|
||||
.call("CreateSession", &(options,))
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::SendFailed)?;
|
||||
let response = wait_for_response(&mut stream).await?;
|
||||
if response.code != 0 {
|
||||
return Err(GlobalShortcutsError::Cancelled);
|
||||
}
|
||||
response
|
||||
.results
|
||||
.get("session_handle")
|
||||
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value)))
|
||||
.ok_or(GlobalShortcutsError::InvalidReply)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn list_shortcuts(
|
||||
conn: &zbus::Connection,
|
||||
session_handle: &str,
|
||||
) -> Result<Vec<BoundShortcut>, GlobalShortcutsError> {
|
||||
let session_path = owned_path(session_handle)?;
|
||||
let handle_token = mint_token("fluxer_gs_list");
|
||||
let mut stream = request_stream(conn, &handle_token).await?;
|
||||
let proxy = global_shortcuts_proxy(conn).await?;
|
||||
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
options.insert("handle_token", Value::new(handle_token.as_str()));
|
||||
let _reply_path: OwnedObjectPath = proxy
|
||||
.call("ListShortcuts", &(&session_path, options))
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::SendFailed)?;
|
||||
let response = wait_for_response(&mut stream).await?;
|
||||
if response.code != 0 {
|
||||
return Err(GlobalShortcutsError::Cancelled);
|
||||
}
|
||||
Ok(shortcuts_from_results(&response.results))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn bind_shortcuts(
|
||||
conn: &zbus::Connection,
|
||||
session_handle: &str,
|
||||
entries: &[ShortcutEntry],
|
||||
) -> Result<ConfigureResult, GlobalShortcutsError> {
|
||||
let session_path = owned_path(session_handle)?;
|
||||
let handle_token = mint_token("fluxer_gs_bind");
|
||||
let mut stream = request_stream(conn, &handle_token).await?;
|
||||
let proxy = global_shortcuts_proxy(conn).await?;
|
||||
let shortcuts = serialize_shortcuts(entries);
|
||||
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
options.insert("handle_token", Value::new(handle_token.as_str()));
|
||||
let _reply_path: OwnedObjectPath = proxy
|
||||
.call("BindShortcuts", &(&session_path, shortcuts, "", options))
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::SendFailed)?;
|
||||
let response = wait_for_response(&mut stream).await?;
|
||||
if response.code != 0 {
|
||||
return Ok(ConfigureResult {
|
||||
action: "cancelled".to_string(),
|
||||
shortcuts: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(ConfigureResult {
|
||||
action: "bound".to_string(),
|
||||
shortcuts: shortcuts_from_results(&response.results),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn global_shortcuts_proxy(
|
||||
conn: &zbus::Connection,
|
||||
) -> Result<Proxy<'_>, GlobalShortcutsError> {
|
||||
Proxy::new(
|
||||
conn,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
GLOBAL_SHORTCUTS_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)
|
||||
}
|
||||
|
||||
async fn request_stream(
|
||||
conn: &zbus::Connection,
|
||||
handle_token: &str,
|
||||
) -> Result<MessageStream, GlobalShortcutsError> {
|
||||
let unique_owned = conn
|
||||
.unique_name()
|
||||
.ok_or(GlobalShortcutsError::DbusError)?
|
||||
.to_owned();
|
||||
let unique_name = unique_owned.as_str().to_string();
|
||||
let expected_path = request_path(&unique_name, handle_token);
|
||||
let rule = MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.interface(REQUEST_INTERFACE)
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?
|
||||
.member("Response")
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?
|
||||
.path(expected_path.clone())
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?
|
||||
.build();
|
||||
MessageStream::for_match_rule(rule, conn, Some(8))
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)
|
||||
}
|
||||
|
||||
async fn wait_for_response(
|
||||
stream: &mut MessageStream,
|
||||
) -> Result<PortalResponse, GlobalShortcutsError> {
|
||||
loop {
|
||||
let timeout = async {
|
||||
async_io::Timer::after(REQUEST_TIMEOUT).await;
|
||||
None::<zbus::Result<zbus::Message>>
|
||||
};
|
||||
match stream.next().or(timeout).await {
|
||||
Some(Ok(message)) => {
|
||||
if let Some(parsed) = parse_request_response(&message) {
|
||||
return Ok(parsed);
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => return Err(GlobalShortcutsError::DbusError),
|
||||
None => return Err(GlobalShortcutsError::PortalTimeout),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_request_response(message: &zbus::Message) -> Option<PortalResponse> {
|
||||
let body = message.body();
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = body.deserialize().ok()?;
|
||||
Some(PortalResponse { code, results })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn serialize_shortcuts(entries: &[ShortcutEntry]) -> Vec<(&str, HashMap<&str, Value<'_>>)> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
options.insert("description", Value::new(entry.description.as_str()));
|
||||
if let Some(trigger) = entry.preferred_trigger.as_deref() {
|
||||
options.insert("preferred_trigger", Value::new(trigger));
|
||||
}
|
||||
(entry.id.as_str(), options)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn shortcuts_from_results(results: &HashMap<String, OwnedValue>) -> Vec<BoundShortcut> {
|
||||
results
|
||||
.get("shortcuts")
|
||||
.and_then(|value| shortcuts_from_value(crate::kwin::value_of_owned(value)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn shortcuts_from_value(value: &Value<'_>) -> Option<Vec<BoundShortcut>> {
|
||||
let inner = unbox_value(value);
|
||||
let Value::Array(array) = inner else {
|
||||
return None;
|
||||
};
|
||||
let mut shortcuts = Vec::new();
|
||||
for value in array.inner() {
|
||||
if let Some(shortcut) = bound_shortcut_from_value(value) {
|
||||
shortcuts.push(shortcut);
|
||||
}
|
||||
}
|
||||
Some(shortcuts)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bound_shortcut_from_value(value: &Value<'_>) -> Option<BoundShortcut> {
|
||||
let Value::Structure(structure) = unbox_value(value) else {
|
||||
return None;
|
||||
};
|
||||
let fields = structure.fields();
|
||||
if fields.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let id = string_or_object_path(&fields[0])?;
|
||||
let dict = match unbox_value(&fields[1]) {
|
||||
Value::Dict(dict) => Some(dict),
|
||||
_ => None,
|
||||
};
|
||||
Some(BoundShortcut {
|
||||
id,
|
||||
description: dict.and_then(|d| dict_string(d, "description")),
|
||||
trigger_description: dict.and_then(|d| dict_string(d, "trigger_description")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dict_string(dict: &zbus::zvariant::Dict<'_, '_>, key: &str) -> Option<String> {
|
||||
dict.iter().find_map(|(k, v)| {
|
||||
if string_or_object_path(k).as_deref() == Some(key) {
|
||||
string_or_object_path(unbox_value(v))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_signal(message: &zbus::Message, session_handle: &str) -> Option<ShortcutEvent> {
|
||||
let header = message.header();
|
||||
let interface = header.interface()?.as_str();
|
||||
let member = header.member()?.as_str();
|
||||
match (interface, member) {
|
||||
(GLOBAL_SHORTCUTS_INTERFACE, "Activated") => {
|
||||
let (session, id, _timestamp, _options): (
|
||||
OwnedObjectPath,
|
||||
String,
|
||||
u64,
|
||||
HashMap<String, OwnedValue>,
|
||||
) = message.body().deserialize().ok()?;
|
||||
if session.as_str() == session_handle {
|
||||
Some(ShortcutEvent::Activated { id })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(GLOBAL_SHORTCUTS_INTERFACE, "Deactivated") => {
|
||||
let (session, id, _timestamp, _options): (
|
||||
OwnedObjectPath,
|
||||
String,
|
||||
u64,
|
||||
HashMap<String, OwnedValue>,
|
||||
) = message.body().deserialize().ok()?;
|
||||
if session.as_str() == session_handle {
|
||||
Some(ShortcutEvent::Deactivated { id })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(GLOBAL_SHORTCUTS_INTERFACE, "ShortcutsChanged") => {
|
||||
let (session, shortcuts): ShortcutsChangedBody = message.body().deserialize().ok()?;
|
||||
if session.as_str() != session_handle {
|
||||
return None;
|
||||
}
|
||||
Some(ShortcutEvent::ShortcutsChanged {
|
||||
shortcuts: shortcuts
|
||||
.into_iter()
|
||||
.map(|(id, properties)| bound_shortcut_from_parts(id, &properties))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
(SESSION_INTERFACE, "Closed") => {
|
||||
if header.path()?.as_str() == session_handle {
|
||||
Some(ShortcutEvent::Closed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bound_shortcut_from_parts(
|
||||
id: String,
|
||||
properties: &HashMap<String, OwnedValue>,
|
||||
) -> BoundShortcut {
|
||||
BoundShortcut {
|
||||
id,
|
||||
description: properties
|
||||
.get("description")
|
||||
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value))),
|
||||
trigger_description: properties
|
||||
.get("trigger_description")
|
||||
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn close_session(
|
||||
conn: &zbus::Connection,
|
||||
session_handle: &str,
|
||||
) -> Result<(), GlobalShortcutsError> {
|
||||
let proxy = Proxy::new(conn, PORTAL_DESTINATION, session_handle, SESSION_INTERFACE)
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::DbusError)?;
|
||||
proxy
|
||||
.call::<_, _, ()>("Close", &())
|
||||
.await
|
||||
.map_err(|_| GlobalShortcutsError::SendFailed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn owned_path(path: &str) -> Result<OwnedObjectPath, GlobalShortcutsError> {
|
||||
OwnedObjectPath::try_from(path.to_string()).map_err(|_| GlobalShortcutsError::InvalidReply)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unbox_value<'a>(value: &'a Value<'a>) -> &'a Value<'a> {
|
||||
match value {
|
||||
Value::Value(inner) => unbox_value(inner),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn string_or_object_path(value: &Value<'_>) -> Option<String> {
|
||||
match unbox_value(value) {
|
||||
Value::Str(v) => Some(v.as_str().to_string()),
|
||||
Value::ObjectPath(v) => Some(v.as_str().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_portal_version() -> Option<u32> {
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.ok()?
|
||||
.method_timeout(Duration::from_millis(1_500))
|
||||
.build()
|
||||
.ok()?;
|
||||
let proxy = zbus::blocking::Proxy::new(
|
||||
&conn,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
GLOBAL_SHORTCUTS_INTERFACE,
|
||||
)
|
||||
.ok()?;
|
||||
proxy.get_property("version").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_available() -> bool {
|
||||
get_portal_version().is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub struct Subscription;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl Subscription {
|
||||
pub fn configure(
|
||||
_entries: Vec<ShortcutEntry>,
|
||||
_callback: Arc<dyn Fn(ShortcutEvent) + Send + Sync + 'static>,
|
||||
) -> Result<(Self, ConfigureResult), GlobalShortcutsError> {
|
||||
Err(GlobalShortcutsError::DbusError)
|
||||
}
|
||||
|
||||
pub fn close(&self) {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn get_portal_version() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn is_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shortcut_id_match_requires_same_ids() {
|
||||
let entries = vec![ShortcutEntry {
|
||||
id: "one".into(),
|
||||
description: "One".into(),
|
||||
preferred_trigger: Some("CTRL+o".into()),
|
||||
}];
|
||||
let persisted = vec![BoundShortcut {
|
||||
id: "one".into(),
|
||||
description: None,
|
||||
trigger_description: None,
|
||||
}];
|
||||
assert!(shortcut_ids_match(&entries, &persisted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_id_match_rejects_empty() {
|
||||
assert!(!shortcut_ids_match(&[], &[]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const GNOME_SHELL_DESTINATION: &str = "org.gnome.Shell";
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const GNOME_SHELL_PATH: &str = "/org/gnome/Shell";
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const GNOME_SHELL_INTERFACE: &str = "org.gnome.Shell";
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const GNOME_SHELL_EVAL_TIMEOUT: Duration = Duration::from_millis(1_500);
|
||||
|
||||
pub fn is_safe_shell_eval_token(token: &str) -> bool {
|
||||
if token.is_empty() || token.len() > 128 {
|
||||
return false;
|
||||
}
|
||||
token
|
||||
.bytes()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
|
||||
}
|
||||
|
||||
pub fn is_gnome_eval_disabled_via_env() -> bool {
|
||||
match std::env::var("FLUXER_PORTALS_GNOME_EVAL") {
|
||||
Ok(v) => {
|
||||
let lower = v.to_ascii_lowercase();
|
||||
lower == "0" || lower == "false" || lower == "no"
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_shell_eval_pid_payload(payload: &str) -> Option<u32> {
|
||||
let trimmed = payload.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stripped = trimmed
|
||||
.trim_start_matches(['[', ' ', '\t'])
|
||||
.trim_end_matches([']', ' ', '\t', ','])
|
||||
.trim();
|
||||
if stripped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut end = 0;
|
||||
for (i, ch) in stripped.char_indices() {
|
||||
if ch.is_ascii_digit() {
|
||||
end = i + ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end == 0 {
|
||||
return None;
|
||||
}
|
||||
let number: u64 = stripped[..end].parse().ok()?;
|
||||
if number == 0 || number > u32::MAX as u64 {
|
||||
return None;
|
||||
}
|
||||
Some(number as u32)
|
||||
}
|
||||
|
||||
pub fn build_window_pid_script(token: &str) -> String {
|
||||
format!(
|
||||
"global.get_window_actors().map(a=>a.meta_window).filter(w=>w.get_id&&w.get_id().toString()===\"{token}\").map(w=>w.get_pid())[0]"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve_gnome_shell_window_pid(token: &str) -> Result<Option<u32>, String> {
|
||||
if is_gnome_eval_disabled_via_env() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !is_safe_shell_eval_token(token) {
|
||||
return Err("resolveWindowPid: token failed validation".into());
|
||||
}
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|err| format!("openSessionBus failed: {err}"))?
|
||||
.method_timeout(GNOME_SHELL_EVAL_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|err| format!("openSessionBus failed: {err}"))?;
|
||||
let proxy = zbus::blocking::Proxy::new(
|
||||
&conn,
|
||||
GNOME_SHELL_DESTINATION,
|
||||
GNOME_SHELL_PATH,
|
||||
GNOME_SHELL_INTERFACE,
|
||||
)
|
||||
.map_err(|err| format!("shell Eval failed: {err}"))?;
|
||||
let script = build_window_pid_script(token);
|
||||
let (success, payload): (bool, String) = proxy
|
||||
.call("Eval", &(script.as_str(),))
|
||||
.map_err(|err| format!("shell Eval failed: {err}"))?;
|
||||
if !success {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(parse_shell_eval_pid_payload(&payload))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_gnome_shell_window_pid(_token: &str) -> Result<Option<u32>, String> {
|
||||
Err("not supported on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_safe_shell_eval_token_accepts_simple() {
|
||||
assert!(is_safe_shell_eval_token("abc"));
|
||||
assert!(is_safe_shell_eval_token("Window_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_shell_eval_token_rejects_metacharacters() {
|
||||
assert!(!is_safe_shell_eval_token(""));
|
||||
assert!(!is_safe_shell_eval_token("\"; system('rm -rf'); \""));
|
||||
assert!(!is_safe_shell_eval_token("abc def"));
|
||||
assert!(!is_safe_shell_eval_token("abc-def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_shell_eval_token_rejects_overlong() {
|
||||
assert!(!is_safe_shell_eval_token(&"a".repeat(129)));
|
||||
assert!(is_safe_shell_eval_token(&"a".repeat(128)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_eval_pid_payload_plain_integer() {
|
||||
assert_eq!(parse_shell_eval_pid_payload("12345"), Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_eval_pid_payload_array_wrapped() {
|
||||
assert_eq!(parse_shell_eval_pid_payload("[12345]"), Some(12345));
|
||||
assert_eq!(parse_shell_eval_pid_payload("[ 12345 ]"), Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_eval_pid_payload_rejects_garbage() {
|
||||
assert_eq!(parse_shell_eval_pid_payload(""), None);
|
||||
assert_eq!(parse_shell_eval_pid_payload("undefined"), None);
|
||||
assert_eq!(parse_shell_eval_pid_payload("0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_window_pid_script_splices_token() {
|
||||
let s = build_window_pid_script("Window_42");
|
||||
assert!(s.contains("===\"Window_42\""));
|
||||
assert!(s.contains("global.get_window_actors()"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use zbus::{
|
||||
blocking::{Connection, Proxy},
|
||||
zvariant::{OwnedValue, Value},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn value_of_owned(value: &OwnedValue) -> &Value<'_> {
|
||||
use std::ops::Deref as _;
|
||||
value.deref()
|
||||
}
|
||||
|
||||
pub const KWIN_DESTINATION: &str = "org.kde.KWin";
|
||||
pub const KWIN_WINDOW_INTERFACE: &str = "org.kde.KWin.Window";
|
||||
pub const PROPERTIES_INTERFACE: &str = "org.freedesktop.DBus.Properties";
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const REQUEST_TIMEOUT: Duration = Duration::from_millis(1_500);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResolveError {
|
||||
InvalidToken,
|
||||
DbusOpenFailed,
|
||||
DbusCallFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResolveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::InvalidToken => "InvalidToken",
|
||||
Self::DbusOpenFailed => "DbusOpenFailed",
|
||||
Self::DbusCallFailed => "DbusCallFailed",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_safe_kwin_path_segment(token: &str) -> bool {
|
||||
if token.is_empty() {
|
||||
return false;
|
||||
}
|
||||
token
|
||||
.bytes()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
|
||||
}
|
||||
|
||||
pub fn build_kwin_window_path(token: &str) -> String {
|
||||
let mut out = String::with_capacity("/org/kde/KWin/Window/".len() + token.len());
|
||||
out.push_str("/org/kde/KWin/Window/");
|
||||
out.push_str(token);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve_kwin_window_pid(token: &str) -> Result<Option<u32>, ResolveError> {
|
||||
if !is_safe_kwin_path_segment(token) {
|
||||
return Err(ResolveError::InvalidToken);
|
||||
}
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|_| ResolveError::DbusOpenFailed)?
|
||||
.method_timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| ResolveError::DbusOpenFailed)?;
|
||||
Ok(resolve_kwin_window_pid_on(&conn, token))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve_kwin_window_pid_on(conn: &Connection, token: &str) -> Option<u32> {
|
||||
let path = build_kwin_window_path(token);
|
||||
let proxy = Proxy::new(conn, KWIN_DESTINATION, path.as_str(), PROPERTIES_INTERFACE).ok()?;
|
||||
let reply: OwnedValue = proxy.call("Get", &(KWIN_WINDOW_INTERFACE, "pid")).ok()?;
|
||||
integer_from_variant(value_of_owned(&reply))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn integer_from_variant(value: &Value<'_>) -> Option<u32> {
|
||||
let raw: i64 = match value {
|
||||
Value::U8(v) => *v as i64,
|
||||
Value::U16(v) => *v as i64,
|
||||
Value::U32(v) => *v as i64,
|
||||
Value::U64(v) => *v as i64,
|
||||
Value::I16(v) => *v as i64,
|
||||
Value::I32(v) => *v as i64,
|
||||
Value::I64(v) => *v,
|
||||
Value::Value(inner) => return integer_from_variant(inner),
|
||||
_ => return None,
|
||||
};
|
||||
if raw <= 0 || raw > u32::MAX as i64 {
|
||||
return None;
|
||||
}
|
||||
Some(raw as u32)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_kwin_window_pid(_token: &str) -> Result<Option<u32>, ResolveError> {
|
||||
Err(ResolveError::DbusOpenFailed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_safe_kwin_path_segment_accepts_plain_alnum_underscore() {
|
||||
assert!(is_safe_kwin_path_segment("abc"));
|
||||
assert!(is_safe_kwin_path_segment("123"));
|
||||
assert!(is_safe_kwin_path_segment("aZ_9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_kwin_path_segment_rejects_empty() {
|
||||
assert!(!is_safe_kwin_path_segment(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_kwin_path_segment_rejects_traversal_and_shell_meta() {
|
||||
assert!(!is_safe_kwin_path_segment("../etc"));
|
||||
assert!(!is_safe_kwin_path_segment("a/b"));
|
||||
assert!(!is_safe_kwin_path_segment("$(rm -rf)"));
|
||||
assert!(!is_safe_kwin_path_segment("a;b"));
|
||||
assert!(!is_safe_kwin_path_segment("a-b"));
|
||||
assert!(!is_safe_kwin_path_segment("a.b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_kwin_window_path_shapes_path_correctly() {
|
||||
assert_eq!(
|
||||
build_kwin_window_path("abc123"),
|
||||
"/org/kde/KWin/Window/abc123"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod background;
|
||||
pub mod env;
|
||||
pub mod filechooser;
|
||||
pub mod global_shortcuts;
|
||||
pub mod gnome_shell;
|
||||
pub mod kwin;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod portal;
|
||||
pub mod settings;
|
||||
pub mod x11;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use napi_bindings::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod napi_bindings {
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
Env, Status,
|
||||
bindgen_prelude::{Array, AsyncTask, Function, Object, Result, Task, ToNapiValue},
|
||||
sys,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::{
|
||||
background::{self, RequestOptions, RequestResult},
|
||||
filechooser::{self, FileChooserResult, Filter, FilterRule, Mode, Options},
|
||||
global_shortcuts::{self, BoundShortcut, ConfigureResult, ShortcutEntry, ShortcutEvent},
|
||||
gnome_shell, kwin,
|
||||
settings::{self, ChangeEvent, ChangePayload, ColorScheme, Contrast},
|
||||
x11,
|
||||
};
|
||||
|
||||
const SETTINGS_EVENT_QUEUE_LIMIT: usize = 128;
|
||||
const SHORTCUT_EVENT_QUEUE_LIMIT: usize = 128;
|
||||
|
||||
fn generic_error(reason: impl Into<String>) -> napi::Error {
|
||||
napi::Error::new(Status::GenericFailure, reason.into())
|
||||
}
|
||||
|
||||
fn invalid_arg(reason: impl Into<String>) -> napi::Error {
|
||||
napi::Error::new(Status::InvalidArg, reason.into())
|
||||
}
|
||||
|
||||
fn read_string_field(object: &Object, key: &str) -> Option<String> {
|
||||
object.get::<String>(key).ok().flatten()
|
||||
}
|
||||
|
||||
fn read_string_field_or_empty(object: &Object, key: &str) -> String {
|
||||
read_string_field(object, key).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_bool_field(object: &Object, key: &str) -> Option<bool> {
|
||||
object.get::<bool>(key).ok().flatten()
|
||||
}
|
||||
|
||||
fn read_object_field<'a>(object: &Object<'a>, key: &str) -> Option<Object<'a>> {
|
||||
object.get::<Object>(key).ok().flatten()
|
||||
}
|
||||
|
||||
fn read_array_field<'a>(object: &Object<'a>, key: &str) -> Option<Array<'a>> {
|
||||
object.get::<Array>(key).ok().flatten()
|
||||
}
|
||||
|
||||
pub struct ResolveKwinTask {
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Task for ResolveKwinTask {
|
||||
type Output = Option<u32>;
|
||||
type JsValue = Option<u32>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
kwin::resolve_kwin_window_pid(&self.token)
|
||||
.map_err(|err| generic_error(format!("resolveKwinWindowPid: {err}")))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "resolveKwinWindowPid")]
|
||||
pub fn resolve_kwin_window_pid(token: String) -> Result<AsyncTask<ResolveKwinTask>> {
|
||||
Ok(AsyncTask::new(ResolveKwinTask { token }))
|
||||
}
|
||||
|
||||
pub struct ResolveX11Task {
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Task for ResolveX11Task {
|
||||
type Output = Option<u32>;
|
||||
type JsValue = Option<u32>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
x11::resolve_x11_window_pid(&self.token)
|
||||
.map_err(|err| generic_error(format!("resolveX11WindowPid: {err}")))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "resolveX11WindowPid")]
|
||||
pub fn resolve_x11_window_pid(token: String) -> Result<AsyncTask<ResolveX11Task>> {
|
||||
Ok(AsyncTask::new(ResolveX11Task { token }))
|
||||
}
|
||||
|
||||
pub struct ResolveWindowPidTask {
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Task for ResolveWindowPidTask {
|
||||
type Output = Option<u32>;
|
||||
type JsValue = Option<u32>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
gnome_shell::resolve_gnome_shell_window_pid(&self.token).map_err(generic_error)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "resolveWindowPid")]
|
||||
pub fn resolve_window_pid(spec: Object) -> Result<AsyncTask<ResolveWindowPidTask>> {
|
||||
let backend = read_string_field(&spec, "backend")
|
||||
.ok_or_else(|| invalid_arg("spec.backend must be a string"))?;
|
||||
if backend != "gnome-shell-eval" {
|
||||
return Err(invalid_arg("spec.backend must be 'gnome-shell-eval'"));
|
||||
}
|
||||
let token = read_string_field(&spec, "token")
|
||||
.ok_or_else(|| invalid_arg("spec.token must be a string"))?;
|
||||
Ok(AsyncTask::new(ResolveWindowPidTask { token }))
|
||||
}
|
||||
|
||||
fn parse_filter_rule(object: &Object) -> Result<FilterRule> {
|
||||
let kind = object
|
||||
.get::<u32>("kind")
|
||||
.map_err(|err| invalid_arg(err.reason.clone()))?
|
||||
.ok_or_else(|| invalid_arg("rule.kind must be a number"))?;
|
||||
if kind > 1 {
|
||||
return Err(invalid_arg("rule.kind must be 0 (glob) or 1 (mime-type)"));
|
||||
}
|
||||
let pattern = read_string_field(object, "pattern")
|
||||
.ok_or_else(|| invalid_arg("rule.pattern must be a string"))?;
|
||||
Ok(FilterRule { kind, pattern })
|
||||
}
|
||||
|
||||
fn parse_filter(object: &Object) -> Result<Filter> {
|
||||
let name = read_string_field(object, "name")
|
||||
.ok_or_else(|| invalid_arg("filter.name must be a string"))?;
|
||||
let rules_array = read_array_field(object, "rules")
|
||||
.ok_or_else(|| invalid_arg("filter.rules must be an array"))?;
|
||||
let mut rules = Vec::with_capacity(rules_array.len() as usize);
|
||||
for i in 0..rules_array.len() {
|
||||
let rule_obj = rules_array
|
||||
.get::<Object>(i)
|
||||
.map_err(|err| invalid_arg(err.reason.clone()))?
|
||||
.ok_or_else(|| invalid_arg("rule must be an object"))?;
|
||||
rules.push(parse_filter_rule(&rule_obj)?);
|
||||
}
|
||||
Ok(Filter { name, rules })
|
||||
}
|
||||
|
||||
fn parse_filechooser_options(object: &Object) -> Result<Options> {
|
||||
let parent_window = read_string_field_or_empty(object, "parentWindow");
|
||||
let title = read_string_field_or_empty(object, "title");
|
||||
let accept_label = read_string_field(object, "acceptLabel");
|
||||
let modal = read_bool_field(object, "modal").unwrap_or(true);
|
||||
let multiple = read_bool_field(object, "multiple").unwrap_or(false);
|
||||
let directory = read_bool_field(object, "directory").unwrap_or(false);
|
||||
let current_folder = read_string_field(object, "currentFolder");
|
||||
let current_name = read_string_field(object, "currentName");
|
||||
let current_file = read_string_field(object, "currentFile");
|
||||
let filters = if let Some(array) = read_array_field(object, "filters") {
|
||||
let mut out = Vec::with_capacity(array.len() as usize);
|
||||
for i in 0..array.len() {
|
||||
let f_obj = array
|
||||
.get::<Object>(i)
|
||||
.map_err(|err| invalid_arg(err.reason.clone()))?
|
||||
.ok_or_else(|| invalid_arg("filter must be an object"))?;
|
||||
out.push(parse_filter(&f_obj)?);
|
||||
}
|
||||
out
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let current_filter = if let Some(obj) = read_object_field(object, "currentFilter") {
|
||||
Some(parse_filter(&obj)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Options {
|
||||
parent_window,
|
||||
title,
|
||||
accept_label,
|
||||
modal,
|
||||
multiple,
|
||||
directory,
|
||||
current_folder,
|
||||
current_name,
|
||||
current_file,
|
||||
filters,
|
||||
current_filter,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct FileChooserTask {
|
||||
mode: Mode,
|
||||
options: Options,
|
||||
}
|
||||
|
||||
impl Task for FileChooserTask {
|
||||
type Output = FileChooserResult;
|
||||
type JsValue = Object<'static>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
filechooser::invoke(self.mode, self.options.clone())
|
||||
.map_err(|err| generic_error(format!("FileChooser portal: {err}")))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
let mut obj = Object::new(&env)?;
|
||||
obj.set("cancelled", output.cancelled)?;
|
||||
let mut array = env.create_array(output.uris.len() as u32)?;
|
||||
for (i, uri) in output.uris.iter().enumerate() {
|
||||
array.set(i as u32, uri.as_str())?;
|
||||
}
|
||||
obj.set("uris", array)?;
|
||||
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "openFile")]
|
||||
pub fn open_file(options: Object) -> Result<AsyncTask<FileChooserTask>> {
|
||||
let parsed = parse_filechooser_options(&options)?;
|
||||
Ok(AsyncTask::new(FileChooserTask {
|
||||
mode: Mode::Open,
|
||||
options: parsed,
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi(js_name = "saveFile")]
|
||||
pub fn save_file(options: Object) -> Result<AsyncTask<FileChooserTask>> {
|
||||
let parsed = parse_filechooser_options(&options)?;
|
||||
Ok(AsyncTask::new(FileChooserTask {
|
||||
mode: Mode::Save,
|
||||
options: parsed,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_string_array_field(object: &Object, key: &str) -> Result<Vec<String>> {
|
||||
let Some(array) = read_array_field(object, key) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::with_capacity(array.len() as usize);
|
||||
for i in 0..array.len() {
|
||||
let value = array
|
||||
.get::<String>(i)
|
||||
.map_err(|err| invalid_arg(err.reason.clone()))?
|
||||
.ok_or_else(|| invalid_arg(format!("{key} entries must be strings")))?;
|
||||
out.push(value);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_background_options(object: &Object) -> Result<RequestOptions> {
|
||||
Ok(RequestOptions {
|
||||
reason: read_string_field(object, "reason"),
|
||||
autostart: read_bool_field(object, "autostart").unwrap_or(false),
|
||||
commandline: parse_string_array_field(object, "commandline")?,
|
||||
dbus_activatable: read_bool_field(object, "dbusActivatable").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct BackgroundTask {
|
||||
options: RequestOptions,
|
||||
}
|
||||
|
||||
impl Task for BackgroundTask {
|
||||
type Output = RequestResult;
|
||||
type JsValue = Object<'static>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
background::request_background(self.options.clone())
|
||||
.map_err(|err| generic_error(format!("Background portal: {err}")))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
let mut obj = Object::new(&env)?;
|
||||
obj.set("response", output.response)?;
|
||||
obj.set("cancelled", output.cancelled())?;
|
||||
obj.set("background", output.background)?;
|
||||
obj.set("autostart", output.autostart)?;
|
||||
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "requestBackground")]
|
||||
pub fn request_background_js(options: Object) -> Result<AsyncTask<BackgroundTask>> {
|
||||
let parsed = parse_background_options(&options)?;
|
||||
Ok(AsyncTask::new(BackgroundTask { options: parsed }))
|
||||
}
|
||||
|
||||
#[napi(js_name = "isAvailable")]
|
||||
pub fn is_available_js() -> bool {
|
||||
global_shortcuts::is_available()
|
||||
}
|
||||
|
||||
#[napi(js_name = "getPortalVersion")]
|
||||
pub fn get_portal_version_js() -> Option<u32> {
|
||||
global_shortcuts::get_portal_version()
|
||||
}
|
||||
|
||||
fn parse_shortcut_entries(array: Array) -> Result<Vec<ShortcutEntry>> {
|
||||
let mut entries = Vec::with_capacity(array.len() as usize);
|
||||
for i in 0..array.len() {
|
||||
let object = array
|
||||
.get::<Object>(i)
|
||||
.map_err(|err| invalid_arg(err.reason.clone()))?
|
||||
.ok_or_else(|| invalid_arg("shortcut entries must be objects"))?;
|
||||
let id = read_string_field(&object, "id")
|
||||
.ok_or_else(|| invalid_arg("shortcut.id must be a string"))?;
|
||||
let description = read_string_field(&object, "description")
|
||||
.ok_or_else(|| invalid_arg("shortcut.description must be a string"))?;
|
||||
entries.push(ShortcutEntry {
|
||||
id,
|
||||
description,
|
||||
preferred_trigger: read_string_field(&object, "preferredTrigger"),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn bound_shortcuts_to_array(env: &Env, shortcuts: &[BoundShortcut]) -> Result<Array<'static>> {
|
||||
let mut array = env.create_array(shortcuts.len() as u32)?;
|
||||
for (i, shortcut) in shortcuts.iter().enumerate() {
|
||||
let mut obj = Object::new(env)?;
|
||||
obj.set("id", shortcut.id.as_str())?;
|
||||
if let Some(description) = shortcut.description.as_deref() {
|
||||
obj.set("description", description)?;
|
||||
}
|
||||
if let Some(trigger) = shortcut.trigger_description.as_deref() {
|
||||
obj.set("triggerDescription", trigger)?;
|
||||
}
|
||||
array.set(i as u32, obj)?;
|
||||
}
|
||||
Ok(unsafe { std::mem::transmute::<Array<'_>, Array<'static>>(array) })
|
||||
}
|
||||
|
||||
pub enum NapiShortcutEvent {
|
||||
Activated { id: String },
|
||||
Deactivated { id: String },
|
||||
ShortcutsChanged { shortcuts: Vec<BoundShortcut> },
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl From<ShortcutEvent> for NapiShortcutEvent {
|
||||
fn from(event: ShortcutEvent) -> Self {
|
||||
match event {
|
||||
ShortcutEvent::Activated { id } => Self::Activated { id },
|
||||
ShortcutEvent::Deactivated { id } => Self::Deactivated { id },
|
||||
ShortcutEvent::ShortcutsChanged { shortcuts } => {
|
||||
Self::ShortcutsChanged { shortcuts }
|
||||
}
|
||||
ShortcutEvent::Closed => Self::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToNapiValue for NapiShortcutEvent {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
let mut obj = Object::new(&env)?;
|
||||
match event {
|
||||
Self::Activated { id } => {
|
||||
obj.set("type", "activated")?;
|
||||
obj.set("id", id)?;
|
||||
}
|
||||
Self::Deactivated { id } => {
|
||||
obj.set("type", "deactivated")?;
|
||||
obj.set("id", id)?;
|
||||
}
|
||||
Self::ShortcutsChanged { shortcuts } => {
|
||||
obj.set("type", "shortcuts-changed")?;
|
||||
obj.set("shortcuts", bound_shortcuts_to_array(&env, &shortcuts)?)?;
|
||||
}
|
||||
Self::Closed => {
|
||||
obj.set("type", "closed")?;
|
||||
}
|
||||
}
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
|
||||
}
|
||||
}
|
||||
|
||||
type ShortcutTsfn = Arc<
|
||||
ThreadsafeFunction<
|
||||
NapiShortcutEvent,
|
||||
UnknownReturnValue,
|
||||
NapiShortcutEvent,
|
||||
Status,
|
||||
false,
|
||||
true,
|
||||
SHORTCUT_EVENT_QUEUE_LIMIT,
|
||||
>,
|
||||
>;
|
||||
|
||||
pub struct ConfigureShortcutsTask {
|
||||
entries: Vec<ShortcutEntry>,
|
||||
state: Arc<std::sync::Mutex<Option<global_shortcuts::Subscription>>>,
|
||||
callback: ShortcutTsfn,
|
||||
}
|
||||
|
||||
impl Task for ConfigureShortcutsTask {
|
||||
type Output = ConfigureResult;
|
||||
type JsValue = Object<'static>;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
let tsfn_for_cb = self.callback.clone();
|
||||
let callback = Arc::new(move |event: ShortcutEvent| {
|
||||
let _ = tsfn_for_cb.call(
|
||||
NapiShortcutEvent::from(event),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
});
|
||||
let (subscription, result) =
|
||||
global_shortcuts::Subscription::configure(self.entries.clone(), callback)
|
||||
.map_err(|err| generic_error(format!("GlobalShortcuts portal: {err}")))?;
|
||||
let mut guard = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| generic_error("global shortcuts lock poisoned"))?;
|
||||
if let Some(previous) = guard.replace(subscription) {
|
||||
previous.close();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
let mut obj = Object::new(&env)?;
|
||||
obj.set("action", output.action)?;
|
||||
obj.set(
|
||||
"shortcuts",
|
||||
bound_shortcuts_to_array(&env, &output.shortcuts)?,
|
||||
)?;
|
||||
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct GlobalShortcutsPortal {
|
||||
subscription: Arc<std::sync::Mutex<Option<global_shortcuts::Subscription>>>,
|
||||
callback: ShortcutTsfn,
|
||||
#[allow(dead_code)]
|
||||
app_id: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GlobalShortcutsPortal {
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
on_event: Function<NapiShortcutEvent, UnknownReturnValue>,
|
||||
app_id: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let callback: ShortcutTsfn = Arc::new(
|
||||
on_event
|
||||
.build_threadsafe_function::<NapiShortcutEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.max_queue_size::<SHORTCUT_EVENT_QUEUE_LIMIT>()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
generic_error(format!(
|
||||
"failed to create global shortcuts callback: {}",
|
||||
err.reason
|
||||
))
|
||||
})?,
|
||||
);
|
||||
Ok(Self {
|
||||
subscription: Arc::new(std::sync::Mutex::new(None)),
|
||||
callback,
|
||||
app_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn configure(&self, entries: Array) -> Result<AsyncTask<ConfigureShortcutsTask>> {
|
||||
let parsed = parse_shortcut_entries(entries)?;
|
||||
Ok(AsyncTask::new(ConfigureShortcutsTask {
|
||||
entries: parsed,
|
||||
state: self.subscription.clone(),
|
||||
callback: self.callback.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&self) -> Result<()> {
|
||||
if let Some(subscription) = self
|
||||
.subscription
|
||||
.lock()
|
||||
.map_err(|_| generic_error("global shortcuts lock poisoned"))?
|
||||
.take()
|
||||
{
|
||||
subscription.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlobalShortcutsPortal {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.subscription.lock()
|
||||
&& let Some(subscription) = guard.take()
|
||||
{
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "readColorScheme")]
|
||||
pub fn read_color_scheme_js() -> &'static str {
|
||||
settings::read_color_scheme().as_str()
|
||||
}
|
||||
|
||||
#[napi(js_name = "readContrast")]
|
||||
pub fn read_contrast_js() -> &'static str {
|
||||
settings::read_contrast().as_str()
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct AccentColorJs {
|
||||
pub r: f64,
|
||||
pub g: f64,
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "readAccentColor")]
|
||||
pub fn read_accent_color_js() -> Option<AccentColorJs> {
|
||||
settings::read_accent_color().map(|a| AccentColorJs {
|
||||
r: a.r,
|
||||
g: a.g,
|
||||
b: a.b,
|
||||
})
|
||||
}
|
||||
|
||||
pub enum NapiSettingsEvent {
|
||||
Uint32 {
|
||||
namespace: String,
|
||||
key: String,
|
||||
value: u32,
|
||||
},
|
||||
Accent {
|
||||
namespace: String,
|
||||
key: String,
|
||||
r: f64,
|
||||
g: f64,
|
||||
b: f64,
|
||||
},
|
||||
Unknown {
|
||||
namespace: String,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ChangeEvent> for NapiSettingsEvent {
|
||||
fn from(event: ChangeEvent) -> Self {
|
||||
match event.payload {
|
||||
ChangePayload::Uint32(v) => Self::Uint32 {
|
||||
namespace: event.namespace,
|
||||
key: event.key,
|
||||
value: v,
|
||||
},
|
||||
ChangePayload::Accent(a) => Self::Accent {
|
||||
namespace: event.namespace,
|
||||
key: event.key,
|
||||
r: a.r,
|
||||
g: a.g,
|
||||
b: a.b,
|
||||
},
|
||||
ChangePayload::Unknown => Self::Unknown {
|
||||
namespace: event.namespace,
|
||||
key: event.key,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToNapiValue for NapiSettingsEvent {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
let mut obj = Object::new(&env)?;
|
||||
match event {
|
||||
Self::Uint32 {
|
||||
namespace,
|
||||
key,
|
||||
value,
|
||||
} => {
|
||||
obj.set("namespace", namespace)?;
|
||||
obj.set("key", key)?;
|
||||
obj.set("uint32", value)?;
|
||||
}
|
||||
Self::Accent {
|
||||
namespace,
|
||||
key,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
} => {
|
||||
obj.set("namespace", namespace)?;
|
||||
obj.set("key", key)?;
|
||||
let mut accent = Object::new(&env)?;
|
||||
accent.set("r", r)?;
|
||||
accent.set("g", g)?;
|
||||
accent.set("b", b)?;
|
||||
obj.set("accent", accent)?;
|
||||
}
|
||||
Self::Unknown { namespace, key } => {
|
||||
obj.set("namespace", namespace)?;
|
||||
obj.set("key", key)?;
|
||||
}
|
||||
}
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsTsfn = Arc<
|
||||
ThreadsafeFunction<
|
||||
NapiSettingsEvent,
|
||||
UnknownReturnValue,
|
||||
NapiSettingsEvent,
|
||||
Status,
|
||||
false,
|
||||
true,
|
||||
SETTINGS_EVENT_QUEUE_LIMIT,
|
||||
>,
|
||||
>;
|
||||
|
||||
#[napi]
|
||||
pub struct Settings {
|
||||
subscription: std::sync::Mutex<Option<settings::Subscription>>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Settings {
|
||||
#[napi(constructor)]
|
||||
pub fn new(on_change: Function<NapiSettingsEvent, UnknownReturnValue>) -> Result<Self> {
|
||||
let tsfn: SettingsTsfn = Arc::new(
|
||||
on_change
|
||||
.build_threadsafe_function::<NapiSettingsEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.max_queue_size::<SETTINGS_EVENT_QUEUE_LIMIT>()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
generic_error(format!(
|
||||
"failed to create settings callback: {}",
|
||||
err.reason
|
||||
))
|
||||
})?,
|
||||
);
|
||||
let tsfn_for_cb = tsfn.clone();
|
||||
let callback = Arc::new(move |event: ChangeEvent| {
|
||||
let _ = tsfn_for_cb.call(
|
||||
NapiSettingsEvent::from(event),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
});
|
||||
let sub = settings::Subscription::new(callback)
|
||||
.map_err(|err| generic_error(format!("Settings subscribe failed: {err}")))?;
|
||||
Ok(Self {
|
||||
subscription: std::sync::Mutex::new(Some(sub)),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&self) -> Result<()> {
|
||||
if let Some(sub) = self
|
||||
.subscription
|
||||
.lock()
|
||||
.map_err(|_| generic_error("settings lock poisoned"))?
|
||||
.take()
|
||||
{
|
||||
sub.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Settings {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.subscription.lock()
|
||||
&& let Some(sub) = guard.take()
|
||||
{
|
||||
sub.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _link_unused(_c: Contrast, _s: ColorScheme) {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod napi_bindings {}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
|
||||
|
||||
pub fn request_path(unique_bus_name: &str, handle_token: &str) -> String {
|
||||
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
|
||||
let mut out = String::with_capacity(40 + trimmed.len() + handle_token.len());
|
||||
out.push_str("/org/freedesktop/portal/desktop/request/");
|
||||
for ch in trimmed.chars() {
|
||||
out.push(if ch == '.' { '_' } else { ch });
|
||||
}
|
||||
out.push('/');
|
||||
out.push_str(handle_token);
|
||||
out
|
||||
}
|
||||
|
||||
static TOKEN_SEQ: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn mint_token(prefix: &str) -> String {
|
||||
let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
format!("{prefix}_{ms:x}_{seq:x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_path_sanitizes_unique_bus_name() {
|
||||
assert_eq!(
|
||||
request_path(":1.42", "fluxer_fc_open_1"),
|
||||
"/org/freedesktop/portal/desktop/request/1_42/fluxer_fc_open_1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_token_is_distinct_and_prefixed() {
|
||||
let a = mint_token("fluxer_fc_open");
|
||||
let b = mint_token("fluxer_fc_open");
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with("fluxer_fc_open_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use futures_lite::{FutureExt, StreamExt, future};
|
||||
#[cfg(target_os = "linux")]
|
||||
use zbus::{
|
||||
MatchRule, MessageStream,
|
||||
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
|
||||
message::Type as MessageType,
|
||||
zvariant::{OwnedValue, Value},
|
||||
};
|
||||
|
||||
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
pub const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
|
||||
pub const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const READ_TIMEOUT: Duration = Duration::from_millis(1_500);
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorScheme {
|
||||
NoPreference,
|
||||
PreferDark,
|
||||
PreferLight,
|
||||
}
|
||||
|
||||
impl ColorScheme {
|
||||
pub fn from_u32(value: u32) -> Self {
|
||||
match value {
|
||||
1 => Self::PreferDark,
|
||||
2 => Self::PreferLight,
|
||||
_ => Self::NoPreference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoPreference => "no-preference",
|
||||
Self::PreferDark => "prefer-dark",
|
||||
Self::PreferLight => "prefer-light",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Contrast {
|
||||
NoPreference,
|
||||
High,
|
||||
}
|
||||
|
||||
impl Contrast {
|
||||
pub fn from_u32(value: u32) -> Self {
|
||||
match value {
|
||||
1 => Self::High,
|
||||
_ => Self::NoPreference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoPreference => "no-preference",
|
||||
Self::High => "high",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AccentColor {
|
||||
pub r: f64,
|
||||
pub g: f64,
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
pub fn classify_accent_color(r: f64, g: f64, b: f64) -> Option<AccentColor> {
|
||||
if r < 0.0 || g < 0.0 || b < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(AccentColor { r, g, b })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChangeEvent {
|
||||
pub namespace: String,
|
||||
pub key: String,
|
||||
pub payload: ChangePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChangePayload {
|
||||
Uint32(u32),
|
||||
Accent(AccentColor),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_blocking_connection() -> zbus::Result<BlockingConnection> {
|
||||
zbus::blocking::connection::Builder::session()?
|
||||
.method_timeout(READ_TIMEOUT)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_uint32_setting(key: &str) -> Option<u32> {
|
||||
let conn = open_blocking_connection().ok()?;
|
||||
let proxy =
|
||||
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
|
||||
let value: OwnedValue = proxy.call("Read", &(APPEARANCE_NAMESPACE, key)).ok()?;
|
||||
crate::kwin::integer_from_variant(crate::kwin::value_of_owned(&value))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn read_color_scheme() -> ColorScheme {
|
||||
read_uint32_setting("color-scheme")
|
||||
.map(ColorScheme::from_u32)
|
||||
.unwrap_or(ColorScheme::NoPreference)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn read_contrast() -> Contrast {
|
||||
read_uint32_setting("contrast")
|
||||
.map(Contrast::from_u32)
|
||||
.unwrap_or(Contrast::NoPreference)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn read_accent_color() -> Option<AccentColor> {
|
||||
let conn = open_blocking_connection().ok()?;
|
||||
let proxy =
|
||||
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
|
||||
let value: OwnedValue = proxy
|
||||
.call("Read", &(APPEARANCE_NAMESPACE, "accent-color"))
|
||||
.ok()?;
|
||||
extract_accent_from_variant(crate::kwin::value_of_owned(&value))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn extract_accent_from_variant(value: &Value<'_>) -> Option<AccentColor> {
|
||||
let inner: &Value<'_> = match value {
|
||||
Value::Value(b) => b.as_ref(),
|
||||
other => other,
|
||||
};
|
||||
let Value::Structure(structure) = inner else {
|
||||
return None;
|
||||
};
|
||||
let fields = structure.fields();
|
||||
if fields.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let r = double_from_value(&fields[0])?;
|
||||
let g = double_from_value(&fields[1])?;
|
||||
let b = double_from_value(&fields[2])?;
|
||||
classify_accent_color(r, g, b)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn double_from_value(value: &Value<'_>) -> Option<f64> {
|
||||
match value {
|
||||
Value::F64(v) => Some(*v),
|
||||
Value::Value(b) => double_from_value(b.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn read_color_scheme() -> ColorScheme {
|
||||
ColorScheme::NoPreference
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn read_contrast() -> Contrast {
|
||||
Contrast::NoPreference
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn read_accent_color() -> Option<AccentColor> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
type ChangeCallback = Arc<dyn Fn(ChangeEvent) + Send + Sync + 'static>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct Subscription {
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
thread: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Subscription {
|
||||
pub fn new(callback: ChangeCallback) -> Result<Self, String> {
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
|
||||
let stop_for_thread = stop_flag.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("fluxer-linux-portals-settings".to_string())
|
||||
.spawn(move || {
|
||||
let setup = future::block_on(async {
|
||||
let conn = zbus::Connection::session().await?;
|
||||
let rule = MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.interface(SETTINGS_INTERFACE)?
|
||||
.member("SettingChanged")?
|
||||
.build();
|
||||
let stream = MessageStream::for_match_rule(rule, &conn, Some(32)).await?;
|
||||
zbus::Result::Ok((conn, stream))
|
||||
});
|
||||
let (_conn, mut stream) = match setup {
|
||||
Ok(parts) => {
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
parts
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = ready_tx.send(Err(err.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
while !stop_for_thread.load(Ordering::Acquire) {
|
||||
let timeout = async {
|
||||
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
|
||||
None::<zbus::Result<zbus::Message>>
|
||||
};
|
||||
match future::block_on(stream.next().or(timeout)) {
|
||||
Some(Ok(message)) => {
|
||||
if let Some(event) = parse_setting_changed(&message)
|
||||
&& event.namespace == APPEARANCE_NAMESPACE
|
||||
{
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => break,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) {
|
||||
Ok(Ok(())) => Ok(Self {
|
||||
stop_flag,
|
||||
thread: Mutex::new(Some(thread)),
|
||||
}),
|
||||
Ok(Err(err)) => {
|
||||
let _ = thread.join();
|
||||
Err(err)
|
||||
}
|
||||
Err(err) => {
|
||||
stop_flag.store(true, Ordering::Release);
|
||||
if matches!(err, mpsc::RecvTimeoutError::Disconnected) {
|
||||
let _ = thread.join();
|
||||
}
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.stop_flag.store(true, Ordering::Release);
|
||||
if let Ok(mut thread) = self.thread.lock()
|
||||
&& let Some(t) = thread.take()
|
||||
{
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_setting_changed(message: &zbus::Message) -> Option<ChangeEvent> {
|
||||
let body = message.body();
|
||||
let (namespace, key, value): (String, String, OwnedValue) = body.deserialize().ok()?;
|
||||
let value_ref = crate::kwin::value_of_owned(&value);
|
||||
let payload = classify_payload(&key, value_ref);
|
||||
Some(ChangeEvent {
|
||||
namespace,
|
||||
key,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn classify_payload(_key: &str, value: &Value<'_>) -> ChangePayload {
|
||||
if let Some(n) = crate::kwin::integer_from_variant(value) {
|
||||
return ChangePayload::Uint32(n);
|
||||
}
|
||||
if let Some(accent) = extract_accent_from_variant(value) {
|
||||
return ChangePayload::Accent(accent);
|
||||
}
|
||||
ChangePayload::Unknown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn color_scheme_maps_to_strings_per_typescript_union() {
|
||||
assert_eq!(ColorScheme::from_u32(0).as_str(), "no-preference");
|
||||
assert_eq!(ColorScheme::from_u32(1).as_str(), "prefer-dark");
|
||||
assert_eq!(ColorScheme::from_u32(2).as_str(), "prefer-light");
|
||||
assert_eq!(ColorScheme::from_u32(99).as_str(), "no-preference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_maps_to_strings_per_typescript_union() {
|
||||
assert_eq!(Contrast::from_u32(0).as_str(), "no-preference");
|
||||
assert_eq!(Contrast::from_u32(1).as_str(), "high");
|
||||
assert_eq!(Contrast::from_u32(99).as_str(), "no-preference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_accent_color_treats_negative_as_no_preference() {
|
||||
assert_eq!(classify_accent_color(-1.0, -1.0, -1.0), None);
|
||||
assert_eq!(classify_accent_color(-0.0001, 0.5, 0.5), None);
|
||||
let accent = classify_accent_color(0.1, 0.2, 0.3).unwrap();
|
||||
assert_eq!(accent.r, 0.1);
|
||||
assert_eq!(accent.g, 0.2);
|
||||
assert_eq!(accent.b, 0.3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::os::raw::c_long;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResolveError {
|
||||
InvalidToken,
|
||||
LibX11Unavailable,
|
||||
MissingSymbol,
|
||||
DisplayUnavailable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResolveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::InvalidToken => "InvalidToken",
|
||||
Self::LibX11Unavailable => "LibX11Unavailable",
|
||||
Self::MissingSymbol => "MissingSymbol",
|
||||
Self::DisplayUnavailable => "DisplayUnavailable",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_window_token(token: &str) -> Option<u32> {
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed: u64 = if let Some(stripped) = token
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| token.strip_prefix("0X"))
|
||||
{
|
||||
u64::from_str_radix(stripped, 16).ok()?
|
||||
} else {
|
||||
token.parse::<u64>().ok()?
|
||||
};
|
||||
if parsed == 0 {
|
||||
return None;
|
||||
}
|
||||
u32::try_from(parsed).ok()
|
||||
}
|
||||
|
||||
pub fn pid_from_long(value: c_long) -> Option<u32> {
|
||||
if value <= 0 {
|
||||
return None;
|
||||
}
|
||||
if (value as u64) > u32::MAX as u64 {
|
||||
return None;
|
||||
}
|
||||
Some(value as u32)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve_x11_window_pid(token: &str) -> Result<Option<u32>, ResolveError> {
|
||||
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
let window = parse_window_token(token).ok_or(ResolveError::InvalidToken)?;
|
||||
let (conn, _screen) =
|
||||
RustConnection::connect(None).map_err(|_| ResolveError::DisplayUnavailable)?;
|
||||
|
||||
let atom_cookie = conn
|
||||
.intern_atom(true, b"_NET_WM_PID")
|
||||
.map_err(|_| ResolveError::DisplayUnavailable)?;
|
||||
let atom = atom_cookie
|
||||
.reply()
|
||||
.map_err(|_| ResolveError::DisplayUnavailable)?
|
||||
.atom;
|
||||
if atom == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let reply = conn
|
||||
.get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1)
|
||||
.map_err(|_| ResolveError::DisplayUnavailable)?
|
||||
.reply()
|
||||
.map_err(|_| ResolveError::DisplayUnavailable)?;
|
||||
|
||||
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 32 || reply.value_len < 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(values) = reply.value32() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pid = values.collect::<Vec<u32>>();
|
||||
let Some(&first) = pid.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(pid_from_long(first as c_long))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_x11_window_pid(_token: &str) -> Result<Option<u32>, ResolveError> {
|
||||
Err(ResolveError::LibX11Unavailable)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_window_token_accepts_decimal_and_hexadecimal_xids() {
|
||||
assert_eq!(parse_window_token("123"), Some(123));
|
||||
assert_eq!(parse_window_token("0x3a00007"), Some(0x3a00007));
|
||||
assert_eq!(parse_window_token("0X3a00007"), Some(0x3a00007));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_window_token_rejects_invalid_or_zero_xids() {
|
||||
assert_eq!(parse_window_token(""), None);
|
||||
assert_eq!(parse_window_token("0"), None);
|
||||
assert_eq!(parse_window_token("0x"), None);
|
||||
assert_eq!(parse_window_token("0xG"), None);
|
||||
assert_eq!(parse_window_token("../123"), None);
|
||||
assert_eq!(parse_window_token("123abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_from_long_validates_positive_uint32_process_ids() {
|
||||
assert_eq!(pid_from_long(1), Some(1));
|
||||
assert_eq!(pid_from_long(42_424), Some(42_424));
|
||||
assert_eq!(pid_from_long(0), None);
|
||||
assert_eq!(pid_from_long(-1), None);
|
||||
assert_eq!(pid_from_long(u32::MAX as c_long + 1), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user