Compare commits

...
4 Commits
Author SHA1 Message Date
miles d6cd7ec94e update 2026-08-21 23:52:13 -05:00
miles f2a1898039 working to make it more stabel 2026-08-21 19:02:37 -05:00
miles 014a8cf8b7 feat: update dependencies and enhance window initialization for rendering context 2026-08-21 18:42:25 -05:00
miles 9446a7d0ab feat: initial implementation of a browser application using egui and wgpu
- Added Cargo.toml with dependencies for egui, wgpu, and other libraries.
- Implemented main application logic in src/app.rs, including window management, rendering, and tab handling.
- Created a new module src/servo_host.rs to manage interactions with the Servo engine and webview.
- Established event handling for user inputs, including keyboard and mouse events.
- Integrated a basic UI layout with a tab strip, toolbar, and status bar.
- Implemented functionality for navigating URLs and managing multiple tabs.
2026-08-21 15:29:21 -05:00
12 changed files with 14089 additions and 1 deletions
+5
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
/target
Generated
+10867
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "browser"
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"
env_logger = "0.11.11"
euclid = "0.22"
image = "0.25"
keyboard-types = "0.8"
log = "0.4.33"
objc2 = "0.6"
objc2-quartz-core = "0.3"
pollster = "0.4"
rustls = "0.23"
servo = "0.5.0"
url = "2"
wgpu = "30.0.0"
winit = "0.30.13"
[profile.dev.package."*"]
# Optimize dependencies (Servo, WebRender, wgpu…) even in dev builds —
# unoptimized engines are unusably slow, while keeping our crate debuggable.
opt-level = 2
[profile.release]
lto = "thin"
codegen-units = 1
+76 -1
View File
@@ -1,2 +1,77 @@
# browser
idk
A minimal Firefox-style web browser built on [Servo](https://servo.org), the Rust web engine — **the newest actively maintained browser built on Servo**, written against the [`servo` crate](https://crates.io/crates/servo) embedding API (v0.1+, released April 2026).
> **Context:** [Verso](https://github.com/versotile-org/verso), the pioneering third-party Servo browser, was archived in October 2025 and is no longer maintained. Servo's own servoshell remains a demo mini-browser for testing the engine. This project picks up the embedder-side baton on the new public `servo` crates.io API — no engine fork required.
## What works
- Tab strip with create/close/switch
- Back / forward / reload navigation with real history state
- URL bar with address parsing and DuckDuckGo search fallback (`localhost` handled too)
- Pages render via Servo into offscreen surfaces and composite under the native chrome
- Mouse, scroll wheel, and keyboard input forwarded to the page
- Live page titles, loading spinners, and hover status bar
- `window.open()` / target=_blank opens a new tab
## Architecture
```
┌──────────────────────────────────────────┐
│ Chrome UI: egui 0.36 + wgpu │ tabs · toolbar · status bar
├──────────────────────────────────────────┤
│ Frame bridge │ WebView::paint() → read_to_image()
├──────────────────────────────────────────┤
│ Engine: servo 0.5 crate │ Stylo · SpiderMonkey · WebRender
│ one WebView per tab, each with its own │
│ OffscreenRenderingContext │
└──────────────────────────────────────────┘
windowing: winit · rendering: wgpu (Metal/Vulkan/DX12)
```
The embedder owns everything above the engine: windowing, chrome, input routing,
and history UI. Servo owns layout, JS, networking, and painting. The two talk through
`WebView::notify_input_event` (down) and a `WebViewDelegate` message queue (up).
## Building
Requires a stable Rust toolchain (1.85+ recommended):
```sh
cargo build --release
cargo run --release
```
First build takes a while — the servo crate compiles SpiderMonkey (C++) and the full
WebRender pipeline. Incremental rebuilds after that are fast.
Tested on macOS (Apple Silicon). Windows and Linux should work in principle since all
dependencies are cross-platform, but they haven't been exercised yet.
## Known limitations
This is an early-days project on top of an engine that is itself pre-1.0:
- No DRM video (Widevine), and some sites will simply not render correctly yet —
that's Servo's web-compat surface area, not something the embedder can fix
- Frames are composited through CPU readback, so heavy pages cost more than a
zero-copy GPU path would (fine for basic browsing)
- No bookmarks/history persistence, downloads, devtools, or preferences UI yet
## Roadmap
- [ ] Zero-copy frame path (share GL textures instead of readback)
- [ ] Bookmarks and browsing history
- [ ] Page context menus
- [ ] Dark mode following system theme
- [ ] Linux CI builds
## Credits
Built on the shoulders of the [Servo project](https://servo.org) (Linux Foundation Europe),
with the chrome rendered by [egui](https://github.com/emilk/egui). Design cues from Firefox.
See also [Verso](https://github.com/versotile-org/verso) (archived) — the pioneering Servo embedder whose contributions live on upstream.
## License
See [LICENSE](LICENSE).
+95
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleExecutable</key><string>browser</string>
<key>CFBundleIconFile</key><string></string>
<key>CFBundleIdentifier</key><string>dev.fox.browser</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>Fox</string>
<key>CFBundleDisplayName</key><string>Fox</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.0</string>
<key>CFBundleVersion</key><string>0.2.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict>
</plist>
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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleExecutable</key><string>$HELPER_NAME</string>
<key>CFBundleIdentifier</key><string>$HELPER_ID</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$HELPER_NAME</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.0</string>
<key>CFBundleVersion</key><string>0.2.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
</dict>
</plist>
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"
+21
View File
@@ -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 "$@"
+1500
View File
File diff suppressed because it is too large Load Diff
+1107
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
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<ExitCode, Box<dyn Error>> {
// Note: logging is initialized by `Servo::setup_logging()` — do not install
// another logger here or it will panic with `SetLoggerError`.
rustls::crypto::aws_lc_rs::default_provider()
.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::<UserEvent>::with_user_event()
.build()
.expect("Failed to create event loop");
let proxy = event_loop.create_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(ExitCode::SUCCESS)
}
+284
View File
@@ -0,0 +1,284 @@
use std::cell::RefCell;
use std::rc::Rc;
use keyboard_types::{Code, Key, KeyState, Location, Modifiers, NamedKey};
use servo::{
DevicePoint, EventLoopWaker, InputEvent, KeyboardEvent as ServoKeyboardEvent, LoadStatus,
MouseButton, MouseButtonAction, OffscreenRenderingContext, RenderingContext, Servo,
ServoBuilder, Theme, WebView, WebViewBuilder, WebViewId, WheelDelta, WheelEvent, WheelMode,
WindowRenderingContext,
};
use url::Url;
pub type SharedQueue = Rc<RefCell<Vec<EngineMsg>>>;
pub enum EngineMsg {
UrlChanged(WebViewId, String),
TitleChanged(WebViewId, Option<String>),
StatusText(WebViewId, Option<String>),
LoadStatus(WebViewId, bool),
NewFrame(WebViewId),
Animating(WebViewId, bool),
Crashed(WebViewId, String),
Closed(WebViewId),
NewWebView(WebView),
}
#[derive(Clone)]
pub struct EguiWaker(pub egui::Context);
impl EventLoopWaker for EguiWaker {
fn clone_box(&self) -> Box<dyn EventLoopWaker> {
Box::new(self.clone())
}
fn wake(&self) {
self.0.request_repaint();
}
}
pub struct BrowserDelegate {
pub queue: SharedQueue,
}
impl servo::WebViewDelegate for BrowserDelegate {
fn notify_url_changed(&self, webview: WebView, url: Url) {
self.queue
.borrow_mut()
.push(EngineMsg::UrlChanged(webview.id(), url.to_string()));
}
fn notify_page_title_changed(&self, webview: WebView, title: Option<String>) {
self.queue
.borrow_mut()
.push(EngineMsg::TitleChanged(webview.id(), title));
}
fn notify_status_text_changed(&self, webview: WebView, status: Option<String>) {
self.queue
.borrow_mut()
.push(EngineMsg::StatusText(webview.id(), status));
}
fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
let loading = !matches!(status, LoadStatus::Complete);
self.queue
.borrow_mut()
.push(EngineMsg::LoadStatus(webview.id(), loading));
}
fn notify_new_frame_ready(&self, webview: WebView) {
self.queue
.borrow_mut()
.push(EngineMsg::NewFrame(webview.id()));
}
fn notify_animating_changed(&self, webview: WebView, animating: bool) {
self.queue
.borrow_mut()
.push(EngineMsg::Animating(webview.id(), animating));
}
fn notify_crashed(&self, webview: WebView, reason: String, _backtrace: Option<String>) {
self.queue
.borrow_mut()
.push(EngineMsg::Crashed(webview.id(), reason));
}
fn notify_closed(&self, webview: WebView) {
self.queue.borrow_mut().push(EngineMsg::Closed(webview.id()));
}
fn request_create_new(&self, parent_webview: WebView, request: servo::CreateNewWebViewRequest) {
let webview = request
.builder(parent_webview.rendering_context())
.delegate(parent_webview.delegate())
.build();
webview.notify_theme_change(Theme::Light);
self.queue.borrow_mut().push(EngineMsg::NewWebView(webview));
}
}
pub struct Engine {
pub servo: Servo,
pub window_context: Rc<WindowRenderingContext>,
pub queue: SharedQueue,
}
impl Engine {
pub fn new(waker: EguiWaker, window_context: Rc<WindowRenderingContext>) -> Engine {
let queue: SharedQueue = Rc::new(RefCell::new(Vec::new()));
let servo = ServoBuilder::default()
.event_loop_waker(Box::new(waker))
.build();
servo.setup_logging();
Engine {
servo,
window_context,
queue,
}
}
pub fn spin(&self) {
self.servo.spin_event_loop();
}
pub fn create_tab(&self, url: Url, hidpi_scale: f32) -> ServoPage {
let size = self.window_context.size();
let offscreen: Rc<OffscreenRenderingContext> =
Rc::new(self.window_context.offscreen_context(size));
let delegate = Rc::new(BrowserDelegate {
queue: self.queue.clone(),
});
let webview = WebViewBuilder::new(&self.servo, offscreen as Rc<dyn RenderingContext>)
.url(url)
.hidpi_scale_factor(euclid::Scale::new(hidpi_scale))
.delegate(delegate)
.build();
webview.notify_theme_change(Theme::Light);
ServoPage::new(webview)
}
}
/// 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,
}
impl ServoPage {
fn new(webview: WebView) -> ServoPage {
ServoPage {
id: webview.id(),
webview,
}
}
pub fn adopt(webview: WebView) -> ServoPage {
Self::new(webview)
}
}
fn winit_named_key_to_kt(key: winit::keyboard::NamedKey) -> Key {
use winit::keyboard::NamedKey as W;
match key {
W::Enter => Key::Named(NamedKey::Enter),
W::Tab => Key::Named(NamedKey::Tab),
W::Backspace => Key::Named(NamedKey::Backspace),
W::Delete => Key::Named(NamedKey::Delete),
W::Escape => Key::Named(NamedKey::Escape),
W::Space => Key::Character(" ".into()),
W::ArrowUp => Key::Named(NamedKey::ArrowUp),
W::ArrowDown => Key::Named(NamedKey::ArrowDown),
W::ArrowLeft => Key::Named(NamedKey::ArrowLeft),
W::ArrowRight => Key::Named(NamedKey::ArrowRight),
W::Home => Key::Named(NamedKey::Home),
W::End => Key::Named(NamedKey::End),
W::PageUp => Key::Named(NamedKey::PageUp),
W::PageDown => Key::Named(NamedKey::PageDown),
W::Insert => Key::Named(NamedKey::Insert),
W::F1 => Key::Named(NamedKey::F1),
W::F2 => Key::Named(NamedKey::F2),
W::F3 => Key::Named(NamedKey::F3),
W::F4 => Key::Named(NamedKey::F4),
W::F5 => Key::Named(NamedKey::F5),
W::F6 => Key::Named(NamedKey::F6),
W::F7 => Key::Named(NamedKey::F7),
W::F8 => Key::Named(NamedKey::F8),
W::F9 => Key::Named(NamedKey::F9),
W::F10 => Key::Named(NamedKey::F10),
W::F11 => Key::Named(NamedKey::F11),
W::F12 => Key::Named(NamedKey::F12),
_ => Key::Named(NamedKey::Unidentified),
}
}
pub fn winit_key_to_kt_key(key: &winit::keyboard::Key) -> Key {
match key {
winit::keyboard::Key::Character(s) => Key::Character(s.as_str().into()),
winit::keyboard::Key::Named(n) => winit_named_key_to_kt(*n),
_ => Key::Named(NamedKey::Unidentified),
}
}
pub struct KeyEventParts {
pub state: KeyState,
pub key: Key,
pub code: Code,
pub repeat: bool,
}
pub fn winit_key_event_to_parts(event: &winit::event::KeyEvent) -> KeyEventParts {
let state = if event.state == winit::event::ElementState::Pressed {
KeyState::Down
} else {
KeyState::Up
};
let code = match event.physical_key {
winit::keyboard::PhysicalKey::Code(code) => {
// winit's `KeyCode` variants share their names with the W3C
// `KeyboardEvent.code` values that `keyboard_types::Code` parses.
format!("{code:?}")
.parse::<Code>()
.unwrap_or(Code::Unidentified)
}
winit::keyboard::PhysicalKey::Unidentified(_native) => Code::Unidentified,
};
KeyEventParts {
state,
key: winit_key_to_kt_key(&event.logical_key),
code,
repeat: event.repeat,
}
}
pub fn make_keyboard_input(parts: KeyEventParts, modifiers: Modifiers) -> Option<InputEvent> {
if parts.key == Key::Named(NamedKey::Unidentified) && parts.code == Code::Unidentified {
return None;
}
let event = ServoKeyboardEvent::new_without_event(
parts.state,
parts.key,
parts.code,
Location::Standard,
modifiers,
parts.repeat,
false,
);
Some(InputEvent::Keyboard(event))
}
pub fn winit_button_to_servo(button: winit::event::MouseButton) -> Option<MouseButton> {
match button {
winit::event::MouseButton::Left => Some(MouseButton::Left),
winit::event::MouseButton::Right => Some(MouseButton::Right),
winit::event::MouseButton::Middle => Some(MouseButton::Middle),
winit::event::MouseButton::Back => Some(MouseButton::Back),
winit::event::MouseButton::Forward => Some(MouseButton::Forward),
winit::event::MouseButton::Other(_) => None,
}
}
pub fn winit_button_action_to_servo(state: winit::event::ElementState) -> MouseButtonAction {
match state {
winit::event::ElementState::Pressed => MouseButtonAction::Down,
winit::event::ElementState::Released => MouseButtonAction::Up,
}
}
pub fn wheel_input(
delta: winit::event::MouseScrollDelta,
point: DevicePoint,
) -> Option<InputEvent> {
let (x, y, mode) = match delta {
winit::event::MouseScrollDelta::LineDelta(dx, dy) => {
((dx * 76.0) as f64, (dy * 76.0) as f64, WheelMode::DeltaLine)
}
winit::event::MouseScrollDelta::PixelDelta(pos) => (pos.x, pos.y, WheelMode::DeltaPixel),
};
Some(InputEvent::Wheel(WheelEvent::new(
WheelDelta { x, y, z: 0.0, mode },
point.into(),
)))
}
+50
View File
@@ -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")
}
}