mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(desktop): portal-based Linux global shortcuts and PTT (#3185)
This commit is contained in:
1 parent
5799ef705d
commit
8f58fcc4c4
149 files changed
+26849
-8769
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ pub mod gnome_shell;
|
||||
pub mod kwin;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod portal;
|
||||
pub mod session_monitor;
|
||||
pub mod settings;
|
||||
pub mod x11;
|
||||
|
||||
@@ -19,7 +20,7 @@ mod napi_bindings {
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
Env, Status,
|
||||
Env, JsDeferred, Status,
|
||||
bindgen_prelude::{Array, AsyncTask, Function, Object, Result, Task, ToNapiValue},
|
||||
sys,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
|
||||
@@ -29,14 +30,18 @@ mod napi_bindings {
|
||||
use crate::{
|
||||
background::{self, RequestOptions, RequestResult},
|
||||
filechooser::{self, FileChooserResult, Filter, FilterRule, Mode, Options},
|
||||
global_shortcuts::{self, BoundShortcut, ConfigureResult, ShortcutEntry, ShortcutEvent},
|
||||
global_shortcuts::{
|
||||
BindOutcome, DEFAULT_SESSION_TOKEN, Desktop, GlobalShortcutsClient, OpenResult,
|
||||
PortalEvent, PortalOptions, Responder, ShortcutBinding, ShortcutDefinition,
|
||||
is_valid_token, validate_definitions,
|
||||
},
|
||||
gnome_shell, kwin,
|
||||
session_monitor::{MonitorEvent, SessionMonitor},
|
||||
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())
|
||||
@@ -310,73 +315,43 @@ mod napi_bindings {
|
||||
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);
|
||||
fn parse_shortcut_definitions(array: Array) -> Result<Vec<ShortcutDefinition>> {
|
||||
let mut shortcuts = 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"))?;
|
||||
.ok_or_else(|| invalid_arg("shortcuts 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 {
|
||||
shortcuts.push(ShortcutDefinition {
|
||||
id,
|
||||
description,
|
||||
preferred_trigger: read_string_field(&object, "preferredTrigger"),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
validate_definitions(&shortcuts).map_err(|err| invalid_arg(err.message()))?;
|
||||
Ok(shortcuts)
|
||||
}
|
||||
|
||||
fn bound_shortcuts_to_array(env: &Env, shortcuts: &[BoundShortcut]) -> Result<Array<'static>> {
|
||||
fn bindings_to_array(env: &Env, shortcuts: &[ShortcutBinding]) -> 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)?;
|
||||
}
|
||||
obj.set("description", shortcut.description.as_deref())?;
|
||||
obj.set(
|
||||
"triggerDescription",
|
||||
shortcut.trigger_description.as_deref(),
|
||||
)?;
|
||||
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 {
|
||||
impl ToNapiValue for PortalEvent {
|
||||
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)?;
|
||||
@@ -391,137 +366,205 @@ mod napi_bindings {
|
||||
}
|
||||
Self::ShortcutsChanged { shortcuts } => {
|
||||
obj.set("type", "shortcuts-changed")?;
|
||||
obj.set("shortcuts", bound_shortcuts_to_array(&env, &shortcuts)?)?;
|
||||
obj.set("shortcuts", bindings_to_array(&env, &shortcuts)?)?;
|
||||
}
|
||||
Self::Closed => {
|
||||
obj.set("type", "closed")?;
|
||||
Self::SessionLost { reason } => {
|
||||
obj.set("type", "session-lost")?;
|
||||
obj.set("reason", reason.as_str())?;
|
||||
}
|
||||
Self::PortalAvailable => {
|
||||
obj.set("type", "portal-available")?;
|
||||
}
|
||||
}
|
||||
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 ToNapiValue for OpenResult {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, result: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
let mut obj = Object::new(&env)?;
|
||||
obj.set("version", result.version)?;
|
||||
obj.set("appIdSource", result.app_id_source.as_str())?;
|
||||
obj.set("uniqueName", result.unique_name)?;
|
||||
obj.set("listed", bindings_to_array(&env, &result.listed)?)?;
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
impl ToNapiValue for BindOutcome {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, outcome: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
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) })
|
||||
match outcome {
|
||||
Self::Bound(shortcuts) => {
|
||||
obj.set("outcome", "bound")?;
|
||||
obj.set("shortcuts", bindings_to_array(&env, &shortcuts)?)?;
|
||||
}
|
||||
Self::Cancelled => obj.set("outcome", "cancelled")?,
|
||||
Self::Denied => obj.set("outcome", "denied")?,
|
||||
Self::Failed(code) => {
|
||||
obj.set("outcome", "failed")?;
|
||||
obj.set("code", code)?;
|
||||
}
|
||||
}
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
|
||||
}
|
||||
}
|
||||
|
||||
type PortalEventTsfn =
|
||||
ThreadsafeFunction<PortalEvent, UnknownReturnValue, PortalEvent, Status, false, true, 0>;
|
||||
|
||||
type Settle<T> = JsDeferred<T, Box<dyn FnOnce(Env) -> Result<T>>>;
|
||||
|
||||
fn deferred_responder<T: ToNapiValue + 'static>(deferred: Settle<T>) -> Responder<T> {
|
||||
Responder::new(move |result| match result {
|
||||
Ok(value) => deferred.resolve(Box::new(move |_env| Ok(value))),
|
||||
Err(err) => deferred.reject(generic_error(err.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_promise<T: ToNapiValue + 'static>(
|
||||
env: &Env,
|
||||
) -> Result<(Responder<T>, Object<'static>)> {
|
||||
let (deferred, promise) = env.create_deferred::<T, Box<dyn FnOnce(Env) -> Result<T>>>()?;
|
||||
Ok((deferred_responder(deferred), unsafe {
|
||||
std::mem::transmute::<Object<'_>, Object<'static>>(promise)
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct GlobalShortcutsPortal {
|
||||
subscription: Arc<std::sync::Mutex<Option<global_shortcuts::Subscription>>>,
|
||||
callback: ShortcutTsfn,
|
||||
#[allow(dead_code)]
|
||||
app_id: Option<String>,
|
||||
client: GlobalShortcutsClient,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GlobalShortcutsPortal {
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
on_event: Function<NapiShortcutEvent, UnknownReturnValue>,
|
||||
app_id: Option<String>,
|
||||
on_event: Function<PortalEvent, UnknownReturnValue>,
|
||||
options: Object,
|
||||
) -> 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();
|
||||
let sandboxed = read_bool_field(&options, "sandboxed")
|
||||
.ok_or_else(|| invalid_arg("options.sandboxed must be a boolean"))?;
|
||||
let portal_app_id = read_string_field(&options, "portalAppId");
|
||||
let session_token = read_string_field(&options, "sessionToken")
|
||||
.unwrap_or_else(|| DEFAULT_SESSION_TOKEN.to_string());
|
||||
if !is_valid_token(&session_token) {
|
||||
return Err(invalid_arg(
|
||||
"options.sessionToken must only contain A-Z, a-z, 0-9 and _",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
let desktop = read_string_field(&options, "desktop")
|
||||
.map_or(Desktop::Other, |value| Desktop::parse(&value));
|
||||
let callback: PortalEventTsfn = on_event
|
||||
.build_threadsafe_function::<PortalEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
generic_error(format!(
|
||||
"failed to create global shortcuts callback: {}",
|
||||
err.reason
|
||||
))
|
||||
})?;
|
||||
let sink = Arc::new(move |event: PortalEvent| {
|
||||
let _ = callback.call(event, ThreadsafeFunctionCallMode::Blocking);
|
||||
});
|
||||
let client = GlobalShortcutsClient::spawn(
|
||||
PortalOptions {
|
||||
portal_app_id,
|
||||
sandboxed,
|
||||
session_token,
|
||||
desktop,
|
||||
},
|
||||
sink,
|
||||
)
|
||||
.map_err(|err| generic_error(format!("failed to start global shortcuts: {err}")))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn open(&self, env: Env) -> Result<Object<'static>> {
|
||||
let (reply, promise) = create_promise::<OpenResult>(&env)?;
|
||||
self.client.open(reply);
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn bind(
|
||||
&self,
|
||||
env: Env,
|
||||
shortcuts: Array,
|
||||
parent_window: String,
|
||||
) -> Result<Object<'static>> {
|
||||
let shortcuts = parse_shortcut_definitions(shortcuts)?;
|
||||
let (reply, promise) = create_promise::<BindOutcome>(&env)?;
|
||||
self.client.bind(shortcuts, parent_window, reply);
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn configure(&self, env: Env, parent_window: String) -> Result<Object<'static>> {
|
||||
let (reply, promise) = create_promise::<()>(&env)?;
|
||||
self.client.configure(parent_window, reply);
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&self) {
|
||||
self.client.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlobalShortcutsPortal {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.subscription.lock()
|
||||
&& let Some(subscription) = guard.take()
|
||||
{
|
||||
subscription.close();
|
||||
}
|
||||
impl ToNapiValue for MonitorEvent {
|
||||
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)?;
|
||||
obj.set(
|
||||
"type",
|
||||
match event {
|
||||
Self::ScreenLocked => "screen-locked",
|
||||
Self::ScreenUnlocked => "screen-unlocked",
|
||||
},
|
||||
)?;
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
|
||||
}
|
||||
}
|
||||
|
||||
type MonitorEventTsfn =
|
||||
ThreadsafeFunction<MonitorEvent, UnknownReturnValue, MonitorEvent, Status, false, true, 0>;
|
||||
|
||||
#[napi]
|
||||
pub struct SessionStateMonitor {
|
||||
monitor: SessionMonitor,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl SessionStateMonitor {
|
||||
#[napi(constructor)]
|
||||
pub fn new(on_event: Function<MonitorEvent, UnknownReturnValue>) -> Result<Self> {
|
||||
let callback: MonitorEventTsfn = on_event
|
||||
.build_threadsafe_function::<MonitorEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
generic_error(format!(
|
||||
"failed to create session monitor callback: {}",
|
||||
err.reason
|
||||
))
|
||||
})?;
|
||||
let monitor = SessionMonitor::spawn(Arc::new(move |event: MonitorEvent| {
|
||||
let _ = callback.call(event, ThreadsafeFunctionCallMode::Blocking);
|
||||
}))
|
||||
.map_err(|err| generic_error(format!("failed to start session monitor: {err}")))?;
|
||||
Ok(Self { monitor })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&self) {
|
||||
self.monitor.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
pub const INHIBIT_INTERFACE: &str = "org.freedesktop.portal.Inhibit";
|
||||
pub const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session";
|
||||
pub const BUS_NAME: &str = "org.freedesktop.DBus";
|
||||
pub const BUS_PATH: &str = "/org/freedesktop/DBus";
|
||||
pub const SETUP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const SHORT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
pub const QUERY_END: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MonitorEvent {
|
||||
ScreenLocked,
|
||||
ScreenUnlocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LockTracker {
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
impl LockTracker {
|
||||
pub fn update(&mut self, screensaver_active: bool) -> Option<MonitorEvent> {
|
||||
if screensaver_active == self.locked {
|
||||
return None;
|
||||
}
|
||||
self.locked = screensaver_active;
|
||||
Some(if screensaver_active {
|
||||
MonitorEvent::ScreenLocked
|
||||
} else {
|
||||
MonitorEvent::ScreenUnlocked
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use worker::SessionMonitor;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod worker {
|
||||
use std::{cell::RefCell, collections::HashMap, sync::Arc, thread};
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use async_io::Timer;
|
||||
use futures_lite::{FutureExt, StreamExt};
|
||||
use zbus::{
|
||||
Connection, MatchRule, Message, MessageStream,
|
||||
message::Type as MessageType,
|
||||
names::OwnedUniqueName,
|
||||
zvariant::{DynamicType, OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
|
||||
|
||||
type EventSink = Arc<dyn Fn(MonitorEvent) + Send + Sync + 'static>;
|
||||
type Results = HashMap<String, OwnedValue>;
|
||||
|
||||
pub struct SessionMonitor {
|
||||
shutdown: Sender<()>,
|
||||
}
|
||||
|
||||
impl SessionMonitor {
|
||||
pub fn spawn(sink: EventSink) -> std::io::Result<Self> {
|
||||
let (shutdown, shutdown_rx) = async_channel::bounded(1);
|
||||
thread::Builder::new()
|
||||
.name("fluxer-linux-portals-session-monitor".to_string())
|
||||
.spawn(move || async_io::block_on(run(sink, shutdown_rx)))?;
|
||||
Ok(Self { shutdown })
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.shutdown.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionMonitor {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
struct Monitor {
|
||||
owner: OwnedUniqueName,
|
||||
handle: OwnedObjectPath,
|
||||
}
|
||||
|
||||
enum Next {
|
||||
Owner(Message),
|
||||
State(Message),
|
||||
End,
|
||||
}
|
||||
|
||||
async fn run(sink: EventSink, shutdown: Receiver<()>) {
|
||||
let Ok(conn) = Connection::session().await else {
|
||||
return;
|
||||
};
|
||||
let current = RefCell::new(None::<Monitor>);
|
||||
watch(&conn, &sink, ¤t)
|
||||
.or(async {
|
||||
let _ = shutdown.recv().await;
|
||||
})
|
||||
.await;
|
||||
if let Some(monitor) = current.take() {
|
||||
let _ = call(
|
||||
&conn,
|
||||
PORTAL_DESTINATION,
|
||||
monitor.handle.as_str(),
|
||||
SESSION_INTERFACE,
|
||||
"Close",
|
||||
&(),
|
||||
SHORT_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch(conn: &Connection, sink: &EventSink, current: &RefCell<Option<Monitor>>) {
|
||||
let Ok(mut owners) = subscribe(conn, owner_rule()).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut states) = subscribe(conn, state_rule()).await else {
|
||||
return;
|
||||
};
|
||||
let mut tracker = LockTracker::default();
|
||||
let mut attempt = true;
|
||||
loop {
|
||||
if attempt {
|
||||
attempt = false;
|
||||
let created = create_monitor(conn).await;
|
||||
current.replace(created);
|
||||
}
|
||||
let next = async {
|
||||
match owners.next().await {
|
||||
Some(Ok(message)) => Next::Owner(message),
|
||||
_ => Next::End,
|
||||
}
|
||||
}
|
||||
.or(async {
|
||||
match states.next().await {
|
||||
Some(Ok(message)) => Next::State(message),
|
||||
_ => Next::End,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match next {
|
||||
Next::End => return,
|
||||
Next::Owner(message) => {
|
||||
let Some(new_owner) = new_portal_owner(&message) else {
|
||||
continue;
|
||||
};
|
||||
let unchanged = current
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|monitor| monitor.owner.as_str() == new_owner);
|
||||
if unchanged {
|
||||
continue;
|
||||
}
|
||||
current.replace(None);
|
||||
attempt = !new_owner.is_empty();
|
||||
}
|
||||
Next::State(message) => {
|
||||
let Some((handle, active, session_state)) =
|
||||
state_change(&message, current.borrow().as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if session_state == QUERY_END {
|
||||
let _ = call(
|
||||
conn,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
INHIBIT_INTERFACE,
|
||||
"QueryEndResponse",
|
||||
&(&handle,),
|
||||
SHORT_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(event) = tracker.update(active) {
|
||||
sink(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
conn: &Connection,
|
||||
rule: zbus::Result<MatchRule<'static>>,
|
||||
) -> zbus::Result<MessageStream> {
|
||||
MessageStream::for_match_rule(rule?, conn, None).await
|
||||
}
|
||||
|
||||
fn owner_rule() -> zbus::Result<MatchRule<'static>> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.sender(BUS_NAME)?
|
||||
.interface(BUS_NAME)?
|
||||
.member("NameOwnerChanged")?
|
||||
.path(BUS_PATH)?
|
||||
.arg(0, PORTAL_DESTINATION)?
|
||||
.build())
|
||||
}
|
||||
|
||||
fn state_rule() -> zbus::Result<MatchRule<'static>> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.sender(PORTAL_DESTINATION)?
|
||||
.interface(INHIBIT_INTERFACE)?
|
||||
.member("StateChanged")?
|
||||
.path(PORTAL_PATH)?
|
||||
.build())
|
||||
}
|
||||
|
||||
fn new_portal_owner(message: &Message) -> Option<String> {
|
||||
let header = message.header();
|
||||
if header
|
||||
.sender()
|
||||
.is_none_or(|sender| sender.as_str() != BUS_NAME)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (_name, _old, new): (String, String, String) = message.body().deserialize().ok()?;
|
||||
Some(new)
|
||||
}
|
||||
|
||||
fn state_change(
|
||||
message: &Message,
|
||||
monitor: Option<&Monitor>,
|
||||
) -> Option<(OwnedObjectPath, bool, u32)> {
|
||||
let monitor = monitor?;
|
||||
if message.header().sender() != Some(monitor.owner.inner()) {
|
||||
return None;
|
||||
}
|
||||
let (handle, state): (OwnedObjectPath, Results) = message.body().deserialize().ok()?;
|
||||
if handle != monitor.handle {
|
||||
return None;
|
||||
}
|
||||
let active = matches!(
|
||||
state.get("screensaver-active").map(unbox),
|
||||
Some(Value::Bool(true))
|
||||
);
|
||||
let session_state = state
|
||||
.get("session-state")
|
||||
.and_then(|value| crate::kwin::integer_from_variant(unbox(value)))
|
||||
.unwrap_or(0);
|
||||
Some((handle, active, session_state))
|
||||
}
|
||||
|
||||
fn unbox(value: &OwnedValue) -> &Value<'_> {
|
||||
let mut value = crate::kwin::value_of_owned(value);
|
||||
while let Value::Value(inner) = value {
|
||||
value = inner;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
async fn create_monitor(conn: &Connection) -> Option<Monitor> {
|
||||
let owner_reply = call(
|
||||
conn,
|
||||
BUS_NAME,
|
||||
BUS_PATH,
|
||||
BUS_NAME,
|
||||
"GetNameOwner",
|
||||
&(PORTAL_DESTINATION,),
|
||||
SHORT_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let owner: OwnedUniqueName = owner_reply.body().deserialize().ok()?;
|
||||
let unique_name = conn.unique_name()?.to_string();
|
||||
let token = mint_token("fluxer_sm");
|
||||
let session_token = mint_token("fluxer_sm_session");
|
||||
let namespace = request_path(&unique_name, "");
|
||||
let rule = MatchRule::builder()
|
||||
.msg_type(MessageType::Signal)
|
||||
.sender(PORTAL_DESTINATION)
|
||||
.and_then(|rule| rule.interface(REQUEST_INTERFACE))
|
||||
.and_then(|rule| rule.member("Response"))
|
||||
.and_then(|rule| rule.path_namespace(namespace.trim_end_matches('/').to_string()))
|
||||
.map(|rule| rule.build());
|
||||
let mut responses = subscribe(conn, rule).await.ok()?;
|
||||
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
options.insert("handle_token", Value::new(token.as_str()));
|
||||
options.insert("session_handle_token", Value::new(session_token.as_str()));
|
||||
let reply = call(
|
||||
conn,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
INHIBIT_INTERFACE,
|
||||
"CreateMonitor",
|
||||
&("", options),
|
||||
SETUP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let request = reply
|
||||
.body()
|
||||
.deserialize::<OwnedObjectPath>()
|
||||
.ok()
|
||||
.map(|path| path.to_string())
|
||||
.unwrap_or_else(|| request_path(&unique_name, &token));
|
||||
let response = async {
|
||||
while let Some(Ok(message)) = responses.next().await {
|
||||
let header = message.header();
|
||||
if header.sender() != Some(owner.inner())
|
||||
|| header.path().map(|path| path.as_str()) != Some(request.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return message.body().deserialize::<(u32, Results)>().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
.or(async {
|
||||
Timer::after(SETUP_TIMEOUT).await;
|
||||
None
|
||||
})
|
||||
.await;
|
||||
let (code, results) = response?;
|
||||
if code != 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = match results.get("session_handle").map(unbox)? {
|
||||
Value::Str(path) => OwnedObjectPath::try_from(path.as_str()).ok()?,
|
||||
Value::ObjectPath(path) => OwnedObjectPath::from(path.to_owned()),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Monitor { owner, handle })
|
||||
}
|
||||
|
||||
async fn call<B>(
|
||||
conn: &Connection,
|
||||
destination: &str,
|
||||
path: &str,
|
||||
interface: &str,
|
||||
method: &str,
|
||||
body: &B,
|
||||
timeout: Duration,
|
||||
) -> Option<Message>
|
||||
where
|
||||
B: zbus::export::serde::Serialize + DynamicType,
|
||||
{
|
||||
async {
|
||||
conn.call_method(Some(destination), path, Some(interface), method, body)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
.or(async {
|
||||
Timer::after(timeout).await;
|
||||
None
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lock_tracker_reports_only_transitions() {
|
||||
let mut tracker = LockTracker::default();
|
||||
assert_eq!(tracker.update(false), None);
|
||||
assert_eq!(tracker.update(true), Some(MonitorEvent::ScreenLocked));
|
||||
assert_eq!(tracker.update(true), None);
|
||||
assert_eq!(tracker.update(false), Some(MonitorEvent::ScreenUnlocked));
|
||||
assert_eq!(tracker.update(false), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user