1use 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#[derive(Clone, Debug, PartialEq)]
21pub enum PageStatus {
22 Idle,
24 Loading(String),
26 Running(String),
28 Error(String),
30}
31
32const FRAME_FUEL_LIMIT: u64 = 50_000_000;
33
34pub struct LiveModule {
39 store: Store<HostState>,
40 on_frame_fn: TypedFunc<u32, ()>,
41 on_timer_fn: Option<TypedFunc<u32, ()>>,
42}
43
44impl LiveModule {
45 pub fn tick(&mut self, dt_ms: u32) -> Result<()> {
51 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
84pub struct BrowserHost {
90 wasm_engine: WasmEngine,
91 pub status: Arc<Mutex<PageStatus>>,
93 pub host_state: HostState,
95}
96
97impl BrowserHost {
98 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 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 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 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
263fn 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
291const MAX_WASM_MODULE_SIZE: u64 = 50 * 1024 * 1024; async 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 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 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 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}