Skip to main content

tauri_plugin_widgets/
desktop.rs

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
26/// Protocol name registered by the plugin for the built-in widget renderer.
27pub(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    // Seed poller with configured App Group so widget taps work before first set_widget_config.
78    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    /// In-memory data store keyed by group.
92    store: Mutex<HashMap<String, DataMap>>,
93    known_groups: Mutex<Vec<String>>,
94    /// Cross-platform render receipts (diagnostics only).
95    receipts: ReceiptStore,
96    /// Host delivery journal (debug / `WIDGET_DEBUG=1`).
97    trace: TraceStore,
98    #[cfg(target_os = "macos")]
99    poller_started: Mutex<bool>,
100    /// Single Apple host transport (config-chosen).
101    #[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            // Align with WidgetProvider Store.DefaultPath so Widgets Board sees host writes.
136            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; // single shared widget_data.json — group lives in map keys
167            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    /// Persist map: one Apple driver on macOS / single file elsewhere.
223    fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
224        #[cfg(target_os = "macos")]
225        {
226            self.macos_driver.write(map)?;
227            // Widget still picks freshest across all transports — keep siblings in sync
228            // so a stale UserDefaults/App Group snapshot cannot outrank this write.
229            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        // Already on the GTK/UI thread (e.g. `setup`) — build inline to avoid deadlock
289        // waiting for a scheduled task that cannot run until we return.
290        #[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            // Setup on non-Linux (or before the loop pumps): task is queued.
307            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        // Close any prior window with this label so rebuilds (watch/inbox) succeed.
330        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            // Label doubles as WM_NAME so harnesses can find the window (xdotool).
335            .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        // Transparent windows on macOS require the host app's `macos-private-api`.
343        #[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    /// Register native widget provider ids.
400    ///
401    /// | Platform | Behaviour |
402    /// |---|---|
403    /// | Android | stores fully-qualified provider class names |
404    /// | iOS / macOS | stores WidgetKit kind strings (advisory) |
405    /// | Desktop | **no-op**, accepted for API symmetry |
406    pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
407        // Desktop has no native provider registry; accept for API symmetry.
408        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    /// Request that the OS show the "add widget" / pin UI.
431    ///
432    /// | Platform | Behaviour |
433    /// |---|---|
434    /// | Android | opens the pin-widget flow |
435    /// | iOS / macOS | no native pin API — returns `Ok` from the mobile bridge |
436    /// | Desktop | **error** — use [`Self::create_widget_window`] instead |
437    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        // Desktop webview always gets a push so an open window stays in sync
466        // even when store bytes were already identical.
467        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            // Widgets Board provider reads Adaptive Card blobs from the same store.
524            // Desktop webview (widget.html) remains the fallback outside Widget Board.
525            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    /// Names of transports that hold the current map after a write.
570    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    /// Read configured driver + merge with in-memory if newer.
608    #[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    /// Drain pending actions for a group (CAS clear under store lock).
626    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// ─── macOS helpers ────────────────────────────────────────────────────────────
793
794#[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/// Windows Widgets Board provider and Rust host share one `widget_data.json`.
835/// Match `WidgetStore.PersistUnlocked`: exclusive `.lock` + merge under it so
836/// provider-enqueued `pending_actions` are not wiped by a concurrent host write.
837#[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    // Same path convention as C#: `{widget_data.json}.lock`
849    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); // FILE_SHARE_NONE
856            match opts.open(&lock_path) {
857                Ok(f) => {
858                    held = Some(f);
859                    break;
860                }
861                Err(e) => {
862                    // ERROR_SHARING_VIOLATION (32) while the provider holds the lock.
863                    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                // Provider enqueued actions after our in-memory snapshot was taken.
899                continue;
900            }
901        }
902        merged.insert(k.clone(), v.clone());
903    }
904
905    // Keep host meta (already bumped) authoritative for this write.
906    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}