diff --git a/Cargo.toml b/Cargo.toml index 83c8d80..5c64718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "oxide-sdk", "examples/hello-oxide", "examples/audio-player", + "examples/timer-demo", "examples/fullstack-notes/frontend", "examples/fullstack-notes/backend", ] diff --git a/ROADMAP.md b/ROADMAP.md index bee44f7..dd774ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,9 +139,9 @@ The core architecture is live: a Rust-native browser that fetches and executes ` ### Timer & Scheduling -- [ ] `set_timeout(callback_id, delay_ms)` — one-shot timer -- [ ] `set_interval(callback_id, interval_ms)` — repeating timer -- [ ] `clear_timeout(id)` / `clear_interval(id)` — cancel timers +- [x] `set_timeout(callback_id, delay_ms)` — one-shot timer +- [x] `set_interval(callback_id, interval_ms)` — repeating timer +- [x] `clear_timeout(id)` / `clear_interval(id)` — cancel timers - [ ] `request_animation_frame(callback_id)` — vsync-aligned frame callback - [ ] Cron-style scheduled tasks for long-running apps diff --git a/examples/timer-demo/Cargo.toml b/examples/timer-demo/Cargo.toml new file mode 100644 index 0000000..6397968 --- /dev/null +++ b/examples/timer-demo/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "timer-demo" +version = "0.1.0" +edition = "2021" +description = "Timer API demo for the Oxide browser" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +oxide-sdk = { path = "../../oxide-sdk" } diff --git a/examples/timer-demo/src/lib.rs b/examples/timer-demo/src/lib.rs new file mode 100644 index 0000000..1da1312 --- /dev/null +++ b/examples/timer-demo/src/lib.rs @@ -0,0 +1,277 @@ +use oxide_sdk::*; + +const BG: (u8, u8, u8) = (25, 25, 40); +const ACCENT: (u8, u8, u8) = (80, 160, 220); +const DIM: (u8, u8, u8) = (140, 130, 160); +const BRIGHT: (u8, u8, u8) = (230, 220, 255); +const GREEN: (u8, u8, u8) = (80, 220, 120); +const ORANGE: (u8, u8, u8) = (240, 180, 60); +const RED: (u8, u8, u8) = (220, 80, 80); +const CYAN: (u8, u8, u8) = (80, 220, 220); + +// Callback IDs for on_timer +const CB_COUNTDOWN: u32 = 1; +const CB_DELAYED_MSG: u32 = 2; +const CB_BLINK: u32 = 3; +const CB_STOPWATCH: u32 = 4; + +// Widget / button IDs +const BTN_START_COUNTDOWN: u32 = 100; +const BTN_FIRE_DELAY: u32 = 101; +const BTN_TOGGLE_BLINK: u32 = 102; +const BTN_STOPWATCH_START: u32 = 103; +const BTN_STOPWATCH_STOP: u32 = 104; +const BTN_STOPWATCH_RESET: u32 = 105; + +static mut COUNTDOWN: i32 = 0; +static mut COUNTDOWN_TIMER: u32 = 0; +static mut DELAYED_MSG: &str = ""; +static mut BLINK_ON: bool = false; +static mut BLINK_TIMER: u32 = 0; +static mut STOPWATCH_MS: u64 = 0; +static mut STOPWATCH_TIMER: u32 = 0; + +#[no_mangle] +pub extern "C" fn start_app() { + log("Timer Demo loaded!"); +} + +#[no_mangle] +pub extern "C" fn on_timer(callback_id: u32) { + match callback_id { + CB_COUNTDOWN => unsafe { + COUNTDOWN -= 1; + if COUNTDOWN <= 0 { + COUNTDOWN = 0; + clear_timer(COUNTDOWN_TIMER); + COUNTDOWN_TIMER = 0; + } + }, + CB_DELAYED_MSG => unsafe { + DELAYED_MSG = "Timer fired! This appeared after 3 seconds."; + }, + CB_BLINK => unsafe { + BLINK_ON = !BLINK_ON; + }, + CB_STOPWATCH => unsafe { + STOPWATCH_MS += 100; + }, + _ => {} + } +} + +#[no_mangle] +pub extern "C" fn on_frame(_dt_ms: u32) { + let (width, _height) = canvas_dimensions(); + let w = width as f32; + + canvas_clear(BG.0, BG.1, BG.2, 255); + + // ── Header ────────────────────────────────────────────────────── + canvas_rect(0.0, 0.0, w, 52.0, ACCENT.0, ACCENT.1, ACCENT.2, 255); + canvas_text(20.0, 14.0, 22.0, 255, 255, 255, "Oxide Timer Demo"); + canvas_text( + 20.0, + 36.0, + 11.0, + 200, + 220, + 255, + "set_timeout / set_interval / clear_timer", + ); + + // ── Countdown (set_interval) ──────────────────────────────────── + canvas_text( + 20.0, + 72.0, + 14.0, + DIM.0, + DIM.1, + DIM.2, + "COUNTDOWN (set_interval)", + ); + + let countdown = unsafe { COUNTDOWN }; + let running = unsafe { COUNTDOWN_TIMER } != 0; + + if ui_button( + BTN_START_COUNTDOWN, + 20.0, + 95.0, + 140.0, + 30.0, + "Start from 10", + ) && !running + { + unsafe { + COUNTDOWN = 10; + COUNTDOWN_TIMER = set_interval(CB_COUNTDOWN, 1000); + } + } + + let (count_text, count_color) = if countdown > 3 { + (format!("{countdown}"), GREEN) + } else if countdown > 0 { + (format!("{countdown}"), ORANGE) + } else if running { + ("0".into(), RED) + } else { + ("--".into(), DIM) + }; + + canvas_text( + 200.0, + 100.0, + 28.0, + count_color.0, + count_color.1, + count_color.2, + &count_text, + ); + + if countdown == 0 && !running { + canvas_text(260.0, 105.0, 14.0, DIM.0, DIM.1, DIM.2, "Done!"); + } + + // ── Delayed Message (set_timeout) ─────────────────────────────── + canvas_line(20.0, 145.0, w - 20.0, 145.0, 40, 35, 60, 1.0); + canvas_text( + 20.0, + 160.0, + 14.0, + DIM.0, + DIM.1, + DIM.2, + "DELAYED MESSAGE (set_timeout)", + ); + + if ui_button(BTN_FIRE_DELAY, 20.0, 183.0, 180.0, 30.0, "Fire after 3 sec") { + unsafe { DELAYED_MSG = "Waiting..." }; + set_timeout(CB_DELAYED_MSG, 3000); + } + + let msg = unsafe { DELAYED_MSG }; + if !msg.is_empty() { + let color = if msg.starts_with("Waiting") { + ORANGE + } else { + GREEN + }; + canvas_text(220.0, 190.0, 14.0, color.0, color.1, color.2, msg); + } + + // ── Blink (set_interval + clear_timer) ────────────────────────── + canvas_line(20.0, 230.0, w - 20.0, 230.0, 40, 35, 60, 1.0); + canvas_text( + 20.0, + 245.0, + 14.0, + DIM.0, + DIM.1, + DIM.2, + "BLINK (interval + clear)", + ); + + let blinking = unsafe { BLINK_TIMER } != 0; + let label = if blinking { + "Stop Blink" + } else { + "Start Blink" + }; + if ui_button(BTN_TOGGLE_BLINK, 20.0, 268.0, 120.0, 30.0, label) { + unsafe { + if blinking { + clear_timer(BLINK_TIMER); + BLINK_TIMER = 0; + BLINK_ON = false; + } else { + BLINK_ON = true; + BLINK_TIMER = set_interval(CB_BLINK, 500); + } + } + } + + let blink_on = unsafe { BLINK_ON }; + if blink_on { + canvas_circle(200.0, 283.0, 14.0, CYAN.0, CYAN.1, CYAN.2, 255); + } else { + canvas_circle(200.0, 283.0, 14.0, 50, 50, 60, 255); + } + + canvas_text( + 230.0, + 276.0, + 13.0, + DIM.0, + DIM.1, + DIM.2, + if blinking { + "Toggling every 500ms" + } else { + "Idle" + }, + ); + + // ── Stopwatch (set_interval + clear_timer) ────────────────────── + canvas_line(20.0, 315.0, w - 20.0, 315.0, 40, 35, 60, 1.0); + canvas_text( + 20.0, + 330.0, + 14.0, + DIM.0, + DIM.1, + DIM.2, + "STOPWATCH (100ms interval)", + ); + + let sw_running = unsafe { STOPWATCH_TIMER } != 0; + let sw_ms = unsafe { STOPWATCH_MS }; + + if ui_button(BTN_STOPWATCH_START, 20.0, 353.0, 80.0, 30.0, "Start") && !sw_running { + unsafe { + STOPWATCH_TIMER = set_interval(CB_STOPWATCH, 100); + } + } + if ui_button(BTN_STOPWATCH_STOP, 110.0, 353.0, 80.0, 30.0, "Stop") && sw_running { + unsafe { + clear_timer(STOPWATCH_TIMER); + STOPWATCH_TIMER = 0; + } + } + if ui_button(BTN_STOPWATCH_RESET, 200.0, 353.0, 80.0, 30.0, "Reset") && !sw_running { + unsafe { STOPWATCH_MS = 0 }; + } + + let secs = sw_ms / 1000; + let tenths = (sw_ms % 1000) / 100; + canvas_text( + 310.0, + 355.0, + 28.0, + BRIGHT.0, + BRIGHT.1, + BRIGHT.2, + &format!("{secs}.{tenths}s"), + ); + + // ── Info ───────────────────────────────────────────────────────── + canvas_line(20.0, 405.0, w - 20.0, 405.0, 40, 35, 60, 1.0); + canvas_text( + 20.0, + 420.0, + 12.0, + DIM.0, + DIM.1, + DIM.2, + "Timers fire via exported on_timer(callback_id). Intervals repeat until cleared.", + ); + canvas_text( + 20.0, + 440.0, + 12.0, + DIM.0, + DIM.1, + DIM.2, + "Resolution is tied to the frame rate (~16ms at 60fps).", + ); +} diff --git a/oxide-browser/src/capabilities.rs b/oxide-browser/src/capabilities.rs index feb2e71..a665872 100644 --- a/oxide-browser/src/capabilities.rs +++ b/oxide-browser/src/capabilities.rs @@ -84,8 +84,8 @@ pub struct HostState { pub console: Arc>>, pub canvas: Arc>, pub storage: Arc>>, - #[allow(dead_code)] pub timers: Arc>>, + pub timer_next_id: Arc>, pub clipboard: Arc>, pub clipboard_allowed: Arc>, pub kv_db: Option>, @@ -198,7 +198,6 @@ pub enum DrawCommand { }, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct TimerEntry { pub id: u32, @@ -207,6 +206,29 @@ pub struct TimerEntry { pub callback_id: u32, } +/// Drain all expired timers, returning their callback IDs. +/// One-shot timers are removed; interval timers are rescheduled. +pub fn drain_expired_timers(timers: &Arc>>) -> Vec { + let now = Instant::now(); + let mut guard = timers.lock().unwrap(); + let mut fired = Vec::new(); + let mut i = 0; + while i < guard.len() { + if guard[i].fire_at <= now { + fired.push(guard[i].callback_id); + if let Some(interval) = guard[i].interval { + guard[i].fire_at = now + interval; + i += 1; + } else { + guard.swap_remove(i); + } + } else { + i += 1; + } + } + fired +} + /// A clickable rectangular region on the canvas that acts as a hyperlink. #[derive(Clone, Debug)] pub struct Hyperlink { @@ -287,6 +309,7 @@ impl Default for HostState { })), storage: Arc::new(Mutex::new(HashMap::new())), timers: Arc::new(Mutex::new(Vec::new())), + timer_next_id: Arc::new(Mutex::new(1)), clipboard: Arc::new(Mutex::new(String::new())), clipboard_allowed: Arc::new(Mutex::new(false)), kv_db: None, @@ -774,6 +797,64 @@ pub fn register_host_functions(linker: &mut Linker) -> Result<()> { }, )?; + // ── Timers ──────────────────────────────────────────────────────── + // Timers fire via the guest-exported `on_timer(callback_id)` function, + // which the host calls from the frame loop for each expired timer. + + linker.func_wrap( + "oxide", + "api_set_timeout", + |caller: Caller<'_, HostState>, callback_id: u32, delay_ms: u32| -> u32 { + let mut next = caller.data().timer_next_id.lock().unwrap(); + let id = *next; + *next = next.wrapping_add(1).max(1); + drop(next); + + let entry = TimerEntry { + id, + fire_at: Instant::now() + Duration::from_millis(delay_ms as u64), + interval: None, + callback_id, + }; + caller.data().timers.lock().unwrap().push(entry); + id + }, + )?; + + linker.func_wrap( + "oxide", + "api_set_interval", + |caller: Caller<'_, HostState>, callback_id: u32, interval_ms: u32| -> u32 { + let mut next = caller.data().timer_next_id.lock().unwrap(); + let id = *next; + *next = next.wrapping_add(1).max(1); + drop(next); + + let interval = Duration::from_millis(interval_ms as u64); + let entry = TimerEntry { + id, + fire_at: Instant::now() + interval, + interval: Some(interval), + callback_id, + }; + caller.data().timers.lock().unwrap().push(entry); + id + }, + )?; + + linker.func_wrap( + "oxide", + "api_clear_timer", + |caller: Caller<'_, HostState>, timer_id: u32| { + caller + .data() + .timers + .lock() + .unwrap() + .retain(|t| t.id != timer_id); + }, + )?; + // ── Random ─────────────────────────────────────────────────────── linker.func_wrap( diff --git a/oxide-browser/src/runtime.rs b/oxide-browser/src/runtime.rs index 71bf921..51f41c8 100644 --- a/oxide-browser/src/runtime.rs +++ b/oxide-browser/src/runtime.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use wasmtime::*; use crate::bookmarks::BookmarkStore; -use crate::capabilities::{register_host_functions, HostState}; +use crate::capabilities::{drain_expired_timers, register_host_functions, HostState}; use crate::engine::{ModuleLoader, SandboxPolicy, WasmEngine}; use crate::url::OxideUrl; @@ -22,10 +22,34 @@ const FRAME_FUEL_LIMIT: u64 = 50_000_000; pub struct LiveModule { store: Store, on_frame_fn: TypedFunc, + on_timer_fn: Option>, } impl LiveModule { pub fn tick(&mut self, dt_ms: u32) -> Result<()> { + // Fire any expired timers before the frame update. + if let Some(ref on_timer) = self.on_timer_fn { + let timers = self.store.data().timers.clone(); + let fired = drain_expired_timers(&timers); + for callback_id in fired { + self.store + .set_fuel(FRAME_FUEL_LIMIT) + .context("failed to set timer fuel")?; + if let Err(e) = on_timer.call(&mut self.store, callback_id) { + let msg = if e.to_string().contains("fuel") { + format!("on_timer({callback_id}) fuel limit exceeded") + } else { + format!("on_timer({callback_id}) trapped: {e}") + }; + crate::capabilities::console_log( + &self.store.data().console, + crate::capabilities::ConsoleLevel::Error, + msg, + ); + } + } + } + self.store .set_fuel(FRAME_FUEL_LIMIT) .context("failed to set per-frame fuel")?; @@ -169,10 +193,16 @@ impl BrowserHost { match start_app.call(&mut store, ()) { Ok(()) => { - // If the guest also exports on_frame, keep the instance alive for the frame loop. if let Ok(on_frame_fn) = instance.get_typed_func::(&mut store, "on_frame") { - Ok(Some(LiveModule { store, on_frame_fn })) + let on_timer_fn = instance + .get_typed_func::(&mut store, "on_timer") + .ok(); + Ok(Some(LiveModule { + store, + on_frame_fn, + on_timer_fn, + })) } else { Ok(None) } diff --git a/oxide-sdk/src/lib.rs b/oxide-sdk/src/lib.rs index e1ef3c8..68a0852 100644 --- a/oxide-sdk/src/lib.rs +++ b/oxide-sdk/src/lib.rs @@ -77,6 +77,15 @@ extern "C" { #[link_name = "api_time_now_ms"] fn _api_time_now_ms() -> u64; + #[link_name = "api_set_timeout"] + fn _api_set_timeout(callback_id: u32, delay_ms: u32) -> u32; + + #[link_name = "api_set_interval"] + fn _api_set_interval(callback_id: u32, interval_ms: u32) -> u32; + + #[link_name = "api_clear_timer"] + fn _api_clear_timer(timer_id: u32); + #[link_name = "api_random"] fn _api_random() -> u64; @@ -459,6 +468,25 @@ pub fn time_now_ms() -> u64 { unsafe { _api_time_now_ms() } } +/// Schedule a one-shot timer that fires after `delay_ms` milliseconds. +/// When it fires the host calls your exported `on_timer(callback_id)`. +/// Returns a timer ID that can be passed to [`clear_timer`]. +pub fn set_timeout(callback_id: u32, delay_ms: u32) -> u32 { + unsafe { _api_set_timeout(callback_id, delay_ms) } +} + +/// Schedule a repeating timer that fires every `interval_ms` milliseconds. +/// When it fires the host calls your exported `on_timer(callback_id)`. +/// Returns a timer ID that can be passed to [`clear_timer`]. +pub fn set_interval(callback_id: u32, interval_ms: u32) -> u32 { + unsafe { _api_set_interval(callback_id, interval_ms) } +} + +/// Cancel a timer previously created with [`set_timeout`] or [`set_interval`]. +pub fn clear_timer(timer_id: u32) { + unsafe { _api_clear_timer(timer_id) } +} + // ─── Random API ───────────────────────────────────────────────────────────── /// Get a random u64 from the host.