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 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 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 {
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
370fn 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
398const MAX_WASM_MODULE_SIZE: u64 = 50 * 1024 * 1024; async 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 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 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 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}