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
24pub(crate) mod island;
25mod notify;
26pub(crate) mod placement;
27mod sources;
28mod window;
29
30/// Protocol name registered by the plugin for the built-in widget renderer.
31pub(crate) const BUILTIN_PROTOCOL: &str = "widgetview";
32
33/// Percent-encode a query component (RFC 3986 unreserved left as-is).
34fn encode_query_component(s: &str) -> String {
35    let mut out = String::with_capacity(s.len());
36    for &b in s.as_bytes() {
37        match b {
38            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
39                out.push(b as char);
40            }
41            _ => {
42                use std::fmt::Write as _;
43                let _ = write!(out, "%{b:02X}");
44            }
45        }
46    }
47    out
48}
49
50fn builtin_widget_url(group: &str, size: &str, widget_id: &str, instance: &str) -> WebviewUrl {
51    let group = encode_query_component(group);
52    let size = encode_query_component(size);
53    let widget_id = encode_query_component(widget_id);
54    let instance = encode_query_component(instance);
55    #[cfg(target_os = "windows")]
56    let url_str = format!(
57        "https://{}.localhost/?group={}&size={}&widgetId={}&instance={}",
58        BUILTIN_PROTOCOL, group, size, widget_id, instance
59    );
60    #[cfg(not(target_os = "windows"))]
61    let url_str = format!(
62        "{}://localhost/?group={}&size={}&widgetId={}&instance={}",
63        BUILTIN_PROTOCOL, group, size, widget_id, instance
64    );
65    WebviewUrl::External(url_str.parse().expect("invalid built-in widget URL"))
66}
67
68/// Device language for the Widgets Board card: `LC_ALL` / `LC_MESSAGES` / `LANG` when set
69/// (developer override), else the Windows user locale (`GetUserDefaultLocaleName`, BCP-47).
70#[cfg(target_os = "windows")]
71fn windows_locale() -> String {
72    if let Some(v) = ["LC_ALL", "LC_MESSAGES", "LANG"]
73        .iter()
74        .find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty()))
75    {
76        return v.split('.').next().unwrap_or_default().to_string();
77    }
78    #[link(name = "kernel32")]
79    unsafe extern "system" {
80        fn GetUserDefaultLocaleName(name: *mut u16, len: i32) -> i32;
81    }
82    let mut buf = [0u16; 85]; // LOCALE_NAME_MAX_LENGTH
83                              // SAFETY: the buffer is valid for `len` UTF-16 units; the call writes at most that.
84    let n = unsafe { GetUserDefaultLocaleName(buf.as_mut_ptr(), buf.len() as i32) };
85    if n > 1 {
86        String::from_utf16_lossy(&buf[..(n - 1) as usize])
87    } else {
88        String::new()
89    }
90}
91
92/// GET/POST a source with the shared limits; `None` on any failure or non-JSON body.
93#[cfg(all(target_os = "windows", feature = "image-prefetch"))]
94fn fetch_source_text(req: &crate::resolve::SourceRequest) -> Option<String> {
95    use std::io::Read;
96    let mut r = ureq::request(&req.method, &req.url).timeout(std::time::Duration::from_secs(10));
97    for (k, v) in &req.headers {
98        r = r.set(k, v);
99    }
100    let resp = match &req.body {
101        Some(b) => r.send_string(b),
102        None => r.call(),
103    }
104    .ok()?;
105    let mut buf = Vec::new();
106    resp.into_reader()
107        .take(crate::resolve::MAX_SOURCE_BYTES as u64 + 1)
108        .read_to_end(&mut buf)
109        .ok()?;
110    if buf.len() > crate::resolve::MAX_SOURCE_BYTES {
111        return None;
112    }
113    let text = String::from_utf8(buf).ok()?;
114    serde_json::from_str::<serde_json::Value>(&text).ok()?;
115    Some(text)
116}
117
118/// Desktop live-activity window size (logical px).
119const ACTIVITY_WINDOW_SIZE: (f64, f64) = (340.0, 128.0);
120
121/// Window label for an activity id. `.` is not allowed in Tauri labels; `_` is doubled
122/// first so `a.b` (`a_db`) and `a_b` (`a__b`) never share a window.
123/// Logical top-left of the `n`-th activity window: stacked down the work area's right edge.
124fn activity_slot(area_x: f64, area_y: f64, area_w: f64, n: usize) -> (f64, f64) {
125    const MARGIN: f64 = 16.0;
126    let (w, h) = ACTIVITY_WINDOW_SIZE;
127    (
128        area_x + area_w - w - MARGIN,
129        area_y + MARGIN + n as f64 * (h + 8.0),
130    )
131}
132
133fn activity_window_label(id: &str) -> String {
134    format!("activity-{}", id.replace('_', "__").replace('.', "_d"))
135}
136
137/// The process main thread runs the event loop on every desktop platform.
138fn on_main_thread() -> bool {
139    std::thread::current().name() == Some("main")
140}
141
142pub fn init<R: Runtime>(
143    app: &AppHandle<R>,
144    api: PluginApi<R, Option<WidgetsPluginConfig>>,
145) -> crate::Result<Widget<R>> {
146    let cfg = api
147        .config()
148        .clone()
149        .unwrap_or_default()
150        .with_defaults(&app.config().identifier);
151    init_with_config(app, cfg)
152}
153
154pub(crate) fn init_with_config<R: Runtime>(
155    app: &AppHandle<R>,
156    cfg: WidgetsPluginConfig,
157) -> crate::Result<Widget<R>> {
158    let receipts = ReceiptStore::new();
159    let trace = TraceStore::new();
160    if let Ok(dir) = app.path().app_data_dir() {
161        receipts.load_from_path(&receipts_path(&dir));
162        trace.load_from_path(&trace_path(&dir));
163    }
164
165    // An unsigned / ad-hoc dev build has no App Group container: fall back to the widget
166    // extension's own container instead of aborting the whole app at startup.
167    #[cfg(target_os = "macos")]
168    let macos_driver = match crate::transport::resolve_driver(&cfg) {
169        Ok(driver) => driver,
170        Err(e) => {
171            log::warn!(
172                "widgets: {e}\nFalling back to transport=widgetContainer (works for ad-hoc signed \
173                 builds). Sign with a Team ID and App Groups for transport=appGroup."
174            );
175            crate::transport::build_driver(crate::config::TransportKind::WidgetContainer, &cfg)?
176        }
177    };
178
179    let widget = Widget {
180        app: app.clone(),
181        cfg,
182        store: Mutex::new(HashMap::new()),
183        known_groups: Mutex::new(Vec::new()),
184        receipts,
185        trace,
186        windows: Mutex::new(HashMap::new()),
187        activities: Mutex::new(HashMap::new()),
188        activity_ts: Mutex::new(HashMap::new()),
189        island: Mutex::new(island::Island::default()),
190        listener_gate: store::ListenerGate::new(
191            store::now_ms(),
192            store::ListenerGate::DEFAULT_FALLBACK_MS,
193        ),
194        #[cfg(target_os = "windows")]
195        board_timers: Mutex::new(HashMap::new()),
196        #[cfg(target_os = "windows")]
197        board_fetches: Mutex::new(HashMap::new()),
198        #[cfg(target_os = "macos")]
199        poller_started: Mutex::new(false),
200        #[cfg(target_os = "macos")]
201        persist: Arc::new(PersistQueue::default()),
202        #[cfg(target_os = "macos")]
203        sibling_sweeps: Mutex::new(HashMap::new()),
204        #[cfg(target_os = "macos")]
205        macos_driver,
206        window_store: placement::WindowStore::load(
207            app.path()
208                .app_data_dir()
209                .ok()
210                .map(|d| d.join("widget-windows.json")),
211        ),
212        notifier: notify::Notifier::default(),
213        sources: Mutex::new(HashMap::new()),
214    };
215    // Seed poller with configured App Group so widget taps work before first set_widget_config.
216    if let Some(g) = widget.cfg.app_group.clone() {
217        widget.remember_group(&g);
218    }
219    #[cfg(target_os = "macos")]
220    widget.ensure_action_poller();
221
222    // Queued: runs once the plugin state is managed and the event loop turns.
223    if widget.cfg.restore_windows_on_start {
224        let handle = app.clone();
225        let _ = app.run_on_main_thread(move || {
226            if let Some(w) = handle.try_state::<Widget<R>>() {
227                w.restore_windows();
228            }
229        });
230    }
231
232    Ok(widget)
233}
234
235pub struct Widget<R: Runtime> {
236    app: AppHandle<R>,
237    #[allow(dead_code)]
238    cfg: WidgetsPluginConfig,
239    /// In-memory data store keyed by group.
240    store: Mutex<HashMap<String, DataMap>>,
241    known_groups: Mutex<Vec<String>>,
242    /// Cross-platform render receipts (diagnostics only).
243    receipts: ReceiptStore,
244    /// Host delivery journal (debug / `WIDGET_DEBUG=1`).
245    trace: TraceStore,
246    /// Widget windows this plugin opened: label → placement.
247    windows: Mutex<HashMap<String, crate::store::ActiveWidget>>,
248    /// Running live activities: id → group.
249    activities: Mutex<HashMap<String, String>>,
250    /// Last applied `timestamp` per running activity (out-of-order guard, as iOS / Android).
251    activity_ts: Mutex<HashMap<String, u64>>,
252    /// Activities on the top-centre island window (`activitySurface: "island"`).
253    island: Mutex<island::Island>,
254    /// Hold queued taps until JavaScript listens (see [`store::ListenerGate`]).
255    #[allow(dead_code)]
256    listener_gate: store::ListenerGate,
257    /// Widgets Board rebuild timers: widget id → generation of the live one.
258    #[cfg(target_os = "windows")]
259    board_timers: Mutex<HashMap<String, u64>>,
260    /// Widgets Board source fetches: widget id → last attempt (Unix ms).
261    #[cfg(target_os = "windows")]
262    board_fetches: Mutex<HashMap<String, u64>>,
263    #[cfg(target_os = "macos")]
264    poller_started: Mutex<bool>,
265    /// Host writes not yet in the App Group files (see [`PersistQueue`]).
266    #[cfg(target_os = "macos")]
267    persist: Arc<PersistQueue>,
268    /// Last sweep of the sibling transports per group (see [`Self::sweep_due`]).
269    #[cfg(target_os = "macos")]
270    sibling_sweeps: Mutex<HashMap<String, std::time::Instant>>,
271    /// Single Apple host transport (config-chosen).
272    #[cfg(target_os = "macos")]
273    macos_driver: Arc<dyn Transport>,
274    /// Remembered window positions + open window set (`widget-windows.json`).
275    window_store: placement::WindowStore,
276    /// Live activities shown as notifications.
277    notifier: notify::Notifier,
278    /// Poll / listen data sources: widget id → generation of the live one.
279    sources: Mutex<HashMap<String, u64>>,
280}
281
282impl<R: Runtime> Widget<R> {
283    fn remember_group(&self, group: &str) {
284        let mut groups = self.known_groups.lock().unwrap();
285        if !groups.iter().any(|g| g == group) {
286            groups.push(group.to_string());
287        }
288    }
289
290    fn storage_path(&self, group: &str) -> crate::Result<PathBuf> {
291        #[cfg(target_os = "macos")]
292        {
293            if let Some(path) = crate::macos_transport::app_group_data_override() {
294                if let Some(parent) = path.parent() {
295                    if !parent.exists() {
296                        fs::create_dir_all(parent)?;
297                    }
298                }
299                return Ok(path);
300            }
301            if let Some(dir) = macos_shared_container(group) {
302                if !dir.exists() {
303                    fs::create_dir_all(&dir)?;
304                }
305                return Ok(dir.join("widget_data.json"));
306            }
307            Ok(crate::macos_transport::sandbox_widget_data_path(group))
308        }
309
310        #[cfg(target_os = "windows")]
311        {
312            // Align with WidgetProvider Store.DefaultPath so Widgets Board sees host writes.
313            for key in ["TAURI_WIDGETS_DATA", "WIDGET_DATA_DIR"] {
314                if let Ok(env_path) = std::env::var(key) {
315                    let p = env_path.trim();
316                    if !p.is_empty() {
317                        let path = if p.to_ascii_lowercase().ends_with(".json") {
318                            PathBuf::from(p)
319                        } else {
320                            PathBuf::from(p).join("widget_data.json")
321                        };
322                        if let Some(parent) = path.parent() {
323                            if !parent.exists() {
324                                fs::create_dir_all(parent)?;
325                            }
326                        }
327                        return Ok(path);
328                    }
329                }
330            }
331            let local = std::env::var("LOCALAPPDATA")
332                .map(PathBuf::from)
333                .or_else(|_| {
334                    self.app
335                        .path()
336                        .app_data_dir()
337                        .map_err(|e| Error::Io(e.to_string()))
338                })?;
339            let dir = local.join("tauri-plugin-widgets");
340            if !dir.exists() {
341                fs::create_dir_all(&dir)?;
342            }
343            let _ = group; // single shared widget_data.json — group lives in map keys
344            Ok(dir.join("widget_data.json"))
345        }
346
347        #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
348        {
349            let base = self
350                .app
351                .path()
352                .app_data_dir()
353                .map_err(|e| Error::Io(e.to_string()))?;
354            let dir = base.join("widgets");
355            if !dir.exists() {
356                fs::create_dir_all(&dir)?;
357            }
358            // validate_group already limits the charset; keep '-' so `a-b` and `a_b` differ.
359            let safe: String = group
360                .chars()
361                .map(|c| {
362                    if c.is_alphanumeric() || c == '.' || c == '-' {
363                        c
364                    } else {
365                        '_'
366                    }
367                })
368                .collect();
369            Ok(dir.join(format!("{safe}.json")))
370        }
371    }
372
373    fn load_map_locked<'a>(
374        store: &'a mut HashMap<String, DataMap>,
375        path: &PathBuf,
376        group: &str,
377    ) -> &'a mut DataMap {
378        #[cfg(not(target_os = "macos"))]
379        let _ = group; // macOS also reads the group's transports below
380                       // Keyed by file, not group: groups that share a file (Windows: one widget_data.json
381                       // for Widgets Board) must share the map, or one group's cached copy overwrites the
382                       // other's writes and misses them when comparing.
383        store
384            .entry(path.to_string_lossy().into_owned())
385            .or_insert_with(|| {
386                let mut maps = Vec::new();
387                if path.exists() {
388                    if let Some(m) = fs::read_to_string(path)
389                        .ok()
390                        .and_then(|s| serde_json::from_str(&s).ok())
391                    {
392                        maps.push(m);
393                    }
394                }
395                #[cfg(target_os = "macos")]
396                {
397                    for t in crate::macos_transport::all_transports(group) {
398                        if let Some(m) = t.read() {
399                            maps.push(m);
400                        }
401                    }
402                }
403                store::pick_freshest(maps)
404            })
405    }
406
407    /// The cached map is the host's last write; a higher nonce on disk means the widget
408    /// extension wrote since (a `set` tap's `state:` key). Take that map before changing
409    /// it, or the host's next write would drop the extension's keys.
410    #[cfg(target_os = "macos")]
411    fn adopt_newer_disk_map(&self, map: &mut DataMap, group: &str) {
412        let disk = store::pick_freshest(
413            self.hot_transports(group)
414                .iter()
415                .filter_map(|t| t.read())
416                .collect::<Vec<DataMap>>(),
417        );
418        if store::map_nonce(&disk) > store::map_nonce(map) {
419            *map = disk;
420        }
421    }
422
423    /// Persist map on macOS: merge pending from all channels, clear leftover
424    /// maps on **sibling** transports only, then write the configured driver.
425    ///
426    /// The primary is never wiped before a successful write (failed write must
427    /// not erase the last good map). Sibling clear re-merges pending on each
428    /// wipe attempt so a mid-clear tap is not dropped.
429    fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> {
430        #[cfg(target_os = "macos")]
431        crate::macos_transport::with_store_lock(group, || {
432            let mut out = map.clone();
433            if self.sweep_due(group) {
434                crate::macos_transport::merge_pending_into_map(&mut out, group);
435                crate::macos_transport::clear_sibling_transports(
436                    group,
437                    self.macos_driver.name(),
438                    &mut out,
439                );
440            } else {
441                crate::macos_transport::merge_pending_in(&mut out, &self.hot_transports(group));
442            }
443            self.macos_driver.write(&out)
444        })?;
445        #[cfg(target_os = "windows")]
446        {
447            let path = self.storage_path(group)?;
448            persist_windows_shared_map(&path, map, &[])?;
449        }
450        #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
451        {
452            let path = self.storage_path(group)?;
453            let json = serde_json::to_string_pretty(map)?;
454            atomic_write(&path, json.as_bytes())?;
455        }
456        Ok(())
457    }
458
459    /// Transports a hot path (every write, every tap poll) reads: the working channel, chosen
460    /// once at startup, for its App Group; every transport for another group.
461    #[cfg(target_os = "macos")]
462    fn hot_transports(&self, group: &str) -> Vec<Arc<dyn Transport>> {
463        if crate::group::default_group() == Some(group) {
464            vec![self.macos_driver.clone()]
465        } else {
466            crate::macos_transport::all_transports(group)
467        }
468    }
469
470    /// Whether the sibling transports of `group` are due a sweep (fold in their taps, then
471    /// empty them). The extension fans each tap out to every transport, so siblings collect
472    /// copies the host never reads; sweeping them at most every 30 s keeps them small without
473    /// touching every channel at every write / poll.
474    #[cfg(target_os = "macos")]
475    fn sweep_due(&self, group: &str) -> bool {
476        const EVERY: std::time::Duration = std::time::Duration::from_secs(30);
477        let mut last = self.sibling_sweeps.lock().unwrap();
478        let now = std::time::Instant::now();
479        if last
480            .get(group)
481            .is_some_and(|t| now.duration_since(*t) < EVERY)
482        {
483            return false;
484        }
485        last.insert(group.to_string(), now);
486        true
487    }
488
489    pub fn set_items(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
490        self.remember_group(group);
491        // macOS: memory now, files from the writer thread (`PersistQueue`): any file
492        // operation in a Group Container can stall for 5 s, which must not block a
493        // command (an island frame, a live widget). The writer merges the extension's
494        // newer writes under the lock before it writes.
495        #[cfg(target_os = "macos")]
496        {
497            let path = self.storage_path(group)?;
498            let mut store = self.store.lock().unwrap();
499            let map = Self::load_map_locked(&mut store, &path, group);
500            self.adopt_newer_disk_map(map, group);
501            // What the host last wrote: a queued value, else the map.
502            let current = self
503                .persist
504                .unwritten(group)
505                .into_iter()
506                .rev()
507                .find(|(k, _)| k == key)
508                .map(|(_, v)| v)
509                .or_else(|| map.get(key).cloned());
510            if current.as_deref() == Some(value) {
511                return Ok(true);
512            }
513            map.insert(key.into(), value.into());
514            store::touch_meta_above(
515                map,
516                crate::macos_transport::max_nonce_in(&self.hot_transports(group)),
517            );
518            drop(store);
519            self.queue_persist(group, Some((key, value)), false);
520            let _ = self.app.emit("widget-update", group);
521            Ok(true)
522        }
523        #[cfg(not(target_os = "macos"))]
524        self.set_items_locked(key, value, group)
525    }
526
527    /// Hand a host write (and / or a WidgetKit reload) to the writer thread.
528    #[cfg(target_os = "macos")]
529    fn queue_persist(&self, group: &str, kv: Option<(&str, &str)>, reload: bool) {
530        self.persist.push(group, kv, reload);
531        if !std::mem::replace(&mut *self.persist.started.lock().unwrap(), true) {
532            let app = self.app.clone();
533            let queue = self.persist.clone();
534            std::thread::spawn(move || loop {
535                let (group, job) = queue.next();
536                let Some(widget) = app.try_state::<Widget<R>>() else {
537                    continue;
538                };
539                widget.inner().write_job(&group, job);
540            });
541        }
542    }
543
544    /// Write every queued host key of `group` to the App Group files, then reload.
545    #[cfg(target_os = "macos")]
546    fn write_job(&self, group: &str, job: PersistJob) {
547        if !job.keys.is_empty() {
548            let res = crate::macos_transport::with_store_lock(group, || {
549                let path = self.storage_path(group)?;
550                let mut store = self.store.lock().unwrap();
551                let map = Self::load_map_locked(&mut store, &path, group);
552                // The extension may have written since (state after a tap): take its map
553                // and put our keys back on top.
554                self.adopt_newer_disk_map(map, group);
555                // Read under the lock: a drain may have superseded a key since (see `forget`).
556                for (k, v) in self.persist.in_flight(group) {
557                    map.insert(k, v);
558                }
559                store::touch_meta_above(
560                    map,
561                    crate::macos_transport::max_nonce_in(&self.hot_transports(group)),
562                );
563                let snapshot = map.clone();
564                drop(store);
565                self.persist_map(group, &snapshot)
566            });
567            if let Err(e) = res {
568                log::warn!("widget store write for '{group}' failed: {e}");
569            }
570        }
571        self.persist.done(group);
572        if job.reload {
573            let _ = self.reload_all_timelines_now();
574        }
575    }
576
577    /// Wait until every queued write is in the files (tests, app exit, explicit reloads).
578    #[cfg(target_os = "macos")]
579    pub fn flush_store(&self) {
580        self.persist.wait_idle();
581    }
582
583    #[cfg_attr(target_os = "macos", allow(dead_code))]
584    fn set_items_locked(&self, key: &str, value: &str, group: &str) -> crate::Result<bool> {
585        let path = self.storage_path(group)?;
586        let mut store = self.store.lock().unwrap();
587        let map = Self::load_map_locked(&mut store, &path, group);
588        #[cfg(target_os = "macos")]
589        self.adopt_newer_disk_map(map, group);
590        if map.get(key).map(String::as_str) == Some(value) {
591            return Ok(true);
592        }
593        map.insert(key.into(), value.into());
594        #[cfg(target_os = "macos")]
595        {
596            let floor = crate::macos_transport::max_nonce_in(&self.hot_transports(group));
597            store::touch_meta_above(map, floor);
598        }
599        #[cfg(not(target_os = "macos"))]
600        {
601            store::touch_meta(map);
602        }
603        let snapshot = map.clone();
604        drop(store);
605        self.persist_map(group, &snapshot)?;
606        let _ = self.app.emit("widget-update", group);
607        Ok(true)
608    }
609
610    pub fn get_items(&self, key: &str, group: &str) -> crate::Result<Option<String>> {
611        #[cfg(target_os = "macos")]
612        {
613            let freshest = self.macos_driver_map(group)?;
614            Ok(freshest.get(key).cloned())
615        }
616        #[cfg(not(target_os = "macos"))]
617        {
618            let path = self.storage_path(group)?;
619            let mut store = self.store.lock().unwrap();
620            let map = Self::load_map_locked(&mut store, &path, group);
621            Ok(map.get(key).cloned())
622        }
623    }
624
625    pub fn create_widget_window(&self, config: WidgetWindowConfig) -> crate::Result<bool> {
626        if config.url.as_deref().map_or(true, str::is_empty) {
627            self.windows.lock().unwrap().insert(
628                config.label.clone(),
629                crate::store::ActiveWidget {
630                    widget_id: config.widget_id.clone().unwrap_or_else(|| "default".into()),
631                    instance: config
632                        .instance
633                        .clone()
634                        .or_else(|| Some(config.label.clone())),
635                    size: Some(config.size.clone().unwrap_or_else(|| "small".into())),
636                    last_seen_ms: None,
637                },
638            );
639        }
640        let app = self.app.clone();
641        // Already on the GTK/UI thread (e.g. `setup`) — build inline to avoid deadlock
642        // waiting for a scheduled task that cannot run until we return.
643        #[cfg(all(target_os = "linux", feature = "linux"))]
644        {
645            if gtk::glib::MainContext::default().is_owner() {
646                return Self::create_widget_window_on_main(&app, config);
647            }
648        }
649
650        // Called from the main thread (e.g. `setup`): the queued task cannot run until we
651        // return, so waiting would only stall. Report the build error in the log.
652        if on_main_thread() {
653            self.app
654                .run_on_main_thread(move || {
655                    if let Err(e) = Self::create_widget_window_on_main(&app, config) {
656                        log::error!("{e}");
657                    }
658                })
659                .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
660            return Ok(true);
661        }
662
663        let (tx, rx) = std::sync::mpsc::sync_channel(1);
664        self.app
665            .run_on_main_thread(move || {
666                let _ = tx.send(Self::create_widget_window_on_main(&app, config));
667            })
668            .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
669        rx.recv()
670            .map_err(|_| Error::new("create_widget_window: main thread dropped"))?
671    }
672
673    fn create_widget_window_on_main(
674        app: &AppHandle<R>,
675        config: WidgetWindowConfig,
676    ) -> crate::Result<bool> {
677        let label_log = config.label.clone();
678        let url = match config.url.as_deref() {
679            Some(u) if !u.is_empty() => WebviewUrl::App(u.into()),
680            _ => {
681                let group = config
682                    .group
683                    .clone()
684                    .or_else(|| crate::group::default_group().map(String::from))
685                    .unwrap_or_default();
686                let group = group.as_str();
687                let size = config.size.as_deref().unwrap_or("small");
688                let widget_id = config.widget_id.as_deref().unwrap_or("default");
689                // The listed placement id is the one the window reads: instance or label.
690                let instance = config.instance.as_deref().unwrap_or(&config.label);
691                builtin_widget_url(group, size, widget_id, instance)
692            }
693        };
694        let skip_taskbar = config.skip_taskbar;
695        // Linux with the `linux` feature: build hidden, pin before the first map, show.
696        let linux_pin = cfg!(all(target_os = "linux", feature = "linux"));
697        let modes = placement::window_modes(&config, linux_pin);
698        let target = window::resolve_target(app, &config);
699        let move_to = |w: &tauri::WebviewWindow<R>, t: &window::Target| {
700            let r = match t.physical {
701                Some(p) => w.set_position(p),
702                None => w.set_position(tauri::LogicalPosition::new(t.logical.0, t.logical.1)),
703            };
704            r.map_err(|e| Error::new(e.to_string()))
705        };
706        // Same label again (watch / rebuild): reuse the window. `close()` is asynchronous,
707        // so building a new one right after it fails with "label already exists".
708        if let Some(prev) = app.get_webview_window(&config.label) {
709            let url = match url {
710                WebviewUrl::External(u) | WebviewUrl::CustomProtocol(u) => u,
711                WebviewUrl::App(path) => prev
712                    .url()
713                    .map_err(|e| Error::new(e.to_string()))?
714                    .join(&path.to_string_lossy())
715                    .map_err(|e| Error::new(e.to_string()))?,
716                _ => return Err(Error::new("create_widget_window: unsupported url")),
717            };
718            prev.navigate(url).map_err(|e| Error::new(e.to_string()))?;
719            prev.set_size(tauri::LogicalSize::new(config.width, config.height))
720                .map_err(|e| Error::new(e.to_string()))?;
721            if let Some(t) = &target {
722                move_to(&prev, t)?;
723            }
724            window::apply_modes(&prev, &modes, config.effects, true);
725            #[cfg(target_os = "windows")]
726            if modes.layer == crate::models::WindowLayer::Desktop {
727                if let Err(e) = crate::windows::attach_window(&prev) {
728                    log::warn!("'{label_log}': {e}");
729                }
730            }
731            prev.show().map_err(|e| Error::new(e.to_string()))?;
732            Self::remember_open(app, &config);
733            log::debug!("reused widget window '{label_log}'");
734            return Ok(true);
735        }
736        // macOS clamps a new window below the menu bar; the island is only allowed up
737        // there after `apply_modes` raises its level. Shown once it sits at the notch.
738        let island_first_show = cfg!(target_os = "macos") && config.label == island::LABEL;
739        let mut builder = WebviewWindowBuilder::new(app, &config.label, url)
740            // Label doubles as WM_NAME so harnesses can find the window (xdotool).
741            .title(&config.label)
742            .inner_size(config.width, config.height)
743            .decorations(false)
744            .skip_taskbar(skip_taskbar)
745            .always_on_top(modes.on_top())
746            .resizable(false)
747            // Widgets and activities appear without taking focus from the user's window.
748            .focused(false)
749            .visible(!linux_pin && !island_first_show);
750        // Transparent windows on macOS require the host app's `macos-private-api`.
751        #[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
752        {
753            builder = builder.transparent(true);
754        }
755        // Opaque macOS window: at least black like the pill (square corners).
756        #[cfg(all(target_os = "macos", not(feature = "macos-private-api")))]
757        if config.label == island::LABEL {
758            builder = builder.background_color(tauri::window::Color(0, 0, 0, 255));
759        }
760
761        if let Some(t) = &target {
762            builder = builder.position(t.logical.0, t.logical.1);
763        }
764
765        let win = builder
766            .build()
767            .map_err(|e| Error::new(format!("create_widget_window '{}': {e}", config.label)))?;
768        window::apply_modes(&win, &modes, config.effects, false);
769        if let Some(t) = target.as_ref().filter(|t| t.physical.is_some()) {
770            move_to(&win, t)?;
771        }
772        if island_first_show {
773            if let Some(t) = &target {
774                let _ = win.set_position(tauri::LogicalPosition::new(t.logical.0, t.logical.1));
775            }
776            win.show().map_err(|e| Error::new(e.to_string()))?;
777        }
778        // Behind the desktop icons (WorkerW / 24H2 Progman), kept across Explorer restarts.
779        #[cfg(target_os = "windows")]
780        if modes.layer == crate::models::WindowLayer::Desktop {
781            if let Err(e) = crate::windows::attach_window(&win) {
782                log::warn!("'{label_log}': {e}");
783            }
784        }
785        #[cfg(all(target_os = "linux", feature = "linux"))]
786        if !crate::linux::pin_widget_window(
787            &win,
788            &config,
789            &modes,
790            target.as_ref().map(|t| t.logical),
791        ) {
792            win.show().map_err(|e| Error::new(e.to_string()))?;
793            // After the map: WMs often ignore the requested spot for DESKTOP windows.
794            if let Some(t) = &target {
795                move_to(&win, t)?;
796            }
797        }
798        if config.remember_position != Some(false) {
799            window::track_position(&win);
800        }
801        Self::remember_open(app, &config);
802        log::debug!("created widget window '{label_log}'");
803        Ok(true)
804    }
805
806    /// Add a widget window to the open set reopened by `restoreWindowsOnStart`.
807    fn remember_open(app: &AppHandle<R>, config: &WidgetWindowConfig) {
808        if config.label.starts_with("activity-") {
809            return;
810        }
811        if let Some(w) = app.try_state::<Widget<R>>() {
812            w.window_store.set_open(&config.label, Some(config));
813        }
814    }
815
816    /// Reopen the widget windows that were open when the app last ran (closed with
817    /// `close_widget_window` = removed from the set; quitting the app keeps them).
818    /// `plugins.widgets.restoreWindowsOnStart: true` calls this at startup. Returns how
819    /// many windows were requested.
820    pub fn restore_windows(&self) -> usize {
821        let list = self.window_store.open_windows();
822        let n = list.len();
823        for cfg in list {
824            let label = cfg.label.clone();
825            if let Err(e) = self.create_widget_window(cfg) {
826                log::warn!("widgets: restore window '{label}': {e}");
827            }
828        }
829        n
830    }
831
832    pub fn close_widget_window(&self, label: &str) -> crate::Result<bool> {
833        self.windows.lock().unwrap().remove(label);
834        if !label.starts_with("activity-") {
835            self.window_store.set_open(label, None);
836        }
837        let app = self.app.clone();
838        let label = label.to_string();
839
840        #[cfg(all(target_os = "linux", feature = "linux"))]
841        {
842            if gtk::glib::MainContext::default().is_owner() {
843                return Self::close_widget_window_on_main(&app, &label);
844            }
845        }
846
847        if on_main_thread() {
848            return Self::close_widget_window_on_main(&app, &label);
849        }
850        let (tx, rx) = std::sync::mpsc::sync_channel(1);
851        self.app
852            .run_on_main_thread(move || {
853                let _ = tx.send(Self::close_widget_window_on_main(&app, &label));
854            })
855            .map_err(|e| Error::new(format!("main thread dispatch: {e}")))?;
856
857        rx.recv()
858            .map_err(|_| Error::new("close_widget_window: main thread dropped"))?
859    }
860
861    fn close_widget_window_on_main(app: &AppHandle<R>, label: &str) -> crate::Result<bool> {
862        if let Some(win) = app.get_webview_window(label) {
863            win.close().map_err(|e| Error::new(e.to_string()))?;
864            Ok(true)
865        } else {
866            Ok(false)
867        }
868    }
869
870    /// Register native widget provider ids.
871    ///
872    /// | Platform | Behaviour |
873    /// |---|---|
874    /// | Android | stores fully-qualified provider class names |
875    /// | iOS / macOS | stores WidgetKit kind strings (advisory) |
876    /// | Desktop | **no-op**, accepted for API symmetry |
877    pub fn set_register_widget(&self, _widgets: Vec<String>) -> crate::Result<bool> {
878        // Desktop has no native provider registry; accept for API symmetry.
879        Ok(true)
880    }
881
882    pub fn reload_all_timelines(&self) -> crate::Result<bool> {
883        // WidgetKit reads the files: they must hold every write made before this call.
884        #[cfg(target_os = "macos")]
885        self.flush_store();
886        self.reload_all_timelines_now()
887    }
888
889    fn reload_all_timelines_now(&self) -> crate::Result<bool> {
890        let _ = self.app.emit("widget-reload", "all");
891        #[cfg(target_os = "macos")]
892        if !unsafe { macos_widget_reload_all() } {
893            return Err(Error::Unsupported(
894                "WidgetKit reload needs macOS 11+".into(),
895            ));
896        }
897        Ok(true)
898    }
899
900    pub fn reload_timelines(&self, of_kind: &str) -> crate::Result<bool> {
901        #[cfg(target_os = "macos")]
902        self.flush_store();
903        let _ = self.app.emit("widget-reload", of_kind);
904        #[cfg(target_os = "macos")]
905        {
906            let c = CString::new(of_kind)
907                .map_err(|_| Error::new("reload_timelines: kind contains a NUL byte"))?;
908            if !unsafe { macos_widget_reload_kind(c.as_ptr()) } {
909                return Err(Error::Unsupported(
910                    "WidgetKit reload needs macOS 11+".into(),
911                ));
912            }
913        }
914        Ok(true)
915    }
916
917    /// Request that the OS show the "add widget" / pin UI.
918    ///
919    /// | Platform | Behaviour |
920    /// |---|---|
921    /// | Android | opens the pin-widget flow |
922    /// | iOS / macOS | no native pin API — returns `Ok` from the mobile bridge |
923    /// | Desktop | **error** — use [`Self::create_widget_window`] instead |
924    pub fn request_widget(&self) -> crate::Result<bool> {
925        Err(Error::Unsupported(
926            "Use create_widget_window on desktop".into(),
927        ))
928    }
929
930    /// Load a [`WidgetConfig`] JSON file from disk, then apply it like [`Self::set_widget_config`].
931    pub fn set_widget_config_from_path(
932        &self,
933        path: impl AsRef<std::path::Path>,
934        group: &str,
935        widget_id: &str,
936        skip_reload: bool,
937    ) -> crate::Result<ApplyOutcome> {
938        if widget_id.is_empty() {
939            return Err(Error::new("widget_id must not be empty"));
940        }
941        let path = path.as_ref();
942        let path_str = path
943            .to_str()
944            .ok_or_else(|| Error::new(format!("invalid UTF-8 path: {}", path.display())))?;
945        let config = crate::commands::load_widget_config_from_path(path_str)?;
946        self.set_widget_config(&config, group, widget_id, skip_reload)
947    }
948
949    pub fn set_widget_config(
950        &self,
951        config: &WidgetConfig,
952        group: &str,
953        widget_id: &str,
954        skip_reload: bool,
955    ) -> crate::Result<ApplyOutcome> {
956        if widget_id.is_empty() {
957            return Err(Error::new("widget_id must not be empty"));
958        }
959        self.remember_group(group);
960
961        let mut config =
962            crate::normalize::normalize(config, crate::capabilities::WidgetPlatform::current())
963                .config;
964        crate::image_prefetch::prefetch_remote_images(&mut config);
965
966        let json = serde_json::to_string(&config)
967            .map_err(|e| Error::new(format!("serialize config: {e}")))?;
968        let compact: serde_json::Value = serde_json::from_str(&json)
969            .map_err(|e| Error::new(format!("serialize config: {e}")))?;
970        let hash = config_content_hash(&json);
971        let key = config_key(widget_id);
972
973        let existing = self.get_items(&key, group)?;
974        let changed = existing.as_deref() != Some(json.as_str());
975
976        // Desktop webview always gets a push so an open window stays in sync
977        // even when store bytes were already identical.
978        let _ = self.app.emit(
979            "widget-config-push",
980            serde_json::json!({
981                "group": group,
982                "widgetId": widget_id,
983                "config": compact,
984            }),
985        );
986
987        if !changed {
988            let outcome = ApplyOutcome::unchanged(hash);
989            self.trace.push(TraceEvent::ConfigSet {
990                widget_id: widget_id.into(),
991                nonce: 0,
992                bytes: json.len(),
993                changed: false,
994                skip: Some(SkipReason::Unchanged { hash }),
995            });
996            self.trace.push(TraceEvent::Reload {
997                performed: false,
998                reason: outcome.reload.clone(),
999            });
1000            self.maybe_flush_trace();
1001            return Ok(outcome);
1002        }
1003
1004        crate::capabilities::log_capabilities(&config);
1005        let t0 = std::time::Instant::now();
1006        self.set_items(&key, &json, group)?;
1007        let write_ms = t0.elapsed().as_millis() as u32;
1008        let transports = self.written_transport_names(group);
1009        let nonce = self
1010            .get_items("__meta_nonce__", group)
1011            .ok()
1012            .flatten()
1013            .and_then(|s| s.parse().ok())
1014            .unwrap_or(0);
1015
1016        self.trace.push(TraceEvent::ConfigSet {
1017            widget_id: widget_id.into(),
1018            nonce,
1019            bytes: json.len(),
1020            changed: true,
1021            skip: None,
1022        });
1023        for name in &transports {
1024            self.trace.push(TraceEvent::Write {
1025                transport: name.clone(),
1026                ok: true,
1027                duration_ms: write_ms,
1028                error: None,
1029            });
1030        }
1031
1032        #[cfg(target_os = "windows")]
1033        self.write_board_card(&config, group, widget_id)?;
1034
1035        let reload = if skip_reload {
1036            ReloadOutcome::Skipped {
1037                why: "skip_reload".into(),
1038            }
1039        } else {
1040            // macOS: the writer reloads right after this config is in the files.
1041            #[cfg(target_os = "macos")]
1042            let reloaded = {
1043                self.queue_persist(group, None, true);
1044                Ok::<bool, Error>(true)
1045            };
1046            #[cfg(not(target_os = "macos"))]
1047            let reloaded = self.reload_all_timelines();
1048            match reloaded {
1049                Ok(_) => ReloadOutcome::Ok,
1050                Err(e) => ReloadOutcome::Failed {
1051                    error: e.to_string(),
1052                },
1053            }
1054        };
1055        self.trace.push(TraceEvent::Reload {
1056            performed: matches!(reload, ReloadOutcome::Ok),
1057            reason: reload.clone(),
1058        });
1059
1060        #[cfg(target_os = "macos")]
1061        self.ensure_action_poller();
1062
1063        self.maybe_flush_trace();
1064
1065        Ok(ApplyOutcome {
1066            written: true,
1067            reload,
1068            transports,
1069            skip: None,
1070        })
1071    }
1072
1073    /// Widgets Board: one Adaptive Card with a body per size (`$when` on
1074    /// `$host.widgetSize`), resolved for now (entries, `{{…}}`, `setWidgetData`). While the
1075    /// app runs, the card is rebuilt at the next scheduled entry.
1076    #[cfg(target_os = "windows")]
1077    pub(crate) fn write_board_card(
1078        &self,
1079        config: &WidgetConfig,
1080        group: &str,
1081        widget_id: &str,
1082    ) -> crate::Result<()> {
1083        let (id, instance) = match widget_id.split_once(store::INSTANCE_SEPARATOR) {
1084            Some((id, inst)) => (id, Some(inst)),
1085            None => (widget_id, None),
1086        };
1087        let read_json = |prefix: &str| {
1088            store::lookup_keys(prefix, id, instance)
1089                .into_iter()
1090                .find_map(|k| self.get_items(&k, group).ok().flatten())
1091                .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
1092        };
1093        let host_data = read_json(store::DATA_KEY_PREFIX);
1094        let fetched = read_json(store::FETCHED_KEY_PREFIX);
1095        let data = crate::resolve::merge_data([host_data.as_ref(), fetched.as_ref()]);
1096        let locale = windows_locale();
1097        let now = store::now_ms();
1098        let raw = serde_json::to_value(config).unwrap_or_default();
1099        let last_fetch = self.board_fetch_source(&raw, group, widget_id, instance, &locale, now);
1100        let Some((result, _)) =
1101            crate::adaptive_card::board_card(config, data.as_ref(), now, &locale, instance)
1102        else {
1103            return Ok(());
1104        };
1105        let template = serde_json::to_string(&result.card)
1106            .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?;
1107        self.set_items(
1108            &crate::adaptive_card::ac_template_key(widget_id),
1109            &template,
1110            group,
1111        )?;
1112        self.set_items(&crate::adaptive_card::ac_data_key(widget_id), "{}", group)?;
1113        // One timer per widget: every rebuild bumps the generation, an older sleeper exits.
1114        let gen = {
1115            let mut timers = self.board_timers.lock().unwrap();
1116            let g = timers.entry(widget_id.to_string()).or_insert(0);
1117            *g += 1;
1118            *g
1119        };
1120        if let Some(at) = crate::resolve::next_wake_ms(&raw, now, last_fetch) {
1121            let app = self.app.clone();
1122            let (config, group, widget_id) =
1123                (config.clone(), group.to_string(), widget_id.to_string());
1124            std::thread::spawn(move || {
1125                std::thread::sleep(std::time::Duration::from_millis(
1126                    at.saturating_sub(store::now_ms()) + 50,
1127                ));
1128                if let Some(w) = app.try_state::<Widget<R>>() {
1129                    if w.board_timers.lock().unwrap().get(&widget_id) != Some(&gen) {
1130                        return;
1131                    }
1132                    let current = w.get_widget_config(&group, &widget_id).ok().flatten();
1133                    let cfg = current.unwrap_or(config);
1134                    let _ = w.write_board_card(&cfg, &group, &widget_id);
1135                }
1136            });
1137        }
1138        Ok(())
1139    }
1140
1141    /// Widgets Board cannot fetch: while the app runs, the host fetches the config's
1142    /// `source` when due (in a thread), stores it as `fetched:{id}` and rebuilds the card.
1143    /// Returns the time of the last fetch attempt (for the wake-up timer).
1144    #[cfg(target_os = "windows")]
1145    fn board_fetch_source(
1146        &self,
1147        raw: &serde_json::Value,
1148        group: &str,
1149        widget_id: &str,
1150        instance: Option<&str>,
1151        locale: &str,
1152        now: u64,
1153    ) -> Option<u64> {
1154        let ctx = crate::resolve::ResolveContext {
1155            now_ms: now,
1156            locale,
1157            instance,
1158            ..Default::default()
1159        };
1160        let req = crate::resolve::source_request(raw, &ctx)?;
1161        let mut attempts = self.board_fetches.lock().unwrap();
1162        let last = attempts.get(widget_id).copied();
1163        let due = last.map_or(true, |t| now >= t + u64::from(req.refresh_minutes) * 60_000);
1164        if !due {
1165            return last;
1166        }
1167        attempts.insert(widget_id.to_string(), now);
1168        drop(attempts);
1169        #[cfg(feature = "image-prefetch")]
1170        {
1171            let app = self.app.clone();
1172            let (group, widget_id) = (group.to_string(), widget_id.to_string());
1173            std::thread::spawn(move || {
1174                let Some(text) = fetch_source_text(&req) else {
1175                    log::warn!("widget source fetch failed for {widget_id}");
1176                    return;
1177                };
1178                if let Some(w) = app.try_state::<Widget<R>>() {
1179                    let key = format!("{}{widget_id}", store::FETCHED_KEY_PREFIX);
1180                    if w.set_items(&key, &text, &group).is_ok() {
1181                        if let Ok(Some(cfg)) = w.get_widget_config(&group, &widget_id) {
1182                            let _ = w.write_board_card(&cfg, &group, &widget_id);
1183                        }
1184                    }
1185                }
1186            });
1187        }
1188        #[cfg(not(feature = "image-prefetch"))]
1189        let _ = (group, req);
1190        Some(now)
1191    }
1192
1193    /// `setWidgetData` changed `{{data.…}}`: rebuild what the host renders itself.
1194    pub fn data_changed(&self, group: &str, widget_id: &str, instance: Option<&str>) {
1195        #[cfg(target_os = "windows")]
1196        {
1197            let id = store::scoped_id(widget_id, instance);
1198            let cfg = self
1199                .get_widget_config(group, &id)
1200                .ok()
1201                .flatten()
1202                .or_else(|| self.get_widget_config(group, widget_id).ok().flatten());
1203            if let Some(cfg) = cfg {
1204                let _ = self.write_board_card(&cfg, group, &id);
1205            }
1206        }
1207        #[cfg(not(target_os = "windows"))]
1208        let _ = (group, widget_id, instance);
1209    }
1210
1211    /// Names of transports that hold the current map after a write.
1212    fn written_transport_names(&self, group: &str) -> Vec<String> {
1213        #[cfg(target_os = "macos")]
1214        {
1215            let _ = group;
1216            vec![self.macos_driver.name().to_string()]
1217        }
1218        #[cfg(not(target_os = "macos"))]
1219        {
1220            let _ = group;
1221            vec!["file".into()]
1222        }
1223    }
1224
1225    pub fn get_widget_config(
1226        &self,
1227        group: &str,
1228        widget_id: &str,
1229    ) -> crate::Result<Option<WidgetConfig>> {
1230        if widget_id.is_empty() {
1231            return Err(Error::new("widget_id must not be empty"));
1232        }
1233        let raw = self.get_items(&config_key(widget_id), group)?;
1234        match raw {
1235            Some(json) => {
1236                let config: WidgetConfig = serde_json::from_str(&json)
1237                    .map_err(|e| Error::new(format!("parse config: {e}")))?;
1238                Ok(Some(config))
1239            }
1240            None => Ok(None),
1241        }
1242    }
1243
1244    /// Read configured driver + merge with in-memory if newer.
1245    #[cfg(target_os = "macos")]
1246    fn macos_driver_map(&self, group: &str) -> crate::Result<DataMap> {
1247        let mut maps = Vec::new();
1248        if let Some(disk) = self.macos_driver.read() {
1249            maps.push(disk);
1250        }
1251        let path = self.storage_path(group)?;
1252        let mut store = self.store.lock().unwrap();
1253        let map = Self::load_map_locked(&mut store, &path, group);
1254        maps.push(map.clone());
1255        let mut freshest = store::pick_freshest(maps);
1256        if store::map_nonce(&freshest) > store::map_nonce(map) {
1257            *map = freshest.clone();
1258        }
1259        // Host writes still on their way to the files win over an extension map read
1260        // in between.
1261        self.persist.overlay(group, &mut freshest);
1262        Ok(freshest)
1263    }
1264
1265    /// Drain pending actions for a group. On macOS the whole read-drain-write runs under
1266    /// the App Group file lock the widget extension appends under.
1267    pub fn poll_pending_actions(
1268        &self,
1269        group: &str,
1270    ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
1271        #[cfg(target_os = "macos")]
1272        return crate::macos_transport::with_store_lock(group, || {
1273            self.drain_pending_actions(group)
1274        });
1275        #[cfg(not(target_os = "macos"))]
1276        self.drain_pending_actions(group)
1277    }
1278
1279    fn drain_pending_actions(
1280        &self,
1281        group: &str,
1282    ) -> crate::Result<Vec<crate::WidgetActionEnvelope>> {
1283        self.remember_group(group);
1284
1285        // Every 30 s also the siblings (taps the extension fanned out there); else only the
1286        // working channel, so a 100 ms poll is a `stat` of one file.
1287        #[cfg(target_os = "macos")]
1288        let sweep = self.sweep_due(group);
1289        #[cfg(target_os = "macos")]
1290        let polled: Vec<Arc<dyn Transport>> = if sweep {
1291            crate::macos_transport::all_transports(group)
1292        } else {
1293            self.hot_transports(group)
1294        };
1295        #[cfg(target_os = "macos")]
1296        let disk_maps: Vec<DataMap> = {
1297            let mut maps = Vec::new();
1298            for t in polled.iter() {
1299                if let Some(m) = t.read() {
1300                    maps.push(m);
1301                }
1302            }
1303            maps
1304        };
1305        #[cfg(not(target_os = "macos"))]
1306        let disk_maps: Vec<DataMap> = {
1307            let path = self.storage_path(group)?;
1308            if path.exists() {
1309                fs::read_to_string(&path)
1310                    .ok()
1311                    .and_then(|s| serde_json::from_str(&s).ok())
1312                    .into_iter()
1313                    .collect()
1314            } else {
1315                Vec::new()
1316            }
1317        };
1318
1319        let freshest = store::pick_freshest(disk_maps);
1320
1321        let mut store = self.store.lock().unwrap();
1322        let path = self.storage_path(group)?;
1323        let map = Self::load_map_locked(&mut store, &path, group);
1324
1325        if store::map_nonce(&freshest) > store::map_nonce(map) {
1326            *map = freshest;
1327        }
1328
1329        #[cfg(target_os = "macos")]
1330        let actions = {
1331            let harvested = crate::macos_transport::harvest_pending_in(&polled);
1332            if !harvested.is_empty() {
1333                harvested
1334            } else {
1335                parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()))
1336            }
1337        };
1338        #[cfg(not(target_os = "macos"))]
1339        let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str()));
1340
1341        if actions.is_empty() {
1342            return Ok(Vec::new());
1343        }
1344
1345        map.insert(PENDING_ACTIONS_KEY.into(), "[]".into());
1346        // A queued host write of the queue would bring the drained taps back.
1347        #[cfg(target_os = "macos")]
1348        self.persist.forget(group, PENDING_ACTIONS_KEY);
1349        #[cfg(target_os = "macos")]
1350        {
1351            let floor = crate::macos_transport::max_nonce_in(&self.hot_transports(group));
1352            store::touch_meta_above(map, floor);
1353        }
1354        #[cfg(not(target_os = "macos"))]
1355        {
1356            store::touch_meta(map);
1357        }
1358        let snapshot = map.clone();
1359        drop(store);
1360
1361        #[cfg(target_os = "windows")]
1362        persist_windows_shared_map(&self.storage_path(group)?, &snapshot, &actions)?;
1363        #[cfg(not(target_os = "windows"))]
1364        self.persist_map(group, &snapshot)?;
1365        let _ = self.app.emit("widget-update", group);
1366        #[cfg(target_os = "macos")]
1367        {
1368            crate::macos_transport::clear_pending_actions_in(&polled, &actions);
1369        }
1370
1371        Ok(actions)
1372    }
1373
1374    pub fn report_receipt(&self, receipt: WidgetRenderReceipt) -> crate::Result<bool> {
1375        self.remember_group(&receipt.group);
1376        let trigger = receipt.trigger.clone().unwrap_or_else(|| "timeline".into());
1377        let lag_ms = {
1378            let since = self.trace.list_since(None);
1379            since
1380                .iter()
1381                .rev()
1382                .find_map(|e| match &e.event {
1383                    TraceEvent::ConfigSet { nonce, .. }
1384                        if *nonce == receipt.nonce && *nonce > 0 =>
1385                    {
1386                        Some(receipt.ts.saturating_sub(e.ts))
1387                    }
1388                    _ => None,
1389                })
1390                .unwrap_or(0)
1391        };
1392        self.trace.push(TraceEvent::Render {
1393            instance: receipt.instance.clone(),
1394            nonce: receipt.nonce,
1395            source: receipt.source.clone(),
1396            trigger,
1397            lag_ms,
1398            skipped: receipt.skipped.clone(),
1399        });
1400        self.receipts.upsert(receipt);
1401        if let Ok(dir) = self.app.path().app_data_dir() {
1402            let _ = self.receipts.save_to_path(&receipts_path(&dir));
1403        }
1404        self.maybe_flush_trace();
1405        Ok(true)
1406    }
1407
1408    pub fn get_widget_diagnostics(&self, group: &str) -> crate::Result<Vec<WidgetRenderReceipt>> {
1409        Ok(self.receipts.list(group))
1410    }
1411
1412    /// JavaScript drains the queue itself: queued taps may be emitted from now on.
1413    pub fn mark_listener_ready(&self) {
1414        self.listener_gate.open();
1415    }
1416
1417    /// Open widget windows, plus placements that rendered recently (macOS WidgetKit
1418    /// extension, Windows Widgets Board) according to their receipts.
1419    pub fn get_active_widgets(
1420        &self,
1421        group: &str,
1422    ) -> crate::Result<Vec<crate::store::ActiveWidget>> {
1423        let open: Vec<crate::store::ActiveWidget> = {
1424            let mut windows = self.windows.lock().unwrap();
1425            windows.retain(|label, _| self.app.get_webview_window(label).is_some());
1426            // Live-activity windows are not placed widgets.
1427            windows
1428                .values()
1429                .filter(|a| !a.widget_id.starts_with("activity."))
1430                .cloned()
1431                .collect()
1432        };
1433        #[allow(unused_mut)]
1434        let mut open = open;
1435        // Widgets Board instances the provider tracks (`board_widgets`, Store.cs).
1436        #[cfg(target_os = "windows")]
1437        if let Ok(Some(raw)) = self.get_items(store::BOARD_WIDGETS_KEY, group) {
1438            open.extend(store::parse_board_widgets(&raw));
1439        }
1440        let seen = store::active_from_receipts(
1441            &self.receipts.list(group),
1442            store::now_ms(),
1443            store::ACTIVE_RECEIPT_MAX_AGE_MS,
1444        );
1445        Ok(store::merge_active(open, seen))
1446    }
1447
1448    /// Desktop surface, by `plugins.widgets.activitySurface`:
1449    /// - `"island"` (default): a black pill at the top centre of the primary display that
1450    ///   wraps the notch on notched Macs and expands on hover ([`island`]);
1451    /// - `"window"`: a small always-on-top window in the top-right corner drawing the
1452    ///   activity layout;
1453    /// - `"notification"` (feature `notify`, Linux / Windows): a system notification.
1454    ///
1455    /// When no window can open, a system notification instead (feature `notify`).
1456    pub fn start_activity(
1457        &self,
1458        content: crate::models::ActivityContent,
1459        group: &str,
1460    ) -> crate::Result<crate::models::ActivityHandle> {
1461        let id = content.id.clone();
1462        self.write_activity(&content, group)?;
1463        // A start always applies; a fresh one forgets a timestamp left by a failed start.
1464        let running = self.activities.lock().unwrap().contains_key(&id);
1465        match content.timestamp {
1466            Some(ts) => {
1467                self.activity_ts.lock().unwrap().insert(id.clone(), ts);
1468            }
1469            None if !running => {
1470                self.activity_ts.lock().unwrap().remove(&id);
1471            }
1472            None => {}
1473        }
1474        let handle = crate::models::ActivityHandle {
1475            id: id.clone(),
1476            surface: "desktop".into(),
1477            push_token: None,
1478        };
1479        let notified = crate::models::ActivityHandle {
1480            surface: "notification".into(),
1481            ..handle.clone()
1482        };
1483        // Already running: the config push redraws its window in place.
1484        if self.activities.lock().unwrap().contains_key(&id) {
1485            if self.island.lock().unwrap().contains(&id) {
1486                self.island.lock().unwrap().upsert(content, group);
1487                self.island_refresh()?;
1488                return Ok(handle);
1489            }
1490            if self.notifier.has(&id) {
1491                self.notify_activity(&content)?;
1492                return Ok(notified);
1493            }
1494            return Ok(handle);
1495        }
1496        let surface = self.cfg.activity_surface.as_deref().unwrap_or("island");
1497        if surface == "notification" && notify::Notifier::AVAILABLE {
1498            match self.notify_activity(&content) {
1499                Ok(()) => {
1500                    self.activities
1501                        .lock()
1502                        .unwrap()
1503                        .insert(id, group.to_string());
1504                    return Ok(notified);
1505                }
1506                Err(e) => log::warn!("widgets: activity notification failed ({e}); using a window"),
1507            }
1508        }
1509        if surface != "window" && surface != "notification" {
1510            self.island.lock().unwrap().upsert(content.clone(), group);
1511            self.activities
1512                .lock()
1513                .unwrap()
1514                .insert(id.clone(), group.to_string());
1515            match self.island_refresh() {
1516                Ok(()) => return Ok(handle),
1517                Err(e) => {
1518                    self.island.lock().unwrap().remove(&id);
1519                    self.activities.lock().unwrap().remove(&id);
1520                    if !notify::Notifier::AVAILABLE {
1521                        return Err(e);
1522                    }
1523                    log::warn!("widgets: activity island failed ({e}); using a notification");
1524                    self.notify_activity(&content)?;
1525                    self.activities
1526                        .lock()
1527                        .unwrap()
1528                        .insert(id, group.to_string());
1529                    return Ok(notified);
1530                }
1531            }
1532        }
1533        let (x, y) = self.activity_origin();
1534        let opened = self.create_widget_window(WidgetWindowConfig {
1535            label: activity_window_label(&id),
1536            url: None,
1537            width: ACTIVITY_WINDOW_SIZE.0,
1538            height: ACTIVITY_WINDOW_SIZE.1,
1539            x,
1540            y,
1541            always_on_top: true,
1542            skip_taskbar: true,
1543            group: Some(group.to_string()),
1544            widget_id: Some(crate::models::activity_widget_id(&id)),
1545            size: Some("medium".into()),
1546            // Stacked by `activity_origin`, not where a previous run was dragged.
1547            remember_position: Some(false),
1548            ..Default::default()
1549        });
1550        let handle = match opened {
1551            Ok(_) => handle,
1552            // No window possible (headless session, …): fall back to a notification.
1553            Err(e) if notify::Notifier::AVAILABLE => {
1554                log::warn!("widgets: activity window failed ({e}); using a notification");
1555                self.notify_activity(&content)?;
1556                notified
1557            }
1558            Err(e) => return Err(e),
1559        };
1560        self.activities
1561            .lock()
1562            .unwrap()
1563            .insert(id, group.to_string());
1564        Ok(handle)
1565    }
1566
1567    pub fn update_activity(
1568        &self,
1569        content: crate::models::ActivityContent,
1570        group: &str,
1571    ) -> crate::Result<bool> {
1572        if !self.activities.lock().unwrap().contains_key(&content.id) {
1573            return Err(Error::new(format!(
1574                "activity \"{}\" is not running",
1575                content.id
1576            )));
1577        }
1578        // Out of order: an update older than the last applied one is dropped (`false`).
1579        if let Some(ts) = content.timestamp {
1580            let mut last = self.activity_ts.lock().unwrap();
1581            if last.get(&content.id).is_some_and(|prev| ts < *prev) {
1582                return Ok(false);
1583            }
1584            last.insert(content.id.clone(), ts);
1585        }
1586        // Cheap on macOS too: the store write is queued (see `PersistQueue`).
1587        self.write_activity(&content, group)?;
1588        if self.notifier.has(&content.id) {
1589            self.notify_activity(&content)?;
1590        }
1591        let on_island = self.island.lock().unwrap().contains(&content.id);
1592        if on_island {
1593            self.island.lock().unwrap().upsert(content, group);
1594            self.island_refresh()?;
1595        }
1596        Ok(true)
1597    }
1598
1599    fn notify_activity(&self, content: &crate::models::ActivityContent) -> crate::Result<()> {
1600        let config = self.app.config();
1601        let app_name = if cfg!(target_os = "windows") {
1602            // Toast AppUserModelID.
1603            config.identifier.as_str()
1604        } else {
1605            config.product_name.as_deref().unwrap_or(&config.identifier)
1606        };
1607        self.notifier.show(app_name, content)
1608    }
1609
1610    pub fn end_activity(&self, id: &str, dismiss_after_ms: Option<u64>) -> crate::Result<bool> {
1611        if self.activities.lock().unwrap().remove(id).is_none() {
1612            return Ok(false);
1613        }
1614        self.activity_ts.lock().unwrap().remove(id);
1615        let label = activity_window_label(id);
1616        let on_island = self.island.lock().unwrap().contains(id);
1617        match dismiss_after_ms.filter(|ms| *ms > 0) {
1618            None if on_island => {
1619                self.island.lock().unwrap().remove(id);
1620                self.island_refresh()?;
1621                Ok(true)
1622            }
1623            None => {
1624                self.notifier.close(id);
1625                self.close_widget_window(&label)
1626            }
1627            Some(ms) => {
1628                let app = self.app.clone();
1629                let id = id.to_string();
1630                std::thread::spawn(move || {
1631                    std::thread::sleep(std::time::Duration::from_millis(ms));
1632                    // Started again during the delay: the window belongs to the new run.
1633                    if app
1634                        .try_state::<Widget<R>>()
1635                        .is_some_and(|w| w.activities.lock().unwrap().contains_key(&id))
1636                    {
1637                        return;
1638                    }
1639                    if let Some(w) = app.try_state::<Widget<R>>() {
1640                        w.notifier.close(&id);
1641                        if on_island {
1642                            w.island.lock().unwrap().remove(&id);
1643                            if let Err(e) = w.island_refresh() {
1644                                log::warn!("widgets: activity island: {e}");
1645                            }
1646                            return;
1647                        }
1648                    }
1649                    let handle = app.clone();
1650                    let _ = app.run_on_main_thread(move || {
1651                        if let Some(w) = handle.get_webview_window(&label) {
1652                            let _ = w.close();
1653                        }
1654                    });
1655                });
1656                Ok(true)
1657            }
1658        }
1659    }
1660
1661    /// Live Updates promotion is an Android gate; desktop activities need no permission.
1662    pub fn can_promote_activities(&self) -> crate::Result<bool> {
1663        Ok(true)
1664    }
1665
1666    pub fn open_activity_promotion_settings(&self) -> crate::Result<bool> {
1667        Ok(false)
1668    }
1669
1670    /// Push-to-start is an ActivityKit (iOS) feature.
1671    pub fn activity_push_to_start_token(&self) -> crate::Result<Option<String>> {
1672        Ok(None)
1673    }
1674
1675    pub fn list_activities(&self) -> crate::Result<Vec<String>> {
1676        let mut ids: Vec<String> = self.activities.lock().unwrap().keys().cloned().collect();
1677        ids.sort();
1678        Ok(ids)
1679    }
1680
1681    fn write_activity(
1682        &self,
1683        content: &crate::models::ActivityContent,
1684        group: &str,
1685    ) -> crate::Result<()> {
1686        let layout = content.effective_layout();
1687        let cfg = WidgetConfig {
1688            small: Some(layout.clone()),
1689            medium: Some(layout),
1690            ..Default::default()
1691        };
1692        self.set_widget_config(
1693            &cfg,
1694            group,
1695            &crate::models::activity_widget_id(&content.id),
1696            true,
1697        )?;
1698        Ok(())
1699    }
1700
1701    /// Run `f` on the main thread (AppKit / GTK calls) and wait for its result.
1702    fn on_main<T: Send + 'static>(
1703        &self,
1704        f: impl FnOnce(&AppHandle<R>) -> T + Send + 'static,
1705    ) -> Option<T> {
1706        #[cfg(all(target_os = "linux", feature = "linux"))]
1707        if gtk::glib::MainContext::default().is_owner() {
1708            return Some(f(&self.app));
1709        }
1710        if on_main_thread() {
1711            return Some(f(&self.app));
1712        }
1713        let (tx, rx) = std::sync::mpsc::sync_channel(1);
1714        let app = self.app.clone();
1715        self.app
1716            .run_on_main_thread(move || {
1717                let _ = tx.send(f(&app));
1718            })
1719            .ok()?;
1720        rx.recv().ok()
1721    }
1722
1723    /// Bring the island window in line with [`Self::island`]: open it, resize / move it
1724    /// to the current shapes and send the page the new view (`activity-island`), or close
1725    /// it when no activity is left.
1726    fn island_refresh(&self) -> crate::Result<()> {
1727        let screen = self
1728            .on_main(island::screen)
1729            .flatten()
1730            .unwrap_or(island::Screen::FALLBACK);
1731        let (layout, view, group) = {
1732            let isl = self.island.lock().unwrap();
1733            if isl.is_empty() {
1734                drop(isl);
1735                return self.close_widget_window(island::LABEL).map(|_| ());
1736            }
1737            (isl.layout(&screen), isl.view(&screen), isl.front_group())
1738        };
1739        // Before the early return: the poller ends with the last activity, and a new one
1740        // can arrive while the old window still exists (a quick switch).
1741        let start_poller = !std::mem::replace(&mut self.island.lock().unwrap().polling, true);
1742        if start_poller {
1743            let app = self.app.clone();
1744            std::thread::spawn(move || Self::island_hover_poller(app));
1745        }
1746        if let Some(win) = self.app.get_webview_window(island::LABEL) {
1747            island::place(&win, layout.window);
1748            // Global: the page listens with target `Any`, which `emit_to(label)` skips.
1749            let _ = self.app.emit("activity-island", &view);
1750            return Ok(());
1751        }
1752        let r = layout.window;
1753        self.create_widget_window(WidgetWindowConfig {
1754            label: island::LABEL.into(),
1755            url: None,
1756            width: r.w,
1757            height: r.h,
1758            x: Some(r.x),
1759            y: Some(r.y),
1760            always_on_top: true,
1761            skip_taskbar: true,
1762            focusable: Some(false),
1763            all_workspaces: Some(true),
1764            shadow: Some(false),
1765            group,
1766            widget_id: Some(island::WIDGET_ID.into()),
1767            size: Some("medium".into()),
1768            remember_position: Some(false),
1769            ..Default::default()
1770        })
1771        .map(|_| ())
1772    }
1773
1774    /// Hover from the cursor position: a webview in an inactive app gets no mouse-enter
1775    /// on macOS (WKWebView tracks only in the key window), so the host samples the global
1776    /// cursor every 80 ms while the island exists. Ends with the last activity.
1777    fn island_hover_poller(app: AppHandle<R>) {
1778        let mut outside = 0;
1779        loop {
1780            std::thread::sleep(std::time::Duration::from_millis(80));
1781            let Some(w) = app.try_state::<Widget<R>>() else {
1782                return;
1783            };
1784            let expanded = {
1785                let mut isl = w.island.lock().unwrap();
1786                if isl.is_empty() {
1787                    isl.polling = false;
1788                    return;
1789                }
1790                isl.expanded
1791            };
1792            let Some(win) = app.get_webview_window(island::LABEL) else {
1793                continue;
1794            };
1795            // No global cursor (Wayland): the page's own hover / click handles it.
1796            let (Ok(p), Ok(pos), Ok(size)) = (
1797                app.cursor_position(),
1798                win.outer_position(),
1799                win.outer_size(),
1800            ) else {
1801                continue;
1802            };
1803            let inside = p.x >= pos.x as f64
1804                && p.y >= pos.y as f64
1805                && p.x < pos.x as f64 + size.width as f64
1806                && p.y < pos.y as f64 + size.height as f64;
1807            let r = match island::hover_step(inside, expanded, &mut outside) {
1808                island::Hover::Nothing => Ok(None),
1809                island::Hover::Expand => w.activity_island(Some(true), None),
1810                island::Hover::AskCollapse => {
1811                    let screen = w
1812                        .on_main(island::screen)
1813                        .flatten()
1814                        .unwrap_or(island::Screen::FALLBACK);
1815                    let mut view = w.island.lock().unwrap().view(&screen);
1816                    view.expanded = false;
1817                    let _ = app.emit("activity-island", &view);
1818                    Ok(None)
1819                }
1820                island::Hover::Collapse => w.activity_island(Some(false), None),
1821            };
1822            if let Err(e) = r {
1823                log::debug!("widgets: island hover: {e}");
1824            }
1825        }
1826    }
1827
1828    /// Island page → host: expand / collapse (hover, click) or bring a minimal activity to
1829    /// the pill. Returns what the island shows (`None` without an island).
1830    pub fn activity_island(
1831        &self,
1832        expanded: Option<bool>,
1833        focus: Option<&str>,
1834    ) -> crate::Result<Option<serde_json::Value>> {
1835        let changed = {
1836            let mut isl = self.island.lock().unwrap();
1837            if isl.is_empty() {
1838                return Ok(None);
1839            }
1840            let mut changed = focus.is_some_and(|id| isl.focus(id));
1841            if let Some(e) = expanded.filter(|e| *e != isl.expanded) {
1842                isl.expanded = e;
1843                changed = true;
1844            }
1845            changed
1846        };
1847        if changed {
1848            self.island_refresh()?;
1849        }
1850        let screen = self
1851            .on_main(island::screen)
1852            .flatten()
1853            .unwrap_or(island::Screen::FALLBACK);
1854        let view = self.island.lock().unwrap().view(&screen);
1855        Ok(Some(serde_json::to_value(view)?))
1856    }
1857
1858    /// Top-right corner of the primary monitor, below the menu bar, stacked per activity.
1859    fn activity_origin(&self) -> (Option<f64>, Option<f64>) {
1860        let Some(m) = island::primary_monitor(&self.app) else {
1861            return (None, None);
1862        };
1863        // Work area: below the macOS menu bar, clear of the Windows taskbar / Linux panels,
1864        // and offset by the monitor's own position in the virtual desktop.
1865        let scale = m.scale_factor();
1866        let area = m.work_area();
1867        let n = self.activities.lock().unwrap().len();
1868        let (x, y) = activity_slot(
1869            area.position.x as f64 / scale,
1870            area.position.y as f64 / scale,
1871            area.size.width as f64 / scale,
1872            n,
1873        );
1874        (Some(x), Some(y))
1875    }
1876
1877    pub fn get_widget_trace(
1878        &self,
1879        group: &str,
1880        since_ms: Option<u64>,
1881    ) -> crate::Result<WidgetTrace> {
1882        self.maybe_flush_trace();
1883        Ok(WidgetTrace {
1884            enabled: crate::trace::trace_enabled(),
1885            events: self.trace.list_since(since_ms),
1886            receipts: self.receipts.history(group),
1887        })
1888    }
1889
1890    pub fn flush_widget_trace(&self) -> crate::Result<bool> {
1891        if let Ok(dir) = self.app.path().app_data_dir() {
1892            self.trace.flush_to_path(&trace_path(&dir))?;
1893        }
1894        Ok(true)
1895    }
1896
1897    fn maybe_flush_trace(&self) {
1898        if !self.trace.needs_timed_flush() {
1899            return;
1900        }
1901        if let Ok(dir) = self.app.path().app_data_dir() {
1902            let _ = self.trace.flush_to_path(&trace_path(&dir));
1903        }
1904    }
1905
1906    #[cfg(target_os = "macos")]
1907    fn ensure_action_poller(&self) {
1908        let mut started = self.poller_started.lock().unwrap();
1909        if *started {
1910            return;
1911        }
1912        *started = true;
1913
1914        let groups_handle = self.app.clone();
1915        std::thread::spawn(move || loop {
1916            // A tap in the WidgetKit extension waits for this poll: keep it short. Cheap,
1917            // the store files are only re-read when `stat` says they changed.
1918            std::thread::sleep(std::time::Duration::from_millis(100));
1919            let Some(widget) = groups_handle.try_state::<Widget<R>>() else {
1920                continue;
1921            };
1922            widget.inner().maybe_flush_trace();
1923            if !widget.inner().listener_gate.is_open(store::now_ms()) {
1924                continue;
1925            }
1926            let groups = widget.inner().known_groups.lock().unwrap().clone();
1927            for group in groups {
1928                match widget.inner().poll_pending_actions(&group) {
1929                    Ok(actions) if !actions.is_empty() => {
1930                        widget.inner().trace.push(TraceEvent::Poll {
1931                            count: actions.len(),
1932                        });
1933                        for action in actions {
1934                            // A `set` tap in the WidgetKit extension: state, not an action.
1935                            match store::state_event(&action) {
1936                                Some(ev) => {
1937                                    let _ = groups_handle.emit("widget-state-changed", ev);
1938                                }
1939                                None => {
1940                                    let _ = groups_handle.emit("widget-action", action);
1941                                }
1942                            }
1943                        }
1944                    }
1945                    _ => {}
1946                }
1947            }
1948        });
1949    }
1950}
1951
1952// ─── macOS helpers ────────────────────────────────────────────────────────────
1953
1954/// Host writes waiting for the writer thread, per group. Writes made while the writer
1955/// is busy (a stalled Group Container call) fold into one file write: latest value per
1956/// key, one reload.
1957#[cfg(target_os = "macos")]
1958#[derive(Default)]
1959struct PersistQueue {
1960    jobs: Mutex<HashMap<String, PersistJob>>,
1961    /// Keys the writer has taken but not written yet, per group.
1962    inflight: Mutex<HashMap<String, Vec<(String, String)>>>,
1963    changed: std::sync::Condvar,
1964    started: Mutex<bool>,
1965}
1966
1967#[cfg(target_os = "macos")]
1968#[derive(Default)]
1969struct PersistJob {
1970    keys: Vec<(String, String)>,
1971    reload: bool,
1972}
1973
1974#[cfg(target_os = "macos")]
1975impl PersistQueue {
1976    fn push(&self, group: &str, kv: Option<(&str, &str)>, reload: bool) {
1977        let mut jobs = self.jobs.lock().unwrap();
1978        let job = jobs.entry(group.to_string()).or_default();
1979        if let Some((k, v)) = kv {
1980            job.keys.retain(|(old, _)| old != k);
1981            job.keys.push((k.to_string(), v.to_string()));
1982        }
1983        job.reload |= reload;
1984        self.changed.notify_all();
1985    }
1986
1987    /// Block until a group has work; it counts as busy until [`Self::done`].
1988    fn next(&self) -> (String, PersistJob) {
1989        let mut jobs = self.jobs.lock().unwrap();
1990        loop {
1991            if let Some(group) = jobs.keys().next().cloned() {
1992                let job = jobs.remove(&group).unwrap_or_default();
1993                self.inflight
1994                    .lock()
1995                    .unwrap()
1996                    .insert(group.clone(), job.keys.clone());
1997                return (group, job);
1998            }
1999            jobs = self.changed.wait(jobs).unwrap();
2000        }
2001    }
2002
2003    fn done(&self, group: &str) {
2004        let _jobs = self.jobs.lock().unwrap();
2005        self.inflight.lock().unwrap().remove(group);
2006        self.changed.notify_all();
2007    }
2008
2009    /// Unwritten host keys of `group`, oldest first (in flight, then queued).
2010    fn unwritten(&self, group: &str) -> Vec<(String, String)> {
2011        let jobs = self.jobs.lock().unwrap();
2012        let mut out = self
2013            .inflight
2014            .lock()
2015            .unwrap()
2016            .get(group)
2017            .cloned()
2018            .unwrap_or_default();
2019        if let Some(job) = jobs.get(group) {
2020            out.extend(job.keys.iter().cloned());
2021        }
2022        out
2023    }
2024
2025    /// Keys the writer took for `group` and has not written, minus forgotten ones.
2026    fn in_flight(&self, group: &str) -> Vec<(String, String)> {
2027        self.inflight
2028            .lock()
2029            .unwrap()
2030            .get(group)
2031            .cloned()
2032            .unwrap_or_default()
2033    }
2034
2035    /// A newer host write of `key` went to the files directly: drop the queued one.
2036    fn forget(&self, group: &str, key: &str) {
2037        let mut jobs = self.jobs.lock().unwrap();
2038        if let Some(job) = jobs.get_mut(group) {
2039            job.keys.retain(|(k, _)| k != key);
2040        }
2041        if let Some(keys) = self.inflight.lock().unwrap().get_mut(group) {
2042            keys.retain(|(k, _)| k != key);
2043        }
2044    }
2045
2046    fn overlay(&self, group: &str, map: &mut DataMap) {
2047        for (k, v) in self.unwritten(group) {
2048            map.insert(k, v);
2049        }
2050    }
2051
2052    fn wait_idle(&self) {
2053        let mut jobs = self.jobs.lock().unwrap();
2054        while !jobs.is_empty() || !self.inflight.lock().unwrap().is_empty() {
2055            jobs = self.changed.wait(jobs).unwrap();
2056        }
2057    }
2058}
2059
2060#[cfg(target_os = "macos")]
2061extern "C" {
2062    fn macos_widget_reload_all() -> bool;
2063    fn macos_widget_reload_kind(kind: *const std::ffi::c_char) -> bool;
2064}
2065
2066#[cfg(target_os = "macos")]
2067fn macos_shared_container(group: &str) -> Option<PathBuf> {
2068    crate::macos_transport::shared_container_dir(group)
2069}
2070
2071fn atomic_write(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
2072    let nanos = std::time::SystemTime::now()
2073        .duration_since(std::time::UNIX_EPOCH)
2074        .map(|d| d.as_nanos())
2075        .unwrap_or(0);
2076    let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
2077    fs::write(&tmp, data)?;
2078    match fs::rename(&tmp, path) {
2079        Ok(()) => Ok(()),
2080        Err(e) => {
2081            let _ = fs::remove_file(&tmp);
2082            Err(e)
2083        }
2084    }
2085}
2086
2087/// Windows Widgets Board provider and Rust host share one `widget_data.json`.
2088/// Match `WidgetStore.WithFileLock`: exclusive `.lock` + merge under it so
2089/// provider-enqueued `pending_actions` are not wiped by a concurrent host write.
2090#[cfg(target_os = "windows")]
2091fn persist_windows_shared_map(
2092    path: &PathBuf,
2093    map: &DataMap,
2094    drained: &[crate::WidgetActionEnvelope],
2095) -> crate::Result<()> {
2096    use std::fs::OpenOptions;
2097    use std::os::windows::fs::OpenOptionsExt;
2098    use std::thread;
2099    use std::time::Duration;
2100
2101    if let Some(parent) = path.parent() {
2102        fs::create_dir_all(parent).map_err(|e| Error::Io(e.to_string()))?;
2103    }
2104
2105    // Same path convention as C#: `{widget_data.json}.lock`
2106    let lock_path = PathBuf::from(format!("{}.lock", path.display()));
2107    let _lock = {
2108        let mut last_err = None;
2109        let mut held = None;
2110        for _ in 0..100 {
2111            let mut opts = OpenOptions::new();
2112            opts.read(true).write(true).create(true).share_mode(0); // FILE_SHARE_NONE
2113            match opts.open(&lock_path) {
2114                Ok(f) => {
2115                    held = Some(f);
2116                    break;
2117                }
2118                Err(e) => {
2119                    // ERROR_SHARING_VIOLATION (32) while the provider holds the lock.
2120                    if e.raw_os_error() == Some(32) {
2121                        last_err = Some(e);
2122                        thread::sleep(Duration::from_millis(20));
2123                        continue;
2124                    }
2125                    return Err(Error::Io(e.to_string()));
2126                }
2127            }
2128        }
2129        held.ok_or_else(|| {
2130            Error::Io(
2131                last_err
2132                    .map(|e| e.to_string())
2133                    .unwrap_or_else(|| "widget_data.json.lock busy".into()),
2134            )
2135        })?
2136    };
2137
2138    let mut merged: DataMap = if path.exists() {
2139        fs::read_to_string(path)
2140            .ok()
2141            .and_then(|s| serde_json::from_str(&s).ok())
2142            .unwrap_or_default()
2143    } else {
2144        DataMap::new()
2145    };
2146
2147    for (k, v) in map {
2148        if k == PENDING_ACTIONS_KEY {
2149            // The provider owns the queue: keep the disk copy minus what we drained.
2150            let rest = store::pending_minus_drained(merged.get(k).map(String::as_str), drained)?;
2151            merged.insert(k.clone(), rest);
2152            continue;
2153        }
2154        merged.insert(k.clone(), v.clone());
2155    }
2156
2157    // Keep host meta (already bumped) authoritative for this write.
2158    if let Some(n) = map.get(store::META_NONCE_KEY) {
2159        merged.insert(store::META_NONCE_KEY.into(), n.clone());
2160    }
2161    if let Some(t) = map.get(store::META_UPDATED_AT_KEY) {
2162        merged.insert(store::META_UPDATED_AT_KEY.into(), t.clone());
2163    }
2164
2165    let json = serde_json::to_string_pretty(&merged).map_err(|e| Error::new(e.to_string()))?;
2166    atomic_write(path, json.as_bytes()).map_err(|e| Error::Io(e.to_string()))?;
2167    Ok(())
2168}
2169
2170#[cfg(test)]
2171mod builtin_url_tests {
2172    use super::*;
2173    use proptest::prelude::*;
2174
2175    /// Inverse of `activity_window_label` (`__` → `_`, `_d` → `.`), None if not a label.
2176    fn decode_label(label: &str) -> Option<String> {
2177        let mut out = String::new();
2178        let mut it = label.strip_prefix("activity-")?.chars();
2179        while let Some(c) = it.next() {
2180            match c {
2181                '_' => match it.next()? {
2182                    '_' => out.push('_'),
2183                    'd' => out.push('.'),
2184                    _ => return None,
2185                },
2186                c => out.push(c),
2187            }
2188        }
2189        Some(out)
2190    }
2191
2192    proptest! {
2193        /// Query components: unreserved bytes pass through, everything else is `%XX`, so a
2194        /// value can never inject `&` / `=` and percent-decoding gives the input back; the
2195        /// built-in URL parses with exactly the four parameters given.
2196        /// Activity window labels decode back to the id, so two ids never share a window.
2197        #[test]
2198        fn query_encoding_and_activity_labels_are_reversible(
2199            v in "[a-zA-Z0-9._~&=/?# %-]{0,16}",
2200            id in "[a-z._]{0,10}",
2201        ) {
2202            let enc = encode_query_component(&v);
2203            prop_assert!(!enc.contains(['&', '=', '?', '#', ' ', '/']), "{}", enc);
2204            let decoded: Vec<u8> = {
2205                let b = enc.as_bytes();
2206                let mut out = Vec::new();
2207                let mut i = 0;
2208                while i < b.len() {
2209                    if b[i] == b'%' {
2210                        out.push(u8::from_str_radix(&enc[i + 1..i + 3], 16).unwrap());
2211                        i += 3;
2212                    } else {
2213                        out.push(b[i]);
2214                        i += 1;
2215                    }
2216                }
2217                out
2218            };
2219            prop_assert_eq!(String::from_utf8(decoded).unwrap(), v.clone());
2220            let WebviewUrl::External(url) = builtin_widget_url(&v, "small", "w", "1") else {
2221                panic!("external url")
2222            };
2223            let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
2224            prop_assert_eq!(pairs.len(), 4);
2225            prop_assert_eq!(&pairs[0], &("group".to_string(), v.clone()));
2226            prop_assert_eq!(&pairs[2], &("widgetId".to_string(), "w".to_string()));
2227
2228            prop_assert_eq!(decode_label(&activity_window_label(&id)), Some(id));
2229        }
2230    }
2231
2232    /// Fixed examples of the same rules, plus where stacked activity windows go.
2233    #[test]
2234    fn encoding_label_and_slot_examples() {
2235        assert_eq!(
2236            encode_query_component("small&widgetId=hijack"),
2237            "small%26widgetId%3Dhijack"
2238        );
2239        assert_eq!(encode_query_component("a=b&c"), "a%3Db%26c");
2240        assert_eq!(
2241            encode_query_component("group.com.example.app"),
2242            "group.com.example.app"
2243        );
2244        assert_eq!(encode_query_component("weather_1"), "weather_1");
2245        assert_eq!(activity_window_label("a.b"), "activity-a_db");
2246        assert_eq!(activity_window_label("a_b"), "activity-a__b");
2247        assert_ne!(activity_window_label("a_d"), activity_window_label("a.d"));
2248        assert_ne!(activity_window_label("x_.y"), activity_window_label("x._y"));
2249        let (w, h) = ACTIVITY_WINDOW_SIZE;
2250        // Second monitor at x=1920, menu bar 25 pt: inside that work area, stacked.
2251        assert_eq!(
2252            activity_slot(1920.0, 25.0, 1440.0, 1),
2253            (1920.0 + 1440.0 - w - 16.0, 25.0 + 16.0 + h + 8.0)
2254        );
2255    }
2256}