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