Skip to main content

tauri_plugin_widgets/
store.rs

1//! Shared storage contract for widget data across platforms.
2//!
3//! Keys (breaking in 0.4 — no legacy `__widget_config__`):
4//! - `config:{widgetId}` — serialized [`crate::models::WidgetConfig`]
5//! - `config:{widgetId}#{instance}` — config of one placement (falls back to `config:{widgetId}`)
6//! - `data:{widgetId}` / `data:{widgetId}#{instance}` — values for `{{data.…}}` (`setWidgetData`)
7//! - `state:{widgetId}` / `state:{widgetId}#{instance}` — local state written by `set` taps
8//!   and `setWidgetState`, merged over the config's `state` (widget key, then instance key)
9//! - `activity-template:{name}` — Live Activity layout (`registerActivityTemplate`)
10//! - `fetched:{widgetId}` / `fetched:{widgetId}#{instance}` — last `source` response (renderer-owned)
11//! - `instances:{widgetId}` — `[{id, title}]` the Apple Edit Widget sheet offers
12//! - `pending_actions` — JSON array of [`WidgetActionEnvelope`]
13//! - `__meta_nonce__` / `__meta_updated_at__` — freshness for multi-transport pick
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19/// Key prefix for per-widget UI configs.
20pub const CONFIG_KEY_PREFIX: &str = "config:";
21/// Queue of actions emitted by native widgets for the host to consume.
22pub const PENDING_ACTIONS_KEY: &str = "pending_actions";
23/// Monotonic freshness counter written with every map mutation.
24pub const META_NONCE_KEY: &str = "__meta_nonce__";
25/// Unix epoch milliseconds of last map write.
26pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__";
27
28/// String key/value bag persisted by transports.
29pub type DataMap = HashMap<String, String>;
30
31/// Build the storage key for a widget UI config.
32pub fn config_key(widget_id: &str) -> String {
33    format!("{CONFIG_KEY_PREFIX}{widget_id}")
34}
35
36/// Separator between widget id and placement id in store keys. Not in the widget id
37/// charset, so `a#b` can never be another widget's id.
38pub const INSTANCE_SEPARATOR: char = '#';
39
40/// `widgetId` or `widgetId#instance`: the id a placement's keys are built from.
41pub fn scoped_id(widget_id: &str, instance: Option<&str>) -> String {
42    match instance.filter(|i| !i.is_empty()) {
43        Some(i) => format!("{widget_id}{INSTANCE_SEPARATOR}{i}"),
44        None => widget_id.to_string(),
45    }
46}
47
48/// Key prefix of host-set data for `{{data.…}}`.
49pub const DATA_KEY_PREFIX: &str = "data:";
50/// Key prefix of the cached `source` response a renderer fetched.
51pub const FETCHED_KEY_PREFIX: &str = "fetched:";
52/// Key prefix of the instance choices offered in the Apple Edit Widget sheet.
53pub const INSTANCES_KEY_PREFIX: &str = "instances:";
54
55/// `data:{scoped id}`.
56pub fn data_key(widget_id: &str, instance: Option<&str>) -> String {
57    format!("{DATA_KEY_PREFIX}{}", scoped_id(widget_id, instance))
58}
59
60/// Key prefix of a placement's local state (`set`, `setWidgetState`).
61pub const STATE_KEY_PREFIX: &str = "state:";
62/// Key prefix of a stored Live Activity template.
63pub const ACTIVITY_TEMPLATE_KEY_PREFIX: &str = "activity-template:";
64
65/// Pending-action name a native widget queues after a `set` tap; payload
66/// `{widgetId, instance, state}`. The host emits it as `widget-state-changed`.
67pub const STATE_ACTION: &str = "$state";
68
69/// `widget-state-changed` payload `{group, widgetId, instance, state}` for a
70/// [`STATE_ACTION`] envelope; `None` for any other action.
71pub fn state_event(env: &WidgetActionEnvelope) -> Option<serde_json::Value> {
72    use serde_json::Value;
73    if env.action != STATE_ACTION {
74        return None;
75    }
76    let p: Value = env
77        .payload
78        .as_deref()
79        .and_then(|p| serde_json::from_str(p).ok())
80        .unwrap_or_default();
81    Some(serde_json::json!({
82        "group": env.group,
83        "widgetId": p.get("widgetId").cloned().unwrap_or(Value::String(env.widget_id.clone())),
84        "instance": p.get("instance").cloned().unwrap_or(Value::Null),
85        "state": p.get("state").cloned().unwrap_or(Value::Null),
86    }))
87}
88
89/// `state:{scoped id}`.
90pub fn state_key(widget_id: &str, instance: Option<&str>) -> String {
91    format!("{STATE_KEY_PREFIX}{}", scoped_id(widget_id, instance))
92}
93
94/// `activity-template:{name}`.
95pub fn activity_template_key(name: &str) -> String {
96    format!("{ACTIVITY_TEMPLATE_KEY_PREFIX}{name}")
97}
98
99/// A placement's state: config `state` defaults, then the widget's stored layer, then the
100/// placement's own (later wins per top-level key). Non-object layers are ignored.
101pub fn merged_state(
102    layers: &[Option<&serde_json::Value>],
103) -> serde_json::Map<String, serde_json::Value> {
104    let mut out = serde_json::Map::new();
105    for layer in layers.iter().flatten() {
106        if let Some(m) = layer.as_object() {
107            out.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
108        }
109    }
110    out
111}
112
113/// Apply a `set` / `setWidgetState` patch to the stored `layer`. Operators read the
114/// placement's merged state `current`: `"$toggle"` flips a boolean (missing → `true`),
115/// `"$inc"` / `"$dec"` add / subtract 1 (missing → 0 first). `null` removes the key from
116/// the layer (the default shows again); any other value is stored as is.
117pub fn apply_state_patch(
118    layer: &mut serde_json::Map<String, serde_json::Value>,
119    current: &serde_json::Map<String, serde_json::Value>,
120    patch: &serde_json::Map<String, serde_json::Value>,
121) {
122    use serde_json::Value;
123    for (k, v) in patch {
124        let now = current.get(k);
125        let step = |d: i64| -> Value {
126            match now {
127                // Integers stay integers while they fit `i64`; floats and overflow add as f64.
128                Some(Value::Number(n)) => n
129                    .as_i64()
130                    .and_then(|i| i.checked_add(d))
131                    .map(Value::from)
132                    .unwrap_or_else(|| Value::from(n.as_f64().unwrap_or(0.0) + d as f64)),
133                _ => Value::from(d),
134            }
135        };
136        let next = match v {
137            Value::Null => {
138                layer.remove(k);
139                continue;
140            }
141            Value::String(op) if op == "$toggle" => {
142                Value::Bool(!matches!(now, Some(Value::Bool(true))))
143            }
144            Value::String(op) if op == "$inc" => step(1),
145            Value::String(op) if op == "$dec" => step(-1),
146            other => other.clone(),
147        };
148        layer.insert(k.clone(), next);
149    }
150}
151
152/// `instances:{widgetId}`.
153pub fn instances_key(widget_id: &str) -> String {
154    format!("{INSTANCES_KEY_PREFIX}{widget_id}")
155}
156
157/// Keys a renderer reads for one placement, most specific first: the placement's own,
158/// then the widget's.
159pub fn lookup_keys(prefix: &str, widget_id: &str, instance: Option<&str>) -> Vec<String> {
160    let mut keys = Vec::with_capacity(2);
161    if instance.is_some_and(|i| !i.is_empty()) {
162        keys.push(format!("{prefix}{}", scoped_id(widget_id, instance)));
163    }
164    keys.push(format!("{prefix}{widget_id}"));
165    keys
166}
167
168/// One choice in the Apple Edit Widget sheet (and a label for an Android placement).
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "camelCase")]
172pub struct WidgetInstanceOption {
173    /// Placement id (`{{instance}}`, `config:{widgetId}#{id}`).
174    pub id: String,
175    /// Text shown to the user.
176    pub title: String,
177}
178
179/// A widget currently placed on a home screen, desktop or board.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ActiveWidget {
183    /// Logical widget id (WidgetKit kind, `widgetId` of the config).
184    pub widget_id: String,
185    /// Placement id: Android `appWidgetId`, Apple Edit Widget choice, window label,
186    /// Widgets Board id. `None` when the placement has no own identity.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub instance: Option<String>,
189    /// `small` | `medium` | `large` when known.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub size: Option<String>,
192    /// Unix ms of the last render, when the list comes from render receipts.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub last_seen_ms: Option<u64>,
195}
196
197/// Live placements first (open windows, native lists), then receipt-only ones not already
198/// listed; a receipt's `lastSeenMs` / `size` fill gaps of the live entry.
199pub fn merge_active(live: Vec<ActiveWidget>, seen: Vec<ActiveWidget>) -> Vec<ActiveWidget> {
200    let mut out = live;
201    for s in seen {
202        match out
203            .iter_mut()
204            .find(|a| a.widget_id == s.widget_id && a.instance == s.instance)
205        {
206            Some(a) => {
207                a.last_seen_ms = a.last_seen_ms.or(s.last_seen_ms);
208                a.size = a.size.take().or(s.size);
209            }
210            None => out.push(s),
211        }
212    }
213    out.sort_by(|a, b| (&a.widget_id, &a.instance).cmp(&(&b.widget_id, &b.instance)));
214    out
215}
216
217/// Native placement list + receipts.
218///
219/// `exact` (Android): the native list names every placement; receipts only fill size and
220/// last render, never add. Not exact (iOS: WidgetCenter knows kinds, the extension knows
221/// the Edit Widget choice): a kind's receipts with an instance replace its bare entries;
222/// kinds no longer placed are dropped even if they rendered recently.
223pub fn merge_native_active(
224    live: Vec<ActiveWidget>,
225    seen: Vec<ActiveWidget>,
226    exact: bool,
227) -> Vec<ActiveWidget> {
228    if exact {
229        let mut merged = merge_active(live.clone(), seen);
230        merged.retain(|a| {
231            live.iter()
232                .any(|l| l.widget_id == a.widget_id && l.instance == a.instance)
233        });
234        return merged;
235    }
236    let mut out: Vec<ActiveWidget> = Vec::new();
237    for l in &live {
238        let instances: Vec<&ActiveWidget> = seen
239            .iter()
240            .filter(|s| s.widget_id == l.widget_id && s.instance.is_some())
241            .collect();
242        if instances.is_empty() {
243            let receipt = seen.iter().find(|s| s.widget_id == l.widget_id);
244            out.push(ActiveWidget {
245                last_seen_ms: receipt.and_then(|r| r.last_seen_ms),
246                ..l.clone()
247            });
248        } else {
249            for s in instances {
250                if !out.contains(s) {
251                    out.push(s.clone());
252                }
253            }
254        }
255    }
256    out.sort_by(|a, b| {
257        (&a.widget_id, &a.instance, &a.size).cmp(&(&b.widget_id, &b.instance, &b.size))
258    });
259    out
260}
261
262/// Shared-map key where the Windows Widgets Board provider lists its placed instances.
263pub const BOARD_WIDGETS_KEY: &str = "board_widgets";
264
265/// `[{widgetId, instance}]` written by `templates/windows-widget` (Store.cs); empty on junk.
266pub fn parse_board_widgets(raw: &str) -> Vec<ActiveWidget> {
267    #[derive(Deserialize)]
268    #[serde(rename_all = "camelCase")]
269    struct Board {
270        widget_id: String,
271        instance: String,
272    }
273    serde_json::from_str::<Vec<Board>>(raw)
274        .unwrap_or_default()
275        .into_iter()
276        .map(|b| ActiveWidget {
277            widget_id: b.widget_id,
278            instance: Some(b.instance),
279            size: Some("medium".into()),
280            last_seen_ms: None,
281        })
282        .collect()
283}
284
285/// Receipts older than this do not count as "placed" (WidgetKit renders at least daily).
286pub const ACTIVE_RECEIPT_MAX_AGE_MS: u64 = 48 * 60 * 60 * 1000;
287
288/// Active placements from render receipts: one per (widget, instance), newest render
289/// wins, older than `max_age_ms` dropped, sorted by widget id then instance.
290pub fn active_from_receipts(
291    receipts: &[crate::receipt::WidgetRenderReceipt],
292    now_ms: u64,
293    max_age_ms: u64,
294) -> Vec<ActiveWidget> {
295    let mut best: HashMap<(String, String), &crate::receipt::WidgetRenderReceipt> = HashMap::new();
296    for r in receipts {
297        if now_ms.saturating_sub(r.ts) > max_age_ms {
298            continue;
299        }
300        let key = (r.widget_id.clone(), r.instance.clone());
301        match best.get(&key) {
302            Some(prev) if prev.ts >= r.ts => {}
303            _ => {
304                best.insert(key, r);
305            }
306        }
307    }
308    let mut out: Vec<ActiveWidget> = best
309        .into_values()
310        .map(|r| ActiveWidget {
311            widget_id: r.widget_id.clone(),
312            instance: Some(r.instance.clone()).filter(|i| !i.is_empty()),
313            size: r.size.clone(),
314            last_seen_ms: Some(r.ts),
315        })
316        .collect();
317    out.sort_by(|a, b| (&a.widget_id, &a.instance).cmp(&(&b.widget_id, &b.instance)));
318    out
319}
320
321/// Current time as Unix ms.
322pub fn now_ms() -> u64 {
323    SystemTime::now()
324        .duration_since(UNIX_EPOCH)
325        .map(|d| d.as_millis() as u64)
326        .unwrap_or(0)
327}
328
329/// Read nonce from a data map (0 if missing/invalid).
330pub fn map_nonce(map: &DataMap) -> u64 {
331    map.get(META_NONCE_KEY)
332        .and_then(|s| s.parse().ok())
333        .unwrap_or(0)
334}
335
336/// Read updatedAt from a data map (0 if missing/invalid).
337pub fn map_updated_at(map: &DataMap) -> u64 {
338    map.get(META_UPDATED_AT_KEY)
339        .and_then(|s| s.parse().ok())
340        .unwrap_or(0)
341}
342
343/// Bump nonce + updatedAt on the map (call before persisting).
344pub fn touch_meta(map: &mut DataMap) {
345    let next = map_nonce(map).saturating_add(1);
346    map.insert(META_NONCE_KEY.into(), next.to_string());
347    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
348}
349
350/// Like [`touch_meta`], but the new nonce is at least `floor + 1`.
351///
352/// Needed on Apple: the widget still picks the freshest map across *all*
353/// transports. A stale sibling with a higher nonce would otherwise win forever
354/// after the host switches to a single writer.
355pub fn touch_meta_above(map: &mut DataMap, floor: u64) {
356    let next = map_nonce(map)
357        .saturating_add(1)
358        .max(floor.saturating_add(1));
359    map.insert(META_NONCE_KEY.into(), next.to_string());
360    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
361}
362
363/// Whether this map carries at least one `config:{widgetId}` payload.
364pub fn map_has_config(map: &DataMap) -> bool {
365    map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX))
366}
367
368/// Pick the freshest map among candidates.
369///
370/// Maps that carry a `config:*` key always beat probe-only / empty siblings,
371/// even when the empty sibling has a higher nonce (pending-clear bumps).
372/// Among equals on that axis: max nonce, then max updatedAt.
373pub fn pick_freshest(maps: impl IntoIterator<Item = DataMap>) -> DataMap {
374    let mut best: Option<DataMap> = None;
375    let mut best_has_config = false;
376    let mut best_nonce = 0u64;
377    let mut best_ts = 0u64;
378    for map in maps {
379        let has_config = map_has_config(&map);
380        let n = map_nonce(&map);
381        let t = map_updated_at(&map);
382        let better = match &best {
383            None => true,
384            Some(_) if has_config && !best_has_config => true,
385            Some(_) if !has_config && best_has_config => false,
386            Some(_) => n > best_nonce || (n == best_nonce && t > best_ts),
387        };
388        if better {
389            best_has_config = has_config;
390            best_nonce = n;
391            best_ts = t;
392            best = Some(map);
393        }
394    }
395    best.unwrap_or_default()
396}
397
398/// Action delivered from a native widget back to the host app.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct WidgetActionEnvelope {
402    /// Action verb (button / toggle / list row).
403    pub action: String,
404    /// Optional opaque payload string.
405    #[serde(default)]
406    pub payload: Option<String>,
407    /// Unix epoch milliseconds when the action was enqueued.
408    pub ts: u64,
409    /// Widget id that emitted the action.
410    pub widget_id: String,
411    /// App Group / prefs group key.
412    pub group: String,
413}
414
415impl WidgetActionEnvelope {
416    /// Build an envelope with `ts = now`.
417    pub fn new(
418        action: impl Into<String>,
419        payload: Option<String>,
420        widget_id: impl Into<String>,
421        group: impl Into<String>,
422    ) -> Self {
423        Self {
424            action: action.into(),
425            payload,
426            ts: now_ms(),
427            widget_id: widget_id.into(),
428            group: group.into(),
429        }
430    }
431}
432
433/// Gate for emitting queued widget taps as `widget-action`.
434///
435/// A tap that launches the app is queued before any JavaScript listener exists; emitting
436/// it then loses it. The host keeps the queue until JS shows it listens (its first
437/// `poll_pending_actions`, done by `onWidgetAction`), or until `fallback_ms` after start
438/// for apps that only `listen("widget-action")`.
439#[derive(Debug)]
440pub struct ListenerGate {
441    ready: std::sync::atomic::AtomicBool,
442    started_ms: u64,
443    fallback_ms: u64,
444}
445
446impl ListenerGate {
447    /// Default fallback: a webview is listening well within this after start.
448    pub const DEFAULT_FALLBACK_MS: u64 = 10_000;
449
450    /// Closed gate started at `now_ms`.
451    pub fn new(now_ms: u64, fallback_ms: u64) -> Self {
452        Self {
453            ready: std::sync::atomic::AtomicBool::new(false),
454            started_ms: now_ms,
455            fallback_ms,
456        }
457    }
458
459    /// JavaScript listens: open for good.
460    pub fn open(&self) {
461        self.ready.store(true, std::sync::atomic::Ordering::SeqCst);
462    }
463
464    /// Milliseconds until the fallback opens the gate (0 when open).
465    pub fn remaining_ms(&self, now_ms: u64) -> u64 {
466        if self.is_open(now_ms) {
467            0
468        } else {
469            (self.started_ms + self.fallback_ms).saturating_sub(now_ms)
470        }
471    }
472
473    /// May queued actions be emitted at `now_ms`?
474    pub fn is_open(&self, now_ms: u64) -> bool {
475        self.ready.load(std::sync::atomic::Ordering::SeqCst)
476            || now_ms.saturating_sub(self.started_ms) >= self.fallback_ms
477    }
478}
479
480/// Parse pending actions JSON; empty on missing/invalid.
481pub fn parse_pending_actions(raw: Option<&str>) -> Vec<WidgetActionEnvelope> {
482    let Some(s) = raw.filter(|s| !s.is_empty()) else {
483        return Vec::new();
484    };
485    serde_json::from_str(s).unwrap_or_default()
486}
487
488/// Shared-file queue owned by another process (Windows provider): keep what is on disk,
489/// minus the actions this host just drained. The host never writes its own snapshot of
490/// the queue, so a stale snapshot cannot resurrect drained actions or drop new ones.
491pub fn pending_minus_drained(
492    disk_raw: Option<&str>,
493    drained: &[WidgetActionEnvelope],
494) -> crate::Result<String> {
495    let rest: Vec<_> = parse_pending_actions(disk_raw)
496        .into_iter()
497        .filter(|a| !drained.contains(a))
498        .collect();
499    encode_pending_actions(&rest)
500}
501
502/// Serialize pending actions for storage.
503pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
504    Ok(serde_json::to_string(actions)?)
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn receipt(id: &str, inst: &str, ts: u64) -> crate::receipt::WidgetRenderReceipt {
512        crate::receipt::WidgetRenderReceipt {
513            widget_id: id.into(),
514            group: "g".into(),
515            instance: inst.into(),
516            nonce: 0,
517            size: Some("small".into()),
518            theme: None,
519            schema: 1,
520            source: "prefs".into(),
521            trigger: None,
522            rendered: vec![],
523            skipped: vec![],
524            ts,
525        }
526    }
527
528    fn aw(id: &str, inst: Option<&str>, size: Option<&str>, seen: Option<u64>) -> ActiveWidget {
529        ActiveWidget {
530            widget_id: id.into(),
531            instance: inst.map(Into::into),
532            size: size.map(Into::into),
533            last_seen_ms: seen,
534        }
535    }
536
537    /// The order a renderer reads a placement's keys in (the keys commands write are
538    /// checked over IPC in tests/offline_model.rs).
539    #[test]
540    fn lookup_keys_scenario() {
541        assert_eq!(scoped_id("w", Some("")), "w");
542        assert_eq!(config_key("w"), "config:w");
543        assert_eq!(
544            lookup_keys(CONFIG_KEY_PREFIX, "w", Some("1")),
545            ["config:w#1", "config:w"]
546        );
547        assert_eq!(lookup_keys(CONFIG_KEY_PREFIX, "w", Some("")), ["config:w"]);
548        assert_eq!(lookup_keys(FETCHED_KEY_PREFIX, "w", None), ["fetched:w"]);
549    }
550
551    /// Which widgets are placed: render receipts aged and deduped, merged with live
552    /// windows, with native lists (exact Android, inexact iOS) and the Widgets Board.
553    #[test]
554    fn placement_listing_scenario() {
555        let rs = [
556            receipt("w", "2", 900),
557            receipt("w", "1", 500),
558            receipt("w", "1", 800),
559            receipt("w", "1", 700),
560            receipt("old", "", 99),
561            receipt("a", "", 1000),
562        ];
563        let got = active_from_receipts(&rs, 1000, 900);
564        assert_eq!(
565            got,
566            [
567                ActiveWidget {
568                    size: Some("small".into()),
569                    ..aw("a", None, None, Some(1000))
570                },
571                ActiveWidget {
572                    size: Some("small".into()),
573                    ..aw("w", Some("1"), None, Some(800))
574                },
575                ActiveWidget {
576                    size: Some("small".into()),
577                    ..aw("w", Some("2"), None, Some(900))
578                },
579            ]
580        );
581        // Exactly at max age still counts; the default window is two days.
582        assert_eq!(
583            active_from_receipts(&[receipt("x", "", 100)], 1000, 900).len(),
584            1
585        );
586        let h = 60 * 60 * 1000;
587        let rs = [
588            receipt("fresh", "", 100 * h - 47 * h),
589            receipt("stale", "", 100 * h - 49 * h),
590        ];
591        let got = active_from_receipts(&rs, 100 * h, ACTIVE_RECEIPT_MAX_AGE_MS);
592        assert_eq!(
593            got.iter().map(|a| a.widget_id.as_str()).collect::<Vec<_>>(),
594            ["fresh"]
595        );
596
597        // Live first; receipts fill gaps and add what is missing.
598        let got = merge_active(
599            vec![
600                aw("w", Some("win"), None, None),
601                aw("b", None, Some("large"), None),
602            ],
603            vec![
604                aw("w", Some("win"), Some("small"), Some(5)),
605                aw("a", None, None, Some(1)),
606                aw("b", None, Some("small"), Some(2)),
607            ],
608        );
609        assert_eq!(
610            got,
611            vec![
612                aw("a", None, None, Some(1)),
613                aw("b", None, Some("large"), Some(2)),
614                aw("w", Some("win"), Some("small"), Some(5)),
615            ]
616        );
617        // Android: exact list; receipts fill, never add.
618        let got = merge_native_active(
619            vec![aw("w", Some("7"), None, None)],
620            vec![
621                aw("w", Some("7"), Some("small"), Some(3)),
622                aw("w", Some("8"), None, Some(4)),
623            ],
624            true,
625        );
626        assert_eq!(got, vec![aw("w", Some("7"), Some("small"), Some(3))]);
627        // iOS: kinds from WidgetCenter, instances from receipts.
628        let got = merge_native_active(
629            vec![
630                aw("w", None, Some("small"), None),
631                aw("w", None, Some("medium"), None),
632                aw("plain", None, Some("large"), None),
633            ],
634            vec![
635                aw("w", Some("nyc"), Some("small"), Some(5)),
636                aw("w", Some("ldn"), Some("medium"), Some(6)),
637                aw("plain", None, Some("large"), Some(9)),
638                aw("gone", Some("x"), None, Some(1)),
639            ],
640            false,
641        );
642        assert_eq!(
643            got,
644            vec![
645                aw("plain", None, Some("large"), Some(9)),
646                aw("w", Some("ldn"), Some("medium"), Some(6)),
647                aw("w", Some("nyc"), Some("small"), Some(5)),
648            ]
649        );
650        // Placed kind that never rendered: listed bare.
651        assert_eq!(
652            merge_native_active(vec![aw("n", None, None, None)], vec![], false),
653            vec![aw("n", None, None, None)]
654        );
655        // Windows Widgets Board list.
656        assert_eq!(
657            parse_board_widgets(r#"[{"widgetId":"default","instance":"B1"}]"#),
658            vec![aw("default", Some("B1"), Some("medium"), None)]
659        );
660        assert!(parse_board_widgets("nope").is_empty());
661    }
662
663    /// Several transports hold copies of the map: each write bumps the nonce and time, and
664    /// the reader picks the freshest (config first, then nonce, then updatedAt).
665    #[test]
666    fn transport_freshness_scenario() {
667        let m = |tag: &str, config: bool, nonce: u64, ts: u64| {
668            let mut d = DataMap::new();
669            d.insert("tag".into(), tag.into());
670            if config {
671                d.insert("config:w".into(), "{}".into());
672            }
673            d.insert(META_NONCE_KEY.into(), nonce.to_string());
674            d.insert(META_UPDATED_AT_KEY.into(), ts.to_string());
675            d
676        };
677        let pick = |maps: Vec<DataMap>| pick_freshest(maps)["tag"].clone();
678
679        // A write: nonce + 1, updatedAt = now (Unix ms).
680        let before = now_ms();
681        assert!(before > 1_600_000_000_000, "now_ms is Unix ms");
682        let mut live = m("live", true, 7, 0);
683        touch_meta(&mut live);
684        assert_eq!(map_nonce(&live), 8);
685        assert!(map_updated_at(&live) >= before);
686        assert_eq!(map_updated_at(&m("x", false, 0, 123)), 123);
687        assert_eq!(
688            (map_nonce(&DataMap::new()), map_updated_at(&DataMap::new())),
689            (0, 0)
690        );
691        assert!(map_has_config(&live) && !map_has_config(&m("p", false, 0, 0)));
692
693        // A probe-only sibling with a higher nonce never wipes the config, in either order.
694        let probe = m("probe", false, 99, u64::MAX);
695        assert_eq!(pick(vec![probe.clone(), live.clone()]), "live");
696        assert_eq!(pick(vec![live.clone(), probe]), "live");
697        // A stale sibling stuck at nonce 65: the next write goes above it and wins.
698        let stale = m("stale", true, 65, 0);
699        assert_eq!(pick(vec![live.clone(), stale.clone()]), "stale");
700        touch_meta_above(&mut live, 65);
701        assert_eq!(map_nonce(&live), 66);
702        assert_eq!(pick(vec![stale, live]), "live");
703
704        // Same config axis: higher nonce wins whatever the order and timestamps.
705        assert_eq!(pick(vec![m("a", false, 1, 9), m("b", false, 2, 0)]), "b");
706        assert_eq!(pick(vec![m("a", true, 1, 9), m("b", true, 2, 0)]), "b");
707        assert_eq!(pick(vec![m("a", false, 2, 0), m("b", false, 1, 9)]), "a");
708        // Equal nonce: newer updatedAt wins; a full tie keeps the first.
709        assert_eq!(pick(vec![m("a", false, 2, 1), m("b", false, 2, 5)]), "b");
710        assert_eq!(pick(vec![m("a", false, 2, 5), m("b", false, 2, 1)]), "a");
711        assert_eq!(pick(vec![m("a", false, 2, 5), m("b", false, 2, 5)]), "a");
712        assert!(pick_freshest(Vec::new()).is_empty());
713    }
714
715    /// Widget taps: held until JS listens (or the fallback), and a drain that keeps taps
716    /// queued meanwhile. Delivery through `pollPendingActions`: tests/actions.rs.
717    #[test]
718    fn action_queue_scenario() {
719        let g = ListenerGate::new(1_000, 500);
720        assert!(!g.is_open(1_000));
721        assert!(!g.is_open(1_499));
722        assert!(g.is_open(1_500));
723        assert!(!g.is_open(0));
724        assert_eq!(g.remaining_ms(1_200), 300);
725        assert_eq!(g.remaining_ms(1_500), 0);
726        g.open();
727        assert_eq!(g.remaining_ms(1_000), 0);
728        assert!(g.is_open(1_000));
729
730        let a = WidgetActionEnvelope::new("a", None, "w", "g");
731        let b = WidgetActionEnvelope::new("b", Some("1".into()), "w", "g");
732        let disk = encode_pending_actions(&[a.clone(), b.clone()]).unwrap();
733        let left = pending_minus_drained(Some(&disk), &[a]).unwrap();
734        assert_eq!(parse_pending_actions(Some(&left)), vec![b]);
735        assert_eq!(pending_minus_drained(None, &[]).unwrap(), "[]");
736        assert!(parse_pending_actions(Some("junk")).is_empty());
737    }
738}