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__";
19/// On-disk filename under the app data / widgets dir.
20pub 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")]
25/// Element the renderer could not paint (capability / parse).
26pub struct SkippedElement {
27    /// IR type name (`chart`, `canvas`, …).
28    #[serde(rename = "type")]
29    pub type_name: String,
30    /// Why it was skipped.
31    pub reason: String,
32}
33
34/// What a renderer actually painted for one widget instance.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct WidgetRenderReceipt {
38    /// Logical widget id from JS / IR.
39    pub widget_id: String,
40    /// App Group / prefs group.
41    pub group: String,
42    /// `appWidgetId` | WidgetFamily | window label
43    pub instance: String,
44    /// Config-map nonce that was rendered (0 if unknown).
45    pub nonce: u64,
46    /// Family / slot size label when known.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub size: Option<String>,
49    /// Light / dark when reported.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub theme: Option<String>,
52    /// IR schema version the renderer understands.
53    #[serde(default = "default_schema")]
54    pub schema: u32,
55    /// `prefs` | `state` | `appgroup` | `defaults` | `container` | `push` | `pull`
56    pub source: String,
57    /// Why this paint ran: `reload` | `timeline` | `action` | `added` | `resize` | `snapshot`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub trigger: Option<String>,
60    /// IR type names that painted successfully.
61    #[serde(default)]
62    pub rendered: Vec<String>,
63    /// Elements skipped with reasons.
64    #[serde(default)]
65    pub skipped: Vec<SkippedElement>,
66    /// Unix ms when this receipt was written.
67    pub ts: u64,
68}
69
70fn default_schema() -> u32 {
71    1
72}
73
74impl WidgetRenderReceipt {
75    /// Fill `ts` with [`now_ms`] if still zero.
76    pub fn touch_ts(mut self) -> Self {
77        if self.ts == 0 {
78            self.ts = now_ms();
79        }
80        self
81    }
82}
83
84/// In-memory + optional disk bag of receipts, keyed by group → instance → history.
85#[derive(Default)]
86pub struct ReceiptStore {
87    by_group: Mutex<HashMap<String, HashMap<String, VecDeque<WidgetRenderReceipt>>>>,
88}
89
90impl ReceiptStore {
91    /// Empty in-memory store.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Append a receipt to the per-instance history ring.
97    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    /// Latest receipt per instance (newest first).
109    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    /// Full history for a group (oldest → newest), capped by ring.
126    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    /// Instance ids with a recent receipt for `widget_id` (age ≤ `max_age_ms`).
140    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    /// Persist all groups to JSON at `path`.
150    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    /// Load receipts from disk (best-effort; ignores corrupt files).
173    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
190/// Default disk path under an app data dir.
191pub 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}