feat(desktop): portal-based Linux global shortcuts and PTT (#3185)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-04 01:08:51 +02:00
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
+194 -151
View File
@@ -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, &current)
.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);
}
}