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::atomic::AtomicBool;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use anyhow::{Context, Result};
19use image::GenericImageView;
20use reqwest::header::{ACCEPT, CONTENT_TYPE};
21use wasmtime::*;
22
23use crate::audio_format;
24use crate::bookmarks::SharedBookmarkStore;
25use crate::download::DownloadManager;
26use crate::engine::ModuleLoader;
27use crate::history::SharedHistoryStore;
28use crate::navigation::NavigationStack;
29use crate::subtitle;
30use crate::url as oxide_url;
31use crate::video::{self, VideoPlaybackState};
32use crate::video_format;
33
34/// Per-channel audio state: a rodio Player plus metadata.
35struct AudioChannel {
36    player: rodio::Player,
37    duration_ms: u64,
38    looping: bool,
39}
40
41/// Multi-channel audio playback engine backed by [rodio](https://crates.io/crates/rodio).
42///
43/// Each logical channel has its own [`rodio::Player`] so guests can play overlapping
44/// sounds (for example music on one channel and effects on another). The default channel
45/// used by the single-channel `api_audio_*` imports is `0`.
46pub struct AudioEngine {
47    _device_sink: rodio::stream::MixerDeviceSink,
48    channels: HashMap<u32, AudioChannel>,
49}
50
51impl AudioEngine {
52    fn try_new() -> Option<Self> {
53        let mut device_sink = rodio::DeviceSinkBuilder::open_default_sink().ok()?;
54        device_sink.log_on_drop(false);
55        Some(Self {
56            _device_sink: device_sink,
57            channels: HashMap::new(),
58        })
59    }
60
61    fn ensure_channel(&mut self, id: u32) -> &mut AudioChannel {
62        if !self.channels.contains_key(&id) {
63            let player = rodio::Player::connect_new(self._device_sink.mixer());
64            self.channels.insert(
65                id,
66                AudioChannel {
67                    player,
68                    duration_ms: 0,
69                    looping: false,
70                },
71            );
72        }
73        self.channels.get_mut(&id).unwrap()
74    }
75
76    fn play_bytes_on(&mut self, channel_id: u32, data: Vec<u8>) -> bool {
77        use rodio::Source;
78
79        let cursor = std::io::Cursor::new(data);
80        let reader = std::io::BufReader::new(cursor);
81        let source = match rodio::Decoder::try_from(reader) {
82            Ok(s) => s,
83            Err(_) => return false,
84        };
85
86        let duration_ms = source
87            .total_duration()
88            .map(|d| d.as_millis() as u64)
89            .unwrap_or(0);
90
91        let ch = self.ensure_channel(channel_id);
92        ch.player.clear();
93        ch.duration_ms = duration_ms;
94
95        if ch.looping {
96            ch.player.append(source.repeat_infinite());
97        } else {
98            ch.player.append(source);
99        }
100        ch.player.play();
101        true
102    }
103}
104
105/// One-shot animation frame request. Queued by `api_request_animation_frame` and drained
106/// every frame (before `on_frame`) in `LiveModule::tick`. Fires the guest `callback_id` via
107/// the existing `on_timer` export exactly once.
108#[derive(Clone, Debug)]
109pub struct AnimationRequest {
110    /// Host-assigned ID (for `cancel_animation_frame`). Mirrors timer ID scheme.
111    pub id: u32,
112    /// Guest-defined ID passed to `on_timer(callback_id)` when the frame fires.
113    pub callback_id: u32,
114}
115
116/// Drain all pending animation frame requests (one-shot by design). Returns the
117/// `callback_id`s to fire via `on_timer`. The queue is cleared after draining.
118pub fn drain_animation_frame_requests(requests: &Arc<Mutex<Vec<AnimationRequest>>>) -> Vec<u32> {
119    let mut guard = requests.lock().unwrap();
120    let callback_ids: Vec<u32> = guard.iter().map(|r| r.callback_id).collect();
121    guard.clear();
122    callback_ids
123}
124
125/// All shared state between the browser host and a guest Wasm module (and dynamically loaded children).
126///
127/// Most fields are behind [`Arc`] and [`Mutex`] so the same state can be shared across
128/// threads and nested module loads. Host code sets fields like [`HostState::memory`] and
129/// [`HostState::current_url`] before or during execution; guest imports mutate the rest
130/// through the registered `oxide` functions.
131#[derive(Clone)]
132pub struct HostState {
133    /// Console log lines shown in the host UI, appended by [`console_log`] and `api_*` helpers.
134    pub console: Arc<Mutex<Vec<ConsoleEntry>>>,
135    /// Raster canvas: queued draw commands and decoded images for the current frame.
136    pub canvas: Arc<Mutex<CanvasState>>,
137    /// In-memory key/value session storage (string keys and values), similar to `localStorage` in scope.
138    pub storage: Arc<Mutex<HashMap<String, String>>>,
139    /// Pending one-shot and interval timers; the host drains these and invokes `on_timer` on the guest.
140    pub timers: Arc<Mutex<Vec<TimerEntry>>>,
141    /// Pending one-shot animation frame requests. Drained every frame (before timers and `on_frame`)
142    /// and fired via the existing `on_timer` export. See [`drain_animation_frame_requests`].
143    pub animation_requests: Arc<Mutex<Vec<AnimationRequest>>>,
144    /// Monotonic counter used to assign unique [`TimerEntry::id`] values for `api_set_timeout` / `api_set_interval`.
145    pub timer_next_id: Arc<Mutex<u32>>,
146    /// Last text written to or read from the clipboard via the guest API (when permitted).
147    pub clipboard: Arc<Mutex<String>>,
148    /// When `false`, `api_clipboard_read` / `api_clipboard_write` are blocked and log a warning.
149    pub clipboard_allowed: Arc<Mutex<bool>>,
150    /// Optional embedded [`sled`] database for persistent per-origin key/value bytes (`api_kv_store_*`).
151    pub kv_db: Option<Arc<sled::Db>>,
152    /// The guest’s exported linear memory, used to read/write pointers passed to host imports.
153    pub memory: Option<Memory>,
154    /// Engine and limits used by `api_load_module` to fetch and instantiate child Wasm modules.
155    pub module_loader: Option<Arc<ModuleLoader>>,
156    /// Session history stack for `api_push_state`, `api_replace_state`, and back/forward navigation.
157    pub navigation: Arc<Mutex<NavigationStack>>,
158    /// Hit-test regions registered by the guest for link clicks in the canvas area.
159    pub hyperlinks: Arc<Mutex<Vec<Hyperlink>>>,
160    /// Set by guest `api_navigate` — consumed by the UI after module returns.
161    pub pending_navigation: Arc<Mutex<Option<String>>>,
162    /// The URL of the currently loaded module (set by the host before execution).
163    pub current_url: Arc<Mutex<String>>,
164    /// Input state polled by the guest each frame.
165    pub input_state: Arc<Mutex<InputState>>,
166    /// Widget commands issued by the guest during `on_frame`.
167    pub widget_commands: Arc<Mutex<Vec<WidgetCommand>>>,
168    /// Persistent widget values (checkbox, slider, text input state).
169    pub widget_states: Arc<Mutex<HashMap<u32, WidgetValue>>>,
170    /// Button IDs that were clicked during the last render pass.
171    pub widget_clicked: Arc<Mutex<HashSet<u32>>>,
172    /// Top-left corner of the canvas panel in GPUI screen coords.
173    pub canvas_offset: Arc<Mutex<(f32, f32)>>,
174    /// Persistent bookmark storage shared across tabs.
175    pub bookmark_store: SharedBookmarkStore,
176    /// Persistent browsing history shared across tabs.
177    pub history_store: SharedHistoryStore,
178    /// Audio playback engine (lazily initialised on first audio API call).
179    pub audio: Arc<Mutex<Option<AudioEngine>>>,
180    /// `Content-Type` from the last `api_audio_play_url` response (UTF-8), for codec negotiation introspection.
181    pub last_audio_url_content_type: Arc<Mutex<String>>,
182    /// Video playback, decode, subtitles, and HLS variant metadata (FFmpeg).
183    pub video: Arc<Mutex<VideoPlaybackState>>,
184    /// Last decoded video frame for picture-in-picture (RGBA, copied when PiP is enabled).
185    pub video_pip_frame: Arc<Mutex<Option<DecodedImage>>>,
186    /// Bumped when the PiP buffer is updated so the UI can refresh the floating texture.
187    pub video_pip_serial: Arc<Mutex<u64>>,
188    /// Camera, microphone, and screen capture (permission prompts + native APIs).
189    pub media_capture: Arc<Mutex<crate::media_capture::MediaCaptureState>>,
190    /// WebGPU-style GPU resource state (lazily initialised on first GPU API call).
191    pub gpu: Arc<Mutex<Option<crate::gpu::GpuState>>>,
192    /// WebRTC peer connections, data channels, and signaling (lazily initialised on first RTC call).
193    pub rtc: Arc<Mutex<Option<crate::rtc::RtcState>>>,
194    /// WebSocket connections (lazily initialised on first ws call).
195    pub ws: Arc<Mutex<Option<crate::websocket::WsState>>>,
196    /// MIDI input/output connections (lazily initialised on first midi_open call).
197    pub midi: Arc<Mutex<Option<crate::midi::MidiState>>>,
198    /// Streaming / non-blocking fetch state (lazily initialised on first `api_fetch_begin`).
199    pub fetch: Arc<Mutex<Option<crate::fetch::FetchState>>>,
200    /// Native file and folder picker handles. Paths never cross the sandbox;
201    /// guests only see opaque `u32` handles allocated here.
202    pub file_picker: Arc<Mutex<crate::file_picker::FilePickerState>>,
203    /// Event listeners, queued events, and built-in event detector state
204    /// (resize, focus, online/offline, touch, gamepad, drag-drop).
205    pub events: Arc<Mutex<crate::events::EventState>>,
206    /// Download manager for saving files and exporting canvas content.
207    pub download_manager: DownloadManager,
208    /// Whether the canvas currently has keyboard/window focus. Set by the UI
209    /// layer each frame; consumed by the event system to fire `focus` /
210    /// `blur` / `visibility_change`.
211    pub focused: Arc<AtomicBool>,
212    /// Per-frame GPUI [`WindowTextSystem`] used for synchronous text shaping
213    /// from `api_canvas_measure_text`. The UI layer installs it right before
214    /// calling `on_frame` and clears it immediately after, so this is only
215    /// `Some` during guest frame callbacks.
216    pub text_system: Arc<Mutex<Option<Arc<gpui::WindowTextSystem>>>>,
217    /// Virtual width of guest content.
218    pub content_width: Arc<Mutex<u32>>,
219    /// Virtual height of guest content.
220    pub content_height: Arc<Mutex<u32>>,
221    /// Absolute scroll horizontal offset.
222    pub scroll_x: Arc<Mutex<f32>>,
223    /// Absolute scroll vertical offset.
224    pub scroll_y: Arc<Mutex<f32>>,
225}
226
227/// A single console log line: local time, severity, and message text.
228#[derive(Clone, Debug)]
229pub struct ConsoleEntry {
230    /// Time of day when the entry was recorded (`chrono` local format, e.g. `14:03:22.123`).
231    pub timestamp: String,
232    /// Severity bucket for styling in the host console.
233    pub level: ConsoleLevel,
234    /// UTF-8 message body.
235    pub message: String,
236}
237
238/// Severity level for [`ConsoleEntry`] and [`console_log`].
239#[derive(Clone, Debug)]
240pub enum ConsoleLevel {
241    /// Informational message (maps to `api_log`).
242    Log,
243    /// Warning (maps to `api_warn`).
244    Warn,
245    /// Error (maps to `api_error`).
246    Error,
247}
248
249/// Current canvas snapshot for one frame: command list, dimensions, image atlas, and invalidation generation.
250#[derive(Clone, Debug)]
251pub struct CanvasState {
252    /// Ordered draw operations accumulated since the last clear (or start of frame).
253    pub commands: Vec<DrawCommand>,
254    /// Canvas width in pixels.
255    pub width: u32,
256    /// Canvas height in pixels.
257    pub height: u32,
258    /// Decoded images indexed by position in this vector; [`DrawCommand::Image`] references them by `image_id`.
259    pub images: Vec<DecodedImage>,
260    /// Bumped when the canvas is cleared so the host can detect a full redraw.
261    pub generation: u64,
262}
263
264/// An image decoded to RGBA8 pixels for compositing in the host canvas renderer.
265#[derive(Clone, Debug)]
266pub struct DecodedImage {
267    /// Width in pixels.
268    pub width: u32,
269    /// Height in pixels.
270    pub height: u32,
271    /// Raw RGBA bytes, row-major (`width * height * 4` elements when full frame).
272    pub pixels: Vec<u8>,
273}
274
275/// A single color stop inside a gradient (offset + RGBA).
276#[derive(Clone, Debug)]
277pub struct GradientStop {
278    /// Position along the gradient axis, 0.0 to 1.0.
279    pub offset: f32,
280    pub r: u8,
281    pub g: u8,
282    pub b: u8,
283    pub a: u8,
284}
285
286/// One canvas drawing operation produced by guest `api_canvas_*` imports and consumed by the host renderer.
287#[derive(Clone, Debug)]
288pub enum DrawCommand {
289    /// Fill the entire canvas with a solid RGBA color and reset the command list (see `api_canvas_clear`).
290    Clear { r: u8, g: u8, b: u8, a: u8 },
291    /// Axis-aligned filled rectangle in canvas coordinates with RGBA fill.
292    Rect {
293        x: f32,
294        y: f32,
295        w: f32,
296        h: f32,
297        r: u8,
298        g: u8,
299        b: u8,
300        a: u8,
301    },
302    /// Filled circle centered at `(cx, cy)` with the given radius and RGBA fill.
303    Circle {
304        cx: f32,
305        cy: f32,
306        radius: f32,
307        r: u8,
308        g: u8,
309        b: u8,
310        a: u8,
311    },
312    /// Text baseline position `(x, y)`, font size in pixels, RGBA color, and string payload.
313    Text {
314        x: f32,
315        y: f32,
316        size: f32,
317        r: u8,
318        g: u8,
319        b: u8,
320        a: u8,
321        text: String,
322    },
323    /// Line from `(x1, y1)` to `(x2, y2)` with RGBA stroke color and stroke width in pixels.
324    Line {
325        x1: f32,
326        y1: f32,
327        x2: f32,
328        y2: f32,
329        r: u8,
330        g: u8,
331        b: u8,
332        a: u8,
333        thickness: f32,
334    },
335    /// Draw [`DecodedImage`] `image_id` from `images` into the axis-aligned rectangle `(x, y, w, h)`.
336    Image {
337        x: f32,
338        y: f32,
339        w: f32,
340        h: f32,
341        image_id: usize,
342    },
343    /// Filled rounded rectangle with uniform corner radius.
344    RoundedRect {
345        x: f32,
346        y: f32,
347        w: f32,
348        h: f32,
349        radius: f32,
350        r: u8,
351        g: u8,
352        b: u8,
353        a: u8,
354    },
355    /// Circular arc stroke from `start_angle` to `end_angle` (radians, CW from +X axis).
356    Arc {
357        cx: f32,
358        cy: f32,
359        radius: f32,
360        start_angle: f32,
361        end_angle: f32,
362        r: u8,
363        g: u8,
364        b: u8,
365        a: u8,
366        thickness: f32,
367    },
368    /// Cubic Bézier curve stroke from `(x1,y1)` to `(x2,y2)` with two control points.
369    Bezier {
370        x1: f32,
371        y1: f32,
372        cp1x: f32,
373        cp1y: f32,
374        cp2x: f32,
375        cp2y: f32,
376        x2: f32,
377        y2: f32,
378        r: u8,
379        g: u8,
380        b: u8,
381        a: u8,
382        thickness: f32,
383    },
384    /// Linear gradient fill over an axis-aligned rectangle.
385    Gradient {
386        x: f32,
387        y: f32,
388        w: f32,
389        h: f32,
390        /// 0 = linear, 1 = radial.
391        kind: u8,
392        /// Gradient axis start (linear) or center (radial) X, relative to the rect.
393        ax: f32,
394        /// Gradient axis start (linear) or center (radial) Y, relative to the rect.
395        ay: f32,
396        /// Gradient axis end X (linear) or ignored for radial.
397        bx: f32,
398        /// Gradient axis end Y (linear) or radius for radial.
399        by: f32,
400        /// Color stops: each entry is `(offset 0.0–1.0, r, g, b, a)`.
401        stops: Vec<GradientStop>,
402    },
403    /// Push the current transform/clip/opacity state onto the stack.
404    Save,
405    /// Pop and restore the most recently saved state.
406    Restore,
407    /// Apply a 2D affine transform to subsequent draw commands (column-major: `[a,b,c,d,tx,ty]`).
408    Transform {
409        a: f32,
410        b: f32,
411        c: f32,
412        d: f32,
413        tx: f32,
414        ty: f32,
415    },
416    /// Intersect the current clip with an axis-aligned rectangle.
417    Clip { x: f32, y: f32, w: f32, h: f32 },
418    /// Set layer opacity for subsequent draw commands (0.0 transparent – 1.0 opaque).
419    Opacity { alpha: f32 },
420    /// Text with explicit family, weight, style, and alignment. The baseline is
421    /// at `(x, y)` for [`TextAlign::Left`]; for centre/right alignment, `x` is
422    /// the right edge or centre respectively.
423    TextEx {
424        x: f32,
425        y: f32,
426        size: f32,
427        r: u8,
428        g: u8,
429        b: u8,
430        a: u8,
431        /// CSS-style family name (e.g. `"Helvetica"`). Empty string falls back
432        /// to the system UI font.
433        family: String,
434        /// Weight in the CSS range `100..=900`. `0` means the default (400).
435        weight: u16,
436        /// `0` = normal, `1` = italic, `2` = oblique.
437        style: u8,
438        /// `0` = left (x is left edge), `1` = centre (x is centre), `2` = right (x is right edge).
439        align: u8,
440        text: String,
441    },
442}
443
444/// A scheduled timer: either a one-shot `setTimeout` or repeating `setInterval`.
445#[derive(Clone, Debug)]
446pub struct TimerEntry {
447    /// Host-assigned id returned by `api_set_timeout` / `api_set_interval` for `api_clear_timer`.
448    pub id: u32,
449    /// Absolute time when this entry should fire next.
450    pub fire_at: Instant,
451    /// `None` for a one-shot timer; `Some(duration)` for an interval (rescheduled after each fire).
452    pub interval: Option<Duration>,
453    /// Guest-defined id passed to the exported `on_timer` callback when this timer fires.
454    pub callback_id: u32,
455}
456
457/// Remove due timers from `timers`, collect each fired entry’s [`TimerEntry::callback_id`], and return them.
458///
459/// Compares each [`TimerEntry::fire_at`] against `Instant::now()`. **One-shot** entries
460/// (`interval` is `None`) are removed from the vector after firing. **Interval** entries
461/// are kept and their `fire_at` is advanced by `interval` so they fire again later. The
462/// host typically calls the guest’s `on_timer` once per id in the returned vector.
463pub fn drain_expired_timers(timers: &Arc<Mutex<Vec<TimerEntry>>>) -> Vec<u32> {
464    let now = Instant::now();
465    let mut guard = timers.lock().unwrap();
466    let mut fired = Vec::new();
467    let mut i = 0;
468    while i < guard.len() {
469        if guard[i].fire_at <= now {
470            fired.push(guard[i].callback_id);
471            if let Some(interval) = guard[i].interval {
472                guard[i].fire_at = now + interval;
473                i += 1;
474            } else {
475                guard.swap_remove(i);
476            }
477        } else {
478            i += 1;
479        }
480    }
481    fired
482}
483
484/// A clickable axis-aligned rectangle on the canvas that navigates to a URL when hit-tested.
485///
486/// Populated by `api_register_hyperlink` and cleared with `api_clear_hyperlinks`. Coordinates
487/// are in the same space as canvas drawing (the host maps pointer position into this space).
488#[derive(Clone, Debug)]
489pub struct Hyperlink {
490    /// Left edge of the hit region in canvas coordinates.
491    pub x: f32,
492    /// Top edge of the hit region in canvas coordinates.
493    pub y: f32,
494    /// Width of the hit region.
495    pub w: f32,
496    /// Height of the hit region.
497    pub h: f32,
498    /// Target URL (already resolved relative to the current page URL when registered).
499    pub url: String,
500}
501
502/// Per-frame input snapshot from the host (GPUI) for guest polling via `api_mouse_*`, `api_key_*`, etc.
503#[derive(Clone, Debug, Default)]
504pub struct InputState {
505    /// Pointer horizontal position in window/content coordinates before canvas offset subtraction in APIs.
506    pub mouse_x: f32,
507    /// Pointer vertical position in window/content coordinates before canvas offset subtraction in APIs.
508    pub mouse_y: f32,
509    /// Mouse buttons currently held: index 0 = primary, 1 = secondary, 2 = middle.
510    pub mouse_buttons_down: [bool; 3],
511    /// Mouse buttons that transitioned to pressed this frame (same indexing as `mouse_buttons_down`).
512    pub mouse_buttons_clicked: [bool; 3],
513    /// Key codes currently held (host-defined `u32` values, polled by `api_key_down`).
514    pub keys_down: Vec<u32>,
515    /// Key codes that registered a press this frame (`api_key_pressed`).
516    pub keys_pressed: Vec<u32>,
517    /// Shift modifier held this frame.
518    pub modifiers_shift: bool,
519    /// Control modifier held this frame.
520    pub modifiers_ctrl: bool,
521    /// Alt modifier held this frame.
522    pub modifiers_alt: bool,
523    /// Horizontal scroll delta for this frame.
524    pub scroll_x: f32,
525    /// Vertical scroll delta for this frame.
526    pub scroll_y: f32,
527}
528
529/// UI control the guest requested for the current frame; the host GPUI layer renders these after canvas content.
530///
531/// Commands are queued during `on_frame`; stable `id` values tie widgets to [`WidgetValue`] state and click tracking.
532#[derive(Clone, Debug)]
533pub enum WidgetCommand {
534    /// Clickable button with label; `api_ui_button` returns whether this `id` was clicked this pass.
535    Button {
536        id: u32,
537        x: f32,
538        y: f32,
539        w: f32,
540        h: f32,
541        label: String,
542    },
543    /// Toggle with label; checked state lives in [`WidgetValue::Bool`] for this `id`.
544    Checkbox {
545        id: u32,
546        x: f32,
547        y: f32,
548        label: String,
549    },
550    /// Horizontal slider between `min` and `max`; value stored in [`WidgetValue::Float`].
551    Slider {
552        id: u32,
553        x: f32,
554        y: f32,
555        w: f32,
556        min: f32,
557        max: f32,
558    },
559    /// Single-line text field; current text stored in [`WidgetValue::Text`].
560    TextInput { id: u32, x: f32, y: f32, w: f32 },
561}
562
563/// Persistent control state for interactive widgets, keyed by widget `id` across frames.
564#[derive(Clone, Debug)]
565pub enum WidgetValue {
566    /// Checkbox on/off.
567    Bool(bool),
568    /// Slider current value.
569    Float(f32),
570    /// Text field contents.
571    Text(String),
572}
573
574impl Default for HostState {
575    fn default() -> Self {
576        Self {
577            console: Arc::new(Mutex::new(Vec::new())),
578            canvas: Arc::new(Mutex::new(CanvasState {
579                commands: Vec::new(),
580                width: 800,
581                height: 600,
582                images: Vec::new(),
583                generation: 0,
584            })),
585            storage: Arc::new(Mutex::new(HashMap::new())),
586            timers: Arc::new(Mutex::new(Vec::new())),
587            animation_requests: Arc::new(Mutex::new(Vec::new())),
588            timer_next_id: Arc::new(Mutex::new(1)),
589            clipboard: Arc::new(Mutex::new(String::new())),
590            clipboard_allowed: Arc::new(Mutex::new(false)),
591            kv_db: None,
592            memory: None,
593            module_loader: None,
594            navigation: Arc::new(Mutex::new(NavigationStack::new())),
595            hyperlinks: Arc::new(Mutex::new(Vec::new())),
596            pending_navigation: Arc::new(Mutex::new(None)),
597            current_url: Arc::new(Mutex::new(String::new())),
598            input_state: Arc::new(Mutex::new(InputState::default())),
599            widget_commands: Arc::new(Mutex::new(Vec::new())),
600            widget_states: Arc::new(Mutex::new(HashMap::new())),
601            widget_clicked: Arc::new(Mutex::new(HashSet::new())),
602            canvas_offset: Arc::new(Mutex::new((0.0, 0.0))),
603            bookmark_store: crate::bookmarks::new_shared(),
604            history_store: Arc::new(Mutex::new(None)),
605            audio: Arc::new(Mutex::new(None)),
606            last_audio_url_content_type: Arc::new(Mutex::new(String::new())),
607            video: Arc::new(Mutex::new(VideoPlaybackState::default())),
608            video_pip_frame: Arc::new(Mutex::new(None)),
609            video_pip_serial: Arc::new(Mutex::new(0)),
610            media_capture: Arc::new(Mutex::new(
611                crate::media_capture::MediaCaptureState::default(),
612            )),
613            gpu: Arc::new(Mutex::new(None)),
614            rtc: Arc::new(Mutex::new(None)),
615            ws: Arc::new(Mutex::new(None)),
616            midi: Arc::new(Mutex::new(None)),
617            fetch: Arc::new(Mutex::new(None)),
618            file_picker: Arc::new(Mutex::new(crate::file_picker::FilePickerState::default())),
619            events: Arc::new(Mutex::new(crate::events::EventState::default())),
620            download_manager: DownloadManager::new(),
621            focused: Arc::new(AtomicBool::new(true)),
622            text_system: Arc::new(Mutex::new(None)),
623            content_width: Arc::new(Mutex::new(0)),
624            content_height: Arc::new(Mutex::new(0)),
625            scroll_x: Arc::new(Mutex::new(0.0)),
626            scroll_y: Arc::new(Mutex::new(0.0)),
627        }
628    }
629}
630
631#[allow(clippy::too_many_arguments)]
632fn video_render_at(
633    video: &Arc<Mutex<VideoPlaybackState>>,
634    pip_frame: &Arc<Mutex<Option<DecodedImage>>>,
635    pip_serial: &Arc<Mutex<u64>>,
636    canvas: &Arc<Mutex<CanvasState>>,
637    x: f32,
638    y: f32,
639    w: f32,
640    h: f32,
641) -> Result<(), String> {
642    let t = {
643        let g = video.lock().unwrap();
644        g.current_position_ms()
645    };
646    let mut g = video.lock().unwrap();
647    let player = g
648        .player
649        .as_mut()
650        .ok_or_else(|| "no video loaded".to_string())?;
651    let (pixels, pw, ph) = player.decode_frame_at(t)?;
652    let pip_on = g.pip;
653    let subtitle_text = subtitle::cue_text_at(&g.subtitles, t).map(|s| s.to_string());
654    drop(g);
655
656    let decoded = DecodedImage {
657        width: pw,
658        height: ph,
659        pixels,
660    };
661    if pip_on {
662        *pip_frame.lock().unwrap() = Some(decoded.clone());
663        if let Ok(mut s) = pip_serial.lock() {
664            *s = s.saturating_add(1);
665        }
666    }
667    let mut canvas = canvas.lock().unwrap();
668    let image_id = canvas.images.len();
669    canvas.images.push(decoded);
670    canvas.commands.push(DrawCommand::Image {
671        x,
672        y,
673        w,
674        h,
675        image_id,
676    });
677    if let Some(text) = subtitle_text {
678        let ty = (y + h - 24.0).max(y + 12.0);
679        canvas.commands.push(DrawCommand::Text {
680            x: x + 8.0,
681            y: ty,
682            size: 16.0,
683            r: 255,
684            g: 255,
685            b: 255,
686            a: 255,
687            text,
688        });
689    }
690    Ok(())
691}
692
693pub(crate) fn read_guest_string(
694    memory: &Memory,
695    store: &impl AsContext,
696    ptr: u32,
697    len: u32,
698) -> Result<String> {
699    let start = ptr as usize;
700    let end = start
701        .checked_add(len as usize)
702        .context("guest string pointer arithmetic overflow")?;
703    let data = memory
704        .data(store)
705        .get(start..end)
706        .context("guest string out of bounds")?;
707    String::from_utf8(data.to_vec()).context("guest string is not valid utf-8")
708}
709
710pub(crate) fn read_guest_bytes(
711    memory: &Memory,
712    store: &impl AsContext,
713    ptr: u32,
714    len: u32,
715) -> Result<Vec<u8>> {
716    let start = ptr as usize;
717    let end = start
718        .checked_add(len as usize)
719        .context("guest buffer pointer arithmetic overflow")?;
720    let data = memory
721        .data(store)
722        .get(start..end)
723        .context("guest buffer out of bounds")?;
724    Ok(data.to_vec())
725}
726
727pub(crate) fn write_guest_bytes(
728    memory: &Memory,
729    store: &mut impl AsContextMut,
730    ptr: u32,
731    bytes: &[u8],
732) -> Result<()> {
733    let start = ptr as usize;
734    let end = start
735        .checked_add(bytes.len())
736        .context("guest write pointer arithmetic overflow")?;
737    memory
738        .data_mut(store)
739        .get_mut(start..end)
740        .context("guest buffer out of bounds")?
741        .copy_from_slice(bytes);
742    Ok(())
743}
744
745/// Clamp a guest-supplied font weight to the CSS `100..=900` range. A value of
746/// `0` means "use the default" and maps to `400` (normal).
747pub(crate) fn clamp_weight(weight: u32) -> u16 {
748    if weight == 0 {
749        400
750    } else {
751        weight.clamp(100, 900) as u16
752    }
753}
754
755/// Build a [`gpui::Font`] from the guest-supplied family, weight, and style.
756/// An empty family falls back to `.SystemUIFont`.
757pub(crate) fn make_gpui_font(family: &str, weight: u16, style: u8) -> gpui::Font {
758    let family_name = if family.is_empty() {
759        ".SystemUIFont"
760    } else {
761        family
762    };
763    let mut f = gpui::font(family_name.to_string());
764    f.weight = gpui::FontWeight(weight as f32);
765    f.style = match style {
766        1 => gpui::FontStyle::Italic,
767        2 => gpui::FontStyle::Oblique,
768        _ => gpui::FontStyle::Normal,
769    };
770    f
771}
772
773/// Append a [`ConsoleEntry`] with the current local timestamp to the shared console buffer.
774///
775/// Used by `api_log` / `api_warn` / `api_error` and by other host helpers that surface messages to the UI.
776pub fn console_log(console: &Arc<Mutex<Vec<ConsoleEntry>>>, level: ConsoleLevel, message: String) {
777    console.lock().unwrap().push(ConsoleEntry {
778        timestamp: chrono::Local::now().format("%H:%M:%S%.3f").to_string(),
779        level,
780        message,
781    });
782}
783
784fn audio_try_play(
785    engine: &mut AudioEngine,
786    channel: u32,
787    data: Vec<u8>,
788    format_hint: u32,
789    console: &Arc<Mutex<Vec<ConsoleEntry>>>,
790) -> bool {
791    let sniffed = audio_format::sniff_audio_format(&data);
792    if format_hint != 0
793        && format_hint != audio_format::AUDIO_FORMAT_UNKNOWN
794        && sniffed != audio_format::AUDIO_FORMAT_UNKNOWN
795        && sniffed != format_hint
796    {
797        console_log(
798            console,
799            ConsoleLevel::Warn,
800            format!("[AUDIO] Format hint {format_hint} does not match sniffed container {sniffed}"),
801        );
802    }
803    engine.play_bytes_on(channel, data)
804}
805
806/// Minimal PDF writer — builds a PDF document byte-by-byte with standard
807/// Type 1 fonts (no embedding needed). Supports text, rectangles, lines,
808/// circles, arcs, beziers, and rounded rects.
809fn render_canvas_to_pdf(canvas: &CanvasState, user_filename: &str) -> anyhow::Result<()> {
810    use std::io::{Cursor, Seek, Write};
811
812    const PT_PER_PX: f32 = 0.75;
813    let w_pt = canvas.width as f32 * PT_PER_PX;
814    let h_pt = canvas.height as f32 * PT_PER_PX;
815
816    let mut buf = Cursor::new(Vec::new());
817    let mut offsets: Vec<u64> = Vec::new();
818
819    // Object 1: Catalog
820    offsets.push(buf.stream_position()?);
821    writeln!(buf, "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj")?;
822
823    // Object 2: Pages
824    offsets.push(buf.stream_position()?);
825    writeln!(buf, "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj")?;
826
827    // Object 5: Font (Helvetica)
828    offsets.push(buf.stream_position()?);
829    writeln!(
830        buf,
831        "5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj"
832    )?;
833
834    let mut content = Vec::new();
835    let flip_y = |y: f32| -> f32 { h_pt - y };
836
837    for cmd in &canvas.commands {
838        match cmd {
839            DrawCommand::Clear { r, g, b, a: _ } => {
840                let rf = *r as f32 / 255.0;
841                let gf = *g as f32 / 255.0;
842                let bf = *b as f32 / 255.0;
843                write!(content, "{rf:.3} {gf:.3} {bf:.3} rg ")?;
844                writeln!(content, "0 0 {w_pt:.1} {h_pt:.1} re f")?;
845            }
846            DrawCommand::Rect {
847                x,
848                y,
849                w: rw,
850                h: rh,
851                r,
852                g,
853                b,
854                a: _,
855            } => {
856                let rf = *r as f32 / 255.0;
857                let gf = *g as f32 / 255.0;
858                let bf = *b as f32 / 255.0;
859                let xp = *x * PT_PER_PX;
860                let yp = flip_y((*y + *rh) * PT_PER_PX);
861                let wp = *rw * PT_PER_PX;
862                let hp = *rh * PT_PER_PX;
863                write!(content, "{rf:.3} {gf:.3} {bf:.3} rg ")?;
864                writeln!(content, "{xp:.1} {yp:.1} {wp:.1} {hp:.1} re f")?;
865            }
866            DrawCommand::Text {
867                x,
868                y,
869                size,
870                r,
871                g,
872                b,
873                a: _,
874                text,
875            } => {
876                let rf = *r as f32 / 255.0;
877                let gf = *g as f32 / 255.0;
878                let bf = *b as f32 / 255.0;
879                let font_size = *size * PT_PER_PX;
880                let xp = *x * PT_PER_PX;
881                let yp = flip_y(*y * PT_PER_PX);
882                let escaped = escape_pdf_string(text);
883                write!(content, "BT {rf:.3} {gf:.3} {bf:.3} rg ")?;
884                writeln!(
885                    content,
886                    "/F1 {font_size:.1} Tf {xp:.1} {yp:.1} Td ({escaped}) Tj ET"
887                )?;
888            }
889            DrawCommand::Line {
890                x1,
891                y1,
892                x2,
893                y2,
894                r,
895                g,
896                b,
897                a: _,
898                thickness,
899            } => {
900                let rf = *r as f32 / 255.0;
901                let gf = *g as f32 / 255.0;
902                let bf = *b as f32 / 255.0;
903                let tp = *thickness * PT_PER_PX;
904                let x1p = *x1 * PT_PER_PX;
905                let y1p = flip_y(*y1 * PT_PER_PX);
906                let x2p = *x2 * PT_PER_PX;
907                let y2p = flip_y(*y2 * PT_PER_PX);
908                write!(content, "{rf:.3} {gf:.3} {bf:.3} RG {tp:.1} w ")?;
909                writeln!(content, "{x1p:.1} {y1p:.1} m {x2p:.1} {y2p:.1} l S")?;
910            }
911            DrawCommand::Circle {
912                cx,
913                cy,
914                radius,
915                r,
916                g,
917                b,
918                a: _,
919            } => {
920                let rf = *r as f32 / 255.0;
921                let gf = *g as f32 / 255.0;
922                let bf = *b as f32 / 255.0;
923                let cxp = *cx * PT_PER_PX;
924                let cyp = flip_y(*cy * PT_PER_PX);
925                let rp = *radius * PT_PER_PX;
926                let k = 0.5522848f32;
927                let kr = k * rp;
928                write!(content, "{rf:.3} {gf:.3} {bf:.3} rg ")?;
929                write!(content, "{:.1} {:.1} m ", cxp + rp, cyp)?;
930                write!(
931                    content,
932                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
933                    cxp + rp,
934                    cyp + kr,
935                    cxp + kr,
936                    cyp + rp,
937                    cxp,
938                    cyp + rp
939                )?;
940                write!(
941                    content,
942                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
943                    cxp - kr,
944                    cyp + rp,
945                    cxp - rp,
946                    cyp + kr,
947                    cxp - rp,
948                    cyp
949                )?;
950                write!(
951                    content,
952                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
953                    cxp - rp,
954                    cyp - kr,
955                    cxp - kr,
956                    cyp - rp,
957                    cxp,
958                    cyp - rp
959                )?;
960                writeln!(
961                    content,
962                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c f",
963                    cxp + kr,
964                    cyp - rp,
965                    cxp + rp,
966                    cyp - kr,
967                    cxp + rp,
968                    cyp
969                )?;
970            }
971            DrawCommand::RoundedRect {
972                x,
973                y,
974                w: rw,
975                h: rh,
976                radius,
977                r,
978                g,
979                b,
980                a: _,
981            } => {
982                let rf = *r as f32 / 255.0;
983                let gf = *g as f32 / 255.0;
984                let bf = *b as f32 / 255.0;
985                let xp = *x * PT_PER_PX;
986                let yp = flip_y(*y * PT_PER_PX);
987                let wp = *rw * PT_PER_PX;
988                let hp = *rh * PT_PER_PX;
989                let rad = (*radius * PT_PER_PX).min(wp / 2.0).min(hp / 2.0);
990                let k = 0.5522848f32;
991                let kr = k * rad;
992                let x0 = xp;
993                let y0 = yp - hp;
994                let x1 = xp + wp;
995                let y1 = yp;
996                write!(content, "{rf:.3} {gf:.3} {bf:.3} rg ")?;
997                write!(content, "{:.1} {:.1} m ", x0 + rad, y0)?;
998                write!(content, "{:.1} {:.1} l ", x1 - rad, y0)?;
999                write!(
1000                    content,
1001                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
1002                    x1 - rad + kr,
1003                    y0,
1004                    x1,
1005                    y0 + rad - kr,
1006                    x1,
1007                    y0 + rad
1008                )?;
1009                write!(content, "{:.1} {:.1} l ", x1, y1 - rad)?;
1010                write!(
1011                    content,
1012                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
1013                    x1,
1014                    y1 - rad + kr,
1015                    x1 - rad + kr,
1016                    y1,
1017                    x1 - rad,
1018                    y1
1019                )?;
1020                write!(content, "{:.1} {:.1} l ", x0 + rad, y1)?;
1021                write!(
1022                    content,
1023                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c ",
1024                    x0 + rad - kr,
1025                    y1,
1026                    x0,
1027                    y1 - rad + kr,
1028                    x0,
1029                    y1 - rad
1030                )?;
1031                write!(content, "{:.1} {:.1} l ", x0, y0 + rad)?;
1032                writeln!(
1033                    content,
1034                    "{:.1} {:.1} {:.1} {:.1} {:.1} {:.1} c f",
1035                    x0,
1036                    y0 + rad - kr,
1037                    x0 + rad - kr,
1038                    y0,
1039                    x0 + rad,
1040                    y0
1041                )?;
1042            }
1043            DrawCommand::Arc {
1044                cx,
1045                cy,
1046                radius,
1047                start_angle,
1048                end_angle,
1049                r,
1050                g,
1051                b,
1052                a: _,
1053                thickness,
1054            } => {
1055                let rf = *r as f32 / 255.0;
1056                let gf = *g as f32 / 255.0;
1057                let bf = *b as f32 / 255.0;
1058                let tp = *thickness * PT_PER_PX;
1059                let cxp = *cx * PT_PER_PX;
1060                let cyp = flip_y(*cy * PT_PER_PX);
1061                let rp = *radius * PT_PER_PX;
1062                let segs = 32u32;
1063                let angle_range = if *end_angle > *start_angle {
1064                    *end_angle - *start_angle
1065                } else {
1066                    *end_angle + 2.0 * std::f32::consts::PI - *start_angle
1067                };
1068                write!(content, "{rf:.3} {gf:.3} {bf:.3} RG {tp:.1} w ")?;
1069                let a0 = *start_angle;
1070                let mut first = true;
1071                for i in 0..=segs {
1072                    let a = a0 + angle_range * i as f32 / segs as f32;
1073                    let px = cxp + rp * a.cos();
1074                    let py = cyp - rp * a.sin();
1075                    if first {
1076                        write!(content, "{px:.1} {py:.1} m ")?;
1077                        first = false;
1078                    } else {
1079                        write!(content, "{px:.1} {py:.1} l ")?;
1080                    }
1081                }
1082                writeln!(content, "S")?;
1083            }
1084            DrawCommand::Bezier {
1085                x1,
1086                y1,
1087                cp1x,
1088                cp1y,
1089                cp2x,
1090                cp2y,
1091                x2,
1092                y2,
1093                r,
1094                g,
1095                b,
1096                a: _,
1097                thickness,
1098            } => {
1099                let rf = *r as f32 / 255.0;
1100                let gf = *g as f32 / 255.0;
1101                let bf = *b as f32 / 255.0;
1102                let tp = *thickness * PT_PER_PX;
1103                let x1p = *x1 * PT_PER_PX;
1104                let y1p = flip_y(*y1 * PT_PER_PX);
1105                let cp1xp = *cp1x * PT_PER_PX;
1106                let cp1yp = flip_y(*cp1y * PT_PER_PX);
1107                let cp2xp = *cp2x * PT_PER_PX;
1108                let cp2yp = flip_y(*cp2y * PT_PER_PX);
1109                let x2p = *x2 * PT_PER_PX;
1110                let y2p = flip_y(*y2 * PT_PER_PX);
1111                write!(content, "{rf:.3} {gf:.3} {bf:.3} RG {tp:.1} w ")?;
1112                writeln!(content, "{x1p:.1} {y1p:.1} m {cp1xp:.1} {cp1yp:.1} {cp2xp:.1} {cp2yp:.1} {x2p:.1} {y2p:.1} c S")?;
1113            }
1114            DrawCommand::Image { .. }
1115            | DrawCommand::Gradient { .. }
1116            | DrawCommand::TextEx { .. }
1117            | DrawCommand::Save
1118            | DrawCommand::Restore
1119            | DrawCommand::Transform { .. }
1120            | DrawCommand::Clip { .. }
1121            | DrawCommand::Opacity { .. } => {}
1122        }
1123    }
1124
1125    // Object 4: Page content stream
1126    let content_len = content.len();
1127    offsets.push(buf.stream_position()?);
1128    writeln!(buf, "4 0 obj<</Length {content_len}>>stream")?;
1129    buf.write_all(&content)?;
1130    writeln!(buf)?;
1131    writeln!(buf, "endstream")?;
1132    writeln!(buf, "endobj")?;
1133
1134    // Object 3: Page
1135    offsets.push(buf.stream_position()?);
1136    writeln!(
1137        buf,
1138        "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 {w_pt:.1} {h_pt:.1}]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj"
1139    )?;
1140
1141    // xref table
1142    let xref_offset = buf.stream_position()?;
1143    let obj_count = offsets.len() as u64 + 1;
1144    writeln!(buf, "xref\n0 {obj_count}\n0000000000 65535 f ")?;
1145    for off in &offsets {
1146        writeln!(buf, "{off:010} 00000 n ")?;
1147    }
1148
1149    write!(
1150        buf,
1151        "trailer<</Size {obj_count}/Root 1 0 R>>\nstartxref\n{xref_offset}\n%%EOF\n"
1152    )?;
1153
1154    let pdf_bytes = buf.into_inner();
1155    let dest_dir = dirs::download_dir()
1156        .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")));
1157    let name_to_save = if user_filename.trim().is_empty() {
1158        format!(
1159            "{}",
1160            chrono::Local::now().format("oxide-canvas-%Y%m%d-%H%M%S.pdf")
1161        )
1162    } else if user_filename.to_lowercase().ends_with(".pdf") {
1163        user_filename.to_string()
1164    } else {
1165        format!("{}.pdf", user_filename)
1166    };
1167    let dest = crate::download::unique_path(&dest_dir, &name_to_save);
1168    std::fs::write(&dest, &pdf_bytes)?;
1169    Ok(())
1170}
1171
1172fn escape_pdf_string(s: &str) -> String {
1173    let mut out = String::with_capacity(s.len());
1174    for ch in s.chars() {
1175        match ch {
1176            '(' => out.push_str("\\("),
1177            ')' => out.push_str("\\)"),
1178            '\\' => out.push_str("\\\\"),
1179            '\n' => out.push_str("\\n"),
1180            '\r' => out.push_str("\\r"),
1181            '\t' => out.push_str("\\t"),
1182            c if (c as u32) < 0x80 => out.push(c),
1183            _ => {} // Skip non-ASCII — standard PDF fonts only support Latin-1
1184        }
1185    }
1186    out
1187}
1188
1189/// Register every `oxide` import on `linker` so guest modules can link against them.
1190///
1191/// This wires dozens of functions (console, canvas, storage, clipboard, timers, HTTP,
1192/// dynamic module loading, crypto helpers, navigation, hyperlinks, input, audio, UI
1193/// widgets, etc.) under the Wasm import module name **`oxide`**. Each closure captures
1194/// [`Caller`] to read [`HostState`] from the store: guest pointers are resolved through
1195/// [`HostState::memory`], and shared handles (`Arc<Mutex<…>>`) are updated in place.
1196///
1197/// Call this once when building the linker for a main or child instance; the dynamic loader
1198/// path also invokes it when instantiating a child module (see the `api_load_module` import).
1199pub fn register_host_functions(linker: &mut Linker<HostState>) -> Result<()> {
1200    // ── Console ──────────────────────────────────────────────────────
1201
1202    linker.func_wrap(
1203        "oxide",
1204        "api_log",
1205        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
1206            let mem = caller.data().memory.expect("memory not set");
1207            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
1208            console_log(&caller.data().console, ConsoleLevel::Log, msg);
1209        },
1210    )?;
1211
1212    linker.func_wrap(
1213        "oxide",
1214        "api_warn",
1215        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
1216            let mem = caller.data().memory.expect("memory not set");
1217            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
1218            console_log(&caller.data().console, ConsoleLevel::Warn, msg);
1219        },
1220    )?;
1221
1222    linker.func_wrap(
1223        "oxide",
1224        "api_error",
1225        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
1226            let mem = caller.data().memory.expect("memory not set");
1227            let msg = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
1228            console_log(&caller.data().console, ConsoleLevel::Error, msg);
1229        },
1230    )?;
1231
1232    // ── Geolocation ──────────────────────────────────────────────────
1233
1234    linker.func_wrap(
1235        "oxide",
1236        "api_get_location",
1237        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
1238            let location = "37.7749,-122.4194"; // mock: San Francisco
1239            let bytes = location.as_bytes();
1240            let write_len = bytes.len().min(out_cap as usize);
1241            let mem = caller.data().memory.expect("memory not set");
1242            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1243            write_len as u32
1244        },
1245    )?;
1246
1247    // ── File Picker ──────────────────────────────────────────────────
1248
1249    linker.func_wrap(
1250        "oxide",
1251        "api_upload_file",
1252        |mut caller: Caller<'_, HostState>,
1253         name_ptr: u32,
1254         name_cap: u32,
1255         data_ptr: u32,
1256         data_cap: u32|
1257         -> u64 {
1258            let dialog = rfd::FileDialog::new()
1259                .set_title("Oxide: Select a file to upload")
1260                .pick_file();
1261
1262            match dialog {
1263                Some(path) => {
1264                    let file_name = path
1265                        .file_name()
1266                        .map(|n| n.to_string_lossy().to_string())
1267                        .unwrap_or_default();
1268                    let file_data = std::fs::read(&path).unwrap_or_default();
1269
1270                    let mem = caller.data().memory.expect("memory not set");
1271
1272                    let name_bytes = file_name.as_bytes();
1273                    let name_written = name_bytes.len().min(name_cap as usize);
1274                    write_guest_bytes(&mem, &mut caller, name_ptr, &name_bytes[..name_written])
1275                        .ok();
1276
1277                    let data_written = file_data.len().min(data_cap as usize);
1278                    write_guest_bytes(&mem, &mut caller, data_ptr, &file_data[..data_written]).ok();
1279
1280                    ((name_written as u64) << 32) | (data_written as u64)
1281                }
1282                None => 0,
1283            }
1284        },
1285    )?;
1286
1287    // ── Canvas Drawing ───────────────────────────────────────────────
1288
1289    linker.func_wrap(
1290        "oxide",
1291        "api_canvas_clear",
1292        |caller: Caller<'_, HostState>, r: u32, g: u32, b: u32, a: u32| {
1293            let mut canvas = caller.data().canvas.lock().unwrap();
1294            canvas.commands.clear();
1295            canvas.images.clear();
1296            canvas.generation += 1;
1297            canvas.commands.push(DrawCommand::Clear {
1298                r: r as u8,
1299                g: g as u8,
1300                b: b as u8,
1301                a: a as u8,
1302            });
1303        },
1304    )?;
1305
1306    linker.func_wrap(
1307        "oxide",
1308        "api_canvas_rect",
1309        |caller: Caller<'_, HostState>,
1310         x: f32,
1311         y: f32,
1312         w: f32,
1313         h: f32,
1314         r: u32,
1315         g: u32,
1316         b: u32,
1317         a: u32| {
1318            caller
1319                .data()
1320                .canvas
1321                .lock()
1322                .unwrap()
1323                .commands
1324                .push(DrawCommand::Rect {
1325                    x,
1326                    y,
1327                    w,
1328                    h,
1329                    r: r as u8,
1330                    g: g as u8,
1331                    b: b as u8,
1332                    a: a as u8,
1333                });
1334        },
1335    )?;
1336
1337    linker.func_wrap(
1338        "oxide",
1339        "api_canvas_circle",
1340        |caller: Caller<'_, HostState>,
1341         cx: f32,
1342         cy: f32,
1343         radius: f32,
1344         r: u32,
1345         g: u32,
1346         b: u32,
1347         a: u32| {
1348            caller
1349                .data()
1350                .canvas
1351                .lock()
1352                .unwrap()
1353                .commands
1354                .push(DrawCommand::Circle {
1355                    cx,
1356                    cy,
1357                    radius,
1358                    r: r as u8,
1359                    g: g as u8,
1360                    b: b as u8,
1361                    a: a as u8,
1362                });
1363        },
1364    )?;
1365
1366    linker.func_wrap(
1367        "oxide",
1368        "api_canvas_text",
1369        |caller: Caller<'_, HostState>,
1370         x: f32,
1371         y: f32,
1372         size: f32,
1373         r: u32,
1374         g: u32,
1375         b: u32,
1376         a: u32,
1377         txt_ptr: u32,
1378         txt_len: u32| {
1379            let mem = caller.data().memory.expect("memory not set");
1380            let text = read_guest_string(&mem, &caller, txt_ptr, txt_len).unwrap_or_default();
1381            caller
1382                .data()
1383                .canvas
1384                .lock()
1385                .unwrap()
1386                .commands
1387                .push(DrawCommand::Text {
1388                    x,
1389                    y,
1390                    size,
1391                    r: r as u8,
1392                    g: g as u8,
1393                    b: b as u8,
1394                    a: a as u8,
1395                    text,
1396                });
1397        },
1398    )?;
1399
1400    linker.func_wrap(
1401        "oxide",
1402        "api_canvas_text_ex",
1403        |caller: Caller<'_, HostState>,
1404         x: f32,
1405         y: f32,
1406         size: f32,
1407         r: u32,
1408         g: u32,
1409         b: u32,
1410         a: u32,
1411         fam_ptr: u32,
1412         fam_len: u32,
1413         weight: u32,
1414         style: u32,
1415         align: u32,
1416         txt_ptr: u32,
1417         txt_len: u32| {
1418            let mem = caller.data().memory.expect("memory not set");
1419            let family = read_guest_string(&mem, &caller, fam_ptr, fam_len).unwrap_or_default();
1420            let text = read_guest_string(&mem, &caller, txt_ptr, txt_len).unwrap_or_default();
1421            let weight = clamp_weight(weight);
1422            let style = (style.min(2)) as u8;
1423            let align = (align.min(2)) as u8;
1424            caller
1425                .data()
1426                .canvas
1427                .lock()
1428                .unwrap()
1429                .commands
1430                .push(DrawCommand::TextEx {
1431                    x,
1432                    y,
1433                    size,
1434                    r: r as u8,
1435                    g: g as u8,
1436                    b: b as u8,
1437                    a: a as u8,
1438                    family,
1439                    weight,
1440                    style,
1441                    align,
1442                    text,
1443                });
1444        },
1445    )?;
1446
1447    // Synchronous text measurement. Writes 3 × f32 (width, ascent, descent) in
1448    // pixels to `out_ptr` and returns 1 on success; returns 0 if the text
1449    // system isn't available yet or the out buffer is invalid.
1450    linker.func_wrap(
1451        "oxide",
1452        "api_canvas_measure_text",
1453        |mut caller: Caller<'_, HostState>,
1454         size: f32,
1455         fam_ptr: u32,
1456         fam_len: u32,
1457         weight: u32,
1458         style: u32,
1459         txt_ptr: u32,
1460         txt_len: u32,
1461         out_ptr: u32|
1462         -> u32 {
1463            let mem = caller.data().memory.expect("memory not set");
1464            let family = read_guest_string(&mem, &caller, fam_ptr, fam_len).unwrap_or_default();
1465            let text = read_guest_string(&mem, &caller, txt_ptr, txt_len).unwrap_or_default();
1466            let weight = clamp_weight(weight);
1467            let style = (style.min(2)) as u8;
1468            let ts = caller.data().text_system.lock().unwrap().clone();
1469            let Some(ts) = ts else { return 0 };
1470            let font = make_gpui_font(&family, weight, style);
1471            let run = gpui::TextRun {
1472                len: text.len(),
1473                font,
1474                color: gpui::rgba(0xffffffff).into(),
1475                background_color: None,
1476                underline: None,
1477                strikethrough: None,
1478            };
1479            let layout = ts.layout_line(&text, gpui::px(size), &[run], None);
1480            let mut buf = [0u8; 12];
1481            buf[0..4].copy_from_slice(&f32::from(layout.width).to_le_bytes());
1482            buf[4..8].copy_from_slice(&f32::from(layout.ascent).to_le_bytes());
1483            buf[8..12].copy_from_slice(&f32::from(layout.descent).to_le_bytes());
1484            if write_guest_bytes(&mem, &mut caller, out_ptr, &buf).is_ok() {
1485                1
1486            } else {
1487                0
1488            }
1489        },
1490    )?;
1491
1492    linker.func_wrap(
1493        "oxide",
1494        "api_canvas_line",
1495        |caller: Caller<'_, HostState>,
1496         x1: f32,
1497         y1: f32,
1498         x2: f32,
1499         y2: f32,
1500         r: u32,
1501         g: u32,
1502         b: u32,
1503         a: u32,
1504         thickness: f32| {
1505            caller
1506                .data()
1507                .canvas
1508                .lock()
1509                .unwrap()
1510                .commands
1511                .push(DrawCommand::Line {
1512                    x1,
1513                    y1,
1514                    x2,
1515                    y2,
1516                    r: r as u8,
1517                    g: g as u8,
1518                    b: b as u8,
1519                    a: a as u8,
1520                    thickness,
1521                });
1522        },
1523    )?;
1524
1525    linker.func_wrap(
1526        "oxide",
1527        "api_canvas_dimensions",
1528        |caller: Caller<'_, HostState>| -> u64 {
1529            let canvas = caller.data().canvas.lock().unwrap();
1530            ((canvas.width as u64) << 32) | (canvas.height as u64)
1531        },
1532    )?;
1533
1534    linker.func_wrap(
1535        "oxide",
1536        "api_set_content_size",
1537        |caller: Caller<'_, HostState>, w: u32, h: u32| {
1538            *caller.data().content_width.lock().unwrap() = w;
1539            *caller.data().content_height.lock().unwrap() = h;
1540        },
1541    )?;
1542
1543    linker.func_wrap(
1544        "oxide",
1545        "api_get_scroll_position",
1546        |caller: Caller<'_, HostState>| -> u64 {
1547            let x = *caller.data().scroll_x.lock().unwrap();
1548            let y = *caller.data().scroll_y.lock().unwrap();
1549            ((x.to_bits() as u64) << 32) | (y.to_bits() as u64)
1550        },
1551    )?;
1552
1553    linker.func_wrap(
1554        "oxide",
1555        "api_set_scroll_position",
1556        |caller: Caller<'_, HostState>, x: f32, y: f32| {
1557            let content_w = *caller.data().content_width.lock().unwrap();
1558            let content_h = *caller.data().content_height.lock().unwrap();
1559
1560            let viewport_w = caller.data().canvas.lock().unwrap().width;
1561            let viewport_h = caller.data().canvas.lock().unwrap().height;
1562
1563            let max_x = (content_w as f32 - viewport_w as f32).max(0.0);
1564            let max_y = (content_h as f32 - viewport_h as f32).max(0.0);
1565
1566            *caller.data().scroll_x.lock().unwrap() = x.clamp(0.0, max_x);
1567            *caller.data().scroll_y.lock().unwrap() = y.clamp(0.0, max_y);
1568        },
1569    )?;
1570
1571    // ── Extended Shape Primitives ─────────────────────────────────────
1572
1573    linker.func_wrap(
1574        "oxide",
1575        "api_canvas_rounded_rect",
1576        |caller: Caller<'_, HostState>,
1577         x: f32,
1578         y: f32,
1579         w: f32,
1580         h: f32,
1581         radius: f32,
1582         r: u32,
1583         g: u32,
1584         b: u32,
1585         a: u32| {
1586            caller
1587                .data()
1588                .canvas
1589                .lock()
1590                .unwrap()
1591                .commands
1592                .push(DrawCommand::RoundedRect {
1593                    x,
1594                    y,
1595                    w,
1596                    h,
1597                    radius,
1598                    r: r as u8,
1599                    g: g as u8,
1600                    b: b as u8,
1601                    a: a as u8,
1602                });
1603        },
1604    )?;
1605
1606    linker.func_wrap(
1607        "oxide",
1608        "api_canvas_arc",
1609        |caller: Caller<'_, HostState>,
1610         cx: f32,
1611         cy: f32,
1612         radius: f32,
1613         start_angle: f32,
1614         end_angle: f32,
1615         r: u32,
1616         g: u32,
1617         b: u32,
1618         a: u32,
1619         thickness: f32| {
1620            caller
1621                .data()
1622                .canvas
1623                .lock()
1624                .unwrap()
1625                .commands
1626                .push(DrawCommand::Arc {
1627                    cx,
1628                    cy,
1629                    radius,
1630                    start_angle,
1631                    end_angle,
1632                    r: r as u8,
1633                    g: g as u8,
1634                    b: b as u8,
1635                    a: a as u8,
1636                    thickness,
1637                });
1638        },
1639    )?;
1640
1641    linker.func_wrap(
1642        "oxide",
1643        "api_canvas_bezier",
1644        |caller: Caller<'_, HostState>,
1645         x1: f32,
1646         y1: f32,
1647         cp1x: f32,
1648         cp1y: f32,
1649         cp2x: f32,
1650         cp2y: f32,
1651         x2: f32,
1652         y2: f32,
1653         r: u32,
1654         g: u32,
1655         b: u32,
1656         a: u32,
1657         thickness: f32| {
1658            caller
1659                .data()
1660                .canvas
1661                .lock()
1662                .unwrap()
1663                .commands
1664                .push(DrawCommand::Bezier {
1665                    x1,
1666                    y1,
1667                    cp1x,
1668                    cp1y,
1669                    cp2x,
1670                    cp2y,
1671                    x2,
1672                    y2,
1673                    r: r as u8,
1674                    g: g as u8,
1675                    b: b as u8,
1676                    a: a as u8,
1677                    thickness,
1678                });
1679        },
1680    )?;
1681
1682    linker.func_wrap(
1683        "oxide",
1684        "api_canvas_gradient",
1685        |caller: Caller<'_, HostState>,
1686         x: f32,
1687         y: f32,
1688         w: f32,
1689         h: f32,
1690         kind: u32,
1691         ax: f32,
1692         ay: f32,
1693         bx: f32,
1694         by: f32,
1695         stops_ptr: u32,
1696         stops_len: u32| {
1697            let mem = caller.data().memory.expect("memory not set");
1698            let bytes = read_guest_bytes(&mem, &caller, stops_ptr, stops_len).unwrap_or_default();
1699            let mut stops = Vec::new();
1700            // Each stop is 8 bytes: f32 offset + u8 r + u8 g + u8 b + u8 a (packed).
1701            let mut i = 0;
1702            while i + 8 <= bytes.len() {
1703                let offset =
1704                    f32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
1705                let sr = bytes[i + 4];
1706                let sg = bytes[i + 5];
1707                let sb = bytes[i + 6];
1708                let sa = bytes[i + 7];
1709                stops.push(GradientStop {
1710                    offset,
1711                    r: sr,
1712                    g: sg,
1713                    b: sb,
1714                    a: sa,
1715                });
1716                i += 8;
1717            }
1718            caller
1719                .data()
1720                .canvas
1721                .lock()
1722                .unwrap()
1723                .commands
1724                .push(DrawCommand::Gradient {
1725                    x,
1726                    y,
1727                    w,
1728                    h,
1729                    kind: kind as u8,
1730                    ax,
1731                    ay,
1732                    bx,
1733                    by,
1734                    stops,
1735                });
1736        },
1737    )?;
1738
1739    // ── Canvas State (transform / clip / opacity) ──────────────────────
1740
1741    linker.func_wrap(
1742        "oxide",
1743        "api_canvas_save",
1744        |caller: Caller<'_, HostState>| {
1745            caller
1746                .data()
1747                .canvas
1748                .lock()
1749                .unwrap()
1750                .commands
1751                .push(DrawCommand::Save);
1752        },
1753    )?;
1754
1755    linker.func_wrap(
1756        "oxide",
1757        "api_canvas_restore",
1758        |caller: Caller<'_, HostState>| {
1759            caller
1760                .data()
1761                .canvas
1762                .lock()
1763                .unwrap()
1764                .commands
1765                .push(DrawCommand::Restore);
1766        },
1767    )?;
1768
1769    linker.func_wrap(
1770        "oxide",
1771        "api_canvas_transform",
1772        |caller: Caller<'_, HostState>, a: f32, b: f32, c: f32, d: f32, tx: f32, ty: f32| {
1773            caller
1774                .data()
1775                .canvas
1776                .lock()
1777                .unwrap()
1778                .commands
1779                .push(DrawCommand::Transform { a, b, c, d, tx, ty });
1780        },
1781    )?;
1782
1783    linker.func_wrap(
1784        "oxide",
1785        "api_canvas_clip",
1786        |caller: Caller<'_, HostState>, x: f32, y: f32, w: f32, h: f32| {
1787            caller
1788                .data()
1789                .canvas
1790                .lock()
1791                .unwrap()
1792                .commands
1793                .push(DrawCommand::Clip { x, y, w, h });
1794        },
1795    )?;
1796
1797    linker.func_wrap(
1798        "oxide",
1799        "api_canvas_opacity",
1800        |caller: Caller<'_, HostState>, alpha: f32| {
1801            caller
1802                .data()
1803                .canvas
1804                .lock()
1805                .unwrap()
1806                .commands
1807                .push(DrawCommand::Opacity { alpha });
1808        },
1809    )?;
1810
1811    // ── Canvas Image ─────────────────────────────────────────────────
1812
1813    linker.func_wrap(
1814        "oxide",
1815        "api_canvas_image",
1816        |caller: Caller<'_, HostState>,
1817         x: f32,
1818         y: f32,
1819         w: f32,
1820         h: f32,
1821         data_ptr: u32,
1822         data_len: u32| {
1823            let mem = caller.data().memory.expect("memory not set");
1824            let raw = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
1825            match image::load_from_memory(&raw) {
1826                Ok(img) => {
1827                    let (iw, ih) = img.dimensions();
1828                    const MAX_IMAGE_PIXELS: u32 = 4096 * 4096; // ~16M pixels
1829                    if iw.saturating_mul(ih) > MAX_IMAGE_PIXELS {
1830                        console_log(
1831                            &caller.data().console,
1832                            ConsoleLevel::Error,
1833                            format!(
1834                                "[IMAGE] Rejected: {iw}x{ih} exceeds maximum of {MAX_IMAGE_PIXELS} pixels"
1835                            ),
1836                        );
1837                        return;
1838                    }
1839                    let rgba = img.to_rgba8();
1840                    let (iw, ih) = (rgba.width(), rgba.height());
1841                    let decoded = DecodedImage {
1842                        width: iw,
1843                        height: ih,
1844                        pixels: rgba.into_raw(),
1845                    };
1846                    let mut canvas = caller.data().canvas.lock().unwrap();
1847                    let image_id = canvas.images.len();
1848                    canvas.images.push(decoded);
1849                    canvas.commands.push(DrawCommand::Image {
1850                        x,
1851                        y,
1852                        w,
1853                        h,
1854                        image_id,
1855                    });
1856                }
1857                Err(e) => {
1858                    console_log(
1859                        &caller.data().console,
1860                        ConsoleLevel::Error,
1861                        format!("[IMAGE] Failed to decode: {e}"),
1862                    );
1863                }
1864            }
1865        },
1866    )?;
1867
1868    // ── Local Storage ────────────────────────────────────────────────
1869
1870    linker.func_wrap(
1871        "oxide",
1872        "api_storage_set",
1873        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32, val_ptr: u32, val_len: u32| {
1874            let mem = caller.data().memory.expect("memory not set");
1875            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1876            let val = read_guest_string(&mem, &caller, val_ptr, val_len).unwrap_or_default();
1877            caller.data().storage.lock().unwrap().insert(key, val);
1878        },
1879    )?;
1880
1881    linker.func_wrap(
1882        "oxide",
1883        "api_storage_get",
1884        |mut caller: Caller<'_, HostState>,
1885         key_ptr: u32,
1886         key_len: u32,
1887         out_ptr: u32,
1888         out_cap: u32|
1889         -> u32 {
1890            let mem = caller.data().memory.expect("memory not set");
1891            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1892            let val = caller
1893                .data()
1894                .storage
1895                .lock()
1896                .unwrap()
1897                .get(&key)
1898                .cloned()
1899                .unwrap_or_default();
1900            let bytes = val.as_bytes();
1901            let write_len = bytes.len().min(out_cap as usize);
1902            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1903            write_len as u32
1904        },
1905    )?;
1906
1907    linker.func_wrap(
1908        "oxide",
1909        "api_storage_remove",
1910        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32| {
1911            let mem = caller.data().memory.expect("memory not set");
1912            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
1913            caller.data().storage.lock().unwrap().remove(&key);
1914        },
1915    )?;
1916
1917    // ── Clipboard ────────────────────────────────────────────────────
1918
1919    linker.func_wrap(
1920        "oxide",
1921        "api_clipboard_write",
1922        |caller: Caller<'_, HostState>, ptr: u32, len: u32| {
1923            let allowed = *caller.data().clipboard_allowed.lock().unwrap();
1924            if !allowed {
1925                console_log(
1926                    &caller.data().console,
1927                    ConsoleLevel::Warn,
1928                    "[CLIPBOARD] Write blocked — clipboard access not permitted".into(),
1929                );
1930                return;
1931            }
1932            let mem = caller.data().memory.expect("memory not set");
1933            let text = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
1934            *caller.data().clipboard.lock().unwrap() = text.clone();
1935            if let Ok(mut ctx) = arboard::Clipboard::new() {
1936                let _ = ctx.set_text(text);
1937            }
1938        },
1939    )?;
1940
1941    linker.func_wrap(
1942        "oxide",
1943        "api_clipboard_read",
1944        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
1945            let allowed = *caller.data().clipboard_allowed.lock().unwrap();
1946            if !allowed {
1947                console_log(
1948                    &caller.data().console,
1949                    ConsoleLevel::Warn,
1950                    "[CLIPBOARD] Read blocked — clipboard access not permitted".into(),
1951                );
1952                return 0;
1953            }
1954            let text = arboard::Clipboard::new()
1955                .and_then(|mut ctx| ctx.get_text())
1956                .unwrap_or_default();
1957            let bytes = text.as_bytes();
1958            let write_len = bytes.len().min(out_cap as usize);
1959            let mem = caller.data().memory.expect("memory not set");
1960            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
1961            write_len as u32
1962        },
1963    )?;
1964
1965    // ── Timers (simplified: returns epoch millis) ────────────────────
1966
1967    linker.func_wrap(
1968        "oxide",
1969        "api_time_now_ms",
1970        |_caller: Caller<'_, HostState>| -> u64 {
1971            std::time::SystemTime::now()
1972                .duration_since(std::time::UNIX_EPOCH)
1973                .unwrap_or_default()
1974                .as_millis() as u64
1975        },
1976    )?;
1977
1978    // ── Timers ────────────────────────────────────────────────────────
1979    // Timers fire via the guest-exported `on_timer(callback_id)` function,
1980    // which the host calls from the frame loop for each expired timer.
1981
1982    linker.func_wrap(
1983        "oxide",
1984        "api_set_timeout",
1985        |caller: Caller<'_, HostState>, callback_id: u32, delay_ms: u32| -> u32 {
1986            let mut next = caller.data().timer_next_id.lock().unwrap();
1987            let id = *next;
1988            *next = next.wrapping_add(1).max(1);
1989            drop(next);
1990
1991            let entry = TimerEntry {
1992                id,
1993                fire_at: Instant::now() + Duration::from_millis(delay_ms as u64),
1994                interval: None,
1995                callback_id,
1996            };
1997            caller.data().timers.lock().unwrap().push(entry);
1998            id
1999        },
2000    )?;
2001
2002    linker.func_wrap(
2003        "oxide",
2004        "api_set_interval",
2005        |caller: Caller<'_, HostState>, callback_id: u32, interval_ms: u32| -> u32 {
2006            let mut next = caller.data().timer_next_id.lock().unwrap();
2007            let id = *next;
2008            *next = next.wrapping_add(1).max(1);
2009            drop(next);
2010
2011            let interval = Duration::from_millis(interval_ms as u64);
2012            let entry = TimerEntry {
2013                id,
2014                fire_at: Instant::now() + interval,
2015                interval: Some(interval),
2016                callback_id,
2017            };
2018            caller.data().timers.lock().unwrap().push(entry);
2019            id
2020        },
2021    )?;
2022
2023    linker.func_wrap(
2024        "oxide",
2025        "api_clear_timer",
2026        |caller: Caller<'_, HostState>, timer_id: u32| {
2027            caller
2028                .data()
2029                .timers
2030                .lock()
2031                .unwrap()
2032                .retain(|t| t.id != timer_id);
2033        },
2034    )?;
2035
2036    // ── Animation Frames ──────────────────────────────────────────────
2037    // One-shot per request (call again from inside `on_timer` to continue). Drained
2038    // every frame in `LiveModule::tick` before regular timers/`on_frame`. Reuses
2039    // `timer_next_id` counter and `on_timer` callback mechanism.
2040
2041    linker.func_wrap(
2042        "oxide",
2043        "api_request_animation_frame",
2044        |caller: Caller<'_, HostState>, callback_id: u32| -> u32 {
2045            let mut next = caller.data().timer_next_id.lock().unwrap();
2046            let id = *next;
2047            *next = next.wrapping_add(1).max(1);
2048            drop(next);
2049
2050            let req = AnimationRequest { id, callback_id };
2051            caller.data().animation_requests.lock().unwrap().push(req);
2052            id
2053        },
2054    )?;
2055
2056    linker.func_wrap(
2057        "oxide",
2058        "api_cancel_animation_frame",
2059        |caller: Caller<'_, HostState>, request_id: u32| {
2060            caller
2061                .data()
2062                .animation_requests
2063                .lock()
2064                .unwrap()
2065                .retain(|r| r.id != request_id);
2066        },
2067    )?;
2068
2069    // ── Random ───────────────────────────────────────────────────────
2070
2071    linker.func_wrap(
2072        "oxide",
2073        "api_random",
2074        |_caller: Caller<'_, HostState>| -> u64 {
2075            let mut buf = [0u8; 8];
2076            getrandom(&mut buf);
2077            u64::from_le_bytes(buf)
2078        },
2079    )?;
2080
2081    // ── Notification (writes to console as a "notification") ─────────
2082
2083    linker.func_wrap(
2084        "oxide",
2085        "api_notify",
2086        |caller: Caller<'_, HostState>,
2087         title_ptr: u32,
2088         title_len: u32,
2089         body_ptr: u32,
2090         body_len: u32| {
2091            let mem = caller.data().memory.expect("memory not set");
2092            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
2093            let body = read_guest_string(&mem, &caller, body_ptr, body_len).unwrap_or_default();
2094            console_log(
2095                &caller.data().console,
2096                ConsoleLevel::Log,
2097                format!("[NOTIFICATION] {title}: {body}"),
2098            );
2099        },
2100    )?;
2101
2102    // ── HTTP Fetch ───────────────────────────────────────────────────
2103    // Synchronous HTTP client exposed to guest wasm. The actual network
2104    // call runs on a dedicated OS thread to avoid blocking the tokio
2105    // runtime that the browser host lives on.
2106
2107    linker.func_wrap(
2108        "oxide",
2109        "api_fetch",
2110        |mut caller: Caller<'_, HostState>,
2111         method_ptr: u32,
2112         method_len: u32,
2113         url_ptr: u32,
2114         url_len: u32,
2115         ct_ptr: u32,
2116         ct_len: u32,
2117         body_ptr: u32,
2118         body_len: u32,
2119         out_ptr: u32,
2120         out_cap: u32|
2121         -> i64 {
2122            let mem = caller.data().memory.expect("memory not set");
2123            let method =
2124                read_guest_string(&mem, &caller, method_ptr, method_len).unwrap_or_default();
2125            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2126            let content_type = read_guest_string(&mem, &caller, ct_ptr, ct_len).unwrap_or_default();
2127            let body = if body_len > 0 {
2128                read_guest_bytes(&mem, &caller, body_ptr, body_len).unwrap_or_default()
2129            } else {
2130                Vec::new()
2131            };
2132
2133            console_log(
2134                &caller.data().console,
2135                ConsoleLevel::Log,
2136                format!("[FETCH] {method} {url}"),
2137            );
2138
2139            let (resp_tx, resp_rx) =
2140                std::sync::mpsc::sync_channel::<Result<(u16, Vec<u8>), String>>(1);
2141
2142            std::thread::spawn(move || {
2143                let result = (|| -> Result<(u16, Vec<u8>), String> {
2144                    let client = reqwest::blocking::Client::builder()
2145                        .timeout(Duration::from_secs(30))
2146                        .build()
2147                        .map_err(|e| e.to_string())?;
2148                    let parsed: reqwest::Method = method.parse().unwrap_or(reqwest::Method::GET);
2149                    let mut req = client.request(parsed, &url);
2150                    if !content_type.is_empty() {
2151                        req = req.header("Content-Type", &content_type);
2152                    }
2153                    if !body.is_empty() {
2154                        req = req.body(body);
2155                    }
2156                    let resp = req.send().map_err(|e| e.to_string())?;
2157                    let status = resp.status().as_u16();
2158                    let bytes = resp.bytes().map_err(|e| e.to_string())?.to_vec();
2159                    Ok((status, bytes))
2160                })();
2161                let _ = resp_tx.send(result);
2162            });
2163
2164            match resp_rx.recv() {
2165                Ok(Ok((status, response_body))) => {
2166                    let write_len = response_body.len().min(out_cap as usize);
2167                    write_guest_bytes(&mem, &mut caller, out_ptr, &response_body[..write_len]).ok();
2168                    ((status as i64) << 32) | (write_len as i64)
2169                }
2170                Ok(Err(e)) => {
2171                    console_log(
2172                        &caller.data().console,
2173                        ConsoleLevel::Error,
2174                        format!("[FETCH ERROR] {e}"),
2175                    );
2176                    -1
2177                }
2178                Err(_) => -1,
2179            }
2180        },
2181    )?;
2182
2183    // ── Dynamic Module Loading ───────────────────────────────────────
2184    // Allows a running wasm guest to fetch and execute another .wasm
2185    // module. The child module shares the same canvas, console, and
2186    // storage — similar to how a <script> tag loads code into the same
2187    // page context.
2188
2189    linker.func_wrap(
2190        "oxide",
2191        "api_load_module",
2192        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
2193            let mem = caller.data().memory.expect("memory not set");
2194            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2195            let loader = match &caller.data().module_loader {
2196                Some(l) => l.clone(),
2197                None => return -1,
2198            };
2199            let mut child_state = caller.data().clone();
2200            child_state.memory = None;
2201            let console = caller.data().console.clone();
2202
2203            console_log(
2204                &console,
2205                ConsoleLevel::Log,
2206                format!("[LOAD] Fetching module: {url}"),
2207            );
2208
2209            let (tx, rx) = std::sync::mpsc::sync_channel::<Result<Vec<u8>, String>>(1);
2210            let fetch_url = url.clone();
2211            std::thread::spawn(move || {
2212                let result = (|| -> Result<Vec<u8>, String> {
2213                    let client = reqwest::blocking::Client::builder()
2214                        .timeout(Duration::from_secs(30))
2215                        .build()
2216                        .map_err(|e| e.to_string())?;
2217                    let resp = client
2218                        .get(&fetch_url)
2219                        .header("Accept", "application/wasm")
2220                        .send()
2221                        .map_err(|e| e.to_string())?;
2222                    if !resp.status().is_success() {
2223                        return Err(format!("HTTP {}", resp.status()));
2224                    }
2225                    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
2226                })();
2227                let _ = tx.send(result);
2228            });
2229
2230            let wasm_bytes = match rx.recv() {
2231                Ok(Ok(bytes)) => bytes,
2232                Ok(Err(e)) => {
2233                    console_log(&console, ConsoleLevel::Error, format!("[LOAD ERROR] {e}"));
2234                    return -1;
2235                }
2236                Err(_) => return -1,
2237            };
2238
2239            let module = match Module::new(&loader.engine, &wasm_bytes) {
2240                Ok(m) => m,
2241                Err(e) => {
2242                    console_log(
2243                        &console,
2244                        ConsoleLevel::Error,
2245                        format!("[LOAD ERROR] Compile: {e}"),
2246                    );
2247                    return -2;
2248                }
2249            };
2250
2251            let mut store = Store::new(&loader.engine, child_state);
2252            if store.set_fuel(loader.fuel_limit).is_err() {
2253                return -3;
2254            }
2255
2256            let mut child_linker = Linker::new(&loader.engine);
2257            if register_host_functions(&mut child_linker).is_err() {
2258                return -3;
2259            }
2260
2261            let mem_type = MemoryType::new(1, Some(loader.max_memory_pages));
2262            let memory = match Memory::new(&mut store, mem_type) {
2263                Ok(m) => m,
2264                Err(_) => return -4,
2265            };
2266
2267            if child_linker
2268                .define(&store, "oxide", "memory", memory)
2269                .is_err()
2270            {
2271                return -5;
2272            }
2273            store.data_mut().memory = Some(memory);
2274
2275            let instance = match child_linker.instantiate(&mut store, &module) {
2276                Ok(i) => i,
2277                Err(e) => {
2278                    console_log(
2279                        &console,
2280                        ConsoleLevel::Error,
2281                        format!("[LOAD ERROR] Instantiate: {e}"),
2282                    );
2283                    return -6;
2284                }
2285            };
2286
2287            // Use the child module's own exported memory for string I/O
2288            if let Some(guest_mem) = instance.get_memory(&mut store, "memory") {
2289                store.data_mut().memory = Some(guest_mem);
2290            }
2291
2292            let start_fn = match instance.get_typed_func::<(), ()>(&mut store, "start_app") {
2293                Ok(f) => f,
2294                Err(_) => {
2295                    console_log(
2296                        &console,
2297                        ConsoleLevel::Error,
2298                        "[LOAD ERROR] Module missing start_app".into(),
2299                    );
2300                    return -7;
2301                }
2302            };
2303
2304            match start_fn.call(&mut store, ()) {
2305                Ok(()) => {
2306                    console_log(
2307                        &console,
2308                        ConsoleLevel::Log,
2309                        format!("[LOAD] Module {url} executed successfully"),
2310                    );
2311                    0
2312                }
2313                Err(e) => {
2314                    let msg = if e.to_string().contains("fuel") {
2315                        "[LOAD ERROR] Child module fuel limit exceeded".to_string()
2316                    } else {
2317                        format!("[LOAD ERROR] Runtime: {e}")
2318                    };
2319                    console_log(&console, ConsoleLevel::Error, msg);
2320                    -8
2321                }
2322            }
2323        },
2324    )?;
2325
2326    // ── SHA-256 Hashing ──────────────────────────────────────────────
2327
2328    linker.func_wrap(
2329        "oxide",
2330        "api_hash_sha256",
2331        |mut caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32, out_ptr: u32| -> u32 {
2332            use sha2::{Digest, Sha256};
2333            let mem = caller.data().memory.expect("memory not set");
2334            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2335            let hash = Sha256::digest(&data);
2336            write_guest_bytes(&mem, &mut caller, out_ptr, &hash).ok();
2337            hash.len() as u32
2338        },
2339    )?;
2340
2341    // ── Base64 Encoding / Decoding ───────────────────────────────────
2342
2343    linker.func_wrap(
2344        "oxide",
2345        "api_base64_encode",
2346        |mut caller: Caller<'_, HostState>,
2347         data_ptr: u32,
2348         data_len: u32,
2349         out_ptr: u32,
2350         out_cap: u32|
2351         -> u32 {
2352            use base64::Engine;
2353            let mem = caller.data().memory.expect("memory not set");
2354            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2355            let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
2356            let bytes = encoded.as_bytes();
2357            let write_len = bytes.len().min(out_cap as usize);
2358            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2359            write_len as u32
2360        },
2361    )?;
2362
2363    linker.func_wrap(
2364        "oxide",
2365        "api_base64_decode",
2366        |mut caller: Caller<'_, HostState>,
2367         data_ptr: u32,
2368         data_len: u32,
2369         out_ptr: u32,
2370         out_cap: u32|
2371         -> u32 {
2372            use base64::Engine;
2373            let mem = caller.data().memory.expect("memory not set");
2374            let encoded = read_guest_string(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2375            match base64::engine::general_purpose::STANDARD.decode(&encoded) {
2376                Ok(decoded) => {
2377                    let write_len = decoded.len().min(out_cap as usize);
2378                    write_guest_bytes(&mem, &mut caller, out_ptr, &decoded[..write_len]).ok();
2379                    write_len as u32
2380                }
2381                Err(_) => 0,
2382            }
2383        },
2384    )?;
2385
2386    // ── Persistent Key-Value Store ───────────────────────────────────
2387    // Backed by a sled embedded database on the host's filesystem.
2388    // The guest has no direct access to the .db files.
2389
2390    linker.func_wrap(
2391        "oxide",
2392        "api_kv_store_set",
2393        |caller: Caller<'_, HostState>,
2394         key_ptr: u32,
2395         key_len: u32,
2396         val_ptr: u32,
2397         val_len: u32|
2398         -> i32 {
2399            let mem = caller.data().memory.expect("memory not set");
2400            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
2401            let val = read_guest_bytes(&mem, &caller, val_ptr, val_len).unwrap_or_default();
2402            let origin = caller.data().current_url.lock().unwrap().clone();
2403            let prefixed_key = format!("{origin}::{key}");
2404            match &caller.data().kv_db {
2405                Some(db) => match db.insert(prefixed_key.as_bytes(), val) {
2406                    Ok(_) => {
2407                        let _ = db.flush();
2408                        0
2409                    }
2410                    Err(e) => {
2411                        console_log(
2412                            &caller.data().console,
2413                            ConsoleLevel::Error,
2414                            format!("[KV] set failed: {e}"),
2415                        );
2416                        -1
2417                    }
2418                },
2419                None => {
2420                    console_log(
2421                        &caller.data().console,
2422                        ConsoleLevel::Error,
2423                        "[KV] store not initialised".into(),
2424                    );
2425                    -1
2426                }
2427            }
2428        },
2429    )?;
2430
2431    linker.func_wrap(
2432        "oxide",
2433        "api_kv_store_get",
2434        |mut caller: Caller<'_, HostState>,
2435         key_ptr: u32,
2436         key_len: u32,
2437         out_ptr: u32,
2438         out_cap: u32|
2439         -> i32 {
2440            let mem = caller.data().memory.expect("memory not set");
2441            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
2442            let origin = caller.data().current_url.lock().unwrap().clone();
2443            let prefixed_key = format!("{origin}::{key}");
2444            match &caller.data().kv_db {
2445                Some(db) => match db.get(prefixed_key.as_bytes()) {
2446                    Ok(Some(val)) => {
2447                        let bytes = val.as_ref();
2448                        let write_len = bytes.len().min(out_cap as usize);
2449                        write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2450                        write_len as i32
2451                    }
2452                    Ok(None) => -1,
2453                    Err(e) => {
2454                        console_log(
2455                            &caller.data().console,
2456                            ConsoleLevel::Error,
2457                            format!("[KV] get failed: {e}"),
2458                        );
2459                        -2
2460                    }
2461                },
2462                None => -2,
2463            }
2464        },
2465    )?;
2466
2467    linker.func_wrap(
2468        "oxide",
2469        "api_kv_store_delete",
2470        |caller: Caller<'_, HostState>, key_ptr: u32, key_len: u32| -> i32 {
2471            let mem = caller.data().memory.expect("memory not set");
2472            let key = read_guest_string(&mem, &caller, key_ptr, key_len).unwrap_or_default();
2473            let origin = caller.data().current_url.lock().unwrap().clone();
2474            let prefixed_key = format!("{origin}::{key}");
2475            match &caller.data().kv_db {
2476                Some(db) => match db.remove(prefixed_key.as_bytes()) {
2477                    Ok(_) => {
2478                        let _ = db.flush();
2479                        0
2480                    }
2481                    Err(e) => {
2482                        console_log(
2483                            &caller.data().console,
2484                            ConsoleLevel::Error,
2485                            format!("[KV] delete failed: {e}"),
2486                        );
2487                        -1
2488                    }
2489                },
2490                None => -1,
2491            }
2492        },
2493    )?;
2494
2495    // ── Navigation ──────────────────────────────────────────────────
2496
2497    linker.func_wrap(
2498        "oxide",
2499        "api_navigate",
2500        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
2501            let mem = caller.data().memory.expect("memory not set");
2502            let raw_url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2503
2504            let resolved = {
2505                let cur = caller.data().current_url.lock().unwrap();
2506                if cur.is_empty() {
2507                    raw_url.clone()
2508                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
2509                    base.join(&raw_url)
2510                        .map(|u| u.as_str().to_string())
2511                        .unwrap_or(raw_url.clone())
2512                } else {
2513                    raw_url.clone()
2514                }
2515            };
2516
2517            if oxide_url::OxideUrl::parse(&resolved).is_err() {
2518                console_log(
2519                    &caller.data().console,
2520                    ConsoleLevel::Error,
2521                    format!("[NAV] invalid URL: {resolved}"),
2522                );
2523                return -1;
2524            }
2525
2526            console_log(
2527                &caller.data().console,
2528                ConsoleLevel::Log,
2529                format!("[NAV] navigate → {resolved}"),
2530            );
2531            *caller.data().pending_navigation.lock().unwrap() = Some(resolved);
2532            0
2533        },
2534    )?;
2535
2536    linker.func_wrap(
2537        "oxide",
2538        "api_push_state",
2539        |caller: Caller<'_, HostState>,
2540         state_ptr: u32,
2541         state_len: u32,
2542         title_ptr: u32,
2543         title_len: u32,
2544         url_ptr: u32,
2545         url_len: u32| {
2546            let mem = caller.data().memory.expect("memory not set");
2547            let state = read_guest_bytes(&mem, &caller, state_ptr, state_len).unwrap_or_default();
2548            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
2549            let url_arg = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2550
2551            let resolved_url = if url_arg.is_empty() {
2552                caller.data().current_url.lock().unwrap().clone()
2553            } else {
2554                let cur = caller.data().current_url.lock().unwrap();
2555                if cur.is_empty() {
2556                    url_arg
2557                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
2558                    base.join(&url_arg)
2559                        .map(|u| u.as_str().to_string())
2560                        .unwrap_or(url_arg)
2561                } else {
2562                    url_arg
2563                }
2564            };
2565
2566            let entry = crate::navigation::HistoryEntry::new(&resolved_url)
2567                .with_title(title)
2568                .with_state(state);
2569            caller.data().navigation.lock().unwrap().push(entry);
2570            *caller.data().current_url.lock().unwrap() = resolved_url;
2571        },
2572    )?;
2573
2574    linker.func_wrap(
2575        "oxide",
2576        "api_replace_state",
2577        |caller: Caller<'_, HostState>,
2578         state_ptr: u32,
2579         state_len: u32,
2580         title_ptr: u32,
2581         title_len: u32,
2582         url_ptr: u32,
2583         url_len: u32| {
2584            let mem = caller.data().memory.expect("memory not set");
2585            let state = read_guest_bytes(&mem, &caller, state_ptr, state_len).unwrap_or_default();
2586            let title = read_guest_string(&mem, &caller, title_ptr, title_len).unwrap_or_default();
2587            let url_arg = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2588
2589            let resolved_url = if url_arg.is_empty() {
2590                caller.data().current_url.lock().unwrap().clone()
2591            } else {
2592                let cur = caller.data().current_url.lock().unwrap();
2593                if cur.is_empty() {
2594                    url_arg
2595                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
2596                    base.join(&url_arg)
2597                        .map(|u| u.as_str().to_string())
2598                        .unwrap_or(url_arg)
2599                } else {
2600                    url_arg
2601                }
2602            };
2603
2604            let entry = crate::navigation::HistoryEntry::new(&resolved_url)
2605                .with_title(title)
2606                .with_state(state);
2607            caller
2608                .data()
2609                .navigation
2610                .lock()
2611                .unwrap()
2612                .replace_current(entry);
2613            *caller.data().current_url.lock().unwrap() = resolved_url;
2614        },
2615    )?;
2616
2617    linker.func_wrap(
2618        "oxide",
2619        "api_get_url",
2620        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
2621            let url = caller.data().current_url.lock().unwrap().clone();
2622            let bytes = url.as_bytes();
2623            let write_len = bytes.len().min(out_cap as usize);
2624            let mem = caller.data().memory.expect("memory not set");
2625            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2626            write_len as u32
2627        },
2628    )?;
2629
2630    linker.func_wrap(
2631        "oxide",
2632        "api_get_state",
2633        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> i32 {
2634            let state_bytes = {
2635                let nav = caller.data().navigation.lock().unwrap();
2636                match nav.current() {
2637                    Some(entry) if !entry.state.is_empty() => Some(entry.state.clone()),
2638                    _ => None,
2639                }
2640            };
2641            match state_bytes {
2642                Some(bytes) => {
2643                    let write_len = bytes.len().min(out_cap as usize);
2644                    let mem = caller.data().memory.expect("memory not set");
2645                    write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2646                    write_len as i32
2647                }
2648                None => -1,
2649            }
2650        },
2651    )?;
2652
2653    linker.func_wrap(
2654        "oxide",
2655        "api_history_length",
2656        |caller: Caller<'_, HostState>| -> u32 {
2657            caller.data().navigation.lock().unwrap().len() as u32
2658        },
2659    )?;
2660
2661    linker.func_wrap(
2662        "oxide",
2663        "api_history_back",
2664        |caller: Caller<'_, HostState>| -> i32 {
2665            let mut nav = caller.data().navigation.lock().unwrap();
2666            match nav.go_back() {
2667                Some(entry) => {
2668                    let url = entry.url.clone();
2669                    *caller.data().current_url.lock().unwrap() = url.clone();
2670                    *caller.data().pending_navigation.lock().unwrap() = Some(url);
2671                    1
2672                }
2673                None => 0,
2674            }
2675        },
2676    )?;
2677
2678    linker.func_wrap(
2679        "oxide",
2680        "api_history_forward",
2681        |caller: Caller<'_, HostState>| -> i32 {
2682            let mut nav = caller.data().navigation.lock().unwrap();
2683            match nav.go_forward() {
2684                Some(entry) => {
2685                    let url = entry.url.clone();
2686                    *caller.data().current_url.lock().unwrap() = url.clone();
2687                    *caller.data().pending_navigation.lock().unwrap() = Some(url);
2688                    1
2689                }
2690                None => 0,
2691            }
2692        },
2693    )?;
2694
2695    // ── Hyperlinks ──────────────────────────────────────────────────
2696
2697    linker.func_wrap(
2698        "oxide",
2699        "api_register_hyperlink",
2700        |caller: Caller<'_, HostState>,
2701         x: f32,
2702         y: f32,
2703         w: f32,
2704         h: f32,
2705         url_ptr: u32,
2706         url_len: u32|
2707         -> i32 {
2708            let mem = caller.data().memory.expect("memory not set");
2709            let raw_url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
2710
2711            let resolved = {
2712                let cur = caller.data().current_url.lock().unwrap();
2713                if cur.is_empty() {
2714                    raw_url.clone()
2715                } else if let Ok(base) = oxide_url::OxideUrl::parse(&cur) {
2716                    base.join(&raw_url)
2717                        .map(|u| u.as_str().to_string())
2718                        .unwrap_or(raw_url.clone())
2719                } else {
2720                    raw_url.clone()
2721                }
2722            };
2723
2724            caller.data().hyperlinks.lock().unwrap().push(Hyperlink {
2725                x,
2726                y,
2727                w,
2728                h,
2729                url: resolved,
2730            });
2731            0
2732        },
2733    )?;
2734
2735    linker.func_wrap(
2736        "oxide",
2737        "api_clear_hyperlinks",
2738        |caller: Caller<'_, HostState>| {
2739            caller.data().hyperlinks.lock().unwrap().clear();
2740        },
2741    )?;
2742
2743    // ── URL Utilities ───────────────────────────────────────────────
2744
2745    linker.func_wrap(
2746        "oxide",
2747        "api_url_resolve",
2748        |mut caller: Caller<'_, HostState>,
2749         base_ptr: u32,
2750         base_len: u32,
2751         rel_ptr: u32,
2752         rel_len: u32,
2753         out_ptr: u32,
2754         out_cap: u32|
2755         -> i32 {
2756            let mem = caller.data().memory.expect("memory not set");
2757            let base_str = read_guest_string(&mem, &caller, base_ptr, base_len).unwrap_or_default();
2758            let rel_str = read_guest_string(&mem, &caller, rel_ptr, rel_len).unwrap_or_default();
2759
2760            let base = match oxide_url::OxideUrl::parse(&base_str) {
2761                Ok(u) => u,
2762                Err(_) => return -1,
2763            };
2764            let resolved = match base.join(&rel_str) {
2765                Ok(u) => u,
2766                Err(_) => return -2,
2767            };
2768
2769            let bytes = resolved.as_str().as_bytes();
2770            let write_len = bytes.len().min(out_cap as usize);
2771            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2772            write_len as i32
2773        },
2774    )?;
2775
2776    linker.func_wrap(
2777        "oxide",
2778        "api_url_encode",
2779        |mut caller: Caller<'_, HostState>,
2780         input_ptr: u32,
2781         input_len: u32,
2782         out_ptr: u32,
2783         out_cap: u32|
2784         -> u32 {
2785            let mem = caller.data().memory.expect("memory not set");
2786            let input = read_guest_string(&mem, &caller, input_ptr, input_len).unwrap_or_default();
2787            let encoded = oxide_url::percent_encode(&input);
2788            let bytes = encoded.as_bytes();
2789            let write_len = bytes.len().min(out_cap as usize);
2790            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2791            write_len as u32
2792        },
2793    )?;
2794
2795    linker.func_wrap(
2796        "oxide",
2797        "api_url_decode",
2798        |mut caller: Caller<'_, HostState>,
2799         input_ptr: u32,
2800         input_len: u32,
2801         out_ptr: u32,
2802         out_cap: u32|
2803         -> u32 {
2804            let mem = caller.data().memory.expect("memory not set");
2805            let input = read_guest_string(&mem, &caller, input_ptr, input_len).unwrap_or_default();
2806            let decoded = oxide_url::percent_decode(&input);
2807            let bytes = decoded.as_bytes();
2808            let write_len = bytes.len().min(out_cap as usize);
2809            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
2810            write_len as u32
2811        },
2812    )?;
2813
2814    // ── Input Polling ────────────────────────────────────────────────
2815
2816    linker.func_wrap(
2817        "oxide",
2818        "api_mouse_position",
2819        |caller: Caller<'_, HostState>| -> u64 {
2820            let input = caller.data().input_state.lock().unwrap();
2821            let offset = caller.data().canvas_offset.lock().unwrap();
2822            let x = input.mouse_x - offset.0;
2823            let y = input.mouse_y - offset.1;
2824            ((x.to_bits() as u64) << 32) | (y.to_bits() as u64)
2825        },
2826    )?;
2827
2828    linker.func_wrap(
2829        "oxide",
2830        "api_mouse_button_down",
2831        |caller: Caller<'_, HostState>, button: u32| -> u32 {
2832            let input = caller.data().input_state.lock().unwrap();
2833            if (button as usize) < 3 && input.mouse_buttons_down[button as usize] {
2834                1
2835            } else {
2836                0
2837            }
2838        },
2839    )?;
2840
2841    linker.func_wrap(
2842        "oxide",
2843        "api_mouse_button_clicked",
2844        |caller: Caller<'_, HostState>, button: u32| -> u32 {
2845            let input = caller.data().input_state.lock().unwrap();
2846            if (button as usize) < 3 && input.mouse_buttons_clicked[button as usize] {
2847                1
2848            } else {
2849                0
2850            }
2851        },
2852    )?;
2853
2854    linker.func_wrap(
2855        "oxide",
2856        "api_key_down",
2857        |caller: Caller<'_, HostState>, key: u32| -> u32 {
2858            let input = caller.data().input_state.lock().unwrap();
2859            if input.keys_down.contains(&key) {
2860                1
2861            } else {
2862                0
2863            }
2864        },
2865    )?;
2866
2867    linker.func_wrap(
2868        "oxide",
2869        "api_key_pressed",
2870        |caller: Caller<'_, HostState>, key: u32| -> u32 {
2871            let input = caller.data().input_state.lock().unwrap();
2872            if input.keys_pressed.contains(&key) {
2873                1
2874            } else {
2875                0
2876            }
2877        },
2878    )?;
2879
2880    linker.func_wrap(
2881        "oxide",
2882        "api_scroll_delta",
2883        |caller: Caller<'_, HostState>| -> u64 {
2884            let input = caller.data().input_state.lock().unwrap();
2885            ((input.scroll_x.to_bits() as u64) << 32) | (input.scroll_y.to_bits() as u64)
2886        },
2887    )?;
2888
2889    linker.func_wrap(
2890        "oxide",
2891        "api_modifiers",
2892        |caller: Caller<'_, HostState>| -> u32 {
2893            let input = caller.data().input_state.lock().unwrap();
2894            let mut flags = 0u32;
2895            if input.modifiers_shift {
2896                flags |= 1;
2897            }
2898            if input.modifiers_ctrl {
2899                flags |= 2;
2900            }
2901            if input.modifiers_alt {
2902                flags |= 4;
2903            }
2904            flags
2905        },
2906    )?;
2907
2908    // ── Audio Playback ────────────────────────────────────────────
2909    // All single-argument functions operate on the default channel (0).
2910    // Channel-specific variants allow simultaneous playback on separate
2911    // channels (e.g. background music on 0, SFX on 1+).
2912
2913    linker.func_wrap(
2914        "oxide",
2915        "api_audio_play",
2916        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32| -> i32 {
2917            let mem = caller.data().memory.expect("memory not set");
2918            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2919            if data.is_empty() {
2920                return -1;
2921            }
2922
2923            let audio = caller.data().audio.clone();
2924            let mut guard = audio.lock().unwrap();
2925            if guard.is_none() {
2926                *guard = AudioEngine::try_new();
2927            }
2928            match guard.as_mut() {
2929                Some(engine) => {
2930                    if engine.play_bytes_on(0, data) {
2931                        console_log(
2932                            &caller.data().console,
2933                            ConsoleLevel::Log,
2934                            "[AUDIO] Playing from bytes".into(),
2935                        );
2936                        0
2937                    } else {
2938                        console_log(
2939                            &caller.data().console,
2940                            ConsoleLevel::Error,
2941                            "[AUDIO] Failed to decode audio data".into(),
2942                        );
2943                        -2
2944                    }
2945                }
2946                None => {
2947                    console_log(
2948                        &caller.data().console,
2949                        ConsoleLevel::Error,
2950                        "[AUDIO] No audio device available".into(),
2951                    );
2952                    -3
2953                }
2954            }
2955        },
2956    )?;
2957
2958    linker.func_wrap(
2959        "oxide",
2960        "api_audio_detect_format",
2961        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32| -> u32 {
2962            let mem = caller.data().memory.expect("memory not set");
2963            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2964            audio_format::sniff_audio_format(&data)
2965        },
2966    )?;
2967
2968    linker.func_wrap(
2969        "oxide",
2970        "api_audio_play_with_format",
2971        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32, format_hint: u32| -> i32 {
2972            let mem = caller.data().memory.expect("memory not set");
2973            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
2974            if data.is_empty() {
2975                return -1;
2976            }
2977
2978            let audio = caller.data().audio.clone();
2979            let mut guard = audio.lock().unwrap();
2980            if guard.is_none() {
2981                *guard = AudioEngine::try_new();
2982            }
2983            match guard.as_mut() {
2984                Some(engine) => {
2985                    if audio_try_play(engine, 0, data, format_hint, &caller.data().console) {
2986                        console_log(
2987                            &caller.data().console,
2988                            ConsoleLevel::Log,
2989                            "[AUDIO] Playing from bytes (with format hint)".into(),
2990                        );
2991                        0
2992                    } else {
2993                        console_log(
2994                            &caller.data().console,
2995                            ConsoleLevel::Error,
2996                            "[AUDIO] Failed to decode audio data".into(),
2997                        );
2998                        -2
2999                    }
3000                }
3001                None => {
3002                    console_log(
3003                        &caller.data().console,
3004                        ConsoleLevel::Error,
3005                        "[AUDIO] No audio device available".into(),
3006                    );
3007                    -3
3008                }
3009            }
3010        },
3011    )?;
3012
3013    linker.func_wrap(
3014        "oxide",
3015        "api_audio_play_url",
3016        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
3017            let mem = caller.data().memory.expect("memory not set");
3018            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
3019
3020            console_log(
3021                &caller.data().console,
3022                ConsoleLevel::Log,
3023                format!("[AUDIO] Fetching {url}"),
3024            );
3025
3026            let (tx, rx) =
3027                std::sync::mpsc::sync_channel::<Result<(Vec<u8>, Option<String>), String>>(1);
3028            let fetch_url = url.clone();
3029            std::thread::spawn(move || {
3030                let result = (|| -> Result<(Vec<u8>, Option<String>), String> {
3031                    let client = reqwest::blocking::Client::builder()
3032                        .timeout(Duration::from_secs(30))
3033                        .build()
3034                        .map_err(|e| e.to_string())?;
3035                    let resp = client
3036                        .get(&fetch_url)
3037                        .header(ACCEPT, audio_format::AUDIO_HTTP_ACCEPT)
3038                        .send()
3039                        .map_err(|e| e.to_string())?;
3040                    if !resp.status().is_success() {
3041                        return Err(format!("HTTP {}", resp.status()));
3042                    }
3043                    let ct = resp
3044                        .headers()
3045                        .get(CONTENT_TYPE)
3046                        .and_then(|v| v.to_str().ok())
3047                        .map(|s| s.to_string());
3048                    let bytes = resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())?;
3049                    Ok((bytes, ct))
3050                })();
3051                let _ = tx.send(result);
3052            });
3053
3054            let (data, content_type) = match rx.recv() {
3055                Ok(Ok(pair)) => pair,
3056                Ok(Err(e)) => {
3057                    console_log(
3058                        &caller.data().console,
3059                        ConsoleLevel::Error,
3060                        format!("[AUDIO] Fetch error: {e}"),
3061                    );
3062                    return -1;
3063                }
3064                Err(_) => return -1,
3065            };
3066
3067            *caller.data().last_audio_url_content_type.lock().unwrap() =
3068                content_type.clone().unwrap_or_default();
3069
3070            let sniffed = audio_format::sniff_audio_format(&data);
3071            if let Some(ref ct) = content_type {
3072                if audio_format::is_likely_non_audio_document(ct)
3073                    && sniffed == audio_format::AUDIO_FORMAT_UNKNOWN
3074                {
3075                    console_log(
3076                        &caller.data().console,
3077                        ConsoleLevel::Error,
3078                        "[AUDIO] Response is not a supported audio resource (document MIME, no audio signature)"
3079                            .into(),
3080                    );
3081                    return -4;
3082                }
3083                let mime_fmt = audio_format::mime_to_audio_format(ct);
3084                if mime_fmt != audio_format::AUDIO_FORMAT_UNKNOWN
3085                    && sniffed != audio_format::AUDIO_FORMAT_UNKNOWN
3086                    && mime_fmt != sniffed
3087                {
3088                    console_log(
3089                        &caller.data().console,
3090                        ConsoleLevel::Warn,
3091                        format!(
3092                            "[AUDIO] Content-Type disagrees with sniffed container (MIME -> {mime_fmt}, sniff -> {sniffed})"
3093                        ),
3094                    );
3095                }
3096            }
3097
3098            let audio = caller.data().audio.clone();
3099            let mut guard = audio.lock().unwrap();
3100            if guard.is_none() {
3101                *guard = AudioEngine::try_new();
3102            }
3103            match guard.as_mut() {
3104                Some(engine) => {
3105                    if engine.play_bytes_on(0, data) {
3106                        let ct = content_type.as_deref().unwrap_or("(none)");
3107                        console_log(
3108                            &caller.data().console,
3109                            ConsoleLevel::Log,
3110                            format!("[AUDIO] Playing from URL: {url} (Content-Type: {ct})"),
3111                        );
3112                        0
3113                    } else {
3114                        console_log(
3115                            &caller.data().console,
3116                            ConsoleLevel::Error,
3117                            "[AUDIO] Failed to decode fetched audio".into(),
3118                        );
3119                        -2
3120                    }
3121                }
3122                None => {
3123                    console_log(
3124                        &caller.data().console,
3125                        ConsoleLevel::Error,
3126                        "[AUDIO] No audio device available".into(),
3127                    );
3128                    -3
3129                }
3130            }
3131        },
3132    )?;
3133
3134    linker.func_wrap(
3135        "oxide",
3136        "api_audio_last_url_content_type",
3137        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
3138            let s = caller
3139                .data()
3140                .last_audio_url_content_type
3141                .lock()
3142                .unwrap()
3143                .clone();
3144            let bytes = s.as_bytes();
3145            let write_len = bytes.len().min(out_cap as usize);
3146            let mem = caller.data().memory.expect("memory not set");
3147            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
3148            write_len as u32
3149        },
3150    )?;
3151
3152    linker.func_wrap(
3153        "oxide",
3154        "api_audio_pause",
3155        |caller: Caller<'_, HostState>| {
3156            let audio = caller.data().audio.clone();
3157            let guard = audio.lock().unwrap();
3158            if let Some(engine) = guard.as_ref() {
3159                if let Some(ch) = engine.channels.get(&0) {
3160                    ch.player.pause();
3161                }
3162            }
3163        },
3164    )?;
3165
3166    linker.func_wrap(
3167        "oxide",
3168        "api_audio_resume",
3169        |caller: Caller<'_, HostState>| {
3170            let audio = caller.data().audio.clone();
3171            let guard = audio.lock().unwrap();
3172            if let Some(engine) = guard.as_ref() {
3173                if let Some(ch) = engine.channels.get(&0) {
3174                    ch.player.play();
3175                }
3176            }
3177        },
3178    )?;
3179
3180    linker.func_wrap(
3181        "oxide",
3182        "api_audio_stop",
3183        |caller: Caller<'_, HostState>| {
3184            let audio = caller.data().audio.clone();
3185            let guard = audio.lock().unwrap();
3186            if let Some(engine) = guard.as_ref() {
3187                if let Some(ch) = engine.channels.get(&0) {
3188                    ch.player.stop();
3189                }
3190            }
3191        },
3192    )?;
3193
3194    linker.func_wrap(
3195        "oxide",
3196        "api_audio_set_volume",
3197        |caller: Caller<'_, HostState>, level: f32| {
3198            let audio = caller.data().audio.clone();
3199            let guard = audio.lock().unwrap();
3200            if let Some(engine) = guard.as_ref() {
3201                if let Some(ch) = engine.channels.get(&0) {
3202                    ch.player.set_volume(level.clamp(0.0, 2.0));
3203                }
3204            }
3205        },
3206    )?;
3207
3208    linker.func_wrap(
3209        "oxide",
3210        "api_audio_get_volume",
3211        |caller: Caller<'_, HostState>| -> f32 {
3212            let audio = caller.data().audio.clone();
3213            let guard = audio.lock().unwrap();
3214            guard
3215                .as_ref()
3216                .and_then(|e| e.channels.get(&0))
3217                .map(|ch| ch.player.volume())
3218                .unwrap_or(1.0)
3219        },
3220    )?;
3221
3222    linker.func_wrap(
3223        "oxide",
3224        "api_audio_is_playing",
3225        |caller: Caller<'_, HostState>| -> u32 {
3226            let audio = caller.data().audio.clone();
3227            let guard = audio.lock().unwrap();
3228            match guard.as_ref().and_then(|e| e.channels.get(&0)) {
3229                Some(ch) if !ch.player.is_paused() && !ch.player.empty() => 1,
3230                _ => 0,
3231            }
3232        },
3233    )?;
3234
3235    linker.func_wrap(
3236        "oxide",
3237        "api_audio_position",
3238        |caller: Caller<'_, HostState>| -> u64 {
3239            let audio = caller.data().audio.clone();
3240            let guard = audio.lock().unwrap();
3241            guard
3242                .as_ref()
3243                .and_then(|e| e.channels.get(&0))
3244                .map(|ch| ch.player.get_pos().as_millis() as u64)
3245                .unwrap_or(0)
3246        },
3247    )?;
3248
3249    linker.func_wrap(
3250        "oxide",
3251        "api_audio_seek",
3252        |caller: Caller<'_, HostState>, position_ms: u64| -> i32 {
3253            let audio = caller.data().audio.clone();
3254            let guard = audio.lock().unwrap();
3255            match guard.as_ref().and_then(|e| e.channels.get(&0)) {
3256                Some(ch) => {
3257                    let pos = Duration::from_millis(position_ms);
3258                    match ch.player.try_seek(pos) {
3259                        Ok(_) => 0,
3260                        Err(e) => {
3261                            console_log(
3262                                &caller.data().console,
3263                                ConsoleLevel::Warn,
3264                                format!("[AUDIO] Seek failed: {e}"),
3265                            );
3266                            -1
3267                        }
3268                    }
3269                }
3270                None => -1,
3271            }
3272        },
3273    )?;
3274
3275    linker.func_wrap(
3276        "oxide",
3277        "api_audio_duration",
3278        |caller: Caller<'_, HostState>| -> u64 {
3279            let audio = caller.data().audio.clone();
3280            let guard = audio.lock().unwrap();
3281            guard
3282                .as_ref()
3283                .and_then(|e| e.channels.get(&0))
3284                .map(|ch| ch.duration_ms)
3285                .unwrap_or(0)
3286        },
3287    )?;
3288
3289    linker.func_wrap(
3290        "oxide",
3291        "api_audio_set_loop",
3292        |caller: Caller<'_, HostState>, enabled: u32| {
3293            let audio = caller.data().audio.clone();
3294            let mut guard = audio.lock().unwrap();
3295            if guard.is_none() {
3296                *guard = AudioEngine::try_new();
3297            }
3298            if let Some(engine) = guard.as_mut() {
3299                engine.ensure_channel(0).looping = enabled != 0;
3300            }
3301        },
3302    )?;
3303
3304    linker.func_wrap(
3305        "oxide",
3306        "api_audio_channel_play",
3307        |caller: Caller<'_, HostState>, channel: u32, data_ptr: u32, data_len: u32| -> i32 {
3308            let mem = caller.data().memory.expect("memory not set");
3309            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
3310            if data.is_empty() {
3311                return -1;
3312            }
3313
3314            let audio = caller.data().audio.clone();
3315            let mut guard = audio.lock().unwrap();
3316            if guard.is_none() {
3317                *guard = AudioEngine::try_new();
3318            }
3319            match guard.as_mut() {
3320                Some(engine) => {
3321                    if engine.play_bytes_on(channel, data) {
3322                        console_log(
3323                            &caller.data().console,
3324                            ConsoleLevel::Log,
3325                            format!("[AUDIO] Playing on channel {channel}"),
3326                        );
3327                        0
3328                    } else {
3329                        console_log(
3330                            &caller.data().console,
3331                            ConsoleLevel::Error,
3332                            format!("[AUDIO] Failed to decode audio for channel {channel}"),
3333                        );
3334                        -2
3335                    }
3336                }
3337                None => -3,
3338            }
3339        },
3340    )?;
3341
3342    linker.func_wrap(
3343        "oxide",
3344        "api_audio_channel_play_with_format",
3345        |caller: Caller<'_, HostState>,
3346         channel: u32,
3347         data_ptr: u32,
3348         data_len: u32,
3349         format_hint: u32|
3350         -> i32 {
3351            let mem = caller.data().memory.expect("memory not set");
3352            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
3353            if data.is_empty() {
3354                return -1;
3355            }
3356
3357            let audio = caller.data().audio.clone();
3358            let mut guard = audio.lock().unwrap();
3359            if guard.is_none() {
3360                *guard = AudioEngine::try_new();
3361            }
3362            match guard.as_mut() {
3363                Some(engine) => {
3364                    if audio_try_play(engine, channel, data, format_hint, &caller.data().console) {
3365                        console_log(
3366                            &caller.data().console,
3367                            ConsoleLevel::Log,
3368                            format!("[AUDIO] Playing on channel {channel} (with format hint)"),
3369                        );
3370                        0
3371                    } else {
3372                        console_log(
3373                            &caller.data().console,
3374                            ConsoleLevel::Error,
3375                            format!("[AUDIO] Failed to decode audio for channel {channel}"),
3376                        );
3377                        -2
3378                    }
3379                }
3380                None => -3,
3381            }
3382        },
3383    )?;
3384
3385    linker.func_wrap(
3386        "oxide",
3387        "api_audio_channel_stop",
3388        |caller: Caller<'_, HostState>, channel: u32| {
3389            let audio = caller.data().audio.clone();
3390            let guard = audio.lock().unwrap();
3391            if let Some(engine) = guard.as_ref() {
3392                if let Some(ch) = engine.channels.get(&channel) {
3393                    ch.player.stop();
3394                }
3395            }
3396        },
3397    )?;
3398
3399    linker.func_wrap(
3400        "oxide",
3401        "api_audio_channel_set_volume",
3402        |caller: Caller<'_, HostState>, channel: u32, level: f32| {
3403            let audio = caller.data().audio.clone();
3404            let guard = audio.lock().unwrap();
3405            if let Some(engine) = guard.as_ref() {
3406                if let Some(ch) = engine.channels.get(&channel) {
3407                    ch.player.set_volume(level.clamp(0.0, 2.0));
3408                }
3409            }
3410        },
3411    )?;
3412
3413    // ── Video (FFmpeg) ─────────────────────────────────────────────
3414
3415    linker.func_wrap(
3416        "oxide",
3417        "api_video_detect_format",
3418        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32| -> u32 {
3419            let mem = caller.data().memory.expect("memory not set");
3420            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
3421            video_format::sniff_video_format(&data)
3422        },
3423    )?;
3424
3425    linker.func_wrap(
3426        "oxide",
3427        "api_video_load",
3428        |caller: Caller<'_, HostState>, data_ptr: u32, data_len: u32, format_hint: u32| -> i32 {
3429            let mem = caller.data().memory.expect("memory not set");
3430            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
3431            if data.is_empty() {
3432                return -1;
3433            }
3434            let mut guard = caller.data().video.lock().unwrap();
3435            match guard.open_bytes(&data, format_hint) {
3436                Ok(()) => {
3437                    console_log(
3438                        &caller.data().console,
3439                        ConsoleLevel::Log,
3440                        "[VIDEO] Loaded from bytes".into(),
3441                    );
3442                    0
3443                }
3444                Err(e) => {
3445                    console_log(
3446                        &caller.data().console,
3447                        ConsoleLevel::Error,
3448                        format!("[VIDEO] Load failed: {e}"),
3449                    );
3450                    -2
3451                }
3452            }
3453        },
3454    )?;
3455
3456    linker.func_wrap(
3457        "oxide",
3458        "api_video_load_url",
3459        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
3460            let mem = caller.data().memory.expect("memory not set");
3461            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
3462            if url.is_empty() {
3463                return -1;
3464            }
3465            console_log(
3466                &caller.data().console,
3467                ConsoleLevel::Log,
3468                format!("[VIDEO] Opening {url}"),
3469            );
3470
3471            let client = match reqwest::blocking::Client::builder()
3472                .timeout(Duration::from_secs(90))
3473                .build()
3474            {
3475                Ok(c) => c,
3476                Err(_) => return -3,
3477            };
3478
3479            let mut ct = String::new();
3480            if let Ok(resp) = client.head(&url).send() {
3481                if let Some(h) = resp.headers().get(CONTENT_TYPE) {
3482                    if let Ok(s) = h.to_str() {
3483                        ct = s.to_string();
3484                    }
3485                }
3486            }
3487
3488            let mut master_body: Option<String> = None;
3489            let fetch_master = url.to_ascii_lowercase().contains("m3u8")
3490                || ct.to_ascii_lowercase().contains("mpegurl")
3491                || ct.to_ascii_lowercase().contains("m3u8");
3492            if fetch_master {
3493                if let Ok(resp) = client
3494                    .get(&url)
3495                    .header(ACCEPT, video_format::VIDEO_HTTP_ACCEPT)
3496                    .timeout(Duration::from_secs(60))
3497                    .send()
3498                {
3499                    if resp.status().is_success() {
3500                        if let Ok(t) = resp.text() {
3501                            master_body = Some(t);
3502                        }
3503                    }
3504                }
3505            }
3506
3507            let mut guard = caller.data().video.lock().unwrap();
3508            guard.stop();
3509            guard.last_url_content_type = ct.clone();
3510            guard.hls_base_url = url.clone();
3511            if let Some(ref body) = master_body {
3512                guard.hls_variants = video::parse_hls_master_variants(body);
3513            } else {
3514                guard.hls_variants.clear();
3515            }
3516
3517            match video::VideoPlayer::open_url(&url) {
3518                Ok(p) => {
3519                    guard.player = Some(p);
3520                    let ctd = ct.as_str();
3521                    console_log(
3522                        &caller.data().console,
3523                        ConsoleLevel::Log,
3524                        format!("[VIDEO] Opened URL (Content-Type: {ctd})"),
3525                    );
3526                    0
3527                }
3528                Err(e) => {
3529                    console_log(
3530                        &caller.data().console,
3531                        ConsoleLevel::Error,
3532                        format!("[VIDEO] Open failed: {e}"),
3533                    );
3534                    -2
3535                }
3536            }
3537        },
3538    )?;
3539
3540    linker.func_wrap(
3541        "oxide",
3542        "api_video_last_url_content_type",
3543        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
3544            let s = caller
3545                .data()
3546                .video
3547                .lock()
3548                .unwrap()
3549                .last_url_content_type
3550                .clone();
3551            let bytes = s.as_bytes();
3552            let write_len = bytes.len().min(out_cap as usize);
3553            let mem = caller.data().memory.expect("memory not set");
3554            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
3555            write_len as u32
3556        },
3557    )?;
3558
3559    linker.func_wrap(
3560        "oxide",
3561        "api_video_hls_variant_count",
3562        |caller: Caller<'_, HostState>| -> u32 {
3563            caller.data().video.lock().unwrap().hls_variants.len() as u32
3564        },
3565    )?;
3566
3567    linker.func_wrap(
3568        "oxide",
3569        "api_video_hls_variant_url",
3570        |mut caller: Caller<'_, HostState>, index: u32, out_ptr: u32, out_cap: u32| -> u32 {
3571            let resolved = {
3572                let g = caller.data().video.lock().unwrap();
3573                g.hls_variants
3574                    .get(index as usize)
3575                    .and_then(|rel| video::resolve_against_base(&g.hls_base_url, rel))
3576                    .or_else(|| g.hls_variants.get(index as usize).cloned())
3577                    .unwrap_or_default()
3578            };
3579            let bytes = resolved.as_bytes();
3580            let write_len = bytes.len().min(out_cap as usize);
3581            let mem = caller.data().memory.expect("memory not set");
3582            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
3583            write_len as u32
3584        },
3585    )?;
3586
3587    linker.func_wrap(
3588        "oxide",
3589        "api_video_hls_open_variant",
3590        |caller: Caller<'_, HostState>, index: u32| -> i32 {
3591            let url_opt = {
3592                let g = caller.data().video.lock().unwrap();
3593                g.hls_variants.get(index as usize).map(|rel| {
3594                    video::resolve_against_base(&g.hls_base_url, rel).unwrap_or_else(|| rel.clone())
3595                })
3596            };
3597            let Some(url) = url_opt else {
3598                return -1;
3599            };
3600            let mut guard = caller.data().video.lock().unwrap();
3601            guard.hls_base_url = url.clone();
3602            guard.hls_variants.clear();
3603            match video::VideoPlayer::open_url(&url) {
3604                Ok(p) => {
3605                    guard.player = Some(p);
3606                    guard.reset_playback_clock();
3607                    console_log(
3608                        &caller.data().console,
3609                        ConsoleLevel::Log,
3610                        format!("[VIDEO] Opened HLS variant {index}"),
3611                    );
3612                    0
3613                }
3614                Err(e) => {
3615                    console_log(
3616                        &caller.data().console,
3617                        ConsoleLevel::Error,
3618                        format!("[VIDEO] Variant open failed: {e}"),
3619                    );
3620                    -2
3621                }
3622            }
3623        },
3624    )?;
3625
3626    linker.func_wrap(
3627        "oxide",
3628        "api_video_play",
3629        |caller: Caller<'_, HostState>| {
3630            caller.data().video.lock().unwrap().play();
3631        },
3632    )?;
3633
3634    linker.func_wrap(
3635        "oxide",
3636        "api_video_pause",
3637        |caller: Caller<'_, HostState>| {
3638            caller.data().video.lock().unwrap().pause();
3639        },
3640    )?;
3641
3642    linker.func_wrap(
3643        "oxide",
3644        "api_video_stop",
3645        |caller: Caller<'_, HostState>| {
3646            caller.data().video.lock().unwrap().stop();
3647            *caller.data().video_pip_frame.lock().unwrap() = None;
3648        },
3649    )?;
3650
3651    linker.func_wrap(
3652        "oxide",
3653        "api_video_seek",
3654        |caller: Caller<'_, HostState>, position_ms: u64| -> i32 {
3655            caller.data().video.lock().unwrap().seek(position_ms);
3656            0
3657        },
3658    )?;
3659
3660    linker.func_wrap(
3661        "oxide",
3662        "api_video_position",
3663        |caller: Caller<'_, HostState>| -> u64 {
3664            caller.data().video.lock().unwrap().current_position_ms()
3665        },
3666    )?;
3667
3668    linker.func_wrap(
3669        "oxide",
3670        "api_video_duration",
3671        |caller: Caller<'_, HostState>| -> u64 {
3672            caller.data().video.lock().unwrap().duration_ms()
3673        },
3674    )?;
3675
3676    linker.func_wrap(
3677        "oxide",
3678        "api_video_render",
3679        |caller: Caller<'_, HostState>, x: f32, y: f32, w: f32, h: f32| -> i32 {
3680            match video_render_at(
3681                &caller.data().video,
3682                &caller.data().video_pip_frame,
3683                &caller.data().video_pip_serial,
3684                &caller.data().canvas,
3685                x,
3686                y,
3687                w,
3688                h,
3689            ) {
3690                Ok(()) => 0,
3691                Err(e) => {
3692                    console_log(
3693                        &caller.data().console,
3694                        ConsoleLevel::Error,
3695                        format!("[VIDEO] Render: {e}"),
3696                    );
3697                    -1
3698                }
3699            }
3700        },
3701    )?;
3702
3703    linker.func_wrap(
3704        "oxide",
3705        "api_video_set_volume",
3706        |caller: Caller<'_, HostState>, level: f32| {
3707            caller.data().video.lock().unwrap().volume = level.clamp(0.0, 2.0);
3708        },
3709    )?;
3710
3711    linker.func_wrap(
3712        "oxide",
3713        "api_video_get_volume",
3714        |caller: Caller<'_, HostState>| -> f32 { caller.data().video.lock().unwrap().volume },
3715    )?;
3716
3717    linker.func_wrap(
3718        "oxide",
3719        "api_video_set_loop",
3720        |caller: Caller<'_, HostState>, enabled: u32| {
3721            caller.data().video.lock().unwrap().looping = enabled != 0;
3722        },
3723    )?;
3724
3725    linker.func_wrap(
3726        "oxide",
3727        "api_video_set_pip",
3728        |caller: Caller<'_, HostState>, enabled: u32| {
3729            caller.data().video.lock().unwrap().pip = enabled != 0;
3730            if enabled == 0 {
3731                *caller.data().video_pip_frame.lock().unwrap() = None;
3732            }
3733        },
3734    )?;
3735
3736    linker.func_wrap(
3737        "oxide",
3738        "api_subtitle_load_srt",
3739        |caller: Caller<'_, HostState>, ptr: u32, len: u32| -> i32 {
3740            let mem = caller.data().memory.expect("memory not set");
3741            let s = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
3742            caller.data().video.lock().unwrap().subtitles = subtitle::parse_srt(&s);
3743            0
3744        },
3745    )?;
3746
3747    linker.func_wrap(
3748        "oxide",
3749        "api_subtitle_load_vtt",
3750        |caller: Caller<'_, HostState>, ptr: u32, len: u32| -> i32 {
3751            let mem = caller.data().memory.expect("memory not set");
3752            let s = read_guest_string(&mem, &caller, ptr, len).unwrap_or_default();
3753            caller.data().video.lock().unwrap().subtitles = subtitle::parse_vtt(&s);
3754            0
3755        },
3756    )?;
3757
3758    linker.func_wrap(
3759        "oxide",
3760        "api_subtitle_clear",
3761        |caller: Caller<'_, HostState>| {
3762            caller.data().video.lock().unwrap().subtitles.clear();
3763        },
3764    )?;
3765
3766    // ── Interactive Widgets ─────────────────────────────────────────
3767
3768    linker.func_wrap(
3769        "oxide",
3770        "api_ui_button",
3771        |caller: Caller<'_, HostState>,
3772         id: u32,
3773         x: f32,
3774         y: f32,
3775         w: f32,
3776         h: f32,
3777         label_ptr: u32,
3778         label_len: u32|
3779         -> u32 {
3780            let mem = caller.data().memory.expect("memory not set");
3781            let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default();
3782            caller
3783                .data()
3784                .widget_commands
3785                .lock()
3786                .unwrap()
3787                .push(WidgetCommand::Button {
3788                    id,
3789                    x,
3790                    y,
3791                    w,
3792                    h,
3793                    label,
3794                });
3795            if caller.data().widget_clicked.lock().unwrap().contains(&id) {
3796                1
3797            } else {
3798                0
3799            }
3800        },
3801    )?;
3802
3803    linker.func_wrap(
3804        "oxide",
3805        "api_ui_checkbox",
3806        |caller: Caller<'_, HostState>,
3807         id: u32,
3808         x: f32,
3809         y: f32,
3810         label_ptr: u32,
3811         label_len: u32,
3812         initial: u32|
3813         -> u32 {
3814            let mem = caller.data().memory.expect("memory not set");
3815            let label = read_guest_string(&mem, &caller, label_ptr, label_len).unwrap_or_default();
3816            let mut states = caller.data().widget_states.lock().unwrap();
3817            let entry = states
3818                .entry(id)
3819                .or_insert_with(|| WidgetValue::Bool(initial != 0));
3820            let checked = match entry {
3821                WidgetValue::Bool(b) => *b,
3822                _ => initial != 0,
3823            };
3824            drop(states);
3825            caller
3826                .data()
3827                .widget_commands
3828                .lock()
3829                .unwrap()
3830                .push(WidgetCommand::Checkbox { id, x, y, label });
3831            if checked {
3832                1
3833            } else {
3834                0
3835            }
3836        },
3837    )?;
3838
3839    linker.func_wrap(
3840        "oxide",
3841        "api_ui_slider",
3842        |caller: Caller<'_, HostState>,
3843         id: u32,
3844         x: f32,
3845         y: f32,
3846         w: f32,
3847         min: f32,
3848         max: f32,
3849         initial: f32|
3850         -> f32 {
3851            let mut states = caller.data().widget_states.lock().unwrap();
3852            let entry = states
3853                .entry(id)
3854                .or_insert_with(|| WidgetValue::Float(initial));
3855            let value = match entry {
3856                WidgetValue::Float(v) => *v,
3857                _ => initial,
3858            };
3859            drop(states);
3860            caller
3861                .data()
3862                .widget_commands
3863                .lock()
3864                .unwrap()
3865                .push(WidgetCommand::Slider {
3866                    id,
3867                    x,
3868                    y,
3869                    w,
3870                    min,
3871                    max,
3872                });
3873            value
3874        },
3875    )?;
3876
3877    linker.func_wrap(
3878        "oxide",
3879        "api_ui_text_input",
3880        |mut caller: Caller<'_, HostState>,
3881         id: u32,
3882         x: f32,
3883         y: f32,
3884         w: f32,
3885         init_ptr: u32,
3886         init_len: u32,
3887         out_ptr: u32,
3888         out_cap: u32|
3889         -> u32 {
3890            let mem = caller.data().memory.expect("memory not set");
3891            let text = {
3892                let mut states = caller.data().widget_states.lock().unwrap();
3893                let entry = states.entry(id).or_insert_with(|| {
3894                    let init =
3895                        read_guest_string(&mem, &caller, init_ptr, init_len).unwrap_or_default();
3896                    WidgetValue::Text(init)
3897                });
3898                match entry {
3899                    WidgetValue::Text(t) => t.clone(),
3900                    _ => String::new(),
3901                }
3902            };
3903            caller
3904                .data()
3905                .widget_commands
3906                .lock()
3907                .unwrap()
3908                .push(WidgetCommand::TextInput { id, x, y, w });
3909            let bytes = text.as_bytes();
3910            let write_len = bytes.len().min(out_cap as usize);
3911            write_guest_bytes(&mem, &mut caller, out_ptr, &bytes[..write_len]).ok();
3912            write_len as u32
3913        },
3914    )?;
3915
3916    crate::media_capture::register_media_capture_functions(linker)?;
3917
3918    // ── GPU / WebGPU-style API ───────────────────────────────────────
3919
3920    linker.func_wrap(
3921        "oxide",
3922        "api_gpu_create_buffer",
3923        |caller: Caller<'_, HostState>, size_lo: u32, size_hi: u32, usage: u32| -> u32 {
3924            let size = ((size_hi as u64) << 32) | (size_lo as u64);
3925            let mut gpu_lock = caller.data().gpu.lock().unwrap();
3926            let gpu = match gpu_lock.as_mut() {
3927                Some(g) => g,
3928                None => {
3929                    if let Some(g) = crate::gpu::init_gpu() {
3930                        *gpu_lock = Some(g);
3931                        gpu_lock.as_mut().unwrap()
3932                    } else {
3933                        console_log(
3934                            &caller.data().console,
3935                            ConsoleLevel::Error,
3936                            "[GPU] No suitable GPU adapter found".into(),
3937                        );
3938                        return 0;
3939                    }
3940                }
3941            };
3942            gpu.create_buffer(size, usage)
3943        },
3944    )?;
3945
3946    linker.func_wrap(
3947        "oxide",
3948        "api_gpu_create_texture",
3949        |caller: Caller<'_, HostState>, width: u32, height: u32| -> u32 {
3950            let mut gpu_lock = caller.data().gpu.lock().unwrap();
3951            let gpu = match gpu_lock.as_mut() {
3952                Some(g) => g,
3953                None => {
3954                    if let Some(g) = crate::gpu::init_gpu() {
3955                        *gpu_lock = Some(g);
3956                        gpu_lock.as_mut().unwrap()
3957                    } else {
3958                        return 0;
3959                    }
3960                }
3961            };
3962            gpu.create_texture(width, height)
3963        },
3964    )?;
3965
3966    linker.func_wrap(
3967        "oxide",
3968        "api_gpu_create_shader",
3969        |caller: Caller<'_, HostState>, src_ptr: u32, src_len: u32| -> u32 {
3970            let mem = caller.data().memory.expect("memory not set");
3971            let source = read_guest_string(&mem, &caller, src_ptr, src_len).unwrap_or_default();
3972            let mut gpu_lock = caller.data().gpu.lock().unwrap();
3973            let gpu = match gpu_lock.as_mut() {
3974                Some(g) => g,
3975                None => {
3976                    if let Some(g) = crate::gpu::init_gpu() {
3977                        *gpu_lock = Some(g);
3978                        gpu_lock.as_mut().unwrap()
3979                    } else {
3980                        return 0;
3981                    }
3982                }
3983            };
3984            gpu.create_shader(&source)
3985        },
3986    )?;
3987
3988    linker.func_wrap(
3989        "oxide",
3990        "api_gpu_create_render_pipeline",
3991        |caller: Caller<'_, HostState>,
3992         shader: u32,
3993         vs_ptr: u32,
3994         vs_len: u32,
3995         fs_ptr: u32,
3996         fs_len: u32|
3997         -> u32 {
3998            let mem = caller.data().memory.expect("memory not set");
3999            let vs = read_guest_string(&mem, &caller, vs_ptr, vs_len).unwrap_or_default();
4000            let fs = read_guest_string(&mem, &caller, fs_ptr, fs_len).unwrap_or_default();
4001            let mut gpu_lock = caller.data().gpu.lock().unwrap();
4002            match gpu_lock.as_mut() {
4003                Some(g) => g.create_render_pipeline(shader, &vs, &fs),
4004                None => 0,
4005            }
4006        },
4007    )?;
4008
4009    linker.func_wrap(
4010        "oxide",
4011        "api_gpu_create_compute_pipeline",
4012        |caller: Caller<'_, HostState>, shader: u32, ep_ptr: u32, ep_len: u32| -> u32 {
4013            let mem = caller.data().memory.expect("memory not set");
4014            let ep = read_guest_string(&mem, &caller, ep_ptr, ep_len).unwrap_or_default();
4015            let mut gpu_lock = caller.data().gpu.lock().unwrap();
4016            match gpu_lock.as_mut() {
4017                Some(g) => g.create_compute_pipeline(shader, &ep),
4018                None => 0,
4019            }
4020        },
4021    )?;
4022
4023    linker.func_wrap(
4024        "oxide",
4025        "api_gpu_write_buffer",
4026        |caller: Caller<'_, HostState>,
4027         handle: u32,
4028         offset_lo: u32,
4029         offset_hi: u32,
4030         data_ptr: u32,
4031         data_len: u32|
4032         -> u32 {
4033            let mem = caller.data().memory.expect("memory not set");
4034            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
4035            let offset = ((offset_hi as u64) << 32) | (offset_lo as u64);
4036            let gpu_lock = caller.data().gpu.lock().unwrap();
4037            u32::from(
4038                gpu_lock
4039                    .as_ref()
4040                    .is_some_and(|g| g.write_buffer(handle, offset, &data)),
4041            )
4042        },
4043    )?;
4044
4045    linker.func_wrap(
4046        "oxide",
4047        "api_gpu_draw",
4048        |caller: Caller<'_, HostState>,
4049         pipeline: u32,
4050         target: u32,
4051         vertex_count: u32,
4052         instance_count: u32|
4053         -> u32 {
4054            let gpu_lock = caller.data().gpu.lock().unwrap();
4055            u32::from(
4056                gpu_lock
4057                    .as_ref()
4058                    .is_some_and(|g| g.draw(pipeline, target, vertex_count, instance_count)),
4059            )
4060        },
4061    )?;
4062
4063    linker.func_wrap(
4064        "oxide",
4065        "api_gpu_dispatch_compute",
4066        |caller: Caller<'_, HostState>, pipeline: u32, x: u32, y: u32, z: u32| -> u32 {
4067            let gpu_lock = caller.data().gpu.lock().unwrap();
4068            u32::from(
4069                gpu_lock
4070                    .as_ref()
4071                    .is_some_and(|g| g.dispatch_compute(pipeline, x, y, z)),
4072            )
4073        },
4074    )?;
4075
4076    linker.func_wrap(
4077        "oxide",
4078        "api_gpu_destroy_buffer",
4079        |caller: Caller<'_, HostState>, handle: u32| -> u32 {
4080            let mut gpu_lock = caller.data().gpu.lock().unwrap();
4081            u32::from(gpu_lock.as_mut().is_some_and(|g| g.destroy_buffer(handle)))
4082        },
4083    )?;
4084
4085    linker.func_wrap(
4086        "oxide",
4087        "api_gpu_destroy_texture",
4088        |caller: Caller<'_, HostState>, handle: u32| -> u32 {
4089            let mut gpu_lock = caller.data().gpu.lock().unwrap();
4090            u32::from(gpu_lock.as_mut().is_some_and(|g| g.destroy_texture(handle)))
4091        },
4092    )?;
4093
4094    // ── WebRTC / Real-Time Communication API ─────────────────────────
4095    crate::rtc::register_rtc_functions(linker)?;
4096
4097    // ── WebSocket API ─────────────────────────────────────────────────
4098    crate::websocket::register_ws_functions(linker)?;
4099
4100    // ── MIDI API ──────────────────────────────────────────────────────
4101    crate::midi::register_midi_functions(linker)?;
4102
4103    // ── Streaming / non-blocking Fetch API ────────────────────────────
4104    crate::fetch::register_fetch_functions(linker)?;
4105
4106    // ── Event System ──────────────────────────────────────────────────
4107    crate::events::register_event_functions(linker)?;
4108
4109    // ── Native File / Folder Picker API ───────────────────────────────
4110    crate::file_picker::register_file_picker_functions(linker)?;
4111
4112    // ── Download Manager API ──────────────────────────────────────────
4113
4114    linker.func_wrap(
4115        "oxide",
4116        "api_download_data",
4117        |caller: Caller<'_, HostState>,
4118         data_ptr: u32,
4119         data_len: u32,
4120         filename_ptr: u32,
4121         filename_len: u32|
4122         -> i32 {
4123            let mem = caller.data().memory.expect("memory not set");
4124            let data = read_guest_bytes(&mem, &caller, data_ptr, data_len).unwrap_or_default();
4125            let filename =
4126                read_guest_string(&mem, &caller, filename_ptr, filename_len).unwrap_or_default();
4127            if data.is_empty() || filename.is_empty() {
4128                return -1;
4129            }
4130            match caller.data().download_manager.save_data(&data, &filename) {
4131                Ok(_) => {
4132                    console_log(
4133                        &caller.data().console,
4134                        ConsoleLevel::Log,
4135                        format!("[DOWNLOAD] Saved {} bytes to {}", data.len(), filename),
4136                    );
4137                    0
4138                }
4139                Err(e) => {
4140                    console_log(
4141                        &caller.data().console,
4142                        ConsoleLevel::Error,
4143                        format!("[DOWNLOAD] Failed to save {}: {e}", filename),
4144                    );
4145                    -1
4146                }
4147            }
4148        },
4149    )?;
4150
4151    linker.func_wrap(
4152        "oxide",
4153        "api_download_url",
4154        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
4155            let mem = caller.data().memory.expect("memory not set");
4156            let url = read_guest_string(&mem, &caller, url_ptr, url_len).unwrap_or_default();
4157            if url.is_empty() {
4158                return -1;
4159            }
4160            caller.data().download_manager.start_download(url.clone());
4161            console_log(
4162                &caller.data().console,
4163                ConsoleLevel::Log,
4164                format!("[DOWNLOAD] Started download for {url}"),
4165            );
4166            0
4167        },
4168    )?;
4169
4170    linker.func_wrap(
4171        "oxide",
4172        "api_canvas_print_pdf",
4173        |caller: Caller<'_, HostState>, filename_ptr: u32, filename_len: u32| -> i32 {
4174            let mem = caller.data().memory.expect("memory not set");
4175            let filename =
4176                read_guest_string(&mem, &caller, filename_ptr, filename_len).unwrap_or_default();
4177            if filename.is_empty() {
4178                return -1;
4179            }
4180            let canvas = caller.data().canvas.lock().unwrap().clone();
4181            match render_canvas_to_pdf(&canvas, &filename) {
4182                Ok(_) => {
4183                    console_log(
4184                        &caller.data().console,
4185                        ConsoleLevel::Log,
4186                        format!("[PRINT] Canvas exported to PDF: {filename}"),
4187                    );
4188                    0
4189                }
4190                Err(e) => {
4191                    console_log(
4192                        &caller.data().console,
4193                        ConsoleLevel::Error,
4194                        format!("[PRINT] PDF export failed: {e}"),
4195                    );
4196                    -1
4197                }
4198            }
4199        },
4200    )?;
4201
4202    Ok(())
4203}
4204
4205fn getrandom(buf: &mut [u8]) {
4206    ::getrandom::getrandom(buf).expect("OS random number generator unavailable");
4207}