Skip to main content

lingxia_shell/
runtime.rs

1use crate::{
2    ResolvedShellActivator, ShellActivator, ShellError, ShellManager, ShellPin, ShellPinTarget,
3    ShellResult,
4};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex, OnceLock};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ShellActivationIntent {
10    pub id: String,
11    pub generation: u64,
12}
13
14pub trait ShellHost: Send + Sync + 'static {
15    fn resolve_activators(
16        &self,
17        items: &[ShellActivator],
18    ) -> ShellResult<Vec<ResolvedShellActivator>>;
19
20    fn apply_activators(&self, items: &[ResolvedShellActivator]) -> ShellResult<()>;
21
22    fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()>;
23
24    fn activate(&self, intent: ShellActivationIntent) -> ShellResult<()>;
25}
26
27#[derive(Clone)]
28struct ActiveShell {
29    manager: Arc<ShellManager>,
30    host: Arc<dyn ShellHost>,
31}
32
33fn active_slot() -> &'static Mutex<Option<ActiveShell>> {
34    static ACTIVE: OnceLock<Mutex<Option<ActiveShell>>> = OnceLock::new();
35    ACTIVE.get_or_init(|| Mutex::new(None))
36}
37
38pub fn initialize(root: impl Into<PathBuf>, host: Arc<dyn ShellHost>) -> ShellResult<()> {
39    let manager = Arc::new(ShellManager::open(root)?);
40    let mut active = active_slot()
41        .lock()
42        .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
43    *active = Some(ActiveShell { manager, host });
44    Ok(())
45}
46
47pub fn manager() -> ShellResult<Arc<ShellManager>> {
48    active_slot()
49        .lock()
50        .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?
51        .as_ref()
52        .map(|active| active.manager.clone())
53        .ok_or(ShellError::NotInitialized)
54}
55
56pub fn resolved_activators() -> ShellResult<Vec<ResolvedShellActivator>> {
57    with_active(|active| {
58        let snapshot = active.manager.snapshot();
59        active.host.resolve_activators(snapshot.activators.items())
60    })
61}
62
63pub fn apply_current_activators() -> ShellResult<Vec<ResolvedShellActivator>> {
64    with_active(|active| {
65        let snapshot = active.manager.snapshot();
66        let resolved = active
67            .host
68            .resolve_activators(snapshot.activators.items())?;
69        active.host.apply_activators(&resolved)?;
70        Ok(resolved)
71    })
72}
73
74pub fn pins() -> ShellResult<Vec<ShellPin>> {
75    Ok(manager()?.snapshot().pins.items)
76}
77
78pub fn apply_current_pins() -> ShellResult<Vec<ShellPin>> {
79    with_active(|active| {
80        let items = active.manager.snapshot().pins.items;
81        active.host.apply_pins(&items)?;
82        Ok(items)
83    })
84}
85
86pub fn is_pinned(target: &ShellPinTarget) -> ShellResult<bool> {
87    Ok(manager()?.snapshot().pins.is_pinned(target))
88}
89
90pub fn set_pinned(target: ShellPinTarget, pinned: bool) -> ShellResult<crate::PinMutation> {
91    let _mutation = pin_mutation_lock()
92        .lock()
93        .map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
94    with_active(|active| {
95        let previous = active.manager.snapshot().pins;
96        let (mutation, snapshot) = if pinned {
97            active.manager.pin(target)?
98        } else {
99            active.manager.unpin(&target)?
100        };
101        if mutation == crate::PinMutation::Changed
102            && let Err(error) = active.host.apply_pins(&snapshot.pins.items)
103        {
104            let _ = active.manager.commit_pins(&snapshot.pins, previous.clone());
105            let _ = active.host.apply_pins(&previous.items);
106            return Err(error);
107        }
108        Ok(mutation)
109    })
110}
111
112fn pin_mutation_lock() -> &'static Mutex<()> {
113    static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
114    MUTATION.get_or_init(|| Mutex::new(()))
115}
116
117pub fn activate(id: &str) -> ShellResult<()> {
118    let id = id.trim();
119    if id.is_empty() {
120        return Err(ShellError::EmptyActivatorId);
121    }
122    with_active(|active| {
123        let snapshot = active.manager.snapshot();
124        let Some(item) = snapshot
125            .activators
126            .items()
127            .iter()
128            .find(|item| item.id == id)
129        else {
130            return Err(ShellError::ActivatorNotFound { id: id.to_string() });
131        };
132        if item.disabled {
133            return Err(ShellError::ActivatorDisabled { id: id.to_string() });
134        }
135        let intent = ShellActivationIntent {
136            id: item.id.clone(),
137            generation: snapshot.activators.generation(),
138        };
139        active.host.activate(intent)
140    })?;
141    let _ = apply_current_activators();
142    Ok(())
143}
144
145fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
146    let active = {
147        let slot = active_slot()
148            .lock()
149            .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
150        slot.clone().ok_or(ShellError::NotInitialized)?
151    };
152    run(&active)
153}
154
155#[cfg(test)]
156pub(crate) fn reset_for_test() {
157    *active_slot().lock().unwrap() = None;
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::sync::atomic::{AtomicBool, Ordering};
164
165    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
166        static TEST_LOCK: Mutex<()> = Mutex::new(());
167        TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
168    }
169
170    #[derive(Default)]
171    struct TestHost {
172        activated: Mutex<Vec<ShellActivationIntent>>,
173        applied: Mutex<Vec<Vec<ResolvedShellActivator>>>,
174        applied_pins: Mutex<Vec<Vec<ShellPin>>>,
175        reject_pins: AtomicBool,
176    }
177
178    impl ShellHost for TestHost {
179        fn resolve_activators(
180            &self,
181            items: &[ShellActivator],
182        ) -> ShellResult<Vec<ResolvedShellActivator>> {
183            Ok(items
184                .iter()
185                .map(|item| ResolvedShellActivator {
186                    id: item.id.clone(),
187                    label: item.label.clone(),
188                    icon_path: Some(item.icon.clone()),
189                    disabled: item.disabled,
190                })
191                .collect())
192        }
193
194        fn apply_activators(&self, items: &[ResolvedShellActivator]) -> ShellResult<()> {
195            self.applied.lock().unwrap().push(items.to_vec());
196            Ok(())
197        }
198
199        fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
200            self.applied_pins.lock().unwrap().push(items.to_vec());
201            if self.reject_pins.load(Ordering::Relaxed) {
202                return Err(ShellError::Host("rejected Pins".to_string()));
203            }
204            Ok(())
205        }
206
207        fn activate(&self, intent: ShellActivationIntent) -> ShellResult<()> {
208            self.activated.lock().unwrap().push(intent);
209            Ok(())
210        }
211    }
212
213    #[test]
214    fn stable_id_activation_routes_the_current_generation() {
215        let _guard = test_guard();
216        reset_for_test();
217        let dir = tempfile::tempdir().unwrap();
218        let host = Arc::new(TestHost::default());
219        initialize(dir.path(), host.clone()).unwrap();
220        manager()
221            .unwrap()
222            .replace_activators(vec![ShellActivator {
223                id: "sync".to_string(),
224                label: "Sync".to_string(),
225                icon: "icons/sync.svg".to_string(),
226                disabled: false,
227            }])
228            .unwrap();
229
230        activate("sync").unwrap();
231
232        assert_eq!(
233            host.activated.lock().unwrap().as_slice(),
234            &[ShellActivationIntent {
235                id: "sync".to_string(),
236                generation: 1,
237            }]
238        );
239        assert_eq!(host.applied.lock().unwrap()[0][0].label, "Sync");
240    }
241
242    #[test]
243    fn disabled_items_never_reach_the_host() {
244        let _guard = test_guard();
245        reset_for_test();
246        let dir = tempfile::tempdir().unwrap();
247        let host = Arc::new(TestHost::default());
248        initialize(dir.path(), host.clone()).unwrap();
249        manager()
250            .unwrap()
251            .replace_activators(vec![ShellActivator {
252                id: "chat".to_string(),
253                label: "Chat".to_string(),
254                icon: "icons/chat.svg".to_string(),
255                disabled: true,
256            }])
257            .unwrap();
258
259        assert_eq!(
260            activate("chat"),
261            Err(ShellError::ActivatorDisabled {
262                id: "chat".to_string()
263            })
264        );
265        assert!(host.activated.lock().unwrap().is_empty());
266    }
267
268    #[test]
269    fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
270        let _guard = test_guard();
271        reset_for_test();
272        let dir = tempfile::tempdir().unwrap();
273        let host = Arc::new(TestHost::default());
274        initialize(dir.path(), host.clone()).unwrap();
275        for index in 0..crate::MAX_SHELL_PINS {
276            let target = if index % 2 == 0 {
277                ShellPinTarget::Lxapp {
278                    key: format!("app.{index}"),
279                }
280            } else {
281                ShellPinTarget::Bookmark {
282                    key: format!("bookmark-{index}"),
283                }
284            };
285            set_pinned(target, true).unwrap();
286        }
287
288        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
289        assert_eq!(
290            set_pinned(
291                ShellPinTarget::Lxapp {
292                    key: "app.overflow".to_string(),
293                },
294                true,
295            ),
296            Err(ShellError::LimitReached {
297                max: crate::MAX_SHELL_PINS,
298            })
299        );
300        assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
301    }
302
303    #[test]
304    fn failed_pin_apply_rolls_back_memory_and_disk() {
305        let _guard = test_guard();
306        reset_for_test();
307        let dir = tempfile::tempdir().unwrap();
308        let host = Arc::new(TestHost::default());
309        initialize(dir.path(), host.clone()).unwrap();
310        host.reject_pins.store(true, Ordering::Relaxed);
311
312        assert_eq!(
313            set_pinned(
314                ShellPinTarget::Lxapp {
315                    key: "app.chat".to_string(),
316                },
317                true,
318            ),
319            Err(ShellError::Host("rejected Pins".to_string()))
320        );
321        assert!(manager().unwrap().snapshot().pins.items.is_empty());
322        assert!(
323            ShellManager::open(dir.path())
324                .unwrap()
325                .snapshot()
326                .pins
327                .items
328                .is_empty()
329        );
330    }
331}