1
//! The ACTION JOURNAL: a bounded breadcrumb trail of what the user just did.
2
//!
3
//! Every event callback the engine dispatches leaves one entry — when it
4
//! fired, which node it hit, and the resolved handler name (the same
5
//! `cb:`-style resolution the probe uses: `dladdr`, then `addr2line`, then a
6
//! module-relative offset). A problem report or crash dump can then answer
7
//! "what happened right before this" without the app instrumenting anything.
8
//!
9
//! Bounded by construction: a fixed-capacity ring, oldest dropped first, so
10
//! a long session costs the same as a short one. Recording is OFF until
11
//! something enables it — the report dialog and the crash hook do, and an
12
//! app can via [`set_enabled`] — because an app that never files reports
13
//! should not pay even this much.
14
//!
15
//! It records HANDLER NAMES AND NODES, never user data: no text, no field
16
//! contents, no clipboard. What the user typed is their business; that a
17
//! `submit_form` handler ran on `#login` is the diagnostic.
18

            
19
use alloc::{
20
    string::{String, ToString},
21
    vec::Vec,
22
};
23
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
24
use std::sync::{Mutex, OnceLock};
25

            
26
use azul_core::dom::DomNodeId;
27

            
28
/// Entries kept before the oldest is dropped.
29
pub const DEFAULT_CAPACITY: usize = 64;
30

            
31
/// One dispatched callback.
32
#[derive(Debug, Clone, PartialEq, Eq)]
33
pub struct ActionEntry {
34
    /// Unix milliseconds when the callback was dispatched.
35
    pub unix_millis: u64,
36
    /// `dom.node` the event hit (`root.-` when the event had no node).
37
    pub node: String,
38
    /// Resolved handler name, or an address when no symbol was available.
39
    pub callback: String,
40
}
41

            
42
static ENABLED: AtomicBool = AtomicBool::new(false);
43
static CAPACITY: AtomicUsize = AtomicUsize::new(DEFAULT_CAPACITY);
44

            
45
20
fn ring() -> &'static Mutex<Vec<ActionEntry>> {
46
    static RING: OnceLock<Mutex<Vec<ActionEntry>>> = OnceLock::new();
47
20
    RING.get_or_init(|| Mutex::new(Vec::new()))
48
20
}
49

            
50
/// Turns recording on or off. Off (the default) makes [`record`] a single
51
/// relaxed atomic load.
52
5
pub fn set_enabled(on: bool) {
53
5
    ENABLED.store(on, Ordering::Relaxed);
54
5
    if !on {
55
3
        clear();
56
3
    }
57
5
}
58

            
59
/// Whether the journal is recording.
60
#[must_use]
61
pub fn is_enabled() -> bool {
62
    ENABLED.load(Ordering::Relaxed)
63
}
64

            
65
/// Sets how many entries are retained (minimum 1).
66
4
pub fn set_capacity(entries: usize) {
67
4
    CAPACITY.store(entries.max(1), Ordering::Relaxed);
68
4
}
69

            
70
/// Drops every recorded entry.
71
5
pub fn clear() {
72
5
    if let Ok(mut ring) = ring().lock() {
73
5
        ring.clear();
74
5
    }
75
5
}
76

            
77
/// Records one dispatched callback. Cheap and non-blocking when disabled;
78
/// a poisoned/contended lock drops the entry rather than stalling the UI
79
/// thread — a breadcrumb is never worth a frame.
80
12
pub fn record(node: DomNodeId, callback_ptr: usize) {
81
12
    if !ENABLED.load(Ordering::Relaxed) {
82
1
        return;
83
11
    }
84
11
    let unix_millis = std::time::SystemTime::now()
85
11
        .duration_since(std::time::UNIX_EPOCH)
86
11
        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
87
11
    let node_str = match node.node.into_crate_internal() {
88
11
        Some(id) => alloc::format!("{}.{}", node.dom.inner, id.index()),
89
        None => alloc::format!("{}.-", node.dom.inner),
90
    };
91
11
    let entry = ActionEntry {
92
11
        unix_millis,
93
11
        node: node_str,
94
11
        callback: crate::probe::callback_name(callback_ptr).to_string(),
95
11
    };
96
11
    let Ok(mut ring) = ring().try_lock() else {
97
        return;
98
    };
99
11
    let cap = CAPACITY.load(Ordering::Relaxed).max(1);
100
11
    if ring.len() >= cap {
101
6
        let overflow = ring.len() - cap + 1;
102
6
        ring.drain(..overflow);
103
6
    }
104
11
    ring.push(entry);
105
12
}
106

            
107
/// The most recent entries, oldest first, at most `max` of them.
108
#[must_use]
109
4
pub fn recent(max: usize) -> Vec<ActionEntry> {
110
4
    let Ok(ring) = ring().lock() else {
111
        return Vec::new();
112
    };
113
4
    let start = ring.len().saturating_sub(max);
114
4
    ring[start..].to_vec()
115
4
}
116

            
117
/// The most recent entries as a JSON array — what a report attaches.
118
#[must_use]
119
2
pub fn recent_json(max: usize) -> String {
120
2
    let entries = recent(max);
121
    use core::fmt::Write as _;
122
2
    let mut out = String::from("[");
123
2
    for (i, e) in entries.iter().enumerate() {
124
1
        if i > 0 {
125
            out.push(',');
126
1
        }
127
1
        let _ = write!(
128
1
            out,
129
1
            r#"{{"unix_millis":{},"node":"{}","callback":"{}"}}"#,
130
            e.unix_millis,
131
1
            escape(&e.node),
132
1
            escape(&e.callback),
133
        );
134
    }
135
2
    out.push(']');
136
2
    out
137
2
}
138

            
139
/// Minimal JSON string escaping — journal fields are symbol names and ids,
140
/// but a symbol name can carry `"` or `\` and must not break the document.
141
3
fn escape(s: &str) -> String {
142
3
    let mut out = String::with_capacity(s.len());
143
12
    for c in s.chars() {
144
10
        match c {
145
1
            '"' => out.push_str("\\\""),
146
1
            '\\' => out.push_str("\\\\"),
147
            '\n' => out.push_str("\\n"),
148
            '\r' => out.push_str("\\r"),
149
            '\t' => out.push_str("\\t"),
150
10
            c if (c as u32) < 0x20 => {
151
                use core::fmt::Write as _;
152
                let _ = write!(out, "\\u{:04x}", c as u32);
153
            }
154
10
            c => out.push(c),
155
        }
156
    }
157
3
    out
158
3
}
159

            
160
#[cfg(test)]
161
mod tests {
162
    use super::*;
163
    use azul_core::{
164
        dom::{DomId, DomNodeId},
165
        styled_dom::NodeHierarchyItemId,
166
    };
167
    use azul_core::id::NodeId;
168

            
169
    /// The journal is process-global state, so its tests must not run
170
    /// concurrently with each other — without this they interleave
171
    /// enable/clear/record and fail at random.
172
3
    fn serial() -> std::sync::MutexGuard<'static, ()> {
173
        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
174
3
        LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
175
3
    }
