Skip to main content

tauri_plugin_auditaur/
lib.rs

1pub mod commands;
2pub mod desktop;
3pub mod error;
4pub mod ipc;
5pub mod state;
6pub mod test_helpers;
7pub mod tracing;
8
9pub use auditaur_core::AuditaurConfig;
10use auditaur_core::{
11    drive_bridge::{DRIVE_BRIDGE_REQUESTS_DIR, DRIVE_BRIDGE_REQUEST_EVENT},
12    model::TauriWindowState,
13};
14pub use ipc::{
15    ipc_traceparent, ipc_traceparent_from_request, ipc_traceparent_from_request_or_context,
16    IpcTraceContext, IPC_CONTEXT_ARG, IPC_TRACEPARENT_HEADER,
17};
18use serde_json::{json, Map, Value};
19use std::{fs, path::Path, thread, time::Duration};
20use tauri::{
21    plugin::{Builder as TauriPluginBuilder, TauriPlugin},
22    Emitter, Manager, Runtime, WebviewWindow, Window, WindowEvent,
23};
24pub use tauri_plugin_auditaur_macros::{auditaur_command, instrument_ipc};
25pub use tracing::tracing_layer;
26
27#[cfg(test)]
28pub(crate) mod test_support {
29    use std::sync::{Mutex, MutexGuard};
30
31    static GLOBAL_STATE_LOCK: Mutex<()> = Mutex::new(());
32
33    pub(crate) fn global_state_lock() -> MutexGuard<'static, ()> {
34        GLOBAL_STATE_LOCK.lock().unwrap()
35    }
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct Builder {
40    config: AuditaurConfig,
41}
42
43impl Builder {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn service_name(mut self, service_name: impl Into<String>) -> Self {
49        self.config.service_name = Some(service_name.into());
50        self
51    }
52
53    pub fn session_name(mut self, session_name: impl Into<String>) -> Self {
54        self.config.session_name = Some(session_name.into());
55        self
56    }
57
58    pub fn redact_defaults(mut self, redact_defaults: bool) -> Self {
59        self.config.redact_defaults = redact_defaults;
60        self
61    }
62
63    pub fn max_session_bytes(mut self, max_session_bytes: u64) -> Self {
64        self.config.max_session_bytes = max_session_bytes;
65        self
66    }
67
68    pub fn allow_release_builds(mut self, allow_release_builds: bool) -> Self {
69        self.config.allow_release_builds = allow_release_builds;
70        self
71    }
72
73    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
74        let config = self.config;
75        TauriPluginBuilder::new("auditaur")
76            .invoke_handler(tauri::generate_handler![
77                commands::export_otel_batch,
78                commands::register_drive_bridge,
79                commands::poll_drive_bridge_request,
80                commands::complete_drive_bridge_request,
81            ])
82            .on_window_ready(|window| {
83                record_window_ready(&window);
84                register_window_lifecycle(window);
85            })
86            .setup(move |app, _api| {
87                let app_identifier = Some(app.config().identifier.clone());
88                let state = state::AuditaurState::initialize(
89                    config.clone(),
90                    std::process::id(),
91                    app_identifier,
92                )?;
93                capture_initial_windows(app, &state);
94                app.manage(state);
95                Ok(())
96            })
97            .build()
98    }
99}
100
101pub(crate) fn start_drive_bridge_request_notifier<R: Runtime>(
102    app: &tauri::AppHandle<R>,
103    bridge_dir: std::path::PathBuf,
104    alive: std::sync::Arc<std::sync::atomic::AtomicBool>,
105) {
106    let app = app.clone();
107    thread::spawn(move || {
108        let requests_dir = bridge_dir.join(DRIVE_BRIDGE_REQUESTS_DIR);
109        while alive.load(std::sync::atomic::Ordering::SeqCst) {
110            if has_pending_drive_bridge_request(&requests_dir) {
111                if let Err(error) = app.emit(
112                    DRIVE_BRIDGE_REQUEST_EVENT,
113                    json!({ "reason": "request_file_available" }),
114                ) {
115                    ::tracing::debug!(error = %error, "Auditaur drive bridge request wake emit failed");
116                }
117            }
118            thread::sleep(Duration::from_millis(250));
119        }
120    });
121}
122
123fn has_pending_drive_bridge_request(requests_dir: &Path) -> bool {
124    let Ok(entries) = fs::read_dir(requests_dir) else {
125        return false;
126    };
127    entries.filter_map(Result::ok).any(|entry| {
128        entry
129            .path()
130            .extension()
131            .is_some_and(|extension| extension == "json")
132    })
133}
134
135fn capture_initial_windows<R: Runtime>(app: &tauri::AppHandle<R>, state: &state::AuditaurState) {
136    let Some(session_id) = state.session_id.as_ref() else {
137        return;
138    };
139    let Some(store) = state.store() else {
140        return;
141    };
142    let Ok(store) = store.lock() else {
143        return;
144    };
145    for window in app.webview_windows().values() {
146        let record = window_state(session_id, window);
147        let _ = store.insert_tauri_window_state(&record);
148    }
149}
150
151fn register_window_lifecycle<R: Runtime>(window: Window<R>) {
152    let listener_window = window.clone();
153    window.on_window_event(move |event| record_window_event(&listener_window, event));
154}
155
156fn record_window_ready<R: Runtime>(window: &Window<R>) {
157    record_window_state(window, "window_ready", None);
158}
159
160fn record_window_event<R: Runtime>(window: &Window<R>, event: &WindowEvent) {
161    record_window_state(window, "window_event", Some(event));
162}
163
164fn record_window_state<R: Runtime>(window: &Window<R>, capture: &str, event: Option<&WindowEvent>) {
165    let Some(state) = window.try_state::<state::AuditaurState>() else {
166        return;
167    };
168    let Some(session_id) = state.session_id.as_ref() else {
169        return;
170    };
171    let Some(store) = state.store() else {
172        return;
173    };
174    let Ok(store) = store.lock() else {
175        return;
176    };
177    let size = window.inner_size().ok();
178    let attributes = window_attributes(capture, event);
179    let record = TauriWindowState {
180        session_id: session_id.to_string(),
181        timestamp_unix_nanos: now_unix_nanos(),
182        window_label: window.label().to_string(),
183        webview_label: None,
184        url: None,
185        title: window.title().ok(),
186        focused: window.is_focused().ok(),
187        visible: window.is_visible().ok(),
188        width: size.as_ref().map(|size| f64::from(size.width)),
189        height: size.as_ref().map(|size| f64::from(size.height)),
190        scale_factor: window.scale_factor().ok(),
191        attributes,
192    };
193    let _ = store.insert_tauri_window_state(&record);
194}
195
196fn window_attributes(capture: &str, event: Option<&WindowEvent>) -> Value {
197    let mut attributes = Map::new();
198    attributes.insert("auditaur.capture".to_string(), json!(capture));
199
200    if let Some(event) = event {
201        attributes.extend(window_event_attributes(event));
202    }
203
204    Value::Object(attributes)
205}
206
207fn window_event_attributes(event: &WindowEvent) -> Map<String, Value> {
208    let mut attributes = Map::new();
209    attributes.insert(
210        "tauri.window.event".to_string(),
211        json!(window_event_kind(event)),
212    );
213    attributes.insert(
214        "tauri.window.event_debug".to_string(),
215        json!(format!("{event:?}")),
216    );
217
218    match event {
219        WindowEvent::Resized(size) => {
220            attributes.insert("tauri.window.event.width".to_string(), json!(size.width));
221            attributes.insert("tauri.window.event.height".to_string(), json!(size.height));
222        }
223        WindowEvent::Moved(position) => {
224            attributes.insert("tauri.window.event.x".to_string(), json!(position.x));
225            attributes.insert("tauri.window.event.y".to_string(), json!(position.y));
226        }
227        WindowEvent::Focused(focused) => {
228            attributes.insert("tauri.window.event.focused".to_string(), json!(focused));
229        }
230        WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
231            attributes.insert(
232                "tauri.window.event.scale_factor".to_string(),
233                json!(scale_factor),
234            );
235        }
236        WindowEvent::ThemeChanged(theme) => {
237            attributes.insert(
238                "tauri.window.event.theme".to_string(),
239                json!(format!("{theme:?}")),
240            );
241        }
242        _ => {}
243    }
244
245    attributes
246}
247
248fn window_event_kind(event: &WindowEvent) -> &'static str {
249    match event {
250        WindowEvent::Resized(_) => "resized",
251        WindowEvent::Moved(_) => "moved",
252        WindowEvent::CloseRequested { .. } => "close_requested",
253        WindowEvent::Destroyed => "destroyed",
254        WindowEvent::Focused(true) => "focused",
255        WindowEvent::Focused(false) => "blurred",
256        WindowEvent::ScaleFactorChanged { .. } => "scale_factor_changed",
257        WindowEvent::DragDrop(_) => "drag_drop",
258        WindowEvent::ThemeChanged(_) => "theme_changed",
259        _ => "unknown",
260    }
261}
262
263fn window_state<R: Runtime>(session_id: &str, window: &WebviewWindow<R>) -> TauriWindowState {
264    let size = window.inner_size().ok();
265    TauriWindowState {
266        session_id: session_id.to_string(),
267        timestamp_unix_nanos: now_unix_nanos(),
268        window_label: window.label().to_string(),
269        webview_label: Some(window.label().to_string()),
270        url: None,
271        title: window.title().ok(),
272        focused: window.is_focused().ok(),
273        visible: window.is_visible().ok(),
274        width: size.as_ref().map(|size| f64::from(size.width)),
275        height: size.as_ref().map(|size| f64::from(size.height)),
276        scale_factor: window.scale_factor().ok(),
277        attributes: json!({ "auditaur.capture": "initial_window_state" }),
278    }
279}
280
281fn now_unix_nanos() -> i64 {
282    let now = std::time::SystemTime::now()
283        .duration_since(std::time::UNIX_EPOCH)
284        .unwrap_or_default();
285    i64::try_from(now.as_nanos()).unwrap_or(i64::MAX)
286}
287
288#[cfg(test)]
289mod window_tests {
290    use super::{window_attributes, window_event_attributes};
291
292    #[test]
293    fn focused_window_events_record_authoritative_event_state() {
294        let attributes = window_event_attributes(&tauri::WindowEvent::Focused(false));
295
296        assert_eq!(attributes["tauri.window.event"], "blurred");
297        assert_eq!(attributes["tauri.window.event.focused"], false);
298    }
299
300    #[test]
301    fn resize_window_events_record_authoritative_event_size() {
302        let attributes = window_event_attributes(&tauri::WindowEvent::Resized(
303            tauri::PhysicalSize::new(800, 600),
304        ));
305
306        assert_eq!(attributes["tauri.window.event"], "resized");
307        assert_eq!(attributes["tauri.window.event.width"], 800);
308        assert_eq!(attributes["tauri.window.event.height"], 600);
309    }
310
311    #[test]
312    fn moved_window_events_record_authoritative_event_position() {
313        let attributes = window_event_attributes(&tauri::WindowEvent::Moved(
314            tauri::PhysicalPosition::new(12, 34),
315        ));
316
317        assert_eq!(attributes["tauri.window.event"], "moved");
318        assert_eq!(attributes["tauri.window.event.x"], 12);
319        assert_eq!(attributes["tauri.window.event.y"], 34);
320    }
321
322    #[test]
323    fn capture_only_window_attributes_do_not_claim_an_event() {
324        let attributes = window_attributes("window_ready", None);
325
326        assert_eq!(attributes["auditaur.capture"], "window_ready");
327        assert!(attributes.get("tauri.window.event").is_none());
328    }
329}