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}