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};
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, TraceSkipReason, 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<()> {
224 #[cfg(target_os = "macos")]
225 {
226 self.macos_driver.write(map)?;
227 crate::macos_transport::mirror_to_siblings(self.macos_driver.as_ref(), group, map);
230 }
231 #[cfg(target_os = "windows")]
232 {
233 let path = self.storage_path(group)?;
234 persist_windows_shared_map(&path, map)?;
235 }
236 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
237 {
238 let path = self.storage_path(group)?;
239 let json = serde_json::to_string_pretty(map)?;
240 atomic_write(&path, json.as_bytes())?;
241 }
242
243 let _ = self.app.emit("widget-update", group);
244 Ok(())
245 }
246
247 pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
248 self.remember_group(group);
249 let path = self.storage_path(group)?;
250 let mut store = self.store.lock().unwrap();
251 let map = Self::load_map_locked(&mut store, &path, group);
252 if map.get(key).map(String::as_str) == Some(value) {
253 return Ok(true);
254 }
255 map.insert(key.into(), value.into());
256 #[cfg(target_os = "macos")]
257 {
258 let floor = crate::macos_transport::max_nonce_across(group);
259 store::touch_meta_above(map, floor);
260 }
261 #[cfg(not(target_os = "macos"))]
262 {
263 store::touch_meta(map);
264 }
265 let snapshot = map.clone();
266 drop(store);
267 self.persist_map(group, &snapshot)?;
268 Ok(true)
269 }
270
271 pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
272 #[cfg(target_os = "macos")]
273 {
274 let freshest = self.macos_driver_map(group)?;
275 Ok(freshest.get(key).cloned())
276 }
277 #[cfg(not(target_os = "macos"))]
278 {
279 let path = self.storage_path(group)?;
280 let mut store = self.store.lock().unwrap();
281 let map = Self::load_map_locked(&mut store, &path, group);
282 Ok(map.get(key).cloned())
283 }
284 }
285
286 pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
287 let app = self.app.clone();
288 #[cfg(all(target_os = "linux", feature = "linux"))]
291 {
292 if gtk::glib::MainContext::default().is_owner() {
293 return Self::create_widget_window_on_main(&app, config);
294 }
295 }
296
297 let (tx, rx) = std::sync::mpsc::sync_channel(1);
298 self.app
299 .run_on_main_thread(move || {
300 let _ = tx.send(Self::create_widget_window_on_main(&app, config));
301 })
302 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
303
304 match rx.recv_timeout(std::time::Duration::from_secs(8)) {
305 Ok(result) => result,
306 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(true),
308 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
309 Err(Error::new("create_widget_window: main thread dropped"))
310 }
311 }
312 }
313
314 fn create_widget_window_on_main(
315 app: &AppHandle<R>,
316 config: WidgetWindowConfig,
317 ) -> crate::Result<bool> {
318 let label_log = config.label.clone();
319 let url = match config.url.as_deref() {
320 Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
321 _ => {
322 let group = config.group.as_deref().unwrap_or("default");
323 let size = config.size.as_deref().unwrap_or("small");
324 let widget_id = config.widget_id.as_deref().unwrap_or("default");
325 builtin_widget_url(group, size, widget_id)
326 }
327 };
328 let skip_taskbar = config.skip_taskbar;
329 if let Some(prev) = app.get_webview_window(&config.label) {
331 let _ = prev.close();
332 }
333 let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
334 .title(&config.label)
336 .inner_size(config.width, config.height)
337 .decorations(false)
338 .skip_taskbar(skip_taskbar)
339 .always_on_top(config.always_on_top)
340 .resizable(false)
341 .visible(true);
342 #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
344 {
345 builder = builder.transparent(true);
346 }
347
348 if let (Some(x), Some(y)) = (config.x, config.y) {
349 builder = builder.position(x, y);
350 }
351
352 let win = builder
353 .build()
354 .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
355 #[cfg(all(target_os = "linux", feature = "linux"))]
356 crate::linux::pin_widget_window(&win, skip_taskbar);
357 #[cfg(not(all(target_os = "linux", feature = "linux")))]
358 let _ = win;
359 log::debug!("created widget window '{label_log}'");
360 Ok(true)
361 }
362
363 pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
364 let app = self.app.clone();
365 let label = label.to_string();
366
367 #[cfg(all(target_os = "linux", feature = "linux"))]
368 {
369 if gtk::glib::MainContext::default().is_owner() {
370 return Self::close_widget_window_on_main(&app, &label);
371 }
372 }
373
374 let (tx, rx) = std::sync::mpsc::sync_channel(1);
375 self.app
376 .run_on_main_thread(move || {
377 let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
378 })
379 .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
380
381 match rx.recv_timeout(std::time::Duration::from_secs(3)) {
382 Ok(result) => result,
383 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok(true),
384 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
385 Err(Error::new("close_widget_window: main thread dropped"))
386 }
387 }
388 }
389
390 fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
391 if let Some(win) = app.get_webview_window(label) {
392 win.close().map_err(|e| Error::new(e.to_string()))?;
393 Ok(true)
394 } else {
395 Ok(false)
396 }
397 }
398
399 pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
407 Ok(true)
409 }
410
411 pub fn reload_all_timelines(&self) -> crate::Result<bool> {
412 #[cfg(target_os = "macos")]
413 {
414 let _ = unsafe { macos_widget_reload_all() };
415 }
416 let _ = self.app.emit("widget-reload", "all");
417 Ok(true)
418 }
419
420 pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
421 #[cfg(target_os = "macos")]
422 {
423 let c = CString::new(of_kind).unwrap_or_default();
424 let _ = unsafe { macos_widget_reload_kind(c.as_ptr()) };
425 }
426 let _ = self.app.emit("widget-reload", of_kind);
427 Ok(true)
428 }
429
430 pub fn request_widget(&self) -> crate::Result<bool> {
438 Err(Error::Unsupported(
439 "Use create_widget_window on desktop".into(),
440 ))
441 }
442
443 pub fn set_widget_config(
444 &self,
445 config: &WidgetConfig,
446 group: &str,
447 widget_id: &str,
448 skip_reload: bool,
449 ) -> crate::Result<ApplyOutcome> {
450 if widget_id.is_empty() {
451 return Err(Error::new("widget_id must not be empty"));
452 }
453 self.remember_group(group);
454
455 let json = serde_json::to_string(config)
456 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
457 let compact: serde_json::Value = serde_json::from_str(&json)
458 .map_err(|e| Error::new(format!("serialize config: {e}")))?;
459 let hash = config_content_hash(&json);
460 let key = config_key(widget_id);
461
462 let existing = self.get_items(&key, group)?;
463 let changed = existing.as_deref() != Some(json.as_str());
464
465 let _ = self.app.emit(
468 "widget-config-push",
469 serde_json::json!({
470 "group": group,
471 "widgetId": widget_id,
472 "config": compact,
473 }),
474 );
475
476 if !changed {
477 let outcome = ApplyOutcome::unchanged(hash);
478 self.trace.push(TraceEvent::ConfigSet {
479 widget_id: widget_id.into(),
480 nonce: 0,
481 bytes: json.len(),
482 changed: false,
483 skip: Some(TraceSkipReason::Unchanged { hash }),
484 });
485 self.trace.push(TraceEvent::Reload {
486 performed: false,
487 reason: outcome.reload.clone(),
488 });
489 self.maybe_flush_trace();
490 return Ok(outcome);
491 }
492
493 crate::capabilities::log_capabilities(config);
494 let t0 = std::time::Instant::now();
495 self.set_items(&key, &json, group)?;
496 let write_ms = t0.elapsed().as_millis() as u32;
497 let transports = self.written_transport_names(group);
498 let nonce = self
499 .get_items("__meta_nonce__", group)
500 .ok()
501 .flatten()
502 .and_then(|s| s.parse().ok())
503 .unwrap_or(0);
504
505 self.trace.push(TraceEvent::ConfigSet {
506 widget_id: widget_id.into(),
507 nonce,
508 bytes: json.len(),
509 changed: true,
510 skip: None,
511 });
512 for name in &transports {
513 self.trace.push(TraceEvent::Write {
514 transport: name.clone(),
515 ok: true,
516 duration_ms: write_ms,
517 error: None,
518 });
519 }
520
521 #[cfg(target_os = "windows")]
522 {
523 if let Some(result) =
526 crate::adaptive_card::to_adaptive_card_for_size(config, "medium")
527 {
528 let template = serde_json::to_string(&result.card)
529 .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
530 self.set_items(
531 &crate::adaptive_card::ac_template_key(widget_id),
532 &template,
533 group,
534 )?;
535 self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
536 }
537 }
538
539 let reload = if skip_reload {
540 ReloadOutcome::Skipped {
541 why: "skip_reload".into(),
542 }
543 } else {
544 match self.reload_all_timelines() {
545 Ok(_) => ReloadOutcome::Ok,
546 Err(e) => ReloadOutcome::Failed {
547 error: e.to_string(),
548 },
549 }
550 };
551 self.trace.push(TraceEvent::Reload {
552 performed: matches!(reload, ReloadOutcome::Ok),
553 reason: reload.clone(),
554 });
555
556 #[cfg(target_os = "macos")]
557 self.ensure_action_poller();
558
559 self.maybe_flush_trace();
560
561 Ok(ApplyOutcome {
562 written: true,
563 reload,
564 transports,
565 skip: None,
566 })
567 }
568
569 fn written_transport_names(&self, group: &str) -> Vec<String> {
571 #[cfg(target_os = "macos")]
572 {
573 let mut names = vec![self.macos_driver.name().to_string()];
574 for t in crate::macos_transport::all_transports(group) {
575 if t.name() != self.macos_driver.name() && t.available() {
576 names.push(t.name().to_string());
577 }
578 }
579 names
580 }
581 #[cfg(not(target_os = "macos"))]
582 {
583 let _ = group;
584 vec!["file".into()]
585 }
586 }
587
588 pub fn get_widget_config(
589 &self,
590 group: &str,
591 widget_id: &str,
592 ) -> crate::Result<Option<WidgetConfig>> {
593 if widget_id.is_empty() {
594 return Err(Error::new("widget_id must not be empty"));
595 }
596 let raw = self.get_items(&config_key(widget_id), group)?;
597 match raw {
598 Some(json) => {
599 let config: WidgetConfig = serde_json::from_str(&json)
600 .map_err(|e| Error::new(format!("parse config: {e}")))?;
601 Ok(Some(config))
602 }
603 None => Ok(None),
604 }
605 }
606
607 #[cfg(target_os = "macos")]
609 fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
610 let mut maps = Vec::new();
611 if let Some(disk) = self.macos_driver.read() {
612 maps.push(disk);
613 }
614 let path = self.storage_path(group)?;
615 let mut store = self.store.lock().unwrap();
616 let map = Self::load_map_locked(&mut store, &path, group);
617 maps.push(map.clone());
618 let freshest = store::pick_freshest(maps);
619 if store::map_nonce(&freshest) > store::map_nonce(map) {
620 *map = freshest.clone();
621 }
622 Ok(freshest)
623 }
624
625 pub fn poll_pending_actions(
627 &self,
628 group: &str,
629 ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
630 self.remember_group(group);
631
632 #[cfg(target_os = "macos")]
633 let disk_maps: Vec<DataMap> = {
634 let mut maps = Vec::new();
635 for t in crate::macos_transport::all_transports(group) {
636 if let Some(m) = t.read() {
637 maps.push(m);
638 }
639 }
640 maps
641 };
642 #[cfg(not(target_os = "macos"))]
643 let disk_maps: Vec<DataMap> = {
644 let path = self.storage_path(group)?;
645 if path.exists() {
646 fs::read_to_string(&path)
647 .ok()
648 .and_then(|s| serde_json::from_str(&s).ok())
649 .into_iter()
650 .collect()
651 } else {
652 Vec::new()
653 }
654 };
655
656 let freshest = store::pick_freshest(disk_maps);
657
658 let mut store = self.store.lock().unwrap();
659 let path = self.storage_path(group)?;
660 let map = Self::load_map_locked(&mut store, &path, group);
661
662 if store::map_nonce(&freshest) > store::map_nonce(map) {
663 *map = freshest;
664 }
665
666 let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
667 if actions.is_empty() {
668 return Ok(Vec::new());
669 }
670
671 map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
672 #[cfg(target_os = "macos")]
673 {
674 let floor = crate::macos_transport::max_nonce_across(group);
675 store::touch_meta_above(map, floor);
676 }
677 #[cfg(not(target_os = "macos"))]
678 {
679 store::touch_meta(map);
680 }
681 let snapshot = map.clone();
682 drop(store);
683
684 self.persist_map(group, &snapshot)?;
685
686 Ok(actions)
687 }
688
689 pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
690 self.remember_group(&receipt.group);
691 let trigger = receipt
692 .trigger
693 .clone()
694 .unwrap_or_else(|| "timeline".into());
695 let lag_ms = {
696 let since = self.trace.list_since(None);
697 since
698 .iter()
699 .rev()
700 .find_map(|e| match &e.event {
701 TraceEvent::ConfigSet { nonce, .. } if *nonce == receipt.nonce && *nonce > 0 => {
702 Some(receipt.ts.saturating_sub(e.ts))
703 }
704 _ => None,
705 })
706 .unwrap_or(0)
707 };
708 self.trace.push(TraceEvent::Render {
709 instance: receipt.instance.clone(),
710 nonce: receipt.nonce,
711 source: receipt.source.clone(),
712 trigger,
713 lag_ms,
714 skipped: receipt.skipped.clone(),
715 });
716 self.receipts.upsert(receipt);
717 if let Ok(dir) = self.app.path().app_data_dir() {
718 let _ = self.receipts.save_to_path(&receipts_path(&dir));
719 }
720 self.maybe_flush_trace();
721 Ok(true)
722 }
723
724 pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
725 Ok(self.receipts.list(group))
726 }
727
728 pub fn get_widget_trace(
729 &self,
730 group: &str,
731 since_ms: Option<u64>,
732 ) -> crate::Result<WidgetTrace> {
733 self.maybe_flush_trace();
734 Ok(WidgetTrace {
735 enabled: crate::trace::trace_enabled(),
736 events: self.trace.list_since(since_ms),
737 receipts: self.receipts.history(group),
738 })
739 }
740
741 pub fn flush_widget_trace(&self) -> crate::Result<bool> {
742 if let Ok(dir) = self.app.path().app_data_dir() {
743 self.trace.flush_to_path(&trace_path(&dir))?;
744 }
745 Ok(true)
746 }
747
748 fn maybe_flush_trace(&self) {
749 if !self.trace.needs_timed_flush() {
750 return;
751 }
752 if let Ok(dir) = self.app.path().app_data_dir() {
753 let _ = self.trace.flush_to_path(&trace_path(&dir));
754 }
755 }
756
757 #[cfg(target_os = "macos")]
758 fn ensure_action_poller(&self) {
759 let mut started = self.poller_started.lock().unwrap();
760 if *started {
761 return;
762 }
763 *started = true;
764
765 let groups_handle = self.app.clone();
766 std::thread::spawn(move || {
767 loop {
768 std::thread::sleep(std::time::Duration::from_millis(500));
769 let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
770 continue;
771 };
772 widget.inner().maybe_flush_trace();
773 let groups = widget.inner().known_groups.lock().unwrap().clone();
774 for group in groups {
775 match widget.inner().poll_pending_actions(&group) {
776 Ok(actions) if !actions.is_empty() => {
777 widget.inner().trace.push(TraceEvent::Poll {
778 count: actions.len(),
779 });
780 for action in actions {
781 let _ = groups_handle.emit("widget-action", action);
782 }
783 }
784 _ => {}
785 }
786 }
787 }
788 });
789 }
790}
791
792#[cfg(target_os = "macos")]
795extern "C" {
796 fn macos_widget_reload_all() -> bool;
797 fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
798 fn macos_widget_container_path(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
799 fn macos_widget_free_string(ptr: *mut std::ffi::c_char);
800}
801
802#[cfg(target_os = "macos")]
803fn macos_shared_container(group: &str) -> Option<PathBuf> {
804 use std::ffi::CStr;
805 let c_group = CString::new(group).ok()?;
806 let ptr = unsafe { macos_widget_container_path(c_group.as_ptr()) };
807 if ptr.is_null() {
808 return None;
809 }
810 let path = unsafe { CStr::from_ptr(ptr) }
811 .to_string_lossy()
812 .into_owned();
813 unsafe { macos_widget_free_string(ptr) };
814 Some(PathBuf::from(path))
815}
816
817#[cfg(not(target_os = "macos"))]
818fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
819 let nanos = std::time::SystemTime::now()
820 .duration_since(std::time::UNIX_EPOCH)
821 .map(|d| d.as_nanos())
822 .unwrap_or(0);
823 let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
824 fs::write(&tmp, data)?;
825 match fs::rename(&tmp, path) {
826 Ok(()) => Ok(()),
827 Err(e) => {
828 let _ = fs::remove_file(&tmp);
829 Err(e)
830 }
831 }
832}
833
834#[cfg(target_os = "windows")]
838fn persist_windows_shared_map(path: &PathBuf, map: &DataMap) -> crate::Result<()> {
839 use std::fs::OpenOptions;
840 use std::os::windows::fs::OpenOptionsExt;
841 use std::thread;
842 use std::time::Duration;
843
844 if let Some(parent) = path.parent() {
845 fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
846 }
847
848 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
850 let _lock = {
851 let mut last_err = None;
852 let mut held = None;
853 for _ in 0..100 {
854 let mut opts = OpenOptions::new();
855 opts.read(true).write(true).create(true).share_mode(0); match opts.open(&lock_path) {
857 Ok(f) => {
858 held = Some(f);
859 break;
860 }
861 Err(e) => {
862 if e.raw_os_error() == Some(32) {
864 last_err = Some(e);
865 thread::sleep(Duration::from_millis(20));
866 continue;
867 }
868 return Err(Error::Io(e.to_string()));
869 }
870 }
871 }
872 held.ok_or_else(|| {
873 Error::Io(
874 last_err
875 .map(|e| e.to_string())
876 .unwrap_or_else(|| "widget_data.json.lock busy".into()),
877 )
878 })?
879 };
880
881 let mut merged: DataMap = if path.exists() {
882 fs::read_to_string(path)
883 .ok()
884 .and_then(|s| serde_json::from_str(&s).ok())
885 .unwrap_or_default()
886 } else {
887 DataMap::new()
888 };
889
890 for (k, v) in map {
891 if k == PENDING_ACTIONS_KEY {
892 let host_empty = v.trim().is_empty() || v.trim() == "[]";
893 let disk_empty = merged
894 .get(k)
895 .map(|s| s.trim().is_empty() || s.trim() == "[]")
896 .unwrap_or(true);
897 if host_empty && !disk_empty {
898 continue;
900 }
901 }
902 merged.insert(k.clone(), v.clone());
903 }
904
905 if let Some(n) = map.get(store::META_NONCE_KEY) {
907 merged.insert(store::META_NONCE_KEY.into(), n.clone());
908 }
909 if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
910 merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
911 }
912
913 let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
914 atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
915 Ok(())
916}