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//! - `pending_actions` — JSON array of [`WidgetActionEnvelope`]
6//! - `__meta_nonce__` / `__meta_updated_at__` — freshness for multi-transport pick
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Key prefix for per-widget UI configs.
13pub const CONFIG_KEY_PREFIX: &str = "config:";
14/// Queue of actions emitted by native widgets for the host to consume.
15pub const PENDING_ACTIONS_KEY: &str = "pending_actions";
16/// Monotonic freshness counter written with every map mutation.
17pub const META_NONCE_KEY: &str = "__meta_nonce__";
18/// Unix epoch milliseconds of last map write.
19pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__";
20
21/// String key/value bag persisted by transports.
22pub type DataMap = HashMap<String, String>;
23
24/// Build the storage key for a widget UI config.
25pub fn config_key(widget_id: &str) -> String {
26    format!("{CONFIG_KEY_PREFIX}{widget_id}")
27}
28
29/// Current time as Unix ms.
30pub fn now_ms() -> u64 {
31    SystemTime::now()
32        .duration_since(UNIX_EPOCH)
33        .map(|d| d.as_millis() as u64)
34        .unwrap_or(0)
35}
36
37/// Read nonce from a data map (0 if missing/invalid).
38pub fn map_nonce(map: &DataMap) -> u64 {
39    map.get(META_NONCE_KEY)
40        .and_then(|s| s.parse().ok())
41        .unwrap_or(0)
42}
43
44/// Read updatedAt from a data map (0 if missing/invalid).
45pub fn map_updated_at(map: &DataMap) -> u64 {
46    map.get(META_UPDATED_AT_KEY)
47        .and_then(|s| s.parse().ok())
48        .unwrap_or(0)
49}
50
51/// Bump nonce + updatedAt on the map (call before persisting).
52pub fn touch_meta(map: &mut DataMap) {
53    let next = map_nonce(map).saturating_add(1);
54    map.insert(META_NONCE_KEY.into(), next.to_string());
55    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
56}
57
58/// Like [`touch_meta`], but the new nonce is at least `floor + 1`.
59///
60/// Needed on Apple: the widget still picks the freshest map across *all*
61/// transports. A stale sibling with a higher nonce would otherwise win forever
62/// after the host switches to a single writer.
63pub fn touch_meta_above(map: &mut DataMap, floor: u64) {
64    let next = map_nonce(map)
65        .saturating_add(1)
66        .max(floor.saturating_add(1));
67    map.insert(META_NONCE_KEY.into(), next.to_string());
68    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
69}
70
71/// Whether this map carries at least one `config:{widgetId}` payload.
72pub fn map_has_config(map: &DataMap) -> bool {
73    map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX))
74}
75
76/// Pick the freshest map among candidates.
77///
78/// Maps that carry a `config:*` key always beat probe-only / empty siblings,
79/// even when the empty sibling has a higher nonce (pending-clear bumps).
80/// Among equals on that axis: max nonce, then max updatedAt.
81pub fn pick_freshest(maps: impl IntoIterator<Item = DataMap>) -> DataMap {
82    let mut best: Option<DataMap> = None;
83    let mut best_has_config = false;
84    let mut best_nonce = 0u64;
85    let mut best_ts = 0u64;
86    for map in maps {
87        let has_config = map_has_config(&map);
88        let n = map_nonce(&map);
89        let t = map_updated_at(&map);
90        let better = match &best {
91            None => true,
92            Some(_) if has_config && !best_has_config => true,
93            Some(_) if !has_config && best_has_config => false,
94            Some(_) => n > best_nonce || (n == best_nonce && t > best_ts),
95        };
96        if better {
97            best_has_config = has_config;
98            best_nonce = n;
99            best_ts = t;
100            best = Some(map);
101        }
102    }
103    best.unwrap_or_default()
104}
105
106/// Action delivered from a native widget back to the host app.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct WidgetActionEnvelope {
110    /// Action verb (button / toggle / list row).
111    pub action: String,
112    /// Optional opaque payload string.
113    #[serde(default)]
114    pub payload: Option<String>,
115    /// Unix epoch milliseconds when the action was enqueued.
116    pub ts: u64,
117    /// Widget id that emitted the action.
118    pub widget_id: String,
119    /// App Group / prefs group key.
120    pub group: String,
121}
122
123impl WidgetActionEnvelope {
124    /// Build an envelope with `ts = now`.
125    pub fn new(
126        action: impl Into<String>,
127        payload: Option<String>,
128        widget_id: impl Into<String>,
129        group: impl Into<String>,
130    ) -> Self {
131        Self {
132            action: action.into(),
133            payload,
134            ts: now_ms(),
135            widget_id: widget_id.into(),
136            group: group.into(),
137        }
138    }
139}
140
141/// Parse pending actions JSON; empty on missing/invalid.
142pub fn parse_pending_actions(raw: Option<&str>) -> Vec<WidgetActionEnvelope> {
143    let Some(s) = raw.filter(|s| !s.is_empty()) else {
144        return Vec::new();
145    };
146    serde_json::from_str(s).unwrap_or_default()
147}
148
149/// Serialize pending actions for storage.
150pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
151    Ok(serde_json::to_string(actions)?)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn touch_meta_above_beats_stale_sibling_nonce() {
160        let mut map = DataMap::new();
161        map.insert("config:example".into(), "{}".into());
162        // Local map only saw nonce 9; sibling UserDefaults is stuck at 65.
163        map.insert(META_NONCE_KEY.into(), "9".into());
164        touch_meta_above(&mut map, 65);
165        assert_eq!(map_nonce(&map), 66);
166    }
167
168    #[test]
169    fn pick_freshest_prefers_config_over_higher_nonce_probe() {
170        let mut probe = DataMap::new();
171        probe.insert("__probe__".into(), "1".into());
172        probe.insert(META_NONCE_KEY.into(), "99".into());
173        probe.insert(META_UPDATED_AT_KEY.into(), "999".into());
174
175        let mut live = DataMap::new();
176        live.insert("config:example".into(), r#"{"version":1}"#.into());
177        live.insert(META_NONCE_KEY.into(), "4".into());
178        live.insert(META_UPDATED_AT_KEY.into(), "100".into());
179
180        let picked = pick_freshest([probe, live]);
181        assert!(
182            picked.contains_key("config:example"),
183            "probe-only sibling must not wipe live config"
184        );
185        assert_eq!(map_nonce(&picked), 4);
186    }
187}