Skip to main content

incular_runtime/
application_shell.rs

1//! Runtime ownership for application-scoped desktop shell resources.
2
3use crate::{request_registry::RequestRegistry, tasks::RuntimeWake};
4use incular_platform::{
5    ApplicationShellError, ApplicationShellFeature, CapabilitySupport, NotificationActionId,
6    NotificationId, NotificationPresentation, PlatformCapabilities, TaskbarDockState, TrayItemId,
7    TrayItemPresentation,
8};
9use incular_widgets::internal::PlatformMenuCommandModel;
10use incular_widgets::{
11    PlatformMenu, PlatformMenuBuildError, PlatformMenuEvent, PlatformMenuSnapshot,
12};
13use std::{
14    cell::RefCell,
15    collections::VecDeque,
16    rc::Rc,
17    sync::{Arc, Mutex, RwLock},
18};
19
20#[derive(Clone, Debug)]
21pub enum NativeApplicationShellOperation {
22    CreateTray {
23        id: TrayItemId,
24        presentation: TrayItemPresentation,
25        menu: PlatformMenuSnapshot,
26    },
27    UpdateTray {
28        id: TrayItemId,
29        presentation: TrayItemPresentation,
30        menu: PlatformMenuSnapshot,
31    },
32    RemoveTray {
33        id: TrayItemId,
34    },
35    ShowNotification {
36        id: NotificationId,
37        presentation: NotificationPresentation,
38    },
39    UpdateNotification {
40        id: NotificationId,
41        presentation: NotificationPresentation,
42    },
43    CloseNotification {
44        id: NotificationId,
45    },
46    SetTaskbarDockState(TaskbarDockState),
47}
48
49impl NativeApplicationShellOperation {
50    /// The capability a native backend must provide to apply this operation.
51    #[must_use]
52    pub fn required_feature(&self) -> ApplicationShellFeature {
53        match self {
54            Self::CreateTray { .. } | Self::UpdateTray { .. } | Self::RemoveTray { .. } => {
55                ApplicationShellFeature::TrayOrStatusItem
56            }
57            Self::ShowNotification { .. } => ApplicationShellFeature::Notifications,
58            Self::UpdateNotification { .. } => ApplicationShellFeature::NotificationUpdate,
59            Self::CloseNotification { .. } => ApplicationShellFeature::NotificationDismiss,
60            Self::SetTaskbarDockState(state) => state.required_feature(),
61        }
62    }
63}
64
65#[derive(Clone, Debug)]
66pub struct NativeApplicationShellRequest {
67    pub request_id: ApplicationShellRequestId,
68    pub operation: NativeApplicationShellOperation,
69}
70
71#[derive(Clone, Debug)]
72pub struct NativeApplicationShellCompletion {
73    pub request_id: ApplicationShellRequestId,
74    pub result: Result<(), ApplicationShellError>,
75}
76
77/// Whether a native shell request finished during the current event-loop turn
78/// or will report its result asynchronously through the shell completion sink.
79///
80/// UserNotifications on macOS is genuinely asynchronous (including permission
81/// authorization), so forcing every backend through a synchronous `Result`
82/// would either block AppKit or lie about completion. Backends which complete
83/// synchronously use `Completed`; `Deferred` means exactly one later
84/// [`NativeApplicationShellCompletion`] is required.
85#[derive(Clone, Debug)]
86pub enum NativeApplicationShellApplyResult {
87    Completed(Result<(), ApplicationShellError>),
88    Deferred,
89}
90
91impl From<Result<(), ApplicationShellError>> for NativeApplicationShellApplyResult {
92    fn from(result: Result<(), ApplicationShellError>) -> Self {
93        Self::Completed(result)
94    }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
98pub struct ApplicationShellRequestId(u64);
99
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum NativeApplicationShellEvent {
102    TrayMenu {
103        id: TrayItemId,
104        event: PlatformMenuEvent,
105    },
106    TrayActivated {
107        id: TrayItemId,
108    },
109    NotificationActivated {
110        id: NotificationId,
111    },
112    NotificationAction {
113        id: NotificationId,
114        action: NotificationActionId,
115    },
116    NotificationDismissed {
117        id: NotificationId,
118    },
119}
120
121struct Slot<T> {
122    generation: u32,
123    retired: bool,
124    value: Option<T>,
125}
126
127impl<T> Default for Slot<T> {
128    fn default() -> Self {
129        Self {
130            generation: 0,
131            retired: false,
132            value: None,
133        }
134    }
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138enum ResourcePhase {
139    Queued,
140    Dispatched,
141    Live,
142    Failed,
143}
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145enum ShellResource {
146    Tray(TrayItemId),
147    Notification(NotificationId),
148}
149
150struct TrayRecord {
151    phase: ResourcePhase,
152    presentation: TrayItemPresentation,
153    menu: PlatformMenuCommandModel,
154    on_activated: Option<Arc<dyn Fn() + Send + Sync>>,
155}
156
157struct NotificationRecord {
158    phase: ResourcePhase,
159    presentation: NotificationPresentation,
160    on_activated: Option<Arc<dyn Fn() + Send + Sync>>,
161    on_action: Option<Arc<dyn Fn(NotificationActionId) + Send + Sync>>,
162    on_dismissed: Option<Arc<dyn Fn() + Send + Sync>>,
163}
164
165#[derive(Default)]
166struct ShellState {
167    trays: Vec<Slot<TrayRecord>>,
168    notifications: Vec<Slot<NotificationRecord>>,
169    requests: VecDeque<NativeApplicationShellRequest>,
170    pending: RequestRegistry<ApplicationShellRequestId, NativeApplicationShellOperation>,
171    completions: VecDeque<NativeApplicationShellCompletion>,
172    next_request: u64,
173    stopped: bool,
174    notification_identity: Option<String>,
175}
176
177#[derive(Clone)]
178pub struct ApplicationShellService {
179    state: Rc<RefCell<ShellState>>,
180    capabilities: Arc<RwLock<PlatformCapabilities>>,
181    wake: Rc<RefCell<Option<Arc<dyn RuntimeWake>>>>,
182}
183
184impl ApplicationShellService {
185    pub(crate) fn new(capabilities: Arc<RwLock<PlatformCapabilities>>) -> Self {
186        Self {
187            state: Rc::new(RefCell::new(ShellState::default())),
188            capabilities,
189            wake: Rc::new(RefCell::new(None)),
190        }
191    }
192
193    pub(crate) fn set_wake(&self, wake: Arc<dyn RuntimeWake>) {
194        *self.wake.borrow_mut() = Some(wake);
195    }
196
197    fn wake(&self) {
198        let wake = self.wake.borrow().clone();
199        if let Some(wake) = wake {
200            wake.wake();
201        }
202    }
203
204    #[doc(hidden)]
205    #[must_use]
206    pub fn new_for_test(capabilities: Arc<RwLock<PlatformCapabilities>>) -> Self {
207        Self::new(capabilities)
208    }
209
210    #[must_use]
211    pub fn capabilities(&self) -> incular_platform::ApplicationServiceCapabilities {
212        self.capabilities
213            .read()
214            .expect("platform capabilities lock")
215            .application_services
216    }
217
218    pub fn create_tray_item(
219        &self,
220        presentation: TrayItemPresentation,
221        menus: &[PlatformMenu],
222    ) -> Result<TrayItemHandle, ApplicationShellError> {
223        self.require_support(
224            self.capabilities().tray_or_status_item,
225            ApplicationShellFeature::TrayOrStatusItem,
226        )?;
227        let menu = PlatformMenuCommandModel::compile(menus).map_err(menu_build_error)?;
228        let mut state = self.state.borrow_mut();
229        ensure_running(&state)?;
230        let id = reserve_tray(
231            &mut state.trays,
232            TrayRecord {
233                phase: ResourcePhase::Queued,
234                presentation: presentation.clone(),
235                menu: menu.clone(),
236                on_activated: None,
237            },
238        );
239        enqueue(
240            &mut state,
241            NativeApplicationShellOperation::CreateTray {
242                id,
243                presentation,
244                menu: menu.snapshot().clone(),
245            },
246        );
247        self.wake();
248        Ok(TrayItemHandle {
249            id,
250            service: self.clone(),
251            released: false,
252        })
253    }
254
255    pub fn show_notification(
256        &self,
257        presentation: NotificationPresentation,
258    ) -> Result<NotificationHandle, ApplicationShellError> {
259        let capabilities = self.capabilities();
260        self.require_support(
261            capabilities.notifications,
262            ApplicationShellFeature::Notifications,
263        )?;
264        if !presentation.actions.is_empty() {
265            self.require_support(
266                capabilities.notification_actions,
267                ApplicationShellFeature::NotificationActions,
268            )?;
269        }
270        let mut state = self.state.borrow_mut();
271        ensure_running(&state)?;
272        let id = reserve_notification(
273            &mut state.notifications,
274            NotificationRecord {
275                phase: ResourcePhase::Queued,
276                presentation: presentation.clone(),
277                on_activated: None,
278                on_action: None,
279                on_dismissed: None,
280            },
281        );
282        enqueue(
283            &mut state,
284            NativeApplicationShellOperation::ShowNotification { id, presentation },
285        );
286        self.wake();
287        Ok(NotificationHandle {
288            id,
289            service: self.clone(),
290            released: false,
291        })
292    }
293
294    /// Sets the installed desktop identity used by notification systems that
295    /// require one (for example a Windows AppUserModelID). Incular never uses a
296    /// misleading shell identity as a fallback.
297    pub fn set_notification_identity(
298        &self,
299        identity: impl Into<String>,
300    ) -> Result<(), ApplicationShellError> {
301        let identity = identity.into();
302        if identity.trim().is_empty() || identity.chars().any(char::is_control) {
303            return Err(ApplicationShellError::PlatformConfigurationRequired(
304                "notification identity must be a non-empty printable installed application identity".to_owned(),
305            ));
306        }
307        let mut state = self.state.borrow_mut();
308        ensure_running(&state)?;
309        state.notification_identity = Some(identity);
310        Ok(())
311    }
312
313    #[doc(hidden)]
314    #[must_use]
315    pub fn notification_identity(&self) -> Option<String> {
316        self.state.borrow().notification_identity.clone()
317    }
318
319    pub fn set_taskbar_dock_state(
320        &self,
321        state_value: TaskbarDockState,
322    ) -> Result<(), ApplicationShellError> {
323        let capabilities = self.capabilities();
324        if state_value.progress != incular_platform::TaskbarProgress::None {
325            self.require_support(
326                capabilities.taskbar_progress,
327                ApplicationShellFeature::TaskbarProgress,
328            )?;
329        }
330        if state_value.badge != incular_platform::ApplicationBadge::None {
331            self.require_support(
332                capabilities.application_badge,
333                ApplicationShellFeature::ApplicationBadge,
334            )?;
335        }
336        if state_value.overlay_icon.is_some() {
337            self.require_support(
338                capabilities.taskbar_overlay_icon,
339                ApplicationShellFeature::TaskbarOverlayIcon,
340            )?;
341        }
342        let mut state = self.state.borrow_mut();
343        ensure_running(&state)?;
344        enqueue(
345            &mut state,
346            NativeApplicationShellOperation::SetTaskbarDockState(state_value),
347        );
348        self.wake();
349        Ok(())
350    }
351
352    fn require_support(
353        &self,
354        support: CapabilitySupport,
355        feature: ApplicationShellFeature,
356    ) -> Result<(), ApplicationShellError> {
357        if support == CapabilitySupport::Unsupported {
358            Err(ApplicationShellError::Unsupported(feature))
359        } else {
360            Ok(())
361        }
362    }
363
364    pub(crate) fn take_native_requests(&self) -> Vec<NativeApplicationShellRequest> {
365        let mut state = self.state.borrow_mut();
366        let requests: Vec<_> = state.requests.drain(..).collect();
367        for request in &requests {
368            state.pending.dispatch(&request.request_id);
369            if let Some(resource) = created_resource(&request.operation) {
370                set_resource_phase(&mut state, resource, ResourcePhase::Dispatched);
371            }
372        }
373        requests
374    }
375
376    #[doc(hidden)]
377    pub fn take_native_requests_for_test(&self) -> Vec<NativeApplicationShellRequest> {
378        self.take_native_requests()
379    }
380
381    pub(crate) fn complete(&self, completion: NativeApplicationShellCompletion) {
382        let mut state = self.state.borrow_mut();
383        if let Ok(operation) = state.pending.complete(&completion.request_id, |_| true) {
384            if let Some(resource) = created_resource(&operation) {
385                let phase = if completion.result.is_ok() {
386                    ResourcePhase::Live
387                } else {
388                    ResourcePhase::Failed
389                };
390                set_resource_phase(&mut state, resource, phase);
391                if phase == ResourcePhase::Failed {
392                    cancel_queued_resource(&mut state, resource);
393                }
394            }
395            state.completions.push_back(completion);
396        }
397    }
398
399    #[doc(hidden)]
400    pub fn complete_for_test(&self, completion: NativeApplicationShellCompletion) {
401        self.complete(completion);
402    }
403
404    #[must_use]
405    pub fn take_completions(&self) -> Vec<NativeApplicationShellCompletion> {
406        self.state.borrow_mut().completions.drain(..).collect()
407    }
408
409    pub(crate) fn handle_native_event(&self, event: NativeApplicationShellEvent) -> bool {
410        match event {
411            NativeApplicationShellEvent::TrayMenu { id, event } => {
412                let callback = {
413                    let state = self.state.borrow_mut();
414                    tray_record(&state.trays, id).map(|record| record.menu.clone())
415                };
416                callback.is_some_and(|menu| {
417                    matches!(
418                        menu.dispatch(&event),
419                        incular_widgets::MenuDispatchResult::Handled
420                    )
421                })
422            }
423            NativeApplicationShellEvent::TrayActivated { id } => {
424                let callback = {
425                    let state = self.state.borrow();
426                    tray_record(&state.trays, id).and_then(|record| record.on_activated.clone())
427                };
428                callback.is_some_and(|callback| {
429                    callback();
430                    true
431                })
432            }
433            NativeApplicationShellEvent::NotificationActivated { id } => {
434                notification_callback(&self.state, id, |record| record.on_activated.clone())
435            }
436            NativeApplicationShellEvent::NotificationAction { id, action } => {
437                let callback = {
438                    let state = self.state.borrow_mut();
439                    notification_record(&state.notifications, id).and_then(|record| {
440                        record
441                            .presentation
442                            .actions
443                            .iter()
444                            .any(|known| known.id == action)
445                            .then(|| record.on_action.clone())
446                            .flatten()
447                    })
448                };
449                callback.is_some_and(|callback| {
450                    callback(action);
451                    true
452                })
453            }
454            NativeApplicationShellEvent::NotificationDismissed { id } => {
455                let handled =
456                    notification_callback(&self.state, id, |record| record.on_dismissed.clone());
457                let dismiss_native =
458                    self.capabilities().notification_dismiss != CapabilitySupport::Unsupported;
459                let mut state = self.state.borrow_mut();
460                let phase =
461                    notification_record(&state.notifications, id).map(|record| record.phase);
462                let released = release_notification(&mut state.notifications, id);
463                if let Some(phase) = phase {
464                    retire_resource(
465                        &mut state,
466                        ShellResource::Notification(id),
467                        phase,
468                        dismiss_native,
469                    );
470                }
471                drop(state);
472                if released && dismiss_native {
473                    self.wake();
474                }
475                handled || released
476            }
477        }
478    }
479
480    #[doc(hidden)]
481    pub fn handle_native_event_for_test(&self, event: NativeApplicationShellEvent) -> bool {
482        self.handle_native_event(event)
483    }
484
485    pub(crate) fn stop(&self) {
486        let mut state = self.state.borrow_mut();
487        state.stopped = true;
488        state.requests.clear();
489        let pending: Vec<_> = state
490            .pending
491            .drain()
492            .map(|(request_id, _)| NativeApplicationShellCompletion {
493                request_id,
494                result: Err(ApplicationShellError::ApplicationStopped),
495            })
496            .collect();
497        state.completions.extend(pending);
498        for slot in &mut state.trays {
499            retire_slot(slot);
500        }
501        for slot in &mut state.notifications {
502            retire_slot(slot);
503        }
504    }
505}
506
507fn notification_callback<T>(
508    state: &Rc<RefCell<ShellState>>,
509    id: NotificationId,
510    select: impl FnOnce(&NotificationRecord) -> Option<Arc<T>>,
511) -> bool
512where
513    T: Fn() + Send + Sync + ?Sized,
514{
515    let callback = {
516        let state = state.borrow();
517        notification_record(&state.notifications, id).and_then(select)
518    };
519    callback.is_some_and(|callback| {
520        callback();
521        true
522    })
523}
524
525fn operation_resource(operation: &NativeApplicationShellOperation) -> Option<ShellResource> {
526    match operation {
527        NativeApplicationShellOperation::CreateTray { id, .. }
528        | NativeApplicationShellOperation::UpdateTray { id, .. }
529        | NativeApplicationShellOperation::RemoveTray { id } => Some(ShellResource::Tray(*id)),
530        NativeApplicationShellOperation::ShowNotification { id, .. }
531        | NativeApplicationShellOperation::UpdateNotification { id, .. }
532        | NativeApplicationShellOperation::CloseNotification { id } => {
533            Some(ShellResource::Notification(*id))
534        }
535        NativeApplicationShellOperation::SetTaskbarDockState(_) => None,
536    }
537}
538fn created_resource(operation: &NativeApplicationShellOperation) -> Option<ShellResource> {
539    match operation {
540        NativeApplicationShellOperation::CreateTray { .. }
541        | NativeApplicationShellOperation::ShowNotification { .. } => operation_resource(operation),
542        _ => None,
543    }
544}
545fn set_resource_phase(state: &mut ShellState, resource: ShellResource, phase: ResourcePhase) {
546    match resource {
547        ShellResource::Tray(id) => {
548            if let Some(record) = tray_record_mut(&mut state.trays, id) {
549                record.phase = phase;
550            }
551        }
552        ShellResource::Notification(id) => {
553            if let Some(record) = notification_record_mut(&mut state.notifications, id) {
554                record.phase = phase;
555            }
556        }
557    }
558}
559fn cancel_queued_resource(state: &mut ShellState, resource: ShellResource) {
560    let ids: Vec<_> = state
561        .requests
562        .iter()
563        .filter(|request| operation_resource(&request.operation) == Some(resource))
564        .map(|request| request.request_id)
565        .collect();
566    state
567        .requests
568        .retain(|request| operation_resource(&request.operation) != Some(resource));
569    for request_id in ids {
570        state.pending.remove(&request_id);
571        state
572            .completions
573            .push_back(NativeApplicationShellCompletion {
574                request_id,
575                result: Err(ApplicationShellError::StaleResource),
576            });
577    }
578}
579fn retire_resource(
580    state: &mut ShellState,
581    resource: ShellResource,
582    phase: ResourcePhase,
583    native_cleanup: bool,
584) {
585    cancel_queued_resource(state, resource);
586    if native_cleanup && matches!(phase, ResourcePhase::Dispatched | ResourcePhase::Live) {
587        enqueue(
588            state,
589            match resource {
590                ShellResource::Tray(id) => NativeApplicationShellOperation::RemoveTray { id },
591                ShellResource::Notification(id) => {
592                    NativeApplicationShellOperation::CloseNotification { id }
593                }
594            },
595        );
596    }
597}
598
599fn menu_build_error(error: PlatformMenuBuildError) -> ApplicationShellError {
600    ApplicationShellError::NativeFailure(error.to_string())
601}
602
603fn ensure_running(state: &ShellState) -> Result<(), ApplicationShellError> {
604    (!state.stopped)
605        .then_some(())
606        .ok_or(ApplicationShellError::ApplicationStopped)
607}
608
609fn enqueue(state: &mut ShellState, operation: NativeApplicationShellOperation) {
610    let request_id = ApplicationShellRequestId(state.next_request);
611    state.next_request = state
612        .next_request
613        .checked_add(1)
614        .expect("shell request identity exhausted");
615    state.pending.insert(request_id, operation.clone());
616    state.requests.push_back(NativeApplicationShellRequest {
617        request_id,
618        operation,
619    });
620}
621
622fn reserve_tray(slots: &mut Vec<Slot<TrayRecord>>, value: TrayRecord) -> TrayItemId {
623    if let Some((index, slot)) = slots
624        .iter_mut()
625        .enumerate()
626        .find(|(_, slot)| !slot.retired && slot.value.is_none())
627    {
628        slot.value = Some(value);
629        return TrayItemId::from_parts(
630            u32::try_from(index).expect("tray index was validated on insertion"),
631            slot.generation,
632        );
633    }
634    let index = u32::try_from(slots.len()).expect("tray identity space exhausted");
635    slots.push(Slot {
636        generation: 0,
637        retired: false,
638        value: Some(value),
639    });
640    TrayItemId::from_parts(index, 0)
641}
642
643fn reserve_notification(
644    slots: &mut Vec<Slot<NotificationRecord>>,
645    value: NotificationRecord,
646) -> NotificationId {
647    if let Some((index, slot)) = slots
648        .iter_mut()
649        .enumerate()
650        .find(|(_, slot)| !slot.retired && slot.value.is_none())
651    {
652        slot.value = Some(value);
653        return NotificationId::from_parts(
654            u32::try_from(index).expect("notification index was validated on insertion"),
655            slot.generation,
656        );
657    }
658    let index = u32::try_from(slots.len()).expect("notification identity space exhausted");
659    slots.push(Slot {
660        generation: 0,
661        retired: false,
662        value: Some(value),
663    });
664    NotificationId::from_parts(index, 0)
665}
666
667fn tray_record(slots: &[Slot<TrayRecord>], id: TrayItemId) -> Option<&TrayRecord> {
668    slots
669        .get(id.index() as usize)
670        .filter(|slot| slot.generation == id.generation())?
671        .value
672        .as_ref()
673}
674
675fn tray_record_mut(slots: &mut [Slot<TrayRecord>], id: TrayItemId) -> Option<&mut TrayRecord> {
676    slots
677        .get_mut(id.index() as usize)
678        .filter(|slot| slot.generation == id.generation())?
679        .value
680        .as_mut()
681}
682
683fn notification_record(
684    slots: &[Slot<NotificationRecord>],
685    id: NotificationId,
686) -> Option<&NotificationRecord> {
687    slots
688        .get(id.index() as usize)
689        .filter(|slot| slot.generation == id.generation())?
690        .value
691        .as_ref()
692}
693
694fn notification_record_mut(
695    slots: &mut [Slot<NotificationRecord>],
696    id: NotificationId,
697) -> Option<&mut NotificationRecord> {
698    slots
699        .get_mut(id.index() as usize)
700        .filter(|slot| slot.generation == id.generation())?
701        .value
702        .as_mut()
703}
704
705fn release_notification(slots: &mut [Slot<NotificationRecord>], id: NotificationId) -> bool {
706    let Some(slot) = slots
707        .get_mut(id.index() as usize)
708        .filter(|slot| slot.generation == id.generation())
709    else {
710        return false;
711    };
712    if slot.value.is_none() {
713        return false;
714    }
715    retire_slot(slot);
716    true
717}
718
719fn retire_slot<T>(slot: &mut Slot<T>) {
720    if slot.value.take().is_none() {
721        return;
722    }
723    if let Some(next_generation) = slot.generation.checked_add(1) {
724        slot.generation = next_generation;
725    } else {
726        slot.retired = true;
727    }
728}
729
730pub struct TrayItemHandle {
731    id: TrayItemId,
732    service: ApplicationShellService,
733    released: bool,
734}
735
736impl TrayItemHandle {
737    #[must_use]
738    pub const fn id(&self) -> TrayItemId {
739        self.id
740    }
741
742    pub fn update(
743        &self,
744        presentation: TrayItemPresentation,
745        menus: &[PlatformMenu],
746    ) -> Result<(), ApplicationShellError> {
747        self.service.require_support(
748            self.service.capabilities().tray_or_status_item,
749            ApplicationShellFeature::TrayOrStatusItem,
750        )?;
751        let menu = PlatformMenuCommandModel::compile(menus).map_err(menu_build_error)?;
752        let mut state = self.service.state.borrow_mut();
753        ensure_running(&state)?;
754        let record = tray_record_mut(&mut state.trays, self.id)
755            .ok_or(ApplicationShellError::StaleResource)?;
756        if record.phase == ResourcePhase::Failed {
757            return Err(ApplicationShellError::StaleResource);
758        }
759        record.presentation = presentation.clone();
760        record.menu = menu.clone();
761        enqueue(
762            &mut state,
763            NativeApplicationShellOperation::UpdateTray {
764                id: self.id,
765                presentation,
766                menu: menu.snapshot().clone(),
767            },
768        );
769        drop(state);
770        self.service.wake();
771        Ok(())
772    }
773
774    pub fn on_activated(
775        &self,
776        callback: impl Fn() + Send + Sync + 'static,
777    ) -> Result<(), ApplicationShellError> {
778        let mut state = self.service.state.borrow_mut();
779        ensure_running(&state)?;
780        let record = tray_record_mut(&mut state.trays, self.id)
781            .ok_or(ApplicationShellError::StaleResource)?;
782        record.on_activated = Some(Arc::new(callback));
783        Ok(())
784    }
785
786    pub fn remove(mut self) -> Result<(), ApplicationShellError> {
787        self.service.remove_tray(self.id)?;
788        self.released = true;
789        Ok(())
790    }
791}
792
793impl Drop for TrayItemHandle {
794    fn drop(&mut self) {
795        if !self.released {
796            let _ = self.service.remove_tray(self.id);
797        }
798    }
799}
800
801impl ApplicationShellService {
802    fn remove_tray(&self, id: TrayItemId) -> Result<(), ApplicationShellError> {
803        let mut state = self.state.borrow_mut();
804        ensure_running(&state)?;
805        let slot = state
806            .trays
807            .get_mut(id.index() as usize)
808            .filter(|slot| slot.generation == id.generation())
809            .ok_or(ApplicationShellError::StaleResource)?;
810        let record = slot
811            .value
812            .take()
813            .ok_or(ApplicationShellError::StaleResource)?;
814        if let Some(next_generation) = slot.generation.checked_add(1) {
815            slot.generation = next_generation;
816        } else {
817            slot.retired = true;
818        }
819        retire_resource(&mut state, ShellResource::Tray(id), record.phase, true);
820        drop(state);
821        self.wake();
822        Ok(())
823    }
824}
825
826pub struct NotificationHandle {
827    id: NotificationId,
828    service: ApplicationShellService,
829    released: bool,
830}
831
832impl NotificationHandle {
833    #[must_use]
834    pub const fn id(&self) -> NotificationId {
835        self.id
836    }
837
838    pub fn on_activated(
839        &self,
840        callback: impl Fn() + Send + Sync + 'static,
841    ) -> Result<(), ApplicationShellError> {
842        self.with_record(|record| record.on_activated = Some(Arc::new(callback)))
843    }
844
845    pub fn on_action(
846        &self,
847        callback: impl Fn(NotificationActionId) + Send + Sync + 'static,
848    ) -> Result<(), ApplicationShellError> {
849        self.with_record(|record| record.on_action = Some(Arc::new(callback)))
850    }
851
852    pub fn on_dismissed(
853        &self,
854        callback: impl Fn() + Send + Sync + 'static,
855    ) -> Result<(), ApplicationShellError> {
856        self.with_record(|record| record.on_dismissed = Some(Arc::new(callback)))
857    }
858
859    pub fn update(
860        &self,
861        presentation: NotificationPresentation,
862    ) -> Result<(), ApplicationShellError> {
863        self.service.require_support(
864            self.service.capabilities().notification_update,
865            ApplicationShellFeature::NotificationUpdate,
866        )?;
867        if !presentation.actions.is_empty() {
868            self.service.require_support(
869                self.service.capabilities().notification_actions,
870                ApplicationShellFeature::NotificationActions,
871            )?;
872        }
873        let mut state = self.service.state.borrow_mut();
874        ensure_running(&state)?;
875        let record = notification_record_mut(&mut state.notifications, self.id)
876            .ok_or(ApplicationShellError::StaleResource)?;
877        if record.phase == ResourcePhase::Failed {
878            return Err(ApplicationShellError::StaleResource);
879        }
880        record.presentation = presentation.clone();
881        enqueue(
882            &mut state,
883            NativeApplicationShellOperation::UpdateNotification {
884                id: self.id,
885                presentation,
886            },
887        );
888        drop(state);
889        self.service.wake();
890        Ok(())
891    }
892
893    fn with_record(
894        &self,
895        update: impl FnOnce(&mut NotificationRecord),
896    ) -> Result<(), ApplicationShellError> {
897        let mut state = self.service.state.borrow_mut();
898        ensure_running(&state)?;
899        let record = notification_record_mut(&mut state.notifications, self.id)
900            .ok_or(ApplicationShellError::StaleResource)?;
901        update(record);
902        Ok(())
903    }
904
905    pub fn close(mut self) -> Result<(), ApplicationShellError> {
906        self.service.require_support(
907            self.service.capabilities().notification_dismiss,
908            ApplicationShellFeature::NotificationDismiss,
909        )?;
910        self.service.release_notification_handle(self.id, true)?;
911        self.released = true;
912        Ok(())
913    }
914}
915
916impl Drop for NotificationHandle {
917    fn drop(&mut self) {
918        if self.released {
919            return;
920        }
921        // Dropping a handle always releases the runtime callback/resource
922        // identity. If the backend can dismiss native notifications (or its
923        // capability is not known yet), also request native teardown. A backend
924        // such as Windows which explicitly cannot retract a toast still gets a
925        // stale stable ID, so a late native activation can never reach dropped
926        // application callbacks.
927        let dismiss_native =
928            self.service.capabilities().notification_dismiss != CapabilitySupport::Unsupported;
929        let _ = self
930            .service
931            .release_notification_handle(self.id, dismiss_native);
932    }
933}
934
935impl ApplicationShellService {
936    fn release_notification_handle(
937        &self,
938        id: NotificationId,
939        dismiss_native: bool,
940    ) -> Result<(), ApplicationShellError> {
941        let mut state = self.state.borrow_mut();
942        ensure_running(&state)?;
943        let phase = notification_record(&state.notifications, id)
944            .ok_or(ApplicationShellError::StaleResource)?
945            .phase;
946        release_notification(&mut state.notifications, id);
947        retire_resource(
948            &mut state,
949            ShellResource::Notification(id),
950            phase,
951            dismiss_native,
952        );
953        drop(state);
954        if dismiss_native {
955            self.wake();
956        }
957        Ok(())
958    }
959}
960
961#[derive(Clone, Default)]
962pub struct MemoryApplicationShellAdapter {
963    operations: Arc<Mutex<Vec<NativeApplicationShellOperation>>>,
964}
965
966impl MemoryApplicationShellAdapter {
967    pub fn apply(
968        &self,
969        request: NativeApplicationShellRequest,
970    ) -> NativeApplicationShellCompletion {
971        self.operations
972            .lock()
973            .expect("memory shell adapter lock")
974            .push(request.operation);
975        NativeApplicationShellCompletion {
976            request_id: request.request_id,
977            result: Ok(()),
978        }
979    }
980
981    #[must_use]
982    pub fn operations(&self) -> Vec<NativeApplicationShellOperation> {
983        self.operations
984            .lock()
985            .expect("memory shell adapter lock")
986            .clone()
987    }
988}