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
29/// Percent-encode a query component (RFC 3986 unreserved left as-is).
30fn 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    // Seed poller with configured App Group so widget taps work before first set_widget_config.
121    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    /// In-memory data store keyed by group.
135    store: Mutex<HashMap<String, DataMap>>,
136    known_groups: Mutex<Vec<String>>,
137    /// Cross-platform render receipts (diagnostics only).
138    receipts: ReceiptStore,
139    /// Host delivery journal (debug / `WIDGET_DEBUG=1`).
140    trace: TraceStore,
141    #[cfg(target_os = "macos")]
142    poller_started: Mutex<bool>,
143    /// Single Apple host transport (config-chosen).
144    #[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            // Align with WidgetProvider Store.DefaultPath so Widgets Board sees host writes.
179            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; // single shared widget_data.json — group lives in map keys
210            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    /// Persist map on macOS: merge pending from all channels, clear leftover
266    /// maps on **sibling** transports only, then write the configured driver.
267    ///
268    /// The primary is never wiped before a successful write (failed write must
269    /// not erase the last good map). Sibling clear re-merges pending on each
270    /// wipe attempt so a mid-clear tap is not dropped.
271    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        // Already on the GTK/UI thread (e.g. `setup`) — build inline to avoid deadlock
341        // waiting for a scheduled task that cannot run until we return.
342        #[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            // Setup on non-Linux (or before the loop pumps): task is queued.
359            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        // Close any prior window with this label so rebuilds (watch/inbox) succeed.
382        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            // Label doubles as WM_NAME so harnesses can find the window (xdotool).
387            .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        // Transparent windows on macOS require the host app's `macos-private-api`.
395        #[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    /// Register native widget provider ids.
452    ///
453    /// | Platform | Behaviour |
454    /// |---|---|
455    /// | Android | stores fully-qualified provider class names |
456    /// | iOS / macOS | stores WidgetKit kind strings (advisory) |
457    /// | Desktop | **no-op**, accepted for API symmetry |
458    pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
459        // Desktop has no native provider registry; accept for API symmetry.
460        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    /// Request that the OS show the "add widget" / pin UI.
483    ///
484    /// | Platform | Behaviour |
485    /// |---|---|
486    /// | Android | opens the pin-widget flow |
487    /// | iOS / macOS | no native pin API — returns `Ok` from the mobile bridge |
488    /// | Desktop | **error** — use [`Self::create_widget_window`] instead |
489    pub fn request_widget(&self) -> crate::Result<bool> {
490        Err(Error::Unsupported(
491            "Use create_widget_window on desktop".into(),
492        ))
493    }
494
495    /// Load a [`WidgetConfig`] JSON file from disk, then apply it like [`Self::set_widget_config`].
496    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        // Desktop webview always gets a push so an open window stays in sync
544        // even when store bytes were already identical.
545        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            // Widgets Board: single Adaptive Card from the medium IR branch.
602            // Package Size capability is medium-only (templates/windows-widget).
603            // Desktop webview (widget.html) remains the fallback outside Widget Board.
604            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    /// Names of transports that hold the current map after a write.
649    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    /// Read configured driver + merge with in-memory if newer.
682    #[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    /// Drain pending actions for a group (CAS clear under store lock).
700    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// ─── macOS helpers ────────────────────────────────────────────────────────────
882
883#[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/// Windows Widgets Board provider and Rust host share one `widget_data.json`.
924/// Match `WidgetStore.PersistUnlocked`: exclusive `.lock` + merge under it so
925/// provider-enqueued `pending_actions` are not wiped by a concurrent host write.
926#[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    // Same path convention as C#: `{widget_data.json}.lock`
938    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); // FILE_SHARE_NONE
945            match opts.open(&lock_path) {
946                Ok(f) => {
947                    held = Some(f);
948                    break;
949                }
950                Err(e) => {
951                    // ERROR_SHARING_VIOLATION (32) while the provider holds the lock.
952                    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                // Provider enqueued actions after our in-memory snapshot was taken.
988                continue;
989            }
990        }
991        merged.insert(k.clone(), v.clone());
992    }
993
994    // Keep host meta (already bumped) authoritative for this write.
995    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}