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