176

            
177
12
    fn node(idx: usize) -> DomNodeId {
178
12
        DomNodeId {
179
12
            dom: DomId::ROOT_ID,
180
12
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
181
12
        }
182
12
    }
183

            
184
    /// LAW: disabled means NOTHING is retained — the journal must not be a
185
    /// silent always-on recorder of what the user touched.
186
    #[test]
187
1
    fn recording_is_off_until_enabled() {
188
1
        let _serial = serial();
189
1
        set_enabled(false);
190
1
        record(node(1), 0);
191
1
        assert!(recent(10).is_empty(), "a disabled journal must record nothing");
192
1
        assert_eq!(recent_json(10), "[]");
193
1
    }
194

            
195
    /// LAW: the ring is BOUNDED — a long session costs the same as a short
196
    /// one, and the entries kept are the most recent ones.
197
    #[test]
198
1
    fn the_ring_drops_the_oldest_and_keeps_the_newest() {
199
1
        let _serial = serial();
200
1
        set_enabled(true);
201
1
        set_capacity(4);
202
1
        clear();
203
11
        for i in 0..10 {
204
10
            record(node(i), 0);
205
10
        }
206
1
        let kept = recent(100);
207
1
        assert_eq!(kept.len(), 4, "capacity must bound the ring");
208
1
        assert_eq!(kept[0].node, "0.6", "oldest kept is entry 6 of 0..10");
209
1
        assert_eq!(kept[3].node, "0.9", "newest kept is the last recorded");
210
1
        set_enabled(false);
211
1
        set_capacity(DEFAULT_CAPACITY);
212
1
    }
213

            
214
    #[test]
215
1
    fn json_is_well_formed_and_escapes() {
216
1
        let _serial = serial();
217
1
        set_enabled(true);
218
1
        set_capacity(8);
219
1
        clear();
220
1
        record(node(3), 0);
221
1
        let json = recent_json(8);
222
1
        let parsed: serde_json::Value =
223
1
            serde_json::from_str(&json).expect("the journal must emit parseable JSON");
224
1
        let arr = parsed.as_array().expect("a JSON array");
225
1
        assert_eq!(arr.len(), 1);
226
1
        assert_eq!(arr[0]["node"], "0.3");
227
1
        assert_eq!(escape(r#"a"b\c"#), r#"a\"b\\c"#);
228
1
        set_enabled(false);
229
1
        set_capacity(DEFAULT_CAPACITY);
230
1
    }
231
}