Skip to main content

tauri_plugin_widgets/
receipt.rs

1//! Cross-platform render receipts (diagnostics, not a critical path).
2//!
3//! Renderers write after paint; host reads when it wants. Separate from the
4//! config map so receipt writes never bump `__meta_nonce__`.
5//!
6//! History is a ring per instance (cap 32) so intermittent failures stay
7//! visible after the latest upsert.
8
9use 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
17/// Prefs / file basename for the receipt bag (outside config DataMap).
18pub 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/// What a renderer actually painted for one widget instance.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct WidgetRenderReceipt {
34    pub widget_id: String,
35    pub group: String,
36    /// `appWidgetId` | WidgetFamily | window label
37    pub instance: String,
38    /// Config-map nonce that was rendered (0 if unknown).
39    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    /// IR schema version the renderer understands.
45    #[serde(default = "default_schema")]
46    pub schema: u32,
47    /// `prefs` | `state` | `appgroup` | `defaults` | `container` | `push` | `pull`
48    pub source: String,
49    /// Why this paint ran: `reload` | `timeline` | `action` | `added` | `resize` | `snapshot`.
50    #[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/// In-memory + optional disk bag of receipts, keyed by group → instance → history.
73#[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    /// Latest receipt per instance (newest first).
95    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    /// Full history for a group (oldest → newest), capped by ring.
112    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
173/// Default disk path under an app data dir.
174pub 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}