Skip to main content

oxide_browser/
events.rs

1//! Host-side event system for Oxide guest modules.
2//!
3//! Guests register listeners with `api_on_event(type, callback_id)` and receive
4//! events through the optional exported `on_event(callback_id: u32)` function.
5//! While the callback runs, the guest may read the current event type and
6//! payload via `api_event_type_*` and `api_event_data_*`.
7//!
8//! Two sources push events onto the per-guest queue:
9//!
10//! 1. **Custom events** emitted by the guest itself via `api_emit_event(type, data)`.
11//! 2. **Built-in events** produced by the host each tick:
12//!    - `resize` — payload is `(width: u32, height: u32)` little-endian.
13//!    - `focus` / `blur` — empty payload; fires when the canvas gains/loses focus.
14//!    - `visibility_change` — payload is `"visible"` or `"hidden"` UTF-8.
15//!    - `online` / `offline` — empty payload; backed by a 30 s reachability check.
16//!    - `touch_start` / `touch_move` / `touch_end` — payload is `(x: f32, y: f32)`
17//!      little-endian. Synthesised from primary mouse button + mouse position;
18//!      `touch_cancel` is reserved for real touch input on platforms that surface it.
19//!    - `gamepad_connected` — payload is the device name (UTF-8).
20//!    - `gamepad_button` — payload is `(gamepad_id: u32, button_code: u32, pressed: u32)`
21//!      little-endian (`pressed` = 1 for press, 0 for release).
22//!    - `gamepad_axis` — payload is `(gamepad_id: u32, axis_code: u32, value: f32)`
23//!      little-endian (`value` in [-1.0, 1.0]).
24//!    - `drop_files` — payload is a JSON array of dropped file paths (UTF-8).
25//!
26//! Built-in producers run inside [`drain_pending_events`], which the runtime
27//! calls once per frame before invoking the guest `on_event` exports.
28
29use std::collections::{HashMap, VecDeque};
30use std::sync::atomic::{AtomicU8, Ordering};
31use std::sync::mpsc::{self, Receiver, Sender};
32use std::sync::Arc;
33use std::thread;
34use std::time::Duration;
35
36use anyhow::Result;
37use wasmtime::{Caller, Linker};
38
39use crate::capabilities::{read_guest_bytes, read_guest_string, write_guest_bytes, HostState};
40
41/// Maximum events kept in a guest's pending queue. Older events are dropped
42/// on overflow to prevent unbounded growth from a slow / non-listening guest.
43const MAX_QUEUED_EVENTS: usize = 4096;
44
45/// `online_state` sentinel values.
46const ONLINE_UNKNOWN: u8 = 0;
47const ONLINE_YES: u8 = 1;
48const ONLINE_NO: u8 = 2;
49
50/// One queued event: type name and arbitrary opaque payload bytes.
51#[derive(Clone, Debug)]
52pub struct PendingEvent {
53    pub event_type: String,
54    pub data: Vec<u8>,
55}
56
57/// Event type and payload currently being delivered to the guest callback.
58#[derive(Clone, Default, Debug)]
59struct CurrentEvent {
60    event_type: String,
61    data: Vec<u8>,
62}
63
64/// Per-guest event system state: listener table, pending queue, current event,
65/// and built-in event detector state (last canvas size, focus, mouse, online).
66pub struct EventState {
67    /// `event_type` → ordered list of `(listener_id, callback_id)`.
68    /// Vec preserves insertion order so listeners fire in registration order.
69    listeners: HashMap<String, Vec<(u32, u32)>>,
70    /// Reverse index: `listener_id` → `event_type` (for `api_off_event`).
71    listener_owner: HashMap<u32, String>,
72    next_listener_id: u32,
73    queue: VecDeque<PendingEvent>,
74    current: CurrentEvent,
75
76    // Built-in detector state — updated inside `drain_pending_events`.
77    last_canvas_size: Option<(u32, u32)>,
78    last_focused: Option<bool>,
79    last_mouse_down: bool,
80    last_mouse_pos: (f32, f32),
81
82    // Online/offline: atomic shared with checker thread (ONLINE_UNKNOWN/YES/NO).
83    // Dropping `online_shutdown` signals the thread to exit via channel disconnect.
84    online_state: Arc<AtomicU8>,
85    last_online: Option<bool>,
86    online_thread_started: bool,
87    online_shutdown: Option<Sender<()>>,
88
89    // Gamepad: events arrive from poll thread via channel.
90    // Dropping `gamepad_shutdown` signals the thread to exit via channel disconnect.
91    gamepad_rx: Option<Receiver<PendingEvent>>,
92    gamepad_thread_started: bool,
93    gamepad_shutdown: Option<Sender<()>>,
94}
95
96impl Default for EventState {
97    fn default() -> Self {
98        Self {
99            listeners: HashMap::new(),
100            listener_owner: HashMap::new(),
101            next_listener_id: 1,
102            queue: VecDeque::new(),
103            current: CurrentEvent::default(),
104            last_canvas_size: None,
105            last_focused: None,
106            last_mouse_down: false,
107            last_mouse_pos: (0.0, 0.0),
108            online_state: Arc::new(AtomicU8::new(ONLINE_UNKNOWN)),
109            last_online: None,
110            online_thread_started: false,
111            online_shutdown: None,
112            gamepad_rx: None,
113            gamepad_thread_started: false,
114            gamepad_shutdown: None,
115        }
116    }
117}
118
119impl EventState {
120    fn alloc_listener_id(&mut self) -> u32 {
121        let id = self.next_listener_id;
122        self.next_listener_id = self.next_listener_id.wrapping_add(1).max(1);
123        id
124    }
125
126    fn add_listener(&mut self, event_type: String, callback_id: u32) -> u32 {
127        // Lazily kick off background producers when something is actually listening.
128        if (event_type == "online" || event_type == "offline") && !self.online_thread_started {
129            self.start_online_checker();
130        }
131        if event_type.starts_with("gamepad_") && !self.gamepad_thread_started {
132            self.start_gamepad_poll();
133        }
134        let listener_id = self.alloc_listener_id();
135        self.listeners
136            .entry(event_type.clone())
137            .or_default()
138            .push((listener_id, callback_id));
139        self.listener_owner.insert(listener_id, event_type);
140        listener_id
141    }
142
143    fn remove_listener(&mut self, listener_id: u32) -> bool {
144        let Some(event_type) = self.listener_owner.remove(&listener_id) else {
145            return false;
146        };
147        if let Some(vec) = self.listeners.get_mut(&event_type) {
148            vec.retain(|(lid, _)| *lid != listener_id);
149            if vec.is_empty() {
150                self.listeners.remove(&event_type);
151            }
152        }
153        true
154    }
155
156    fn enqueue(&mut self, event: PendingEvent) {
157        if self.listeners.contains_key(&event.event_type) {
158            if self.queue.len() >= MAX_QUEUED_EVENTS {
159                self.queue.pop_front();
160            }
161            self.queue.push_back(event);
162        }
163    }
164
165    fn start_online_checker(&mut self) {
166        self.online_thread_started = true;
167        let state = self.online_state.clone();
168        let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
169        self.online_shutdown = Some(shutdown_tx);
170        thread::Builder::new()
171            .name("oxide-online-checker".into())
172            .spawn(move || {
173                let client = match reqwest::blocking::Client::builder()
174                    .timeout(Duration::from_secs(5))
175                    .build()
176                {
177                    Ok(c) => c,
178                    Err(_) => return,
179                };
180                loop {
181                    let ok = client
182                        .head("https://www.cloudflare.com/cdn-cgi/trace")
183                        .send()
184                        .map(|r| r.status().is_success())
185                        .unwrap_or(false);
186                    state.store(if ok { ONLINE_YES } else { ONLINE_NO }, Ordering::Relaxed);
187                    // Sleep 30 s but wake immediately on shutdown (sender dropped).
188                    match shutdown_rx.recv_timeout(Duration::from_secs(30)) {
189                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return,
190                        Err(mpsc::RecvTimeoutError::Timeout) => {}
191                    }
192                }
193            })
194            .ok();
195    }
196
197    fn start_gamepad_poll(&mut self) {
198        self.gamepad_thread_started = true;
199        let (tx, rx) = mpsc::channel::<PendingEvent>();
200        let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
201        self.gamepad_rx = Some(rx);
202        self.gamepad_shutdown = Some(shutdown_tx);
203        thread::Builder::new()
204            .name("oxide-gamepad-poll".into())
205            .spawn(move || gamepad_poll_loop(tx, shutdown_rx))
206            .ok();
207    }
208}
209
210/// Drain built-in event sources + the pending custom-event queue and return
211/// `(callback_id, type, data)` tuples to dispatch via `on_event`.
212///
213/// `state` is mutably accessed once. The runtime then calls `on_event` for
214/// each returned tuple, after writing the type/data into [`EventState::current`]
215/// via [`set_current_event`].
216pub fn drain_pending_events(
217    events: &Arc<std::sync::Mutex<EventState>>,
218    canvas_size: (u32, u32),
219    focused: bool,
220    mouse_down: bool,
221    mouse_pos: (f32, f32),
222) -> Vec<(u32, String, Vec<u8>)> {
223    let mut g = events.lock().unwrap();
224
225    // ── Built-in: resize ──────────────────────────────────────────────
226    match g.last_canvas_size {
227        Some(prev) if prev == canvas_size => {}
228        _ => {
229            if g.last_canvas_size.is_some() {
230                let mut data = Vec::with_capacity(8);
231                data.extend_from_slice(&canvas_size.0.to_le_bytes());
232                data.extend_from_slice(&canvas_size.1.to_le_bytes());
233                g.enqueue(PendingEvent {
234                    event_type: "resize".into(),
235                    data,
236                });
237            }
238            g.last_canvas_size = Some(canvas_size);
239        }
240    }
241
242    // ── Built-in: focus / blur / visibility_change ────────────────────
243    match g.last_focused {
244        Some(prev) if prev == focused => {}
245        _ => {
246            if g.last_focused.is_some() {
247                let (focus_evt, vis_payload) = if focused {
248                    ("focus", b"visible".to_vec())
249                } else {
250                    ("blur", b"hidden".to_vec())
251                };
252                g.enqueue(PendingEvent {
253                    event_type: focus_evt.into(),
254                    data: Vec::new(),
255                });
256                g.enqueue(PendingEvent {
257                    event_type: "visibility_change".into(),
258                    data: vis_payload,
259                });
260            }
261            g.last_focused = Some(focused);
262        }
263    }
264
265    // ── Built-in: touch_start / touch_move / touch_end (mouse-synthesised) ─
266    let prev_down = g.last_mouse_down;
267    let prev_pos = g.last_mouse_pos;
268    if mouse_down && !prev_down {
269        g.enqueue(PendingEvent {
270            event_type: "touch_start".into(),
271            data: encode_xy(mouse_pos),
272        });
273    } else if mouse_down && prev_down && mouse_pos != prev_pos {
274        g.enqueue(PendingEvent {
275            event_type: "touch_move".into(),
276            data: encode_xy(mouse_pos),
277        });
278    } else if !mouse_down && prev_down {
279        g.enqueue(PendingEvent {
280            event_type: "touch_end".into(),
281            data: encode_xy(prev_pos),
282        });
283    }
284    g.last_mouse_down = mouse_down;
285    g.last_mouse_pos = mouse_pos;
286
287    // ── Built-in: online / offline ────────────────────────────────────
288    let online_raw = g.online_state.load(Ordering::Relaxed);
289    if online_raw != ONLINE_UNKNOWN {
290        let now = online_raw == ONLINE_YES;
291        match g.last_online {
292            Some(prev) if prev == now => {}
293            _ => {
294                if g.last_online.is_some() {
295                    g.enqueue(PendingEvent {
296                        event_type: if now { "online" } else { "offline" }.into(),
297                        data: Vec::new(),
298                    });
299                }
300                g.last_online = Some(now);
301            }
302        }
303    }
304
305    // ── Built-in: gamepad_* ───────────────────────────────────────────
306    if let Some(rx) = g.gamepad_rx.as_ref() {
307        let mut drained = Vec::new();
308        while let Ok(ev) = rx.try_recv() {
309            drained.push(ev);
310        }
311        for ev in drained {
312            g.enqueue(ev);
313        }
314    }
315
316    // ── Drain queue → dispatch tuples ─────────────────────────────────
317    let mut out = Vec::new();
318    while let Some(ev) = g.queue.pop_front() {
319        if let Some(vec) = g.listeners.get(&ev.event_type) {
320            for &(_, cb) in vec {
321                out.push((cb, ev.event_type.clone(), ev.data.clone()));
322            }
323        }
324    }
325    out
326}
327
328/// Push a freshly-dropped file batch onto the queue as a `drop_files` event.
329///
330/// Called from the UI layer when the GPUI canvas receives an external file drop.
331/// `paths` are encoded as a UTF-8 JSON array (e.g. `["/tmp/a.png","/tmp/b.png"]`).
332pub fn enqueue_drop_files(
333    events: &Arc<std::sync::Mutex<EventState>>,
334    paths: &[std::path::PathBuf],
335) {
336    let mut g = events.lock().unwrap();
337    let mut json = String::from("[");
338    for (i, p) in paths.iter().enumerate() {
339        if i > 0 {
340            json.push(',');
341        }
342        json.push('"');
343        for c in p.to_string_lossy().chars() {
344            match c {
345                '"' => json.push_str("\\\""),
346                '\\' => json.push_str("\\\\"),
347                '\n' => json.push_str("\\n"),
348                '\r' => json.push_str("\\r"),
349                '\t' => json.push_str("\\t"),
350                c if (c as u32) < 0x20 => json.push_str(&format!("\\u{:04x}", c as u32)),
351                c => json.push(c),
352            }
353        }
354        json.push('"');
355    }
356    json.push(']');
357    g.enqueue(PendingEvent {
358        event_type: "drop_files".into(),
359        data: json.into_bytes(),
360    });
361}
362
363/// Populate `current` so the guest can read the event type/data inside its
364/// `on_event` callback. Called by the runtime immediately before each
365/// `on_event(callback_id)` invocation.
366pub fn set_current_event(
367    events: &Arc<std::sync::Mutex<EventState>>,
368    event_type: String,
369    data: Vec<u8>,
370) {
371    let mut g = events.lock().unwrap();
372    g.current.event_type = event_type;
373    g.current.data = data;
374}
375
376fn encode_xy((x, y): (f32, f32)) -> Vec<u8> {
377    let mut data = Vec::with_capacity(8);
378    data.extend_from_slice(&x.to_le_bytes());
379    data.extend_from_slice(&y.to_le_bytes());
380    data
381}
382
383// ── Gamepad polling ──────────────────────────────────────────────────────────
384
385fn gamepad_poll_loop(tx: Sender<PendingEvent>, shutdown: Receiver<()>) {
386    use gilrs::{EventType, Gilrs};
387    let mut gilrs = match Gilrs::new() {
388        Ok(g) => g,
389        Err(_) => return,
390    };
391    loop {
392        while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
393            let id_u: u32 = Into::<usize>::into(id) as u32;
394            let pending = match event {
395                EventType::Connected => {
396                    let name = gilrs.gamepad(id).name().to_string();
397                    Some(PendingEvent {
398                        event_type: "gamepad_connected".into(),
399                        data: name.into_bytes(),
400                    })
401                }
402                EventType::ButtonPressed(btn, _) => Some(PendingEvent {
403                    event_type: "gamepad_button".into(),
404                    data: encode_button(id_u, btn as u32, true),
405                }),
406                EventType::ButtonReleased(btn, _) => Some(PendingEvent {
407                    event_type: "gamepad_button".into(),
408                    data: encode_button(id_u, btn as u32, false),
409                }),
410                EventType::AxisChanged(axis, value, _) => Some(PendingEvent {
411                    event_type: "gamepad_axis".into(),
412                    data: encode_axis(id_u, axis as u32, value),
413                }),
414                _ => None,
415            };
416            if let Some(ev) = pending {
417                if tx.send(ev).is_err() {
418                    return;
419                }
420            }
421        }
422        // Sleep 16 ms between polls but wake immediately on shutdown (sender dropped).
423        match shutdown.recv_timeout(Duration::from_millis(16)) {
424            Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return,
425            Err(mpsc::RecvTimeoutError::Timeout) => {}
426        }
427    }
428}
429
430fn encode_button(id: u32, code: u32, pressed: bool) -> Vec<u8> {
431    let mut data = Vec::with_capacity(12);
432    data.extend_from_slice(&id.to_le_bytes());
433    data.extend_from_slice(&code.to_le_bytes());
434    data.extend_from_slice(&(pressed as u32).to_le_bytes());
435    data
436}
437
438fn encode_axis(id: u32, code: u32, value: f32) -> Vec<u8> {
439    let mut data = Vec::with_capacity(12);
440    data.extend_from_slice(&id.to_le_bytes());
441    data.extend_from_slice(&code.to_le_bytes());
442    data.extend_from_slice(&value.to_le_bytes());
443    data
444}
445
446// ── Host function registration ───────────────────────────────────────────────
447
448/// Register `api_on_event`, `api_off_event`, `api_emit_event`, and the
449/// in-callback type/data read functions on the linker.
450pub fn register_event_functions(linker: &mut Linker<HostState>) -> Result<()> {
451    linker.func_wrap(
452        "oxide",
453        "api_on_event",
454        |caller: Caller<'_, HostState>, type_ptr: u32, type_len: u32, callback_id: u32| -> u32 {
455            let mem = match caller.data().memory {
456                Some(m) => m,
457                None => return 0,
458            };
459            let event_type = match read_guest_string(&mem, &caller, type_ptr, type_len) {
460                Ok(s) if !s.is_empty() => s,
461                _ => return 0,
462            };
463            let mut g = caller.data().events.lock().unwrap();
464            g.add_listener(event_type, callback_id)
465        },
466    )?;
467
468    linker.func_wrap(
469        "oxide",
470        "api_off_event",
471        |caller: Caller<'_, HostState>, listener_id: u32| -> u32 {
472            let mut g = caller.data().events.lock().unwrap();
473            u32::from(g.remove_listener(listener_id))
474        },
475    )?;
476
477    linker.func_wrap(
478        "oxide",
479        "api_emit_event",
480        |caller: Caller<'_, HostState>,
481         type_ptr: u32,
482         type_len: u32,
483         data_ptr: u32,
484         data_len: u32| {
485            let mem = match caller.data().memory {
486                Some(m) => m,
487                None => return,
488            };
489            let event_type = match read_guest_string(&mem, &caller, type_ptr, type_len) {
490                Ok(s) if !s.is_empty() => s,
491                _ => return,
492            };
493            let data = if data_len == 0 {
494                Vec::new()
495            } else {
496                read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default()
497            };
498            let mut g = caller.data().events.lock().unwrap();
499            g.enqueue(PendingEvent { event_type, data });
500        },
501    )?;
502
503    linker.func_wrap(
504        "oxide",
505        "api_event_type_len",
506        |caller: Caller<'_, HostState>| -> u32 {
507            let g = caller.data().events.lock().unwrap();
508            g.current.event_type.len() as u32
509        },
510    )?;
511
512    linker.func_wrap(
513        "oxide",
514        "api_event_type_read",
515        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
516            let mem = match caller.data().memory {
517                Some(m) => m,
518                None => return 0,
519            };
520            // Clone before write_guest_bytes borrows caller mutably.
521            let bytes = caller
522                .data()
523                .events
524                .lock()
525                .unwrap()
526                .current
527                .event_type
528                .clone()
529                .into_bytes();
530            let len = bytes.len().min(out_cap as usize);
531            if write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..len]).is_err() {
532                return 0;
533            }
534            len as u32
535        },
536    )?;
537
538    linker.func_wrap(
539        "oxide",
540        "api_event_data_len",
541        |caller: Caller<'_, HostState>| -> u32 {
542            let g = caller.data().events.lock().unwrap();
543            g.current.data.len() as u32
544        },
545    )?;
546
547    linker.func_wrap(
548        "oxide",
549        "api_event_data_read",
550        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
551            let mem = match caller.data().memory {
552                Some(m) => m,
553                None => return 0,
554            };
555            // Clone before write_guest_bytes borrows caller mutably.
556            let bytes = caller.data().events.lock().unwrap().current.data.clone();
557            let len = bytes.len().min(out_cap as usize);
558            if write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..len]).is_err() {
559                return 0;
560            }
561            len as u32
562        },
563    )?;
564
565    Ok(())
566}