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
140fn pin_mutation_lock() -> &'static Mutex<()> {
141    static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
142    MUTATION.get_or_init(|| Mutex::new(()))
143}
144
145pub fn activate_sidebar_action(mut intent: SidebarActionIntent) -> ShellResult<()> {
146    let id = intent.id.trim().to_string();
147    if id.is_empty() {
148        return Err(ShellError::EmptySidebarActionId);
149    }
150    intent.id = id.clone();
151    with_active(|active| {
152        let snapshot = active.manager.snapshot();
153        let current_generation = snapshot.sidebar_actions.generation();
154        if intent.generation != current_generation {
155            return Err(ShellError::StaleSidebarActionIntent {
156                generation: intent.generation,
157                current: current_generation,
158            });
159        }
160        let Some(item) = snapshot
161            .sidebar_actions
162            .items()
163            .iter()
164            .find(|item| item.id == id)
165        else {
166            return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
167        };
168        if item.disabled {
169            return Err(ShellError::SidebarActionDisabled { id: id.to_string() });
170        }
171        active.host.activate(intent)
172    })
173}
174
175fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
176    let active = {
177        let slot = active_slot()
178            .lock()
179            .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
180        slot.clone().ok_or(ShellError::NotInitialized)?
181    };
182    run(&active)
183}
184
185#[cfg(test)]
186pub(crate) fn reset_for_test() {
187    *active_slot().lock().unwrap() = None;
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::sync::atomic::{AtomicBool, Ordering};
194
195    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
196        static TEST_LOCK: Mutex<()> = Mutex::new(());
197        TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
198    }
199
200    #[derive(Default)]
201    struct TestHost {
202        activated: Mutex<Vec<SidebarActionIntent>>,
203        applied: Mutex<Vec<Vec<ResolvedShellSidebarAction>>>,
204        applied_pins: Mutex<Vec<Vec<ShellPin>>>,
205        reject_pins: AtomicBool,
206    }
207
208    impl ShellHost for TestHost {
209        fn resolve_sidebar_actions(
210            &self,
211            generation: u64,
212            items: &[ShellSidebarAction],
213        ) -> ShellResult<Vec<ResolvedShellSidebarAction>> {
214            Ok(items
215                .iter()
216                .map(|item| ResolvedShellSidebarAction {
217                    generation,
218                    id: item.id.clone(),
219                    placement: item.placement,
220                    label: item.label.clone(),
221                    icon_path: Some(item.icon.clone()),
222                    disabled: item.disabled,
223                })
224                .collect())
225        }
226
227        fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()> {
228            self.applied.lock().unwrap().push(items.to_vec());
229            Ok(())
230        }
231
232        fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
233            self.applied_pins.lock().unwrap().push(items.to_vec());
234            if self.reject_pins.load(Ordering::Relaxed) {
235                return Err(ShellError::Host("rejected Pins".to_string()));
236            }
237            Ok(())
238        }
239
240        fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()> {
241            self.activated.lock().unwrap().push(intent);
242            Ok(())
243        }
244    }
245
246    #[test]
247    fn stable_id_activation_routes_the_current_generation() {
248        let _guard = test_guard();
249        reset_for_test();
250        let dir = tempfile::tempdir().unwrap();
251        let host = Arc::new(TestHost::default());
252        initialize(dir.path(), host.clone()).unwrap();
253        manager()
254            .unwrap()
255            .replace_sidebar_actions(vec![ShellSidebarAction {
256                id: "sync".to_string(),
257                placement: crate::SidebarActionPlacement::Footer,
258                label: "Sync".to_string(),
259                icon: "icons/sync.svg".to_string(),
260                disabled: false,
261            }])
262            .unwrap();
263
264        activate_sidebar_action(SidebarActionIntent {
265            id: "sync".to_string(),
266            generation: 1,
267        })
268        .unwrap();
269
270        assert_eq!(
271            host.activated.lock().unwrap().as_slice(),
272            &[SidebarActionIntent {
273                id: "sync".to_string(),
274                generation: 1,
275            }]
276        );
277        assert!(host.applied.lock().unwrap().is_empty());
278    }
279
280    #[test]
281    fn settings_shaped_runtime_action_stays_on_the_generic_host_channel() {
282        let _guard = test_guard();
283        reset_for_test();
284        let dir = tempfile::tempdir().unwrap();
285        let host = Arc::new(TestHost::default());
286        initialize(dir.path(), host.clone()).unwrap();
287        manager()
288            .unwrap()
289            .replace_sidebar_actions(vec![ShellSidebarAction {
290                id: "settings".to_string(),
291                placement: crate::SidebarActionPlacement::Footer,
292                label: "Settings".to_string(),
293                icon: "settings".to_string(),
294                disabled: false,
295            }])
296            .unwrap();
297
298        activate_sidebar_action(SidebarActionIntent {
299            id: "settings".to_string(),
300            generation: 1,
301        })
302        .unwrap();
303
304        assert_eq!(
305            host.activated.lock().unwrap().as_slice(),
306            &[SidebarActionIntent {
307                id: "settings".to_string(),
308                generation: 1,
309            }]
310        );
311    }
312
313    #[test]
314    fn disabled_items_never_reach_the_host() {
315        let _guard = test_guard();
316        reset_for_test();
317        let dir = tempfile::tempdir().unwrap();
318        let host = Arc::new(TestHost::default());
319        initialize(dir.path(), host.clone()).unwrap();
320        manager()
321            .unwrap()
322            .replace_sidebar_actions(vec![ShellSidebarAction {
323                id: "chat".to_string(),
324                placement: crate::SidebarActionPlacement::Footer,
325                label: "Chat".to_string(),
326                icon: "icons/chat.svg".to_string(),
327                disabled: true,
328            }])
329            .unwrap();
330
331        assert_eq!(
332            activate_sidebar_action(SidebarActionIntent {
333                id: "chat".to_string(),
334                generation: 1,
335            }),
336            Err(ShellError::SidebarActionDisabled {
337                id: "chat".to_string()
338            })
339        );
340        assert!(host.activated.lock().unwrap().is_empty());
341    }
342
343    #[test]
344    fn stale_generation_never_retargets_a_replaced_action() {
345        let _guard = test_guard();
346        reset_for_test();
347        let dir = tempfile::tempdir().unwrap();
348        let host = Arc::new(TestHost::default());
349        initialize(dir.path(), host.clone()).unwrap();
350        let manager = manager().unwrap();
351        manager
352            .replace_sidebar_actions(vec![ShellSidebarAction {
353                id: "settings".to_string(),
354                placement: crate::SidebarActionPlacement::Header,
355                label: "Settings".to_string(),
356                icon: "icons/settings.svg".to_string(),
357                disabled: false,
358            }])
359            .unwrap();
360        manager.clear_sidebar_actions().unwrap();
361
362        assert_eq!(
363            activate_sidebar_action(SidebarActionIntent {
364                id: "settings".to_string(),
365                generation: 1,
366            }),
367            Err(ShellError::StaleSidebarActionIntent {
368                generation: 1,
369                current: 2,
370            })
371        );
372        assert!(host.activated.lock().unwrap().is_empty());
373    }
374
375    #[test]
376    fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
377        let _guard = test_guard();
378        reset_for_test();
379        let dir = tempfile::tempdir().unwrap();
380        let host = Arc::new(TestHost::default());
381        initialize(dir.path(), host.clone()).unwrap();
382        for index in 0..crate::MAX_SHELL_PINS {
383            let target = if index % 2 == 0 {
384                ShellPinTarget::Lxapp {
385                    key: format!("app.{index}"),
386                }
387            } else {
388                ShellPinTarget::Bookmark {
389                    key: format!("bookmark-{index}"),
390                }
391            };
392            set_pinned(target, true).unwrap();
393        }
394
395        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
396        assert_eq!(
397            set_pinned(
398                ShellPinTarget::Lxapp {
399                    key: "app.overflow".to_string(),
400                },
401                true,
402            ),
403            Err(ShellError::LimitReached {
404                max: crate::MAX_SHELL_PINS,
405            })
406        );
407        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
408    }
409
410    #[test]
411    fn failed_pin_apply_rolls_back_memory_and_disk() {
412        let _guard = test_guard();
413        reset_for_test();
414        let dir = tempfile::tempdir().unwrap();
415        let host = Arc::new(TestHost::default());
416        initialize(dir.path(), host.clone()).unwrap();
417        host.reject_pins.store(true, Ordering::Relaxed);
418
419        assert_eq!(
420            set_pinned(
421                ShellPinTarget::Lxapp {
422                    key: "app.chat".to_string(),
423                },
424                true,
425            ),
426            Err(ShellError::Host("rejected Pins".to_string()))
427        );
428        assert!(manager().unwrap().snapshot().pins.items.is_empty());
429        assert!(
430            ShellManager::open(dir.path())
431                .unwrap()
432                .snapshot()
433                .pins
434                .items
435                .is_empty()
436        );
437    }
438}