Skip to main content

oxide_browser/
capabilities.rs

1//! Host capabilities and shared state for WebAssembly guests.
2//!
3//! This module defines [`HostState`] and the data structures the host and guest share
4//! (console, canvas, timers, input, widgets, navigation, and more).
5//! [`register_host_functions`] attaches the **`oxide`** Wasm import module to a Wasmtime
6//! [`Linker`]: every host function that guest modules may call—`api_log`, `api_canvas_*`,
7//! `api_storage_*`, `api_navigate`, audio and UI APIs, etc.—is registered there under the
8//! import module name `oxide`.
9//!
10//! Guest code imports these symbols from `oxide`; implementations run on the host and
11//! read or mutate the [`HostState`] held in the Wasmtime store attached to the linker.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use anyhow::{Context, Result};
18use image::GenericImageView;
19use wasmtime::*;
20
21use crate::bookmarks::SharedBookmarkStore;
22use crate::engine::ModuleLoader;
23use crate::navigation::NavigationStack;
24use crate::url as oxide_url;
25
26/// Per-channel audio state: a rodio Player plus metadata.
27struct AudioChannel {
28    player: rodio::Player,
29    duration_ms: u64,
30    looping: bool,
31}
32
33/// Multi-channel audio playback engine backed by [rodio](https://crates.io/crates/rodio).
34///
35/// Each logical channel has its own [`rodio::Player`] so guests can play overlapping
36/// sounds (for example music on one channel and effects on another). The default channel
37/// used by the single-channel `api_audio_*` imports is `0`.
38pub struct AudioEngine {
39    _device_sink: rodio::stream::MixerDeviceSink,
40    channels: HashMap<u32, AudioChannel>,
41}
42
43impl AudioEngine {
44    fn try_new() -> Option<Self> {
45        let mut device_sink = rodio::DeviceSinkBuilder::open_default_sink().ok()?;
46        device_sink.log_on_drop(false);
47        Some(Self {
48            _device_sink: device_sink,
49            channels: HashMap::new(),
50        })
51    }
52
53    fn ensure_channel(&mut self, id: u32) -> &mut AudioChannel {
54        if !self.channels.contains_key(&id) {
55            let player = rodio::Player::connect_new(self._device_sink.mixer());
56            self.channels.insert(
57                id,
58                AudioChannel {
59                    player,
60                    duration_ms: 0,
61                    looping: false,
62                },
63            );
64        }
65        self.channels.get_mut(&id).unwrap()
66    }
67
68    fn play_bytes_on(&mut self, channel_id: u32, data: Vec<u8>) -> bool {
69        use rodio::Source;
70
71        let cursor = std::io::Cursor::new(data);
72        let reader = std::io::BufReader::new(cursor);
73        let source = match rodio::Decoder::try_from(reader) {
74            Ok(s) => s,
75            Err(_) => return false,
76        };
77
78        let duration_ms = source
79            .total_duration()
80            .map(|d| d.as_millis() as u64)
81            .unwrap_or(0);
82
83        let ch = self.ensure_channel(channel_id);
84        ch.player.clear();
85        ch.duration_ms = duration_ms;
86
87        if ch.looping {
88            ch.player.append(source.repeat_infinite());
89        } else {
90            ch.player.append(source);
91        }
92        ch.player.play();
93        true
94    }
95}
96
97/// All shared state between the browser host and a guest Wasm module (and dynamically loaded children).
98///
99/// Most fields are behind [`Arc`] and [`Mutex`] so the same state can be shared across
100/// threads and nested module loads. Host code sets fields like [`HostState::memory`] and
101/// [`HostState::current_url`] before or during execution; guest imports mutate the rest
102/// through the registered `oxide` functions.
103#[derive(Clone)]
104pub struct HostState {
105    /// Console log lines shown in the host UI, appended by [`console_log`] and `api_*` helpers.
106    pub console: Arc<Mutex<Vec<ConsoleEntry>>>,
107    /// Raster canvas: queued draw commands and decoded images for the current frame.
108    pub canvas: Arc<Mutex<CanvasState>>,
109    /// In-memory key/value session storage (string keys and values), similar to `localStorage` in scope.
110    pub storage: Arc<Mutex<HashMap<String, String>>>,
111    /// Pending one-shot and interval timers; the host drains these and invokes `on_timer` on the guest.
112    pub timers: Arc<Mutex<Vec<TimerEntry>>>,
113    /// Monotonic counter used to assign unique [`TimerEntry::id`] values for `api_set_timeout` / `api_set_interval`.
114    pub timer_next_id: Arc<Mutex<u32>>,
115    /// Last text written to or read from the clipboard via the guest API (when permitted).
116    pub clipboard: Arc<Mutex<String>>,
117    /// When `false`, `api_clipboard_read` / `api_clipboard_write` are blocked and log a warning.
118    pub clipboard_allowed: Arc<Mutex<bool>>,
119    /// Optional embedded [`sled`] database for persistent per-origin key/value bytes (`api_kv_store_*`).
120    pub kv_db: Option<Arc<sled::Db>>,
121    /// The guest’s exported linear memory, used to read/write pointers passed to host imports.
122    pub memory: Option<Memory>,
123    /// Engine and limits used by `api_load_module` to fetch and instantiate child Wasm modules.
124    pub module_loader: Option<Arc<ModuleLoader>>,
125    /// Session history stack for `api_push_state`, `api_replace_state`, and back/forward navigation.
126    pub navigation: Arc<Mutex<NavigationStack>>,
127    /// Hit-test regions registered by the guest for link clicks in the canvas area.
128    pub hyperlinks: Arc<Mutex<Vec<Hyperlink>>>,
129    /// Set by guest `api_navigate` — consumed by the UI after module returns.
130    pub pending_navigation: Arc<Mutex<Option<String>>>,
131    /// The URL of the currently loaded module (set by the host before execution).
132    pub current_url: Arc<Mutex<String>>,
133    /// Input state polled by the guest each frame.
134    pub input_state: Arc<Mutex<InputState>>,
135    /// Widget commands issued by the guest during `on_frame`.
136    pub widget_commands: Arc<Mutex<Vec<WidgetCommand>>>,
137    /// Persistent widget values (checkbox, slider, text input state).
138    pub widget_states: Arc<Mutex<HashMap<u32, WidgetValue>>>,
139    /// Button IDs that were clicked during the last render pass.
140    pub widget_clicked: Arc<Mutex<HashSet<u32>>>,
141    /// Top-left corner of the canvas panel in egui screen coords.
142    pub canvas_offset: Arc<Mutex<(f32, f32)>>,
143    /// Persistent bookmark storage shared across tabs.
144    pub bookmark_store: SharedBookmarkStore,
145    /// Audio playback engine (lazily initialised on first audio API call).
146    pub audio: Arc<Mutex<Option<AudioEngine>>>,
147}
148
149/// A single console log line: local time, severity, and message text.
150#[derive(Clone, Debug)]
151pub struct ConsoleEntry {
152    /// Time of day when the entry was recorded (`chrono` local format, e.g. `14:03:22.123`).
153    pub timestamp: String,
154    /// Severity bucket for styling in the host console.
155    pub level: ConsoleLevel,
156    /// UTF-8 message body.
157    pub message: String,
158}
159
160/// Severity level for [`ConsoleEntry`] and [`console_log`].
161#[derive(Clone, Debug)]
162pub enum ConsoleLevel {
163    /// Informational message (maps to `api_log`).
164    Log,
165    /// Warning (maps to `api_warn`).
166    Warn,
167    /// Error (maps to `api_error`).
168    Error,
169}
170
171/// Current canvas snapshot for one frame: command list, dimensions, image atlas, and invalidation generation.
172#[derive(Clone, Debug)]
173pub struct CanvasState {
174    /// Ordered draw operations accumulated since the last clear (or start of frame).
175    pub commands: Vec<DrawCommand>,
176    /// Canvas width in pixels.
177    pub width: u32,
178    /// Canvas height in pixels.
179    pub height: u32,
180    /// Decoded images indexed by position in this vector; [`DrawCommand::Image`] references them by `image_id`.
181    pub images: Vec<DecodedImage>,
182    /// Bumped when the canvas is cleared so the host can detect a full redraw.
183    pub generation: u64,
184}
185
186/// An image decoded to RGBA8 pixels for compositing in the host canvas renderer.
187#[derive(Clone, Debug)]
188pub struct DecodedImage {
189    /// Width in pixels.
190    pub width: u32,
191    /// Height in pixels.
192    pub height: u32,
193    /// Raw RGBA bytes, row-major (`width * height * 4` elements when full frame).
194    pub pixels: Vec<u8>,
195}
196
197/// One canvas drawing operation produced by guest `api_canvas_*` imports and consumed by the host renderer.
198#[derive(Clone, Debug)]
199pub enum DrawCommand {
200    /// Fill the entire canvas with a solid RGBA color and reset the command list (see `api_canvas_clear`).
201    Clear { r: u8, g: u8, b: u8, a: u8 },
202    /// Axis-aligned filled rectangle in canvas coordinates with RGBA fill.
203    Rect {
204        x: f32,
205        y: f32,
206        w: f32,
207        h: f32,
208        r: u8,
209        g: u8,
210        b: u8,
211        a: u8,
212    },
213    /// Filled circle centered at `(cx, cy)` with the given radius and RGBA fill.
214    Circle {
215        cx: f32,
216        cy: f32,
217        radius: f32,
218        r: u8,
219        g: u8,
220        b: u8,
221        a: u8,
222    },
223    /// Text baseline position `(x, y)`, font size in pixels, RGB color, and string payload.
224    Text {
225        x: f32,
226        y: f32,
227        size: f32,
228        r: u8,
229        g: u8,
230        b: u8,
231        text: String,
232    },
233    /// Line from `(x1, y1)` to `(x2, y2)` with RGB stroke color and stroke width in pixels.
234    Line {
235        x1: f32,
236        y1: f32,
237        x2: f32,
238        y2: f32,
239        r: u8,
240        g: u8,
241        b: u8,
242        thickness: f32,
243    },
244    /// Draw [`DecodedImage`] `image_id` from `images` into the axis-aligned rectangle `(x, y, w, h)`.
245    Image {
246        x: f32,
247        y: f32,
248        w: f32,
249        h: f32,
250        image_id: usize,
251    },
252}
253
254/// A scheduled timer: either a one-shot `setTimeout` or repeating `setInterval`.
255#[derive(Clone, Debug)]
256pub struct TimerEntry {
257    /// Host-assigned id returned by `api_set_timeout` / `api_set_interval` for `api_clear_timer`.
258    pub id: u32,
259    /// Absolute time when this entry should fire next.
260    pub fire_at: Instant,
261    /// `None` for a one-shot timer; `Some(duration)` for an interval (rescheduled after each fire).
262    pub interval: Option<Duration>,
263    /// Guest-defined id passed to the exported `on_timer` callback when this timer fires.
264    pub callback_id: u32,
265}
266
267/// Remove due timers from `timers`, collect each fired entry’s [`TimerEntry::callback_id`], and return them.
268///
269/// Compares each [`TimerEntry::fire_at`] against `Instant::now()`. **One-shot** entries
270/// (`interval` is `None`) are removed from the vector after firing. **Interval** entries
271/// are kept and their `fire_at` is advanced by `interval` so they fire again later. The
272/// host typically calls the guest’s `on_timer` once per id in the returned vector.
273pub fn drain_expired_timers(timers: &Arc<Mutex<Vec<TimerEntry>>>) -> Vec<u32> {
274    let now = Instant::now();
275    let mut guard = timers.lock().unwrap();
276    let mut fired = Vec::new();
277    let mut i = 0;
278    while i < guard.len() {
279        if guard[i].fire_at <= now {
280            fired.push(guard[i].callback_id);
281            if let Some(interval) = guard[i].interval {
282                guard[i].fire_at = now + interval;
283                i += 1;
284            } else {
285                guard.swap_remove(i);
286            }
287        } else {
288            i += 1;
289        }
290    }
291    fired
292}
293
294/// A clickable axis-aligned rectangle on the canvas that navigates to a URL when hit-tested.
295///
296/// Populated by `api_register_hyperlink` and cleared with `api_clear_hyperlinks`. Coordinates
297/// are in the same space as canvas drawing (the host maps pointer position into this space).
298#[derive(Clone, Debug)]
299pub struct Hyperlink {
300    /// Left edge of the hit region in canvas coordinates.
301    pub x: f32,
302    /// Top edge of the hit region in canvas coordinates.
303    pub y: f32,
304    /// Width of the hit region.
305    pub w: f32,
306    /// Height of the hit region.
307    pub h: f32,
308    /// Target URL (already resolved relative to the current page URL when registered).
309    pub url: String,
310}
311
312/// Per-frame input snapshot from the host (egui) for guest polling via `api_mouse_*`, `api_key_*`, etc.
313#[derive(Clone, Debug, Default)]
314pub struct InputState {
315    /// Pointer horizontal position in window/content coordinates before canvas offset subtraction in APIs.
316    pub mouse_x: f32,
317    /// Pointer vertical position in window/content coordinates before canvas offset subtraction in APIs.
318    pub mouse_y: f32,
319    /// Mouse buttons currently held: index 0 = primary, 1 = secondary, 2 = middle.
320    pub mouse_buttons_down: [bool; 3],
321    /// Mouse buttons that transitioned to pressed this frame (same indexing as `mouse_buttons_down`).
322    pub mouse_buttons_clicked: [bool; 3],
323    /// Key codes currently held (host-defined `u32` values, polled by `api_key_down`).
324    pub keys_down: Vec<u32>,
325    /// Key codes that registered a press this frame (`api_key_pressed`).
326    pub keys_pressed: Vec<u32>,
327    /// Shift modifier held this frame.
328    pub modifiers_shift: bool,
329    /// Control modifier held this frame.
330    pub modifiers_ctrl: bool,
331    /// Alt modifier held this frame.
332    pub modifiers_alt: bool,
333    /// Horizontal scroll delta for this frame.
334    pub scroll_x: f32,
335    /// Vertical scroll delta for this frame.
336    pub scroll_y: f32,
337}
338
339/// UI control the guest requested for the current frame; the host egui layer renders these after canvas content.
340///
341/// Commands are queued during `on_frame`; stable `id` values tie widgets to [`WidgetValue`] state and click tracking.
342#[derive(Clone, Debug)]
343pub enum WidgetCommand {
344    /// Clickable button with label; `api_ui_button` returns whether this `id` was clicked this pass.
345    Button {
346        id: u32,
347        x: f32,
348        y: f32,
349        w: f32,
350        h: f32,
351        label: String,
352    },
353    /// Toggle with label; checked state lives in [`WidgetValue::Bool`] for this `id`.
354    Checkbox {
355        id: u32,
356        x: f32,
357        y: f32,
358        label: String,
359    },
360    /// Horizontal slider between `min` and `max`; value stored in [`WidgetValue::Float`].
361    Slider {
362        id: u32,
363        x: f32,
364        y: f32,
365        w: f32,
366        min: f32,
367        max: f32,
368    },
369    /// Single-line text field; current text stored in [`WidgetValue::Text`].
370    TextInput { id: u32, x: f32, y: f32, w: f32 },
371}
372
373/// Persistent control state for interactive widgets, keyed by widget `id` across frames.
374#[derive(Clone, Debug)]
375pub enum WidgetValue {
376    /// Checkbox on/off.
377    Bool(bool),
378    /// Slider current value.
379    Float(f32),
380    /// Text field contents.
381    Text(String),
382}
383
384impl Default for HostState {
385    fn default() -> Self {
386        Self {
387            console: Arc::new(Mutex::new(Vec::new())),
388            canvas: Arc::new(Mutex::new(CanvasState {
389                commands: Vec::new(),
390                width: 800,
391                height: 600,
392                images: Vec::new(),
393                generation: 0,
394            })),
395            storage: Arc::new(Mutex::new(HashMap::new())),
396            timers: Arc::new(Mutex::new(Vec::new())),
397            timer_next_id: Arc::new(Mutex::new(1)),
398            clipboard: Arc::new(Mutex::new(String::new())),
399            clipboard_allowed: Arc::new(Mutex::new(false)),
400            kv_db: None,
401            memory: None,
402            module_loader: None,
403            navigation: Arc::new(Mutex::new(NavigationStack::new())),
404            hyperlinks: Arc::new(Mutex::new(Vec::new())),
405            pending_navigation: Arc::new(Mutex::new(None)),
406            current_url: Arc::new(Mutex::new(String::new())),
407            input_state: Arc::new(Mutex::new(InputState::default())),
408            widget_commands: Arc::new(Mutex::new(Vec::new())),
409            widget_states: Arc::new(Mutex::new(HashMap::new())),
410            widget_clicked: Arc::new(Mutex::new(HashSet::new())),
411            canvas_offset: Arc::new(Mutex::new((0.0, 0.0))),
412            bookmark_store: crate::bookmarks::new_shared(),
413            audio: Arc::new(Mutex::new(None)),
414        }
415    }
416}
417
418fn read_guest_string(
419    memory: &Memory,
420    store: &impl AsContext,
421    ptr: u32,
422    len: u32,
423) -> Result<String> {
424    let start = ptr as usize;
425    let end = start
426        .checked_add(len as usize)
427        .context("guest string pointer arithmetic overflow")?;
428    let data = memory
429        .data(store)
430        .get(start..end)
431        .context("guest string out of bounds")?;
432    String::from_utf8(data.to_vec()).context("guest string is not valid utf-8")
433}
434
435fn read_guest_bytes(
436    memory: &Memory,
437    store: &impl AsContext,
438    ptr: u32,
439    len: u32,
440) -> Result<Vec<u8>> {
441    let start = ptr as usize;
442    let end = start
443        .checked_add(len as usize)
444        .context("guest buffer pointer arithmetic overflow")?;
445    let data = memory
446        .data(store)
447        .get(start..end)
448        .context("guest buffer out of bounds")?;
449    Ok(data.to_vec())
450}
451
452fn write_guest_bytes(
453    memory: &Memory,
454    store: &mut impl AsContextMut,
455    ptr: u32,
456    bytes: &[u8],
457) -> Result<()> {
458    let start = ptr as usize;
459    let end = start
460        .checked_add(bytes.len())
461        .context("guest write pointer arithmetic overflow")?;
462    memory
463        .data_mut(store)
464        .get_mut(start..end)
465        .context("guest buffer out of bounds")?
466        .copy_from_slice(bytes);
467    Ok(())
468}
469
470/// Append a [`ConsoleEntry`] with the current local timestamp to the shared console buffer.
471///
472/// Used by `api_log` / `api_warn` / `api_error` and by other host helpers that surface messages to the UI.
473pub fn console_log(console: &Arc<Mutex<Vec<ConsoleEntry>>>, level: ConsoleLevel, message: String) {
474    console.lock().unwrap().push(ConsoleEntry {
475        timestamp: chrono::Local::now().format("%H:%M:%S%.3f").to_string(),
476        level,
477        message,
478    });
479}
480
481/// Register every `oxide` import on `linker` so guest modules can link against them.
482///
483/// This wires dozens of functions (console, canvas, storage, clipboard, timers, HTTP,
484/// dynamic module loading, crypto helpers, navigation, hyperlinks, input, audio, UI
485/// widgets, etc.) under the Wasm import module name **`oxide`**. Each closure captures
486/// [`Caller`] to read [`HostState`] from the store: guest pointers are resolved through
487/// [`HostState::memory`], and shared handles (`Arc<Mutex<…>>`) are updated in place.
488///
489/// Call this once when building the linker for a main or child instance; the dynamic loader
490/// path also invokes it when instantiating a child module (see the `api_load_module` import).
491pub fn register_host_functions(linker: &mut Linker<HostState>) -> Result<()> {
492    // ── Console ──────────────────────────────────────────────────────
493
494    linker.func_wrap(
495        "oxide",
496        "api_log",
497        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
498            let mem = caller.data().memory.expect("memory not set");
499            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
500            console_log(&caller.data().console, ConsoleLevel::Log, msg);
501        },
502    )?;
503
504    linker.func_wrap(
505        "oxide",
506        "api_warn",
507        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
508            let mem = caller.data().memory.expect("memory not set");
509            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
510            console_log(&caller.data().console, ConsoleLevel::Warn, msg);
511        },
512    )?;
513
514    linker.func_wrap(
515        "oxide",
516        "api_error",
517        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
518            let mem = caller.data().memory.expect("memory not set");
519            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
520            console_log(&caller.data().console, ConsoleLevel::Error, msg);
521        },
522    )?;
523
524    // ── Geolocation ──────────────────────────────────────────────────
525
526    linker.func_wrap(
527        "oxide",
528        "api_get_location",
529        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
530            let location = "37.7749,-122.4194"; // mock: San Francisco
531            let bytes = location.as_bytes();
532            let write_len = bytes.len().min(out_cap as usize);
533            let mem = caller.data().memory.expect("memory not set");
534            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
535            write_len as u32
536        },
537    )?;
538
539    // ── File Picker ──────────────────────────────────────────────────
540
541    linker.func_wrap(
542        "oxide",
543        "api_upload_file",
544        |mut caller: Caller<'_, HostState>,
545         name_ptr: u32,
546         name_cap: u32,
547         data_ptr: u32,
548         data_cap: u32|
549         -> u64 {
550            let dialog = rfd::FileDialog::new()
551                .set_title("Oxide: Select a file to upload")
552                .pick_file();
553
554            match dialog {
555                Some(path) => {
556                    let file_name = path
557                        .file_name()
558                        .map(|n| n.to_string_lossy().to_string())
559                        .unwrap_or_default();
560                    let file_data = std::fs::read(&path).unwrap_or_default();
561
562                    let mem = caller.data().memory.expect("memory not set");
563
564                    let name_bytes = file_name.as_bytes();
565                    let name_written = name_bytes.len().min(name_cap as usize);
566                    write_guest_bytes(&mem, &mut caller, name_ptr, &name_bytes[..name_written])
567                        .ok();
568
569                    let data_written = file_data.len().min(data_cap as usize);
570                    write_guest_bytes(&mem, &mut caller, data_ptr, &file_data[..data_written]).ok();
571
572                    ((name_written as u64) << 32) | (data_written as u64)
573                }
574                None => 0,
575            }
576        },
577    )?;
578
579    // ── Canvas Drawing ───────────────────────────────────────────────
580
581    linker.func_wrap(
582        "oxide",
583        "api_canvas_clear",
584        |caller: Caller<'_, HostState>, r: u32, g: u32, b: u32, a: u32| {
585            let mut canvas = caller.data().canvas.lock().unwrap();
586            canvas.commands.clear();
587            canvas.images.clear();
588            canvas.generation += 1;
589            canvas.commands.push(DrawCommand::Clear {
590                r: r as u8,
591                g: g as u8,
592                b: b as u8,
593                a: a as u8,
594            });
595        },
596    )?;
597
598    linker.func_wrap(
599        "oxide",
600        "api_canvas_rect",
601        |caller: Caller<'_, HostState>,
602         x: f32,
603         y: f32,
604         w: f32,
605         h: f32,
606         r: u32,
607         g: u32,
608         b: u32,
609         a: u32| {
610            caller
611                .data()
612                .canvas
613                .lock()
614                .unwrap()
615                .commands
616                .push(DrawCommand::Rect {
617                    x,
618                    y,
619                    w,
620                    h,
621                    r: r as u8,
622                    g: g as u8,
623                    b: b as u8,
624                    a: a as u8,
625                });
626        },
627    )?;
628
629    linker.func_wrap(
630        "oxide",
631        "api_canvas_circle",
632        |caller: Caller<'_, HostState>,
633         cx: f32,
634         cy: f32,
635         radius: f32,
636         r: u32,
637         g: u32,
638         b: u32,
639         a: u32| {
640            caller
641                .data()
642                .canvas
643                .lock()
644                .unwrap()
645                .commands
646                .push(DrawCommand::Circle {
647                    cx,
648                    cy,
649                    radius,
650                    r: r as u8,
651                    g: g as u8,
652                    b: b as u8,
653                    a: a as u8,
654                });
655        },
656    )?;
657
658    linker.func_wrap(
659        "oxide",
660        "api_canvas_text",
661        |caller: Caller<'_, HostState>,
662         x: f32,
663         y: f32,
664         size: f32,
665         r: u32,
666         g: u32,
667         b: u32,
668         txt_ptr: u32,
669         txt_len: u32| {
670            let mem = caller.data().memory.expect("memory not set");
671            let text = read_guest_string(&mem, &caller, txt_ptr, txt_len).unwrap_or_default();
672            caller
673                .data()
674                .canvas
675                .lock()
676                .unwrap()
677                .commands
678                .push(DrawCommand::Text {
679                    x,
680                    y,
681                    size,
682                    r: r as u8,
683                    g: g as u8,
684                    b: b as u8,
685                    text,
686                });
687        },
688    )?;
689
690    linker.func_wrap(
691        "oxide",
692        "api_canvas_line",
693        |caller: Caller<'_, HostState>,
694         x1: f32,
695         y1: f32,
696         x2: f32,
697         y2: f32,
698         r: u32,
699         g: u32,
700         b: u32,
701         thickness: f32| {
702            caller
703                .data()
704                .canvas
705                .lock()
706                .unwrap()
707                .commands
708                .push(DrawCommand::Line {
709                    x1,
710                    y1,
711                    x2,
712                    y2,
713                    r: r as u8,
714                    g: g as u8,
715                    b: b as u8,
716                    thickness,
717                });
718        },
719    )?;
720
721    linker.func_wrap(
722        "oxide",
723        "api_canvas_dimensions",
724        |caller: Caller<'_, HostState>| -> u64 {
725            let canvas = caller.data().canvas.lock().unwrap();
726            ((canvas.width as u64) << 32) | (canvas.height as u64)
727        },
728    )?;
729
730    // ── Canvas Image ─────────────────────────────────────────────────
731
732    linker.func_wrap(
733        "oxide",
734        "api_canvas_image",
735        |caller: Caller<'_, HostState>,
736         x: f32,
737         y: f32,
738         w: f32,
739         h: f32,
740         data_ptr: u32,
741         data_len: u32| {
742            let mem = caller.data().memory.expect("memory not set");
743            let raw = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
744            match image::load_from_memory(&raw) {
745                Ok(img) => {
746                    let (iw, ih) = img.dimensions();
747                    const MAX_IMAGE_PIXELS: u32 = 4096 * 4096; // ~16M pixels
748                    if iw.saturating_mul(ih) > MAX_IMAGE_PIXELS {
749                        console_log(
750                            &caller.data().console,
751                            ConsoleLevel::Error,
752                            format!(
753                                "[IMAGE] Rejected: {iw}x{ih} exceeds maximum of {MAX_IMAGE_PIXELS} pixels"
754                            ),
755                        );
756                        return;
757                    }
758                    let rgba = img.to_rgba8();
759                    let (iw, ih) = (rgba.width(), rgba.height());
760                    let decoded = DecodedImage {
761                        width: iw,
762                        height: ih,
763                        pixels: rgba.into_raw(),
764                    };
765                    let mut canvas = caller.data().canvas.lock().unwrap();
766                    let image_id = canvas.images.len();
767                    canvas.images.push(decoded);
768                    canvas.commands.push(DrawCommand::Image {
769                        x,
770                        y,
771                        w,
772                        h,
773                        image_id,
774                    });
775                }
776                Err(e) => {
777                    console_log(
778                        &caller.data().console,
779                        ConsoleLevel::Error,
780                        format!("[IMAGE] Failed to decode: {e}"),
781                    );
782                }
783            }
784        },
785    )?;
786
787    // ── Local Storage ────────────────────────────────────────────────
788
789    linker.func_wrap(
790        "oxide",
791        "api_storage_set",
792        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32, val_ptr: u32, val_len: u32| {
793            let mem = caller.data().memory.expect("memory not set");
794            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
795            let val = read_guest_string(&mem, &caller, val_ptr, val_len).unwrap_or_default();
796            caller.data().storage.lock().unwrap().insert(key, val);
797        },
798    )?;
799
800    linker.func_wrap(
801        "oxide",
802        "api_storage_get",
803        |mut caller: Caller<'_, HostState>,
804         key_ptr: u32,
805         key_len: u32,
806         out_ptr: u32,
807         out_cap: u32|
808         -> u32 {
809            let mem = caller.data().memory.expect("memory not set");
810            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
811            let val = caller
812                .data()
813                .storage
814                .lock()
815                .unwrap()
816                .get(&key)
817                .cloned()
818                .unwrap_or_default();
819            let bytes = val.as_bytes();
820            let write_len = bytes.len().min(out_cap as usize);
821            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
822            write_len as u32
823        },
824    )?;
825
826    linker.func_wrap(
827        "oxide",
828        "api_storage_remove",
829        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32| {
830            let mem = caller.data().memory.expect("memory not set");
831            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
832            caller.data().storage.lock().unwrap().remove(&key);
833        },
834    )?;
835
836    // ── Clipboard ────────────────────────────────────────────────────
837
838    linker.func_wrap(
839        "oxide",
840        "api_clipboard_write",
841        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
842            let allowed = *caller.data().clipboard_allowed.lock().unwrap();
843            if !allowed {
844                console_log(
845                    &caller.data().console,
846                    ConsoleLevel::Warn,
847                    "[CLIPBOARD] Write blocked — clipboard access not permitted".into(),
848                );
849                return;
850            }
851            let mem = caller.data().memory.expect("memory not set");
852            let text = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
853            *caller.data().clipboard.lock().unwrap() = text.clone();
854            if let Ok(mut ctx) = arboard::Clipboard::new() {
855                let _ = ctx.set_text(text);
856            }
857        },
858    )?;
859
860    linker.func_wrap(
861        "oxide",
862        "api_clipboard_read",
863        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
864            let allowed = *caller.data().clipboard_allowed.lock().unwrap();
865            if !allowed {
866                console_log(
867                    &caller.data().console,
868                    ConsoleLevel::Warn,
869                    "[CLIPBOARD] Read blocked — clipboard access not permitted".into(),
870                );
871                return 0;
872            }
873            let text = arboard::Clipboard::new()
874                .and_then(|mut ctx| ctx.get_text())
875                .unwrap_or_default();
876            let bytes = text.as_bytes();
877            let write_len = bytes.len().min(out_cap as usize);
878            let mem = caller.data().memory.expect("memory not set");
879            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
880            write_len as u32
881        },
882    )?;
883
884    // ── Timers (simplified: returns epoch millis) ────────────────────
885
886    linker.func_wrap(
887        "oxide",
888        "api_time_now_ms",
889        |_caller: Caller<'_, HostState>| -> u64 {
890            std::time::SystemTime::now()
891                .duration_since(std::time::UNIX_EPOCH)
892                .unwrap_or_default()
893                .as_millis() as u64
894        },
895    )?;
896
897    // ── Timers ────────────────────────────────────────────────────────
898    // Timers fire via the guest-exported `on_timer(callback_id)` function,
899    // which the host calls from the frame loop for each expired timer.
900
901    linker.func_wrap(
902        "oxide",
903        "api_set_timeout",
904        |caller: Caller<'_, HostState>, callback_id: u32, delay_ms: u32| -> u32 {
905            let mut next = caller.data().timer_next_id.lock().unwrap();
906            let id = *next;
907            *next = next.wrapping_add(1).max(1);
908            drop(next);
909
910            let entry = TimerEntry {
911                id,
912                fire_at: Instant::now() + Duration::from_millis(delay_ms as u64),
913                interval: None,
914                callback_id,
915            };
916            caller.data().timers.lock().unwrap().push(entry);
917            id
918        },
919    )?;
920
921    linker.func_wrap(
922        "oxide",
923        "api_set_interval",
924        |caller: Caller<'_, HostState>, callback_id: u32, interval_ms: u32| -> u32 {
925            let mut next = caller.data().timer_next_id.lock().unwrap();
926            let id = *next;
927            *next = next.wrapping_add(1).max(1);
928            drop(next);
929
930            let interval = Duration::from_millis(interval_ms as u64);
931            let entry = TimerEntry {
932                id,
933                fire_at: Instant::now() + interval,
934                interval: Some(interval),
935                callback_id,
936            };
937            caller.data().timers.lock().unwrap().push(entry);
938            id
939        },
940    )?;
941
942    linker.func_wrap(
943        "oxide",
944        "api_clear_timer",
945        |caller: Caller<'_, HostState>, timer_id: u32| {
946            caller
947                .data()
948                .timers
949                .lock()
950                .unwrap()
951                .retain(|t| t.id != timer_id);
952        },
953    )?;
954
955    // ── Random ───────────────────────────────────────────────────────
956
957    linker.func_wrap(
958        "oxide",
959        "api_random",
960        |_caller: Caller<'_, HostState>| -> u64 {
961            let mut buf = [0u8; 8];
962            getrandom(&mut buf);
963            u64::from_le_bytes(buf)
964        },
965    )?;
966
967    // ── Notification (writes to console as a "notification") ─────────
968
969    linker.func_wrap(
970        "oxide",
971        "api_notify",
972        |caller: Caller<'_, HostState>,
973         title_ptr: u32,
974         title_len: u32,
975         body_ptr: u32,
976         body_len: u32| {
977            let mem = caller.data().memory.expect("memory not set");
978            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
979            let body = read_guest_string(&mem, &caller, body_ptr, body_len).unwrap_or_default();
980            console_log(
981                &caller.data().console,
982                ConsoleLevel::Log,
983                format!("[NOTIFICATION] {title}: {body}"),
984            );
985        },
986    )?;
987
988    // ── HTTP Fetch ───────────────────────────────────────────────────
989    // Synchronous HTTP client exposed to guest wasm. The actual network
990    // call runs on a dedicated OS thread to avoid blocking the tokio
991    // runtime that the browser host lives on.
992
993    linker.func_wrap(
994        "oxide",
995        "api_fetch",
996        |mut caller: Caller<'_, HostState>,
997         method_ptr: u32,
998         method_len: u32,
999         url_ptr: u32,
1000         url_len: u32,
1001         ct_ptr: u32,
1002         ct_len: u32,
1003         body_ptr: u32,
1004         body_len: u32,
1005         out_ptr: u32,
1006         out_cap: u32|
1007         -> i64 {
1008            let mem = caller.data().memory.expect("memory not set");
1009            let method =
1010                read_guest_string(&mem, &caller, method_ptr, method_len).unwrap_or_default();
1011            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1012            let content_type = read_guest_string(&mem, &caller, ct_ptr, ct_len).unwrap_or_default();
1013            let body = if body_len > 0 {
1014                read_guest_bytes(&mem, &caller, body_ptr, body_len).unwrap_or_default()
1015            } else {
1016                Vec::new()
1017            };
1018
1019            console_log(
1020                &caller.data().console,
1021                ConsoleLevel::Log,
1022                format!("[FETCH] {method} {url}"),
1023            );
1024
1025            let (resp_tx, resp_rx) =
1026                std::sync::mpsc::sync_channel::<Result<(u16, Vec<u8>), String>>(1);
1027
1028            std::thread::spawn(move || {
1029                let result = (|| -> Result<(u16, Vec<u8>), String> {
1030                    let client = reqwest::blocking::Client::builder()
1031                        .timeout(Duration::from_secs(30))
1032                        .build()
1033                        .map_err(|e| e.to_string())?;
1034                    let parsed: reqwest::Method = method.parse().unwrap_or(reqwest::Method::GET);
1035                    let mut req = client.request(parsed, &url);
1036                    if !content_type.is_empty() {
1037                        req = req.header("Content-Type", &content_type);
1038                    }
1039                    if !body.is_empty() {
1040                        req = req.body(body);
1041                    }
1042                    let resp = req.send().map_err(|e| e.to_string())?;
1043                    let status = resp.status().as_u16();
1044                    let bytes = resp.bytes().map_err(|e| e.to_string())?.to_vec();
1045                    Ok((status, bytes))
1046                })();
1047                let _ = resp_tx.send(result);
1048            });
1049
1050            match resp_rx.recv() {
1051                Ok(Ok((status, response_body))) => {
1052                    let write_len = response_body.len().min(out_cap as usize);
1053                    write_guest_bytes(&mem, &mut caller, out_ptr, &response_body[..write_len]).ok();
1054                    ((status as i64) << 32) | (write_len as i64)
1055                }
1056                Ok(Err(e)) => {
1057                    console_log(
1058                        &caller.data().console,
1059                        ConsoleLevel::Error,
1060                        format!("[FETCH ERROR] {e}"),
1061                    );
1062                    -1
1063                }
1064                Err(_) => -1,
1065            }
1066        },
1067    )?;
1068
1069    // ── Dynamic Module Loading ───────────────────────────────────────
1070    // Allows a running wasm guest to fetch and execute another .wasm
1071    // module. The child module shares the same canvas, console, and
1072    // storage — similar to how a <script> tag loads code into the same
1073    // page context.
1074
1075    linker.func_wrap(
1076        "oxide",
1077        "api_load_module",
1078        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
1079            let mem = caller.data().memory.expect("memory not set");
1080            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1081            let loader = match &caller.data().module_loader {
1082                Some(l) => l.clone(),
1083                None => return -1,
1084            };
1085            let mut child_state = caller.data().clone();
1086            child_state.memory = None;
1087            let console = caller.data().console.clone();
1088
1089            console_log(
1090                &console,
1091                ConsoleLevel::Log,
1092                format!("[LOAD] Fetching module: {url}"),
1093            );
1094
1095            let (tx, rx) = std::sync::mpsc::sync_channel::<Result<Vec<u8>, String>>(1);
1096            let fetch_url = url.clone();
1097            std::thread::spawn(move || {
1098                let result = (|| -> Result<Vec<u8>, String> {
1099                    let client = reqwest::blocking::Client::builder()
1100                        .timeout(Duration::from_secs(30))
1101                        .build()
1102                        .map_err(|e| e.to_string())?;
1103                    let resp = client
1104                        .get(&fetch_url)
1105                        .header("Accept", "application/wasm")
1106                        .send()
1107                        .map_err(|e| e.to_string())?;
1108                    if !resp.status().is_success() {
1109                        return Err(format!("HTTP {}", resp.status()));
1110                    }
1111                    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
1112                })();
1113                let _ = tx.send(result);
1114            });
1115
1116            let wasm_bytes = match rx.recv() {
1117                Ok(Ok(bytes)) => bytes,
1118                Ok(Err(e)) => {
1119                    console_log(&console, ConsoleLevel::Error, format!("[LOAD ERROR] {e}"));
1120                    return -1;
1121                }
1122                Err(_) => return -1,
1123            };
1124
1125            let module = match Module::new(&loader.engine, &wasm_bytes) {
1126                Ok(m) => m,
1127                Err(e) => {
1128                    console_log(
1129                        &console,
1130                        ConsoleLevel::Error,
1131                        format!("[LOAD ERROR] Compile: {e}"),
1132                    );
1133                    return -2;
1134                }
1135            };
1136
1137            let mut store = Store::new(&loader.engine, child_state);
1138            if store.set_fuel(loader.fuel_limit).is_err() {
1139                return -3;
1140            }
1141
1142            let mut child_linker = Linker::new(&loader.engine);
1143            if register_host_functions(&mut child_linker).is_err() {
1144                return -3;
1145            }
1146
1147            let mem_type = MemoryType::new(1, Some(loader.max_memory_pages));
1148            let memory = match Memory::new(&mut store, mem_type) {
1149                Ok(m) => m,
1150                Err(_) => return -4,
1151            };
1152
1153            if child_linker
1154                .define(&store, "oxide", "memory", memory)
1155                .is_err()
1156            {
1157                return -5;
1158            }
1159            store.data_mut().memory = Some(memory);
1160
1161            let instance = match child_linker.instantiate(&mut store, &module) {
1162                Ok(i) => i,
1163                Err(e) => {
1164                    console_log(
1165                        &console,
1166                        ConsoleLevel::Error,
1167                        format!("[LOAD ERROR] Instantiate: {e}"),
1168                    );
1169                    return -6;
1170                }
1171            };
1172
1173            // Use the child module's own exported memory for string I/O
1174            if let Some(guest_mem) = instance.get_memory(&mut store, "memory") {
1175                store.data_mut().memory = Some(guest_mem);
1176            }
1177
1178            let start_fn = match instance.get_typed_func::<(), ()>(&mut store, "start_app") {
1179                Ok(f) => f,
1180                Err(_) => {
1181                    console_log(
1182                        &console,
1183                        ConsoleLevel::Error,
1184                        "[LOAD ERROR] Module missing start_app".into(),
1185                    );
1186                    return -7;
1187                }
1188            };
1189
1190            match start_fn.call(&mut store, ()) {
1191                Ok(()) => {
1192                    console_log(
1193                        &console,
1194                        ConsoleLevel::Log,
1195                        format!("[LOAD] Module {url} executed successfully"),
1196                    );
1197                    0
1198                }
1199                Err(e) => {
1200                    let msg = if e.to_string().contains("fuel") {
1201                        "[LOAD ERROR] Child module fuel limit exceeded".to_string()
1202                    } else {
1203                        format!("[LOAD ERROR] Runtime: {e}")
1204                    };
1205                    console_log(&console, ConsoleLevel::Error, msg);
1206                    -8
1207                }
1208            }
1209        },
1210    )?;
1211
1212    // ── SHA-256 Hashing ──────────────────────────────────────────────
1213
1214    linker.func_wrap(
1215        "oxide",
1216        "api_hash_sha256",
1217        |mut caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32, out_ptr: u32| -> u32 {
1218            use sha2::{Digest, Sha256};
1219            let mem = caller.data().memory.expect("memory not set");
1220            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
1221            let hash = Sha256::digest(&data);
1222            write_guest_bytes(&mem, &mut caller, out_ptr, &hash).ok();
1223            hash.len() as u32
1224        },
1225    )?;
1226
1227    // ── Base64 Encoding / Decoding ───────────────────────────────────
1228
1229    linker.func_wrap(
1230        "oxide",
1231        "api_base64_encode",
1232        |mut caller: Caller<'_, HostState>,
1233         data_ptr: u32,
1234         data_len: u32,
1235         out_ptr: u32,
1236         out_cap: u32|
1237         -> u32 {
1238            use base64::Engine;
1239            let mem = caller.data().memory.expect("memory not set");
1240            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
1241            let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
1242            let bytes = encoded.as_bytes();
1243            let write_len = bytes.len().min(out_cap as usize);
1244            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1245            write_len as u32
1246        },
1247    )?;
1248
1249    linker.func_wrap(
1250        "oxide",
1251        "api_base64_decode",
1252        |mut caller: Caller<'_, HostState>,
1253         data_ptr: u32,
1254         data_len: u32,
1255         out_ptr: u32,
1256         out_cap: u32|
1257         -> u32 {
1258            use base64::Engine;
1259            let mem = caller.data().memory.expect("memory not set");
1260            let encoded = read_guest_string(&mem, &caller, data_ptr, data_len).unwrap_or_default();
1261            match base64::engine::general_purpose::STANDARD.decode(&encoded) {
1262                Ok(decoded) => {
1263                    let write_len = decoded.len().min(out_cap as usize);
1264                    write_guest_bytes(&mem, &mut caller, out_ptr, &decoded[..write_len]).ok();
1265                    write_len as u32
1266                }
1267                Err(_) => 0,
1268            }
1269        },
1270    )?;
1271
1272    // ── Persistent Key-Value Store ───────────────────────────────────
1273    // Backed by a sled embedded database on the host's filesystem.
1274    // The guest has no direct access to the .db files.
1275
1276    linker.func_wrap(
1277        "oxide",
1278        "api_kv_store_set",
1279        |caller: Caller<'_, HostState>,
1280         key_ptr: u32,
1281         key_len: u32,
1282         val_ptr: u32,
1283         val_len: u32|
1284         -> i32 {
1285            let mem = caller.data().memory.expect("memory not set");
1286            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1287            let val = read_guest_bytes(&mem, &caller, val_ptr, val_len).unwrap_or_default();
1288            let origin = caller.data().current_url.lock().unwrap().clone();
1289            let prefixed_key = format!("{origin}::{key}");
1290            match &caller.data().kv_db {
1291                Some(db) => match db.insert(prefixed_key.as_bytes(), val) {
1292                    Ok(_) => {
1293                        let _ = db.flush();
1294                        0
1295                    }
1296                    Err(e) => {
1297                        console_log(
1298                            &caller.data().console,
1299                            ConsoleLevel::Error,
1300                            format!("[KV] set failed: {e}"),
1301                        );
1302                        -1
1303                    }
1304                },
1305                None => {
1306                    console_log(
1307                        &caller.data().console,
1308                        ConsoleLevel::Error,
1309                        "[KV] store not initialised".into(),
1310                    );
1311                    -1
1312                }
1313            }
1314        },
1315    )?;
1316
1317    linker.func_wrap(
1318        "oxide",
1319        "api_kv_store_get",
1320        |mut caller: Caller<'_, HostState>,
1321         key_ptr: u32,
1322         key_len: u32,
1323         out_ptr: u32,
1324         out_cap: u32|
1325         -> i32 {
1326            let mem = caller.data().memory.expect("memory not set");
1327            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1328            let origin = caller.data().current_url.lock().unwrap().clone();
1329            let prefixed_key = format!("{origin}::{key}");
1330            match &caller.data().kv_db {
1331                Some(db) => match db.get(prefixed_key.as_bytes()) {
1332                    Ok(Some(val)) => {
1333                        let bytes = val.as_ref();
1334                        let write_len = bytes.len().min(out_cap as usize);
1335                        write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1336                        write_len as i32
1337                    }
1338                    Ok(None) => -1,
1339                    Err(e) => {
1340                        console_log(
1341                            &caller.data().console,
1342                            ConsoleLevel::Error,
1343                            format!("[KV] get failed: {e}"),
1344                        );
1345                        -2
1346                    }
1347                },
1348                None => -2,
1349            }
1350        },
1351    )?;
1352
1353    linker.func_wrap(
1354        "oxide",
1355        "api_kv_store_delete",
1356        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32| -> i32 {
1357            let mem = caller.data().memory.expect("memory not set");
1358            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1359            let origin = caller.data().current_url.lock().unwrap().clone();
1360            let prefixed_key = format!("{origin}::{key}");
1361            match &caller.data().kv_db {
1362                Some(db) => match db.remove(prefixed_key.as_bytes()) {
1363                    Ok(_) => {
1364                        let _ = db.flush();
1365                        0
1366                    }
1367                    Err(e) => {
1368                        console_log(
1369                            &caller.data().console,
1370                            ConsoleLevel::Error,
1371                            format!("[KV] delete failed: {e}"),
1372                        );
1373                        -1
1374                    }
1375                },
1376                None => -1,
1377            }
1378        },
1379    )?;
1380
1381    // ── Navigation ──────────────────────────────────────────────────
1382
1383    linker.func_wrap(
1384        "oxide",
1385        "api_navigate",
1386        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
1387            let mem = caller.data().memory.expect("memory not set");
1388            let raw_url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1389
1390            let resolved = {
1391                let cur = caller.data().current_url.lock().unwrap();
1392                if cur.is_empty() {
1393                    raw_url.clone()
1394                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
1395                    base.join(&raw_url)
1396                        .map(|u| u.as_str().to_string())
1397                        .unwrap_or(raw_url.clone())
1398                } else {
1399                    raw_url.clone()
1400                }
1401            };
1402
1403            if oxide_url::OxideUrl::parse(&resolved).is_err() {
1404                console_log(
1405                    &caller.data().console,
1406                    ConsoleLevel::Error,
1407                    format!("[NAV] invalid URL: {resolved}"),
1408                );
1409                return -1;
1410            }
1411
1412            console_log(
1413                &caller.data().console,
1414                ConsoleLevel::Log,
1415                format!("[NAV] navigate → {resolved}"),
1416            );
1417            *caller.data().pending_navigation.lock().unwrap() = Some(resolved);
1418            0
1419        },
1420    )?;
1421
1422    linker.func_wrap(
1423        "oxide",
1424        "api_push_state",
1425        |caller: Caller<'_, HostState>,
1426         state_ptr: u32,
1427         state_len: u32,
1428         title_ptr: u32,
1429         title_len: u32,
1430         url_ptr: u32,
1431         url_len: u32| {
1432            let mem = caller.data().memory.expect("memory not set");
1433            let state = read_guest_bytes(&mem, &caller, state_ptr, state_len).unwrap_or_default();
1434            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
1435            let url_arg = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1436
1437            let resolved_url = if url_arg.is_empty() {
1438                caller.data().current_url.lock().unwrap().clone()
1439            } else {
1440                let cur = caller.data().current_url.lock().unwrap();
1441                if cur.is_empty() {
1442                    url_arg
1443                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
1444                    base.join(&url_arg)
1445                        .map(|u| u.as_str().to_string())
1446                        .unwrap_or(url_arg)
1447                } else {
1448                    url_arg
1449                }
1450            };
1451
1452            let entry = crate::navigation::HistoryEntry::new(&resolved_url)
1453                .with_title(title)
1454                .with_state(state);
1455            caller.data().navigation.lock().unwrap().push(entry);
1456            *caller.data().current_url.lock().unwrap() = resolved_url;
1457        },
1458    )?;
1459
1460    linker.func_wrap(
1461        "oxide",
1462        "api_replace_state",
1463        |caller: Caller<'_, HostState>,
1464         state_ptr: u32,
1465         state_len: u32,
1466         title_ptr: u32,
1467         title_len: u32,
1468         url_ptr: u32,
1469         url_len: u32| {
1470            let mem = caller.data().memory.expect("memory not set");
1471            let state = read_guest_bytes(&mem, &caller, state_ptr, state_len).unwrap_or_default();
1472            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
1473            let url_arg = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1474
1475            let resolved_url = if url_arg.is_empty() {
1476                caller.data().current_url.lock().unwrap().clone()
1477            } else {
1478                let cur = caller.data().current_url.lock().unwrap();
1479                if cur.is_empty() {
1480                    url_arg
1481                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
1482                    base.join(&url_arg)
1483                        .map(|u| u.as_str().to_string())
1484                        .unwrap_or(url_arg)
1485                } else {
1486                    url_arg
1487                }
1488            };
1489
1490            let entry = crate::navigation::HistoryEntry::new(&resolved_url)
1491                .with_title(title)
1492                .with_state(state);
1493            caller
1494                .data()
1495                .navigation
1496                .lock()
1497                .unwrap()
1498                .replace_current(entry);
1499            *caller.data().current_url.lock().unwrap() = resolved_url;
1500        },
1501    )?;
1502
1503    linker.func_wrap(
1504        "oxide",
1505        "api_get_url",
1506        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
1507            let url = caller.data().current_url.lock().unwrap().clone();
1508            let bytes = url.as_bytes();
1509            let write_len = bytes.len().min(out_cap as usize);
1510            let mem = caller.data().memory.expect("memory not set");
1511            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1512            write_len as u32
1513        },
1514    )?;
1515
1516    linker.func_wrap(
1517        "oxide",
1518        "api_get_state",
1519        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> i32 {
1520            let state_bytes = {
1521                let nav = caller.data().navigation.lock().unwrap();
1522                match nav.current() {
1523                    Some(entry) if !entry.state.is_empty() => Some(entry.state.clone()),
1524                    _ => None,
1525                }
1526            };
1527            match state_bytes {
1528                Some(bytes) => {
1529                    let write_len = bytes.len().min(out_cap as usize);
1530                    let mem = caller.data().memory.expect("memory not set");
1531                    write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1532                    write_len as i32
1533                }
1534                None => -1,
1535            }
1536        },
1537    )?;
1538
1539    linker.func_wrap(
1540        "oxide",
1541        "api_history_length",
1542        |caller: Caller<'_, HostState>| -> u32 {
1543            caller.data().navigation.lock().unwrap().len() as u32
1544        },
1545    )?;
1546
1547    linker.func_wrap(
1548        "oxide",
1549        "api_history_back",
1550        |caller: Caller<'_, HostState>| -> i32 {
1551            let mut nav = caller.data().navigation.lock().unwrap();
1552            match nav.go_back() {
1553                Some(entry) => {
1554                    let url = entry.url.clone();
1555                    *caller.data().current_url.lock().unwrap() = url.clone();
1556                    *caller.data().pending_navigation.lock().unwrap() = Some(url);
1557                    1
1558                }
1559                None => 0,
1560            }
1561        },
1562    )?;
1563
1564    linker.func_wrap(
1565        "oxide",
1566        "api_history_forward",
1567        |caller: Caller<'_, HostState>| -> i32 {
1568            let mut nav = caller.data().navigation.lock().unwrap();
1569            match nav.go_forward() {
1570                Some(entry) => {
1571                    let url = entry.url.clone();
1572                    *caller.data().current_url.lock().unwrap() = url.clone();
1573                    *caller.data().pending_navigation.lock().unwrap() = Some(url);
1574                    1
1575                }
1576                None => 0,
1577            }
1578        },
1579    )?;
1580
1581    // ── Hyperlinks ──────────────────────────────────────────────────
1582
1583    linker.func_wrap(
1584        "oxide",
1585        "api_register_hyperlink",
1586        |caller: Caller<'_, HostState>,
1587         x: f32,
1588         y: f32,
1589         w: f32,
1590         h: f32,
1591         url_ptr: u32,
1592         url_len: u32|
1593         -> i32 {
1594            let mem = caller.data().memory.expect("memory not set");
1595            let raw_url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1596
1597            let resolved = {
1598                let cur = caller.data().current_url.lock().unwrap();
1599                if cur.is_empty() {
1600                    raw_url.clone()
1601                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
1602                    base.join(&raw_url)
1603                        .map(|u| u.as_str().to_string())
1604                        .unwrap_or(raw_url.clone())
1605                } else {
1606                    raw_url.clone()
1607                }
1608            };
1609
1610            caller.data().hyperlinks.lock().unwrap().push(Hyperlink {
1611                x,
1612                y,
1613                w,
1614                h,
1615                url: resolved,
1616            });
1617            0
1618        },
1619    )?;
1620
1621    linker.func_wrap(
1622        "oxide",
1623        "api_clear_hyperlinks",
1624        |caller: Caller<'_, HostState>| {
1625            caller.data().hyperlinks.lock().unwrap().clear();
1626        },
1627    )?;
1628
1629    // ── URL Utilities ───────────────────────────────────────────────
1630
1631    linker.func_wrap(
1632        "oxide",
1633        "api_url_resolve",
1634        |mut caller: Caller<'_, HostState>,
1635         base_ptr: u32,
1636         base_len: u32,
1637         rel_ptr: u32,
1638         rel_len: u32,
1639         out_ptr: u32,
1640         out_cap: u32|
1641         -> i32 {
1642            let mem = caller.data().memory.expect("memory not set");
1643            let base_str = read_guest_string(&mem, &caller, base_ptr, base_len).unwrap_or_default();
1644            let rel_str = read_guest_string(&mem, &caller, rel_ptr, rel_len).unwrap_or_default();
1645
1646            let base = match oxide_url::OxideUrl::parse(&base_str) {
1647                Ok(u) => u,
1648                Err(_) => return -1,
1649            };
1650            let resolved = match base.join(&rel_str) {
1651                Ok(u) => u,
1652                Err(_) => return -2,
1653            };
1654
1655            let bytes = resolved.as_str().as_bytes();
1656            let write_len = bytes.len().min(out_cap as usize);
1657            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1658            write_len as i32
1659        },
1660    )?;
1661
1662    linker.func_wrap(
1663        "oxide",
1664        "api_url_encode",
1665        |mut caller: Caller<'_, HostState>,
1666         input_ptr: u32,
1667         input_len: u32,
1668         out_ptr: u32,
1669         out_cap: u32|
1670         -> u32 {
1671            let mem = caller.data().memory.expect("memory not set");
1672            let input = read_guest_string(&mem, &caller, input_ptr, input_len).unwrap_or_default();
1673            let encoded = oxide_url::percent_encode(&input);
1674            let bytes = encoded.as_bytes();
1675            let write_len = bytes.len().min(out_cap as usize);
1676            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1677            write_len as u32
1678        },
1679    )?;
1680
1681    linker.func_wrap(
1682        "oxide",
1683        "api_url_decode",
1684        |mut caller: Caller<'_, HostState>,
1685         input_ptr: u32,
1686         input_len: u32,
1687         out_ptr: u32,
1688         out_cap: u32|
1689         -> u32 {
1690            let mem = caller.data().memory.expect("memory not set");
1691            let input = read_guest_string(&mem, &caller, input_ptr, input_len).unwrap_or_default();
1692            let decoded = oxide_url::percent_decode(&input);
1693            let bytes = decoded.as_bytes();
1694            let write_len = bytes.len().min(out_cap as usize);
1695            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1696            write_len as u32
1697        },
1698    )?;
1699
1700    // ── Input Polling ────────────────────────────────────────────────
1701
1702    linker.func_wrap(
1703        "oxide",
1704        "api_mouse_position",
1705        |caller: Caller<'_, HostState>| -> u64 {
1706            let input = caller.data().input_state.lock().unwrap();
1707            let offset = caller.data().canvas_offset.lock().unwrap();
1708            let x = input.mouse_x - offset.0;
1709            let y = input.mouse_y - offset.1;
1710            ((x.to_bits() as u64) << 32) | (y.to_bits() as u64)
1711        },
1712    )?;
1713
1714    linker.func_wrap(
1715        "oxide",
1716        "api_mouse_button_down",
1717        |caller: Caller<'_, HostState>, button: u32| -> u32 {
1718            let input = caller.data().input_state.lock().unwrap();
1719            if (button as usize) < 3 && input.mouse_buttons_down[button as usize] {
1720                1
1721            } else {
1722                0
1723            }
1724        },
1725    )?;
1726
1727    linker.func_wrap(
1728        "oxide",
1729        "api_mouse_button_clicked",
1730        |caller: Caller<'_, HostState>, button: u32| -> u32 {
1731            let input = caller.data().input_state.lock().unwrap();
1732            if (button as usize) < 3 && input.mouse_buttons_clicked[button as usize] {
1733                1
1734            } else {
1735                0
1736            }
1737        },
1738    )?;
1739
1740    linker.func_wrap(
1741        "oxide",
1742        "api_key_down",
1743        |caller: Caller<'_, HostState>, key: u32| -> u32 {
1744            let input = caller.data().input_state.lock().unwrap();
1745            if input.keys_down.contains(&key) {
1746                1
1747            } else {
1748                0
1749            }
1750        },
1751    )?;
1752
1753    linker.func_wrap(
1754        "oxide",
1755        "api_key_pressed",
1756        |caller: Caller<'_, HostState>, key: u32| -> u32 {
1757            let input = caller.data().input_state.lock().unwrap();
1758            if input.keys_pressed.contains(&key) {
1759                1
1760            } else {
1761                0
1762            }
1763        },
1764    )?;
1765
1766    linker.func_wrap(
1767        "oxide",
1768        "api_scroll_delta",
1769        |caller: Caller<'_, HostState>| -> u64 {
1770            let input = caller.data().input_state.lock().unwrap();
1771            ((input.scroll_x.to_bits() as u64) << 32) | (input.scroll_y.to_bits() as u64)
1772        },
1773    )?;
1774
1775    linker.func_wrap(
1776        "oxide",
1777        "api_modifiers",
1778        |caller: Caller<'_, HostState>| -> u32 {
1779            let input = caller.data().input_state.lock().unwrap();
1780            let mut flags = 0u32;
1781            if input.modifiers_shift {
1782                flags |= 1;
1783            }
1784            if input.modifiers_ctrl {
1785                flags |= 2;
1786            }
1787            if input.modifiers_alt {
1788                flags |= 4;
1789            }
1790            flags
1791        },
1792    )?;
1793
1794    // ── Audio Playback ────────────────────────────────────────────
1795    // All single-argument functions operate on the default channel (0).
1796    // Channel-specific variants allow simultaneous playback on separate
1797    // channels (e.g. background music on 0, SFX on 1+).
1798
1799    linker.func_wrap(
1800        "oxide",
1801        "api_audio_play",
1802        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32| -> i32 {
1803            let mem = caller.data().memory.expect("memory not set");
1804            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
1805            if data.is_empty() {
1806                return -1;
1807            }
1808
1809            let audio = caller.data().audio.clone();
1810            let mut guard = audio.lock().unwrap();
1811            if guard.is_none() {
1812                *guard = AudioEngine::try_new();
1813            }
1814            match guard.as_mut() {
1815                Some(engine) => {
1816                    if engine.play_bytes_on(0, data) {
1817                        console_log(
1818                            &caller.data().console,
1819                            ConsoleLevel::Log,
1820                            "[AUDIO] Playing from bytes".into(),
1821                        );
1822                        0
1823                    } else {
1824                        console_log(
1825                            &caller.data().console,
1826                            ConsoleLevel::Error,
1827                            "[AUDIO] Failed to decode audio data".into(),
1828                        );
1829                        -2
1830                    }
1831                }
1832                None => {
1833                    console_log(
1834                        &caller.data().console,
1835                        ConsoleLevel::Error,
1836                        "[AUDIO] No audio device available".into(),
1837                    );
1838                    -3
1839                }
1840            }
1841        },
1842    )?;
1843
1844    linker.func_wrap(
1845        "oxide",
1846        "api_audio_play_url",
1847        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
1848            let mem = caller.data().memory.expect("memory not set");
1849            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
1850
1851            console_log(
1852                &caller.data().console,
1853                ConsoleLevel::Log,
1854                format!("[AUDIO] Fetching {url}"),
1855            );
1856
1857            let (tx, rx) = std::sync::mpsc::sync_channel::<Result<Vec<u8>, String>>(1);
1858            let fetch_url = url.clone();
1859            std::thread::spawn(move || {
1860                let result = (|| -> Result<Vec<u8>, String> {
1861                    let client = reqwest::blocking::Client::builder()
1862                        .timeout(Duration::from_secs(30))
1863                        .build()
1864                        .map_err(|e| e.to_string())?;
1865                    let resp = client.get(&fetch_url).send().map_err(|e| e.to_string())?;
1866                    if !resp.status().is_success() {
1867                        return Err(format!("HTTP {}", resp.status()));
1868                    }
1869                    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
1870                })();
1871                let _ = tx.send(result);
1872            });
1873
1874            let data = match rx.recv() {
1875                Ok(Ok(bytes)) => bytes,
1876                Ok(Err(e)) => {
1877                    console_log(
1878                        &caller.data().console,
1879                        ConsoleLevel::Error,
1880                        format!("[AUDIO] Fetch error: {e}"),
1881                    );
1882                    return -1;
1883                }
1884                Err(_) => return -1,
1885            };
1886
1887            let audio = caller.data().audio.clone();
1888            let mut guard = audio.lock().unwrap();
1889            if guard.is_none() {
1890                *guard = AudioEngine::try_new();
1891            }
1892            match guard.as_mut() {
1893                Some(engine) => {
1894                    if engine.play_bytes_on(0, data) {
1895                        console_log(
1896                            &caller.data().console,
1897                            ConsoleLevel::Log,
1898                            format!("[AUDIO] Playing from URL: {url}"),
1899                        );
1900                        0
1901                    } else {
1902                        console_log(
1903                            &caller.data().console,
1904                            ConsoleLevel::Error,
1905                            "[AUDIO] Failed to decode fetched audio".into(),
1906                        );
1907                        -2
1908                    }
1909                }
1910                None => {
1911                    console_log(
1912                        &caller.data().console,
1913                        ConsoleLevel::Error,
1914                        "[AUDIO] No audio device available".into(),
1915                    );
1916                    -3
1917                }
1918            }
1919        },
1920    )?;
1921
1922    linker.func_wrap(
1923        "oxide",
1924        "api_audio_pause",
1925        |caller: Caller<'_, HostState>| {
1926            let audio = caller.data().audio.clone();
1927            let guard = audio.lock().unwrap();
1928            if let Some(engine) = guard.as_ref() {
1929                if let Some(ch) = engine.channels.get(&0) {
1930                    ch.player.pause();
1931                }
1932            }
1933        },
1934    )?;
1935
1936    linker.func_wrap(
1937        "oxide",
1938        "api_audio_resume",
1939        |caller: Caller<'_, HostState>| {
1940            let audio = caller.data().audio.clone();
1941            let guard = audio.lock().unwrap();
1942            if let Some(engine) = guard.as_ref() {
1943                if let Some(ch) = engine.channels.get(&0) {
1944                    ch.player.play();
1945                }
1946            }
1947        },
1948    )?;
1949
1950    linker.func_wrap(
1951        "oxide",
1952        "api_audio_stop",
1953        |caller: Caller<'_, HostState>| {
1954            let audio = caller.data().audio.clone();
1955            let guard = audio.lock().unwrap();
1956            if let Some(engine) = guard.as_ref() {
1957                if let Some(ch) = engine.channels.get(&0) {
1958                    ch.player.stop();
1959                }
1960            }
1961        },
1962    )?;
1963
1964    linker.func_wrap(
1965        "oxide",
1966        "api_audio_set_volume",
1967        |caller: Caller<'_, HostState>, level: f32| {
1968            let audio = caller.data().audio.clone();
1969            let guard = audio.lock().unwrap();
1970            if let Some(engine) = guard.as_ref() {
1971                if let Some(ch) = engine.channels.get(&0) {
1972                    ch.player.set_volume(level.clamp(0.0, 2.0));
1973                }
1974            }
1975        },
1976    )?;
1977
1978    linker.func_wrap(
1979        "oxide",
1980        "api_audio_get_volume",
1981        |caller: Caller<'_, HostState>| -> f32 {
1982            let audio = caller.data().audio.clone();
1983            let guard = audio.lock().unwrap();
1984            guard
1985                .as_ref()
1986                .and_then(|e| e.channels.get(&0))
1987                .map(|ch| ch.player.volume())
1988                .unwrap_or(1.0)
1989        },
1990    )?;
1991
1992    linker.func_wrap(
1993        "oxide",
1994        "api_audio_is_playing",
1995        |caller: Caller<'_, HostState>| -> u32 {
1996            let audio = caller.data().audio.clone();
1997            let guard = audio.lock().unwrap();
1998            match guard.as_ref().and_then(|e| e.channels.get(&0)) {
1999                Some(ch) if !ch.player.is_paused() && !ch.player.empty() => 1,
2000                _ => 0,
2001            }
2002        },
2003    )?;
2004
2005    linker.func_wrap(
2006        "oxide",
2007        "api_audio_position",
2008        |caller: Caller<'_, HostState>| -> u64 {
2009            let audio = caller.data().audio.clone();
2010            let guard = audio.lock().unwrap();
2011            guard
2012                .as_ref()
2013                .and_then(|e| e.channels.get(&0))
2014                .map(|ch| ch.player.get_pos().as_millis() as u64)
2015                .unwrap_or(0)
2016        },
2017    )?;
2018
2019    linker.func_wrap(
2020        "oxide",
2021        "api_audio_seek",
2022        |caller: Caller<'_, HostState>, position_ms: u64| -> i32 {
2023            let audio = caller.data().audio.clone();
2024            let guard = audio.lock().unwrap();
2025            match guard.as_ref().and_then(|e| e.channels.get(&0)) {
2026                Some(ch) => {
2027                    let pos = Duration::from_millis(position_ms);
2028                    match ch.player.try_seek(pos) {
2029                        Ok(_) => 0,
2030                        Err(e) => {
2031                            console_log(
2032                                &caller.data().console,
2033                                ConsoleLevel::Warn,
2034                                format!("[AUDIO] Seek failed: {e}"),
2035                            );
2036                            -1
2037                        }
2038                    }
2039                }
2040                None => -1,
2041            }
2042        },
2043    )?;
2044
2045    linker.func_wrap(
2046        "oxide",
2047        "api_audio_duration",
2048        |caller: Caller<'_, HostState>| -> u64 {
2049            let audio = caller.data().audio.clone();
2050            let guard = audio.lock().unwrap();
2051            guard
2052                .as_ref()
2053                .and_then(|e| e.channels.get(&0))
2054                .map(|ch| ch.duration_ms)
2055                .unwrap_or(0)
2056        },
2057    )?;
2058
2059    linker.func_wrap(
2060        "oxide",
2061        "api_audio_set_loop",
2062        |caller: Caller<'_, HostState>, enabled: u32| {
2063            let audio = caller.data().audio.clone();
2064            let mut guard = audio.lock().unwrap();
2065            if guard.is_none() {
2066                *guard = AudioEngine::try_new();
2067            }
2068            if let Some(engine) = guard.as_mut() {
2069                engine.ensure_channel(0).looping = enabled != 0;
2070            }
2071        },
2072    )?;
2073
2074    linker.func_wrap(
2075        "oxide",
2076        "api_audio_channel_play",
2077        |caller: Caller<'_, HostState>, channel: u32, data_ptr: u32, data_len: u32| -> i32 {
2078            let mem = caller.data().memory.expect("memory not set");
2079            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2080            if data.is_empty() {
2081                return -1;
2082            }
2083
2084            let audio = caller.data().audio.clone();
2085            let mut guard = audio.lock().unwrap();
2086            if guard.is_none() {
2087                *guard = AudioEngine::try_new();
2088            }
2089            match guard.as_mut() {
2090                Some(engine) => {
2091                    if engine.play_bytes_on(channel, data) {
2092                        console_log(
2093                            &caller.data().console,
2094                            ConsoleLevel::Log,
2095                            format!("[AUDIO] Playing on channel {channel}"),
2096                        );
2097                        0
2098                    } else {
2099                        console_log(
2100                            &caller.data().console,
2101                            ConsoleLevel::Error,
2102                            format!("[AUDIO] Failed to decode audio for channel {channel}"),
2103                        );
2104                        -2
2105                    }
2106                }
2107                None => -3,
2108            }
2109        },
2110    )?;
2111
2112    linker.func_wrap(
2113        "oxide",
2114        "api_audio_channel_stop",
2115        |caller: Caller<'_, HostState>, channel: u32| {
2116            let audio = caller.data().audio.clone();
2117            let guard = audio.lock().unwrap();
2118            if let Some(engine) = guard.as_ref() {
2119                if let Some(ch) = engine.channels.get(&channel) {
2120                    ch.player.stop();
2121                }
2122            }
2123        },
2124    )?;
2125
2126    linker.func_wrap(
2127        "oxide",
2128        "api_audio_channel_set_volume",
2129        |caller: Caller<'_, HostState>, channel: u32, level: f32| {
2130            let audio = caller.data().audio.clone();
2131            let guard = audio.lock().unwrap();
2132            if let Some(engine) = guard.as_ref() {
2133                if let Some(ch) = engine.channels.get(&channel) {
2134                    ch.player.set_volume(level.clamp(0.0, 2.0));
2135                }
2136            }
2137        },
2138    )?;
2139
2140    // ── Interactive Widgets ─────────────────────────────────────────
2141
2142    linker.func_wrap(
2143        "oxide",
2144        "api_ui_button",
2145        |caller: Caller<'_, HostState>,
2146         id: u32,
2147         x: f32,
2148         y: f32,
2149         w: f32,
2150         h: f32,
2151         label_ptr: u32,
2152         label_len: u32|
2153         -> u32 {
2154            let mem = caller.data().memory.expect("memory not set");
2155            let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default();
2156            caller
2157                .data()
2158                .widget_commands
2159                .lock()
2160                .unwrap()
2161                .push(WidgetCommand::Button {
2162                    id,
2163                    x,
2164                    y,
2165                    w,
2166                    h,
2167                    label,
2168                });
2169            if caller.data().widget_clicked.lock().unwrap().contains(&id) {
2170                1
2171            } else {
2172                0
2173            }
2174        },
2175    )?;
2176
2177    linker.func_wrap(
2178        "oxide",
2179        "api_ui_checkbox",
2180        |caller: Caller<'_, HostState>,
2181         id: u32,
2182         x: f32,
2183         y: f32,
2184         label_ptr: u32,
2185         label_len: u32,
2186         initial: u32|
2187         -> u32 {
2188            let mem = caller.data().memory.expect("memory not set");
2189            let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default();
2190            let mut states = caller.data().widget_states.lock().unwrap();
2191            let entry = states
2192                .entry(id)
2193                .or_insert_with(|| WidgetValue::Bool(initial != 0));
2194            let checked = match entry {
2195                WidgetValue::Bool(b) => *b,
2196                _ => initial != 0,
2197            };
2198            drop(states);
2199            caller
2200                .data()
2201                .widget_commands
2202                .lock()
2203                .unwrap()
2204                .push(WidgetCommand::Checkbox { id, x, y, label });
2205            if checked {
2206                1
2207            } else {
2208                0
2209            }
2210        },
2211    )?;
2212
2213    linker.func_wrap(
2214        "oxide",
2215        "api_ui_slider",
2216        |caller: Caller<'_, HostState>,
2217         id: u32,
2218         x: f32,
2219         y: f32,
2220         w: f32,
2221         min: f32,
2222         max: f32,
2223         initial: f32|
2224         -> f32 {
2225            let mut states = caller.data().widget_states.lock().unwrap();
2226            let entry = states
2227                .entry(id)
2228                .or_insert_with(|| WidgetValue::Float(initial));
2229            let value = match entry {
2230                WidgetValue::Float(v) => *v,
2231                _ => initial,
2232            };
2233            drop(states);
2234            caller
2235                .data()
2236                .widget_commands
2237                .lock()
2238                .unwrap()
2239                .push(WidgetCommand::Slider {
2240                    id,
2241                    x,
2242                    y,
2243                    w,
2244                    min,
2245                    max,
2246                });
2247            value
2248        },
2249    )?;
2250
2251    linker.func_wrap(
2252        "oxide",
2253        "api_ui_text_input",
2254        |mut caller: Caller<'_, HostState>,
2255         id: u32,
2256         x: f32,
2257         y: f32,
2258         w: f32,
2259         init_ptr: u32,
2260         init_len: u32,
2261         out_ptr: u32,
2262         out_cap: u32|
2263         -> u32 {
2264            let mem = caller.data().memory.expect("memory not set");
2265            let text = {
2266                let mut states = caller.data().widget_states.lock().unwrap();
2267                let entry = states.entry(id).or_insert_with(|| {
2268                    let init =
2269                        read_guest_string(&mem, &caller, init_ptr, init_len).unwrap_or_default();
2270                    WidgetValue::Text(init)
2271                });
2272                match entry {
2273                    WidgetValue::Text(t) => t.clone(),
2274                    _ => String::new(),
2275                }
2276            };
2277            caller
2278                .data()
2279                .widget_commands
2280                .lock()
2281                .unwrap()
2282                .push(WidgetCommand::TextInput { id, x, y, w });
2283            let bytes = text.as_bytes();
2284            let write_len = bytes.len().min(out_cap as usize);
2285            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2286            write_len as u32
2287        },
2288    )?;
2289
2290    Ok(())
2291}
2292
2293fn getrandom(buf: &mut [u8]) {
2294    ::getrandom::getrandom(buf).expect("OS random number generator unavailable");
2295}