Skip to main content

incular_runtime/
global_shortcuts.rs

1//! Application-scoped native global shortcut requests.
2
3use crate::{
4    request_channel::{RequestChannel, RequestReceiver},
5    tasks::RuntimeWake,
6};
7use incular_platform::{
8    CapabilitySupport, GlobalShortcutChord, GlobalShortcutError, GlobalShortcutId,
9    PlatformCapabilities,
10};
11use std::{
12    collections::{HashMap, VecDeque},
13    future::Future,
14    pin::Pin,
15    sync::{Arc, RwLock, mpsc},
16    task::{Context, Poll},
17};
18use tokio::sync::oneshot;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct GlobalShortcutRequestId(u64);
22
23impl GlobalShortcutRequestId {
24    #[must_use]
25    pub const fn new(value: u64) -> Self {
26        Self(value)
27    }
28
29    #[must_use]
30    pub const fn get(self) -> u64 {
31        self.0
32    }
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum NativeGlobalShortcutOperation {
37    Register {
38        id: GlobalShortcutId,
39        chord: GlobalShortcutChord,
40    },
41    Unregister {
42        id: GlobalShortcutId,
43    },
44}
45
46impl NativeGlobalShortcutOperation {
47    #[must_use]
48    pub const fn id(self) -> GlobalShortcutId {
49        match self {
50            Self::Register { id, .. } | Self::Unregister { id } => id,
51        }
52    }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct NativeGlobalShortcutRequest {
57    pub request_id: GlobalShortcutRequestId,
58    pub operation: NativeGlobalShortcutOperation,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct NativeGlobalShortcutCompletion {
63    pub request_id: GlobalShortcutRequestId,
64    pub result: Result<(), GlobalShortcutError>,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum GlobalShortcutCompletionStatus {
69    Completed,
70    /// The caller dropped a registration future after native registration.
71    /// The desktop host must immediately unregister the supplied ID.
72    AbandonedRegistration(GlobalShortcutId),
73    UnknownRequest,
74}
75
76#[derive(Clone)]
77pub struct GlobalShortcutService {
78    bridge: Arc<GlobalShortcutBridge>,
79    capabilities: Arc<RwLock<PlatformCapabilities>>,
80}
81
82impl std::fmt::Debug for GlobalShortcutService {
83    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        formatter.write_str("GlobalShortcutService(..)")
85    }
86}
87
88impl GlobalShortcutService {
89    pub(crate) fn new(
90        bridge: Arc<GlobalShortcutBridge>,
91        capabilities: Arc<RwLock<PlatformCapabilities>>,
92    ) -> Self {
93        Self {
94            bridge,
95            capabilities,
96        }
97    }
98
99    #[must_use]
100    pub fn support(&self) -> CapabilitySupport {
101        self.capabilities
102            .read()
103            .expect("application capability snapshot lock")
104            .application_services
105            .global_shortcuts
106    }
107
108    /// Queues native registration. The returned future resolves only after the
109    /// OS accepted or rejected the chord, so conflicts are never reported as a
110    /// false success. Registration may be queued before desktop capability
111    /// discovery; an explicit `Unsupported` snapshot is the only state that
112    /// rejects the request before it reaches the native host.
113    pub fn register(
114        &self,
115        id: GlobalShortcutId,
116        chord: GlobalShortcutChord,
117    ) -> Result<GlobalShortcutRegistrationRequest, GlobalShortcutError> {
118        if self.support() == CapabilitySupport::Unsupported {
119            return Err(GlobalShortcutError::Unsupported);
120        }
121        let receiver = self.bridge.register(id, chord)?;
122        Ok(GlobalShortcutRegistrationRequest { receiver })
123    }
124}
125
126#[must_use = "global shortcut registration must be awaited or explicitly dropped"]
127pub struct GlobalShortcutRegistrationRequest {
128    receiver: RequestReceiver<Result<GlobalShortcutRegistration, GlobalShortcutError>>,
129}
130impl Unpin for GlobalShortcutRegistrationRequest {}
131impl Future for GlobalShortcutRegistrationRequest {
132    type Output = Result<GlobalShortcutRegistration, GlobalShortcutError>;
133    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
134        self.receiver
135            .poll_result(context, || Err(GlobalShortcutError::ApplicationStopped))
136    }
137}
138
139/// RAII ownership of one active native global shortcut.
140///
141/// Dropping the handle queues native unregistration. Use [`Self::unregister`]
142/// when the caller needs to observe a typed native unregistration result.
143pub struct GlobalShortcutRegistration {
144    id: GlobalShortcutId,
145    chord: GlobalShortcutChord,
146    bridge: Arc<GlobalShortcutBridge>,
147    released: bool,
148}
149
150impl std::fmt::Debug for GlobalShortcutRegistration {
151    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        formatter
153            .debug_struct("GlobalShortcutRegistration")
154            .field("id", &self.id)
155            .field("chord", &self.chord)
156            .field("released", &self.released)
157            .finish()
158    }
159}
160
161impl GlobalShortcutRegistration {
162    #[must_use]
163    pub const fn id(&self) -> GlobalShortcutId {
164        self.id
165    }
166
167    #[must_use]
168    pub const fn chord(&self) -> GlobalShortcutChord {
169        self.chord
170    }
171
172    pub fn unregister(
173        mut self,
174    ) -> Result<GlobalShortcutUnregistrationRequest, GlobalShortcutError> {
175        let receiver = self.bridge.unregister(self.id)?;
176        self.released = true;
177        Ok(GlobalShortcutUnregistrationRequest { receiver })
178    }
179}
180
181impl Drop for GlobalShortcutRegistration {
182    fn drop(&mut self) {
183        if !self.released {
184            let _ = self.bridge.release(self.id);
185            self.released = true;
186        }
187    }
188}
189
190#[must_use = "global shortcut unregistration must be awaited or explicitly dropped"]
191pub struct GlobalShortcutUnregistrationRequest {
192    receiver: RequestReceiver<Result<(), GlobalShortcutError>>,
193}
194
195impl Unpin for GlobalShortcutUnregistrationRequest {}
196
197impl Future for GlobalShortcutUnregistrationRequest {
198    type Output = Result<(), GlobalShortcutError>;
199
200    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
201        self.receiver
202            .poll_result(context, || Err(GlobalShortcutError::ApplicationStopped))
203    }
204}
205
206pub(crate) struct QueuedGlobalShortcutRequest {
207    pub(crate) request_id: GlobalShortcutRequestId,
208    pub(crate) operation: NativeGlobalShortcutOperation,
209    pub(crate) reply: ShortcutReply,
210}
211
212pub(crate) struct PendingGlobalShortcutRequest {
213    pub(crate) operation: NativeGlobalShortcutOperation,
214    pub(crate) reply: ShortcutReply,
215}
216
217pub(crate) struct GlobalShortcutBridge {
218    channel: RequestChannel<QueuedGlobalShortcutRequest>,
219}
220impl GlobalShortcutBridge {
221    pub(crate) fn new(sender: mpsc::Sender<QueuedGlobalShortcutRequest>) -> Self {
222        Self {
223            channel: RequestChannel::new(sender),
224        }
225    }
226    fn register(
227        self: &Arc<Self>,
228        id: GlobalShortcutId,
229        chord: GlobalShortcutChord,
230    ) -> Result<
231        RequestReceiver<Result<GlobalShortcutRegistration, GlobalShortcutError>>,
232        GlobalShortcutError,
233    > {
234        self.channel
235            .request(|request_id| {
236                let (sender, receiver) = oneshot::channel();
237                (
238                    QueuedGlobalShortcutRequest {
239                        request_id: GlobalShortcutRequestId::new(request_id),
240                        operation: NativeGlobalShortcutOperation::Register { id, chord },
241                        reply: ShortcutReply::Registration {
242                            sender,
243                            bridge: self.clone(),
244                        },
245                    },
246                    RequestReceiver::new(receiver, || {}),
247                )
248            })
249            .map_err(|_| GlobalShortcutError::ApplicationStopped)
250    }
251    fn unregister(
252        &self,
253        id: GlobalShortcutId,
254    ) -> Result<RequestReceiver<Result<(), GlobalShortcutError>>, GlobalShortcutError> {
255        self.channel
256            .request(|request_id| {
257                let (sender, receiver) = oneshot::channel();
258                (
259                    QueuedGlobalShortcutRequest {
260                        request_id: GlobalShortcutRequestId::new(request_id),
261                        operation: NativeGlobalShortcutOperation::Unregister { id },
262                        reply: ShortcutReply::Unregistration(sender),
263                    },
264                    RequestReceiver::new(receiver, || {}),
265                )
266            })
267            .map_err(|_| GlobalShortcutError::ApplicationStopped)
268    }
269    fn release(&self, id: GlobalShortcutId) -> Result<(), GlobalShortcutError> {
270        self.channel
271            .request(|request_id| {
272                (
273                    QueuedGlobalShortcutRequest {
274                        request_id: GlobalShortcutRequestId::new(request_id),
275                        operation: NativeGlobalShortcutOperation::Unregister { id },
276                        reply: ShortcutReply::Detached,
277                    },
278                    (),
279                )
280            })
281            .map_err(|_| GlobalShortcutError::ApplicationStopped)
282    }
283    pub(crate) fn set_wake(&self, wake: Arc<dyn RuntimeWake>) {
284        self.channel.set_wake(wake);
285    }
286    pub(crate) fn stop(&self) {
287        self.channel.stop();
288    }
289}
290
291pub(crate) fn drain_queued(
292    receiver: &mpsc::Receiver<QueuedGlobalShortcutRequest>,
293    pending: &mut crate::request_registry::RequestRegistry<
294        GlobalShortcutRequestId,
295        PendingGlobalShortcutRequest,
296    >,
297    native: &mut VecDeque<NativeGlobalShortcutRequest>,
298) {
299    while let Ok(queued) = receiver.try_recv() {
300        if matches!(
301            queued.operation,
302            NativeGlobalShortcutOperation::Register { .. }
303        ) && queued.reply.is_abandoned()
304        {
305            continue;
306        }
307        pending.insert(
308            queued.request_id,
309            PendingGlobalShortcutRequest {
310                operation: queued.operation,
311                reply: queued.reply,
312            },
313        );
314        native.push_back(NativeGlobalShortcutRequest {
315            request_id: queued.request_id,
316            operation: queued.operation,
317        });
318    }
319}
320
321pub(crate) enum ShortcutReply {
322    Registration {
323        sender: oneshot::Sender<Result<GlobalShortcutRegistration, GlobalShortcutError>>,
324        bridge: Arc<GlobalShortcutBridge>,
325    },
326    Unregistration(oneshot::Sender<Result<(), GlobalShortcutError>>),
327    Detached,
328}
329impl ShortcutReply {
330    pub(crate) fn is_abandoned(&self) -> bool {
331        match self {
332            Self::Registration { sender, .. } => sender.is_closed(),
333            Self::Unregistration(sender) => sender.is_closed(),
334            Self::Detached => false,
335        }
336    }
337    pub(crate) fn finish(
338        self,
339        operation: NativeGlobalShortcutOperation,
340        result: Result<(), GlobalShortcutError>,
341    ) -> GlobalShortcutCompletionStatus {
342        match self {
343            Self::Registration { sender, bridge } => {
344                let NativeGlobalShortcutOperation::Register { id, chord } = operation else {
345                    unreachable!("registration reply requires a register operation");
346                };
347                let result = result.map(|()| GlobalShortcutRegistration {
348                    id,
349                    chord,
350                    bridge,
351                    released: false,
352                });
353                if let Err(Ok(mut registration)) = sender.send(result) {
354                    // The host rolls back immediately. Disarm the returned lease
355                    // so its destructor cannot enqueue a second unregistration.
356                    registration.released = true;
357                    return GlobalShortcutCompletionStatus::AbandonedRegistration(id);
358                }
359            }
360            Self::Unregistration(sender) => {
361                let _ = sender.send(result);
362            }
363            Self::Detached => {}
364        }
365        GlobalShortcutCompletionStatus::Completed
366    }
367}
368
369/// Deterministic native-side adapter for tests and non-OS embedders.
370///
371/// It implements the same stable-ID/chord conflict rules expected from a
372/// desktop backend without registering any host operating-system hotkeys.
373#[derive(Debug)]
374pub struct MemoryGlobalShortcutAdapter {
375    support: CapabilitySupport,
376    registrations: HashMap<GlobalShortcutId, GlobalShortcutChord>,
377}
378
379impl Default for MemoryGlobalShortcutAdapter {
380    fn default() -> Self {
381        Self::new(CapabilitySupport::Supported)
382    }
383}
384
385impl MemoryGlobalShortcutAdapter {
386    #[must_use]
387    pub fn new(support: CapabilitySupport) -> Self {
388        Self {
389            support,
390            registrations: HashMap::new(),
391        }
392    }
393
394    #[must_use]
395    pub const fn support(&self) -> CapabilitySupport {
396        self.support
397    }
398
399    #[must_use]
400    pub fn registered_chord(&self, id: GlobalShortcutId) -> Option<GlobalShortcutChord> {
401        self.registrations.get(&id).copied()
402    }
403
404    #[must_use]
405    pub fn registration_count(&self) -> usize {
406        self.registrations.len()
407    }
408
409    pub fn respond(
410        &mut self,
411        request: NativeGlobalShortcutRequest,
412    ) -> NativeGlobalShortcutCompletion {
413        let result = if self.support != CapabilitySupport::Supported {
414            Err(GlobalShortcutError::Unsupported)
415        } else {
416            match request.operation {
417                NativeGlobalShortcutOperation::Register { id, chord } => {
418                    if self.registrations.contains_key(&id) {
419                        Err(GlobalShortcutError::AlreadyRegistered(id))
420                    } else if self
421                        .registrations
422                        .values()
423                        .any(|registered| *registered == chord)
424                    {
425                        Err(GlobalShortcutError::Conflict(chord))
426                    } else {
427                        self.registrations.insert(id, chord);
428                        Ok(())
429                    }
430                }
431                NativeGlobalShortcutOperation::Unregister { id } => {
432                    if self.registrations.remove(&id).is_some() {
433                        Ok(())
434                    } else {
435                        Err(GlobalShortcutError::NotRegistered(id))
436                    }
437                }
438            }
439        };
440        NativeGlobalShortcutCompletion {
441            request_id: request.request_id,
442            result,
443        }
444    }
445
446    /// Produces the same application-level activation as a real native hotkey
447    /// press when `id` is currently registered.
448    #[must_use]
449    pub fn invoke(&self, id: GlobalShortcutId) -> Option<incular_platform::ApplicationActivation> {
450        self.registrations
451            .contains_key(&id)
452            .then_some(incular_platform::ApplicationActivation::GlobalShortcutInvoked(id))
453    }
454}