From d6cd7ec94ed07b01a4065bcfd15d2283656c5a3e Mon Sep 17 00:00:00 2001 From: mileswa1q22 Date: Fri, 21 Aug 2026 23:52:13 -0500 Subject: [PATCH] update --- .cargo/config.toml | 5 + Cargo.lock | 304 +++++++++++- Cargo.toml | 3 + scripts/bundle.sh | 95 ++++ scripts/run-dev.sh | 21 + src/app.rs | 771 +++++++++++++++++++++++++----- src/cef_host.rs | 1107 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 25 +- src/servo_host.rs | 39 +- src/settings.rs | 50 ++ 10 files changed, 2265 insertions(+), 155 deletions(-) create mode 100644 .cargo/config.toml create mode 100755 scripts/bundle.sh create mode 100755 scripts/run-dev.sh create mode 100644 src/cef_host.rs create mode 100644 src/settings.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9f4f740 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# CEF symbols are resolved at runtime from the dlopen'd framework +# (see cef_host::load_framework_if_bundled), so the linker must tolerate +# undefined cef_* symbols. +[target.'cfg(target_os = "macos")'] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] diff --git a/Cargo.lock b/Cargo.lock index 83c3cfc..b243f90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,7 +347,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -668,6 +668,7 @@ dependencies = [ name = "browser" version = "0.1.0" dependencies = [ + "cef", "egui", "egui-wgpu", "egui-winit", @@ -680,6 +681,8 @@ dependencies = [ "objc2-quartz-core 0.3.2", "pollster", "rustls", + "serde", + "serde_json", "servo", "url", "wgpu", @@ -759,6 +762,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "calendrical_calculations" version = "0.1.3" @@ -820,6 +832,39 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "cbc" version = "0.2.1" @@ -841,6 +886,38 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cef" +version = "151.7.0+151.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96acc058418aaf5dbcb95420677eb053032acc9b8af224079fb091a3043d899" +dependencies = [ + "anyhow", + "cargo_metadata", + "cef-dll-sys", + "clap", + "libloading 0.9.0", + "objc2 0.6.4", + "plist", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "cef-dll-sys" +version = "151.7.0+151.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043cf920289eeaa8e6b4d7f1701ef28e07e0cdaa7c8acf54e6008e9b61435a4c" +dependencies = [ + "anyhow", + "cmake", + "download-cef", + "serde_json", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -942,9 +1019,49 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1049,6 +1166,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -1087,10 +1216,29 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie 0.18.2", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1586,7 +1734,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1604,6 +1752,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "download-cef" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113" +dependencies = [ + "bzip2", + "clap", + "fs-err", + "indicatif", + "regex", + "semver", + "serde", + "serde_json", + "sha1_smol", + "tar", + "thiserror 2.0.20", + "ureq", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1830,6 +1998,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_c" version = "0.9.8" @@ -2235,6 +2409,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3487,6 +3670,19 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.2.2" @@ -3746,7 +3942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.9", "pkg-config", ] @@ -3781,6 +3977,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" version = "0.2.189" @@ -3807,6 +4009,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -5107,6 +5319,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -5827,6 +6052,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -6044,6 +6270,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "seq-macro" @@ -7867,6 +8097,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "speexdsp-resampler" version = "0.1.0" @@ -7972,6 +8213,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -8870,6 +9117,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.6.1" @@ -8886,6 +9139,39 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http 1.5.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -8945,6 +9231,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -9566,7 +9858,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading", + "libloading 0.8.9", "log", "naga", "naga-types", @@ -10204,7 +10496,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", "rustix 1.1.4", "x11rb-protocol", diff --git a/Cargo.toml b/Cargo.toml index f13ccfa..362aa1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +cef = "151" +serde = { version = "1", features = ["derive"] } +serde_json = "1" egui = "0.36.1" egui-wgpu = "0.36.1" egui-winit = "0.36.1" diff --git a/scripts/bundle.sh b/scripts/bundle.sh new file mode 100755 index 0000000..0d2a620 --- /dev/null +++ b/scripts/bundle.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# Build Fox and assemble Fox.app with the CEF framework + helper bundles. +# +# scripts/bundle.sh [debug|release] +# +# The CEF distribution must be exported first: +# cargo install export-cef-dir --version 151.7.0+151.3.23 && export-cef-dir +set -e + +PROFILE="${1:-debug}" +cd "$(dirname "$0")/.." + +export CEF_PATH="${CEF_PATH:-$HOME/.local/share/cef}" +DIST="$CEF_PATH" +FRAMEWORK="Chromium Embedded Framework.framework" + +if [ "$PROFILE" = "release" ]; then + cargo build --release + BIN="target/release/browser" +else + cargo build + BIN="target/debug/browser" +fi + +APP="target/bundle/Fox.app" +CONTENTS="$APP/Contents" +rm -rf "$APP" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Frameworks" + +cp "$BIN" "$CONTENTS/MacOS/browser" +ditto "$DIST/$FRAMEWORK" "$CONTENTS/Frameworks/$FRAMEWORK" + +cat > "$CONTENTS/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegionen + CFBundleExecutablebrowser + CFBundleIconFile + CFBundleIdentifierdev.fox.browser + CFBundleInfoDictionaryVersion6.0 + CFBundleNameFox + CFBundleDisplayNameFox + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.2.0 + CFBundleVersion0.2.0 + LSMinimumSystemVersion11.0 + NSHighResolutionCapable + NSPrincipalClassNSApplication + + +PLIST + +# Chromium on macOS launches child processes (GPU/renderer/network/utility) +# from dedicated .app bundles. We reuse the Fox binary itself as the helper +# executable — CEF child processes are dispatched via --type= arguments, which +# our bootstrap() already understands. +make_helper () { + HELPER_NAME="$1" + HELPER_ID="$2" + HELPER_DIR="$CONTENTS/Frameworks/$HELPER_NAME.app" + mkdir -p "$HELPER_DIR/Contents/MacOS" + cp "$BIN" "$HELPER_DIR/Contents/MacOS/$HELPER_NAME" + cat > "$HELPER_DIR/Contents/Info.plist" < + + + + CFBundleDevelopmentRegionen + CFBundleExecutable$HELPER_NAME + CFBundleIdentifier$HELPER_ID + CFBundleInfoDictionaryVersion6.0 + CFBundleName$HELPER_NAME + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.2.0 + CFBundleVersion0.2.0 + LSMinimumSystemVersion11.0 + NSSupportsAutomaticGraphicsSwitching + + +PLIST +} + +make_helper "Fox Helper" "dev.fox.browser.helper" +make_helper "Fox Helper (Renderer)" "dev.fox.browser.helper.renderer" +make_helper "Fox Helper (GPU)" "dev.fox.browser.helper.gpu" +make_helper "Fox Helper (Plugin)" "dev.fox.browser.helper.plugin" +make_helper "Fox Helper (Alerts)" "dev.fox.browser.helper.alerts" + +codesign --force --deep --sign - "$APP" + +echo "Built $APP" diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh new file mode 100755 index 0000000..fce7f0e --- /dev/null +++ b/scripts/run-dev.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Build and run Fox in dev mode. +# +# scripts/run-dev.sh [extra cargo args...] +# +# On macOS the app must run from a .app bundle (CEF needs its framework +# resources, so a bare target/debug binary cannot start). This script +# therefore bundles first, then launches Fox.app with console output visible. +# +# The CEF distribution must be exported once: +# cargo install export-cef-dir --version 151.7.0+151.3.23 +# export-cef-dir # installs to ~/.local/share/cef +set -e + +cd "$(dirname "$0")/.." + +export CEF_PATH="${CEF_PATH:-$HOME/.local/share/cef}" + +scripts/bundle.sh "${FOX_PROFILE:-debug}" + +exec ./target/bundle/Fox.app/Contents/MacOS/browser "$@" diff --git a/src/app.rs b/src/app.rs index 066176a..b3aa7a2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use std::sync::Arc; use std::time::{Duration, Instant}; +use egui::TextureHandle; use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor}; use egui_winit::State as EguiWinitState; use keyboard_types::Modifiers; @@ -18,10 +19,12 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}; use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawWindowHandle}; use winit::window::{Window, WindowId}; +use crate::cef_host::{self, CefEvent, CefTabHandle}; use crate::servo_host::{ self, make_keyboard_input, winit_button_action_to_servo, winit_button_to_servo, wheel_input, - Engine, EngineMsg, EguiWaker, Tab, + Engine, EngineMsg, EguiWaker, ServoPage, }; +use crate::settings::{EngineKind, Settings}; use crate::UserEvent; const START_PAGE: &str = "https://servo.org/"; @@ -33,14 +36,78 @@ const ACCENT: egui::Color32 = egui::Color32::from_rgb(0, 97, 224); /// Minimum time between expensive GPU->CPU page readbacks (~one vsync). const MIN_PAINT_INTERVAL: Duration = Duration::from_millis(15); +/// One browser tab as seen by the UI — backend-agnostic state plus a handle +/// to whichever engine renders it. +struct Tab { + id: u64, + kind: EngineKind, + title: String, + url_string: String, + status_text: Option, + loading: bool, + animating: bool, + dirty: bool, + texture: Option, + /// Hash of the last uploaded frame — skips redundant GPU texture uploads. + last_frame_hash: u64, + /// Time of the last paint upload — throttles readback/upload frequency. + last_paint: Instant, + page: Page, +} + +enum Page { + Servo(ServoPage), + Cef(CefTabHandle), +} + +impl Tab { + fn new(id: u64, kind: EngineKind, page: Page) -> Tab { + Tab { + id, + kind, + title: String::from("New Tab"), + url_string: String::new(), + status_text: None, + loading: true, + animating: false, + dirty: true, + texture: None, + last_frame_hash: 0, + last_paint: Instant::now() - Duration::from_secs(1), + page, + } + } + + #[allow(dead_code)] + fn servo_page_mut(&mut self) -> Option<&mut ServoPage> { + match &mut self.page { + Page::Servo(page) => Some(page), + _ => None, + } + } + + fn cef_handle(&self) -> Option<&CefTabHandle> { + match &self.page { + Page::Cef(handle) => Some(handle), + _ => None, + } + } +} + pub struct BrowserApp { proxy: EventLoopProxy, + settings: Settings, + /// Live session value for which engine new tabs use; persisted on change. + default_engine: EngineKind, + show_settings: bool, + next_tab_id: u64, window: Option>, gpu: Option, // NOTE: `tabs` must be dropped BEFORE `engine` — closing a WebView sends a // message to Servo, which must still be alive when that happens. tabs: Vec, engine: Option>, + cef_ready: bool, active: usize, url_edit: String, url_bar_focused: bool, @@ -50,6 +117,9 @@ pub struct BrowserApp { modifiers: Modifiers, scale_factor: f64, pending_close: Option, + click_count: u32, + last_click_time: Instant, + last_click_pos: PhysicalPosition, } struct Gpu { @@ -63,13 +133,19 @@ struct Gpu { } impl BrowserApp { - pub fn new(proxy: EventLoopProxy) -> BrowserApp { + pub fn new(proxy: EventLoopProxy, settings: Settings) -> BrowserApp { + let default_engine = settings.default_engine; BrowserApp { proxy, + settings, + default_engine, + show_settings: false, + next_tab_id: 1, window: None, gpu: None, tabs: Vec::new(), engine: None, + cef_ready: false, active: 0, url_edit: START_PAGE.to_owned(), url_bar_focused: false, @@ -79,6 +155,9 @@ impl BrowserApp { modifiers: Modifiers::empty(), scale_factor: 1.0, pending_close: None, + click_count: 0, + last_click_time: Instant::now() - Duration::from_secs(1), + last_click_pos: PhysicalPosition::new(-100.0, -100.0), } } @@ -200,13 +279,17 @@ impl BrowserApp { let waker = EguiWaker(egui_ctx.clone()); let engine = Rc::new(Engine::new(waker, window_context)); - - let start_url = Url::parse(START_PAGE)?; - let tab = engine.create_tab(start_url, self.scale_factor as f32); - self.tabs.push(tab); - self.active = 0; self.engine = Some(engine); + // Bring up Chromium now that the pump proxy is installed and the GPU + // is ready to receive paint callbacks. + cef_host::initialize_runtime() + .map_err(|e| format!("failed to initialise CEF: {e}"))?; + self.cef_ready = true; + + self.open_tab_on(START_PAGE, self.default_engine); + self.active = 0; + self.gpu = Some(Gpu { surface, device, @@ -239,11 +322,21 @@ impl BrowserApp { self.tabs.get_mut(self.active) } - fn tab_by_id_mut(&mut self, id: servo::WebViewId) -> Option<&mut Tab> { + fn tab_by_servo_id_mut(&mut self, id: servo::WebViewId) -> Option<&mut Tab> { + self.tabs + .iter_mut() + .find(|t| matches!(&t.page, Page::Servo(p) if p.id == id)) + } + + fn tab_by_id(&self, id: u64) -> Option<&Tab> { + self.tabs.iter().find(|t| t.id == id) + } + + fn tab_by_id_mut(&mut self, id: u64) -> Option<&mut Tab> { self.tabs.iter_mut().find(|t| t.id == id) } - fn active_id(&self) -> Option { + fn active_id(&self) -> Option { self.tabs.get(self.active).map(|t| t.id) } @@ -252,27 +345,62 @@ impl BrowserApp { return; }; if let Some(tab) = self.active_tab_mut() { - tab.webview.load(url); + match &mut tab.page { + Page::Servo(page) => page.webview.load(url), + Page::Cef(handle) => handle.navigate(url.as_str()), + } tab.dirty = true; } } fn open_new_tab(&mut self) { - let Some(engine) = self.engine.clone() else { - return; + let engine_kind = self.default_engine; + self.open_tab_on(START_PAGE, engine_kind); + } + + fn open_tab_on(&mut self, url: &str, kind: EngineKind) { + let id = self.next_tab_id; + self.next_tab_id += 1; + let ppp = self.scale_factor as f32; + let page = match kind { + EngineKind::Servo => { + let Some(engine) = self.engine.clone() else { + return; + }; + let Ok(parsed) = Url::parse(url) else { + return; + }; + Page::Servo(engine.create_tab(parsed, ppp)) + } + EngineKind::Chromium => { + if !self.cef_ready { + return; + } + // The content rect isn't laid out until the first frame; a + // small default keeps CEF painting while that happens. + let size = ( + (self.content_rect.width().max(100.0)), + (self.content_rect.height().max(100.0)), + ); + Page::Cef(CefTabHandle::create(id, url, size, ppp)) + } }; - let url = Url::parse(START_PAGE).expect("valid start page"); - let tab = engine.create_tab(url, self.scale_factor as f32); + let tab = Tab::new(id, kind, page); self.tabs.push(tab); self.active = self.tabs.len() - 1; self.content_focused = false; - self.url_edit = START_PAGE.to_owned(); + self.url_edit = url.to_owned(); } fn close_tab(&mut self, index: usize) { if index >= self.tabs.len() { return; } + if let Some(tab) = self.tabs.get(index) { + if let Some(handle) = tab.cef_handle() { + handle.close(); + } + } self.tabs.remove(index); if self.tabs.is_empty() { self.open_new_tab(); @@ -286,6 +414,13 @@ impl BrowserApp { self.content_focused = false; } + fn active_servo_id(&self) -> Option { + match self.tabs.get(self.active).map(|t| &t.page) { + Some(Page::Servo(page)) => Some(page.id), + _ => None, + } + } + fn drain_engine_messages(&mut self) { let Some(engine) = self.engine.clone() else { return; @@ -294,8 +429,8 @@ impl BrowserApp { for msg in messages { match msg { EngineMsg::UrlChanged(id, url) => { - let is_active = Some(id) == self.active_id(); - if let Some(tab) = self.tab_by_id_mut(id) { + let is_active = Some(id) == self.active_servo_id(); + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.url_string.clone_from(&url); if is_active && !self.url_bar_focused { self.url_edit.clone_from(&url); @@ -303,16 +438,16 @@ impl BrowserApp { } } EngineMsg::TitleChanged(id, title) => { - let is_active = Some(id) == self.active_id(); + let is_active = Some(id) == self.active_servo_id(); let display = title.unwrap_or_else(|| { fallback_title(&self .tabs .iter() - .find(|t| t.id == id) + .find(|t| matches!(&t.page, Page::Servo(p) if p.id == id)) .map(|t| t.url_string.clone()) .unwrap_or_default()) }); - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.title.clone_from(&display); } if is_active { @@ -322,38 +457,48 @@ impl BrowserApp { } } EngineMsg::StatusText(id, status) => { - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.status_text = status; } } EngineMsg::LoadStatus(id, loading) => { - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.loading = loading; } } EngineMsg::NewFrame(id) => { - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.dirty = true; } } EngineMsg::Animating(id, animating) => { - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.animating = animating; } } EngineMsg::Crashed(id, reason) => { - if let Some(tab) = self.tab_by_id_mut(id) { + if let Some(tab) = self.tab_by_servo_id_mut(id) { tab.title = format!("Crashed: {reason}"); tab.loading = false; } } EngineMsg::Closed(id) => { - if let Some(index) = self.tabs.iter().position(|t| t.id == id) { + if let Some(index) = self + .tabs + .iter() + .position(|t| matches!(&t.page, Page::Servo(p) if p.id == id)) + { self.close_tab(index); } } EngineMsg::NewWebView(webview) => { - let mut tab = Tab::adopt(webview); + let id = self.next_tab_id; + self.next_tab_id += 1; + let mut tab = Tab::new( + id, + EngineKind::Servo, + Page::Servo(ServoPage::adopt(webview)), + ); tab.dirty = true; self.tabs.push(tab); self.active = self.tabs.len() - 1; @@ -363,6 +508,85 @@ impl BrowserApp { } } + fn drain_cef_events(&mut self) { + let all_events: Vec = self + .tabs + .iter() + .flat_map(|t| t.cef_handle().map(|h| h.drain_events()).unwrap_or_default()) + .collect(); + for event in all_events { + self.apply_cef_event(event); + } + } + + fn apply_cef_event(&mut self, event: CefEvent) { + let cef_tab_id = match &event { + CefEvent::UrlChanged(id, _) + | CefEvent::TitleChanged(id, _) + | CefEvent::StatusText(id, _) + | CefEvent::LoadingChanged(id, _) + | CefEvent::FrameReady(id) + | CefEvent::Closed(id) => *id, + }; + let is_active = Some(cef_tab_id) == self.active_id(); + match event { + CefEvent::UrlChanged(_, url) => { + if let Some(tab) = self.tab_by_id_mut(cef_tab_id) { + tab.url_string.clone_from(&url); + if is_active && !self.url_bar_focused { + self.url_edit.clone_from(&url); + } + } + } + CefEvent::TitleChanged(_, title) => { + let display = title.unwrap_or_else(|| { + fallback_title( + &self + .tab_by_id(cef_tab_id) + .map(|t| t.url_string.clone()) + .unwrap_or_default(), + ) + }); + if let Some(tab) = self.tab_by_id_mut(cef_tab_id) { + tab.title.clone_from(&display); + } + if is_active { + if let Some(window) = self.window.as_ref() { + window.set_title(&format!("{display} — Fox")); + } + } + } + CefEvent::StatusText(_, status) => { + if let Some(tab) = self.tab_by_id_mut(cef_tab_id) { + tab.status_text = status; + } + } + CefEvent::LoadingChanged(_, loading) => { + if let Some(tab) = self.tab_by_id_mut(cef_tab_id) { + tab.loading = loading; + } + } + CefEvent::FrameReady(_) => { + if let Some(tab) = self.tab_by_id_mut(cef_tab_id) { + tab.dirty = true; + } + } + CefEvent::Closed(_) => { + if let Some(index) = self.tabs.iter().position(|t| t.id == cef_tab_id) { + // The browser already closed itself; drop the UI tab only. + self.tabs.remove(index); + if self.tabs.is_empty() { + self.open_new_tab(); + } else if self.active >= self.tabs.len() { + self.active = self.tabs.len() - 1; + } else if index < self.active { + self.active -= 1; + } + } + } + } + } + fn sync_viewport_and_paint(&mut self) { let ppp = self.scale_factor as f32; let egui_ctx = self.gpu.as_ref().map(|g| g.egui_ctx.clone()); @@ -371,66 +595,133 @@ impl BrowserApp { let Some(tab) = self.active_tab_mut() else { return; }; - if content.is_positive() { - let phys_w = (content.width() * ppp).round() as u32; - let phys_h = (content.height() * ppp).round() as u32; - let current = tab.webview.size(); - let cur = (current.width.round() as u32, current.height.round() as u32); - if cur != (phys_w, phys_h) && phys_w > 0 && phys_h > 0 { - tab.webview.resize(PhysicalSize::new(phys_w, phys_h)); - tab.dirty = true; - } + if !content.is_positive() { + return; + } + let phys_w = (content.width() * ppp).round() as u32; + let phys_h = (content.height() * ppp).round() as u32; + if phys_w == 0 || phys_h == 0 { + return; } - if !tab.dirty { - return; - } - // Throttle GPU->CPU readbacks: a full-surface read is expensive, so skip - // frames arriving sooner than one vsync after the previous read. Keep the - // dirty flag so the next redraw picks the newer frame up instead. - if tab.last_paint.elapsed() < MIN_PAINT_INTERVAL { - return; - } - tab.webview.paint(); + match &mut tab.page { + Page::Servo(page) => { + let current = page.webview.size(); + let cur = (current.width.round() as u32, current.height.round() as u32); + if cur != (phys_w, phys_h) { + page.webview.resize(PhysicalSize::new(phys_w, phys_h)); + tab.dirty = true; + } - let size = tab.webview.size(); - let w = size.width.round() as i32; - let h = size.height.round() as i32; - if w <= 0 || h <= 0 { + if !tab.dirty || tab.last_paint.elapsed() < MIN_PAINT_INTERVAL { + return; + } + // Throttle GPU->CPU readbacks: a full-surface read is expensive, + // so skip frames arriving sooner than one vsync after the + // previous read. Keep the dirty flag so the next redraw picks + // the newer frame up instead. + page.webview.paint(); + + let size = page.webview.size(); + let w = size.width.round() as i32; + let h = size.height.round() as i32; + if w <= 0 || h <= 0 { + return; + } + let rect = + euclid::Box2D::from_origin_and_size(euclid::point2(0, 0), euclid::size2(w, h)); + let rc = page.webview.rendering_context(); + if let Some(img) = rc.read_to_image(rect) { + tab.last_paint = Instant::now(); + let dims: [u32; 2] = + [img.width().try_into().unwrap(), img.height().try_into().unwrap()]; + let pixels = img.into_raw(); + Self::upload_frame( + tab, + dims[0], + dims[1], + pixels, + false, + egui_ctx.as_ref(), + ); + } + } + Page::Cef(handle) => { + let logical = (content.width(), content.height()); + // Only push resizes when the layout actually changed, otherwise + // was_resized() would trigger repaint churn every frame. + let current = handle + .shared + .lock() + .map(|s| s.size_logical) + .unwrap_or_default(); + if (current.0 - logical.0).abs() > 0.5 || (current.1 - logical.1).abs() > 0.5 { + handle.resize(logical, ppp); + tab.dirty = true; + } + + if !tab.dirty || tab.last_paint.elapsed() < MIN_PAINT_INTERVAL { + return; + } + if let Some(frame) = handle.take_frame() { + tab.last_paint = Instant::now(); + Self::upload_frame( + tab, + frame.width, + frame.height, + frame.bgra, + true, + egui_ctx.as_ref(), + ); + } else { + // No new frame yet — keep the flag so we retry next pass. + } + } + } + } + + /// Uploads raw BGRA or RGBA pixel data to the tab's egui texture, with + /// hash-based skip of unchanged frames. + fn upload_frame( + tab: &mut Tab, + width: u32, + height: u32, + mut pixels: Vec, + swap_rb: bool, + egui_ctx: Option<&egui::Context>, + ) { + if swap_rb && !pixels.is_empty() { + for px in pixels.chunks_exact_mut(4) { + px.swap(0, 2); + } + } + let hash = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + pixels.len().hash(&mut hasher); + for chunk in pixels.chunks(4096) { + chunk.hash(&mut hasher); + } + hasher.finish() + }; + if hash == tab.last_frame_hash && tab.texture.is_some() { + tab.dirty = false; return; } - let rect = euclid::Box2D::from_origin_and_size(euclid::point2(0, 0), euclid::size2(w, h)); - let rc = tab.webview.rendering_context(); - if let Some(img) = rc.read_to_image(rect) { - tab.last_paint = Instant::now(); - let dims = [img.width() as usize, img.height() as usize]; - let pixels = img.into_raw(); - // Skip the (expensive) texture re-upload when nothing changed. - let hash = { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - pixels.len().hash(&mut hasher); - for chunk in pixels.chunks(4096) { - chunk.hash(&mut hasher); - } - hasher.finish() - }; - if hash == tab.last_frame_hash && tab.texture.is_some() { - tab.dirty = false; - return; + tab.last_frame_hash = hash; + let image = egui::ColorImage::from_rgba_unmultiplied( + [width as usize, height as usize], + &pixels, + ); + match (&tab.texture, egui_ctx) { + (Some(texture), _) => { + texture.clone().set(image, egui::TextureOptions::LINEAR); } - tab.last_frame_hash = hash; - let image = egui::ColorImage::from_rgba_unmultiplied(dims, &pixels); - match (&tab.texture, &egui_ctx) { - (Some(texture), _) => { - texture.clone().set(image, egui::TextureOptions::LINEAR); - } - (None, Some(ctx)) => { - let name = format!("page-{:?}", tab.id); - tab.texture = Some(ctx.load_texture(name, image, egui::TextureOptions::LINEAR)); - } - (None, None) => {} + (None, Some(ctx)) => { + let name = format!("page-{}-{width}x{height}", tab.id); + tab.texture = Some(ctx.load_texture(name, image, egui::TextureOptions::LINEAR)); } + (None, None) => {} } tab.dirty = false; } @@ -473,7 +764,7 @@ impl BrowserApp { self.content_rect = rect; let Some(texture) = self.active_tab().and_then(|t| t.texture.clone()) else { ui.centered_and_justified(|ui| { - ui.label("Starting up the Servo engine…"); + ui.label("Loading…"); }); return; }; @@ -575,35 +866,62 @@ impl BrowserApp { } fn toolbar(&mut self, ui: &mut egui::Ui) { - let can_back = self.active_tab().map(|t| t.webview.can_go_back()) == Some(true); - let can_forward = self.active_tab().map(|t| t.webview.can_go_forward()) == Some(true); - let loading = self.active_tab().map(|t| t.loading) == Some(true); + let (can_back, can_forward, loading, engine_label) = match self.active_tab() { + Some(tab) => match &tab.page { + Page::Servo(page) => ( + page.webview.can_go_back(), + page.webview.can_go_forward(), + tab.loading, + tab.kind.label().to_owned(), + ), + Page::Cef(handle) => ( + handle.can_go_back(), + handle.can_go_forward(), + tab.loading, + tab.kind.label().to_owned(), + ), + }, + None => (false, false, false, String::new()), + }; ui.horizontal(|ui| { ui.add_space(4.0); ui.add_enabled_ui(can_back, |ui| { if nav_button(ui, "←", "Go back").clicked() { if let Some(tab) = self.active_tab_mut() { - tab.webview.go_back(1); + if let Page::Servo(page) = &mut tab.page { + page.webview.go_back(1); + } else if let Page::Cef(handle) = &tab.page { + handle.go_back(); + } } } }); ui.add_enabled_ui(can_forward, |ui| { if nav_button(ui, "→", "Go forward").clicked() { if let Some(tab) = self.active_tab_mut() { - tab.webview.go_forward(1); + if let Page::Servo(page) = &mut tab.page { + page.webview.go_forward(1); + } else if let Page::Cef(handle) = &tab.page { + handle.go_forward(); + } } } }); if nav_button(ui, if loading { "◌" } else { "↻" }, "Reload").clicked() { if let Some(tab) = self.active_tab_mut() { - tab.webview.reload(); + if let Page::Servo(page) = &mut tab.page { + page.webview.reload(); + } else if let Page::Cef(handle) = &tab.page { + handle.reload(); + } } } + let url_width = ui.available_width() - 96.0; let url_field = egui::TextEdit::singleline(&mut self.url_edit) .hint_text("Search with DuckDuckGo or enter address") - .desired_width(ui.available_width() - 12.0) + .desired_width(url_width) .font(egui::TextStyle::Small); let response = ui.add(url_field); self.url_bar_focused = response.has_focus(); @@ -612,8 +930,74 @@ impl BrowserApp { self.navigate_active(&text); response.request_focus(); } + + // Engine badge + settings gear pinned to the right. + if !engine_label.is_empty() { + ui.add_space(2.0); + egui::Frame::new() + .fill(CHROME_BG_DARKER) + .corner_radius(4.0) + .inner_margin(egui::Margin::symmetric(5, 2)) + .show(ui, |ui| { + ui.set_min_height(16.0); + ui.label( + egui::RichText::new(engine_label) + .size(10.0) + .color(egui::Color32::from_gray(90)), + ); + }); + } + if ui + .add( + egui::Button::new(egui::RichText::new("⚙").size(14.0)) + .min_size(egui::vec2(26.0, 26.0)) + .corner_radius(5.0), + ) + .on_hover_text("Settings") + .clicked() + { + self.show_settings = !self.show_settings; + } ui.add_space(4.0); }); + + self.settings_panel(ui.ctx()); + } + + fn settings_panel(&mut self, ctx: &egui::Context) { + if !self.show_settings { + return; + } + let mut changed: Option = None; + egui::Window::new("Settings") + .anchor(egui::Align2::CENTER_TOP, [0.0, 60.0]) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.heading("Default engine"); + ui.separator(); + for kind in [EngineKind::Chromium, EngineKind::Servo] { + let selected = self.default_engine == kind; + if ui + .add(egui::RadioButton::new(selected, kind.label())) + .clicked() + && !selected + { + changed = Some(kind); + } + } + ui.add_space(6.0); + ui.small("New tabs open with the default engine.\nThe choice is saved and used again on launch."); + if ui.button("Close").clicked() { + self.show_settings = false; + } + }); + if let Some(kind) = changed { + self.default_engine = kind; + self.settings.default_engine = kind; + self.settings.save(); + log::info!("default engine set to {}", kind.label()); + } } fn point_inside_content(&self, pos: PhysicalPosition) -> bool { @@ -636,50 +1020,117 @@ impl BrowserApp { if !self.point_inside_content(pos) { return; } - let origin_x = self.content_rect.min.x as f64 * self.scale_factor; - let origin_y = self.content_rect.min.y as f64 * self.scale_factor; - let point = servo::DevicePoint::new( - (pos.x - origin_x) as f32, - (pos.y - origin_y) as f32, - ); - if let Some(tab) = self.active_tab_mut() { - tab.webview.notify_input_event(servo::InputEvent::MouseMove( - servo::MouseMoveEvent::new(point.into()), - )); + let servo_point = self.content_point(pos); + let cef_point = self.content_point_cef(pos); + let flags = cef_host::event_flags(self.modifiers); + let Some(tab) = self.active_tab_mut() else { + return; + }; + match &mut tab.page { + Page::Servo(page) => { + page.webview.notify_input_event(servo::InputEvent::MouseMove( + servo::MouseMoveEvent::new(servo_point.into()), + )); + } + Page::Cef(handle) => { + handle.mouse_move(cef_point.0, cef_point.1, flags); + } } } fn forward_mouse_click(&mut self, state: ElementState, button: winit::event::MouseButton) { let pos = self.last_cursor_pos; let inside = self.point_inside_content(pos); + let pressed = state == ElementState::Pressed; + let servo_point = self.content_point(pos); + let cef_point = self.content_point_cef(pos); + let flags = cef_host::event_flags(self.modifiers); + if inside && pressed { + self.track_click_count(pos); + } + let count = self.click_count; if inside { self.content_focused = true; - let point = self.content_point(pos); if let Some(tab) = self.active_tab_mut() { - tab.webview.focus(); - if let Some(btn) = winit_button_to_servo(button) { - let action = winit_button_action_to_servo(state); - tab.webview.notify_input_event(servo::InputEvent::MouseButton( - servo::MouseButtonEvent::new(action, btn, point.into()), - )); + match &mut tab.page { + Page::Servo(page) => { + page.webview.focus(); + if let Some(btn) = winit_button_to_servo(button) { + let action = winit_button_action_to_servo(state); + page.webview.notify_input_event(servo::InputEvent::MouseButton( + servo::MouseButtonEvent::new(action, btn, servo_point.into()), + )); + } + } + Page::Cef(handle) => { + let Some(btn) = cef_mouse_button(button) else { + return; + }; + handle.focus(true); + handle.mouse_click( + cef_point.0, + cef_point.1, + btn, + !pressed, + count, + flags, + ); + } } } - } else if state == ElementState::Pressed { + } else if pressed { self.content_focused = false; + self.click_count = 0; if let Some(tab) = self.active_tab_mut() { - tab.webview.blur(); + match &mut tab.page { + Page::Servo(page) => page.webview.blur(), + Page::Cef(handle) => handle.focus(false), + } } } } + fn track_click_count(&mut self, pos: PhysicalPosition) { + let now = Instant::now(); + let dx = pos.x - self.last_click_pos.x; + let dy = pos.y - self.last_click_pos.y; + let near = (dx * dx + dy * dy).sqrt() < 5.0; + if near && now.duration_since(self.last_click_time) < Duration::from_millis(400) { + self.click_count = (self.click_count % 3) + 1; // cycles 1..=3 + } else { + self.click_count = 1; + } + self.last_click_time = now; + self.last_click_pos = pos; + } + fn forward_wheel(&mut self, delta: winit::event::MouseScrollDelta) { if !self.point_inside_content(self.last_cursor_pos) { return; } - let point = self.content_point(self.last_cursor_pos); + let pos = self.last_cursor_pos; + let servo_point = self.content_point(pos); + let cef_point = self.content_point_cef(pos); + let flags = cef_host::event_flags(self.modifiers); + // Chromium scrolls down on positive deltaY; winit's line deltas are + // positive when scrolling up, hence the sign flip and the ~40px per + // notch scale. + let (dx, dy) = match delta { + winit::event::MouseScrollDelta::LineDelta(dx, dy) => { + ((-dx * 40.0) as i32, (-dy * 40.0) as i32) + } + winit::event::MouseScrollDelta::PixelDelta(p) => (-p.x as i32, -p.y as i32), + }; if let Some(tab) = self.active_tab_mut() { - if let Some(event) = wheel_input(delta, point) { - tab.webview.notify_input_event(event); + match &mut tab.page { + Page::Servo(page) => { + if let Some(event) = wheel_input(delta, servo_point) { + page.webview.notify_input_event(event); + } + } + Page::Cef(handle) => { + handle.mouse_wheel(cef_point.0, cef_point.1, dx, dy, flags); + } } } } @@ -688,14 +1139,44 @@ impl BrowserApp { if !self.content_focused || self.url_bar_focused { return; } - let parts = servo_host::winit_key_event_to_parts(event); - if let Some(input) = make_keyboard_input(parts, self.modifiers) { - if let Some(tab) = self.active_tab_mut() { - tab.webview.notify_input_event(input); + let flags = cef_host::event_flags(self.modifiers); + let mods = self.modifiers; + if let Some(tab) = self.active_tab_mut() { + match &mut tab.page { + Page::Servo(page) => { + let parts = servo_host::winit_key_event_to_parts(event); + if let Some(input) = make_keyboard_input(parts, mods) { + page.webview.notify_input_event(input); + } + } + Page::Cef(handle) => { + use winit::event::ElementState as ES; + let parts = servo_host::winit_key_event_to_parts(event); + let native = cef_host::mac_native_key_code(parts.code); + let windows = cef_host::windows_key_code(parts.code); + if native == 0 && windows == 0 && event.text.is_none() { + return; + } + if event.state == ES::Pressed { + let text = event.text.as_ref().and_then(|s| s.chars().next()); + handle.key_down(text, native, windows, flags); + } else { + handle.key_up(native, windows, flags); + } + } } } } + /// Click position in CEF view coordinates (logical pixels relative to the + /// content rect origin). + fn content_point_cef(&self, pos: PhysicalPosition) -> (i32, i32) { + ( + ((pos.x / self.scale_factor) as f32 - self.content_rect.min.x).floor() as i32, + ((pos.y / self.scale_factor) as f32 - self.content_rect.min.y).floor() as i32, + ) + } + fn redraw(&mut self) { let Some(window) = self.window.clone() else { return; @@ -706,6 +1187,12 @@ impl BrowserApp { engine.spin(); self.drain_engine_messages(); + if self.cef_ready { + // One pump pass per redraw keeps CEF callbacks flowing even when + // no scheduled wake-up is pending. + cef_host::pump(); + self.drain_cef_events(); + } if let Some(index) = self.pending_close.take() { self.close_tab(index); } @@ -748,6 +1235,12 @@ impl BrowserApp { } other => { log::warn!("get_current_texture: {other:?}"); + // Deltas must still be disposed of — dropping them unapplied + // makes epaint panic. + for id in &full_output.textures_delta.free { + gpu.renderer.free_texture(id); + } + full_output.textures_delta.clear(); window.request_redraw(); return; } @@ -817,6 +1310,15 @@ fn nav_button(ui: &mut egui::Ui, glyph: &str, tip: &str) -> egui::Response { .on_hover_text(tip) } +fn cef_mouse_button(button: winit::event::MouseButton) -> Option { + match button { + winit::event::MouseButton::Left => Some(cef::MouseButtonType::LEFT), + winit::event::MouseButton::Right => Some(cef::MouseButtonType::RIGHT), + winit::event::MouseButton::Middle => Some(cef::MouseButtonType::MIDDLE), + _ => None, + } +} + fn truncate_title(title: &str, max_chars: usize) -> String { if title.chars().count() <= max_chars { title.to_owned() @@ -863,6 +1365,14 @@ impl ApplicationHandler for BrowserApp { } fn exiting(&mut self, _event_loop: &ActiveEventLoop) { + // Ask CEF browsers to close first so their on_before_close callbacks + // reach a live runtime. + for tab in &self.tabs { + if let Some(handle) = tab.cef_handle() { + handle.close(); + } + } + cef_host::pump(); // Close all WebViews while the Servo instance is still alive, so their // shutdown messages reach a running constellation instead of a dying one. if let Some(engine) = self.engine.take() { @@ -870,6 +1380,9 @@ impl ApplicationHandler for BrowserApp { self.drain_engine_messages(); } self.tabs.clear(); + if self.cef_ready { + cef_host::shutdown_runtime(); + } } fn window_event( @@ -900,10 +1413,23 @@ impl ApplicationHandler for BrowserApp { } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { self.scale_factor = *scale_factor; - for tab in &self.tabs { - tab.webview - .set_hidpi_scale_factor(euclid::Scale::new(*scale_factor as f32)); + for tab in &mut self.tabs { + match &mut tab.page { + Page::Servo(page) => { + page.webview + .set_hidpi_scale_factor(euclid::Scale::new(*scale_factor as f32)); + } + Page::Cef(handle) => { + let logical = ( + self.content_rect.width().max(1.0), + self.content_rect.height().max(1.0), + ); + handle.resize(logical, *scale_factor as f32); + tab.dirty = true; + } + } } + window.request_redraw(); } WindowEvent::CursorMoved { position, .. } => { self.last_cursor_pos = *position; @@ -958,6 +1484,17 @@ impl ApplicationHandler for BrowserApp { window.request_redraw(); } } + UserEvent::PumpCef(delay) => { + if !self.cef_ready { + return; + } + event_loop.set_control_flow(ControlFlow::WaitUntil(Instant::now() + delay)); + cef_host::pump(); + // Paint callbacks may have queued frames; a redraw drains them. + if let Some(window) = self.window.as_ref() { + window.request_redraw(); + } + } } } } diff --git a/src/cef_host.rs b/src/cef_host.rs new file mode 100644 index 0000000..7fca63b --- /dev/null +++ b/src/cef_host.rs @@ -0,0 +1,1107 @@ +//! Chromium (CEF) backend: runtime bootstrap, per-tab handlers, and the +//! input/paint plumbing used by the UI layer. +//! +//! Threading model: CEF is configured with `external_message_pump`, so every +//! browser-process callback fires on the main thread while we call +//! `do_message_loop_work()` from the winit event loop. Shared tab state is +//! therefore `Arc>` purely as defensive practice. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use cef::sys::CEF_API_VERSION_LAST; +use cef::{ + api_hash, args::Args, browser_host_create_browser_sync, do_message_loop_work, + execute_process, initialize, rc::Rc, shutdown, wrap_app, wrap_browser_process_handler, + wrap_client, wrap_display_handler, wrap_life_span_handler, wrap_load_handler, + wrap_render_handler, App, Browser, BrowserProcessHandler, BrowserSettings, Client, + CefString, DisplayHandler, ImplApp, ImplBrowser, ImplBrowserHost, ImplBrowserProcessHandler, + ImplClient, ImplCommandLine, ImplDisplayHandler, ImplFrame, ImplLifeSpanHandler, ImplLoadHandler, + ImplRenderHandler, KeyEvent, KeyEventType, LifeSpanHandler, LoadHandler, MouseButtonType, + MouseEvent, PaintElementType, Rect, RenderHandler, ScreenInfo, Settings, WindowInfo, + WrapApp, WrapBrowserProcessHandler, WrapClient, WrapDisplayHandler, WrapLifeSpanHandler, + WrapLoadHandler, WrapRenderHandler, +}; +use keyboard_types::{Code, Modifiers}; +use winit::event_loop::EventLoopProxy; + +use crate::UserEvent; + +/// Events delivered from CEF callbacks to the UI thread. +#[derive(Debug)] +pub enum CefEvent { + UrlChanged(u64, String), + TitleChanged(u64, Option), + StatusText(u64, Option), + LoadingChanged(u64, bool), + FrameReady(u64), + Closed(u64), +} + +pub struct CefFrame { + pub width: u32, + pub height: u32, + /// BGRA byte buffer as handed to us by CEF. + pub bgra: Vec, +} + +/// Per-tab state shared between the CEF callbacks and the UI loop. +#[derive(Default)] +pub struct CefTabShared { + /// Our tab id (mirrors `Tab::id` in app.rs). + pub id: u64, + pub events: Vec, + /// Latest full-frame paint, ready for texture upload. + pub frame: Option, + /// View size in logical pixels (set by the UI on resize). + pub size_logical: (f32, f32), + pub device_scale_factor: f32, +} + +pub type SharedCefTab = Arc>; + +static PUMP_PROXY: OnceLock> = OnceLock::new(); +static CONTEXT_READY: AtomicBool = AtomicBool::new(false); + +/// True once CEF's OnContextInitialized callback has run — browsers may only +/// be created after this. +pub fn context_ready() -> bool { + CONTEXT_READY.load(Ordering::Acquire) +} + +pub fn set_pump_proxy(proxy: EventLoopProxy) { + let _ = PUMP_PROXY.set(proxy); +} + +fn schedule_pump(delay: Duration) { + if let Some(proxy) = PUMP_PROXY.get() { + let _ = proxy.send_event(UserEvent::PumpCef(delay)); + } +} + +fn pump_now() { + schedule_pump(Duration::ZERO); +} + +// --------------------------------------------------------------------------- +// Bootstrap / lifecycle +// --------------------------------------------------------------------------- + +#[cfg(target_os = "macos")] +fn load_framework_if_bundled() { + use std::ffi::CString; + + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(dir) = exe.parent() else { + return; + }; + let bundled = dir + .join("../Frameworks") + .join(cef_dll_bootstrap_path()); + if bundled.canonicalize().is_ok() { + // Running from a .app bundle — load relative to the executable. + let loader = cef::library_loader::LibraryLoader::new(&exe, false); + assert!(loader.load(), "failed to dlopen the CEF framework"); + Box::leak(Box::new(loader)); + return; + } + + // Bare dev run: look for an exported CEF distribution instead. + let dist = cef_dist_dir(); + let framework = dist.join(cef_dll_bootstrap_path()); + if !framework.is_file() { + panic!( + "CEF framework not found (looked in {}); run \ + `cargo install export-cef-dir --version 151.7.0+151.3.23 && export-cef-dir` first", + dist.display() + ); + } + let cpath = CString::new(framework.to_string_lossy().into_owned()).unwrap(); + assert!( + unsafe { cef::sys::cef_load_library(cpath.as_ptr()) == 1 }, + "failed to dlopen CEF from {}", + framework.display() + ); +} + +#[cfg(target_os = "macos")] +fn cef_dll_bootstrap_path() -> PathBuf { + PathBuf::from("Chromium Embedded Framework.framework/Chromium Embedded Framework") +} + +/// Directory containing the exported CEF distribution (`CEF_PATH` or +/// `~/.local/share/cef`). +#[cfg(target_os = "macos")] +pub fn cef_dist_dir() -> PathBuf { + match std::env::var("CEF_PATH") { + Ok(p) => PathBuf::from(p), + Err(_) => std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".local/share/cef")) + .expect("no home directory"), + } +} + +/// Returns the framework's Resources dir when running from a bundle. +#[cfg(target_os = "macos")] +fn cef_framework_resources() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let path = dir.join("../Frameworks/Chromium Embedded Framework.framework/Resources"); + path.canonicalize().ok() +} + +/// Runs the pre-winit CEF handshake. Must be called before creating the winit +/// event loop so that helper processes never initialise AppKit. +/// +/// Returns `true` when this process is the browser process and should continue +/// booting the app; `false` when this process was a CEF helper that has already +/// finished its work. +pub fn bootstrap() -> bool { + #[cfg(target_os = "macos")] + load_framework_if_bundled(); + + let _ = api_hash(CEF_API_VERSION_LAST, 0); + + let args = Args::new(); + let mut app = make_app(); + let ret = execute_process(Some(args.as_main_args()), Some(&mut app), std::ptr::null_mut()); + ret == -1 +} + +/// Initialises the CEF browser-process runtime. Call once from the main thread +/// after the pump proxy is installed. +pub fn initialize_runtime() -> Result<(), String> { + let mut settings = Settings::default(); + settings.windowless_rendering_enabled = 1; + settings.external_message_pump = 1; + // TODO: drop this once we ship proper "Fox Helper.app" subprocess bundles + // (the Chromium mac sandbox needs them). + settings.no_sandbox = 1; + settings.background_color = 0xFFFF_FFFF; // opaque white while loading + + if let Ok(home) = std::env::var("HOME") { + let cache = std::path::PathBuf::from(home).join("Library/Caches/Fox/CEF"); + let _ = std::fs::create_dir_all(&cache); + let mut log_path = cache.clone(); + log_path.push("cef.log"); + settings.root_cache_path = CefString::from(cache.to_string_lossy().as_ref()); + settings.cache_path = settings.root_cache_path.clone(); + settings.log_file = CefString::from(log_path.to_string_lossy().as_ref()); + settings.log_severity = cef::LogSeverity::WARNING; + } + + // When not running from a .app bundle, point CEF at the exported dist so + // it can find icudtl.dat / *.pak / locales. + #[cfg(target_os = "macos")] + { + // Child processes must be launched from a helper .app bundle. + let subprocess = match cef_framework_resources() { + Some(resources) => resources + .parent() + .and_then(|p| p.parent()) + .map(|frameworks| frameworks.join("Fox Helper.app/Contents/MacOS/Fox Helper")), + None => None, + } + .unwrap_or_else(|| std::env::current_exe().expect("current exe")); + settings.browser_subprocess_path = + CefString::from(subprocess.to_string_lossy().as_ref()); + + if cef_framework_resources().is_none() { + let dist = cef_dist_dir(); + let resources = dist + .join("Chromium Embedded Framework.framework") + .join("Resources"); + settings.resources_dir_path = CefString::from(resources.to_string_lossy().as_ref()); + settings.framework_dir_path = CefString::from(dist.to_string_lossy().as_ref()); + } + } + + let args = Args::new(); + let mut app = make_app(); + if initialize( + Some(args.as_main_args()), + Some(&settings), + Some(&mut app), + std::ptr::null_mut(), + ) != 1 + { + return Err("cef_initialize failed".to_owned()); + } + + // Browsers may not be created until OnContextInitialized has fired; pump + // the loop until it does (bounded so a stuck CEF can't hang startup). + for _ in 0..500 { + if context_ready() { + return Ok(()); + } + do_message_loop_work(); + std::thread::sleep(Duration::from_millis(2)); + } + Err("cef OnContextInitialized never fired".to_owned()) +} + +/// Tears down CEF after all browsers have been closed/dropped. +pub fn shutdown_runtime() { + shutdown(); +} + +// --------------------------------------------------------------------------- +// Diagnostic probe +// --------------------------------------------------------------------------- + +/// FOX_CEF_PROBE=1: drive the CEF lifecycle step by step with logging, no UI, +/// so we can see exactly which API call dies. +#[allow(dead_code)] +pub fn run_probe() -> bool { + if std::env::var("FOX_CEF_PROBE").is_err() { + return false; + } + + macro_rules! step { + ($name:expr, $code:block) => {{ + eprintln!("PROBE >>> begin {}", $name); + let outcome = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| $code)); + match outcome { + Ok(_) => eprintln!("PROBE <<< ok {}", $name), + Err(_) => { + eprintln!("PROBE !!! PANIC {}", $name); + std::process::exit(70); + } + } + }}; + } + + fn pump_for(ms: u64) -> usize { + let deadline = std::time::Instant::now() + Duration::from_millis(ms); + let mut pumps = 0; + while std::time::Instant::now() < deadline { + do_message_loop_work(); + pumps += 1; + std::thread::sleep(Duration::from_millis(10)); + } + pumps + } + + step!("initialize_runtime", { + initialize_runtime().expect("initialize_runtime"); + }); + + // Each case mirrors what BrowserApp does, varying the suspects: + // timing (immediate vs warmed-up), size change, and scale factor. + let cases: &[(&str, f32, bool)] = &[ + // (label, device_scale_factor, resize_immediately_after_create) + ("warmed_no_change_dsf1", 1.0, false), + ("warmed_no_change_dsf2", 2.0, false), + ("immediate_resize_dsf1", 1.0, true), + ("immediate_resize_dsf2", 2.0, true), + ("warmed_resize_dsf2", 2.0, false), + ]; + + for (label, dsf, immediate) in cases { + eprintln!("PROBE === case {label} ==="); + let mut slot: Option = None; + step!("create_browser", { + slot = Some(CefTabHandle::create(1, "https://example.com/", (400.0, 300.0), *dsf)); + }); + let tab = match slot { + Some(t) => t, + None => { + eprintln!("PROBE !!! create_browser did not produce a browser"); + std::process::exit(71); + } + }; + + if *immediate { + step!("resize_immediately", { + tab.resize((800.0, 600.0), *dsf); + }); + } else { + step!("pump_500ms", { + let pumps = pump_for(500); + eprintln!("PROBE pumped {pumps} times"); + }); + step!("resize_after_warmup", { + tab.resize((800.0, 600.0), *dsf); + }); + } + + step!("pump_1000ms", { + let pumps = pump_for(1000); + eprintln!("PROBE pumped {pumps} times, frame_present={}", + tab.take_frame().is_some()); + }); + + step!("close", { + tab.close(); + pump_for(500); + }); + } + + step!("shutdown", { + shutdown_runtime(); + }); + + eprintln!("PROBE COMPLETE"); + true +} + +/// One iteration of CEF's message loop; call from the main thread only. +pub fn pump() { + do_message_loop_work(); +} + +// --------------------------------------------------------------------------- +// App + BrowserProcessHandler (command-line tweaks & pump scheduling) +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FoxBrowserProcessHandler; + +wrap_browser_process_handler! { + struct FoxBrowserProcessHandlerBuilder { + handler: FoxBrowserProcessHandler, + } + + impl BrowserProcessHandler { + fn on_context_initialized(&self) { + CONTEXT_READY.store(true, Ordering::Release); + pump_now(); + } + + fn on_schedule_message_pump_work(&self, delay_ms: i64) { + let delay = Duration::from_millis(delay_ms.clamp(0, 250) as u64); + schedule_pump(delay); + } + } +} + +impl FoxBrowserProcessHandlerBuilder { + fn build(handler: FoxBrowserProcessHandler) -> cef::BrowserProcessHandler { + Self::new(handler) + } +} + +#[derive(Clone)] +struct FoxApp; + +wrap_app! { + struct FoxAppBuilder { + app: FoxApp, + } + + impl App { + fn on_before_command_line_processing( + &self, + _process_type: Option<&CefString>, + command_line: Option<&mut cef::CommandLine>, + ) { + let Some(command_line) = command_line else { + return; + }; + command_line.append_switch(Some(&"no-startup-window".into())); + command_line.append_switch(Some(&"noerrdialogs".into())); + command_line.append_switch(Some(&"hide-crash-restore-bubble".into())); + command_line.append_switch(Some(&"use-mock-keychain".into())); + command_line.append_switch_with_value( + Some(&"remote-debugging-port".into()), + Some(&"9222".into()), + ); + } + + fn browser_process_handler(&self) -> Option { + Some(FoxBrowserProcessHandlerBuilder::build(FoxBrowserProcessHandler)) + } + } +} + +impl FoxAppBuilder { + fn build(app: FoxApp) -> cef::App { + Self::new(app) + } +} + +fn make_app() -> cef::App { + FoxAppBuilder::build(FoxApp) +} + +// --------------------------------------------------------------------------- +// Per-tab handlers +// --------------------------------------------------------------------------- + +fn shared_id(shared: &SharedCefTab) -> u64 { + shared.lock().map(|s| s.id).unwrap_or(0) +} + +fn push_event(shared: &SharedCefTab, event: CefEvent) { + if let Ok(mut shared) = shared.lock() { + shared.events.push(event); + } + pump_now(); +} + +#[derive(Clone)] +struct TabRender { + shared: SharedCefTab, +} + +wrap_render_handler! { + struct TabRenderBuilder { + handler: TabRender, + } + + impl RenderHandler { + fn view_rect(&self, _browser: Option<&mut Browser>, rect: Option<&mut Rect>) { + if let (Some(rect), Ok(shared)) = (rect, self.handler.shared.lock()) { + let (w, h) = shared.size_logical; + if w > 0.0 && h > 0.0 { + rect.width = w as _; + rect.height = h as _; + } + } + } + + fn screen_info( + &self, + _browser: Option<&mut Browser>, + screen_info: Option<&mut ScreenInfo>, + ) -> ::std::os::raw::c_int { + if let (Some(info), Ok(shared)) = (screen_info, self.handler.shared.lock()) { + info.device_scale_factor = shared.device_scale_factor; + return 1; + } + 0 + } + + fn screen_point( + &self, + _browser: Option<&mut Browser>, + _view_x: ::std::os::raw::c_int, + _view_y: ::std::os::raw::c_int, + _screen_x: Option<&mut ::std::os::raw::c_int>, + _screen_y: Option<&mut ::std::os::raw::c_int>, + ) -> ::std::os::raw::c_int { + 0 + } + + fn on_paint( + &self, + _browser: Option<&mut Browser>, + type_: PaintElementType, + _dirty_rects: Option<&[Rect]>, + buffer: *const u8, + width: ::std::os::raw::c_int, + height: ::std::os::raw::c_int, + ) { + if type_ != PaintElementType::VIEW || buffer.is_null() || width <= 0 || height <= 0 { + return; + } + let size = (width * height * 4) as usize; + let slice = unsafe { std::slice::from_raw_parts(buffer, size) }; + let id = self.handler.shared.lock().map(|s| s.id).unwrap_or(0); + if let Ok(mut shared) = self.handler.shared.lock() { + shared.frame = Some(CefFrame { + width: width as u32, + height: height as u32, + bgra: slice.to_vec(), + }); + shared.events.push(CefEvent::FrameReady(id)); + } + } + } +} + +impl TabRenderBuilder { + fn build(handler: TabRender) -> cef::RenderHandler { + Self::new(handler) + } +} + +#[derive(Clone)] +struct TabDisplay { + shared: SharedCefTab, +} + +wrap_display_handler! { + struct TabDisplayBuilder { + handler: TabDisplay, + } + + impl DisplayHandler { + fn on_address_change( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut cef::Frame>, + url: Option<&CefString>, + ) { + let id = shared_id(&self.handler.shared); + let url = url.map(|u| u.to_string()).unwrap_or_default(); + push_event(&self.handler.shared, CefEvent::UrlChanged(id, url)); + } + + fn on_title_change(&self, _browser: Option<&mut Browser>, title: Option<&CefString>) { + let id = shared_id(&self.handler.shared); + let title = title.map(|t| t.to_string()).filter(|t| !t.is_empty()); + push_event(&self.handler.shared, CefEvent::TitleChanged(id, title)); + } + + fn on_status_message(&self, _browser: Option<&mut Browser>, value: Option<&CefString>) { + let id = shared_id(&self.handler.shared); + let status = value.map(|v| v.to_string()).filter(|s| !s.is_empty()); + push_event(&self.handler.shared, CefEvent::StatusText(id, status)); + } + } +} + +impl TabDisplayBuilder { + fn build(handler: TabDisplay) -> cef::DisplayHandler { + Self::new(handler) + } +} + +#[derive(Clone)] +struct TabLoad { + shared: SharedCefTab, +} + +wrap_load_handler! { + struct TabLoadBuilder { + handler: TabLoad, + } + + impl LoadHandler { + fn on_loading_state_change( + &self, + _browser: Option<&mut Browser>, + is_loading: ::std::os::raw::c_int, + _can_go_back: ::std::os::raw::c_int, + _can_go_forward: ::std::os::raw::c_int, + ) { + let id = shared_id(&self.handler.shared); + push_event(&self.handler.shared, CefEvent::LoadingChanged(id, is_loading == 1)); + } + } +} + +impl TabLoadBuilder { + fn build(handler: TabLoad) -> cef::LoadHandler { + Self::new(handler) + } +} + +#[derive(Clone)] +struct TabLifeSpan { + shared: SharedCefTab, +} + +wrap_life_span_handler! { + struct TabLifeSpanBuilder { + handler: TabLifeSpan, + } + + impl LifeSpanHandler { + fn on_before_close(&self, _browser: Option<&mut Browser>) { + let id = shared_id(&self.handler.shared); + push_event(&self.handler.shared, CefEvent::Closed(id)); + } + + fn on_before_popup( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut cef::Frame>, + _popup_id: ::std::os::raw::c_int, + _target_url: Option<&CefString>, + _target_frame_name: Option<&CefString>, + _target_disposition: cef::WindowOpenDisposition, + _user_gesture: ::std::os::raw::c_int, + _popup_features: Option<&cef::PopupFeatures>, + _window_info: Option<&mut WindowInfo>, + _client: Option<&mut Option>, + _settings: Option<&mut cef::BrowserSettings>, + _extra_info: Option<&mut Option>, + _no_javascript_access: Option<&mut ::std::os::raw::c_int>, + ) -> ::std::os::raw::c_int { + // Block popups for now — surfacing them as tabs needs more plumbing. + 1 + } + } +} + +impl TabLifeSpanBuilder { + fn build(handler: TabLifeSpan) -> cef::LifeSpanHandler { + Self::new(handler) + } +} + +wrap_client! { + struct TabClient { + render: cef::RenderHandler, + display: cef::DisplayHandler, + load: cef::LoadHandler, + life_span: cef::LifeSpanHandler, + } + + impl Client { + fn render_handler(&self) -> Option { + Some(self.render.clone()) + } + + fn display_handler(&self) -> Option { + Some(self.display.clone()) + } + + fn load_handler(&self) -> Option { + Some(self.load.clone()) + } + + fn life_span_handler(&self) -> Option { + Some(self.life_span.clone()) + } + } +} + +impl TabClient { + fn build(shared: SharedCefTab) -> Client { + let render = TabRenderBuilder::build(TabRender { + shared: shared.clone(), + }); + let display = TabDisplayBuilder::build(TabDisplay { + shared: shared.clone(), + }); + let load = TabLoadBuilder::build(TabLoad { + shared: shared.clone(), + }); + let life_span = TabLifeSpanBuilder::build(TabLifeSpan { shared }); + Self::new(render, display, load, life_span) + } +} + +// --------------------------------------------------------------------------- +// Tab handle +// --------------------------------------------------------------------------- + +/// A handle to one CEF browser ("tab"). Dropping it releases our reference; +/// `close()` asks Chromium to tear the browser down properly. +pub struct CefTabHandle { + #[allow(dead_code)] + pub id: u64, + pub browser: Browser, + #[allow(dead_code)] + pub client: Client, + pub shared: SharedCefTab, +} + +impl CefTabHandle { + pub fn create(id: u64, url: &str, logical_size: (f32, f32), dsf: f32) -> CefTabHandle { + assert!( + context_ready(), + "browser created before OnContextInitialized" + ); + let shared: SharedCefTab = Arc::new(Mutex::new(CefTabShared { + id, + size_logical: logical_size, + device_scale_factor: dsf, + ..Default::default() + })); + let client = TabClient::build(shared.clone()); + + let window_info = WindowInfo { + windowless_rendering_enabled: 1, + ..Default::default() + }; + let browser_settings = BrowserSettings { + windowless_frame_rate: 60, + ..Default::default() + }; + + let browser = browser_host_create_browser_sync( + Some(&window_info), + Some(&mut client.clone()), + Some(&CefString::from(url)), + Some(&browser_settings), + None, + None, + ) + .expect("browser_host_create_browser_sync failed"); + + CefTabHandle { + id, + browser, + client, + shared, + } + } + + pub fn resize(&self, logical_size: (f32, f32), dsf: f32) { + // Degenerate rects (e.g. egui's Rect::NOTHING before the first + // layout) must never reach Chromium — it CHECK-fails on empty views. + if !logical_size.0.is_finite() || !logical_size.1.is_finite() + || logical_size.0 < 1.0 || logical_size.1 < 1.0 + || !dsf.is_finite() || dsf <= 0.0 + { + return; + } + if let Ok(mut shared) = self.shared.lock() { + let changed = + shared.size_logical != logical_size || shared.device_scale_factor != dsf; + shared.size_logical = logical_size; + shared.device_scale_factor = dsf; + if !changed { + return; + } + } + if let Some(host) = self.browser.host() { + host.was_resized(); + } + } + + pub fn drain_events(&self) -> Vec { + match self.shared.lock() { + Ok(mut shared) => std::mem::take(&mut shared.events), + Err(_) => Vec::new(), + } + } + + pub fn take_frame(&self) -> Option { + self.shared.lock().ok()?.frame.take() + } + + pub fn navigate(&self, url: &str) { + if let Some(frame) = self.browser.main_frame() { + frame.load_url(Some(&CefString::from(url))); + } + } + + pub fn go_back(&self) { + self.browser.go_back(); + } + + pub fn go_forward(&self) { + self.browser.go_forward(); + } + + pub fn reload(&self) { + self.browser.reload(); + } + + pub fn can_go_back(&self) -> bool { + self.browser.can_go_back() == 1 + } + + pub fn can_go_forward(&self) -> bool { + self.browser.can_go_forward() == 1 + } + + pub fn focus(&self, focused: bool) { + if let Some(host) = self.browser.host() { + host.set_focus(focused as _); + } + } + + pub fn close(&self) { + if let Some(host) = self.browser.host() { + host.close_browser(1); + } + } + + // -- input -------------------------------------------------------------- + + fn mouse_event(x: i32, y: i32, modifiers: u32) -> MouseEvent { + MouseEvent { x, y, modifiers } + } + + pub fn mouse_move(&self, x: i32, y: i32, modifiers: u32) { + if let Some(host) = self.browser.host() { + host.send_mouse_move_event(Some(&Self::mouse_event(x, y, modifiers)), 0); + } + } + + #[allow(dead_code)] + pub fn mouse_leave(&self, modifiers: u32) { + if let Some(host) = self.browser.host() { + host.send_mouse_move_event(Some(&Self::mouse_event(-1, -1, modifiers)), 1); + } + } + + pub fn mouse_click( + &self, + x: i32, + y: i32, + button: MouseButtonType, + up: bool, + click_count: u32, + modifiers: u32, + ) { + if let Some(host) = self.browser.host() { + host.send_mouse_click_event( + Some(&Self::mouse_event(x, y, modifiers)), + button, + up as _, + click_count as _, + ); + } + } + + pub fn mouse_wheel(&self, x: i32, y: i32, dx: i32, dy: i32, modifiers: u32) { + if let Some(host) = self.browser.host() { + host.send_mouse_wheel_event( + Some(&Self::mouse_event(x, y, modifiers)), + dx, + dy, + ); + } + } + + pub fn key_down(&self, text: Option, native_key_code: i32, windows_key_code: i32, modifiers: u32) { + let Some(host) = self.browser.host() else { + return; + }; + let mut event = KeyEvent { + type_: KeyEventType::RAWKEYDOWN, + modifiers, + windows_key_code, + native_key_code, + ..Default::default() + }; + host.send_key_event(Some(&event)); + + if let Some(ch) = text { + let ch = control_char_for(ch); + event.type_ = KeyEventType::CHAR; + event.character = ch as u16; + event.unmodified_character = ch as u16; + host.send_key_event(Some(&event)); + } + } + + pub fn key_up(&self, native_key_code: i32, windows_key_code: i32, modifiers: u32) { + if let Some(host) = self.browser.host() { + let event = KeyEvent { + type_: KeyEventType::KEYUP, + modifiers, + windows_key_code, + native_key_code, + ..Default::default() + }; + host.send_key_event(Some(&event)); + } + } +} + +fn control_char_for(ch: char) -> char { + match ch { + '\r' | '\n' => '\r', + '\t' => '\t', + c => c, + } +} + +// --------------------------------------------------------------------------- +// Keyboard mapping helpers +// --------------------------------------------------------------------------- + +/// macOS virtual key code for a physical key, where known. +pub fn mac_native_key_code(code: Code) -> i32 { + match code { + Code::KeyA => 0x00, + Code::KeyS => 0x01, + Code::KeyD => 0x02, + Code::KeyF => 0x03, + Code::KeyH => 0x04, + Code::KeyG => 0x05, + Code::KeyZ => 0x06, + Code::KeyX => 0x07, + Code::KeyC => 0x08, + Code::KeyV => 0x09, + Code::KeyB => 0x0B, + Code::KeyQ => 0x0C, + Code::KeyW => 0x0D, + Code::KeyE => 0x0E, + Code::KeyR => 0x0F, + Code::KeyY => 0x10, + Code::KeyT => 0x11, + Code::Digit1 => 0x12, + Code::Digit2 => 0x13, + Code::Digit3 => 0x14, + Code::Digit4 => 0x15, + Code::Digit6 => 0x16, + Code::Digit5 => 0x17, + Code::Equal => 0x18, + Code::Digit9 => 0x19, + Code::Digit7 => 0x1A, + Code::Minus => 0x1B, + Code::Digit8 => 0x1C, + Code::Digit0 => 0x1D, + Code::BracketRight => 0x1E, + Code::KeyO => 0x1F, + Code::KeyU => 0x20, + Code::BracketLeft => 0x21, + Code::KeyI => 0x22, + Code::KeyP => 0x23, + Code::Enter => 0x24, + Code::Quote => 0x27, + Code::KeyK => 0x28, + Code::Semicolon => 0x29, + Code::Backslash => 0x2A, + Code::Comma => 0x2B, + Code::Slash => 0x2C, + Code::KeyN => 0x2D, + Code::KeyM => 0x2E, + Code::Period => 0x2F, + Code::Tab => 0x30, + Code::Space => 0x31, + Code::Backquote => 0x32, + Code::Backspace => 0x33, + Code::Escape => 0x35, + Code::MetaRight => 0x36, + Code::MetaLeft => 0x37, + Code::ShiftLeft => 0x38, + Code::CapsLock => 0x39, + Code::AltLeft => 0x3A, + Code::ControlLeft => 0x3B, + Code::ShiftRight => 0x3C, + Code::AltRight => 0x3D, + Code::ControlRight => 0x3E, + Code::F17 => 0x40, + Code::AudioVolumeUp => 0x48, + Code::AudioVolumeDown => 0x49, + Code::AudioVolumeMute => 0x4A, + Code::F18 => 0x4F, + Code::F19 => 0x50, + Code::F20 => 0x5A, + Code::F5 => 0x60, + Code::F6 => 0x61, + Code::F7 => 0x62, + Code::F3 => 0x63, + Code::F8 => 0x64, + Code::F9 => 0x65, + Code::F11 => 0x67, + Code::F13 => 0x69, + Code::F16 => 0x6A, + Code::F14 => 0x6B, + Code::F10 => 0x6D, + Code::F12 => 0x6F, + Code::F15 => 0x71, + Code::Insert => 0x72, + Code::Home => 0x73, + Code::PageUp => 0x74, + Code::Delete => 0x75, + Code::F4 => 0x76, + Code::End => 0x77, + Code::F2 => 0x78, + Code::PageDown => 0x79, + Code::F1 => 0x7A, + Code::ArrowLeft => 0x7B, + Code::ArrowRight => 0x7C, + Code::ArrowDown => 0x7D, + Code::ArrowUp => 0x7E, + _ => 0, + } +} + +/// Windows VK_* code (Chromium's keyboard_codes.h values) for common keys. +pub fn windows_key_code(code: Code) -> i32 { + match code { + Code::KeyA => 0x41, + Code::KeyB => 0x42, + Code::KeyC => 0x43, + Code::KeyD => 0x44, + Code::KeyE => 0x45, + Code::KeyF => 0x46, + Code::KeyG => 0x47, + Code::KeyH => 0x48, + Code::KeyI => 0x49, + Code::KeyJ => 0x4A, + Code::KeyK => 0x4B, + Code::KeyL => 0x4C, + Code::KeyM => 0x4D, + Code::KeyN => 0x4E, + Code::KeyO => 0x4F, + Code::KeyP => 0x50, + Code::KeyQ => 0x51, + Code::KeyR => 0x52, + Code::KeyS => 0x53, + Code::KeyT => 0x54, + Code::KeyU => 0x55, + Code::KeyV => 0x56, + Code::KeyW => 0x57, + Code::KeyX => 0x58, + Code::KeyY => 0x59, + Code::KeyZ => 0x5A, + Code::Digit0 => 0x30, + Code::Digit1 => 0x31, + Code::Digit2 => 0x32, + Code::Digit3 => 0x33, + Code::Digit4 => 0x34, + Code::Digit5 => 0x35, + Code::Digit6 => 0x36, + Code::Digit7 => 0x37, + Code::Digit8 => 0x38, + Code::Digit9 => 0x39, + Code::Enter => 0x0D, + Code::Backspace => 0x08, + Code::Tab => 0x09, + Code::Escape => 0x1B, + Code::Space => 0x20, + Code::ArrowLeft => 0x25, + Code::ArrowUp => 0x26, + Code::ArrowRight => 0x27, + Code::ArrowDown => 0x28, + Code::PageUp => 0x21, + Code::PageDown => 0x22, + Code::End => 0x23, + Code::Home => 0x24, + Code::Insert => 0x2D, + Code::Delete => 0x2E, + Code::Minus => 0xBD, + Code::Equal => 0xBB, + Code::BracketLeft => 0xDB, + Code::BracketRight => 0xDD, + Code::Backslash => 0xDC, + Code::Semicolon => 0xBA, + Code::Quote => 0xDE, + Code::Comma => 0xBC, + Code::Period => 0xBE, + Code::Slash => 0xBF, + Code::Backquote => 0xC0, + Code::ShiftLeft | Code::ShiftRight => 0x10, + Code::ControlLeft | Code::ControlRight => 0x11, + Code::AltLeft | Code::AltRight => 0x12, + Code::MetaLeft | Code::MetaRight => 0x5B, + Code::CapsLock => 0x14, + Code::F1 => 0x70, + Code::F2 => 0x71, + Code::F3 => 0x72, + Code::F4 => 0x73, + Code::F5 => 0x74, + Code::F6 => 0x75, + Code::F7 => 0x76, + Code::F8 => 0x77, + Code::F9 => 0x78, + Code::F10 => 0x79, + Code::F11 => 0x7A, + Code::F12 => 0x7B, + _ => 0, + } +} + +/// Map egui/winit-style modifier flags to CEF EVENTFLAG_* bits. +pub fn event_flags(modifiers: Modifiers) -> u32 { + let mut flags = 0u32; + if modifiers.contains(Modifiers::SHIFT) { + flags |= 2; // EVENTFLAG_SHIFT_DOWN + } + if modifiers.contains(Modifiers::CONTROL) { + flags |= 4; // EVENTFLAG_CONTROL_DOWN + } + if modifiers.contains(Modifiers::ALT) { + flags |= 8; // EVENTFLAG_ALT_DOWN + } + if modifiers.contains(Modifiers::META) { + flags |= 128; // EVENTFLAG_COMMAND_DOWN + } + flags +} diff --git a/src/main.rs b/src/main.rs index fbf2c98..64a1bca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,21 @@ mod app; +mod cef_host; mod servo_host; +mod settings; use std::error::Error; +use std::process::ExitCode; use app::BrowserApp; #[derive(Debug)] pub enum UserEvent { RepaintAfter(std::time::Duration), + /// CEF asked for a message-loop pass after this delay. + PumpCef(std::time::Duration), } -fn main() -> Result<(), Box> { +fn main() -> Result> { // Note: logging is initialized by `Servo::setup_logging()` — do not install // another logger here or it will panic with `SetLoggerError`. @@ -18,13 +23,27 @@ fn main() -> Result<(), Box> { .install_default() .expect("Failed to install crypto provider"); + // The CEF handshake must happen before winit creates an NSApplication: + // when this binary is re-executed as a CEF helper process, bootstrap() + // runs the child-process loop to completion and returns false. + if !cef_host::bootstrap() { + return Ok(ExitCode::SUCCESS); + } + + if cef_host::run_probe() { + return Ok(ExitCode::SUCCESS); + } + let event_loop = winit::event_loop::EventLoop::::with_user_event() .build() .expect("Failed to create event loop"); let proxy = event_loop.create_proxy(); - let mut app = BrowserApp::new(proxy); + cef_host::set_pump_proxy(proxy.clone()); + + let settings = settings::Settings::load(); + let mut app = BrowserApp::new(proxy, settings); event_loop.run_app(&mut app)?; - Ok(()) + Ok(ExitCode::SUCCESS) } diff --git a/src/servo_host.rs b/src/servo_host.rs index d2e64fe..7d87692 100644 --- a/src/servo_host.rs +++ b/src/servo_host.rs @@ -1,7 +1,6 @@ use std::cell::RefCell; use std::rc::Rc; -use egui::TextureHandle; use keyboard_types::{Code, Key, KeyState, Location, Modifiers, NamedKey}; use servo::{ DevicePoint, EventLoopWaker, InputEvent, KeyboardEvent as ServoKeyboardEvent, LoadStatus, @@ -124,7 +123,7 @@ impl Engine { self.servo.spin_event_loop(); } - pub fn create_tab(&self, url: Url, hidpi_scale: f32) -> Tab { + pub fn create_tab(&self, url: Url, hidpi_scale: f32) -> ServoPage { let size = self.window_context.size(); let offscreen: Rc = Rc::new(self.window_context.offscreen_context(size)); @@ -137,45 +136,27 @@ impl Engine { .delegate(delegate) .build(); webview.notify_theme_change(Theme::Light); - Tab::new(webview) + ServoPage::new(webview) } } -pub struct Tab { +/// Engine-side handle for one Servo tab. UI state (title, texture, …) lives in +/// `app::Tab`; this only carries what Servo needs. +pub struct ServoPage { pub webview: WebView, pub id: WebViewId, - pub title: String, - pub url_string: String, - pub status_text: Option, - pub loading: bool, - pub animating: bool, - pub dirty: bool, - pub texture: Option, - /// Hash of the last uploaded frame — skips redundant GPU texture uploads. - pub last_frame_hash: u64, - /// Time of the last GPU->CPU readback — throttles readback frequency. - pub last_paint: std::time::Instant, } -impl Tab { - fn new(webview: WebView) -> Tab { - Tab { +impl ServoPage { + fn new(webview: WebView) -> ServoPage { + ServoPage { id: webview.id(), webview, - title: String::from("New Tab"), - url_string: String::new(), - status_text: None, - loading: true, - animating: false, - dirty: true, - texture: None, - last_frame_hash: 0, - last_paint: std::time::Instant::now() - std::time::Duration::from_secs(1), } } - pub fn adopt(webview: WebView) -> Tab { - Tab::new(webview) + pub fn adopt(webview: WebView) -> ServoPage { + Self::new(webview) } } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..841bae5 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Which browser engine renders a tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EngineKind { + #[default] + Chromium, + Servo, +} + +impl EngineKind { + pub fn label(self) -> &'static str { + match self { + EngineKind::Chromium => "Chromium", + EngineKind::Servo => "Servo", + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Settings { + pub default_engine: EngineKind, +} + +impl Settings { + pub fn load() -> Settings { + let Some(text) = std::fs::read_to_string(Self::path()).ok() else { + return Settings::default(); + }; + serde_json::from_str(&text).unwrap_or_default() + } + + pub fn save(&self) { + let path = Self::path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, json); + } + } + + fn path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned()); + PathBuf::from(home) + .join("Library/Application Support/Fox/settings.json") + } +}