bevy_cef 0.14.0

Bevy CEF integration for web rendering
use crate::RunOnMainThread;
use bevy::prelude::*;
use bevy_cef_core::prelude::*;
use cef::args::Args;
use cef::{Settings, api_hash, execute_process, initialize, shutdown, sys};

/// Controls the CEF message loop.
///
/// Every platform runs CEF in `external_message_pump` mode: CEF asks for work through
/// `on_schedule_message_pump_work`, and [`CefDoMessageLoopWork`](https://cef-builds.spotifycdn.com/docs/106.1/cef__app_8h.html#a830ae43dcdffcf4e719540204cefdb61)
/// runs from a system in the `Main` schedule (throttled to a 4 ms minimum interval, with a
/// 30 Hz max-delay fallback). The CEF UI thread is therefore the Bevy main thread, which is
/// why `Browsers` is a `NonSend` resource.
///
/// `multi_threaded_message_loop` is not used: CEF does not support it on macOS, and
/// `cef_do_message_loop_work` does not block.
pub struct MessageLoopPlugin {
    pub config: CommandLineConfig,
    pub extensions: CefExtensions,
    pub root_cache_path: Option<String>,
    pub no_sandbox: bool,
}

impl Plugin for MessageLoopPlugin {
    fn build(&self, app: &mut App) {
        #[cfg(not(target_os = "macos"))]
        let render_process_binary = render_process_path();

        #[cfg(target_os = "macos")]
        load_cef_library(app);

        let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
        let args = Args::new();

        let (tx, rx) = std::sync::mpsc::channel();

        let mut cef_app =
            BrowserProcessAppBuilder::build(tx, self.config.clone(), self.extensions.clone());

        // On macOS and when a separate render process binary is available,
        // execute_process is called here. For the browser process it returns -1
        // and falls through; subprocesses exit immediately.
        #[cfg(target_os = "macos")]
        {
            let ret = execute_process(
                Some(args.as_main_args()),
                Some(&mut cef_app),
                std::ptr::null_mut(),
            );
            if ret >= 0 {
                std::process::exit(ret);
            }
        }

        #[cfg(not(target_os = "macos"))]
        cef_initialize(
            &args,
            &mut cef_app,
            self.root_cache_path.as_deref(),
            render_process_binary.as_deref(),
            self.no_sandbox,
        );
        #[cfg(target_os = "macos")]
        cef_initialize(
            &args,
            &mut cef_app,
            self.root_cache_path.as_deref(),
            self.no_sandbox,
        );

        app.insert_non_send(cef_app);

        app.insert_non_send(MessageLoopWorkingReceiver(rx));
        app.add_systems(Main, cef_do_message_loop_work);

        #[cfg(all(target_os = "macos", feature = "debug"))]
        app.add_systems(
            Main,
            macos::observe_terminate_request.before(cef_do_message_loop_work),
        );

        app.insert_non_send(RunOnMainThread)
            .add_systems(Update, cef_shutdown.run_if(on_message::<AppExit>));
    }
}

#[cfg(target_os = "macos")]
fn load_cef_library(app: &mut App) {
    macos::install_cef_app_protocol();
    #[cfg(all(target_os = "macos", feature = "debug"))]
    let loader = DebugLibraryLoader::new();
    #[cfg(all(target_os = "macos", not(feature = "debug")))]
    let loader = cef::library_loader::LibraryLoader::new(&std::env::current_exe().unwrap(), false);
    assert!(loader.load());
    app.insert_non_send(loader);
}

#[cfg(target_os = "macos")]
fn cef_initialize(
    args: &Args,
    cef_app: &mut cef::App,
    root_cache_path: Option<&str>,
    no_sandbox: bool,
) {
    // Ensure the cache directory exists before CEF tries to use it.
    // Empty/whitespace paths are valid (CEF treats them as "use default"), so skip those.
    if let Some(path) = root_cache_path.filter(|p| !p.trim().is_empty()) {
        std::fs::create_dir_all(path)
            .unwrap_or_else(|e| panic!("failed to create root_cache_path directory '{path}': {e}"));
    }

    let settings = Settings {
        #[cfg(feature = "debug")]
        framework_dir_path: debug_chromium_embedded_framework_dir_path()
            .to_str()
            .unwrap()
            .into(),
        #[cfg(feature = "debug")]
        browser_subprocess_path: debug_render_process_path().to_str().unwrap().into(),
        no_sandbox: no_sandbox as _,
        root_cache_path: root_cache_path.unwrap_or_default().into(),
        windowless_rendering_enabled: true as _,
        external_message_pump: true as _,
        disable_signal_handlers: true as _,
        ..Default::default()
    };
    assert_eq!(
        initialize(
            Some(args.as_main_args()),
            Some(&settings),
            Some(cef_app),
            std::ptr::null_mut(),
        ),
        1,
        "cef_initialize failed: root_cache_path={root_cache_path:?}",
    );
}

