Skip to main content

lcsa_core/
context_api.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Condvar, Mutex};
3use std::thread;
4
5use crate::capabilities::SignalSupport;
6use crate::error::Error;
7use crate::event_bus::EventBus;
8use crate::permissions::{Capability, Grant, PermissionRequest, PermissionStore};
9use crate::platform::{
10    read_clipboard_content, signal_support, spawn_clipboard_monitor, spawn_focus_monitor,
11    spawn_selection_monitor, supports_signal,
12};
13use crate::signals::{ClipboardContent, ClipboardSignal, SignalType, StructuralSignal};
14use crate::topology::{ApplicationContext, DeviceContext, SignalEnvelope};
15
16pub struct ContextApi {
17    bus: EventBus,
18    permissions: PermissionStore,
19    device_context: DeviceContext,
20    application_context: ApplicationContext,
21    clipboard_monitor_started: bool,
22    selection_monitor_started: bool,
23    focus_monitor_started: bool,
24    shutdown: Arc<AtomicBool>,
25    shutdown_notify: Arc<(Mutex<bool>, Condvar)>,
26}
27
28pub struct SubscriptionHandle {
29    id: usize,
30    signal_type: SignalType,
31}
32
33pub struct ContextApiBuilder {
34    device_context: DeviceContext,
35    application_context: ApplicationContext,
36}
37
38impl ContextApi {
39    pub fn new() -> Result<Self, Error> {
40        Self::builder().build()
41    }
42
43    pub fn builder() -> ContextApiBuilder {
44        ContextApiBuilder {
45            device_context: DeviceContext::default(),
46            application_context: ApplicationContext::default(),
47        }
48    }
49
50    pub fn device_context(&self) -> &DeviceContext {
51        &self.device_context
52    }
53
54    pub fn application_context(&self) -> &ApplicationContext {
55        &self.application_context
56    }
57
58    /// Subscribe to signals with envelope wrapping (includes device/app context).
59    /// The handler is called on the event bus dispatcher thread.
60    pub fn subscribe_enveloped<F>(
61        &mut self,
62        signal_type: SignalType,
63        handler: F,
64    ) -> Result<SubscriptionHandle, Error>
65    where
66        F: Fn(SignalEnvelope) + Send + 'static,
67    {
68        if !self.is_signal_supported(signal_type) {
69            return Err(Error::UnsupportedSignal(signal_type));
70        }
71
72        if !self
73            .permissions
74            .is_granted(semantic_capability_for(signal_type))
75        {
76            return Err(Error::PermissionDenied(signal_type));
77        }
78
79        self.ensure_monitor(signal_type)?;
80
81        let device_context = self.device_context.clone();
82        let application_context = self.application_context.clone();
83
84        // Register handler directly with the dispatcher (no new thread)
85        let id = self.bus.register(move |signal| {
86            if signal.matches(signal_type) {
87                handler(SignalEnvelope::from_signal(
88                    device_context.clone(),
89                    application_context.clone(),
90                    signal,
91                ));
92            }
93        });
94
95        Ok(SubscriptionHandle { id, signal_type })
96    }
97
98    pub fn read_clipboard_content(&self) -> Result<ClipboardContent, Error> {
99        if !self
100            .permissions
101            .is_granted(Capability::ReadClipboardContent)
102        {
103            return Err(Error::CapabilityDenied(Capability::ReadClipboardContent));
104        }
105
106        read_clipboard_content()
107    }
108
109    pub fn is_signal_supported(&self, signal_type: SignalType) -> bool {
110        supports_signal(signal_type)
111    }
112
113    pub fn signal_support(&self, signal_type: SignalType) -> SignalSupport {
114        signal_support(signal_type)
115    }
116
117    pub fn supported_signals(&self) -> Vec<SignalType> {
118        [
119            SignalType::Clipboard,
120            SignalType::Selection,
121            SignalType::Focus,
122        ]
123        .into_iter()
124        .filter(|signal_type| self.is_signal_supported(*signal_type))
125        .collect()
126    }
127
128    /// Subscribe to clipboard signals only.
129    /// The handler is called on the event bus dispatcher thread.
130    pub fn subscribe<F>(
131        &mut self,
132        signal_type: SignalType,
133        handler: F,
134    ) -> Result<SubscriptionHandle, Error>
135    where
136        F: Fn(ClipboardSignal) + Send + 'static,
137    {
138        if signal_type != SignalType::Clipboard {
139            return Err(Error::UnsupportedSignal(signal_type));
140        }
141
142        if !self
143            .permissions
144            .is_granted(semantic_capability_for(signal_type))
145        {
146            return Err(Error::PermissionDenied(signal_type));
147        }
148
149        self.ensure_monitor(signal_type)?;
150
151        // Register handler directly with the dispatcher (no new thread)
152        let id = self.bus.register(move |signal| {
153            if let StructuralSignal::Clipboard(clipboard_signal) = signal {
154                handler(clipboard_signal);
155            }
156        });
157
158        Ok(SubscriptionHandle { id, signal_type })
159    }
160
161    pub fn unsubscribe(&mut self, handle: SubscriptionHandle) {
162        let _ = handle.signal_type;
163        self.bus.unregister(handle.id);
164    }
165
166    pub fn request_permission(&mut self, request: PermissionRequest) -> Result<Grant, Error> {
167        Ok(self.permissions.grant(request))
168    }
169
170    pub fn revoke_permission(&mut self, capability: Capability) -> bool {
171        self.permissions.revoke(capability)
172    }
173
174    pub fn can_access(&self, capability: Capability) -> bool {
175        self.permissions.is_granted(capability)
176    }
177
178    /// Run the context API, blocking until shutdown is called.
179    pub fn run(&self) {
180        let (lock, cvar) = &*self.shutdown_notify;
181        let mut ready = lock.lock().unwrap();
182        while !self.shutdown.load(Ordering::SeqCst) {
183            ready = cvar.wait(ready).unwrap();
184        }
185    }
186
187    /// Signal shutdown and wait for completion.
188    pub fn shutdown(&self) {
189        self.shutdown.store(true, Ordering::SeqCst);
190        self.bus.shutdown();
191
192        let (lock, cvar) = &*self.shutdown_notify;
193        let mut ready = lock.lock().unwrap();
194        *ready = true;
195        cvar.notify_all();
196    }
197
198    /// Check if shutdown has been requested.
199    pub fn is_shutdown(&self) -> bool {
200        self.shutdown.load(Ordering::SeqCst)
201    }
202
203    /// Run with Unix signal handling (SIGINT, SIGTERM).
204    /// This method blocks until a signal is received or shutdown() is called.
205    #[cfg(unix)]
206    pub fn run_with_signals(&self) -> Result<(), Error> {
207        use signal_hook::consts::{SIGINT, SIGTERM};
208        use signal_hook::iterator::Signals;
209
210        let mut signals = Signals::new([SIGINT, SIGTERM])
211            .map_err(|e| Error::PlatformError(format!("failed to register signals: {}", e)))?;
212
213        let shutdown = Arc::clone(&self.shutdown);
214        let shutdown_notify = Arc::clone(&self.shutdown_notify);
215
216        thread::spawn(move || {
217            for _ in signals.forever() {
218                shutdown.store(true, Ordering::SeqCst);
219                let (lock, cvar) = &*shutdown_notify;
220                let mut ready = lock.lock().unwrap();
221                *ready = true;
222                cvar.notify_all();
223                break;
224            }
225        });
226
227        self.run();
228        self.bus.shutdown();
229        Ok(())
230    }
231
232    fn ensure_monitor(&mut self, signal_type: SignalType) -> Result<(), Error> {
233        match signal_type {
234            SignalType::Clipboard => self.ensure_clipboard_monitor(),
235            SignalType::Selection => self.ensure_selection_monitor(),
236            SignalType::Focus => self.ensure_focus_monitor(),
237        }
238    }
239
240    fn ensure_clipboard_monitor(&mut self) -> Result<(), Error> {
241        ensure_started(&mut self.clipboard_monitor_started, || {
242            spawn_clipboard_monitor(self.bus.clone())
243        })
244    }
245
246    fn ensure_selection_monitor(&mut self) -> Result<(), Error> {
247        ensure_started(&mut self.selection_monitor_started, || {
248            spawn_selection_monitor(self.bus.clone())
249        })
250    }
251
252    fn ensure_focus_monitor(&mut self) -> Result<(), Error> {
253        ensure_started(&mut self.focus_monitor_started, || {
254            spawn_focus_monitor(self.bus.clone())
255        })
256    }
257}
258
259impl ContextApiBuilder {
260    pub fn device_context(mut self, device_context: DeviceContext) -> Self {
261        self.device_context = device_context;
262        self
263    }
264
265    pub fn application_context(mut self, application_context: ApplicationContext) -> Self {
266        self.application_context = application_context;
267        self
268    }
269
270    pub fn build(self) -> Result<ContextApi, Error> {
271        Ok(ContextApi {
272            bus: EventBus::new(),
273            permissions: PermissionStore::with_defaults(),
274            device_context: self.device_context,
275            application_context: self.application_context,
276            clipboard_monitor_started: false,
277            selection_monitor_started: false,
278            focus_monitor_started: false,
279            shutdown: Arc::new(AtomicBool::new(false)),
280            shutdown_notify: Arc::new((Mutex::new(false), Condvar::new())),
281        })
282    }
283}
284
285fn semantic_capability_for(signal_type: SignalType) -> Capability {
286    match signal_type {
287        SignalType::Clipboard => Capability::ReadClipboardSemantic,
288        SignalType::Selection => Capability::ReadSelectionSemantic,
289        SignalType::Focus => Capability::ReadFocusSemantic,
290    }
291}
292
293fn ensure_started<F>(started: &mut bool, start: F) -> Result<(), Error>
294where
295    F: FnOnce() -> Result<(), Error>,
296{
297    if *started {
298        return Ok(());
299    }
300
301    start()?;
302    *started = true;
303    Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308    use std::time::Duration;
309
310    use super::*;
311    use crate::capabilities::{SignalSupport, SignalUnsupportedReason};
312    use crate::permissions::Scope;
313    use crate::topology::{DeviceClass, Platform};
314
315    #[test]
316    fn semantic_access_is_available_by_default() {
317        let api = ContextApi::new().expect("context api");
318        assert!(api.can_access(Capability::ReadClipboardSemantic));
319        assert!(api.can_access(Capability::ReadSelectionSemantic));
320        assert!(api.can_access(Capability::ReadFocusSemantic));
321        assert!(!api.can_access(Capability::ReadClipboardContent));
322    }
323
324    #[test]
325    fn content_access_requires_explicit_grant() {
326        let mut api = ContextApi::new().expect("context api");
327        assert!(matches!(
328            api.read_clipboard_content(),
329            Err(Error::CapabilityDenied(Capability::ReadClipboardContent))
330        ));
331
332        let grant = api
333            .request_permission(
334                PermissionRequest::new(
335                    Capability::ReadClipboardContent,
336                    Scope::Session,
337                    "Need clipboard content for paste-aware assistant behavior",
338                )
339                .with_ttl(Duration::from_secs(60)),
340            )
341            .expect("grant permission");
342
343        assert_eq!(grant.capability, Capability::ReadClipboardContent);
344        assert!(api.can_access(Capability::ReadClipboardContent));
345    }
346
347    #[test]
348    fn builder_overrides_runtime_identity() {
349        let api = ContextApi::builder()
350            .device_context(DeviceContext {
351                device_id: "phone:alice".to_string(),
352                device_name: "alice-phone".to_string(),
353                platform: Platform::Android,
354                device_class: DeviceClass::Phone,
355                os_version: Some("14".to_string()),
356            })
357            .application_context(ApplicationContext {
358                app_id: "notes-sync".to_string(),
359                app_name: "notes-sync".to_string(),
360                app_version: Some("1.2.3".to_string()),
361            })
362            .build()
363            .expect("context api");
364
365        assert_eq!(api.device_context().device_id, "phone:alice");
366        assert_eq!(api.application_context().app_id, "notes-sync");
367    }
368
369    #[test]
370    fn clipboard_only_subscription_rejects_non_clipboard_signal_types() {
371        let mut api = ContextApi::new().expect("context api");
372        let result = api.subscribe(SignalType::Selection, |_| {});
373        assert!(matches!(
374            result,
375            Err(Error::UnsupportedSignal(SignalType::Selection))
376        ));
377    }
378
379    #[test]
380    fn supported_signals_reflect_platform_backends() {
381        let api = ContextApi::new().expect("context api");
382        let supported = api.supported_signals();
383        assert!(supported.contains(&SignalType::Clipboard));
384        assert!(api.is_signal_supported(SignalType::Clipboard));
385        assert_eq!(
386            api.is_signal_supported(SignalType::Selection),
387            matches!(
388                api.signal_support(SignalType::Selection),
389                SignalSupport::Supported
390            )
391        );
392        assert_eq!(
393            api.is_signal_supported(SignalType::Focus),
394            matches!(
395                api.signal_support(SignalType::Focus),
396                SignalSupport::Supported
397            )
398        );
399    }
400
401    #[test]
402    fn signal_support_returns_reason_for_unsupported_signals() {
403        let api = ContextApi::new().expect("context api");
404
405        #[cfg(target_os = "linux")]
406        {
407            if std::env::var_os("DISPLAY").is_none() {
408                assert_eq!(
409                    api.signal_support(SignalType::Focus),
410                    SignalSupport::Unsupported(SignalUnsupportedReason::RequiresX11Display)
411                );
412            } else {
413                assert_ne!(
414                    api.signal_support(SignalType::Focus),
415                    SignalSupport::Unsupported(SignalUnsupportedReason::RequiresX11Display)
416                );
417            }
418        }
419
420        #[cfg(not(target_os = "linux"))]
421        {
422            assert_ne!(
423                api.signal_support(SignalType::Focus),
424                SignalSupport::Unsupported(SignalUnsupportedReason::RequiresX11Display)
425            );
426        }
427    }
428
429    #[test]
430    fn shutdown_completes_cleanly() {
431        let api = ContextApi::new().expect("context api");
432        assert!(!api.is_shutdown());
433        api.shutdown();
434        assert!(api.is_shutdown());
435    }
436}