Skip to main content

oxide_browser/
runtime.rs

1//! Guest WebAssembly lifecycle for the Oxide browser.
2//!
3//! This module coordinates fetching `.wasm` binaries (HTTP/HTTPS and `file://`), compiling them
4//! with Wasmtime, applying the sandbox policy, and linking the `oxide` import module (memory,
5//! host capabilities). After `start_app()` runs, interactive guests may export `on_frame(dt_ms:
6//! u32)` for a per-frame render loop; optional `on_timer(callback_id: u32)` callbacks run when
7//! timers expire, immediately before each frame.
8
9use std::sync::{Arc, Mutex};
10
11use anyhow::{Context, Result};
12use wasmtime::*;
13
14use crate::bookmarks::BookmarkStore;
15use crate::capabilities::{
16    drain_animation_frame_requests, drain_expired_timers, register_host_functions, HostState,
17};
18use crate::engine::{ModuleLoader, SandboxPolicy, WasmEngine};
19use crate::events::{drain_pending_events, set_current_event};
20use crate::history::HistoryStore;
21use crate::url::OxideUrl;
22
23/// Current lifecycle state of a browser tab, reflected in the UI and shared across threads.
24#[derive(Clone, Debug, PartialEq)]
25pub enum PageStatus {
26    /// No navigation in progress; ready for a new load.
27    Idle,
28    /// A URL is being resolved and the `.wasm` module fetched or read (the string is the URL or path being loaded).
29    Loading(String),
30    /// Guest code is active after a successful load; the string identifies the page (URL or a local placeholder).
31    Running(String),
32    /// Load, compile, or `start_app` failed; the string is a human-readable error message.
33    Error(String),
34}
35
36const FRAME_FUEL_LIMIT: u64 = 50_000_000;
37
38/// A Wasmtime [`Store`] and typed guest exports kept alive across frames for interactive apps.
39///
40/// Constructed when the module exports `on_frame`. The store holds [`HostState`] (canvas, console,
41/// timers, animation requests, etc.) for the lifetime of the tab.
42pub struct LiveModule {
43    store: Store<HostState>,
44    on_frame_fn: TypedFunc<u32, ()>,
45    on_timer_fn: Option<TypedFunc<u32, ()>>,
46    on_event_fn: Option<TypedFunc<u32, ()>>,
47}
48
49impl LiveModule {
50    /// Advances one frame: drains animation frame requests and expired timers (both invoke
51    /// `on_timer(callback_id)`), then calls `on_frame(dt_ms)`.
52    ///
53    /// Animation requests (from `request_animation_frame`) are one-shot and fire every frame
54    /// they are queued. All callbacks run with bounded fuel. Errors are logged to console.
55    pub fn tick(&mut self, dt_ms: u32) -> Result<()> {
56        // Event dispatch first: gives the guest a chance to react to resize,
57        // input, and custom events before the next frame is composed.
58        if let Some(ref on_event) = self.on_event_fn {
59            let data = self.store.data();
60            let canvas_size = {
61                let c = data.canvas.lock().unwrap();
62                (c.width, c.height)
63            };
64            let focused = data.focused.load(std::sync::atomic::Ordering::Relaxed);
65            let (mouse_down, mouse_pos) = {
66                let i = data.input_state.lock().unwrap();
67                (i.mouse_buttons_down[0], (i.mouse_x, i.mouse_y))
68            };
69            let events = data.events.clone();
70            let pending =
71                drain_pending_events(&events, canvas_size, focused, mouse_down, mouse_pos);
72            for (callback_id, evt_type, evt_data) in pending {
73                set_current_event(&events, evt_type.clone(), evt_data);
74                self.store
75                    .set_fuel(FRAME_FUEL_LIMIT)
76                    .context("failed to set event fuel")?;
77                if let Err(e) = on_event.call(&mut self.store, callback_id) {
78                    let msg = if e.to_string().contains("fuel") {
79                        format!("on_event({evt_type}:{callback_id}) fuel limit exceeded")
80                    } else {
81                        format!("on_event({evt_type}:{callback_id}) trapped: {e}")
82                    };
83                    crate::capabilities::console_log(
84                        &self.store.data().console,
85                        crate::capabilities::ConsoleLevel::Error,
86                        msg,
87                    );
88                }
89            }
90        }
91
92        if let Some(ref on_timer) = self.on_timer_fn {
93            // Animation frames first (vsync-aligned, one-shot).
94            let anim = self.store.data().animation_requests.clone();
95            let fired_anim = drain_animation_frame_requests(&anim);
96            for callback_id in fired_anim {
97                self.store
98                    .set_fuel(FRAME_FUEL_LIMIT)
99                    .context("failed to set animation frame fuel")?;
100                if let Err(e) = on_timer.call(&mut self.store, callback_id) {
101                    let msg = if e.to_string().contains("fuel") {
102                        format!("on_timer(raf:{callback_id}) fuel limit exceeded")
103                    } else {
104                        format!("on_timer(raf:{callback_id}) trapped: {e}")
105                    };
106                    crate::capabilities::console_log(
107                        &self.store.data().console,
108                        crate::capabilities::ConsoleLevel::Error,
109                        msg,
110                    );
111                }
112            }
113
114            // Regular timers.
115            let timers = self.store.data().timers.clone();
116            let fired = drain_expired_timers(&timers);
117            for callback_id in fired {
118                self.store
119                    .set_fuel(FRAME_FUEL_LIMIT)
120                    .context("failed to set timer fuel")?;
121                if let Err(e) = on_timer.call(&mut self.store, callback_id) {
122                    let msg = if e.to_string().contains("fuel") {
123                        format!("on_timer({callback_id}) fuel limit exceeded")
124                    } else {
125                        format!("on_timer({callback_id}) trapped: {e}")
126                    };
127                    crate::capabilities::console_log(
128                        &self.store.data().console,
129                        crate::capabilities::ConsoleLevel::Error,
130                        msg,
131                    );
132                }
133            }
134        }
135
136        self.store
137            .set_fuel(FRAME_FUEL_LIMIT)
138            .context("failed to set per-frame fuel")?;
139        self.on_frame_fn
140            .call(&mut self.store, dt_ms)
141            .context("on_frame trapped")?;
142        Ok(())
143    }
144}
145
146/// Main host-side entry point: Wasmtime engine, shared tab status, and guest-facing host state.
147///
148/// Use [`BrowserHost::new`] on the UI thread, then [`BrowserHost::fetch_and_run`] or
149/// [`BrowserHost::run_bytes`] to load modules. [`BrowserHost::recreate`] builds a second host
150/// that shares [`HostState`] and [`PageStatus`] for background workers.
151pub struct BrowserHost {
152    wasm_engine: WasmEngine,
153    /// Latest [`PageStatus`] for this tab, safe to share with worker threads via [`Arc`] and [`Mutex`].
154    pub status: Arc<Mutex<PageStatus>>,
155    /// Sandbox resources and host imports: module loader, KV/bookmarks, canvas, timers, console, etc.
156    pub host_state: HostState,
157}
158
159impl BrowserHost {
160    /// Creates a new host with default [`SandboxPolicy`], a persistent KV store under the platform
161    /// data directory, and an initialized [`BookmarkStore`].
162    pub fn new() -> Result<Self> {
163        let policy = SandboxPolicy::default();
164        let wasm_engine = WasmEngine::new(policy.clone())?;
165
166        let loader = Arc::new(ModuleLoader {
167            engine: wasm_engine.engine().clone(),
168            max_memory_pages: policy.max_memory_pages,
169            fuel_limit: policy.fuel_limit,
170        });
171
172        let kv_path = dirs::data_dir()
173            .unwrap_or_else(|| std::path::PathBuf::from("."))
174            .join("oxide")
175            .join("kv_store.db");
176        let kv_db = sled::open(&kv_path)
177            .with_context(|| format!("failed to open KV store at {}", kv_path.display()))?;
178
179        let kv_db = Arc::new(kv_db);
180
181        let bookmark_store =
182            BookmarkStore::open(&kv_db).context("failed to initialize bookmark store")?;
183        let history_store =
184            HistoryStore::open(&kv_db).context("failed to initialize history store")?;
185
186        let host_state = HostState {
187            module_loader: Some(loader),
188            kv_db: Some(kv_db.clone()),
189            bookmark_store: Arc::new(Mutex::new(Some(bookmark_store))),
190            history_store: Arc::new(Mutex::new(Some(history_store))),
191            ..Default::default()
192        };
193
194        Ok(Self {
195            wasm_engine,
196            status: Arc::new(Mutex::new(PageStatus::Idle)),
197            host_state,
198        })
199    }
200
201    /// Re-creates a [`BrowserHost`] that shares the given [`HostState`] and [`PageStatus`].
202    ///
203    /// Used when worker threads need their own [`WasmEngine`] / Wasmtime instance while keeping
204    /// bookmarks, KV, canvas handles, and tab status in sync with the main host.
205    pub fn recreate(mut host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
206        let policy = SandboxPolicy::default();
207        let wasm_engine = WasmEngine::new(policy.clone()).expect("failed to create engine");
208
209        if host_state.module_loader.is_none() {
210            host_state.module_loader = Some(Arc::new(ModuleLoader {
211                engine: wasm_engine.engine().clone(),
212                max_memory_pages: policy.max_memory_pages,
213                fuel_limit: policy.fuel_limit,
214            }));
215        }
216
217        Self {
218            wasm_engine,
219            status,
220            host_state,
221        }
222    }
223
224    /// Fetches a `.wasm` from `url`, compiles it, links the `oxide` imports, runs `start_app()`,
225    /// and returns a [`LiveModule`] if the guest exports `on_frame`.
226    ///
227    /// Updates [`PageStatus`] to [`PageStatus::Loading`] then [`PageStatus::Running`] on success.
228    /// Supports `http`/`https` (network fetch) and `file://` (local read) via [`OxideUrl`] parsing;
229    /// other schemes error.
230    pub async fn fetch_and_run(&mut self, url: &str) -> Result<Option<LiveModule>> {
231        let url = resolve_wasm_url(url);
232        *self.status.lock().unwrap() = PageStatus::Loading(url.to_string());
233        self.host_state.canvas.lock().unwrap().commands.clear();
234        self.host_state.console.lock().unwrap().clear();
235        self.host_state.hyperlinks.lock().unwrap().clear();
236        *self.host_state.current_url.lock().unwrap() = url.to_string();
237
238        let parsed = OxideUrl::parse(&url).map_err(|e| anyhow::anyhow!("{e}"))?;
239
240        let wasm_bytes = if parsed.is_fetchable() {
241            fetch_wasm(parsed.as_str()).await?
242        } else if parsed.is_local_file() {
243            let path = parsed
244                .to_file_path()
245                .ok_or_else(|| anyhow::anyhow!("cannot convert file URL to path: {url}"))?;
246            std::fs::read(&path)
247                .with_context(|| format!("failed to read local file: {}", path.display()))?
248        } else if parsed.is_internal() {
249            anyhow::bail!("oxide:// internal pages are not yet implemented");
250        } else {
251            anyhow::bail!("unsupported URL scheme: {}", parsed.scheme());
252        };
253
254        *self.status.lock().unwrap() = PageStatus::Running(url.to_string());
255
256        self.run_module(&wasm_bytes)
257    }
258
259    /// Compiles and runs `wasm_bytes` like [`fetch_and_run`](Self::fetch_and_run), but without a
260    /// network fetch—useful for in-memory or locally read modules.
261    ///
262    /// Sets [`PageStatus::Running`] with a `"(local)"` label. Returns `Some` with a [`LiveModule`]
263    /// when `on_frame` is exported, otherwise [`None`].
264    pub fn run_bytes(&mut self, wasm_bytes: &[u8]) -> Result<Option<LiveModule>> {
265        self.host_state.canvas.lock().unwrap().commands.clear();
266        self.host_state.console.lock().unwrap().clear();
267        self.host_state.hyperlinks.lock().unwrap().clear();
268        *self.status.lock().unwrap() = PageStatus::Running("(local)".to_string());
269        self.run_module(wasm_bytes)
270    }
271
272    fn run_module(&mut self, wasm_bytes: &[u8]) -> Result<Option<LiveModule>> {
273        let module = self.wasm_engine.compile_module(wasm_bytes)?;
274
275        let mut linker = Linker::new(self.wasm_engine.engine());
276        register_host_functions(&mut linker)?;
277
278        let mut host_state = self.host_state.clone();
279        let mut store = self.wasm_engine.create_store(host_state.clone())?;
280
281        let memory = self.wasm_engine.create_bounded_memory(&mut store)?;
282        linker.define(&store, "oxide", "memory", memory)?;
283
284        host_state.memory = Some(memory);
285        *store.data_mut() = host_state;
286
287        let instance = linker
288            .instantiate(&mut store, &module)
289            .context("failed to instantiate wasm module")?;
290
291        if let Some(guest_mem) = instance.get_memory(&mut store, "memory") {
292            store.data_mut().memory = Some(guest_mem);
293        }
294
295        let start_app = instance
296            .get_typed_func::<(), ()>(&mut store, "start_app")
297            .context("module must export `start_app` as extern \"C\" fn()")?;
298
299        match start_app.call(&mut store, ()) {
300            Ok(()) => {
301                if let Ok(on_frame_fn) = instance.get_typed_func::<u32, ()>(&mut store, "on_frame")
302                {
303                    let on_timer_fn = instance
304                        .get_typed_func::<u32, ()>(&mut store, "on_timer")
305                        .ok();
306                    let on_event_fn = instance
307                        .get_typed_func::<u32, ()>(&mut store, "on_event")
308                        .ok();
309                    Ok(Some(LiveModule {
310                        store,
311                        on_frame_fn,
312                        on_timer_fn,
313                        on_event_fn,
314                    }))
315                } else {
316                    Ok(None)
317                }
318            }
319            Err(e) => {
320                let msg = if e.to_string().contains("fuel") {
321                    "Execution halted: fuel limit exceeded (possible infinite loop)".to_string()
322                } else {
323                    format!("Runtime error: {e}")
324                };
325                *self.status.lock().unwrap() = PageStatus::Error(msg.clone());
326                Err(anyhow::anyhow!(msg))
327            }
328        }
329    }
330}
331
332/// If the URL path doesn't already end with `.wasm`, treat it as a directory
333/// and append `/index.wasm` (like `index.html` in a traditional web server).
334fn resolve_wasm_url(url: &str) -> String {
335    let trimmed = url.trim();
336    if trimmed.is_empty() {
337        return trimmed.to_string();
338    }
339    let path_part = if let Some(pos) = trimmed.find("://") {
340        &trimmed[pos + 3..]
341    } else {
342        trimmed
343    };
344    let path = if let Some(slash) = path_part.find('/') {
345        &path_part[slash..]
346    } else {
347        "/"
348    };
349    let path_no_query = path.split('?').next().unwrap_or(path);
350    let path_no_frag = path_no_query.split('#').next().unwrap_or(path_no_query);
351
352    if path_no_frag.ends_with(".wasm") {
353        return trimmed.to_string();
354    }
355
356    let base = trimmed.trim_end_matches('/');
357    format!("{base}/index.wasm")
358}
359
360/// Maximum size of a `.wasm` module that can be fetched over the network.
361const MAX_WASM_MODULE_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
362
363async fn fetch_wasm(url: &str) -> Result<Vec<u8>> {
364    let client = reqwest::Client::builder()
365        .timeout(std::time::Duration::from_secs(30))
366        .build()
367        .context("failed to build HTTP client")?;
368
369    let response = client
370        .get(url)
371        .header("Accept", "application/wasm")
372        .send()
373        .await
374        .context("network request failed")?;
375
376    if !response.status().is_success() {
377        anyhow::bail!("server returned HTTP {} for {}", response.status(), url);
378    }
379
380    // Reject responses with an obviously wrong Content-Type.
381    if let Some(ct) = response.headers().get("content-type") {
382        let ct_str = ct.to_str().unwrap_or("");
383        if !ct_str.is_empty()
384            && !ct_str.contains("application/wasm")
385            && !ct_str.contains("application/octet-stream")
386        {
387            anyhow::bail!("unexpected Content-Type for .wasm module: {ct_str}");
388        }
389    }
390
391    // Enforce size limit early via Content-Length when available.
392    if let Some(len) = response.content_length() {
393        anyhow::ensure!(
394            len <= MAX_WASM_MODULE_SIZE,
395            "module too large ({len} bytes, limit is {MAX_WASM_MODULE_SIZE})"
396        );
397    }
398
399    let bytes = response
400        .bytes()
401        .await
402        .context("failed to read response body")?;
403
404    // Content-Length can be absent or spoofed, so check actual size too.
405    anyhow::ensure!(
406        (bytes.len() as u64) <= MAX_WASM_MODULE_SIZE,
407        "module body exceeds size limit ({} bytes)",
408        bytes.len()
409    );
410
411    Ok(bytes.to_vec())
412}