tauri_plugin_widgets/
receipt.rs1use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use crate::store::now_ms;
16
17pub const RECEIPTS_STORE_NAME: &str = "__tauri_widget_receipts__";
19pub const RECEIPTS_FILE_NAME: &str = "widget_receipts.json";
21const HISTORY_CAP: usize = 32;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct SkippedElement {
27 #[serde(rename = "type")]
29 pub type_name: String,
30 pub reason: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct WidgetRenderReceipt {
38 pub widget_id: String,
40 pub group: String,
42 pub instance: String,
44 pub nonce: u64,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub size: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub theme: Option<String>,
52 #[serde(default = "default_schema")]
54 pub schema: u32,
55 pub source: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub trigger: Option<String>,
60 #[serde(default)]
62 pub rendered: Vec<String>,
63 #[serde(default)]
65 pub skipped: Vec<SkippedElement>,
66 pub ts: u64,
68}
69
70fn default_schema() -> u32 {
71 1
72}
73
74impl WidgetRenderReceipt {
75 pub fn touch_ts(mut self) -> Self {
77 if self.ts == 0 {
78 self.ts = now_ms();
79 }
80 self
81 }
82}
83
84#[derive(Default)]
86pub struct ReceiptStore {
87 by_group: Mutex<HashMap<String, HashMap<String, VecDeque<WidgetRenderReceipt>>>>,
88}
89
90impl ReceiptStore {
91 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn upsert(&self, receipt: WidgetRenderReceipt) {
98 let receipt = receipt.touch_ts();
99 let mut guard = self.by_group.lock().unwrap();
100 let map = guard.entry(receipt.group.clone()).or_default();
101 let q = map.entry(receipt.instance.clone()).or_default();
102 q.push_back(receipt);
103 while q.len() > HISTORY_CAP {
104 q.pop_front();
105 }
106 }
107
108 pub fn list(&self, group: &str) -> Vec<WidgetRenderReceipt> {
110 self.by_group
111 .lock()
112 .unwrap()
113 .get(group)
114 .map(|m| {
115 let mut v: Vec<_> = m
116 .values()
117 .filter_map(|q| q.back().cloned())
118 .collect();
119 v.sort_by_key(|r| std::cmp::Reverse(r.ts));
120 v
121 })
122 .unwrap_or_default()
123 }
124
125 pub fn history(&self, group: &str) -> Vec<WidgetRenderReceipt> {
127 self.by_group
128 .lock()
129 .unwrap()
130 .get(group)
131 .map(|m| {
132 let mut v: Vec<_> = m.values().flat_map(|q| q.iter().cloned()).collect();
133 v.sort_by_key(|r| r.ts);
134 v
135 })
136 .unwrap_or_default()
137 }
138
139 pub fn live_instances(&self, group: &str, widget_id: &str, max_age_ms: u64) -> Vec<String> {
141 let now = now_ms();
142 self.list(group)
143 .into_iter()
144 .filter(|r| r.widget_id == widget_id && now.saturating_sub(r.ts) <= max_age_ms)
145 .map(|r| r.instance)
146 .collect()
147 }
148
149 pub fn save_to_path(&self, path: &Path) -> crate::Result<()> {
151 let all: HashMap<String, Vec<WidgetRenderReceipt>> = self
152 .by_group
153 .lock()
154 .unwrap()
155 .iter()
156 .map(|(g, m)| {
157 let mut list: Vec<_> = m.values().flat_map(|q| q.iter().cloned()).collect();
158 list.sort_by_key(|r| r.ts);
159 (g.clone(), list)
160 })
161 .collect();
162 if let Some(parent) = path.parent() {
163 fs::create_dir_all(parent)?;
164 }
165 let json = serde_json::to_string_pretty(&all)?;
166 let tmp = path.with_extension("tmp");
167 fs::write(&tmp, json.as_bytes())?;
168 fs::rename(&tmp, path)?;
169 Ok(())
170 }
171
172 pub fn load_from_path(&self, path: &Path) {
174 let Ok(raw) = fs::read_to_string(path) else {
175 return;
176 };
177 let Ok(all): Result<HashMap<String, Vec<WidgetRenderReceipt>>, _> =
178 serde_json::from_str(&raw)
179 else {
180 return;
181 };
182 for (_group, list) in all {
183 for r in list {
184 self.upsert(r);
185 }
186 }
187 }
188}
189
190pub fn receipts_path(app_data: &Path) -> PathBuf {
192 app_data.join("widgets").join(RECEIPTS_FILE_NAME)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn sample(nonce: u64, ts: u64) -> WidgetRenderReceipt {
200 WidgetRenderReceipt {
201 widget_id: "weather".into(),
202 group: "group.test".into(),
203 instance: "42".into(),
204 nonce,
205 size: Some("small".into()),
206 theme: None,
207 schema: 1,
208 source: "prefs".into(),
209 trigger: Some("timeline".into()),
210 rendered: vec!["text".into()],
211 skipped: vec![],
212 ts,
213 }
214 }
215
216 #[test]
217 fn upsert_keeps_history() {
218 let store = ReceiptStore::new();
219 store.upsert(sample(1, 100));
220 store.upsert(sample(2, 200));
221 let list = store.list("group.test");
222 assert_eq!(list.len(), 1);
223 assert_eq!(list[0].nonce, 2);
224 let hist = store.history("group.test");
225 assert_eq!(hist.len(), 2);
226 assert_eq!(hist[0].nonce, 1);
227 assert_eq!(hist[1].nonce, 2);
228 }
229}