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, 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
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 on macOS: merge pending from all channels, clear leftover
223    /// maps on **sibling** transports only, then write the configured driver.
224    ///
225    /// The primary is never wiped before a successful write (failed write must
226    /// not erase the last good map). Sibling clear re-merges pending on each
227    /// wipe attempt so a mid-clear tap is not dropped.
228    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        // Already on the GTK/UI thread (e.g. `setup`) — build inline to avoid deadlock
298        // waiting for a scheduled task that cannot run until we return.
299        #[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            // Setup on non-Linux (or before the loop pumps): task is queued.
316            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        // Close any prior window with this label so rebuilds (watch/inbox) succeed.
339        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            // Label doubles as WM_NAME so harnesses can find the window (xdotool).
344            .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        // Transparent windows on macOS require the host app's `macos-private-api`.
352        #[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    /// Register native widget provider ids.
409    ///
410    /// | Platform | Behaviour |
411    /// |---|---|
412    /// | Android | stores fully-qualified provider class names |
413    /// | iOS / macOS | stores WidgetKit kind strings (advisory) |
414    /// | Desktop | **no-op**, accepted for API symmetry |
415    pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
416        // Desktop has no native provider registry; accept for API symmetry.
417        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    /// Request that the OS show the "add widget" / pin UI.
440    ///
441    /// | Platform | Behaviour |
442    /// |---|---|
443    /// | Android | opens the pin-widget flow |
444    /// | iOS / macOS | no native pin API — returns `Ok` from the mobile bridge |
445    /// | Desktop | **error** — use [`Self::create_widget_window`] instead |
446    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        // Desktop webview always gets a push so an open window stays in sync
482        // even when store bytes were already identical.
483        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            // Widgets Board provider reads Adaptive Card blobs from the same store.
540            // Desktop webview (widget.html) remains the fallback outside Widget Board.
541            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    /// Names of transports that hold the current map after a write.
586    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    /// Read configured driver + merge with in-memory if newer.
619    #[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    /// Drain pending actions for a group (CAS clear under store lock).
637    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// ─── macOS helpers ────────────────────────────────────────────────────────────
819
820#[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/// Windows Widgets Board provider and Rust host share one `widget_data.json`.
861/// Match `WidgetStore.PersistUnlocked`: exclusive `.lock` + merge under it so
862/// provider-enqueued `pending_actions` are not wiped by a concurrent host write.
863#[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    // Same path convention as C#: `{widget_data.json}.lock`
875    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); // FILE_SHARE_NONE
882            match opts.open(&lock_path) {
883                Ok(f) => {
884                    held = Some(f);
885                    break;
886                }
887                Err(e) => {
888                    // ERROR_SHARING_VIOLATION (32) while the provider holds the lock.
889                    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                // Provider enqueued actions after our in-memory snapshot was taken.
925                continue;
926            }
927        }
928        merged.insert(k.clone(), v.clone());
929    }
930
931    // Keep host meta (already bumped) authoritative for this write.
932    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}