#[cfg(not(target_os = "macos"))]
fn cef_initialize(
    args: &Args,
    cef_app: &mut cef::App,
    root_cache_path: Option<&str>,
    render_process_binary: Option<&std::path::Path>,
    no_sandbox: bool,
) {
    // Ensure the cache directory exists before CEF tries to use it.
    // Empty/whitespace paths are valid (CEF treats them as "use default"), so skip those.
    if let Some(path) = root_cache_path.filter(|p| !p.trim().is_empty()) {
        std::fs::create_dir_all(path)
            .unwrap_or_else(|e| panic!("failed to create root_cache_path directory '{path}': {e}"));
    }

    let subprocess_path: String = render_process_binary
        .and_then(|p| p.to_str())
        .unwrap_or_default()
        .into();

    let settings = Settings {
        browser_subprocess_path: subprocess_path.as_str().into(),
        no_sandbox: no_sandbox as _,
        root_cache_path: root_cache_path.unwrap_or_default().into(),
        windowless_rendering_enabled: true as _,
        external_message_pump: true as _,
        disable_signal_handlers: false as _,
        ..Default::default()
    };
    assert_eq!(
        initialize(
            Some(args.as_main_args()),
            Some(&settings),
            Some(cef_app),
            std::ptr::null_mut(),
        ),
        1,
        "cef_initialize failed: root_cache_path={root_cache_path:?}, subprocess={subprocess_path:?}",
    );
}

fn cef_do_message_loop_work(
    receiver: NonSend<MessageLoopWorkingReceiver>,
    mut timer: Local<Option<MessageLoopTimer>>,
    mut max_delay_timer: Local<MessageLoopWorkingMaxDelayTimer>,
    mut last_execution: Local<Option<std::time::Instant>>,
) {
    while let Ok(t) = receiver.try_recv() {
        timer.replace(t);
    }
    let should_execute =
        timer.as_ref().map(|t| t.is_finished()).unwrap_or(false) || max_delay_timer.is_finished();
    if should_execute {
        // Enforce a minimum interval between executions to prevent
        // delay_ms=0 requests from causing excessive pump calls.
        const MIN_PUMP_INTERVAL: std::time::Duration = std::time::Duration::from_millis(4);
        let now = std::time::Instant::now();
        if let Some(last) = *last_execution
            && now.duration_since(last) < MIN_PUMP_INTERVAL
        {
            return;
        }
        *last_execution = Some(now);
        cef::do_message_loop_work();
        *max_delay_timer = MessageLoopWorkingMaxDelayTimer::default();
        timer.take();
    }
}

fn cef_shutdown(_: NonSend<RunOnMainThread>) {
    shutdown();
}

#[allow(clippy::needless_doctest_main)]
/// On non-macOS platforms, this detects if the current process is a CEF subprocess
/// (renderer, GPU, utility) and exits immediately if so.
///
/// When no separate render process binary is installed, CEF re-launches the main
/// executable as a subprocess. Call this function at the very beginning of `main()`
/// — **before** any Bevy initialization — so that subprocess instances exit
/// immediately without creating a visible window.
///
/// ```no_run
/// fn main() {
///     bevy_cef::prelude::early_exit_if_subprocess();
///     // ... Bevy App setup ...
/// }
/// ```
///
/// If a dedicated render process binary (`bevy_cef_render_process`) is installed
/// next to your executable, this function is unnecessary because CEF will launch
/// that binary instead of re-using the main executable.
///
/// On macOS this function is not available; macOS always uses a separate render
/// process binary.
#[cfg(not(target_os = "macos"))]
pub fn early_exit_if_subprocess() {
    let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
    let args = Args::new();
    let mut app = RenderProcessAppBuilder::build();
    let ret = execute_process(
        Some(args.as_main_args()),
        Some(&mut app),
        std::ptr::null_mut(),
    );
    if ret >= 0 {
        std::process::exit(ret);
    }
}

