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
21pub type DataMap = HashMap<String, String>;
22
23/// Build the storage key for a widget UI config.
24pub fn config_key(widget_id: &str) -> String {
25    format!("{CONFIG_KEY_PREFIX}{widget_id}")
26}
27
28/// Current time as Unix ms.
29pub fn now_ms() -> u64 {
30    SystemTime::now()
31        .duration_since(UNIX_EPOCH)
32        .map(|d| d.as_millis() as u64)
33        .unwrap_or(0)
34}
35
36/// Read nonce from a data map (0 if missing/invalid).
37pub fn map_nonce(map: &DataMap) -> u64 {
38    map.get(META_NONCE_KEY)
39        .and_then(|s| s.parse().ok())
40        .unwrap_or(0)
41}
42
43/// Read updatedAt from a data map (0 if missing/invalid).
44pub fn map_updated_at(map: &DataMap) -> u64 {
45    map.get(META_UPDATED_AT_KEY)
46        .and_then(|s| s.parse().ok())
47        .unwrap_or(0)
48}
49
50/// Bump nonce + updatedAt on the map (call before persisting).
51pub fn touch_meta(map: &mut DataMap) {
52    let next = map_nonce(map).saturating_add(1);
53    map.insert(META_NONCE_KEY.into(), next.to_string());
54    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
55}
56
57/// Like [`touch_meta`], but the new nonce is at least `floor + 1`.
58///
59/// Needed on Apple: the widget still picks the freshest map across *all*
60/// transports. A stale sibling with a higher nonce would otherwise win forever
61/// after the host switches to a single writer.
62pub fn touch_meta_above(map: &mut DataMap, floor: u64) {
63    let next = map_nonce(map)
64        .saturating_add(1)
65        .max(floor.saturating_add(1));
66    map.insert(META_NONCE_KEY.into(), next.to_string());
67    map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
68}
69
70/// Pick the freshest map among candidates (max nonce, then max updatedAt).
71pub fn pick_freshest(maps: impl IntoIterator<Item = DataMap>) -> DataMap {
72    let mut best: Option<DataMap> = None;
73    let mut best_nonce = 0u64;
74    let mut best_ts = 0u64;
75    for map in maps {
76        let n = map_nonce(&map);
77        let t = map_updated_at(&map);
78        let better = best.is_none() || n > best_nonce || (n == best_nonce && t > best_ts);
79        if better {
80            best_nonce = n;
81            best_ts = t;
82            best = Some(map);
83        }
84    }
85    best.unwrap_or_default()
86}
87
88/// Action delivered from a native widget back to the host app.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct WidgetActionEnvelope {
92    pub action: String,
93    #[serde(default)]
94    pub payload: Option<String>,
95    /// Unix epoch milliseconds when the action was enqueued.
96    pub ts: u64,
97    pub widget_id: String,
98    pub group: String,
99}
100
101impl WidgetActionEnvelope {
102    pub fn new(
103        action: impl Into<String>,
104        payload: Option<String>,
105        widget_id: impl Into<String>,
106        group: impl Into<String>,
107    ) -> Self {
108        Self {
109            action: action.into(),
110            payload,
111            ts: now_ms(),
112            widget_id: widget_id.into(),
113            group: group.into(),
114        }
115    }
116}
117
118/// Parse pending actions JSON; empty on missing/invalid.
119pub fn parse_pending_actions(raw: Option<&str>) -> Vec<WidgetActionEnvelope> {
120    let Some(s) = raw.filter(|s| !s.is_empty()) else {
121        return Vec::new();
122    };
123    serde_json::from_str(s).unwrap_or_default()
124}
125
126/// Serialize pending actions for storage.
127pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
128    Ok(serde_json::to_string(actions)?)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn touch_meta_above_beats_stale_sibling_nonce() {
137        let mut map = DataMap::new();
138        map.insert("config:example".into(), "{}".into());
139        // Local map only saw nonce 9; sibling UserDefaults is stuck at 65.
140        map.insert(META_NONCE_KEY.into(), "9".into());
141        touch_meta_above(&mut map, 65);
142        assert_eq!(map_nonce(&map), 66);
143    }
144}