1use 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#[derive(Clone, Debug, PartialEq)]
25pub enum PageStatus {
26 Idle,
28 Loading(String),
30 Running(String),
32 Error(String),
34}
35
36const FRAME_FUEL_LIMIT: u64 = 50_000_000;
37
38pub 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 pub fn tick(&mut self, dt_ms: u32) -> Result<()> {
56 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 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 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
146pub struct BrowserHost {
152 wasm_engine: WasmEngine,
153 pub status: Arc<Mutex<PageStatus>>,
155 pub host_state: HostState,
157}
158
159impl BrowserHost {
160 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 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 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 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
332fn 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
360const MAX_WASM_MODULE_SIZE: u64 = 50 * 1024 * 1024; async 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 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 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 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}