tauri_plugin_widgets/
store.rs1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12pub const CONFIG_KEY_PREFIX: &str = "config:";
14pub const PENDING_ACTIONS_KEY: &str = "pending_actions";
16pub const META_NONCE_KEY: &str = "__meta_nonce__";
18pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__";
20
21pub type DataMap = HashMap<String, String>;
23
24pub fn config_key(widget_id: &str) -> String {
26 format!("{CONFIG_KEY_PREFIX}{widget_id}")
27}
28
29pub 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
37pub 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
44pub 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
51pub 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
58pub 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
71pub fn map_has_config(map: &DataMap) -> bool {
73 map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX))
74}
75
76pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct WidgetActionEnvelope {
110 pub action: String,
112 #[serde(default)]
114 pub payload: Option<String>,
115 pub ts: u64,
117 pub widget_id: String,
119 pub group: String,
121}
122
123impl WidgetActionEnvelope {
124 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
141pub 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
149pub fn pending_minus_drained(
153 disk_raw: Option<&str>,
154 drained: &[WidgetActionEnvelope],
155) -> crate::Result<String> {
156 let rest: Vec<_> = parse_pending_actions(disk_raw)
157 .into_iter()
158 .filter(|a| !drained.contains(a))
159 .collect();
160 encode_pending_actions(&rest)
161}
162
163pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
165 Ok(serde_json::to_string(actions)?)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn pending_minus_drained_keeps_new_and_drops_drained() {
174 let a = WidgetActionEnvelope::new("a", None, "w", "g");
175 let b = WidgetActionEnvelope::new("b", Some("1".into()), "w", "g");
176 let disk = encode_pending_actions(&[a.clone(), b.clone()]).unwrap();
177 let left = pending_minus_drained(Some(&disk), &[a]).unwrap();
178 assert_eq!(parse_pending_actions(Some(&left)), vec![b]);
179 assert_eq!(pending_minus_drained(None, &[]).unwrap(), "[]");
180 }
181
182 #[test]
183 fn touch_meta_above_beats_stale_sibling_nonce() {
184 let mut map = DataMap::new();
185 map.insert("config:example".into(), "{}".into());
186 map.insert(META_NONCE_KEY.into(), "9".into());
188 touch_meta_above(&mut map, 65);
189 assert_eq!(map_nonce(&map), 66);
190 }
191
192 #[test]
193 fn pick_freshest_prefers_config_over_higher_nonce_probe() {
194 let mut probe = DataMap::new();
195 probe.insert("__probe__".into(), "1".into());
196 probe.insert(META_NONCE_KEY.into(), "99".into());
197 probe.insert(META_UPDATED_AT_KEY.into(), "999".into());
198
199 let mut live = DataMap::new();
200 live.insert("config:example".into(), r#"{"version":1}"#.into());
201 live.insert(META_NONCE_KEY.into(), "4".into());
202 live.insert(META_UPDATED_AT_KEY.into(), "100".into());
203
204 let picked = pick_freshest([probe, live]);
205 assert!(
206 picked.contains_key("config:example"),
207 "probe-only sibling must not wipe live config"
208 );
209 assert_eq!(map_nonce(&picked), 4);
210 }
211}