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 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 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 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 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 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 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 pub fn is_shutdown(&self) -> bool {
200 self.shutdown.load(Ordering::SeqCst)
201 }
202
203 #[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}