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        // Optional sibling manifest (app.wasm → app.toml): metadata + declared permissions.
255        let manifest = match crate::manifest::fetch_manifest(&parsed).await {
256            Ok(m) => m,
257            Err(e) => {
258                crate::capabilities::console_log(
259                    &self.host_state.console,
260                    crate::capabilities::ConsoleLevel::Warn,
261                    format!("[MANIFEST] {e} — loading app without a manifest"),
262                );
263                None
264            }
265        };
266        *self.host_state.manifest.lock().unwrap() = manifest;
267
268        *self.status.lock().unwrap() = PageStatus::Running(url.to_string());
269
270        self.run_module(&wasm_bytes)
271    }
272
273    /// Compiles and runs `wasm_bytes` like [`fetch_and_run`](Self::fetch_and_run), but without a
274    /// network fetch—useful for in-memory or locally read modules.
275    ///
276    /// `source_url` and `manifest` are set on the shared [`HostState`] before the module runs,
277    /// so a reused `BrowserHost` never scopes storage or permissions to the *previous* app's
278    /// origin/manifest. Pass a `file://` URL for picked files or a synthetic identifier (e.g.
279    /// `oxide://forge/run/<slug>`) for in-memory modules.
280    ///
281    /// Sets [`PageStatus::Running`] with `source_url` (or `"(local)"` when empty). Returns
282    /// `Some` with a [`LiveModule`] when `on_frame` is exported, otherwise [`None`].
283    pub fn run_bytes(
284        &mut self,
285        wasm_bytes: &[u8],
286        source_url: &str,
287        manifest: Option<crate::manifest::AppManifest>,
288    ) -> Result<Option<LiveModule>> {
289        self.host_state.canvas.lock().unwrap().commands.clear();
290        self.host_state.console.lock().unwrap().clear();
291        self.host_state.hyperlinks.lock().unwrap().clear();
292        *self.host_state.current_url.lock().unwrap() = source_url.to_string();
293        *self.host_state.manifest.lock().unwrap() = manifest;
294        let label = if source_url.is_empty() {
295            "(local)".to_string()
296        } else {
297            source_url.to_string()
298        };
299        *self.status.lock().unwrap() = PageStatus::Running(label);
300        self.run_module(wasm_bytes)
301    }
302
303    fn run_module(&mut self, wasm_bytes: &[u8]) -> Result<Option<LiveModule>> {
304        // Capture the app origin for storage/permission scoping. Done once per load so guest
305        // `push_state` calls (which mutate `current_url`) can't shift the origin afterwards.
306        {
307            let url = self.host_state.current_url.lock().unwrap().clone();
308            crate::capabilities::set_module_origin(&self.host_state, &url);
309        }
310
311        let module = self.wasm_engine.compile_module(wasm_bytes)?;
312
313        let mut linker = Linker::new(self.wasm_engine.engine());
314        register_host_functions(&mut linker)?;
315
316        let mut host_state = self.host_state.clone();
317        let mut store = self.wasm_engine.create_store(host_state.clone())?;
318
319        let memory = self.wasm_engine.create_bounded_memory(&mut store)?;
320        linker.define(&store, "oxide", "memory", memory)?;
321
322        host_state.memory = Some(memory);
323        *store.data_mut() = host_state;
324
325        let instance = linker
326            .instantiate(&mut store, &module)
327            .context("failed to instantiate wasm module")?;
328
329        if let Some(guest_mem) = instance.get_memory(&mut store, "memory") {
330            store.data_mut().memory = Some(guest_mem);
331        }
332
333        let start_app = instance
334            .get_typed_func::<(), ()>(&mut store, "start_app")
335            .context("module must export `start_app` as extern \"C\" fn()")?;
336
337        match start_app.call(&mut store, ()) {
338            Ok(()) => {
339                if let Ok(on_frame_fn) = instance.get_typed_func::<u32, ()>(&mut store, "on_frame")
340                {
341                    let on_timer_fn = instance
342                        .get_typed_func::<u32, ()>(&mut store, "on_timer")
343                        .ok();
344                    let on_event_fn = instance
345                        .get_typed_func::<u32, ()>(&mut store, "on_event")
346                        .ok();
347                    Ok(Some(LiveModule {
348                        store,
349                        on_frame_fn,
350                        on_timer_fn,
351                        on_event_fn,
352                    }))
353                } else {
354                    Ok(None)
355                }
356            }
357            Err(e) => {
358                let msg = if e.to_string().contains("fuel") {
359                    "Execution halted: fuel limit exceeded (possible infinite loop)".to_string()
360                } else {
361                    format!("Runtime error: {e}")
362                };
363                *self.status.lock().unwrap() = PageStatus::Error(msg.clone());
364                Err(anyhow::anyhow!(msg))
365            }
366        }
367    }
368}
369
370/// If the URL path doesn't already end with `.wasm`, treat it as a directory
371/// and append `/index.wasm` (like `index.html` in a traditional web server).
372fn resolve_wasm_url(url: &str) -> String {
373    let trimmed = url.trim();
374    if trimmed.is_empty() {
375        return trimmed.to_string();
376    }
377    let path_part = if let Some(pos) = trimmed.find("://") {
378        &trimmed[pos + 3..]
379    } else {
380        trimmed
381    };
382    let path = if let Some(slash) = path_part.find('/') {
383        &path_part[slash..]
384    } else {
385        "/"
386    };
387    let path_no_query = path.split('?').next().unwrap_or(path);
388    let path_no_frag = path_no_query.split('#').next().unwrap_or(path_no_query);
389
390    if path_no_frag.ends_with(".wasm") {
391        return trimmed.to_string();
392    }
393
394    let base = trimmed.trim_end_matches('/');
395    format!("{base}/index.wasm")
396}
397
398/// Maximum size of a `.wasm` module that can be fetched over the network.
399const MAX_WASM_MODULE_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
400
401async fn fetch_wasm(url: &str) -> Result<Vec<u8>> {
402    let client = reqwest::Client::builder()
403        .timeout(std::time::Duration::from_secs(30))
404        .build()
405        .context("failed to build HTTP client")?;
406
407    let response = client
408        .get(url)
409        .header("Accept", "application/wasm")
410        .send()
411        .await
412        .context("network request failed")?;
413
414    if !response.status().is_success() {
415        anyhow::bail!("server returned HTTP {} for {}", response.status(), url);
416    }
417
418    // Reject responses with an obviously wrong Content-Type.
419    if let Some(ct) = response.headers().get("content-type") {
420        let ct_str = ct.to_str().unwrap_or("");
421        if !ct_str.is_empty()
422            && !ct_str.contains("application/wasm")
423            && !ct_str.contains("application/octet-stream")
424        {
425            anyhow::bail!("unexpected Content-Type for .wasm module: {ct_str}");
426        }
427    }
428
429    // Enforce size limit early via Content-Length when available.
430    if let Some(len) = response.content_length() {
431        anyhow::ensure!(
432            len <= MAX_WASM_MODULE_SIZE,
433            "module too large ({len} bytes, limit is {MAX_WASM_MODULE_SIZE})"
434        );
435    }
436
437    let bytes = response
438        .bytes()
439        .await
440        .context("failed to read response body")?;
441
442    // Content-Length can be absent or spoofed, so check actual size too.
443    anyhow::ensure!(
444        (bytes.len() as u64) <= MAX_WASM_MODULE_SIZE,
445        "module body exceeds size limit ({} bytes)",
446        bytes.len()
447    );
448
449    Ok(bytes.to_vec())
450}