Skip to main content

brows3r_lib/
events.rs

1//! Typed event emission helper.
2//!
3//! # OCP contract
4//!
5//! - `EventKind::as_str()` is the **single canonical name source** for all
6//!   server→client event names.  Adding an event means adding one variant plus
7//!   one match arm — no other site changes.
8//! - The `EventEmitter` trait + `MockChannel` ensure the same code path runs
9//!   in tests and production.  Tests never reach the Tauri runtime.
10//!
11//! # Usage
12//!
13//! ```text
14//! events::emit(&app_handle, EventKind::ObjectsUpdated, payload)?;
15//! ```
16
17use serde::Serialize;
18
19use crate::error::AppError;
20
21// ---------------------------------------------------------------------------
22// EventKind
23// ---------------------------------------------------------------------------
24
25/// Every event that the backend can emit to the frontend.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum EventKind {
28    BucketsUpdated,
29    ObjectsUpdated,
30    TransferProgress,
31    TransferState,
32    LockAcquired,
33    LockReleased,
34    NotificationNew,
35    SearchPage,
36    MediaRevoked,
37    UpdaterStatus,
38    /// Emitted when the OS keychain is unavailable and the FileBackend
39    /// requires a passphrase to unlock. The Credential Manager UI shows
40    /// the fallback prompt exactly once per session in response.
41    KeychainFallbackRequired,
42}
43
44impl EventKind {
45    /// The canonical event name string used on the IPC channel.
46    ///
47    /// This is the single source of truth — frontend listeners subscribe to
48    /// exactly these strings.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            Self::BucketsUpdated => "buckets:updated",
52            Self::ObjectsUpdated => "objects:updated",
53            Self::TransferProgress => "transfer:progress",
54            Self::TransferState => "transfer:state",
55            Self::LockAcquired => "lock:acquired",
56            Self::LockReleased => "lock:released",
57            Self::NotificationNew => "notification:new",
58            Self::SearchPage => "search:page",
59            Self::MediaRevoked => "media:revoked",
60            Self::UpdaterStatus => "updater:status",
61            Self::KeychainFallbackRequired => "keychain:fallback-required",
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// EventEmitter trait
68// ---------------------------------------------------------------------------
69
70/// Abstraction over anything that can emit Tauri-style events.
71///
72/// `tauri::AppHandle` implements this via the real Tauri runtime.
73/// `MockChannel` implements it for tests.
74pub trait EventEmitter {
75    fn emit<P: Serialize + Clone>(&self, kind: EventKind, payload: P) -> Result<(), AppError>;
76}
77
78// ---------------------------------------------------------------------------
79// AppHandle impl
80// ---------------------------------------------------------------------------
81
82impl EventEmitter for tauri::AppHandle {
83    fn emit<P: Serialize + Clone>(&self, kind: EventKind, payload: P) -> Result<(), AppError> {
84        tauri::Emitter::emit(self, kind.as_str(), payload).map_err(|e| AppError::Network {
85            source: format!("tauri emit error: {e}"),
86        })
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Free convenience function
92// ---------------------------------------------------------------------------
93
94/// Emit an event through any `EventEmitter` channel.
95///
96/// This thin wrapper lets call sites omit the `.emit(…)` method chain and
97/// keeps the signature uniform across production and test code.
98pub fn emit<P, E>(channel: &E, kind: EventKind, payload: P) -> Result<(), AppError>
99where
100    P: Serialize + Clone,
101    E: EventEmitter,
102{
103    channel.emit(kind, payload)
104}
105
106// ---------------------------------------------------------------------------
107// MockChannel — for tests only
108// ---------------------------------------------------------------------------
109
110/// In-memory event recorder used in tests.
111///
112/// Records every `(EventKind, serde_json::Value)` pair in insertion order so
113/// test assertions can check both *what* was emitted and *what payload* it
114/// carried.
115#[cfg(test)]
116#[derive(Default)]
117pub struct MockChannel {
118    recorded: std::sync::Mutex<Vec<(EventKind, serde_json::Value)>>,
119}
120
121#[cfg(test)]
122impl MockChannel {
123    /// Drain all recorded emissions as a `Vec`.
124    pub fn emitted(&self) -> Vec<(EventKind, serde_json::Value)> {
125        self.recorded.lock().expect("lock poisoned").clone()
126    }
127}
128
129#[cfg(test)]
130impl EventEmitter for MockChannel {
131    fn emit<P: Serialize>(&self, kind: EventKind, payload: P) -> Result<(), AppError> {
132        let value = serde_json::to_value(payload).expect("MockChannel: payload must serialize");
133        self.recorded
134            .lock()
135            .expect("lock poisoned")
136            .push((kind, value));
137        Ok(())
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Tests
143// ---------------------------------------------------------------------------
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use serde_json::json;
149
150    #[test]
151    fn all_event_kind_strings_are_unique() {
152        let kinds = [
153            EventKind::BucketsUpdated,
154            EventKind::ObjectsUpdated,
155            EventKind::TransferProgress,
156            EventKind::TransferState,
157            EventKind::LockAcquired,
158            EventKind::LockReleased,
159            EventKind::NotificationNew,
160            EventKind::SearchPage,
161            EventKind::MediaRevoked,
162            EventKind::UpdaterStatus,
163            EventKind::KeychainFallbackRequired,
164        ];
165        let mut seen = std::collections::HashSet::new();
166        for k in &kinds {
167            let s = k.as_str();
168            assert!(seen.insert(s), "duplicate event name: {s}");
169        }
170    }
171
172    #[test]
173    fn event_kind_as_str_values() {
174        assert_eq!(EventKind::BucketsUpdated.as_str(), "buckets:updated");
175        assert_eq!(EventKind::ObjectsUpdated.as_str(), "objects:updated");
176        assert_eq!(EventKind::TransferProgress.as_str(), "transfer:progress");
177        assert_eq!(EventKind::TransferState.as_str(), "transfer:state");
178        assert_eq!(EventKind::LockAcquired.as_str(), "lock:acquired");
179        assert_eq!(EventKind::LockReleased.as_str(), "lock:released");
180        assert_eq!(EventKind::NotificationNew.as_str(), "notification:new");
181        assert_eq!(EventKind::SearchPage.as_str(), "search:page");
182        assert_eq!(EventKind::MediaRevoked.as_str(), "media:revoked");
183        assert_eq!(EventKind::UpdaterStatus.as_str(), "updater:status");
184        assert_eq!(
185            EventKind::KeychainFallbackRequired.as_str(),
186            "keychain:fallback-required"
187        );
188    }
189
190    #[test]
191    fn mock_channel_records_emission() {
192        let channel = MockChannel::default();
193        let payload = json!({
194            "profileId": "my-profile",
195            "bucket": "my-bucket",
196            "prefix": "folder/"
197        });
198        emit(&channel, EventKind::ObjectsUpdated, &payload).expect("emit should succeed");
199
200        let emitted = channel.emitted();
201        assert_eq!(emitted.len(), 1);
202        assert_eq!(emitted[0].0, EventKind::ObjectsUpdated);
203        assert_eq!(emitted[0].1["profileId"], "my-profile");
204        assert_eq!(emitted[0].1["bucket"], "my-bucket");
205        assert_eq!(emitted[0].1["prefix"], "folder/");
206    }
207
208    #[test]
209    fn mock_channel_records_multiple_emissions() {
210        let channel = MockChannel::default();
211        emit(
212            &channel,
213            EventKind::BucketsUpdated,
214            json!({"profileId": "p1"}),
215        )
216        .expect("first emit");
217        emit(
218            &channel,
219            EventKind::TransferState,
220            json!({"requestId": "r1", "state": "done"}),
221        )
222        .expect("second emit");
223
224        let emitted = channel.emitted();
225        assert_eq!(emitted.len(), 2);
226        assert_eq!(emitted[0].0, EventKind::BucketsUpdated);
227        assert_eq!(emitted[1].0, EventKind::TransferState);
228    }
229}