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")
+ }
+}