2026-08-23 00:33:53 -04:00
|
|
|
//! The Wayland half of the shortcut: `org.freedesktop.portal.GlobalShortcuts`.
|
2026-08-09 02:58:13 -04:00
|
|
|
//!
|
2026-08-23 00:33:53 -04:00
|
|
|
//! **The desktop owns the binding**: what we send is a `preferred_trigger`,
|
|
|
|
|
//! and the compositor may bind something else or ask the user; what it bound
|
|
|
|
|
//! comes back as a `trigger_description`, which the Settings tab shows.
|
2026-08-09 02:58:13 -04:00
|
|
|
//!
|
2026-08-23 00:33:53 -04:00
|
|
|
//! All of this lives on its own thread — a portal call is a D-Bus round trip
|
|
|
|
|
//! that can block as long as a dialog stays up. The session must stay open
|
|
|
|
|
//! for activations to keep arriving; dropping it is how a rebind starts over.
|
2026-08-09 02:58:13 -04:00
|
|
|
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
|
|
|
|
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
|
|
|
|
|
use ashpd::desktop::Session;
|
|
|
|
|
use futures_channel::mpsc;
|
|
|
|
|
use futures_util::future::{select, Either};
|
|
|
|
|
use futures_util::StreamExt;
|
|
|
|
|
|
|
|
|
|
use super::Status;
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The portal keys activations by this id; the desktop lists the entry by it.
|
2026-08-09 02:58:13 -04:00
|
|
|
const SHORTCUT_ID: &str = "search";
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Shown next to the key in the desktop's shortcut settings.
|
2026-08-09 02:58:13 -04:00
|
|
|
const SHORTCUT_DESCRIPTION: &str = "Focus the QuickSearch search box";
|
|
|
|
|
|
|
|
|
|
pub(super) struct Portal {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// `Some(trigger)` binds, `None` unbinds. Unbounded: sends happen on
|
|
|
|
|
/// the UI thread and must never block it.
|
2026-08-09 02:58:13 -04:00
|
|
|
tx: mpsc::UnboundedSender<Option<String>>,
|
|
|
|
|
status: Arc<Mutex<Status>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Portal {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The thread runs until the process exits; nothing to shut down.
|
2026-08-09 02:58:13 -04:00
|
|
|
pub(super) fn new(ctx: &egui::Context) -> Portal {
|
|
|
|
|
let (tx, rx) = mpsc::unbounded();
|
|
|
|
|
let status = Arc::new(Mutex::new(Status::Pending));
|
|
|
|
|
let portal = Portal {
|
|
|
|
|
tx,
|
|
|
|
|
status: Arc::clone(&status),
|
|
|
|
|
};
|
|
|
|
|
let ctx = ctx.clone();
|
2026-08-09 16:25:43 -04:00
|
|
|
if let Err(e) = std::thread::Builder::new()
|
2026-08-09 02:58:13 -04:00
|
|
|
.name("quicksearch-hotkey-portal".to_string())
|
|
|
|
|
.spawn(move || pollster::block_on(run(ctx, status, rx)))
|
2026-08-09 16:25:43 -04:00
|
|
|
{
|
2026-08-23 00:33:53 -04:00
|
|
|
// The status must say so, or the Settings tab shows
|
|
|
|
|
// "Waiting for your desktop…" forever.
|
2026-08-09 16:25:43 -04:00
|
|
|
quicksearch_core::log_warn!("global shortcut portal thread: {}", e);
|
|
|
|
|
*lock_ok(&portal.status) =
|
|
|
|
|
Status::Error(format!("the shortcut thread could not be started: {}", e));
|
|
|
|
|
}
|
2026-08-09 02:58:13 -04:00
|
|
|
portal
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Returns immediately; the answer lands in [`Portal::status`]
|
|
|
|
|
/// whenever the desktop gets to it.
|
2026-08-09 02:58:13 -04:00
|
|
|
pub(super) fn bind(&self, trigger: Option<String>) {
|
2026-08-09 16:25:43 -04:00
|
|
|
*lock_ok(&self.status) = match trigger {
|
2026-08-09 02:58:13 -04:00
|
|
|
Some(_) => Status::Pending,
|
|
|
|
|
None => Status::Disabled,
|
|
|
|
|
};
|
|
|
|
|
let _ = self.tx.unbounded_send(trigger);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) fn status(&self) -> Status {
|
2026-08-09 16:25:43 -04:00
|
|
|
lock_ok(&self.status).clone()
|
2026-08-09 02:58:13 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Ignore poisoning: a portal-thread panic must not take the UI thread too.
|
2026-08-09 16:25:43 -04:00
|
|
|
fn lock_ok<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
|
|
|
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:58:13 -04:00
|
|
|
async fn run(
|
|
|
|
|
ctx: egui::Context,
|
|
|
|
|
status: Arc<Mutex<Status>>,
|
|
|
|
|
mut commands: mpsc::UnboundedReceiver<Option<String>>,
|
|
|
|
|
) {
|
|
|
|
|
let shortcuts: GlobalShortcuts<'static> = match GlobalShortcuts::new().await {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(e) => return fail(&ctx, &status, unavailable(&e)),
|
|
|
|
|
};
|
2026-08-23 00:33:53 -04:00
|
|
|
// A signal match on the interface, not a session: survives rebinds.
|
2026-08-09 02:58:13 -04:00
|
|
|
let activated = match shortcuts.receive_activated().await {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(e) => return fail(&ctx, &status, unavailable(&e)),
|
|
|
|
|
};
|
|
|
|
|
futures_util::pin_mut!(activated);
|
|
|
|
|
|
|
|
|
|
let mut session: Option<Session<'static, GlobalShortcuts<'static>>> = None;
|
|
|
|
|
loop {
|
|
|
|
|
match select(activated.next(), commands.next()).await {
|
|
|
|
|
Either::Left((Some(_), _)) => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// No id check needed: this session has exactly one shortcut.
|
2026-08-09 02:58:13 -04:00
|
|
|
super::fire(&ctx);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// The portal went away; the session is already dead.
|
2026-08-09 02:58:13 -04:00
|
|
|
Either::Left((None, _)) => {
|
|
|
|
|
return fail(
|
|
|
|
|
&ctx,
|
|
|
|
|
&status,
|
|
|
|
|
"the desktop's global shortcuts service stopped".to_string(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Either::Right((Some(trigger), _)) => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// A rebind is a new session: the portal treats a session's
|
|
|
|
|
// shortcuts as fixed once bound.
|
2026-08-09 02:58:13 -04:00
|
|
|
if let Some(old) = session.take() {
|
|
|
|
|
let _ = old.close().await;
|
|
|
|
|
}
|
|
|
|
|
let next = match &trigger {
|
|
|
|
|
None => {
|
|
|
|
|
set(&ctx, &status, Status::Disabled);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
Some(trigger) => match bind(&shortcuts, trigger).await {
|
|
|
|
|
Ok((session, description)) => {
|
|
|
|
|
set(&ctx, &status, Status::PortalBound(description));
|
|
|
|
|
Some(session)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
fail(&ctx, &status, unavailable(&e));
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
session = next;
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// The registry dropped the sender: we are on the way out.
|
2026-08-09 02:58:13 -04:00
|
|
|
Either::Right((None, _)) => return,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Bind the trigger; returns the desktop's own wording for what it settled on.
|
2026-08-09 02:58:13 -04:00
|
|
|
async fn bind(
|
|
|
|
|
shortcuts: &GlobalShortcuts<'static>,
|
|
|
|
|
trigger: &str,
|
|
|
|
|
) -> Result<(Session<'static, GlobalShortcuts<'static>>, String), ashpd::Error> {
|
|
|
|
|
let session = shortcuts.create_session().await?;
|
|
|
|
|
let shortcut =
|
|
|
|
|
NewShortcut::new(SHORTCUT_ID, SHORTCUT_DESCRIPTION).preferred_trigger(Some(trigger));
|
|
|
|
|
let request = shortcuts
|
|
|
|
|
.bind_shortcuts(&session, &[shortcut], None)
|
|
|
|
|
.await?;
|
|
|
|
|
let bound = request.response()?;
|
2026-08-23 00:33:53 -04:00
|
|
|
// A blank description falls back to the preferred trigger.
|
2026-08-09 02:58:13 -04:00
|
|
|
let description = bound
|
|
|
|
|
.shortcuts()
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|s| s.id() == SHORTCUT_ID)
|
|
|
|
|
.map(|s| s.trigger_description().to_string())
|
|
|
|
|
.filter(|d| !d.trim().is_empty())
|
|
|
|
|
.unwrap_or_else(|| trigger.to_string());
|
|
|
|
|
Ok((session, description))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unavailable(e: &ashpd::Error) -> String {
|
|
|
|
|
match e {
|
|
|
|
|
ashpd::Error::PortalNotFound(_) => {
|
|
|
|
|
"this desktop does not offer the global shortcuts portal".to_string()
|
|
|
|
|
}
|
|
|
|
|
ashpd::Error::RequiresVersion(required, found) => format!(
|
|
|
|
|
"this desktop's global shortcuts portal is version {}, and {} is needed",
|
|
|
|
|
found, required
|
|
|
|
|
),
|
|
|
|
|
ashpd::Error::Response(_) => "the desktop declined the shortcut".to_string(),
|
|
|
|
|
other => other.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set(ctx: &egui::Context, status: &Mutex<Status>, next: Status) {
|
2026-08-09 16:25:43 -04:00
|
|
|
*lock_ok(status) = next;
|
2026-08-09 02:58:13 -04:00
|
|
|
ctx.request_repaint();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn fail(ctx: &egui::Context, status: &Mutex<Status>, message: String) {
|
|
|
|
|
quicksearch_core::log_warn!("global shortcut: {}", message);
|
|
|
|
|
set(ctx, status, Status::Error(message));
|
|
|
|
|
}
|