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