1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4#[cfg(target_os = "macos")]
5use std::sync::Arc;
6use std::sync::Mutex;
7use tauri::{
8 plugin::PluginApi, AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder,
9};
10
11use crate::apply::{config_content_hash, ApplyOutcome, ReloadOutcome, SkipReason};
12use crate::config::WidgetsPluginConfig;
13use crate::error::Error;
14use crate::models::{WidgetConfig, WidgetWindowConfig};
15use crate::receipt::{receipts_path, ReceiptStore, WidgetRenderReceipt};
16use crate::store::{
17 self, config_key, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY,
18};
19use crate::trace::{trace_path, TraceEvent, TraceStore, WidgetTrace};
20
21#[cfg(target_os = "macos")]
22use crate::transport::Transport;
23#[cfg(target_os = "macos")]
24use std::ffi::CString;
25
26pub(crate) const BUILTIN_PROTOCOL: &str = "widgetview";
28
29fn builtin_widget_url(group: &str, size: &str, widget_id: &str) -> WebviewUrl {
30 #[cfg(target_os = "windows")]
31 let url_str = format!(
32 "https://{}.localhost/?group={}&size={}&widgetId={}",
33 BUILTIN_PROTOCOL, group, size, widget_id
34 );
35 #[cfg(not(target_os = "windows"))]
36 let url_str = format!(
37 "{}://localhost/?group={}&size={}&widgetId={}",
38 BUILTIN_PROTOCOL, group, size, widget_id
39 );
40 WebviewUrl::External(url_str.parse().expect("invalid built-in widget URL"))
41}
42
43pub fn init<R: Runtime>(
44 app: &AppHandle<R>,
45 api: PluginApi<R, Option<WidgetsPluginConfig>>,
46) -> crate::Result<Widget<R>> {
47 let cfg = api.config().clone().unwrap_or_default();
48 init_with_config(app, cfg)
49}
50
51pub(crate) fn init_with_config<R: Runtime>(
52 app: &AppHandle<R>,
53 cfg: WidgetsPluginConfig,
54) -> crate::Result<Widget<R>> {
55 let receipts = ReceiptStore::new();
56 let trace = TraceStore::new();
57 if let Ok(dir) = app.path().app_data_dir() {
58 receipts.load_from_path(&receipts_path(&dir));
59 trace.load_from_path(&trace_path(&dir));
60 }
61
62 #[cfg(target_os = "macos")]
63 let macos_driver = crate::transport::resolve_driver(&cfg)?;
64
65 let widget = Widget {
66 app: app.clone(),
67 cfg,
68 store: Mutex::new(HashMap::new()),
69 known_groups: Mutex::new(Vec::new()),
70 receipts,
71 trace,
72 #[cfg(target_os = "macos")]
73 poller_started: Mutex::new(false),
74 #[cfg(target_os = "macos")]
75 macos_driver,
76 };
77 if let Some(g) = widget.cfg.app_group.clone() {
79 widget.remember_group(&g);
80 }
81 #[cfg(target_os = "macos")]
82 widget.ensure_action_poller();
83
84 Ok(widget)
85}
86
87pub struct Widget<R: Runtime> {
88 app: AppHandle<R>,
89 #[allow(dead_code)]
90 cfg: WidgetsPluginConfig,
91 store: Mutex<HashMap<String, DataMap>>,
93 known_groups: Mutex<Vec<String>>,
94 receipts: ReceiptStore,
96 trace: TraceStore,
98 #[cfg(target_os = "macos")]
99 poller_started: Mutex<bool>,
100 #[cfg(target_os = "macos")]
102 macos_driver: Arc<dyn Transport>,
103}
104
105impl<R: Runtime> Widget<R> {
106 fn remember_group(&self, group: &str) {
107 let mut groups = self.known_groups.lock().unwrap();
108 if !groups.iter().any(|g| g == group) {
109 groups.push(group.to_string());
110 }
111 }
112
113 fn storage_path(&self, group: &str) -> crate::Result<PathBuf> {
114 #[cfg(target_os = "macos")]
115 {
116 if let Some(path) = crate::macos_transport::app_group_data_override() {
117 if let Some(parent) = path.parent() {
118 if !parent.exists() {
119 fs::create_dir_all(parent)?;
120 }
121 }
122 return Ok(path);
123 }
124 if let Some(dir) = macos_shared_container(group) {
125 if !dir.exists() {
126 fs::create_dir_all(&dir)?;
127 }
128 return Ok(dir.join("widget_data.json"));
129 }
130 Ok(crate::macos_transport::sandbox_widget_data_path(group))
131 }
132
133 #[cfg(target_os = "windows")]
134 {
135 for key in ["TAURI_WIDGETS_DATA", "WIDGET_DATA_DIR"] {
137 if let Ok(env_path) = std::env::var(key) {
138 let p = env_path.trim();
139 if !p.is_empty() {
140 let path = if p.to_ascii_lowercase().ends_with(".json") {
141 PathBuf::from(p)
142 } else {
143 PathBuf::from(p).join("widget_data.json")
144 };
145 if let Some(parent) = path.parent() {
146 if !parent.exists() {
147 fs::create_dir_all(parent)?;
148 }
149 }
150 return Ok(path);
151 }
152 }
153 }
154 let local = std::env::var("LOCALAPPDATA")
155 .map(PathBuf::from)
156 .or_else(|_| {
157 self.app
158 .path()
159 .app_data_dir()
160 .map_err(|e| Error::Io(e.to_string()))
161 })?;
162 let dir = local.join("tauri-plugin-widgets");
163 if !dir.exists() {
164 fs::create_dir_all(&dir)?;
165 }
166 let _ = group; Ok(dir.join("widget_data.json"))
168 }
169
170 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
171 {
172 let base = self
173 .app
174 .path()
175 .app_data_dir()
176 .map_err(|e| Error::Io(e.to_string()))?;
177 let dir = base.join("widgets");
178 if !dir.exists() {
179 fs::create_dir_all(&dir)?;
180 }
181 let safe: String = group
182 .chars()
183 .map(|c| {
184 if c.is_alphanumeric() || c == '.' {
185 c
186 } else {
187 '_'
188 }
189 })
190 .collect();
191 Ok(dir.join(format!("{safe}.json")))
192 }
193 }
194
195 fn load_map_locked<'a>(
196 store: &'a mut HashMap<String, DataMap>,
197 path: &PathBuf,
198 group: &str,
199 ) -> &'a mut DataMap {
200 store.entry(group.to_string()).or_insert_with(|| {
201 let mut maps = Vec::new();
202 if path.exists() {
203 if let Some(m) = fs::read_to_string(path)
204 .ok()
205 .and_then(|s| serde_json::from_str(&s).ok())
206 {
207 maps.push(m);
208 }
209 }
210 #[cfg(target_os = "macos")]
211 {
212 for t in crate::macos_transport::all_transports(group) {
213 if let Some(m) = t.read() {
214 maps.push(m);
215 }
216 }
217 }
218 store::pick_freshest(maps)
219 })
220 }
221
222 fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
229 #[cfg(target_os = "macos")]
230 {
231 let mut out = map.clone();
232 crate::macos_transport::merge_pending_into_map(&mut out, group);
233 crate::macos_transport::clear_sibling_transports(
234 group,
235 self.macos_driver.name(),
236 &mut out,
237 );
238 self.macos_driver.write(&out)?;
239 }
240 #[cfg(target_os = "windows")]
241 {
242 let path = self.storage_path(group)?;
243 persist_windows_shared_map(&path, map)?;
244 }
245 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
246 {
247 let path = self.storage_path(group)?;
248 let json = serde_json::to_string_pretty(map)?;
249 atomic_write(&path, json.as_bytes())?;
250 }
251
252 let _ = self.app.emit("widget-update", group);
253 Ok(())
254 }
255
256 pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
257 self.remember_group(group);
258 let path = self.storage_path(group)?;
259 let mut store = self.store.lock().unwrap();
260 let map = Self::load_map_locked(&mut store, &path, group);
261 if map.get(key).map(String::as_str) == Some(value) {
262 return Ok(true);
263 }
264 map.insert(key.into(), value.into());
265 #[cfg(target_os = "macos")]
266 {
267 let floor = crate::macos_transport::max_nonce_across(group);
268 store::touch_meta_above(map, floor);
269 }
270 #[cfg(not(target_os = "macos"))]
271 {
272 store::touch_meta(map);
273 }
274 let snapshot = map.clone();
275 drop(store);
276 self.persist_map(group, &snapshot)?;
277 Ok(true)
278 }
279
280 pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
281 #[cfg(target_os = "macos")]
282 {
283 let freshest = self.macos_driver_map(group)?;
284 Ok(freshest.get(key).cloned())
285 }
286 #[cfg(not(target_os = "macos"))]
287 {
288 let path = self.storage_path(group)?;
289 let mut store = self.store.lock().unwrap();
290 let map = Self::load_map_locked(&mut store, &path, group);
291 Ok(map.get(key).cloned())
292 }
293 }
294
295 pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
296 let app = self.app.clone();
297 #[cfg(all(target_os = "linux", feature = "linux"))]
300 {
301 if gtk::glib::MainContext::default().is_owner() {
302 return Self::create_widget_window_on_main(&app, config);
303 }
304 }
305
306 let (tx, rx) = std::sync::mpsc::sync_channel(1);
307 self.app
308 .run_on_main_thread(move || {
309 let _ = tx.send(Self::create_widget_window_on_main(&app, config));
310 })
311 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
312
313 match rx.recv_timeout(std::time::Duration::from_secs(8)) {
314 Ok(result) => result,
315 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(true),
317 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
318 Err(Error::new("create_widget_window: main thread dropped"))
319 }
320 }
321 }
322
323 fn create_widget_window_on_main(
324 app: &AppHandle<R>,
325 config: WidgetWindowConfig,
326 ) -> crate::Result<bool> {
327 let label_log = config.label.clone();
328 let url = match config.url.as_deref() {
329 Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
330 _ => {
331 let group = config.group.as_deref().unwrap_or("default");
332 let size = config.size.as_deref().unwrap_or("small");
333 let widget_id = config.widget_id.as_deref().unwrap_or("default");
334 builtin_widget_url(group, size, widget_id)
335 }
336 };
337 let skip_taskbar = config.skip_taskbar;
338 if let Some(prev) = app.get_webview_window(&config.label) {
340 let _ = prev.close();
341 }
342 let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
343 .title(&config.label)
345 .inner_size(config.width, config.height)
346 .decorations(false)
347 .skip_taskbar(skip_taskbar)
348 .always_on_top(config.always_on_top)
349 .resizable(false)
350 .visible(true);
351 #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
353 {
354 builder = builder.transparent(true);
355 }
356
357 if let (Some(x), Some(y)) = (config.x, config.y) {
358 builder = builder.position(x, y);
359 }
360
361 let win = builder
362 .build()
363 .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
364 #[cfg(all(target_os = "linux", feature = "linux"))]
365 crate::linux::pin_widget_window(&win, skip_taskbar);
366 #[cfg(not(all(target_os = "linux", feature = "linux")))]
367 let _ = win;
368 log::debug!("created widget window '{label_log}'");
369 Ok(true)
370 }
371
372 pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
373 let app = self.app.clone();
374 let label = label.to_string();
375
376 #[cfg(all(target_os = "linux", feature = "linux"))]
377 {
378 if gtk::glib::MainContext::default().is_owner() {
379 return Self::close_widget_window_on_main(&app, &label);
380 }
381 }
382
383 let (tx, rx) = std::sync::mpsc::sync_channel(1);
384 self.app
385 .run_on_main_thread(move || {
386 let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
387 })
388 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
389
390 match rx.recv_timeout(std::time::Duration::from_secs(3)) {
391 Ok(result) => result,
392 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(true),
393 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
394 Err(Error::new("close_widget_window: main thread dropped"))
395 }
396 }
397 }
398
399 fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
400 if let Some(win) = app.get_webview_window(label) {
401 win.close().map_err(|e| Error::new(e.to_string()))?;
402 Ok(true)
403 } else {
404 Ok(false)
405 }
406 }
407
408 pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
416 Ok(true)
418 }
419
420 pub fn reload_all_timelines(&self) -> crate::Result<bool> {
421 #[cfg(target_os = "macos")]
422 {
423 let _ = unsafe { macos_widget_reload_all() };
424 }
425 let _ = self.app.emit("widget-reload", "all");
426 Ok(true)
427 }
428
429 pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
430 #[cfg(target_os = "macos")]
431 {
432 let c = CString::new(of_kind).unwrap_or_default();
433 let _ = unsafe { macos_widget_reload_kind(c.as_ptr()) };
434 }
435 let _ = self.app.emit("widget-reload", of_kind);
436 Ok(true)
437 }
438
439 pub fn request_widget(&self) -> crate::Result<bool> {
447 Err(Error::Unsupported(
448 "Use create_widget_window on desktop".into(),
449 ))
450 }
451
452 pub fn set_widget_config(
453 &self,
454 config: &WidgetConfig,
455 group: &str,
456 widget_id: &str,
457 skip_reload: bool,
458 ) -> crate::Result<ApplyOutcome> {
459 if widget_id.is_empty() {
460 return Err(Error::new("widget_id must not be empty"));
461 }
462 self.remember_group(group);
463
464 let mut config = crate::normalize::normalize(
465 config,
466 crate::capabilities::WidgetPlatform::current(),
467 )
468 .config;
469 crate::image_prefetch::prefetch_remote_images(&mut config);
470
471 let json = serde_json::to_string(&config)
472 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
473 let compact: serde_json::Value = serde_json::from_str(&json)
474 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
475 let hash = config_content_hash(&json);
476 let key = config_key(widget_id);
477
478 let existing = self.get_items(&key, group)?;
479 let changed = existing.as_deref() != Some(json.as_str());
480
481 let _ = self.app.emit(
484 "widget-config-push",
485 serde_json::json!({
486 "group": group,
487 "widgetId": widget_id,
488 "config": compact,
489 }),
490 );
491
492 if !changed {
493 let outcome = ApplyOutcome::unchanged(hash);
494 self.trace.push(TraceEvent::ConfigSet {
495 widget_id: widget_id.into(),
496 nonce: 0,
497 bytes: json.len(),
498 changed: false,
499 skip: Some(SkipReason::Unchanged { hash }),
500 });
501 self.trace.push(TraceEvent::Reload {
502 performed: false,
503 reason: outcome.reload.clone(),
504 });
505 self.maybe_flush_trace();
506 return Ok(outcome);
507 }
508
509 crate::capabilities::log_capabilities(&config);
510 let t0 = std::time::Instant::now();
511 self.set_items(&key, &json, group)?;
512 let write_ms = t0.elapsed().as_millis() as u32;
513 let transports = self.written_transport_names(group);
514 let nonce = self
515 .get_items("__meta_nonce__", group)
516 .ok()
517 .flatten()
518 .and_then(|s| s.parse().ok())
519 .unwrap_or(0);
520
521 self.trace.push(TraceEvent::ConfigSet {
522 widget_id: widget_id.into(),
523 nonce,
524 bytes: json.len(),
525 changed: true,
526 skip: None,
527 });
528 for name in &transports {
529 self.trace.push(TraceEvent::Write {
530 transport: name.clone(),
531 ok: true,
532 duration_ms: write_ms,
533 error: None,
534 });
535 }
536
537 #[cfg(target_os = "windows")]
538 {
539 if let Some(result) =
542 crate::adaptive_card::to_adaptive_card_for_size(&config, "medium")
543 {
544 let template = serde_json::to_string(&result.card)
545 .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
546 self.set_items(
547 &crate::adaptive_card::ac_template_key(widget_id),
548 &template,
549 group,
550 )?;
551 self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
552 }
553 }
554
555 let reload = if skip_reload {
556 ReloadOutcome::Skipped {
557 why: "skip_reload".into(),
558 }
559 } else {
560 match self.reload_all_timelines() {
561 Ok(_) => ReloadOutcome::Ok,
562 Err(e) => ReloadOutcome::Failed {
563 error: e.to_string(),
564 },
565 }
566 };
567 self.trace.push(TraceEvent::Reload {
568 performed: matches!(reload, ReloadOutcome::Ok),
569 reason: reload.clone(),
570 });
571
572 #[cfg(target_os = "macos")]
573 self.ensure_action_poller();
574
575 self.maybe_flush_trace();
576
577 Ok(ApplyOutcome {
578 written: true,
579 reload,
580 transports,
581 skip: None,
582 })
583 }
584
585 fn written_transport_names(&self, group: &str) -> Vec<String> {
587 #[cfg(target_os = "macos")]
588 {
589 let _ = group;
590 vec![self.macos_driver.name().to_string()]
591 }
592 #[cfg(not(target_os = "macos"))]
593 {
594 let _ = group;
595 vec!["file".into()]
596 }
597 }
598
599 pub fn get_widget_config(
600 &self,
601 group: &str,
602 widget_id: &str,
603 ) -> crate::Result<Option<WidgetConfig>> {
604 if widget_id.is_empty() {
605 return Err(Error::new("widget_id must not be empty"));
606 }
607 let raw = self.get_items(&config_key(widget_id), group)?;
608 match raw {
609 Some(json) => {
610 let config: WidgetConfig = serde_json::from_str(&json)
611 .map_err(|e| Error::new(format!("parse config: {e}")))?;
612 Ok(Some(config))
613 }
614 None => Ok(None),
615 }
616 }
617
618 #[cfg(target_os = "macos")]
620 fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
621 let mut maps = Vec::new();
622 if let Some(disk) = self.macos_driver.read() {
623 maps.push(disk);
624 }
625 let path = self.storage_path(group)?;
626 let mut store = self.store.lock().unwrap();
627 let map = Self::load_map_locked(&mut store, &path, group);
628 maps.push(map.clone());
629 let freshest = store::pick_freshest(maps);
630 if store::map_nonce(&freshest) > store::map_nonce(map) {
631 *map = freshest.clone();
632 }
633 Ok(freshest)
634 }
635
636 pub fn poll_pending_actions(
638 &self,
639 group: &str,
640 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
641 self.remember_group(group);
642
643 #[cfg(target_os = "macos")]
644 let disk_maps: Vec<DataMap> = {
645 let mut maps = Vec::new();
646 for t in crate::macos_transport::all_transports(group) {
647 if let Some(m) = t.read() {
648 maps.push(m);
649 }
650 }
651 maps
652 };
653 #[cfg(not(target_os = "macos"))]
654 let disk_maps: Vec<DataMap> = {
655 let path = self.storage_path(group)?;
656 if path.exists() {
657 fs::read_to_string(&path)
658 .ok()
659 .and_then(|s| serde_json::from_str(&s).ok())
660 .into_iter()
661 .collect()
662 } else {
663 Vec::new()
664 }
665 };
666
667 let freshest = store::pick_freshest(disk_maps);
668
669 let mut store = self.store.lock().unwrap();
670 let path = self.storage_path(group)?;
671 let map = Self::load_map_locked(&mut store, &path, group);
672
673 if store::map_nonce(&freshest) > store::map_nonce(map) {
674 *map = freshest;
675 }
676
677 #[cfg(target_os = "macos")]
678 let actions = {
679 let harvested = crate::macos_transport::harvest_pending_actions(group);
680 if !harvested.is_empty() {
681 harvested
682 } else {
683 parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()))
684 }
685 };
686 #[cfg(not(target_os = "macos"))]
687 let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
688
689 if actions.is_empty() {
690 return Ok(Vec::new());
691 }
692
693 map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
694 #[cfg(target_os = "macos")]
695 {
696 let floor = crate::macos_transport::max_nonce_across(group);
697 store::touch_meta_above(map, floor);
698 }
699 #[cfg(not(target_os = "macos"))]
700 {
701 store::touch_meta(map);
702 }
703 let snapshot = map.clone();
704 drop(store);
705
706 self.persist_map(group, &snapshot)?;
707 #[cfg(target_os = "macos")]
708 {
709 crate::macos_transport::clear_pending_actions_everywhere(group, &actions);
710 }
711
712 Ok(actions)
713 }
714
715 pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
716 self.remember_group(&receipt.group);
717 let trigger = receipt
718 .trigger
719 .clone()
720 .unwrap_or_else(|| "timeline".into());
721 let lag_ms = {
722 let since = self.trace.list_since(None);
723 since
724 .iter()
725 .rev()
726 .find_map(|e| match &e.event {
727 TraceEvent::ConfigSet { nonce, .. } if *nonce == receipt.nonce && *nonce > 0 => {
728 Some(receipt.ts.saturating_sub(e.ts))
729 }
730 _ => None,
731 })
732 .unwrap_or(0)
733 };
734 self.trace.push(TraceEvent::Render {
735 instance: receipt.instance.clone(),
736 nonce: receipt.nonce,
737 source: receipt.source.clone(),
738 trigger,
739 lag_ms,
740 skipped: receipt.skipped.clone(),
741 });
742 self.receipts.upsert(receipt);
743 if let Ok(dir) = self.app.path().app_data_dir() {
744 let _ = self.receipts.save_to_path(&receipts_path(&dir));
745 }
746 self.maybe_flush_trace();
747 Ok(true)
748 }
749
750 pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
751 Ok(self.receipts.list(group))
752 }
753
754 pub fn get_widget_trace(
755 &self,
756 group: &str,
757 since_ms: Option<u64>,
758 ) -> crate::Result<WidgetTrace> {
759 self.maybe_flush_trace();
760 Ok(WidgetTrace {
761 enabled: crate::trace::trace_enabled(),
762 events: self.trace.list_since(since_ms),
763 receipts: self.receipts.history(group),
764 })
765 }
766
767 pub fn flush_widget_trace(&self) -> crate::Result<bool> {
768 if let Ok(dir) = self.app.path().app_data_dir() {
769 self.trace.flush_to_path(&trace_path(&dir))?;
770 }
771 Ok(true)
772 }
773
774 fn maybe_flush_trace(&self) {
775 if !self.trace.needs_timed_flush() {
776 return;
777 }
778 if let Ok(dir) = self.app.path().app_data_dir() {
779 let _ = self.trace.flush_to_path(&trace_path(&dir));
780 }
781 }
782
783 #[cfg(target_os = "macos")]
784 fn ensure_action_poller(&self) {
785 let mut started = self.poller_started.lock().unwrap();
786 if *started {
787 return;
788 }
789 *started = true;
790
791 let groups_handle = self.app.clone();
792 std::thread::spawn(move || {
793 loop {
794 std::thread::sleep(std::time::Duration::from_millis(500));
795 let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
796 continue;
797 };
798 widget.inner().maybe_flush_trace();
799 let groups = widget.inner().known_groups.lock().unwrap().clone();
800 for group in groups {
801 match widget.inner().poll_pending_actions(&group) {
802 Ok(actions) if !actions.is_empty() => {
803 widget.inner().trace.push(TraceEvent::Poll {
804 count: actions.len(),
805 });
806 for action in actions {
807 let _ = groups_handle.emit("widget-action", action);
808 }
809 }
810 _ => {}
811 }
812 }
813 }
814 });
815 }
816}
817
818#[cfg(target_os = "macos")]
821extern "C" {
822 fn macos_widget_reload_all() -> bool;
823 fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
824 fn macos_widget_container_path(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
825 fn macos_widget_free_string(ptr: *mut std::ffi::c_char);
826}
827
828#[cfg(target_os = "macos")]
829fn macos_shared_container(group: &str) -> Option<PathBuf> {
830 use std::ffi::CStr;
831 let c_group = CString::new(group).ok()?;
832 let ptr = unsafe { macos_widget_container_path(c_group.as_ptr()) };
833 if ptr.is_null() {
834 return None;
835 }
836 let path = unsafe { CStr::from_ptr(ptr) }
837 .to_string_lossy()
838 .into_owned();
839 unsafe { macos_widget_free_string(ptr) };
840 Some(PathBuf::from(path))
841}
842
843#[cfg(not(target_os = "macos"))]
844fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
845 let nanos = std::time::SystemTime::now()
846 .duration_since(std::time::UNIX_EPOCH)
847 .map(|d| d.as_nanos())
848 .unwrap_or(0);
849 let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
850 fs::write(&tmp, data)?;
851 match fs::rename(&tmp, path) {
852 Ok(()) => Ok(()),
853 Err(e) => {
854 let _ = fs::remove_file(&tmp);
855 Err(e)
856 }
857 }
858}
859
860#[cfg(target_os = "windows")]
864fn persist_windows_shared_map(path: &PathBuf, map: &DataMap) -> crate::Result<()> {
865 use std::fs::OpenOptions;
866 use std::os::windows::fs::OpenOptionsExt;
867 use std::thread;
868 use std::time::Duration;
869
870 if let Some(parent) = path.parent() {
871 fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
872 }
873
874 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
876 let _lock = {
877 let mut last_err = None;
878 let mut held = None;
879 for _ in 0..100 {
880 let mut opts = OpenOptions::new();
881 opts.read(true).write(true).create(true).share_mode(0); match opts.open(&lock_path) {
883 Ok(f) => {
884 held = Some(f);
885 break;
886 }
887 Err(e) => {
888 if e.raw_os_error() == Some(32) {
890 last_err = Some(e);
891 thread::sleep(Duration::from_millis(20));
892 continue;
893 }
894 return Err(Error::Io(e.to_string()));
895 }
896 }
897 }
898 held.ok_or_else(|| {
899 Error::Io(
900 last_err
901 .map(|e| e.to_string())
902 .unwrap_or_else(|| "widget_data.json.lock busy".into()),
903 )
904 })?
905 };
906
907 let mut merged: DataMap = if path.exists() {
908 fs::read_to_string(path)
909 .ok()
910 .and_then(|s| serde_json::from_str(&s).ok())
911 .unwrap_or_default()
912 } else {
913 DataMap::new()
914 };
915
916 for (k, v) in map {
917 if k == PENDING_ACTIONS_KEY {
918 let host_empty = v.trim().is_empty() || v.trim() == "[]";
919 let disk_empty = merged
920 .get(k)
921 .map(|s| s.trim().is_empty() || s.trim() == "[]")
922 .unwrap_or(true);
923 if host_empty && !disk_empty {
924 continue;
926 }
927 }
928 merged.insert(k.clone(), v.clone());
929 }
930
931 if let Some(n) = map.get(store::META_NONCE_KEY) {
933 merged.insert(store::META_NONCE_KEY.into(), n.clone());
934 }
935 if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
936 merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
937 }
938
939 let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
940 atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
941 Ok(())
942}