Skip to main content

lingxia_shell/
runtime.rs

1use crate::{
2    ResolvedShellSidebarAction, ShellError, ShellManager, ShellPin, ShellPinTarget, ShellResult,
3    ShellSidebarAction,
4};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex, OnceLock};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SidebarActionIntent {
10    pub id: String,
11    pub generation: u64,
12}
13
14pub trait ShellHost: Send + Sync + 'static {
15    fn resolve_sidebar_actions(
16        &self,
17        generation: u64,
18        items: &[ShellSidebarAction],
19    ) -> ShellResult<Vec<ResolvedShellSidebarAction>>;
20
21    fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()>;
22
23    fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()>;
24
25    fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()>;
26}
27
28#[derive(Clone)]
29struct ActiveShell {
30    manager: Arc<ShellManager>,
31    host: Arc<dyn ShellHost>,
32}
33
34fn active_slot() -> &'static Mutex<Option<ActiveShell>> {
35    static ACTIVE: OnceLock<Mutex<Option<ActiveShell>>> = OnceLock::new();
36    ACTIVE.get_or_init(|| Mutex::new(None))
37}
38
39pub fn initialize(root: impl Into<PathBuf>, host: Arc<dyn ShellHost>) -> ShellResult<()> {
40    let manager = Arc::new(ShellManager::open(root)?);
41    let mut active = active_slot()
42        .lock()
43        .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
44    *active = Some(ActiveShell { manager, host });
45    Ok(())
46}
47
48pub fn manager() -> ShellResult<Arc<ShellManager>> {
49    active_slot()
50        .lock()
51        .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?
52        .as_ref()
53        .map(|active| active.manager.clone())
54        .ok_or(ShellError::NotInitialized)
55}
56
57pub fn resolved_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
58    with_active(|active| {
59        let snapshot = active.manager.snapshot();
60        active.host.resolve_sidebar_actions(
61            snapshot.sidebar_actions.generation(),
62            snapshot.sidebar_actions.items(),
63        )
64    })
65}
66
67pub fn apply_current_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
68    with_active(|active| {
69        let snapshot = active.manager.snapshot();
70        let resolved = active.host.resolve_sidebar_actions(
71            snapshot.sidebar_actions.generation(),
72            snapshot.sidebar_actions.items(),
73        )?;
74        active.host.apply_sidebar_actions(&resolved)?;
75        Ok(resolved)
76    })
77}
78
79/// The sidebar state the user last settled on, or the expanded default when
80/// the shell is not initialized.
81pub fn sidebar_chrome() -> crate::SidebarChrome {
82    manager()
83        .map(|manager| manager.sidebar_chrome())
84        .unwrap_or_default()
85}
86
87/// Record the user's own sidebar choice. Never call this for the adaptive
88/// projection: a narrow window forcing a rail is not a choice, and writing it
89/// down would make the next launch open as a rail on any window size.
90pub fn set_sidebar_chrome(chrome: crate::SidebarChrome) -> ShellResult<()> {
91    manager()?.set_sidebar_chrome(chrome)
92}
93
94pub fn window_frame() -> Option<crate::WindowFrame> {
95    manager().ok().and_then(|manager| manager.window_frame())
96}
97
98pub fn set_window_frame(frame: crate::WindowFrame) -> ShellResult<()> {
99    manager()?.set_window_frame(frame)
100}
101
102pub fn pins() -> ShellResult<Vec<ShellPin>> {
103    Ok(manager()?.snapshot().pins.items)
104}
105
106pub fn apply_current_pins() -> ShellResult<Vec<ShellPin>> {
107    with_active(|active| {
108        let items = active.manager.snapshot().pins.items;
109        active.host.apply_pins(&items)?;
110        Ok(items)
111    })
112}
113
114pub fn is_pinned(target: &ShellPinTarget) -> ShellResult<bool> {
115    Ok(manager()?.snapshot().pins.is_pinned(target))
116}
117
118pub fn set_pinned(target: ShellPinTarget, pinned: bool) -> ShellResult<crate::PinMutation> {
119    let _mutation = pin_mutation_lock()
120        .lock()
121        .map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
122    with_active(|active| {
123        let previous = active.manager.snapshot().pins;
124        let (mutation, snapshot) = if pinned {
125            active.manager.pin(target)?
126        } else {
127            active.manager.unpin(&target)?
128        };
129        if mutation == crate::PinMutation::Changed
130            && let Err(error) = active.host.apply_pins(&snapshot.pins.items)
131        {
132            let _ = active.manager.commit_pins(&snapshot.pins, previous.clone());
133            let _ = active.host.apply_pins(&previous.items);
134            return Err(error);
135        }
136        Ok(mutation)
137    })
138}
139
140pub fn reorder_pins(items: Vec<ShellPin>) -> ShellResult<crate::PinMutation> {
141    let _mutation = pin_mutation_lock()
142        .lock()
143        .map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
144    with_active(|active| {
145        let previous = active.manager.snapshot().pins;
146        let mut next = previous.clone();
147        let mutation = next.reorder(items)?;
148        if mutation == crate::PinMutation::Unchanged {
149            return Ok(mutation);
150        }
151        let snapshot = active.manager.commit_pins(&previous, next)?;
152        if let Err(error) = active.host.apply_pins(&snapshot.pins.items) {
153            active
154                .manager
155                .commit_pins(&snapshot.pins, previous.clone())?;
156            let _ = active.host.apply_pins(&previous.items);
157            return Err(error);
158        }
159        Ok(mutation)
160    })
161}
162
163fn pin_mutation_lock() -> &'static Mutex<()> {
164    static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
165    MUTATION.get_or_init(|| Mutex::new(()))
166}
167
168pub fn activate_sidebar_action(mut intent: SidebarActionIntent) -> ShellResult<()> {
169    let id = intent.id.trim().to_string();
170    if id.is_empty() {
171        return Err(ShellError::EmptySidebarActionId);
172    }
173    intent.id = id.clone();
174    with_active(|active| {
175        let snapshot = active.manager.snapshot();
176        let current_generation = snapshot.sidebar_actions.generation();
177        if intent.generation != current_generation {
178            // Chrome can be one replace() behind: the same stable id is still
179            // live, so retarget rather than drop the click. A recycled or
180            // cleared id still fails.
181            let still_live = snapshot
182                .sidebar_actions
183                .items()
184                .iter()
185                .any(|item| item.id == id);
186            if still_live {
187                intent.generation = current_generation;
188            } else {
189                return Err(ShellError::StaleSidebarActionIntent {
190                    generation: intent.generation,
191                    current: current_generation,
192                });
193            }
194        }
195        let Some(item) = snapshot
196            .sidebar_actions
197            .items()
198            .iter()
199            .find(|item| item.id == id)
200        else {
201            return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
202        };
203        if item.disabled {
204            return Err(ShellError::SidebarActionDisabled { id: id.to_string() });
205        }
206        active.host.activate(intent)
207    })
208}
209
210fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
211    let active = {
212        let slot = active_slot()
213            .lock()
214            .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
215        slot.clone().ok_or(ShellError::NotInitialized)?
216    };
217    run(&active)
218}
219
220#[cfg(test)]
221pub(crate) fn reset_for_test() {
222    *active_slot().lock().unwrap() = None;
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::sync::atomic::{AtomicBool, Ordering};
229
230    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
231        static TEST_LOCK: Mutex<()> = Mutex::new(());
232        TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
233    }
234
235    #[derive(Default)]
236    struct TestHost {
237        activated: Mutex<Vec<SidebarActionIntent>>,
238        applied: Mutex<Vec<Vec<ResolvedShellSidebarAction>>>,
239        applied_pins: Mutex<Vec<Vec<ShellPin>>>,
240        reject_pins: AtomicBool,
241    }
242
243    impl ShellHost for TestHost {
244        fn resolve_sidebar_actions(
245            &self,
246            generation: u64,
247            items: &[ShellSidebarAction],
248        ) -> ShellResult<Vec<ResolvedShellSidebarAction>> {
249            Ok(items
250                .iter()
251                .map(|item| ResolvedShellSidebarAction {
252                    generation,
253                    id: item.id.clone(),
254                    placement: item.placement,
255                    label: item.label.clone(),
256                    icon_path: Some(item.icon.clone()),
257                    disabled: item.disabled,
258                })
259                .collect())
260        }
261
262        fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()> {
263            self.applied.lock().unwrap().push(items.to_vec());
264            Ok(())
265        }
266
267        fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
268            self.applied_pins.lock().unwrap().push(items.to_vec());
269            if self.reject_pins.load(Ordering::Relaxed) {
270                return Err(ShellError::Host("rejected Pins".to_string()));
271            }
272            Ok(())
273        }
274
275        fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()> {
276            self.activated.lock().unwrap().push(intent);
277            Ok(())
278        }
279    }
280
281    #[test]
282    fn stable_id_activation_routes_the_current_generation() {
283        let _guard = test_guard();
284        reset_for_test();
285        let dir = tempfile::tempdir().unwrap();
286        let host = Arc::new(TestHost::default());
287        initialize(dir.path(), host.clone()).unwrap();
288        manager()
289            .unwrap()
290            .replace_sidebar_actions(vec![ShellSidebarAction {
291                id: "sync".to_string(),
292                placement: crate::SidebarActionPlacement::Footer,
293                label: "Sync".to_string(),
294                icon: "icons/sync.svg".to_string(),
295                disabled: false,
296            }])
297            .unwrap();
298
299        activate_sidebar_action(SidebarActionIntent {
300            id: "sync".to_string(),
301            generation: 1,
302        })
303        .unwrap();
304
305        assert_eq!(
306            host.activated.lock().unwrap().as_slice(),
307            &[SidebarActionIntent {
308                id: "sync".to_string(),
309                generation: 1,
310            }]
311        );
312        assert!(host.applied.lock().unwrap().is_empty());
313    }
314
315    #[test]
316    fn settings_shaped_runtime_action_stays_on_the_generic_host_channel() {
317        let _guard = test_guard();
318        reset_for_test();
319        let dir = tempfile::tempdir().unwrap();
320        let host = Arc::new(TestHost::default());
321        initialize(dir.path(), host.clone()).unwrap();
322        manager()
323            .unwrap()
324            .replace_sidebar_actions(vec![ShellSidebarAction {
325                id: "settings".to_string(),
326                placement: crate::SidebarActionPlacement::Footer,
327                label: "Settings".to_string(),
328                icon: "settings".to_string(),
329                disabled: false,
330            }])
331            .unwrap();
332
333        activate_sidebar_action(SidebarActionIntent {
334            id: "settings".to_string(),
335            generation: 1,
336        })
337        .unwrap();
338
339        assert_eq!(
340            host.activated.lock().unwrap().as_slice(),
341            &[SidebarActionIntent {
342                id: "settings".to_string(),
343                generation: 1,
344            }]
345        );
346    }
347
348    #[test]
349    fn disabled_items_never_reach_the_host() {
350        let _guard = test_guard();
351        reset_for_test();
352        let dir = tempfile::tempdir().unwrap();
353        let host = Arc::new(TestHost::default());
354        initialize(dir.path(), host.clone()).unwrap();
355        manager()
356            .unwrap()
357            .replace_sidebar_actions(vec![ShellSidebarAction {
358                id: "chat".to_string(),
359                placement: crate::SidebarActionPlacement::Footer,
360                label: "Chat".to_string(),
361                icon: "icons/chat.svg".to_string(),
362                disabled: true,
363            }])
364            .unwrap();
365
366        assert_eq!(
367            activate_sidebar_action(SidebarActionIntent {
368                id: "chat".to_string(),
369                generation: 1,
370            }),
371            Err(ShellError::SidebarActionDisabled {
372                id: "chat".to_string()
373            })
374        );
375        assert!(host.activated.lock().unwrap().is_empty());
376    }
377
378    #[test]
379    fn stale_generation_retargets_when_the_id_is_still_live() {
380        let _guard = test_guard();
381        reset_for_test();
382        let dir = tempfile::tempdir().unwrap();
383        let host = Arc::new(TestHost::default());
384        initialize(dir.path(), host.clone()).unwrap();
385        let manager = manager().unwrap();
386        manager
387            .replace_sidebar_actions(vec![ShellSidebarAction {
388                id: "brand".to_string(),
389                placement: crate::SidebarActionPlacement::Footer,
390                label: "Brand".to_string(),
391                icon: "icons/brand.svg".to_string(),
392                disabled: false,
393            }])
394            .unwrap();
395        manager
396            .replace_sidebar_actions(vec![ShellSidebarAction {
397                id: "brand".to_string(),
398                placement: crate::SidebarActionPlacement::Footer,
399                label: "Brand 2".to_string(),
400                icon: "icons/brand.svg".to_string(),
401                disabled: false,
402            }])
403            .unwrap();
404
405        activate_sidebar_action(SidebarActionIntent {
406            id: "brand".to_string(),
407            generation: 1,
408        })
409        .unwrap();
410
411        assert_eq!(
412            host.activated.lock().unwrap().as_slice(),
413            &[SidebarActionIntent {
414                id: "brand".to_string(),
415                generation: 2,
416            }]
417        );
418    }
419
420    #[test]
421    fn stale_generation_never_retargets_a_replaced_action() {
422        let _guard = test_guard();
423        reset_for_test();
424        let dir = tempfile::tempdir().unwrap();
425        let host = Arc::new(TestHost::default());
426        initialize(dir.path(), host.clone()).unwrap();
427        let manager = manager().unwrap();
428        manager
429            .replace_sidebar_actions(vec![ShellSidebarAction {
430                id: "settings".to_string(),
431                placement: crate::SidebarActionPlacement::Header,
432                label: "Settings".to_string(),
433                icon: "icons/settings.svg".to_string(),
434                disabled: false,
435            }])
436            .unwrap();
437        manager.clear_sidebar_actions().unwrap();
438
439        assert_eq!(
440            activate_sidebar_action(SidebarActionIntent {
441                id: "settings".to_string(),
442                generation: 1,
443            }),
444            Err(ShellError::StaleSidebarActionIntent {
445                generation: 1,
446                current: 2,
447            })
448        );
449        assert!(host.activated.lock().unwrap().is_empty());
450    }
451
452    #[test]
453    fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
454        let _guard = test_guard();
455        reset_for_test();
456        let dir = tempfile::tempdir().unwrap();
457        let host = Arc::new(TestHost::default());
458        initialize(dir.path(), host.clone()).unwrap();
459        for index in 0..crate::MAX_SHELL_PINS {
460            let target = if index % 2 == 0 {
461                ShellPinTarget::Lxapp {
462                    key: format!("app.{index}"),
463                }
464            } else {
465                ShellPinTarget::Bookmark {
466                    key: format!("bookmark-{index}"),
467                }
468            };
469            set_pinned(target, true).unwrap();
470        }
471
472        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
473        assert_eq!(
474            set_pinned(
475                ShellPinTarget::Lxapp {
476                    key: "app.overflow".to_string(),
477                },
478                true,
479            ),
480            Err(ShellError::LimitReached {
481                max: crate::MAX_SHELL_PINS,
482            })
483        );
484        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
485    }
486
487    #[test]
488    fn reorder_persists_mixed_order_and_rolls_back_rejected_projection() {
489        let _guard = test_guard();
490        reset_for_test();
491        let dir = tempfile::tempdir().unwrap();
492        let host = Arc::new(TestHost::default());
493        initialize(dir.path(), host.clone()).unwrap();
494        set_pinned(ShellPinTarget::Lxapp { key: "chat".into() }, true).unwrap();
495        set_pinned(ShellPinTarget::Bookmark { key: "site".into() }, true).unwrap();
496        let original = pins().unwrap();
497        let reversed: Vec<_> = original.iter().rev().cloned().collect();
498        reorder_pins(reversed.clone()).unwrap();
499        assert_eq!(pins().unwrap(), reversed);
500        assert_eq!(
501            ShellManager::open(dir.path())
502                .unwrap()
503                .snapshot()
504                .pins
505                .items,
506            reversed
507        );
508        host.reject_pins.store(true, Ordering::Relaxed);
509        assert!(reorder_pins(original).is_err());
510        assert_eq!(pins().unwrap(), reversed);
511        assert_eq!(
512            ShellManager::open(dir.path())
513                .unwrap()
514                .snapshot()
515                .pins
516                .items,
517            reversed
518        );
519    }
520
521    #[test]
522    fn failed_pin_apply_rolls_back_memory_and_disk() {
523        let _guard = test_guard();
524        reset_for_test();
525        let dir = tempfile::tempdir().unwrap();
526        let host = Arc::new(TestHost::default());
527        initialize(dir.path(), host.clone()).unwrap();
528        host.reject_pins.store(true, Ordering::Relaxed);
529
530        assert_eq!(
531            set_pinned(
532                ShellPinTarget::Lxapp {
533                    key: "app.chat".to_string(),
534                },
535                true,
536            ),
537            Err(ShellError::Host("rejected Pins".to_string()))
538        );
539        assert!(manager().unwrap().snapshot().pins.items.is_empty());
540        assert!(
541            ShellManager::open(dir.path())
542                .unwrap()
543                .snapshot()
544                .pins
545                .items
546                .is_empty()
547        );
548    }
549}