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