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::{self, config_key, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY};
17use crate::trace::{trace_path, TraceEvent, TraceStore, WidgetTrace};
18
19#[cfg(target_os = "macos")]
20use crate::transport::Transport;
21#[cfg(target_os = "macos")]
22use std::ffi::CString;
23
24/// Protocol name registered by the plugin for the built-in widget renderer.
25pub(crate) const BUILTIN_PROTOCOL: &str = "widgetview";
26
27/// Percent-encode a query component (RFC 3986 unreserved left as-is).
28fn encode_query_component(s: &str) -> String {
29    let mut out = String::with_capacity(s.len());
30    for &b in s.as_bytes() {
31        match b {
32            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
33                out.push(b as char);
34            }
35            _ => {
36                use std::fmt::Write as _;
37                let _ = write!(out, "%{b:02X}");
38            }
39        }
40    }
41    out
42}
43
44fn builtin_widget_url(group: &str, size: &str, widget_id: &str) -> WebviewUrl {
45    let group = encode_query_component(group);
46    let size = encode_query_component(size);
47    let widget_id = encode_query_component(widget_id);
48    #[cfg(target_os = "windows")]
49    let url_str = format!(
50        "https://{}.localhost/?group={}&size={}&widgetId={}",
51        BUILTIN_PROTOCOL, group, size, widget_id
52    );
53    #[cfg(not(target_os = "windows"))]
54    let url_str = format!(
55        "{}://localhost/?group={}&size={}&widgetId={}",
56        BUILTIN_PROTOCOL, group, size, widget_id
57    );
58    WebviewUrl::External(url_str.parse().expect("invalid built-in widget URL"))
59}
60
61/// The process main thread runs the event loop on every desktop platform.
62fn on_main_thread() -> bool {
63    std::thread::current().name() == Some("main")
64}
65
66pub fn init<R: Runtime>(
67    app: &AppHandle<R>,
68    api: PluginApi<R, Option<WidgetsPluginConfig>>,
69) -> crate::Result<Widget<R>> {
70    let cfg = api
71        .config()
72        .clone()
73        .unwrap_or_default()
74        .with_defaults(&app.config().identifier);
75    init_with_config(app, cfg)
76}
77
78pub(crate) fn init_with_config<R: Runtime>(
79    app: &AppHandle<R>,
80    cfg: WidgetsPluginConfig,
81) -> crate::Result<Widget<R>> {
82    let receipts = ReceiptStore::new();
83    let trace = TraceStore::new();
84    if let Ok(dir) = app.path().app_data_dir() {
85        receipts.load_from_path(&receipts_path(&dir));
86        trace.load_from_path(&trace_path(&dir));
87    }
88
89    // An unsigned / ad-hoc dev build has no App Group container: fall back to the widget
90    // extension's own container instead of aborting the whole app at startup.
91    #[cfg(target_os = "macos")]
92    let macos_driver = match crate::transport::resolve_driver(&cfg) {
93        Ok(driver) => driver,
94        Err(e) => {
95            log::warn!(
96                "widgets: {e}\nFalling back to transport=widgetContainer (works for ad-hoc signed \
97                 builds). Sign with a Team ID and App Groups for transport=appGroup."
98            );
99            crate::transport::build_driver(crate::config::TransportKind::WidgetContainer, &cfg)?
100        }
101    };
102
103    let widget = Widget {
104        app: app.clone(),
105        cfg,
106        store: Mutex::new(HashMap::new()),
107        known_groups: Mutex::new(Vec::new()),
108        receipts,
109        trace,
110        #[cfg(target_os = "macos")]
111        poller_started: Mutex::new(false),
112        #[cfg(target_os = "macos")]
113        macos_driver,
114    };
115    // Seed poller with configured App Group so widget taps work before first set_widget_config.
116    if let Some(g) = widget.cfg.app_group.clone() {
117        widget.remember_group(&g);
118    }
119    #[cfg(target_os = "macos")]
120    widget.ensure_action_poller();
121
122    Ok(widget)
123}
124
125pub struct Widget<R: Runtime> {
126    app: AppHandle<R>,
127    #[allow(dead_code)]
128    cfg: WidgetsPluginConfig,
129    /// In-memory data store keyed by group.
130    store: Mutex<HashMap<String, DataMap>>,
131    known_groups: Mutex<Vec<String>>,
132    /// Cross-platform render receipts (diagnostics only).
133    receipts: ReceiptStore,
134    /// Host delivery journal (debug / `WIDGET_DEBUG=1`).
135    trace: TraceStore,
136    #[cfg(target_os = "macos")]
137    poller_started: Mutex<bool>,
138    /// Single Apple host transport (config-chosen).
139    #[cfg(target_os = "macos")]
140    macos_driver: Arc<dyn Transport>,
141}
142
143impl<R: Runtime> Widget<R> {
144    fn remember_group(&self, group: &str) {
145        let mut groups = self.known_groups.lock().unwrap();
146        if !groups.iter().any(|g| g == group) {
147            groups.push(group.to_string());
148        }
149    }
150
151    fn storage_path(&self, group: &str) -> crate::Result<PathBuf> {
152        #[cfg(target_os = "macos")]
153        {
154            if let Some(path) = crate::macos_transport::app_group_data_override() {
155                if let Some(parent) = path.parent() {
156                    if !parent.exists() {
157                        fs::create_dir_all(parent)?;
158                    }
159                }
160                return Ok(path);
161            }
162            if let Some(dir) = macos_shared_container(group) {
163                if !dir.exists() {
164                    fs::create_dir_all(&dir)?;
165                }
166                return Ok(dir.join("widget_data.json"));
167            }
168            Ok(crate::macos_transport::sandbox_widget_data_path(group))
169        }
170
171        #[cfg(target_os = "windows")]
172        {
173            // Align with WidgetProvider Store.DefaultPath so Widgets Board sees host writes.
174            for key in ["TAURI_WIDGETS_DATA", "WIDGET_DATA_DIR"] {
175                if let Ok(env_path) = std::env::var(key) {
176                    let p = env_path.trim();
177                    if !p.is_empty() {
178                        let path = if p.to_ascii_lowercase().ends_with(".json") {
179                            PathBuf::from(p)
180                        } else {
181                            PathBuf::from(p).join("widget_data.json")
182                        };
183                        if let Some(parent) = path.parent() {
184                            if !parent.exists() {
185                                fs::create_dir_all(parent)?;
186                            }
187                        }
188                        return Ok(path);
189                    }
190                }
191            }
192            let local = std::env::var("LOCALAPPDATA")
193                .map(PathBuf::from)
194                .or_else(|_| {
195                    self.app
196                        .path()
197                        .app_data_dir()
198                        .map_err(|e| Error::Io(e.to_string()))
199                })?;
200            let dir = local.join("tauri-plugin-widgets");
201            if !dir.exists() {
202                fs::create_dir_all(&dir)?;
203            }
204            let _ = group; // single shared widget_data.json — group lives in map keys
205            Ok(dir.join("widget_data.json"))
206        }
207
208        #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
209        {
210            let base = self
211                .app
212                .path()
213                .app_data_dir()
214                .map_err(|e| Error::Io(e.to_string()))?;
215            let dir = base.join("widgets");
216            if !dir.exists() {
217                fs::create_dir_all(&dir)?;
218            }
219            // validate_group already limits the charset; keep '-' so `a-b` and `a_b` differ.
220            let safe: String = group
221                .chars()
222                .map(|c| {
223                    if c.is_alphanumeric() || c == '.' || c == '-' {
224                        c
225                    } else {
226                        '_'
227                    }
228                })
229                .collect();
230            Ok(dir.join(format!("{safe}.json")))
231        }
232    }
233
234    fn load_map_locked<'a>(
235        store: &'a mut HashMap<String, DataMap>,
236        path: &PathBuf,
237        group: &str,
238    ) -> &'a mut DataMap {
239        store.entry(group.to_string()).or_insert_with(|| {
240            let mut maps = Vec::new();
241            if path.exists() {
242                if let Some(m) = fs::read_to_string(path)
243                    .ok()
244                    .and_then(|s| serde_json::from_str(&s).ok())
245                {
246                    maps.push(m);
247                }
248            }
249            #[cfg(target_os = "macos")]
250            {
251                for t in crate::macos_transport::all_transports(group) {
252                    if let Some(m) = t.read() {
253                        maps.push(m);
254                    }
255                }
256            }
257            store::pick_freshest(maps)
258        })
259    }
260
261    /// Persist map on macOS: merge pending from all channels, clear leftover
262    /// maps on **sibling** transports only, then write the configured driver.
263    ///
264    /// The primary is never wiped before a successful write (failed write must
265    /// not erase the last good map). Sibling clear re-merges pending on each
266    /// wipe attempt so a mid-clear tap is not dropped.
267    fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
268        #[cfg(target_os = "macos")]
269        crate::macos_transport::with_store_lock(group, || {
270            let mut out = map.clone();
271            crate::macos_transport::merge_pending_into_map(&mut out, group);
272            crate::macos_transport::clear_sibling_transports(
273                group,
274                self.macos_driver.name(),
275                &mut out,
276            );
277            self.macos_driver.write(&out)
278        })?;
279        #[cfg(target_os = "windows")]
280        {
281            let path = self.storage_path(group)?;
282            persist_windows_shared_map(&path, map, &[])?;
283        }
284        #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
285        {
286            let path = self.storage_path(group)?;
287            let json = serde_json::to_string_pretty(map)?;
288            atomic_write(&path, json.as_bytes())?;
289        }
290
291        let _ = self.app.emit("widget-update", group);
292        Ok(())
293    }
294
295    pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
296        self.remember_group(group);
297        let path = self.storage_path(group)?;
298        let mut store = self.store.lock().unwrap();
299        let map = Self::load_map_locked(&mut store, &path, group);
300        if map.get(key).map(String::as_str) == Some(value) {
301            return Ok(true);
302        }
303        map.insert(key.into(), value.into());
304        #[cfg(target_os = "macos")]
305        {
306            let floor = crate::macos_transport::max_nonce_across(group);
307            store::touch_meta_above(map, floor);
308        }
309        #[cfg(not(target_os = "macos"))]
310        {
311            store::touch_meta(map);
312        }
313        let snapshot = map.clone();
314        drop(store);
315        self.persist_map(group, &snapshot)?;
316        Ok(true)
317    }
318
319    pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
320        #[cfg(target_os = "macos")]
321        {
322            let freshest = self.macos_driver_map(group)?;
323            Ok(freshest.get(key).cloned())
324        }
325        #[cfg(not(target_os = "macos"))]
326        {
327            let path = self.storage_path(group)?;
328            let mut store = self.store.lock().unwrap();
329            let map = Self::load_map_locked(&mut store, &path, group);
330            Ok(map.get(key).cloned())
331        }
332    }
333
334    pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
335        let app = self.app.clone();
336        // Already on the GTK/UI thread (e.g. `setup`) — build inline to avoid deadlock
337        // waiting for a scheduled task that cannot run until we return.
338        #[cfg(all(target_os = "linux", feature = "linux"))]
339        {
340            if gtk::glib::MainContext::default().is_owner() {
341                return Self::create_widget_window_on_main(&app, config);
342            }
343        }
344
345        // Called from the main thread (e.g. `setup`): the queued task cannot run until we
346        // return, so waiting would only stall. Report the build error in the log.
347        if on_main_thread() {
348            self.app
349                .run_on_main_thread(move || {
350                    if let Err(e) = Self::create_widget_window_on_main(&app, config) {
351                        log::error!("{e}");
352                    }
353                })
354                .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
355            return Ok(true);
356        }
357
358        let (tx, rx) = std::sync::mpsc::sync_channel(1);
359        self.app
360            .run_on_main_thread(move || {
361                let _ = tx.send(Self::create_widget_window_on_main(&app, config));
362            })
363            .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
364        rx.recv()
365            .map_err(|_| Error::new("create_widget_window: main thread dropped"))?
366    }
367
368    fn create_widget_window_on_main(
369        app: &AppHandle<R>,
370        config: WidgetWindowConfig,
371    ) -> crate::Result<bool> {
372        let label_log = config.label.clone();
373        let url = match config.url.as_deref() {
374            Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
375            _ => {
376                let group = config
377                    .group
378                    .clone()
379                    .or_else(|| crate::group::default_group().map(String::from))
380                    .unwrap_or_default();
381                let group = group.as_str();
382                let size = config.size.as_deref().unwrap_or("small");
383                let widget_id = config.widget_id.as_deref().unwrap_or("default");
384                builtin_widget_url(group, size, widget_id)
385            }
386        };
387        let skip_taskbar = config.skip_taskbar;
388        // Same label again (watch / rebuild): reuse the window. `close()` is asynchronous,
389        // so building a new one right after it fails with "label already exists".
390        if let Some(prev) = app.get_webview_window(&config.label) {
391            let url = match url {
392                WebviewUrl::External(u) | WebviewUrl::CustomProtocol(u) => u,
393                WebviewUrl::App(path) => prev
394                    .url()
395                    .map_err(|e| Error::new(e.to_string()))?
396                    .join(&path.to_string_lossy())
397                    .map_err(|e| Error::new(e.to_string()))?,
398                _ => return Err(Error::new("create_widget_window: unsupported url")),
399            };
400            prev.navigate(url).map_err(|e| Error::new(e.to_string()))?;
401            prev.set_size(tauri::LogicalSize::new(config.width, config.height))
402                .map_err(|e| Error::new(e.to_string()))?;
403            if let (Some(x), Some(y)) = (config.x, config.y) {
404                prev.set_position(tauri::LogicalPosition::new(x, y))
405                    .map_err(|e| Error::new(e.to_string()))?;
406            }
407            prev.set_always_on_top(config.always_on_top)
408                .map_err(|e| Error::new(e.to_string()))?;
409            prev.show().map_err(|e| Error::new(e.to_string()))?;
410            log::debug!("reused widget window '{label_log}'");
411            return Ok(true);
412        }
413        let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
414            // Label doubles as WM_NAME so harnesses can find the window (xdotool).
415            .title(&config.label)
416            .inner_size(config.width, config.height)
417            .decorations(false)
418            .skip_taskbar(skip_taskbar)
419            .always_on_top(config.always_on_top)
420            .resizable(false)
421            .visible(true);
422        // Transparent windows on macOS require the host app's `macos-private-api`.
423        #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
424        {
425            builder = builder.transparent(true);
426        }
427
428        if let (Some(x), Some(y)) = (config.x, config.y) {
429            builder = builder.position(x, y);
430        }
431
432        let win = builder
433            .build()
434            .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
435        #[cfg(all(target_os = "linux", feature = "linux"))]
436        crate::linux::pin_widget_window(&win, skip_taskbar);
437        #[cfg(not(all(target_os = "linux", feature = "linux")))]
438        let _ = win;
439        log::debug!("created widget window '{label_log}'");
440        Ok(true)
441    }
442
443    pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
444        let app = self.app.clone();
445        let label = label.to_string();
446
447        #[cfg(all(target_os = "linux", feature = "linux"))]
448        {
449            if gtk::glib::MainContext::default().is_owner() {
450                return Self::close_widget_window_on_main(&app, &label);
451            }
452        }
453
454        if on_main_thread() {
455            return Self::close_widget_window_on_main(&app, &label);
456        }
457        let (tx, rx) = std::sync::mpsc::sync_channel(1);
458        self.app
459            .run_on_main_thread(move || {
460                let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
461            })
462            .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
463
464        rx.recv()
465            .map_err(|_| Error::new("close_widget_window: main thread dropped"))?
466    }
467
468    fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
469        if let Some(win) = app.get_webview_window(label) {
470            win.close().map_err(|e| Error::new(e.to_string()))?;
471            Ok(true)
472        } else {
473            Ok(false)
474        }
475    }
476
477    /// Register native widget provider ids.
478    ///
479    /// | Platform | Behaviour |
480    /// |---|---|
481    /// | Android | stores fully-qualified provider class names |
482    /// | iOS / macOS | stores WidgetKit kind strings (advisory) |
483    /// | Desktop | **no-op**, accepted for API symmetry |
484    pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
485        // Desktop has no native provider registry; accept for API symmetry.
486        Ok(true)
487    }
488
489    pub fn reload_all_timelines(&self) -> crate::Result<bool> {
490        let _ = self.app.emit("widget-reload", "all");
491        #[cfg(target_os = "macos")]
492        if !unsafe { macos_widget_reload_all() } {
493            return Err(Error::Unsupported(
494                "WidgetKit reload needs macOS 11+".into(),
495            ));
496        }
497        Ok(true)
498    }
499
500    pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
501        let _ = self.app.emit("widget-reload", of_kind);
502        #[cfg(target_os = "macos")]
503        {
504            let c = CString::new(of_kind)
505                .map_err(|_| Error::new("reload_timelines: kind contains a NUL byte"))?;
506            if !unsafe { macos_widget_reload_kind(c.as_ptr()) } {
507                return Err(Error::Unsupported(
508                    "WidgetKit reload needs macOS 11+".into(),
509                ));
510            }
511        }
512        Ok(true)
513    }
514
515    /// Request that the OS show the "add widget" / pin UI.
516    ///
517    /// | Platform | Behaviour |
518    /// |---|---|
519    /// | Android | opens the pin-widget flow |
520    /// | iOS / macOS | no native pin API — returns `Ok` from the mobile bridge |
521    /// | Desktop | **error** — use [`Self::create_widget_window`] instead |
522    pub fn request_widget(&self) -> crate::Result<bool> {
523        Err(Error::Unsupported(
524            "Use create_widget_window on desktop".into(),
525        ))
526    }
527
528    /// Load a [`WidgetConfig`] JSON file from disk, then apply it like [`Self::set_widget_config`].
529    pub fn set_widget_config_from_path(
530        &self,
531        path: impl AsRef<std::path::Path>,
532        group: &str,
533        widget_id: &str,
534        skip_reload: bool,
535    ) -> crate::Result<ApplyOutcome> {
536        if widget_id.is_empty() {
537            return Err(Error::new("widget_id must not be empty"));
538        }
539        let path = path.as_ref();
540        let path_str = path
541            .to_str()
542            .ok_or_else(|| Error::new(format!("invalid UTF-8 path: {}", path.display())))?;
543        let config = crate::commands::load_widget_config_from_path(path_str)?;
544        self.set_widget_config(&config, group, widget_id, skip_reload)
545    }
546
547    pub fn set_widget_config(
548        &self,
549        config: &WidgetConfig,
550        group: &str,
551        widget_id: &str,
552        skip_reload: bool,
553    ) -> crate::Result<ApplyOutcome> {
554        if widget_id.is_empty() {
555            return Err(Error::new("widget_id must not be empty"));
556        }
557        self.remember_group(group);
558
559        let mut config =
560            crate::normalize::normalize(config, crate::capabilities::WidgetPlatform::current())
561                .config;
562        crate::image_prefetch::prefetch_remote_images(&mut config);
563
564        let json = serde_json::to_string(&config)
565            .map_err(|e| Error::new(format!("serialize config: {e}")))?;
566        let compact: serde_json::Value = serde_json::from_str(&json)
567            .map_err(|e| Error::new(format!("serialize config: {e}")))?;
568        let hash = config_content_hash(&json);
569        let key = config_key(widget_id);
570
571        let existing = self.get_items(&key, group)?;
572        let changed = existing.as_deref() != Some(json.as_str());
573
574        // Desktop webview always gets a push so an open window stays in sync
575        // even when store bytes were already identical.
576        let _ = self.app.emit(
577            "widget-config-push",
578            serde_json::json!({
579                "group": group,
580                "widgetId": widget_id,
581                "config": compact,
582            }),
583        );
584
585        if !changed {
586            let outcome = ApplyOutcome::unchanged(hash);
587            self.trace.push(TraceEvent::ConfigSet {
588                widget_id: widget_id.into(),
589                nonce: 0,
590                bytes: json.len(),
591                changed: false,
592                skip: Some(SkipReason::Unchanged { hash }),
593            });
594            self.trace.push(TraceEvent::Reload {
595                performed: false,
596                reason: outcome.reload.clone(),
597            });
598            self.maybe_flush_trace();
599            return Ok(outcome);
600        }
601
602        crate::capabilities::log_capabilities(&config);
603        let t0 = std::time::Instant::now();
604        self.set_items(&key, &json, group)?;
605        let write_ms = t0.elapsed().as_millis() as u32;
606        let transports = self.written_transport_names(group);
607        let nonce = self
608            .get_items("__meta_nonce__", group)
609            .ok()
610            .flatten()
611            .and_then(|s| s.parse().ok())
612            .unwrap_or(0);
613
614        self.trace.push(TraceEvent::ConfigSet {
615            widget_id: widget_id.into(),
616            nonce,
617            bytes: json.len(),
618            changed: true,
619            skip: None,
620        });
621        for name in &transports {
622            self.trace.push(TraceEvent::Write {
623                transport: name.clone(),
624                ok: true,
625                duration_ms: write_ms,
626                error: None,
627            });
628        }
629
630        #[cfg(target_os = "windows")]
631        {
632            // Widgets Board: single Adaptive Card from the medium IR branch.
633            // Package Size capability is medium-only (templates/windows-widget).
634            // Desktop webview (widget.html) remains the fallback outside Widget Board.
635            if let Some(result) = crate::adaptive_card::to_adaptive_card_for_size(&config, "medium")
636            {
637                let template = serde_json::to_string(&result.card)
638                    .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
639                self.set_items(
640                    &crate::adaptive_card::ac_template_key(widget_id),
641                    &template,
642                    group,
643                )?;
644                self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
645            }
646        }
647
648        let reload = if skip_reload {
649            ReloadOutcome::Skipped {
650                why: "skip_reload".into(),
651            }
652        } else {
653            match self.reload_all_timelines() {
654                Ok(_) => ReloadOutcome::Ok,
655                Err(e) => ReloadOutcome::Failed {
656                    error: e.to_string(),
657                },
658            }
659        };
660        self.trace.push(TraceEvent::Reload {
661            performed: matches!(reload, ReloadOutcome::Ok),
662            reason: reload.clone(),
663        });
664
665        #[cfg(target_os = "macos")]
666        self.ensure_action_poller();
667
668        self.maybe_flush_trace();
669
670        Ok(ApplyOutcome {
671            written: true,
672            reload,
673            transports,
674            skip: None,
675        })
676    }
677
678    /// Names of transports that hold the current map after a write.
679    fn written_transport_names(&self, group: &str) -> Vec<String> {
680        #[cfg(target_os = "macos")]
681        {
682            let _ = group;
683            vec![self.macos_driver.name().to_string()]
684        }
685        #[cfg(not(target_os = "macos"))]
686        {
687            let _ = group;
688            vec!["file".into()]
689        }
690    }
691
692    pub fn get_widget_config(
693        &self,
694        group: &str,
695        widget_id: &str,
696    ) -> crate::Result<Option<WidgetConfig>> {
697        if widget_id.is_empty() {
698            return Err(Error::new("widget_id must not be empty"));
699        }
700        let raw = self.get_items(&config_key(widget_id), group)?;
701        match raw {
702            Some(json) => {
703                let config: WidgetConfig = serde_json::from_str(&json)
704                    .map_err(|e| Error::new(format!("parse config: {e}")))?;
705                Ok(Some(config))
706            }
707            None => Ok(None),
708        }
709    }
710
711    /// Read configured driver + merge with in-memory if newer.
712    #[cfg(target_os = "macos")]
713    fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
714        let mut maps = Vec::new();
715        if let Some(disk) = self.macos_driver.read() {
716            maps.push(disk);
717        }
718        let path = self.storage_path(group)?;
719        let mut store = self.store.lock().unwrap();
720        let map = Self::load_map_locked(&mut store, &path, group);
721        maps.push(map.clone());
722        let freshest = store::pick_freshest(maps);
723        if store::map_nonce(&freshest) > store::map_nonce(map) {
724            *map = freshest.clone();
725        }
726        Ok(freshest)
727    }
728
729    /// Drain pending actions for a group. On macOS the whole read-drain-write runs under
730    /// the App Group file lock the widget extension appends under.
731    pub fn poll_pending_actions(
732        &self,
733        group: &str,
734    ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
735        #[cfg(target_os = "macos")]
736        return crate::macos_transport::with_store_lock(group, || {
737            self.drain_pending_actions(group)
738        });
739        #[cfg(not(target_os = "macos"))]
740        self.drain_pending_actions(group)
741    }
742
743    fn drain_pending_actions(
744        &self,
745        group: &str,
746    ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
747        self.remember_group(group);
748
749        #[cfg(target_os = "macos")]
750        let disk_maps: Vec<DataMap> = {
751            let mut maps = Vec::new();
752            for t in crate::macos_transport::all_transports(group) {
753                if let Some(m) = t.read() {
754                    maps.push(m);
755                }
756            }
757            maps
758        };
759        #[cfg(not(target_os = "macos"))]
760        let disk_maps: Vec<DataMap> = {
761            let path = self.storage_path(group)?;
762            if path.exists() {
763                fs::read_to_string(&path)
764                    .ok()
765                    .and_then(|s| serde_json::from_str(&s).ok())
766                    .into_iter()
767                    .collect()
768            } else {
769                Vec::new()
770            }
771        };
772
773        let freshest = store::pick_freshest(disk_maps);
774
775        let mut store = self.store.lock().unwrap();
776        let path = self.storage_path(group)?;
777        let map = Self::load_map_locked(&mut store, &path, group);
778
779        if store::map_nonce(&freshest) > store::map_nonce(map) {
780            *map = freshest;
781        }
782
783        #[cfg(target_os = "macos")]
784        let actions = {
785            let harvested = crate::macos_transport::harvest_pending_actions(group);
786            if !harvested.is_empty() {
787                harvested
788            } else {
789                parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()))
790            }
791        };
792        #[cfg(not(target_os = "macos"))]
793        let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
794
795        if actions.is_empty() {
796            return Ok(Vec::new());
797        }
798
799        map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
800        #[cfg(target_os = "macos")]
801        {
802            let floor = crate::macos_transport::max_nonce_across(group);
803            store::touch_meta_above(map, floor);
804        }
805        #[cfg(not(target_os = "macos"))]
806        {
807            store::touch_meta(map);
808        }
809        let snapshot = map.clone();
810        drop(store);
811
812        #[cfg(target_os = "windows")]
813        persist_windows_shared_map(&self.storage_path(group)?, &snapshot, &actions)?;
814        #[cfg(not(target_os = "windows"))]
815        self.persist_map(group, &snapshot)?;
816        #[cfg(target_os = "macos")]
817        {
818            crate::macos_transport::clear_pending_actions_everywhere(group, &actions);
819        }
820
821        Ok(actions)
822    }
823
824    pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
825        self.remember_group(&receipt.group);
826        let trigger = receipt.trigger.clone().unwrap_or_else(|| "timeline".into());
827        let lag_ms = {
828            let since = self.trace.list_since(None);
829            since
830                .iter()
831                .rev()
832                .find_map(|e| match &e.event {
833                    TraceEvent::ConfigSet { nonce, .. }
834                        if *nonce == receipt.nonce && *nonce > 0 =>
835                    {
836                        Some(receipt.ts.saturating_sub(e.ts))
837                    }
838                    _ => None,
839                })
840                .unwrap_or(0)
841        };
842        self.trace.push(TraceEvent::Render {
843            instance: receipt.instance.clone(),
844            nonce: receipt.nonce,
845            source: receipt.source.clone(),
846            trigger,
847            lag_ms,
848            skipped: receipt.skipped.clone(),
849        });
850        self.receipts.upsert(receipt);
851        if let Ok(dir) = self.app.path().app_data_dir() {
852            let _ = self.receipts.save_to_path(&receipts_path(&dir));
853        }
854        self.maybe_flush_trace();
855        Ok(true)
856    }
857
858    pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
859        Ok(self.receipts.list(group))
860    }
861
862    pub fn get_widget_trace(
863        &self,
864        group: &str,
865        since_ms: Option<u64>,
866    ) -> crate::Result<WidgetTrace> {
867        self.maybe_flush_trace();
868        Ok(WidgetTrace {
869            enabled: crate::trace::trace_enabled(),
870            events: self.trace.list_since(since_ms),
871            receipts: self.receipts.history(group),
872        })
873    }
874
875    pub fn flush_widget_trace(&self) -> crate::Result<bool> {
876        if let Ok(dir) = self.app.path().app_data_dir() {
877            self.trace.flush_to_path(&trace_path(&dir))?;
878        }
879        Ok(true)
880    }
881
882    fn maybe_flush_trace(&self) {
883        if !self.trace.needs_timed_flush() {
884            return;
885        }
886        if let Ok(dir) = self.app.path().app_data_dir() {
887            let _ = self.trace.flush_to_path(&trace_path(&dir));
888        }
889    }
890
891    #[cfg(target_os = "macos")]
892    fn ensure_action_poller(&self) {
893        let mut started = self.poller_started.lock().unwrap();
894        if *started {
895            return;
896        }
897        *started = true;
898
899        let groups_handle = self.app.clone();
900        std::thread::spawn(move || loop {
901            std::thread::sleep(std::time::Duration::from_millis(500));
902            let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
903                continue;
904            };
905            widget.inner().maybe_flush_trace();
906            let groups = widget.inner().known_groups.lock().unwrap().clone();
907            for group in groups {
908                match widget.inner().poll_pending_actions(&group) {
909                    Ok(actions) if !actions.is_empty() => {
910                        widget.inner().trace.push(TraceEvent::Poll {
911                            count: actions.len(),
912                        });
913                        for action in actions {
914                            let _ = groups_handle.emit("widget-action", action);
915                        }
916                    }
917                    _ => {}
918                }
919            }
920        });
921    }
922}
923
924// ─── macOS helpers ────────────────────────────────────────────────────────────
925
926#[cfg(target_os = "macos")]
927extern "C" {
928    fn macos_widget_reload_all() -> bool;
929    fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
930    fn macos_widget_container_path(group: *const std::ffi::c_char) -> *mut std::ffi::c_char;
931    fn macos_widget_free_string(ptr: *mut std::ffi::c_char);
932}
933
934#[cfg(target_os = "macos")]
935fn macos_shared_container(group: &str) -> Option<PathBuf> {
936    use std::ffi::CStr;
937    let c_group = CString::new(group).ok()?;
938    let ptr = unsafe { macos_widget_container_path(c_group.as_ptr()) };
939    if ptr.is_null() {
940        return None;
941    }
942    let path = unsafe { CStr::from_ptr(ptr) }
943        .to_string_lossy()
944        .into_owned();
945    unsafe { macos_widget_free_string(ptr) };
946    Some(PathBuf::from(path))
947}
948
949#[cfg(not(target_os = "macos"))]
950fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
951    let nanos = std::time::SystemTime::now()
952        .duration_since(std::time::UNIX_EPOCH)
953        .map(|d| d.as_nanos())
954        .unwrap_or(0);
955    let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
956    fs::write(&tmp, data)?;
957    match fs::rename(&tmp, path) {
958        Ok(()) => Ok(()),
959        Err(e) => {
960            let _ = fs::remove_file(&tmp);
961            Err(e)
962        }
963    }
964}
965
966/// Windows Widgets Board provider and Rust host share one `widget_data.json`.
967/// Match `WidgetStore.PersistUnlocked`: exclusive `.lock` + merge under it so
968/// provider-enqueued `pending_actions` are not wiped by a concurrent host write.
969#[cfg(target_os = "windows")]
970fn persist_windows_shared_map(
971    path: &PathBuf,
972    map: &DataMap,
973    drained: &[crate::WidgetActionEnvelope],
974) -> crate::Result<()> {
975    use std::fs::OpenOptions;
976    use std::os::windows::fs::OpenOptionsExt;
977    use std::thread;
978    use std::time::Duration;
979
980    if let Some(parent) = path.parent() {
981        fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
982    }
983
984    // Same path convention as C#: `{widget_data.json}.lock`
985    let lock_path = PathBuf::from(format!("{}.lock", path.display()));
986    let _lock = {
987        let mut last_err = None;
988        let mut held = None;
989        for _ in 0..100 {
990            let mut opts = OpenOptions::new();
991            opts.read(true).write(true).create(true).share_mode(0); // FILE_SHARE_NONE
992            match opts.open(&lock_path) {
993                Ok(f) => {
994                    held = Some(f);
995                    break;
996                }
997                Err(e) => {
998                    // ERROR_SHARING_VIOLATION (32) while the provider holds the lock.
999                    if e.raw_os_error() == Some(32) {
1000                        last_err = Some(e);
1001                        thread::sleep(Duration::from_millis(20));
1002                        continue;
1003                    }
1004                    return Err(Error::Io(e.to_string()));
1005                }
1006            }
1007        }
1008        held.ok_or_else(|| {
1009            Error::Io(
1010                last_err
1011                    .map(|e| e.to_string())
1012                    .unwrap_or_else(|| "widget_data.json.lock busy".into()),
1013            )
1014        })?
1015    };
1016
1017    let mut merged: DataMap = if path.exists() {
1018        fs::read_to_string(path)
1019            .ok()
1020            .and_then(|s| serde_json::from_str(&s).ok())
1021            .unwrap_or_default()
1022    } else {
1023        DataMap::new()
1024    };
1025
1026    for (k, v) in map {
1027        if k == PENDING_ACTIONS_KEY {
1028            // The provider owns the queue: keep the disk copy minus what we drained.
1029            let rest = store::pending_minus_drained(merged.get(k).map(String::as_str), drained)?;
1030            merged.insert(k.clone(), rest);
1031            continue;
1032        }
1033        merged.insert(k.clone(), v.clone());
1034    }
1035
1036    // Keep host meta (already bumped) authoritative for this write.
1037    if let Some(n) = map.get(store::META_NONCE_KEY) {
1038        merged.insert(store::META_NONCE_KEY.into(), n.clone());
1039    }
1040    if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
1041        merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
1042    }
1043
1044    let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
1045    atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
1046    Ok(())
1047}
1048
1049#[cfg(test)]
1050mod builtin_url_tests {
1051    use super::encode_query_component;
1052
1053    #[test]
1054    fn encodes_ampersand_and_equals() {
1055        assert_eq!(
1056            encode_query_component("small&widgetId=hijack"),
1057            "small%26widgetId%3Dhijack"
1058        );
1059        assert_eq!(encode_query_component("a=b&c"), "a%3Db%26c");
1060    }
1061
1062    #[test]
1063    fn leaves_unreserved_alone() {
1064        assert_eq!(
1065            encode_query_component("group.com.example.app"),
1066            "group.com.example.app"
1067        );
1068        assert_eq!(encode_query_component("weather_1"), "weather_1");
1069    }
1070}