Skip to main content

compose_rust/
boundary.rs

1use crate::protocol::{HostEvent, ProtocolError, decode_event};
2use crate::renderer::ComposeRenderer;
3use crate::schema::{
4    AssetKind, EventPayload, IconRole, LoopMode, PROTOCOL_VERSION, SCHEMA_HASH, Theme,
5};
6use crate::{Element, KeyEvent, RangeRequest, Selection, VirtualDom};
7use dioxus_core::{ElementId, Event};
8use std::cell::RefCell;
9use std::ffi::c_int;
10use std::future::Future;
11use std::panic::{AssertUnwindSafe, catch_unwind};
12use std::pin::pin;
13use std::rc::Rc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex, OnceLock};
16use std::task::{Context, Poll, Wake, Waker};
17
18pub const STATUS_OK: i32 = 0;
19pub const STATUS_PROTOCOL_ERROR: i32 = -1;
20pub const STATUS_NOT_INITIALIZED: i32 = -2;
21pub const STATUS_ALREADY_INITIALIZED: i32 = -3;
22pub const STATUS_PANIC: i32 = -4;
23/// There is no renderer in this build to run. Returned by the loop entry point, never by
24/// a boundary call: a call could not have got this far without a renderer to make it.
25pub const STATUS_NO_RENDERER: i32 = -5;
26
27/// What the process exits with when the renderer loop does not finish successfully.
28///
29/// 1, not the status itself: exit codes are a single byte on Unix, so `STATUS_NO_RENDERER`
30/// would reach the shell as 251 and read as a signal rather than an ordinary failure.
31const EXIT_FAILURE: i32 = 1;
32
33#[repr(C)]
34#[derive(Clone, Copy, Debug)]
35pub struct MutationBatch {
36    pub ptr: *const u8,
37    pub len: u32,
38    pub result: i64,
39}
40
41impl Default for MutationBatch {
42    fn default() -> Self {
43        Self {
44            ptr: std::ptr::null(),
45            len: 0,
46            result: 0,
47        }
48    }
49}
50
51#[derive(Clone, Copy)]
52pub struct RendererApi {
53    pub run: extern "C" fn() -> c_int,
54    pub request_frame: extern "C" fn(),
55}
56
57static RENDERER_API: OnceLock<RendererApi> = OnceLock::new();
58static FRAME_REQUESTED: AtomicBool = AtomicBool::new(false);
59static EVENT_DISPATCH_ACTIVE: AtomicBool = AtomicBool::new(false);
60static DEFERRED_FRAME_REQUEST: AtomicBool = AtomicBool::new(false);
61/// Set between a stop and the start that follows it. While it is set a worker's frame
62/// request is remembered but not delivered, so a process that is not on screen is never
63/// asked to draw.
64static LIFECYCLE_SUPPRESSED: AtomicBool = AtomicBool::new(false);
65
66pub fn install_renderer_api(api: RendererApi) -> Result<(), RendererApi> {
67    RENDERER_API.set(api)
68}
69
70// `renderer_linked` is set by the build script, and only when a renderer was really
71// linked. That is not the same as the feature being on: a documentation build turns the
72// feature on and links nothing, and the result has to behave like the build it is.
73#[cfg(all(renderer_linked, not(any(test, feature = "mock-renderer"))))]
74unsafe extern "C" {
75    fn dioxus_compose_renderer_run() -> c_int;
76    fn dioxus_compose_renderer_request_frame();
77}
78
79#[cfg(all(renderer_linked, not(any(test, feature = "mock-renderer"))))]
80extern "C" fn native_run() -> c_int {
81    // SAFETY: The application links the Renderer implementation of this declared C ABI.
82    unsafe { dioxus_compose_renderer_run() }
83}
84
85#[cfg(all(renderer_linked, not(any(test, feature = "mock-renderer"))))]
86extern "C" fn native_request_frame() {
87    // SAFETY: The Renderer contract makes request_frame thread-safe.
88    unsafe { dioxus_compose_renderer_request_frame() }
89}
90
91// The mock renderer and the crate's own tests drive the boundary directly and never want
92// a window, so doing nothing is the correct answer for them and always has been.
93#[cfg(any(test, feature = "mock-renderer"))]
94extern "C" fn native_run() -> c_int {
95    STATUS_OK
96}
97
98#[cfg(any(test, feature = "mock-renderer"))]
99extern "C" fn native_request_frame() {}
100
101// A real build with no renderer. This used to return success, so an application built
102// this way opened no window, drew nothing, printed nothing and exited 0, and there was
103// no way to tell that from a program that had simply finished.
104#[cfg(all(not(renderer_linked), not(any(test, feature = "mock-renderer"))))]
105extern "C" fn native_run() -> c_int {
106    eprintln!("{}", no_renderer_message());
107    STATUS_NO_RENDERER
108}
109
110#[cfg(all(not(renderer_linked), not(any(test, feature = "mock-renderer"))))]
111extern "C" fn native_request_frame() {}
112
113/// What a build with no renderer says on its way out.
114///
115/// Compiled into every build, not only the one that prints it, so that a test can read it
116/// whatever the build it is running in was configured with.
117pub fn no_renderer_message() -> String {
118    format!(
119        "dioxus-compose: this application was built without a renderer, so there is nothing\n\
120         to draw with and nothing to draw on. Exiting {EXIT_FAILURE} rather than looking like\n\
121         a program that ran and finished.\n\
122         \n\
123         A default `cargo build` links the renderer for the platform it is building for and\n\
124         downloads it if it has to. A build reaches this message by turning that off:\n\
125         \n\
126         \x20   default-features = false, without re-enabling `native-renderer`\n\
127         \x20   a documentation build, which has no network to fetch a renderer with\n\
128         \n\
129         Building for a test or with the `mock-renderer` feature is a third way, and that\n\
130         one is silent on purpose: those builds drive the boundary directly and want no\n\
131         window."
132    )
133}
134
135fn renderer_api() -> RendererApi {
136    RENDERER_API.get().copied().unwrap_or(RendererApi {
137        run: native_run,
138        request_frame: native_request_frame,
139    })
140}
141
142/// Coalesced wake used internally by the Dioxus scheduler waker.
143///
144/// The flag spans the moment of delivery, not the wait for the frame that answers it. The
145/// Renderer folds requests into its own frame clock, so however many arrive between two
146/// frames it draws once; holding the flag until the frame came back would instead mean
147/// that one request the Renderer was not yet listening for silenced every later one. That
148/// happens on a cold start, where the first composition can be seconds after the first
149/// worker request, and it leaves the application frozen with nothing to unfreeze it.
150pub fn request_frame_from_worker() {
151    if !FRAME_REQUESTED.swap(true, Ordering::AcqRel) {
152        if LIFECYCLE_SUPPRESSED.load(Ordering::Acquire)
153            || EVENT_DISPATCH_ACTIVE.load(Ordering::Acquire)
154        {
155            DEFERRED_FRAME_REQUEST.store(true, Ordering::Release);
156        } else {
157            (renderer_api().request_frame)();
158            FRAME_REQUESTED.store(false, Ordering::Release);
159        }
160    }
161}
162
163struct EventDispatchGuard;
164
165impl EventDispatchGuard {
166    fn enter() -> Self {
167        EVENT_DISPATCH_ACTIVE.store(true, Ordering::Release);
168        Self
169    }
170}
171
172impl Drop for EventDispatchGuard {
173    fn drop(&mut self) {
174        EVENT_DISPATCH_ACTIVE.store(false, Ordering::Release);
175        if LIFECYCLE_SUPPRESSED.load(Ordering::Acquire) {
176            return;
177        }
178        if DEFERRED_FRAME_REQUEST.swap(false, Ordering::AcqRel) {
179            (renderer_api().request_frame)();
180            FRAME_REQUESTED.store(false, Ordering::Release);
181        }
182    }
183}
184
185struct FrameWake;
186
187impl Wake for FrameWake {
188    fn wake(self: Arc<Self>) {
189        request_frame_from_worker();
190    }
191
192    fn wake_by_ref(self: &Arc<Self>) {
193        request_frame_from_worker();
194    }
195}
196
197/// One streaming Text node's tail, accumulated between frames.
198struct PendingAppend {
199    node_id: u32,
200    text: String,
201    dirty: bool,
202}
203
204pub struct Host {
205    /// Kept so the tree can be built again from nothing when the Renderer asks for a
206    /// resync, which is the one thing a diff against a lost node table cannot answer.
207    app: fn() -> Element,
208    theme: Theme,
209    window: crate::schema::Window,
210    dom: VirtualDom,
211    renderer: ComposeRenderer,
212    frame_waker: Waker,
213    pending_appends: Vec<PendingAppend>,
214}
215
216impl Host {
217    pub fn new(app: fn() -> Element) -> Self {
218        Self::with_theme(app, launched_theme())
219    }
220
221    /// The theme the application chose. Choosing nothing follows the host platform, with
222    /// Material 3 where the platform has no look of its own.
223    pub fn with_theme(app: fn() -> Element, theme: Theme) -> Self {
224        // A fresh Host has not been measured yet, and the Renderer that is about to drive
225        // it starts from the same assumption. Leaving a previous Host's last measurement
226        // behind would put the two sides out of step, because the Renderer reports only
227        // differences.
228        crate::window::reset_window_size();
229        // Messages queued against a Host that is going away would otherwise be said by
230        // the one replacing it, out of any context that made them make sense.
231        //
232        crate::message::reset_messages();
233        // The assets do not go with them. The Renderer this Host is about to talk to has
234        // an empty cache, so every registration has to be made again, which is what this
235        // does: the ids stay as they were and the bytes are queued for the first batch.
236        //
237        // Forgetting them instead is what this used to do, and it was wrong in a way that
238        // only showed on the screen. A theme names its fonts by id and a window names its
239        // icon by id, and both are built by the caller before the Host is made out of
240        // them, so the ids travelled and the bytes they named had been thrown away.
241        crate::asset::requeue_all();
242        Self {
243            app,
244            theme,
245            window: launched_window(),
246            dom: VirtualDom::new(app),
247            renderer: ComposeRenderer::new(),
248            frame_waker: Waker::from(Arc::new(FrameWake)),
249            pending_appends: Vec::new(),
250        }
251    }
252
253    pub fn rebuild(&mut self) -> Result<&[u8], ProtocolError> {
254        self.renderer.begin_frame();
255        // One record at the root, before any node exists. The Renderer resolves roles to
256        // values, so switching theme or colour scheme costs this one record rather than a
257        // SetProp for every node in the tree.
258        self.renderer.set_theme(self.theme);
259        // Beside the theme, and for the same reason: it is about the window rather than
260        // any node in it, and it is settled once rather than every frame. The Renderer
261        // reads it out of this batch before it stands the window up.
262        self.renderer.set_window(self.window);
263        self.dom.rebuild(&mut self.renderer);
264        self.flush_messages();
265        self.arm_scheduler_wake();
266        self.renderer.finish_frame()
267    }
268
269    pub fn dispatch_event(&mut self, bytes: &[u8]) -> Result<(&[u8], i64), ProtocolError> {
270        let event = decode_event(bytes)?;
271        self.dispatch(event)
272    }
273
274    /// The batch arena, reported to the Renderer on every boundary call.
275    pub fn arena(&self) -> (*const u8, usize) {
276        self.renderer.arena()
277    }
278
279    /// Answers with the whole tree, for a Renderer that no longer has a node table.
280    ///
281    /// The Host keeps no shadow of what it has already sent, so the only way to produce a
282    /// full-tree batch is to build the application again, and that resets component state.
283    /// A Renderer that keeps its node table across a configuration change never needs
284    /// this call, which is what the Android host does.
285    pub fn resync(&mut self) -> Result<(&[u8], i64), ProtocolError> {
286        *self = Self::with_theme(self.app, self.theme);
287        Ok((self.rebuild()?, 0))
288    }
289
290    /// Suppresses timers and animations while the UI is off screen, and releases the
291    /// request that arrived while it was, so nothing is lost by stopping.
292    fn set_lifecycle_running(&mut self, running: bool) -> Result<(&[u8], i64), ProtocolError> {
293        LIFECYCLE_SUPPRESSED.store(!running, Ordering::Release);
294        if running {
295            if DEFERRED_FRAME_REQUEST.swap(false, Ordering::AcqRel) {
296                (renderer_api().request_frame)();
297            }
298            FRAME_REQUESTED.store(false, Ordering::Release);
299        }
300        // An empty batch, not a frame: starting again is the Renderer's cue to draw, and
301        // it asks for that frame itself.
302        self.renderer.begin_frame();
303        Ok((self.renderer.finish_frame()?, 0))
304    }
305
306    /// Records which design system the Renderer resolved the theme to.
307    ///
308    /// The same shape as a size class arriving: it belongs to no node and no handler, and
309    /// a repeat of the same answer wakes nothing, so an application that never asks pays
310    /// a comparison once per change and nothing per frame.
311    fn publish_design_system(
312        &mut self,
313        system: crate::schema::DesignSystem,
314    ) -> Result<(&[u8], i64), ProtocolError> {
315        if crate::design::publish(system) {
316            self.renderer.begin_frame();
317            self.dom.render_immediate(&mut self.renderer);
318            self.flush_messages();
319            self.arm_scheduler_wake();
320            return Ok((self.renderer.finish_frame()?, 0));
321        }
322        self.renderer.begin_frame();
323        Ok((self.renderer.finish_frame()?, 0))
324    }
325
326    pub fn dispatch(&mut self, event: HostEvent<'_>) -> Result<(&[u8], i64), ProtocolError> {
327        // These three address the Host itself: no node, no handler, and an answer before
328        // anything is looked up.
329        match event.payload {
330            EventPayload::Resync => return self.resync(),
331            EventPayload::DesignSystemResolved(system) => {
332                return self.publish_design_system(system);
333            }
334            EventPayload::LifecycleStart => return self.set_lifecycle_running(true),
335            EventPayload::LifecycleStop => return self.set_lifecycle_running(false),
336            _ => {}
337        }
338        // The window's size belongs to no node and no handler: the Renderer measures the
339        // root content and reports it. It takes the same synchronous path as every other
340        // event, so the batch it produces is applied in the frame that asked for it.
341        if let EventPayload::WindowSizeChanged {
342            width_dp,
343            height_dp,
344            class,
345            height_class,
346        } = event.payload
347        {
348            // Node id zero is the window; anything else is one of its nodes. One event
349            // for both, because they are the same fact measured at two scales and a
350            // second way of saying it would be a second thing to keep in step.
351            let size = crate::window::WindowSize::new(width_dp, height_dp, class, height_class);
352            if event.node_id != 0 {
353                let woke = self
354                    .renderer
355                    .size_token(event.node_id)
356                    .is_some_and(|token| crate::window::publish_node(token, size));
357                if !woke {
358                    self.renderer.begin_frame();
359                    return Ok((self.renderer.finish_frame()?, 0));
360                }
361                self.renderer.begin_frame();
362                self.dom.render_immediate(&mut self.renderer);
363                self.flush_messages();
364                self.arm_scheduler_wake();
365                return Ok((self.renderer.finish_frame()?, 0));
366            }
367            crate::window::publish(size);
368            self.renderer.begin_frame();
369            self.dom.render_immediate(&mut self.renderer);
370            self.flush_messages();
371            self.arm_scheduler_wake();
372            return Ok((self.renderer.finish_frame()?, 0));
373        }
374        // The action on a transient message. It belongs to no node, because the message is
375        // not in the tree, so it is found by its handler id alone and the event carries the
376        // "no node" id the Renderer uses for anything the tree does not own.
377        let message_action = match event.payload {
378            EventPayload::Clicked => crate::message::take_action(event.handler_id),
379            _ => None,
380        };
381        if let Some(action) = message_action {
382            if event.node_id != 0 {
383                return Err(ProtocolError::InvalidValueKind(0));
384            }
385            let _dispatch_guard = EventDispatchGuard::enter();
386            action.call(());
387            self.renderer.begin_frame();
388            self.dom.render_immediate(&mut self.renderer);
389            self.flush_messages();
390            self.arm_scheduler_wake();
391            return Ok((self.renderer.finish_frame()?, 0));
392        }
393        let Some((element, node_id, name)) = self.renderer.handler(event.handler_id) else {
394            return Err(ProtocolError::InvalidValueKind(0));
395        };
396        if event.node_id != node_id {
397            return Err(ProtocolError::InvalidValueKind(0));
398        }
399        let mut key_event = None;
400        let event_data = match event.payload {
401            EventPayload::Clicked | EventPayload::FocusLost => {
402                Event::new(Rc::new(()), true).into_any()
403            }
404            EventPayload::TextChanged(text) | EventPayload::TextSubmitted(text) => {
405                Event::new(Rc::new(text.to_owned()), true).into_any()
406            }
407            EventPayload::ProtocolError { .. } => {
408                return Err(ProtocolError::InvalidValueKind(0));
409            }
410            EventPayload::KeyDown {
411                key,
412                shift_key,
413                ctrl_key,
414                alt_key,
415                meta_key,
416            } => {
417                let value = KeyEvent::new(key, shift_key, ctrl_key, alt_key, meta_key);
418                key_event = Some(value.clone());
419                Event::new(Rc::new(value), true).into_any()
420            }
421            EventPayload::FilesEntered => Event::new(Rc::new(()), true).into_any(),
422            EventPayload::FilesDropped(paths) => {
423                Event::new(Rc::new(crate::widgets::FileDrop::new(paths)), true).into_any()
424            }
425            EventPayload::RangeRequested { start, count } => {
426                Event::new(Rc::new(RangeRequest::new(start, count)), true).into_any()
427            }
428            EventPayload::ValueChanged(value) => Event::new(Rc::new(value), true).into_any(),
429            EventPayload::WindowSizeChanged { .. }
430            | EventPayload::DesignSystemResolved(_)
431            | EventPayload::Resync
432            | EventPayload::LifecycleStart
433            | EventPayload::LifecycleStop => unreachable!("handled above"),
434        };
435        let _dispatch_guard = EventDispatchGuard::enter();
436        self.dom.runtime().handle_event(name, event_data, element);
437        self.renderer.begin_frame();
438        self.dom.render_immediate(&mut self.renderer);
439        self.flush_messages();
440        self.arm_scheduler_wake();
441        let result = i64::from(key_event.is_some_and(|event| event.consumed()));
442        Ok((self.renderer.finish_frame()?, result))
443    }
444
445    pub fn render_frame(&mut self, _frame_time_nanos: u64) -> Result<&[u8], ProtocolError> {
446        FRAME_REQUESTED.store(false, Ordering::Release);
447        EVENT_DISPATCH_ACTIVE.store(false, Ordering::Release);
448        DEFERRED_FRAME_REQUEST.store(false, Ordering::Release);
449        self.renderer.begin_frame();
450        self.dom.render_immediate(&mut self.renderer);
451        self.flush_pending_appends();
452        self.flush_messages();
453        self.arm_scheduler_wake();
454        self.renderer.finish_frame()
455    }
456
457    /// Queues a streamed tail for the next frame. Tokens arriving inside one frame are
458    /// merged into a single `AppendText` record, so a token never costs a batch of its own.
459    pub fn append_text(&mut self, node_id: u32, tail: &str) {
460        match self
461            .pending_appends
462            .iter_mut()
463            .find(|pending| pending.node_id == node_id)
464        {
465            Some(pending) => {
466                pending.text.push_str(tail);
467                pending.dirty = true;
468            }
469            None => self.pending_appends.push(PendingAppend {
470                node_id,
471                text: tail.to_owned(),
472                dirty: true,
473            }),
474        }
475        // Repeated calls collapse into one frame request; see `request_frame_from_worker`.
476        request_frame_from_worker();
477    }
478
479    /// Writes whatever the tree asked to say during this call into the batch it produced.
480    ///
481    /// A message therefore arrives in the same call as the change it is about, which is
482    /// what makes "deleted" and the row disappearing one frame rather than two.
483    fn flush_messages(&mut self) {
484        let renderer = &mut self.renderer;
485        // Registrations first. Not because the Renderer needs them first, it applies the
486        // whole batch before drawing any of it, but because a batch read by a person
487        // debugging one reads in the order the screen was built.
488        crate::asset::drain(|pending| {
489            renderer.register_asset(pending.asset_id, pending.kind, pending.bytes);
490        });
491        crate::message::drain(|message| {
492            renderer.show_message(
493                message.handler_id,
494                &message.text,
495                &message.action,
496                message.duration,
497            );
498        });
499    }
500
501    fn flush_pending_appends(&mut self) {
502        for index in 0..self.pending_appends.len() {
503            let pending = &self.pending_appends[index];
504            if pending.dirty {
505                self.renderer
506                    .append_text_node(pending.node_id, &pending.text);
507            }
508        }
509        // Buffers are kept so steady-state streaming reuses their capacity and a streamed
510        // token does not allocate.
511        for pending in &mut self.pending_appends {
512            pending.text.clear();
513            pending.dirty = false;
514        }
515    }
516
517    /// Registers one asset and returns the batch that carries it.
518    ///
519    /// The bytes are copied once here and once more by the Renderer into its cache. After
520    /// that the id is all that travels, so drawing the same image every frame costs a
521    /// fixed-layout property record and nothing else.
522    pub fn register_asset(
523        &mut self,
524        asset_id: u32,
525        kind: AssetKind,
526        bytes: &[u8],
527    ) -> Result<&[u8], ProtocolError> {
528        self.renderer.begin_frame();
529        self.renderer.register_asset(asset_id, kind, bytes);
530        self.renderer.finish_frame()
531    }
532
533    /// Registers an icon by the meaning it carries. The Renderer holds the artwork for
534    /// every design system, so what crosses is the role and not a picture or a name.
535    pub fn register_icon(&mut self, asset_id: u32, role: IconRole) -> Result<&[u8], ProtocolError> {
536        self.register_asset(
537            asset_id,
538            AssetKind::VectorIcon,
539            &(role as u16).to_le_bytes(),
540        )
541    }
542
543    /// Drops the asset from the Renderer's cache. Using the id afterwards is a reported
544    /// protocol error.
545    pub fn release_asset(&mut self, asset_id: u32) -> Result<&[u8], ProtocolError> {
546        self.renderer.begin_frame();
547        self.renderer.release_asset(asset_id);
548        self.renderer.finish_frame()
549    }
550
551    pub fn set_text(
552        &mut self,
553        node_id: u32,
554        text: &str,
555        selection: Option<Selection>,
556    ) -> Result<&[u8], ProtocolError> {
557        self.renderer.begin_frame();
558        self.renderer.set_text_node(node_id, text, selection);
559        self.renderer.finish_frame()
560    }
561
562    pub fn handler_target(&self, handler_id: u64) -> Option<ElementId> {
563        self.renderer.handler(handler_id).map(|value| value.0)
564    }
565
566    fn arm_scheduler_wake(&mut self) {
567        let mut context = Context::from_waker(&self.frame_waker);
568        let future = self.dom.wait_for_work();
569        let mut future = pin!(future);
570        if future.as_mut().poll(&mut context) == Poll::Ready(()) {
571            request_frame_from_worker();
572        }
573    }
574}
575
576/// Holds the thread's `Host` and, crucially, keeps it out of thread-local teardown.
577///
578/// Dropping a `VirtualDom` reaches back into the Dioxus runtime's own
579/// thread-locals. Thread-local destruction order is unspecified, so if the UI thread
580/// ends without `dioxus_compose_host_shutdown`, that drop can run after the Dioxus
581/// locals are already gone and panic with "cannot access a TLS value during or after
582/// destruction". A panic in a destructor is non-unwinding: it aborts the process, which
583/// is exactly what must not happen: a protocol or teardown fault has to stay recoverable.
584///
585/// So the slot empties itself and leaks the `Host` when the thread is tearing down. An
586/// orderly `shutdown` still drops it properly; only the unorderly path leaks, and that
587/// path is a thread ending anyway.
588struct HostSlot(RefCell<Option<Host>>);
589
590impl Drop for HostSlot {
591    fn drop(&mut self) {
592        std::mem::forget(self.0.borrow_mut().take());
593    }
594}
595
596impl std::ops::Deref for HostSlot {
597    type Target = RefCell<Option<Host>>;
598
599    fn deref(&self) -> &Self::Target {
600        &self.0
601    }
602}
603
604/// The launched application.
605///
606/// The `VirtualDom` and every `dioxus_compose_host_*` call run on the Renderer UI
607/// thread, and that thread is not the one that called `launch`: with `LoopMode::Renderer`
608/// the Rust main thread blocks inside `dioxus_compose_renderer_run` while Compose composes
609/// on the toolkit's own thread. So the app function, unlike the `Host` it builds, has to be
610/// reachable across threads. A `fn() -> Element` is a plain function pointer, so sharing it
611/// costs nothing and adds no thread affinity.
612///
613/// `shutdown` deliberately leaves this set: it belongs to `launch`, not to one UI thread's
614/// `Host`. That is also what `LoopMode::Platform` needs, where the Renderer may tear the
615/// Host down and initialize it again (Android recreates its surface on a configuration
616/// change) without relaunching.
617static APP: Mutex<Option<fn() -> Element>> = Mutex::new(None);
618
619/// Chosen by `LaunchBuilder::with_theme`, read once when the Host is built.
620static THEME: Mutex<Theme> = Mutex::new(Theme::unified(crate::schema::DesignSystem::Material3));
621
622/// Chosen by `LaunchBuilder::with_window`, read once when the Host is built.
623static WINDOW: Mutex<crate::schema::Window> = Mutex::new(crate::schema::Window::new());
624
625thread_local! {
626    static HOST: HostSlot = const { HostSlot(RefCell::new(None)) };
627}
628
629fn launched_app() -> Option<fn() -> Element> {
630    APP.lock().map_or(None, |app| *app)
631}
632
633fn launched_window() -> crate::schema::Window {
634    WINDOW
635        .lock()
636        .map_or_else(|error| *error.into_inner(), |window| *window)
637}
638
639fn launched_theme() -> Theme {
640    THEME
641        .lock()
642        .map_or_else(|error| *error.into_inner(), |theme| *theme)
643}
644
645#[derive(Clone, Copy, Debug)]
646pub struct LaunchBuilder {
647    mode: LoopMode,
648    theme: Theme,
649    window: crate::schema::Window,
650}
651
652impl Default for LaunchBuilder {
653    fn default() -> Self {
654        Self {
655            mode: LoopMode::Renderer,
656            theme: Theme::default(),
657            window: crate::schema::Window::new(),
658        }
659    }
660}
661
662impl LaunchBuilder {
663    pub fn new() -> Self {
664        Self::default()
665    }
666
667    pub fn with_mode(mut self, mode: LoopMode) -> Self {
668        self.mode = mode;
669        self
670    }
671
672    /// `Theme::unified` for one design system everywhere, `Theme::adaptive` to follow the
673    /// host platform. Not calling this follows the host platform, falling back to
674    /// Material 3.
675    pub fn with_theme(mut self, theme: Theme) -> Self {
676        self.theme = theme;
677        self
678    }
679
680    /// What the application asks of its own window: its size, and whether it wears the
681    /// platform's title bar or has content run into it.
682    ///
683    /// Not calling this gets a modern window that the Renderer sizes. The window belongs
684    /// to the Renderer and most of what it looks like is the design system's, so what can
685    /// be said here is short on purpose: nothing names a colour, a corner, or where the
686    /// window buttons go.
687    pub fn with_window(mut self, window: crate::schema::Window) -> Self {
688        self.window = window;
689        self
690    }
691
692    /// Runs the application. Does not return while it is running, and ends the process
693    /// with a failing status if the renderer loop could not run at all.
694    ///
695    /// Exiting rather than returning is the point. `launch` is the last statement of
696    /// `main` in every application that uses this crate, so returning from it means `main`
697    /// returns, which means the process exits 0. A window that never opened would then be
698    /// indistinguishable from a program that did its work and stopped.
699    pub fn launch(self, app: fn() -> Element) {
700        let status = self.try_launch(app);
701        if status != STATUS_OK {
702            std::process::exit(EXIT_FAILURE);
703        }
704    }
705
706    /// [`LaunchBuilder::launch`] without the exit: the status the renderer loop ended
707    /// with, handed back for a caller that has its own idea of what to do with it.
708    pub fn try_launch(self, app: fn() -> Element) -> i32 {
709        if let Ok(mut slot) = APP.lock() {
710            *slot = Some(app);
711        }
712        if let Ok(mut slot) = THEME.lock() {
713            *slot = self.theme;
714        }
715        if let Ok(mut slot) = WINDOW.lock() {
716            *slot = self.window;
717        }
718        // Under `LoopMode::Platform` the platform owns the loop and calls in through the
719        // boundary when it is ready. There is nothing to run and nothing to fail.
720        if self.mode != LoopMode::Renderer {
721            return STATUS_OK;
722        }
723        (renderer_api().run)()
724    }
725}
726
727pub fn launch(app: fn() -> Element) {
728    LaunchBuilder::new().launch(app);
729}
730
731fn parse_handshake(bytes: &[u8]) -> Result<LoopMode, ProtocolError> {
732    if bytes.len() < 12 {
733        return Err(ProtocolError::Truncated);
734    }
735    let hash = u64::from_le_bytes(
736        bytes[0..8]
737            .try_into()
738            .map_err(|_| ProtocolError::Truncated)?,
739    );
740    let version = u16::from_le_bytes(
741        bytes[8..10]
742            .try_into()
743            .map_err(|_| ProtocolError::Truncated)?,
744    );
745    if hash != SCHEMA_HASH || version != PROTOCOL_VERSION {
746        // Say what did not match, and say it here rather than leaving the status code to
747        // carry it. A mismatch means the two halves were generated from different
748        // versions of the schema, which on a desktop build means a renderer library
749        // compiled before the last codegen run. The window still opens, because the
750        // renderer stands it up before it asks, so the only thing on screen is an empty
751        // page in the default theme: a symptom that looks like a blank application rather
752        // than like a stale build, and one that cost a morning to read the first time.
753        eprintln!(
754            "dioxus-compose: the renderer was built from a different schema than this              program. It sent hash {hash:#x} version {version}, and this build expects              hash {SCHEMA_HASH:#x} version {PROTOCOL_VERSION}. Rebuild the renderer after              running codegen; if it was already rebuilt, its build directory is holding a              cached copy of the generated protocol and has to be cleared."
755        );
756        return Err(ProtocolError::InvalidEnvelope);
757    }
758    match bytes[10] {
759        0 => Ok(LoopMode::Renderer),
760        1 => Ok(LoopMode::Platform),
761        other => Err(ProtocolError::InvalidValueKind(u16::from(other))),
762    }
763}
764
765unsafe fn input_slice<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], ProtocolError> {
766    if len == 0 {
767        return Ok(&[]);
768    }
769    if ptr.is_null() {
770        return Err(ProtocolError::Truncated);
771    }
772    // SAFETY: The C caller promises a readable buffer of `len` bytes for this call.
773    Ok(unsafe { std::slice::from_raw_parts(ptr, len as usize) })
774}
775
776unsafe fn write_batch(
777    out: *mut MutationBatch,
778    bytes: &[u8],
779    result: i64,
780) -> Result<(), ProtocolError> {
781    if out.is_null() {
782        return Err(ProtocolError::Truncated);
783    }
784    let len = u32::try_from(bytes.len()).map_err(|_| ProtocolError::LengthOverflow)?;
785    // SAFETY: Null was rejected and the C caller promises writable storage.
786    unsafe {
787        out.write(MutationBatch {
788            ptr: bytes.as_ptr(),
789            len,
790            result,
791        });
792    }
793    Ok(())
794}
795
796/// A call-order failure is not a malformed message: the boundary declares distinct statuses
797/// so the Renderer can tell "you called me too early" from "your bytes were wrong". The
798/// closures below therefore yield a status directly.
799fn ffi_status(operation: impl FnOnce() -> Result<(), i32>) -> i32 {
800    // There is no channel for the Host to raise a ProtocolError event back to the Renderer:
801    // the boundary is a synchronous call that returns a status, and events only travel
802    // Renderer to Host. Malformed calls therefore return STATUS_PROTOCOL_ERROR.
803    match catch_unwind(AssertUnwindSafe(operation)) {
804        Ok(Ok(())) => STATUS_OK,
805        Ok(Err(status)) => status,
806        Err(_) => STATUS_PANIC,
807    }
808}
809
810/// Every `ProtocolError` leaving an export is reported as one status code.
811fn protocol_error(_error: ProtocolError) -> i32 {
812    STATUS_PROTOCOL_ERROR
813}
814
815#[unsafe(no_mangle)]
816/// Initializes the UI-thread Host from the handshake and returns the initial batch.
817///
818/// # Safety
819/// `handshake` must address `len` readable bytes and `out` must be writable.
820pub unsafe extern "C" fn dioxus_compose_host_init(
821    handshake: *const u8,
822    len: u32,
823    out: *mut MutationBatch,
824) -> i32 {
825    ffi_status(|| {
826        // SAFETY: Validated according to the function's C ABI contract.
827        let bytes = unsafe { input_slice(handshake, len) }.map_err(protocol_error)?;
828        let _mode = parse_handshake(bytes).map_err(protocol_error)?;
829        let app = launched_app().ok_or(STATUS_NOT_INITIALIZED)?;
830        HOST.with(|slot| {
831            let mut host_slot = slot.borrow_mut();
832            if host_slot.is_some() {
833                return Err(STATUS_ALREADY_INITIALIZED);
834            }
835            let mut host = Host::new(app);
836            let batch = host.rebuild().map_err(protocol_error)?;
837            // SAFETY: `out` is checked before writing.
838            unsafe { write_batch(out, batch, 0) }.map_err(protocol_error)?;
839            *host_slot = Some(host);
840            Ok(())
841        })
842    })
843}
844
845#[unsafe(no_mangle)]
846/// Dispatches one encoded event synchronously and returns its diff batch.
847///
848/// # Safety
849/// `event` must address `len` readable bytes and `out` must be writable.
850pub unsafe extern "C" fn dioxus_compose_host_dispatch_event(
851    event: *const u8,
852    len: u32,
853    out: *mut MutationBatch,
854) -> i32 {
855    ffi_status(|| {
856        // SAFETY: Validated according to the function's C ABI contract.
857        let bytes = unsafe { input_slice(event, len) }.map_err(protocol_error)?;
858        HOST.with(|slot| {
859            let mut host_slot = slot.borrow_mut();
860            let host = host_slot.as_mut().ok_or(STATUS_NOT_INITIALIZED)?;
861            let (batch, result) = host.dispatch_event(bytes).map_err(protocol_error)?;
862            // SAFETY: `out` is checked before writing.
863            unsafe { write_batch(out, batch, result) }.map_err(protocol_error)
864        })
865    })
866}
867
868#[unsafe(no_mangle)]
869/// Renders work scheduled before the current platform frame.
870///
871/// # Safety
872/// `out` must point to writable storage for one [`MutationBatch`].
873pub unsafe extern "C" fn dioxus_compose_host_render_frame(
874    frame_time_nanos: u64,
875    out: *mut MutationBatch,
876) -> i32 {
877    ffi_status(|| {
878        HOST.with(|slot| {
879            let mut host_slot = slot.borrow_mut();
880            let host = host_slot.as_mut().ok_or(STATUS_NOT_INITIALIZED)?;
881            let batch = host
882                .render_frame(frame_time_nanos)
883                .map_err(protocol_error)?;
884            // SAFETY: `out` is checked before writing.
885            unsafe { write_batch(out, batch, 0) }.map_err(protocol_error)
886        })
887    })
888}
889
890#[unsafe(no_mangle)]
891/// Releases a batch after the Renderer has applied it on the same call stack.
892///
893/// # Safety
894/// `batch` must be null or point to writable storage for one [`MutationBatch`].
895pub unsafe extern "C" fn dioxus_compose_host_release_batch(batch: *mut MutationBatch) {
896    let _ = catch_unwind(AssertUnwindSafe(|| {
897        if !batch.is_null() {
898            // SAFETY: The caller supplied its own writable MutationBatch.
899            unsafe { batch.write(MutationBatch::default()) };
900        }
901    }));
902}
903
904/// The UI thread's batch arena.
905///
906/// Not a boundary entry point: it is crate-internal, and the generated Android shims use
907/// it to tell the Renderer where the arena is so it can map it once instead of per call.
908pub fn current_arena() -> (*const u8, usize) {
909    HOST.with(|slot| {
910        slot.borrow()
911            .as_ref()
912            .map_or((std::ptr::null(), 0), Host::arena)
913    })
914}
915
916#[unsafe(no_mangle)]
917pub extern "C" fn dioxus_compose_host_shutdown() {
918    let _ = catch_unwind(AssertUnwindSafe(|| {
919        HOST.with(|slot| *slot.borrow_mut() = None);
920        FRAME_REQUESTED.store(false, Ordering::Release);
921    }));
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use crate::prelude::*;
928    use crate::protocol::{Mutation, PropertyValue, decode_batch};
929    use crate::schema::PropertyKind;
930    use std::collections::HashMap;
931    use std::sync::atomic::{AtomicUsize, Ordering};
932
933    static CLICKS: AtomicUsize = AtomicUsize::new(0);
934
935    /// `APP` is process-global, as `launch` is, so two tests that launch different apps at
936    /// the same time would each see the other's. Cargo runs tests on one thread each, so
937    /// the ones that launch take this first. Poisoning is ignored: a failing test has
938    /// already reported itself, and the rest still need the lock.
939    static LAUNCH: Mutex<()> = Mutex::new(());
940
941    fn launch_guard() -> std::sync::MutexGuard<'static, ()> {
942        LAUNCH
943            .lock()
944            .unwrap_or_else(|poisoned| poisoned.into_inner())
945    }
946
947    fn app() -> Element {
948        let mut count = use_signal(|| 0_i64);
949        rsx! {
950            Column {
951                Text { text: count().to_string() }
952                TextField { placeholder: "Message" }
953                Button {
954                    text: "Increment",
955                    on_click: move |_| {
956                        CLICKS.fetch_add(1, Ordering::SeqCst);
957                        *count.write() += 1;
958                    }
959                }
960            }
961        }
962    }
963
964    fn key_app() -> Element {
965        rsx! {
966            Column {
967                TextField {
968                    multiline: true,
969                    on_key_down: move |event: KeyEvent| {
970                        if event.key() == Key::Enter && !event.shift_key() {
971                            event.consume();
972                        }
973                    }
974                }
975                TextField {
976                    on_key_down: move |_event: KeyEvent| {}
977                }
978            }
979        }
980    }
981
982    #[derive(Default)]
983    struct MockRenderer {
984        widgets: HashMap<u32, crate::WidgetKind>,
985        parents: HashMap<u32, u32>,
986    }
987
988    impl MockRenderer {
989        fn apply(&mut self, batch: &[Mutation<'_>]) {
990            for mutation in batch {
991                match mutation {
992                    Mutation::Create { node_id, widget } => {
993                        self.widgets.insert(*node_id, *widget);
994                    }
995                    Mutation::Insert {
996                        parent_id, node_id, ..
997                    }
998                    | Mutation::Move {
999                        parent_id, node_id, ..
1000                    } => {
1001                        self.parents.insert(*node_id, *parent_id);
1002                    }
1003                    Mutation::Remove { node_id } => {
1004                        self.widgets.remove(node_id);
1005                        self.parents.remove(node_id);
1006                    }
1007                    Mutation::SetProp { .. }
1008                    | Mutation::SetModifier { .. }
1009                    | Mutation::SetText { .. }
1010                    | Mutation::AppendText { .. }
1011                    | Mutation::RegisterAsset { .. }
1012                    | Mutation::ReleaseAsset { .. }
1013                    | Mutation::ShowMessage { .. }
1014                    | Mutation::SetTheme(_)
1015                    | Mutation::SetWindow(_) => {}
1016                }
1017            }
1018        }
1019    }
1020
1021    #[test]
1022    fn click_runs_once_and_emits_only_text_set_prop() {
1023        CLICKS.store(0, Ordering::SeqCst);
1024        let mut host = Host::new(app);
1025        let initial = decode_batch(host.rebuild().unwrap()).unwrap();
1026        let mut mock = MockRenderer::default();
1027        mock.apply(&initial);
1028        assert_eq!(mock.widgets.len(), 4);
1029        assert_eq!(mock.parents.len(), 3);
1030
1031        let (button_node, handler_id) = initial
1032            .iter()
1033            .find_map(|mutation| match mutation {
1034                Mutation::SetProp {
1035                    node_id,
1036                    property: PropertyKind::OnClick,
1037                    value: PropertyValue::Integer(handler),
1038                } => Some((*node_id, *handler as u64)),
1039                _ => None,
1040            })
1041            .unwrap();
1042        drop(initial);
1043
1044        let event = HostEvent {
1045            node_id: button_node,
1046            handler_id,
1047            payload: EventPayload::Clicked,
1048        };
1049        let (batch, result) = host.dispatch(event).unwrap();
1050        let mutations = decode_batch(batch).unwrap();
1051        assert_eq!(CLICKS.load(Ordering::SeqCst), 1);
1052        assert_eq!(result, 0);
1053        assert_eq!(mutations.len(), 1);
1054        assert!(matches!(
1055            &mutations[0],
1056            Mutation::SetProp {
1057                property: PropertyKind::Text,
1058                value: PropertyValue::String(value),
1059                ..
1060            } if *value == "1"
1061        ));
1062    }
1063
1064    #[test]
1065    fn malformed_ffi_input_returns_protocol_error() {
1066        let mut output = MutationBatch::default();
1067        // SAFETY: The test passes a valid output pointer and intentionally null input.
1068        let status =
1069            unsafe { dioxus_compose_host_dispatch_event(std::ptr::null(), 4, &mut output) };
1070        assert_eq!(status, STATUS_PROTOCOL_ERROR);
1071    }
1072
1073    #[test]
1074    fn fr12_key_consumption_is_returned_and_does_not_leak() {
1075        let _launch = launch_guard();
1076        LaunchBuilder::new()
1077            .with_mode(LoopMode::Platform)
1078            .launch(key_app);
1079        let mut handshake = Vec::with_capacity(12);
1080        handshake.extend_from_slice(&SCHEMA_HASH.to_le_bytes());
1081        handshake.extend_from_slice(&PROTOCOL_VERSION.to_le_bytes());
1082        handshake.extend_from_slice(&[LoopMode::Platform as u8, 0]);
1083        let mut output = MutationBatch::default();
1084        // SAFETY: All pointers refer to live test-owned buffers for the duration of each call.
1085        unsafe {
1086            assert_eq!(
1087                dioxus_compose_host_init(handshake.as_ptr(), handshake.len() as u32, &mut output,),
1088                STATUS_OK
1089            );
1090        }
1091        // SAFETY: A successful init returned a readable batch owned by the Host.
1092        let initial = unsafe { std::slice::from_raw_parts(output.ptr, output.len as usize) };
1093        let handlers: Vec<_> = decode_batch(initial)
1094            .unwrap()
1095            .into_iter()
1096            .filter_map(|mutation| match mutation {
1097                Mutation::SetProp {
1098                    node_id,
1099                    property: PropertyKind::OnKeyDown,
1100                    value: PropertyValue::Integer(handler),
1101                } => Some((node_id, handler as u64)),
1102                _ => None,
1103            })
1104            .collect();
1105        assert_eq!(handlers.len(), 2);
1106
1107        let dispatch = |node_id, handler_id, shift_key, output: &mut MutationBatch| {
1108            let event = HostEvent {
1109                node_id,
1110                handler_id,
1111                payload: EventPayload::KeyDown {
1112                    key: Key::Enter,
1113                    shift_key,
1114                    ctrl_key: false,
1115                    alt_key: false,
1116                    meta_key: false,
1117                },
1118            };
1119            let mut bytes = Vec::new();
1120            crate::protocol::encode_event(&event, &mut bytes).unwrap();
1121            // SAFETY: The encoded event and output storage remain live for the call.
1122            unsafe {
1123                assert_eq!(
1124                    dioxus_compose_host_dispatch_event(bytes.as_ptr(), bytes.len() as u32, output,),
1125                    STATUS_OK
1126                );
1127            }
1128        };
1129
1130        dispatch(handlers[0].0, handlers[0].1, false, &mut output);
1131        assert_ne!(output.result, 0);
1132        dispatch(handlers[0].0, handlers[0].1, true, &mut output);
1133        assert_eq!(output.result, 0);
1134        dispatch(handlers[1].0, handlers[1].1, false, &mut output);
1135        assert_eq!(output.result, 0);
1136        dispatch(handlers[0].0, handlers[0].1, false, &mut output);
1137        assert_ne!(output.result, 0);
1138        dioxus_compose_host_shutdown();
1139    }
1140
1141    /// `launch` runs on the Rust main thread, but the Renderer UI thread that calls
1142    /// `dioxus_compose_host_init` is a different thread (on macOS, AWT's event thread inside
1143    /// the isolate). The app the application launched must be reachable from there.
1144    #[test]
1145    fn pr3_init_runs_on_a_different_thread_than_launch() {
1146        let _launch = launch_guard();
1147        LaunchBuilder::new()
1148            .with_mode(LoopMode::Platform)
1149            .launch(app);
1150        let mut handshake = Vec::with_capacity(12);
1151        handshake.extend_from_slice(&SCHEMA_HASH.to_le_bytes());
1152        handshake.extend_from_slice(&PROTOCOL_VERSION.to_le_bytes());
1153        handshake.extend_from_slice(&[LoopMode::Platform as u8, 0]);
1154        std::thread::spawn(move || {
1155            let mut output = MutationBatch::default();
1156            // SAFETY: Both buffers are live test-owned storage for the duration of the call.
1157            let status = unsafe {
1158                dioxus_compose_host_init(handshake.as_ptr(), handshake.len() as u32, &mut output)
1159            };
1160            assert_eq!(status, STATUS_OK, "init must work off the launch thread");
1161            assert!(output.len > 0, "init returns the initial tree batch");
1162            dioxus_compose_host_shutdown();
1163        })
1164        .join()
1165        .unwrap();
1166    }
1167
1168    /// The first record of the initial batch carries the theme. Saying nothing follows the
1169    /// host platform with a Material 3 fallback; `unified` is never implicit.
1170    #[test]
1171    fn fr14_initial_batch_opens_with_the_launched_theme() {
1172        let _launch = launch_guard();
1173        let first_record = |builder: LaunchBuilder| {
1174            builder.with_mode(LoopMode::Platform).launch(app);
1175            let mut handshake = Vec::with_capacity(12);
1176            handshake.extend_from_slice(&SCHEMA_HASH.to_le_bytes());
1177            handshake.extend_from_slice(&PROTOCOL_VERSION.to_le_bytes());
1178            handshake.extend_from_slice(&[LoopMode::Platform as u8, 0]);
1179            std::thread::spawn(move || {
1180                let mut output = MutationBatch::default();
1181                // SAFETY: Both buffers are live test-owned storage for this call.
1182                let status = unsafe {
1183                    dioxus_compose_host_init(
1184                        handshake.as_ptr(),
1185                        handshake.len() as u32,
1186                        &mut output,
1187                    )
1188                };
1189                assert_eq!(status, STATUS_OK);
1190                // SAFETY: A successful init returned a readable batch owned by the Host.
1191                let batch = unsafe { std::slice::from_raw_parts(output.ptr, output.len as usize) };
1192                let first = decode_batch(batch).unwrap().into_iter().next().unwrap();
1193                dioxus_compose_host_shutdown();
1194                first
1195            })
1196            .join()
1197            .unwrap()
1198        };
1199
1200        // Saying nothing follows the host platform, falling back to Material 3.
1201        assert_eq!(
1202            first_record(LaunchBuilder::new()),
1203            Mutation::SetTheme(Theme::adaptive(DesignSystem::Material3))
1204        );
1205        assert_eq!(
1206            first_record(LaunchBuilder::new().with_theme(Theme::unified(DesignSystem::Fluent))),
1207            Mutation::SetTheme(Theme {
1208                design_system: DesignSystem::Fluent,
1209                fallback: DesignSystem::Fluent,
1210                color_scheme: ColorScheme::FollowSystem,
1211                adaptive: false,
1212                fonts: [0; crate::schema::TYPE_ROLE_COUNT],
1213            })
1214        );
1215        assert_eq!(
1216            first_record(LaunchBuilder::new().with_theme(Theme::adaptive(DesignSystem::Cupertino))),
1217            Mutation::SetTheme(Theme {
1218                design_system: DesignSystem::Cupertino,
1219                fallback: DesignSystem::Cupertino,
1220                color_scheme: ColorScheme::FollowSystem,
1221                adaptive: true,
1222                fonts: [0; crate::schema::TYPE_ROLE_COUNT],
1223            })
1224        );
1225    }
1226
1227    /// The theme is sent once, not once per frame: resending it would put a record in every
1228    /// batch for a value that almost never changes.
1229    #[test]
1230    fn fr14_set_theme_is_not_resent_every_frame() {
1231        let mut host = Host::new(app);
1232        let initial = decode_batch(host.rebuild().unwrap()).unwrap();
1233        assert!(matches!(initial.first(), Some(Mutation::SetTheme(_))));
1234        let frame = decode_batch(host.render_frame(0).unwrap()).unwrap();
1235        assert!(
1236            !frame
1237                .iter()
1238                .any(|mutation| matches!(mutation, Mutation::SetTheme(_)))
1239        );
1240    }
1241
1242    #[test]
1243    fn all_exports_isolate_invalid_calls() {
1244        let _launch = launch_guard();
1245        let mut output = MutationBatch::default();
1246        // SAFETY: Each call uses null or valid test-owned pointers as documented.
1247        unsafe {
1248            assert_eq!(
1249                dioxus_compose_host_init(std::ptr::null(), 1, &mut output),
1250                STATUS_PROTOCOL_ERROR
1251            );
1252            // No handshake has run on this thread, so this is a call-order error.
1253            assert_eq!(
1254                dioxus_compose_host_render_frame(0, &mut output),
1255                STATUS_NOT_INITIALIZED
1256            );
1257            dioxus_compose_host_release_batch(std::ptr::null_mut());
1258        }
1259        dioxus_compose_host_shutdown();
1260    }
1261}
1262
1263/// The design system a demonstration should start in.
1264///
1265/// An application picks its own theme and never needs this. The samples do, because the
1266/// point of a sample is to show what one declaration looks like under each system, and on
1267/// any given machine the adaptive default can only ever show you one of them. Material 3
1268/// and Fluent went unseen for weeks for exactly that reason: everything was checked on a
1269/// Mac, so everything was Cupertino.
1270///
1271/// `DXC_DESIGN` names the system. Anything else, including nothing, adapts to the host.
1272/// `DXC_SCHEME` names `light` or `dark`; anything else leaves the reader's own setting
1273/// alone.
1274pub fn demo_theme() -> Theme {
1275    demo_theme_for(Theme::adaptive(crate::schema::DesignSystem::Material3))
1276}
1277
1278/// The same, for a demonstration that has a theme of its own to start from.
1279///
1280/// A sample whose design is one particular design system in one particular colour scheme
1281/// says so, and that declaration is the sample. But the machine it is being looked at on
1282/// can only draw one of the seven at a time, so without a way to point it somewhere else
1283/// the other six are never seen. This keeps the sample's own theme as the answer and lets
1284/// the two variables override the part they name, so what is on screen is either the
1285/// design the sample chose or exactly the one that was asked for.
1286pub fn demo_theme_for(theme: Theme) -> Theme {
1287    use crate::schema::DesignSystem;
1288    let theme = match std::env::var("DXC_DESIGN").as_deref().map(str::trim) {
1289        Ok("material3") => Theme::unified(DesignSystem::Material3),
1290        Ok("cupertino") => Theme::unified(DesignSystem::Cupertino),
1291        Ok("fluent") => Theme::unified(DesignSystem::Fluent),
1292        Ok("gnome") => Theme::unified(DesignSystem::Gnome),
1293        Ok("breeze") => Theme::unified(DesignSystem::Breeze),
1294        Ok("deepin") => Theme::unified(DesignSystem::Deepin),
1295        // Spelled both ways, because the name is two words everywhere it is written down
1296        // and nobody remembers which one a shell variable wants.
1297        Ok("liquidglass") | Ok("liquid-glass") => Theme::unified(DesignSystem::LiquidGlass),
1298        // Naming a system replaces the sample's design system but keeps its colour
1299        // scheme, so asking to see one design does not also change how light it is.
1300        _ => return with_scheme_override(theme),
1301    }
1302    .with_color_scheme(theme.color_scheme);
1303    with_scheme_override(theme)
1304}
1305
1306/// Applies `DXC_SCHEME` if it names one of the two schemes.
1307fn with_scheme_override(theme: Theme) -> Theme {
1308    match std::env::var("DXC_SCHEME").as_deref().map(str::trim) {
1309        Ok("light") => theme.with_color_scheme(crate::schema::ColorScheme::Light),
1310        Ok("dark") => theme.with_color_scheme(crate::schema::ColorScheme::Dark),
1311        _ => theme,
1312    }
1313}
1314
1315#[cfg(test)]
1316mod demo_theme_tests {
1317    use super::*;
1318    use crate::schema::{ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem};
1319    use std::sync::{Mutex, MutexGuard};
1320
1321    /// Held for the length of any test that sets one of the variables.
1322    ///
1323    /// The variables belong to the process, not to the test, so two of these running at
1324    /// once read each other's settings and fail on a value neither of them asked for.
1325    static ENVIRONMENT: Mutex<()> = Mutex::new(());
1326
1327    /// Clears both variables and keeps everyone else out until the guard is dropped.
1328    fn exclusive_environment() -> MutexGuard<'static, ()> {
1329        let guard = ENVIRONMENT
1330            .lock()
1331            .unwrap_or_else(|error| error.into_inner());
1332        // SAFETY: the lock makes this the only thread touching the environment, and the
1333        // variables are read only by the functions called under the same lock.
1334        unsafe {
1335            std::env::remove_var("DXC_DESIGN");
1336            std::env::remove_var("DXC_SCHEME");
1337        }
1338        guard
1339    }
1340
1341    /// Named for what it defends: a sample that cannot be pointed at a design system
1342    /// leaves five of the six unseen on any one machine.
1343    #[test]
1344    fn fr14_a_named_design_system_is_unified_and_anything_else_adapts() {
1345        let _environment = exclusive_environment();
1346        // SAFETY: `exclusive_environment` holds the lock, so nothing else is reading or
1347        // writing these while this runs.
1348        for (name, system) in [
1349            ("material3", DesignSystem::Material3),
1350            ("cupertino", DesignSystem::Cupertino),
1351            ("fluent", DesignSystem::Fluent),
1352            ("gnome", DesignSystem::Gnome),
1353            ("breeze", DesignSystem::Breeze),
1354            ("deepin", DesignSystem::Deepin),
1355            ("liquidglass", DesignSystem::LiquidGlass),
1356            ("liquid-glass", DesignSystem::LiquidGlass),
1357        ] {
1358            unsafe { std::env::set_var("DXC_DESIGN", name) };
1359            assert_eq!(
1360                demo_theme(),
1361                Theme::unified(system),
1362                "{name} is not selectable"
1363            );
1364        }
1365        assert_eq!(
1366            DESIGN_SYSTEM_SCHEMA.len(),
1367            7,
1368            "a system nobody can select goes unseen"
1369        );
1370
1371        unsafe { std::env::set_var("DXC_DESIGN", "nonsense") };
1372        assert_eq!(demo_theme(), Theme::adaptive(DesignSystem::Material3));
1373
1374        unsafe { std::env::remove_var("DXC_DESIGN") };
1375        assert_eq!(demo_theme(), Theme::adaptive(DesignSystem::Material3));
1376    }
1377
1378    /// Named for what it defends: a machine set to dark draws every sample dark, so a
1379    /// design that is meant to be read light cannot be looked at at all without this.
1380    #[test]
1381    fn fr14_a_named_colour_scheme_is_pinned_and_anything_else_is_left_alone() {
1382        let _environment = exclusive_environment();
1383        // SAFETY: as above.
1384        for (name, scheme) in [("light", ColorScheme::Light), ("dark", ColorScheme::Dark)] {
1385            unsafe { std::env::set_var("DXC_SCHEME", name) };
1386            assert_eq!(
1387                demo_theme().color_scheme,
1388                scheme,
1389                "{name} is not selectable"
1390            );
1391        }
1392
1393        unsafe { std::env::set_var("DXC_SCHEME", "nonsense") };
1394        assert_eq!(demo_theme().color_scheme, ColorScheme::FollowSystem);
1395
1396        unsafe { std::env::remove_var("DXC_SCHEME") };
1397        assert_eq!(demo_theme().color_scheme, ColorScheme::FollowSystem);
1398    }
1399
1400    /// A sample that names its own theme keeps it, and each variable overrides only the
1401    /// half it names. Asking to see Liquid Glass must not also throw away the colour
1402    /// scheme the design was drawn for.
1403    #[test]
1404    fn fr14_a_samples_own_theme_survives_everything_the_variables_do_not_name() {
1405        let _environment = exclusive_environment();
1406        let sample = Theme::unified(DesignSystem::Cupertino).with_color_scheme(ColorScheme::Light);
1407        assert_eq!(demo_theme_for(sample), sample);
1408
1409        unsafe { std::env::set_var("DXC_DESIGN", "liquidglass") };
1410        assert_eq!(
1411            demo_theme_for(sample),
1412            Theme::unified(DesignSystem::LiquidGlass).with_color_scheme(ColorScheme::Light)
1413        );
1414
1415        unsafe { std::env::set_var("DXC_SCHEME", "dark") };
1416        assert_eq!(
1417            demo_theme_for(sample),
1418            Theme::unified(DesignSystem::LiquidGlass).with_color_scheme(ColorScheme::Dark)
1419        );
1420
1421        unsafe {
1422            std::env::remove_var("DXC_DESIGN");
1423            std::env::remove_var("DXC_SCHEME");
1424        };
1425    }
1426    /// A renderer built from a different schema is refused, and says so.
1427    ///
1428    /// The refusal already worked. What did not was finding out why: the renderer stands
1429    /// its window up before it asks the Host anything, so a mismatch leaves an empty page
1430    /// in the default theme and a status code, which reads as an application that draws
1431    /// nothing rather than as a build that is out of date. It cost a morning once.
1432    #[test]
1433    fn pr2_a_handshake_from_another_schema_is_refused() {
1434        let mut wrong = Vec::with_capacity(12);
1435        wrong.extend_from_slice(&SCHEMA_HASH.wrapping_add(1).to_le_bytes());
1436        wrong.extend_from_slice(&PROTOCOL_VERSION.to_le_bytes());
1437        wrong.extend_from_slice(&[LoopMode::Renderer as u8, 0]);
1438        assert!(
1439            matches!(
1440                parse_handshake(&wrong),
1441                Err(crate::protocol::ProtocolError::InvalidEnvelope)
1442            ),
1443            "a handshake carrying another schema's hash was accepted, so the two halves \
1444             would go on to disagree about what every record means"
1445        );
1446
1447        let mut right = Vec::with_capacity(12);
1448        right.extend_from_slice(&SCHEMA_HASH.to_le_bytes());
1449        right.extend_from_slice(&PROTOCOL_VERSION.to_le_bytes());
1450        right.extend_from_slice(&[LoopMode::Renderer as u8, 0]);
1451        assert!(
1452            parse_handshake(&right).is_ok(),
1453            "the handshake this build generates is not one it accepts"
1454        );
1455    }
1456}
1457
1458/// Keeps the five exported boundary functions in the final executable.
1459///
1460/// The Renderer resolves them by name once it is loaded, which means nothing in the
1461/// application ever refers to them and the linker is free to conclude they are dead. It
1462/// does exactly that in any binary whose own code happens not to reach them, a test
1463/// harness for an application being the ordinary case, and the result is a process that
1464/// dies on startup with the dynamic loader unable to bind a symbol that should have been
1465/// right there in the executable.
1466///
1467/// Taking their addresses in a static the compiler is told to keep marks them as roots
1468/// for the dead-code pass, which is the whole job. It costs five pointers.
1469#[used]
1470static BOUNDARY_EXPORTS: BoundaryExports = BoundaryExports([
1471    dioxus_compose_host_init as *const (),
1472    dioxus_compose_host_dispatch_event as *const (),
1473    dioxus_compose_host_render_frame as *const (),
1474    dioxus_compose_host_release_batch as *const (),
1475    dioxus_compose_host_shutdown as *const (),
1476]);
1477
1478/// The same five names, as a linker directive the object file carries.
1479///
1480/// The Renderer finds these with GetProcAddress against the running executable, which
1481/// reads the PE export table, and an executable has no export table unless the link is
1482/// told to make one. The build script asks for that with `/EXPORT:` arguments, and those
1483/// reach the binaries of this package and no further: Cargo does not pass a dependency's
1484/// link arguments on to whatever depends on it.
1485///
1486/// So every application built on this crate linked without an export table, the Renderer
1487/// could not reach back into it, and the window came up empty. It looked like it worked
1488/// because the only Windows binaries anyone had run were this package's own examples.
1489///
1490/// `.drectve` is how an object file carries linker arguments of its own. The MSVC linker
1491/// reads that section out of every object it links, so the directive travels inside the
1492/// rlib and applies wherever the rlib ends up.
1493#[cfg(all(target_os = "windows", target_env = "msvc"))]
1494const EXPORT_DIRECTIVES: &str = concat!(
1495    " /EXPORT:dioxus_compose_host_init",
1496    " /EXPORT:dioxus_compose_host_dispatch_event",
1497    " /EXPORT:dioxus_compose_host_render_frame",
1498    " /EXPORT:dioxus_compose_host_release_batch",
1499    " /EXPORT:dioxus_compose_host_shutdown",
1500);
1501
1502#[cfg(all(target_os = "windows", target_env = "msvc"))]
1503#[used]
1504#[unsafe(link_section = ".drectve")]
1505static EXPORT_DIRECTIVE_BYTES: [u8; EXPORT_DIRECTIVES.len()] = {
1506    let source = EXPORT_DIRECTIVES.as_bytes();
1507    let mut bytes = [0u8; EXPORT_DIRECTIVES.len()];
1508    let mut index = 0;
1509    while index < source.len() {
1510        bytes[index] = source[index];
1511        index += 1;
1512    }
1513    bytes
1514};
1515
1516/// Never read. Being referenced is the entire contract.
1517struct BoundaryExports(#[allow(dead_code)] [*const (); 5]);
1518
1519// SAFETY: The addresses are written once, at compile time, and never read. A static has
1520// to be Sync to exist at all, and raw pointers decline to be only because of what they
1521// might point at; these point at code.
1522unsafe impl Sync for BoundaryExports {}