diff --git a/Cargo.lock b/Cargo.lock
index cd6028f..65e1207 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7,7 +7,7 @@ version = 4
[[package]]
name = "RustyWolf"
-version = "0.2.0"
+version = "0.1.1"
dependencies = [
"anyhow",
"base64",
@@ -15,13 +15,10 @@ dependencies = [
"dirs",
"gtk",
"image",
- "libc",
- "serde",
"serde_json",
"tao",
"ureq",
"url",
- "webkit2gtk",
"wry",
]
@@ -2218,7 +2215,6 @@ 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 26842cc..efd8bab 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,30 +1,24 @@
#Cargo.toml
[package]
name = "RustyWolf"
-version = "0.2.0"
+version = "0.1.1"
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"] }
-
-[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
+image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
+dirs = "6.0"
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 8c53075..8f9c024 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,46 @@
# RustyWolf
-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.
+RustyWolf is a minimal, privacy-focused browser shell built with Rust + system webviews.
(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 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
+- 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.
- 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`), 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
+- Built-in custom adblock with top-bar toggle (`ADS`)
+- Popup permission gate: asks before a site opens a new tab
- 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 in both engines
-- Engine switch with tab handoff: WebKit tabs become Firefox tabs and vice versa
+- Optional `--private` mode when backend support is available
## Run
```bash
cargo run --
+```
-# pick a URL / window title
+```bash
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
@@ -70,29 +48,18 @@ 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
-WebKit mode applies baseline hardening in page context:
+RustyWolf 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. Gecko mode uses Firefox's own strict tracking protection instead.
+Adblocking is built-in and lightweight. It is not full uBlock Origin parity.
# Linux Setup (prerequisites)
@@ -129,8 +96,6 @@ 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:
@@ -214,8 +179,6 @@ 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)
@@ -224,25 +187,17 @@ If RustyWolf segfaults instantly with `Couldn't register with accessibility bus`
/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) for WebKit mode, then build normally with the Rust MSVC toolchain.
+Install the Microsoft Edge WebView2 Runtime (Evergreen), then build normally with 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 c6879ab..87c4ff7 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,30 +1,6 @@
-//! Command line interface definitions.
-
+//cli.rs
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,
@@ -35,50 +11,38 @@ pub enum LinuxBackend {
#[derive(Debug, Parser)]
#[command(
name = "rustywolf",
- 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.",
+ about = "Privacy-focused WebKit browser",
+ long_about = "RustyWolf launches an embedded WebKit browser window.",
version
)]
pub struct Cli {
- /// Startup URL (defaults to https://duckduckgo.com)
- #[arg(long)]
+ #[arg(
+ long,
+ help = "Optional startup URL (defaults to https://duckduckgo.com)"
+ )]
pub url: Option,
- /// Window title (WebKit mode)
- #[arg(long)]
+ #[arg(long, help = "Set a custom window title")]
pub title: Option,
- /// Select the browsing engine (persisted for the next launch too)
- #[arg(long, value_enum)]
- pub engine: Option,
-
- /// Start in private/incognito mode
- #[arg(long)]
+ #[arg(long, help = "Try private/incognito mode when backend supports it")]
pub private: bool,
- /// Print launch settings without opening a window
- #[arg(long)]
+ #[arg(long, help = "Print launch settings without opening a window")]
pub dry_run: bool,
- /// 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)]
+ #[arg(
+ long,
+ value_enum,
+ default_value_t = LinuxBackend::Auto,
+ help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
+ )]
pub linux_backend: LinuxBackend,
- /// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
- #[arg(long)]
+ #[arg(
+ long,
+ help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
+ )]
pub linux_disable_dmabuf: bool,
}
diff --git a/src/config.rs b/src/config.rs
deleted file mode 100644
index 180655e..0000000
--- a/src/config.rs
+++ /dev/null
@@ -1,88 +0,0 @@
-//! 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
deleted file mode 100644
index 343d543..0000000
--- a/src/gecko/home.html
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-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
deleted file mode 100644
index 6f0cdc8..0000000
--- a/src/gecko/mod.rs
+++ /dev/null
@@ -1,330 +0,0 @@
-//! 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
deleted file mode 100644
index 96d0c7b..0000000
--- a/src/gecko/prefs.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-//! 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
deleted file mode 100644
index b5a8e79..0000000
--- a/src/gecko/server.rs
+++ /dev/null
@@ -1,170 +0,0 @@
-//! 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
deleted file mode 100644
index 357baa4..0000000
--- a/src/handoff.rs
+++ /dev/null
@@ -1,350 +0,0 @@
-//! 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 7396308..4a2b658 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,8 @@
-//! RustyWolf entry point: load config, pick the engine, go.
-
+//main.rs
mod cli;
-mod config;
-mod gecko;
-mod handoff;
-mod switch;
-mod util;
-mod webkit;
+mod webview;
use anyhow::Result;
-use cli::{Cli, Engine};
-use std::path::PathBuf;
fn main() {
if let Err(err) = run() {
@@ -20,63 +12,6 @@ fn main() {
}
fn run() -> Result<()> {
- 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()
- );
- }
- }
+ let cli = cli::Cli::parse_args();
+ webview::launch_webkit(&cli)
}
diff --git a/src/switch.rs b/src/switch.rs
deleted file mode 100644
index 23b02d5..0000000
--- a/src/switch.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-//! 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
deleted file mode 100644
index 7cd7c26..0000000
Binary files a/src/testdata/recovery.jsonlz4 and /dev/null differ
diff --git a/src/util.rs b/src/util.rs
deleted file mode 100644
index e44fd65..0000000
--- a/src/util.rs
+++ /dev/null
@@ -1,247 +0,0 @@
-//! 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
deleted file mode 100644
index 2909bce..0000000
--- a/src/webkit/chrome.html
+++ /dev/null
@@ -1,360 +0,0 @@
-
-
-
-
-
-
-
-