#[cfg(target_os = "macos")]
mod macos {
    use core::sync::atomic::AtomicBool;
    use objc::runtime::{Class, Object, Sel};
    use objc::{sel, sel_impl};
    use std::os::raw::c_char;
    use std::os::raw::c_void;
    use std::sync::atomic::Ordering;

    unsafe extern "C" {
        fn class_addMethod(
            cls: *const Class,
            name: Sel,
            imp: *const c_void,
            types: *const c_char,
        ) -> bool;
    }

    #[cfg(feature = "debug")]
    use bevy::prelude::{AppExit, MessageWriter, info};

    #[cfg(feature = "debug")]
    use objc::runtime::Method;

    #[cfg(feature = "debug")]
    unsafe extern "C" {
        fn class_getInstanceMethod(cls: *const Class, sel: Sel) -> *mut Method;
        fn method_exchangeImplementations(m1: *mut Method, m2: *mut Method);
    }

    static IS_HANDLING_SEND_EVENT: AtomicBool = AtomicBool::new(false);

    #[cfg(feature = "debug")]
    static TERMINATE_REQUESTED: AtomicBool = AtomicBool::new(false);

    extern "C" fn is_handling_send_event(_: &Object, _: Sel) -> bool {
        IS_HANDLING_SEND_EVENT.load(Ordering::Relaxed)
    }

    extern "C" fn set_handling_send_event(_: &Object, _: Sel, flag: bool) {
        IS_HANDLING_SEND_EVENT.swap(flag, Ordering::Relaxed);
    }

    #[cfg(feature = "debug")]
    extern "C" fn swizzled_terminate(_: &Object, _: Sel, _sender: *mut Object) {
        // Intentionally does NOT call the original terminate:.
        // Calling it would post NSApplicationWillTerminateNotification and
        // re-trigger the winit `applicationWillTerminate:` panic.
        TERMINATE_REQUESTED.store(true, Ordering::Relaxed);
    }

    #[cfg(feature = "debug")]
    fn install_terminate_swizzle() {
        unsafe {
            let cls = Class::get("NSApplication").expect("NSApplication class not found");

            let placeholder_sel = sel!(cef_swizzled_terminate:);
            let added = class_addMethod(
                cls as *const _,
                placeholder_sel,
                swizzled_terminate as *const c_void,
                c"v@:@".as_ptr() as *const c_char,
            );
            assert!(
                added,
                "Failed to add cef_swizzled_terminate: to NSApplication"
            );

            let terminate_method = class_getInstanceMethod(cls as *const _, sel!(terminate:));
            assert!(
                !terminate_method.is_null(),
                "terminate: method not found on NSApplication"
            );
            let swizzled_method = class_getInstanceMethod(cls as *const _, placeholder_sel);
            assert!(
                !swizzled_method.is_null(),
                "cef_swizzled_terminate: method not found after class_addMethod"
            );

            method_exchangeImplementations(terminate_method, swizzled_method);
        }
    }

    pub fn install_cef_app_protocol() {
        unsafe {
            let cls = Class::get("NSApplication").expect("NSApplication class not found");
            let sel_name = sel!(isHandlingSendEvent);
            let success = class_addMethod(
                cls as *const _,
                sel_name,
                is_handling_send_event as *const c_void,
                c"c@:".as_ptr() as *const c_char,
            );
            assert!(
                success,
                "Failed to add isHandlingSendEvent to NSApplication"
            );

            let sel_set = sel!(setHandlingSendEvent:);
            let success2 = class_addMethod(
                cls as *const _,
                sel_set,
                set_handling_send_event as *const c_void,
                c"v@:c".as_ptr() as *const c_char,
            );
            assert!(
                success2,
                "Failed to add setHandlingSendEvent: to NSApplication"
            );

            #[cfg(feature = "debug")]
            install_terminate_swizzle();
        }
    }

    /// `swap(false, Relaxed)` atomically reads-and-clears the flag, so AppExit
    /// is emitted exactly once even if this system runs again before shutdown
    /// completes.
    #[cfg(feature = "debug")]
    pub(super) fn observe_terminate_request(mut writer: MessageWriter<AppExit>) {
        if TERMINATE_REQUESTED.swap(false, Ordering::Relaxed) {
            info!("Termination intercepted, requesting AppExit");
            writer.write(AppExit::from_code(130));
        }
    }
}