diff --git a/Cargo.lock b/Cargo.lock
index 65e1207..cd6028f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7,7 +7,7 @@ version = 4
[[package]]
name = "RustyWolf"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"anyhow",
"base64",
@@ -15,10 +15,13 @@ dependencies = [
"dirs",
"gtk",
"image",
+ "libc",
+ "serde",
"serde_json",
"tao",
"ureq",
"url",
+ "webkit2gtk",
"wry",
]
@@ -2215,6 +2218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
+ "serde_derive",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index efd8bab..26842cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,24 +1,30 @@
#Cargo.toml
[package]
name = "RustyWolf"
-version = "0.1.1"
+version = "0.2.0"
edition = "2024"
[dependencies]
anyhow = "1.0"
base64 = "0.22"
clap = { version = "4.5", features = ["derive"] }
+dirs = "6.0"
+image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
+serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tao = "0.31"
ureq = "2.12"
url = "2.5"
wry = { version = "0.47", features = ["fullscreen"] }
-image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
-dirs = "6.0"
+
+[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
gtk = "0.18.2"
+webkit2gtk = { version = "2.0", features = ["v2_40"] }
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
[package.metadata.bundle]
name = "RustyWolf"
identifier = "com.rustywolf.browser"
icon = ["src/rustywolf.icns"]
-
diff --git a/README.md b/README.md
index 8f9c024..8c53075 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,68 @@
# RustyWolf
-RustyWolf is a minimal, privacy-focused browser shell built with Rust + system webviews.
+RustyWolf is a minimal, privacy-focused browser shell built in Rust with two engine modes:
+
+- **WebKit mode (default, light)** — the system webview: `WebKitGTK` on Linux, `WKWebView` on macOS, `WebView2` on Windows. No browser install needed.
+- **Gecko mode (opt-in)** — real Firefox (Gecko) engine through a RustyWolf-managed profile. Requires an installed Firefox.
+
+Switch between them any time with the **leaf toggle** in the toolbar (or the leaf on the RustyWolf home page in Gecko mode). Open tabs carry over in both directions.
(complete honesty, this whole thing is vibecoded, however I never paid for any ai service (I got a free student plan for a year) so if you wanna avoid AI slop, just skip this but I never contributed to the AI BOOM with my money, if you wanna contribute, feel free to do so)
-- macOS: native `WKWebView`
-- Linux: `WebKitGTK`
-- Windows: `WebView2`
-
-No Firefox install. No extension system. Fast startup, clean UI.
-
+## Engines
+
+| | WebKit mode | Gecko mode |
+|---|---|---|
+| Engine | WebKitGTK / WKWebView / WebView2 | Firefox (Gecko), driven by RustyWolf |
+| Weight | lightest — typically ~300 MB with a tab open | Firefox-weight |
+| RustyWolf UI | full chrome (tabs, URL bar, ADS, K, D, Properties) | Firefox's own UI + RustyWolf home page |
+| Adblock | built-in lightweight blocker (ADS toggle) | Firefox strict tracking protection |
+| Extras | keep-in-ram tabs (`K`), popup gate, download panel, properties page | Firefox features, managed profile, tab handoff |
+
+The leaf button shows the current engine: **lit green = WebKit**, **dim = Gecko**.
+
## What It Does
-- Really, I mean really lightweight! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
- - So light in fact in my gentoo(openrc)+i3wm setup it took only 285mb ram with one tab open (dvorakmortonxd.dev) compared to firefox which took 866mb ram
- - THATS 3.04 TIMES AS MUCH RAM, so yeah it is light
- - tabs have a toggle for Keeping-in-ram (`K`), it basically just saves the url if its off and reloads it on reclick, if you toggle it on it keeps the whole page in ram.
+- Really, I mean really lightweight in WebKit mode! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
+ - in a gentoo(openrc)+i3wm setup it took only ~285 MB RAM with one tab open (dvorakmortonxd.dev) compared to firefox which took ~866 MB
+ - tabs have a toggle for Keeping-in-ram (`K`): with it off the tab is unloaded, only the URL is kept, and it reloads on click
+ - background tabs are suspended automatically, and the download ticker only runs while something is downloading
- Privacy-first defaults: DuckDuckGo home + search fallback from the URL bar
- Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`)
-- Built-in custom adblock with top-bar toggle (`ADS`)
-- Popup permission gate: asks before a site opens a new tab
+- Built-in custom adblock with top-bar toggle (`ADS`), persisted across restarts
+- Popup handling that behaves like a real browser: clicked links open tabs, clicked `window.open` popups (OAuth/"Sign in with Google", SoundCloud, ...) open real popup windows with `window.opener` intact, and scripted popups go through the permission gate
+- Native user agent by default, so sites (and their login flows) see an honest browser; `--ua-chrome` is available for stubborn sites
- Download support with live progress panel (`D`) and session download history
- Properties page with session browsing history + download history
- Default window size `1000x600` (still fully resizable)
-- Optional `--private` mode when backend support is available
+- Optional `--private` mode in both engines
+- Engine switch with tab handoff: WebKit tabs become Firefox tabs and vice versa
## Run
```bash
cargo run --
-```
-```bash
+# pick a URL / window title
cargo run -- --url https://example.com
cargo run -- --url example.com
cargo run -- --title "RustyWolf"
+
+# engine selection (persisted in ~/.config/RustyWolf/config.json)
+cargo run -- --engine webkit
+cargo run -- --engine gecko
+cargo run -- --engine gecko --firefox /path/to/firefox
+
+# compatible user agent for picky sites (WebKit mode)
+cargo run -- --ua-chrome
+
+# misc
cargo run -- --private
cargo run -- --dry-run
@@ -48,18 +70,29 @@ cargo run -- --dry-run
cargo run -- --linux-backend x11
cargo run -- --linux-backend wayland
cargo run -- --linux-disable-dmabuf
-cargo run -- --linux-backend x11 --linux-disable-dmabuf
```
+The leaf button in the toolbar switches engines immediately; the choice is remembered for the next launch.
+
+## Gecko Mode (real Firefox engine)
+
+There is no embeddable Gecko in 2026, so Gecko mode supervises a real Firefox with a dedicated profile under `~/.config/RustyWolf/gecko-profile`:
+
+- hardening prefs are rewritten on every launch (strict tracking protection, DNT/GPC, no telemetry, no sponsored content, no Pocket)
+- downloads go straight to your Downloads folder
+- new tabs stay blank; the RustyWolf home page is the start page and carries the leaf button back to WebKit
+- `browser.sessionstore.interval` is shortened so RustyWolf can hand your tabs back to WebKit
+- Firefox is launched with `--no-remote --new-instance`, so your normal Firefox session is never touched (`--firefox` / `RUSTYWOLF_FIREFOX` to point at a specific binary)
+
## Privacy Notes
-RustyWolf applies baseline hardening in page context:
+WebKit mode applies baseline hardening in page context:
- `navigator.doNotTrack = "1"`
- disables legacy `openDatabase`
- disables `RTCPeerConnection`
-Adblocking is built-in and lightweight. It is not full uBlock Origin parity.
+Adblocking is built-in and lightweight. It is not full uBlock Origin parity. Gecko mode uses Firefox's own strict tracking protection instead.
# Linux Setup (prerequisites)
@@ -96,6 +129,8 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
```
+For Gecko mode just make sure Firefox is installed (`emerge www-client/firefox` or the binary release).
+
For NVIDIA, make the DRM modeset setting part of the boot command line, not only
`/etc/modprobe.d`, then reboot:
@@ -179,6 +214,8 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from
/etc/xdg/gtk-3.0/settings.ini
```
+If RustyWolf segfaults instantly with `Couldn't register with accessibility bus` when launched from a bare TTY/SSH shell, there is no session D-Bus for the GTK accessibility bridge (`libatk-bridge`). Launch it from your graphical session, or make `DBUS_SESSION_BUS_ADDRESS` available (on many systems `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus`).
+
# MacOS
## installing Homebrew (skip if already installed)
@@ -187,17 +224,25 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
+Gecko mode finds Firefox in `/Applications/Firefox.app` automatically; WebKit mode uses the system `WKWebView`.
+
# Windows Setup
-Install the Microsoft Edge WebView2 Runtime (Evergreen), then build normally with Rust MSVC toolchain.
+Install the Microsoft Edge WebView2 Runtime (Evergreen) for WebKit mode, then build normally with the Rust MSVC toolchain.
- WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/
- Rust toolchain target: `x86_64-pc-windows-msvc`
+Gecko mode finds Firefox under `Program Files` / `%LOCALAPPDATA%\Mozilla Firefox`, or use `--firefox`.
+
## Dev Quick Checks
```bash
cargo fmt
cargo check
cargo test
+cargo clippy --all-targets
+
+# exercises the Gecko handoff against a real headless Firefox
+cargo test -- --ignored --nocapture
```
diff --git a/src/cli.rs b/src/cli.rs
index 87c4ff7..c6879ab 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,6 +1,30 @@
-//cli.rs
-use clap::{Parser, ValueEnum};
+//! Command line interface definitions.
+use clap::{Parser, ValueEnum};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+/// Browsing engine.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum)]
+#[serde(rename_all = "lowercase")]
+pub enum Engine {
+ /// Native system webview (WebKitGTK / WKWebView / WebView2). Lightest.
+ #[default]
+ Webkit,
+ /// Firefox (Gecko) through a RustyWolf-managed profile. Requires Firefox.
+ Gecko,
+}
+
+impl Engine {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Engine::Webkit => "webkit",
+ Engine::Gecko => "gecko",
+ }
+ }
+}
+
+/// GTK windowing backend (Linux only).
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LinuxBackend {
Auto,
@@ -11,38 +35,50 @@ pub enum LinuxBackend {
#[derive(Debug, Parser)]
#[command(
name = "rustywolf",
- about = "Privacy-focused WebKit browser",
- long_about = "RustyWolf launches an embedded WebKit browser window.",
+ about = "Lightweight privacy browser (WebKit + Gecko engines)",
+ long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.",
version
)]
pub struct Cli {
- #[arg(
- long,
- help = "Optional startup URL (defaults to https://duckduckgo.com)"
- )]
+ /// Startup URL (defaults to https://duckduckgo.com)
+ #[arg(long)]
pub url: Option,
- #[arg(long, help = "Set a custom window title")]
+ /// Window title (WebKit mode)
+ #[arg(long)]
pub title: Option,
- #[arg(long, help = "Try private/incognito mode when backend supports it")]
+ /// Select the browsing engine (persisted for the next launch too)
+ #[arg(long, value_enum)]
+ pub engine: Option,
+
+ /// Start in private/incognito mode
+ #[arg(long)]
pub private: bool,
- #[arg(long, help = "Print launch settings without opening a window")]
+ /// Print launch settings without opening a window
+ #[arg(long)]
pub dry_run: bool,
- #[arg(
- long,
- value_enum,
- default_value_t = LinuxBackend::Auto,
- help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
- )]
+ /// Firefox binary used by Gecko mode (persisted)
+ #[arg(long, value_name = "PATH")]
+ pub firefox: Option,
+
+ /// WebKit mode: send a Chrome-like user agent for sites that
+ /// misbehave with the native WebKitGTK user agent
+ #[arg(long)]
+ pub ua_chrome: bool,
+
+ /// Internal: restore the tab set from the last engine handoff
+ #[arg(long, hide = true)]
+ pub resume: bool,
+
+ /// Linux only: select a GTK windowing backend (auto, x11, wayland)
+ #[arg(long, value_enum, default_value_t = LinuxBackend::Auto)]
pub linux_backend: LinuxBackend,
- #[arg(
- long,
- help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
- )]
+ /// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
+ #[arg(long)]
pub linux_disable_dmabuf: bool,
}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..180655e
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,88 @@
+//! Persisted user configuration (engine choice, adblock, Firefox path).
+
+use crate::cli::Engine;
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Config {
+ pub engine: Engine,
+ pub adblock: bool,
+ pub firefox_bin: Option,
+}
+
+/// `~/.config/RustyWolf` (or the platform equivalent).
+pub fn base_dir() -> PathBuf {
+ dirs::config_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("RustyWolf")
+}
+
+pub fn config_path() -> PathBuf {
+ base_dir().join("config.json")
+}
+
+pub fn handoff_path() -> PathBuf {
+ base_dir().join("handoff.json")
+}
+
+pub fn gecko_profile_dir() -> PathBuf {
+ base_dir().join("gecko-profile")
+}
+
+pub fn load() -> Config {
+ match fs::read_to_string(config_path()) {
+ Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|err| {
+ eprintln!("rustywolf: ignoring malformed config: {err}");
+ Config::default()
+ }),
+ Err(_) => Config::default(),
+ }
+}
+
+pub fn save(cfg: &Config) -> Result<()> {
+ let dir = base_dir();
+ fs::create_dir_all(&dir).with_context(|| format!("could not create {}", dir.display()))?;
+ let raw = serde_json::to_string_pretty(cfg)?;
+ fs::write(config_path(), raw).with_context(|| "could not write config.json")?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn defaults_to_webkit_without_adblock() {
+ let cfg = Config::default();
+ assert_eq!(cfg.engine, Engine::Webkit);
+ assert!(!cfg.adblock);
+ assert!(cfg.firefox_bin.is_none());
+ }
+
+ #[test]
+ fn roundtrips_through_json() {
+ let cfg = Config {
+ engine: Engine::Gecko,
+ adblock: true,
+ firefox_bin: Some(PathBuf::from("/usr/bin/firefox-bin")),
+ };
+ let raw = serde_json::to_string(&cfg).unwrap();
+ let back: Config = serde_json::from_str(&raw).unwrap();
+ assert_eq!(back.engine, Engine::Gecko);
+ assert!(back.adblock);
+ assert_eq!(
+ back.firefox_bin.as_deref(),
+ Some(std::path::Path::new("/usr/bin/firefox-bin"))
+ );
+ }
+
+ #[test]
+ fn tolerates_missing_fields() {
+ let cfg: Config = serde_json::from_str("{}").unwrap();
+ assert_eq!(cfg.engine, Engine::Webkit);
+ }
+}
diff --git a/src/gecko/home.html b/src/gecko/home.html
new file mode 100644
index 0000000..343d543
--- /dev/null
+++ b/src/gecko/home.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+RustyWolf
+
+
+
+
+
RustyWolf
+
Gecko mode — powered by Firefox, managed by RustyWolf.
+
+
+
+
+ Engine: Gecko (Firefox)
+ Click the leaf to switch back to the light WebKit engine.
+
+
+
+
+
+
+
+ This dedicated profile uses strict tracking protection, no telemetry, and downloads
+ straight to your Downloads folder. Your open tabs carry over when you switch engines.
+ WebKit mode extras: the built-in adblock toggle (ADS), keep-in-ram tabs
+ (K), the popup gate and the properties page. In Gecko mode Firefox handles
+ browsing natively.
+
+
+
+
+
diff --git a/src/gecko/mod.rs b/src/gecko/mod.rs
new file mode 100644
index 0000000..6f0cdc8
--- /dev/null
+++ b/src/gecko/mod.rs
@@ -0,0 +1,330 @@
+//! Gecko mode: supervise a RustyWolf-managed Firefox (real Gecko engine).
+//!
+//! There is no embeddable Gecko, so RustyWolf runs Firefox with a dedicated
+//! profile and keeps the experience coherent:
+//! - the profile carries RustyWolf hardening prefs and the control home page,
+//! - a loopback control server lets the home page switch back to WebKit,
+//! - the Firefox session store supplies the tab handoff to the WebKit engine.
+//!
+//! This supervisor is tiny (a listener, a child process handle, a poll loop),
+//! keeping RustyWolf's own RAM/CPU footprint minimal even in Gecko mode.
+
+mod prefs;
+mod server;
+
+use crate::cli::{Cli, Engine};
+use crate::config::{self, Config};
+use crate::handoff;
+use crate::switch;
+use crate::util;
+use anyhow::{Context, Result, bail};
+use server::{ControlServer, Request};
+use std::fs;
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::time::{Duration, Instant};
+
+const FAVICON: &[u8] = include_bytes!("../rustywolf.ico");
+const POLL_INTERVAL: Duration = Duration::from_millis(120);
+const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
+
+pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> {
+ let firefox = resolve_firefox(cli, cfg)?;
+ let profile = config::gecko_profile_dir();
+ fs::create_dir_all(&profile)
+ .with_context(|| format!("could not create {}", profile.display()))?;
+
+ let server = ControlServer::bind()?;
+ let homepage = format!("http://127.0.0.1:{}/?token={}", server.port, server.token);
+ prefs::write_user_js(&profile, &homepage, dirs::download_dir().as_deref())?;
+
+ let urls = startup_urls(cli);
+ let mut child = spawn_firefox(&firefox, &profile, &urls, cli.private)?;
+ eprintln!(
+ "rustywolf: gecko engine (Firefox {}), profile {}",
+ firefox.display(),
+ profile.display()
+ );
+
+ let home_html = include_str!("home.html")
+ .replace("{{TOKEN}}", &server.token)
+ .into_bytes();
+
+ loop {
+ match child.try_wait() {
+ Ok(Some(_)) => return Ok(()), // user closed Firefox
+ Ok(None) => {}
+ Err(err) => return Err(err).context("lost track of the Firefox process"),
+ }
+
+ let Some(mut conn) = server.poll() else {
+ std::thread::sleep(POLL_INTERVAL);
+ continue;
+ };
+
+ match conn.request {
+ Request::Home => {
+ conn.respond("200 OK", "text/html; charset=utf-8", &home_html);
+ }
+ Request::Favicon => {
+ conn.respond("200 OK", "image/x-icon", FAVICON);
+ }
+ Request::Switch => {
+ let captured = handoff::capture_firefox_session(&profile, cli.private);
+ match switch::switch_to(Engine::Webkit, cfg, captured.as_ref(), cli.private) {
+ Ok(()) => {
+ conn.respond_204();
+ terminate(&mut child);
+ return Ok(());
+ }
+ Err(err) => {
+ let body = format!("RustyWolf could not switch engines: {err:#}");
+ conn.respond(
+ "500 Internal Server Error",
+ "text/plain; charset=utf-8",
+ body.as_bytes(),
+ );
+ }
+ }
+ }
+ Request::NotFound => {
+ conn.respond("404 Not Found", "text/plain; charset=utf-8", b"not found");
+ }
+ }
+ }
+}
+
+/// Tabs to open: explicit `--url` wins, then a pending handoff, then the home page.
+fn startup_urls(cli: &Cli) -> Vec {
+ if let Some(url) = &cli.url {
+ return vec![util::normalize_url(url)];
+ }
+ if cli.resume
+ && let Some(handoff) = handoff::take_fresh()
+ {
+ let mut urls: Vec = handoff.tabs.into_iter().map(|tab| tab.url).collect();
+ if !urls.is_empty() {
+ // Firefox selects the first URL argument; bring the previously
+ // active tab to the front, then the rest follows.
+ let active = handoff.active.min(urls.len() - 1);
+ urls.swap(0, active);
+ return urls;
+ }
+ }
+ vec![util::DEFAULT_HOME.to_string()]
+}
+
+fn spawn_firefox(
+ firefox: &PathBuf,
+ profile: &PathBuf,
+ urls: &[String],
+ private: bool,
+) -> Result {
+ let mut cmd = Command::new(firefox);
+ cmd.arg("--no-remote")
+ .arg("--new-instance")
+ .arg("--profile")
+ .arg(profile);
+ if private {
+ // Private sessions are ephemeral; only the active URL comes along.
+ cmd.arg("--private-window").arg(&urls[0]);
+ } else {
+ for url in urls {
+ cmd.arg(url);
+ }
+ }
+ cmd.env("MOZ_CRASHREPORTER_DISABLE", "1")
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ cmd.spawn()
+ .with_context(|| format!("could not launch Firefox ({})", firefox.display()))
+}
+
+/// Ask Firefox to quit, with a hard kill as a last resort.
+fn terminate(child: &mut Child) {
+ #[cfg(unix)]
+ unsafe {
+ libc::kill(child.id() as i32, libc::SIGTERM);
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = child.kill();
+ }
+
+ let deadline = Instant::now() + SHUTDOWN_GRACE;
+ while Instant::now() < deadline {
+ match child.try_wait() {
+ Ok(Some(_)) => return,
+ _ => std::thread::sleep(Duration::from_millis(50)),
+ }
+ }
+ let _ = child.kill();
+ let _ = child.wait();
+}
+
+// ---------- Firefox discovery -------------------------------------------
+
+fn resolve_firefox(cli: &Cli, cfg: &mut Config) -> Result {
+ if let Some(path) = &cli.firefox {
+ if path.is_file() {
+ cfg.firefox_bin = Some(path.clone());
+ return Ok(path.clone());
+ }
+ bail!("--firefox path does not exist: {}", path.display());
+ }
+
+ if let Some(found) = locate_firefox(cfg) {
+ return Ok(found);
+ }
+
+ bail!(
+ "Firefox was not found. Install Firefox or point RustyWolf at it with --firefox /path/to/firefox"
+ )
+}
+
+/// Best-effort Firefox discovery (also used by `--dry-run`).
+pub fn locate_firefox(cfg: &Config) -> Option {
+ if let Some(path) = &cfg.firefox_bin {
+ if path.is_file() {
+ return Some(path.clone());
+ }
+ eprintln!(
+ "rustywolf: configured Firefox path no longer exists: {}",
+ path.display()
+ );
+ }
+
+ if let Some(path) = std::env::var_os("RUSTYWOLF_FIREFOX") {
+ let path = PathBuf::from(path);
+ if path.is_file() {
+ return Some(path);
+ }
+ }
+
+ if let Some(found) = find_in_path(&["firefox-bin", "firefox", "firefox-esr"]) {
+ return Some(found);
+ }
+
+ platform_candidates()
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+}
+
+fn find_in_path(names: &[&str]) -> Option {
+ let path = std::env::var_os("PATH")?;
+ for dir in std::env::split_paths(&path) {
+ for name in names {
+ let candidate = dir.join(name);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ }
+ }
+ None
+}
+
+#[cfg(target_os = "macos")]
+fn platform_candidates() -> Vec {
+ vec![
+ PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox"),
+ PathBuf::from("/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"),
+ ]
+}
+
+#[cfg(target_os = "windows")]
+fn platform_candidates() -> Vec {
+ let mut candidates = Vec::new();
+ for base in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
+ if let Some(root) = std::env::var_os(base) {
+ candidates.push(
+ PathBuf::from(root)
+ .join("Mozilla Firefox")
+ .join("firefox.exe"),
+ );
+ }
+ }
+ candidates
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+fn platform_candidates() -> Vec {
+ vec![
+ PathBuf::from("/usr/bin/firefox-bin"),
+ PathBuf::from("/usr/bin/firefox"),
+ PathBuf::from("/usr/local/bin/firefox-bin"),
+ PathBuf::from("/usr/local/bin/firefox"),
+ PathBuf::from("/snap/bin/firefox"),
+ PathBuf::from("/var/lib/flatpak/exports/bin/org.mozilla.firefox"),
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Parser;
+
+ fn cli_from(args: &[&str]) -> Cli {
+ let mut full = vec!["rustywolf"];
+ full.extend_from_slice(args);
+ Cli::try_parse_from(full).unwrap()
+ }
+
+ #[test]
+ fn explicit_url_wins_over_handoff() {
+ let cli = cli_from(&["--url", "example.com", "--resume"]);
+ assert_eq!(startup_urls(&cli), vec!["https://example.com"]);
+ }
+
+ #[test]
+ fn falls_back_to_home_without_handoff() {
+ let cli = cli_from(&[]);
+ assert_eq!(startup_urls(&cli), vec![util::DEFAULT_HOME]);
+ }
+
+ /// Launches a real headless Firefox, then captures its tabs for handoff.
+ /// Run explicitly: `cargo test -- --ignored --nocapture`
+ #[test]
+ #[ignore = "launches a real headless Firefox"]
+ fn gecko_session_handoff_roundtrip() {
+ let Some(firefox) = locate_firefox(&Config::default()) else {
+ eprintln!("skipping: no Firefox found");
+ return;
+ };
+ let profile =
+ std::env::temp_dir().join(format!("rustywolf-gecko-test-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&profile);
+ fs::create_dir_all(&profile).unwrap();
+ prefs::write_user_js(&profile, "about:blank", None).unwrap();
+
+ let mut child = Command::new(&firefox)
+ .arg("--headless")
+ .arg("--no-remote")
+ .arg("--new-instance")
+ .arg("--profile")
+ .arg(&profile)
+ .arg("https://example.com")
+ .arg("https://example.org")
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .expect("Firefox should start");
+
+ std::thread::sleep(Duration::from_secs(10));
+ terminate(&mut child);
+
+ let captured = handoff::capture_firefox_session(&profile, false);
+ let _ = fs::remove_dir_all(&profile);
+
+ let captured = captured.expect("session handoff should be captured");
+ assert!(
+ !captured.tabs.is_empty(),
+ "expected captured tabs, got none"
+ );
+ for tab in &captured.tabs {
+ eprintln!("captured tab: {}", tab.url);
+ }
+ }
+}
diff --git a/src/gecko/prefs.rs b/src/gecko/prefs.rs
new file mode 100644
index 0000000..96d0c7b
--- /dev/null
+++ b/src/gecko/prefs.rs
@@ -0,0 +1,161 @@
+//! Managed Firefox profile: the `user.js` preference set.
+
+use anyhow::{Context, Result};
+use serde_json::Value;
+use std::fs;
+use std::path::Path;
+
+/// Gecko launches rewrite `user.js` so the profile stays RustyWolf-managed.
+pub fn write_user_js(profile: &Path, homepage: &str, download_dir: Option<&Path>) -> Result<()> {
+ let mut prefs: Vec<(&str, Value)> = vec![
+ // Tab handoff relies on a reasonably fresh session store, and handoff
+ // freshness is a lightness trade-off (2s writes, not the 15s default).
+ ("browser.sessionstore.interval", Value::from(2000)),
+ ("browser.sessionstore.resume_from_crash", Value::from(true)),
+ // Start on the RustyWolf control page; new tabs stay blank and light.
+ ("browser.startup.page", Value::from(1)),
+ ("browser.startup.homepage", Value::from(homepage)),
+ (
+ "browser.startup.homepage_override.mstone",
+ Value::from("ignore"),
+ ),
+ ("startup.homepage_welcome_url", Value::from("")),
+ ("startup.homepage_welcome_url.additional", Value::from("")),
+ ("startup.homepage_override_url", Value::from("")),
+ ("browser.aboutwelcome.enabled", Value::from(false)),
+ ("browser.newtabpage.enabled", Value::from(false)),
+ (
+ "browser.newtabpage.activity-stream.enabled",
+ Value::from(false),
+ ),
+ (
+ "browser.newtabpage.activity-stream.showSponsored",
+ Value::from(false),
+ ),
+ (
+ "browser.newtabpage.activity-stream.showSponsoredTopSites",
+ Value::from(false),
+ ),
+ (
+ "browser.newtabpage.activity-stream.feeds.telemetry",
+ Value::from(false),
+ ),
+ ("browser.shell.checkDefaultBrowser", Value::from(false)),
+ ("browser.discovery.enabled", Value::from(false)),
+ ("browser.tabs.warnOnClose", Value::from(false)),
+ ("browser.tabs.warnOnCloseOtherTabs", Value::from(false)),
+ ("browser.warnOnQuit", Value::from(false)),
+ // Lightness: let Firefox unload stale background tabs under pressure.
+ ("browser.tabs.unloadOnLowMemory", Value::from(true)),
+ (
+ "browser.tabs.min_inactive_duration_before_unload",
+ Value::from(600_000),
+ ),
+ // Downloads land in the system download folder without extra dialogs.
+ ("browser.download.useDownloadDir", Value::from(true)),
+ ("browser.download.alwaysOpenPanel", Value::from(false)),
+ // Privacy hardening: strict tracking protection, no telemetry, no sponsored content.
+ ("privacy.donottrackheader.enabled", Value::from(true)),
+ ("privacy.globalprivacycontrol.enabled", Value::from(true)),
+ ("browser.contentblocking.category", Value::from("strict")),
+ ("privacy.trackingprotection.enabled", Value::from(true)),
+ (
+ "privacy.trackingprotection.pbmode.enabled",
+ Value::from(true),
+ ),
+ (
+ "privacy.trackingprotection.socialtracking.enabled",
+ Value::from(true),
+ ),
+ (
+ "privacy.trackingprotection.cryptomining.enabled",
+ Value::from(true),
+ ),
+ (
+ "privacy.trackingprotection.fingerprinting.enabled",
+ Value::from(true),
+ ),
+ (
+ "privacy.annotate_channels.strict_list.enabled",
+ Value::from(true),
+ ),
+ ("toolkit.telemetry.enabled", Value::from(false)),
+ ("toolkit.telemetry.unified", Value::from(false)),
+ (
+ "toolkit.telemetry.shutdownPingSender.enabled",
+ Value::from(false),
+ ),
+ (
+ "datareporting.healthreport.uploadEnabled",
+ Value::from(false),
+ ),
+ (
+ "datareporting.policy.dataSubmissionEnabled",
+ Value::from(false),
+ ),
+ ("app.shield.optoutstudies.enabled", Value::from(false)),
+ (
+ "browser.crashReports.unsubmittedCheck.autoSubmit2",
+ Value::from(false),
+ ),
+ // A quiet address bar: no sponsored suggestions, no search-suggestion calls.
+ ("browser.urlbar.quicksuggest.enabled", Value::from(false)),
+ (
+ "browser.urlbar.suggest.quicksuggest.sponsored",
+ Value::from(false),
+ ),
+ (
+ "browser.urlbar.suggest.quicksuggest.nonsponsored",
+ Value::from(false),
+ ),
+ ("browser.urlbar.suggest.topsites", Value::from(false)),
+ ("browser.search.suggest.enabled", Value::from(false)),
+ ("browser.urlbar.suggest.searches", Value::from(false)),
+ ];
+
+ if let Some(dir) = download_dir {
+ prefs.push((
+ "browser.download.dir",
+ Value::from(dir.to_string_lossy().into_owned()),
+ ));
+ }
+
+ let mut out = String::from("// Managed by RustyWolf - rewritten on every Gecko launch.\n");
+ for (key, value) in prefs {
+ out.push_str("user_pref(");
+ out.push_str(&serde_json::to_string(key).unwrap());
+ out.push_str(", ");
+ out.push_str(&value.to_string());
+ out.push_str(");\n");
+ }
+
+ fs::write(profile.join("user.js"), out)
+ .with_context(|| format!("could not write {}/user.js", profile.display()))?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn writes_managed_prefs() {
+ let dir = std::env::temp_dir().join(format!("rustywolf-prefs-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ write_user_js(
+ &dir,
+ "http://127.0.0.1:4242/?token=deadbeef",
+ Some(Path::new("/home/user/Downloads")),
+ )
+ .unwrap();
+ let raw = std::fs::read_to_string(dir.join("user.js")).unwrap();
+ assert!(raw.contains("Managed by RustyWolf"));
+ assert!(
+ raw.contains("\"browser.startup.homepage\", \"http://127.0.0.1:4242/?token=deadbeef\"")
+ );
+ assert!(raw.contains("\"privacy.trackingprotection.enabled\", true"));
+ assert!(raw.contains("\"browser.download.dir\", \"/home/user/Downloads\""));
+ assert!(raw.contains("\"browser.sessionstore.interval\", 2000"));
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
diff --git a/src/gecko/server.rs b/src/gecko/server.rs
new file mode 100644
index 0000000..b5a8e79
--- /dev/null
+++ b/src/gecko/server.rs
@@ -0,0 +1,170 @@
+//! Tiny loopback HTTP control server for Gecko mode.
+//!
+//! The RustyWolf home page (served from here) can ask the supervisor to
+//! switch back to the WebKit engine. Everything is bound to 127.0.0.1 and
+//! gated behind a per-launch random token.
+
+use anyhow::{Context, Result};
+use std::fs::File;
+use std::io::{Read, Write};
+use std::net::{Shutdown, TcpListener, TcpStream};
+use std::time::Duration;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Request {
+ Home,
+ Switch,
+ Favicon,
+ NotFound,
+}
+
+pub struct ControlServer {
+ listener: TcpListener,
+ pub port: u16,
+ pub token: String,
+}
+
+pub struct Connection {
+ stream: TcpStream,
+ pub request: Request,
+}
+
+impl ControlServer {
+ pub fn bind() -> Result {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).context("could not bind 127.0.0.1")?;
+ listener.set_nonblocking(true)?;
+ let port = listener.local_addr()?.port();
+ Ok(Self {
+ listener,
+ port,
+ token: random_token(),
+ })
+ }
+
+ /// Non-blocking: `None` means "no request right now".
+ pub fn poll(&self) -> Option {
+ let (stream, _) = self.listener.accept().ok()?;
+ let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
+ let request = read_request(&stream, &self.token);
+ Some(Connection { stream, request })
+ }
+}
+
+impl Connection {
+ pub fn respond(&mut self, status: &str, content_type: &str, body: &[u8]) {
+ let header = format!(
+ "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ let _ = self.stream.write_all(header.as_bytes());
+ let _ = self.stream.write_all(body);
+ let _ = self.stream.flush();
+ let _ = self.stream.shutdown(Shutdown::Write);
+ }
+
+ pub fn respond_204(&mut self) {
+ let _ = self.stream.write_all(
+ b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
+ );
+ let _ = self.stream.flush();
+ let _ = self.stream.shutdown(Shutdown::Write);
+ }
+}
+
+fn read_request(stream: &TcpStream, expected_token: &str) -> Request {
+ let mut buf = [0u8; 4096];
+ let mut used = 0usize;
+ let mut reader = match stream.try_clone() {
+ Ok(clone) => clone,
+ Err(_) => return Request::NotFound,
+ };
+
+ loop {
+ if used >= buf.len() {
+ break;
+ }
+ match reader.read(&mut buf[used..]) {
+ Ok(0) => break,
+ Ok(n) => {
+ used += n;
+ if buf[..used].windows(4).any(|w| w == b"\r\n\r\n") {
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+
+ let head = String::from_utf8_lossy(&buf[..used]);
+ let Some(first_line) = head.lines().next() else {
+ return Request::NotFound;
+ };
+ let mut parts = first_line.split_whitespace();
+ let method = parts.next().unwrap_or_default();
+ let target = parts.next().unwrap_or_default();
+ if method != "GET" {
+ return Request::NotFound;
+ }
+
+ let (path, query) = match target.split_once('?') {
+ Some((path, query)) => (path, query),
+ None => (target, ""),
+ };
+
+ match path {
+ "/" => Request::Home,
+ "/favicon.ico" => Request::Favicon,
+ "/switch" => {
+ let token_ok = query
+ .split('&')
+ .filter_map(|pair| pair.split_once('='))
+ .any(|(key, value)| key == "token" && value == expected_token);
+ if token_ok {
+ Request::Switch
+ } else {
+ Request::NotFound
+ }
+ }
+ _ => Request::NotFound,
+ }
+}
+
+fn random_token() -> String {
+ let mut bytes = [0u8; 16];
+ let from_urandom = File::open("/dev/urandom")
+ .and_then(|mut file| file.read_exact(&mut bytes))
+ .is_ok();
+
+ if !from_urandom {
+ use std::hash::{Hash, Hasher};
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ std::process::id().hash(&mut hasher);
+ std::thread::current().id().hash(&mut hasher);
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ .hash(&mut hasher);
+ let a = hasher.finish().to_le_bytes();
+ bytes[..8].copy_from_slice(&a);
+ std::process::id().rotate_left(7).hash(&mut hasher);
+ let b = hasher.finish().to_le_bytes();
+ bytes[8..].copy_from_slice(&b);
+ }
+
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tokens_are_hex_and_unique() {
+ let a = random_token();
+ let b = random_token();
+ assert_eq!(a.len(), 32);
+ assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
+ assert_ne!(a, b);
+ }
+}
diff --git a/src/handoff.rs b/src/handoff.rs
new file mode 100644
index 0000000..357baa4
--- /dev/null
+++ b/src/handoff.rs
@@ -0,0 +1,350 @@
+//! Engine handoff: pass the open tab set between WebKit and Gecko mode,
+//! plus Firefox session-store reading (mozlz4 = magic + u32 size + LZ4 block).
+//!
+//! No external decompression dependency: the session file format is decoded
+//! with a small in-tree LZ4 block decompressor (see `lz4_block_decompress`).
+
+use crate::config;
+use crate::util::now_unix;
+use anyhow::{Context, Result, bail};
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::Path;
+
+/// Only hand over this many tabs at most (keeps the switch snappy and light).
+pub const MAX_HANDOFF_TABS: usize = 32;
+/// A handoff older than this is ignored.
+pub const MAX_HANDOFF_AGE_SECS: u64 = 600;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HandoffTab {
+ pub url: String,
+ #[serde(default)]
+ pub title: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Handoff {
+ pub created_at: u64,
+ #[serde(default)]
+ pub private: bool,
+ #[serde(default)]
+ pub active: usize,
+ #[serde(default)]
+ pub tabs: Vec,
+}
+
+impl Handoff {
+ pub fn new(private: bool, mut active: usize, mut tabs: Vec) -> Self {
+ tabs.truncate(MAX_HANDOFF_TABS);
+ if active >= tabs.len() {
+ active = tabs.len().saturating_sub(1);
+ }
+ Self {
+ created_at: now_unix(),
+ private,
+ active,
+ tabs,
+ }
+ }
+}
+
+pub fn save(handoff: &Handoff) -> Result<()> {
+ save_to(&config::handoff_path(), handoff)
+}
+
+pub fn save_to(path: &Path, handoff: &Handoff) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("could not create {}", parent.display()))?;
+ }
+ let raw = serde_json::to_string(handoff)?;
+ fs::write(path, raw).with_context(|| format!("could not write {}", path.display()))?;
+ Ok(())
+}
+
+/// Load and consume a fresh handoff (the file is removed either way).
+pub fn take_fresh() -> Option {
+ take_fresh_from(&config::handoff_path(), MAX_HANDOFF_AGE_SECS)
+}
+
+pub(crate) fn take_fresh_from(path: &Path, max_age_secs: u64) -> Option {
+ let raw = fs::read_to_string(path).ok()?;
+ let _ = fs::remove_file(path);
+ let handoff: Handoff = serde_json::from_str(&raw).ok()?;
+ let now = now_unix();
+ if handoff.created_at > now || now.saturating_sub(handoff.created_at) > max_age_secs {
+ return None;
+ }
+ if handoff.tabs.is_empty() {
+ return None;
+ }
+ Some(handoff)
+}
+
+/// Snapshot the tabs of a (possibly still running) Firefox profile.
+pub fn capture_firefox_session(profile_dir: &Path, private: bool) -> Option {
+ let candidates = [
+ profile_dir
+ .join("sessionstore-backups")
+ .join("recovery.jsonlz4"),
+ profile_dir.join("sessionstore.jsonlz4"),
+ ];
+ for candidate in candidates {
+ let Ok(raw) = fs::read(&candidate) else {
+ continue;
+ };
+ let Ok(json) = decode_mozlz4(&raw) else {
+ continue;
+ };
+ let Ok(doc) = serde_json::from_slice::(&json) else {
+ continue;
+ };
+ let (tabs, active) = extract_tabs(&doc);
+ if tabs.is_empty() {
+ continue;
+ }
+ return Some(Handoff::new(private, active, tabs));
+ }
+ None
+}
+
+/// Pull (url, title) pairs out of a decoded session-store document.
+pub(crate) fn extract_tabs(doc: &serde_json::Value) -> (Vec, usize) {
+ let mut tabs = Vec::new();
+ let mut active = 0usize;
+ let selected_window = doc["selectedWindow"].as_u64().unwrap_or(1);
+
+ let Some(windows) = doc["windows"].as_array() else {
+ return (tabs, active);
+ };
+
+ for (wi, window) in windows.iter().enumerate() {
+ let window_selected = window["selected"].as_u64().unwrap_or(1);
+ let Some(tab_list) = window["tabs"].as_array() else {
+ continue;
+ };
+ for (ti, tab) in tab_list.iter().enumerate() {
+ let entries = tab["entries"].as_array();
+ let index = tab["index"].as_u64().unwrap_or(0) as usize; // 1-based
+ let current = entries.and_then(|list| {
+ let idx = index.saturating_sub(1);
+ list.get(idx).or_else(|| list.last())
+ });
+ let Some(current) = current else {
+ continue;
+ };
+ let url = current["url"].as_str().unwrap_or_default().trim();
+ if !usable_handoff_url(url) {
+ continue;
+ }
+ if tabs.len() >= MAX_HANDOFF_TABS {
+ break;
+ }
+ let title = tab["title"]
+ .as_str()
+ .or_else(|| current["title"].as_str())
+ .unwrap_or_default()
+ .to_string();
+ if wi as u64 + 1 == selected_window && ti as u64 + 1 == window_selected {
+ active = tabs.len();
+ }
+ tabs.push(HandoffTab {
+ url: url.to_string(),
+ title,
+ });
+ }
+ }
+
+ (tabs, active)
+}
+
+fn usable_handoff_url(url: &str) -> bool {
+ if url.is_empty() || url.eq_ignore_ascii_case("about:blank") {
+ return false;
+ }
+ let lower = url.to_ascii_lowercase();
+ if lower.starts_with("about:")
+ || lower.starts_with("chrome:")
+ || lower.starts_with("javascript:")
+ || lower.starts_with("data:")
+ {
+ return false;
+ }
+ // The RustyWolf control page of a previous Gecko session.
+ if lower.starts_with("http://127.0.0.1:") && url.contains("/?token=") {
+ return false;
+ }
+ lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://")
+}
+
+// ---------- mozlz4 ------------------------------------------------------
+
+const MOZLZ4_MAGIC: &[u8; 8] = b"mozLz40\0";
+
+pub(crate) fn decode_mozlz4(bytes: &[u8]) -> Result> {
+ if bytes.len() < 12 || &bytes[..8] != MOZLZ4_MAGIC {
+ bail!("not a mozlz4 file");
+ }
+ let declared = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
+ let decoded = lz4_block_decompress(&bytes[12..])?;
+ if decoded.len() != declared {
+ bail!(
+ "mozlz4 size mismatch: header says {declared}, decoded {}",
+ decoded.len()
+ );
+ }
+ Ok(decoded)
+}
+
+/// Plain LZ4 block decompression (the codec behind "mozLz4").
+pub(crate) fn lz4_block_decompress(src: &[u8]) -> Result> {
+ let mut out: Vec = Vec::new();
+ let mut pos = 0usize;
+
+ while pos < src.len() {
+ let token = src[pos];
+ pos += 1;
+
+ let mut literal_len = (token >> 4) as usize;
+ if literal_len == 15 {
+ loop {
+ let extra = *src.get(pos).context("lz4: truncated literal length")?;
+ pos += 1;
+ literal_len += extra as usize;
+ if extra != 255 {
+ break;
+ }
+ }
+ }
+ if pos + literal_len > src.len() {
+ bail!("lz4: truncated literals");
+ }
+ out.extend_from_slice(&src[pos..pos + literal_len]);
+ pos += literal_len;
+
+ if pos >= src.len() {
+ break;
+ }
+
+ if pos + 2 > src.len() {
+ bail!("lz4: truncated match offset");
+ }
+ let offset = u16::from_le_bytes([src[pos], src[pos + 1]]) as usize;
+ pos += 2;
+ if offset == 0 || offset > out.len() {
+ bail!("lz4: match offset out of range");
+ }
+
+ let mut match_len = (token & 0x0f) as usize;
+ if match_len == 15 {
+ loop {
+ let extra = *src.get(pos).context("lz4: truncated match length")?;
+ pos += 1;
+ match_len += extra as usize;
+ if extra != 255 {
+ break;
+ }
+ }
+ }
+ match_len += 4;
+
+ let start = out.len() - offset;
+ for i in 0..match_len {
+ let byte = out[start + i];
+ out.push(byte);
+ }
+ }
+
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn lz4_decodes_literals_only() {
+ // token 0x30 => 3 literal bytes, no match section
+ let src = [0x30, b'a', b'b', b'c'];
+ assert_eq!(lz4_block_decompress(&src).unwrap(), b"abc");
+ }
+
+ #[test]
+ fn lz4_decodes_backreferences() {
+ // 4 literals "abcd", then offset 4, length 4 => "abcdabcd"; then 1 literal "x"
+ let src = [0x40, b'a', b'b', b'c', b'd', 0x04, 0x00, 0x10, b'x'];
+ assert_eq!(lz4_block_decompress(&src).unwrap(), b"abcdabcdx");
+ }
+
+ #[test]
+ fn decodes_real_firefox_session_fixture() {
+ let raw = include_bytes!("testdata/recovery.jsonlz4");
+ let json = decode_mozlz4(raw).expect("fixture must decode");
+ let doc: serde_json::Value = serde_json::from_slice(&json).expect("fixture must be JSON");
+ let (tabs, active) = extract_tabs(&doc);
+ assert_eq!(tabs.len(), 2);
+ assert_eq!(tabs[0].url, "https://example.com/");
+ assert_eq!(tabs[1].url, "https://example.org/");
+ // Firefox's `selected` is 1-based; the first tab was focused.
+ assert_eq!(active, 0);
+ }
+
+ #[test]
+ fn rejects_unknown_container() {
+ assert!(decode_mozlz4(b"not a mozlz4 file at all").is_err());
+ }
+
+ #[test]
+ fn handoff_roundtrip_and_freshness() {
+ let dir = std::env::temp_dir().join(format!("rustywolf-test-{}", std::process::id()));
+ let path = dir.join("handoff.json");
+ let handoff = Handoff::new(
+ false,
+ 1,
+ vec![
+ HandoffTab {
+ url: "https://example.com/".into(),
+ title: "Example".into(),
+ },
+ HandoffTab {
+ url: "https://example.org/".into(),
+ title: "".into(),
+ },
+ ],
+ );
+ save_to(&path, &handoff).unwrap();
+
+ let loaded = take_fresh_from(&path, 60).expect("fresh handoff");
+ assert_eq!(loaded.tabs.len(), 2);
+ assert_eq!(loaded.active, 1);
+ assert!(!path.exists(), "handoff is consumed on read");
+
+ // Stale handoffs are dropped.
+ let stale = Handoff {
+ created_at: now_unix().saturating_sub(9999),
+ ..handoff
+ };
+ save_to(&path, &stale).unwrap();
+ assert!(take_fresh_from(&path, 60).is_none());
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn skips_unusable_session_urls() {
+ let doc = serde_json::json!({
+ "selectedWindow": 1,
+ "windows": [{
+ "selected": 2,
+ "tabs": [
+ {"index": 1, "entries": [{"url": "about:newtab"}]},
+ {"index": 1, "entries": [{"url": "https://ok.example/"}]},
+ ],
+ }],
+ });
+ let (tabs, active) = extract_tabs(&doc);
+ assert_eq!(tabs.len(), 1);
+ assert_eq!(tabs[0].url, "https://ok.example/");
+ assert_eq!(active, 0);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 4a2b658..7396308 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,16 @@
-//main.rs
+//! RustyWolf entry point: load config, pick the engine, go.
+
mod cli;
-mod webview;
+mod config;
+mod gecko;
+mod handoff;
+mod switch;
+mod util;
+mod webkit;
use anyhow::Result;
+use cli::{Cli, Engine};
+use std::path::PathBuf;
fn main() {
if let Err(err) = run() {
@@ -12,6 +20,63 @@ fn main() {
}
fn run() -> Result<()> {
- let cli = cli::Cli::parse_args();
- webview::launch_webkit(&cli)
+ let cli = Cli::parse_args();
+ let mut cfg = config::load();
+
+ if cli.dry_run {
+ let engine = cli.engine.unwrap_or(cfg.engine);
+ print_dry_run(&cli, &cfg, engine);
+ return Ok(());
+ }
+
+ if let Some(engine) = cli.engine
+ && cfg.engine != engine
+ {
+ cfg.engine = engine;
+ config::save(&cfg)?;
+ }
+ if let Some(firefox) = &cli.firefox {
+ cfg.firefox_bin = Some(firefox.clone());
+ config::save(&cfg)?;
+ }
+
+ match cli.engine.unwrap_or(cfg.engine) {
+ Engine::Webkit => webkit::launch(&cli, &mut cfg),
+ Engine::Gecko => gecko::launch(&cli, &mut cfg),
+ }
+}
+
+fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) {
+ let url = cli
+ .url
+ .as_deref()
+ .map(util::normalize_url)
+ .unwrap_or_else(|| util::DEFAULT_HOME.to_string());
+
+ match engine {
+ Engine::Webkit => {
+ let effective_dmabuf =
+ std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string());
+ println!(
+ "engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}",
+ cli.private,
+ cfg.adblock,
+ if cli.ua_chrome { "chrome" } else { "native" },
+ cli.linux_backend,
+ cli.linux_disable_dmabuf,
+ );
+ }
+ Engine::Gecko => {
+ let firefox: Option =
+ cli.firefox.clone().or_else(|| gecko::locate_firefox(cfg));
+ let firefox = firefox
+ .map(|path| path.display().to_string())
+ .unwrap_or_else(|| "not-found".to_string());
+ println!(
+ "engine=gecko url={url} private={} firefox={firefox} profile={}",
+ cli.private,
+ config::gecko_profile_dir().display()
+ );
+ }
+ }
}
diff --git a/src/switch.rs b/src/switch.rs
new file mode 100644
index 0000000..23b02d5
--- /dev/null
+++ b/src/switch.rs
@@ -0,0 +1,59 @@
+//! Engine switching: hand the tab set to the other engine and relaunch.
+
+use crate::cli::Engine;
+use crate::config::{self, Config};
+use crate::handoff::{self, Handoff};
+use anyhow::{Context, Result};
+use std::process::{Command, Stdio};
+
+/// Persist the handoff, relaunch RustyWolf in `target` mode, remember the choice.
+pub fn switch_to(
+ target: Engine,
+ cfg: &mut Config,
+ handoff: Option<&Handoff>,
+ private: bool,
+) -> Result<()> {
+ let has_handoff = match handoff {
+ Some(handoff) if !handoff.tabs.is_empty() => {
+ handoff::save(handoff)?;
+ true
+ }
+ _ => false,
+ };
+ spawn_detached(target, has_handoff, private)?;
+ cfg.engine = target;
+ if let Err(err) = config::save(cfg) {
+ eprintln!("rustywolf: could not persist the engine choice: {err:#}");
+ }
+ Ok(())
+}
+
+/// Start another RustyWolf process in the background (survives our exit).
+pub fn spawn_detached(target: Engine, resume: bool, private: bool) -> Result<()> {
+ let exe = std::env::current_exe().context("could not locate the RustyWolf binary")?;
+ let mut cmd = Command::new(exe);
+ cmd.arg("--engine").arg(target.as_str());
+ if resume {
+ cmd.arg("--resume");
+ }
+ if private {
+ cmd.arg("--private");
+ }
+ cmd.stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ #[cfg(unix)]
+ unsafe {
+ use std::os::unix::process::CommandExt;
+ cmd.pre_exec(|| {
+ // New session so the switched process survives this one's exit.
+ libc::setsid();
+ Ok(())
+ });
+ }
+
+ cmd.spawn()
+ .with_context(|| format!("could not launch the {} engine", target.as_str()))?;
+ Ok(())
+}
diff --git a/src/testdata/recovery.jsonlz4 b/src/testdata/recovery.jsonlz4
new file mode 100644
index 0000000..7cd7c26
Binary files /dev/null and b/src/testdata/recovery.jsonlz4 differ
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..e44fd65
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,247 @@
+//! Small shared helpers: URL normalization, filenames, timestamps.
+
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub const DEFAULT_HOME: &str = "https://duckduckgo.com";
+pub const NEW_TAB_URL: &str = "https://duckduckgo.com";
+pub const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
+
+/// Turn a URL-bar entry into a URL (search terms go to DuckDuckGo).
+pub fn normalize_url(input: &str) -> String {
+ let trimmed = input.trim();
+ if trimmed == "about:blank" {
+ return trimmed.to_string();
+ }
+ if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
+ return trimmed.to_string();
+ }
+ if looks_like_address(trimmed) {
+ return format!("https://{trimmed}");
+ }
+ let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
+ format!("{SEARCH_URL_PREFIX}{encoded}")
+}
+
+pub fn looks_like_address(value: &str) -> bool {
+ value.contains('.')
+ || value.contains(':')
+ || value.starts_with("localhost")
+ || value.starts_with("127.")
+ || value.starts_with('[')
+ || value.starts_with('/')
+}
+
+/// New-window targets RustyWolf refuses to open as tabs/popups.
+pub fn should_open_new_window_url(raw: &str) -> bool {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") {
+ return false;
+ }
+ let lower = trimmed.to_ascii_lowercase();
+ if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("about:")
+ {
+ return false;
+ }
+
+ let Ok(parsed) = url::Url::parse(trimmed) else {
+ return false;
+ };
+
+ if parsed.scheme() != "http" && parsed.scheme() != "https" {
+ return false;
+ }
+
+ // DuckDuckGo's in-page "open in a new window" helpers are not real links.
+ let host = parsed.host_str().unwrap_or_default();
+ let path = parsed.path().to_ascii_lowercase();
+ if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
+ && (path == "/post3.html" || path == "/page3" || path == "/page3.html")
+ {
+ return false;
+ }
+
+ true
+}
+
+pub fn sanitize_filename(raw: &str) -> String {
+ let mut out = String::with_capacity(raw.len());
+ for ch in raw.chars() {
+ let invalid =
+ matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
+ out.push(if invalid { '_' } else { ch });
+ }
+ let trimmed = out.trim_matches('.').trim();
+ if trimmed.is_empty() {
+ "download.bin".to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+pub fn filename_from_url(url: &str) -> String {
+ let raw = url::Url::parse(url)
+ .ok()
+ .and_then(|u| {
+ u.path_segments().and_then(|mut parts| {
+ parts
+ .next_back()
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .map(ToString::to_string)
+ })
+ })
+ .unwrap_or_else(|| "download.bin".to_string());
+
+ sanitize_filename(&raw)
+}
+
+pub fn ensure_unique_download_path(path: PathBuf) -> PathBuf {
+ if !path.exists() {
+ return path;
+ }
+
+ let parent = path
+ .parent()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("."));
+ let stem = path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .filter(|s| !s.is_empty())
+ .unwrap_or("download")
+ .to_string();
+ let ext = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .filter(|e| !e.is_empty())
+ .map(ToString::to_string);
+
+ for idx in 1..=9999 {
+ let file_name = match &ext {
+ Some(ext) => format!("{stem} ({idx}).{ext}"),
+ None => format!("{stem} ({idx})"),
+ };
+ let candidate = parent.join(file_name);
+ if !candidate.exists() {
+ return candidate;
+ }
+ }
+
+ parent.join(format!("{stem}-{}", now_unix()))
+}
+
+pub fn path_to_string(path: &Path) -> String {
+ path.to_string_lossy().into_owned()
+}
+
+pub fn now_unix() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Human timestamp used by history/downloads rows.
+pub fn now_stamp() -> String {
+ now_unix().to_string()
+}
+
+pub fn escape_html(input: &str) -> String {
+ input
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+/// RustyWolf's own generated pages (properties, etc).
+pub fn is_internal_page(url: &str) -> bool {
+ url.starts_with("data:text/html;base64,")
+}
+
+pub fn path_string_to_file_url(path: &str) -> String {
+ url::Url::from_file_path(Path::new(path))
+ .map(|u| u.to_string())
+ .unwrap_or_else(|_| format!("file://{path}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn keeps_existing_scheme() {
+ assert_eq!(normalize_url("https://example.com"), "https://example.com");
+ }
+
+ #[test]
+ fn adds_https_for_domains() {
+ assert_eq!(normalize_url("example.com"), "https://example.com");
+ }
+
+ #[test]
+ fn keeps_about_blank() {
+ assert_eq!(normalize_url("about:blank"), "about:blank");
+ }
+
+ #[test]
+ fn converts_search_terms_to_duckduckgo_query() {
+ assert_eq!(
+ normalize_url("privacy browser"),
+ "https://duckduckgo.com/?q=privacy+browser"
+ );
+ }
+
+ #[test]
+ fn converts_single_token_to_duckduckgo_query() {
+ assert_eq!(normalize_url("rust"), "https://duckduckgo.com/?q=rust");
+ }
+
+ #[test]
+ fn trims_before_search() {
+ assert_eq!(
+ normalize_url(" rust webview "),
+ "https://duckduckgo.com/?q=rust+webview"
+ );
+ }
+
+ #[test]
+ fn rejects_non_web_new_window_targets() {
+ assert!(!should_open_new_window_url(""));
+ assert!(!should_open_new_window_url(" about:blank "));
+ assert!(!should_open_new_window_url("javascript:alert(1)"));
+ assert!(!should_open_new_window_url("data:text/html,hi"));
+ }
+
+ #[test]
+ fn rejects_duckduckgo_internal_popup_paths() {
+ assert!(!should_open_new_window_url(
+ "https://duckduckgo.com/post3.html"
+ ));
+ assert!(!should_open_new_window_url("https://duckduckgo.com/page3"));
+ assert!(!should_open_new_window_url(
+ "https://duckduckgo.com/page3.html"
+ ));
+ }
+
+ #[test]
+ fn allows_regular_https_new_window_targets() {
+ assert!(should_open_new_window_url("https://example.com/"));
+ assert!(should_open_new_window_url("https://duckduckgo.com/?q=rust"));
+ assert!(should_open_new_window_url(
+ "https://accounts.google.com/o/oauth2/v2/auth"
+ ));
+ }
+
+ #[test]
+ fn sanitizes_windows_unsafe_filename_chars() {
+ assert_eq!(sanitize_filename("report:2026?.pdf"), "report_2026_.pdf");
+ }
+
+ #[test]
+ fn falls_back_when_filename_becomes_empty() {
+ assert_eq!(sanitize_filename("..."), "download.bin");
+ }
+}
diff --git a/src/webkit/chrome.html b/src/webkit/chrome.html
new file mode 100644
index 0000000..2909bce
--- /dev/null
+++ b/src/webkit/chrome.html
@@ -0,0 +1,360 @@
+
+
+
+
+
+
+
+