Complete engine rewrite - now featuring Gecko!

This commit is contained in:
Martin Dvořák 2026-10-11 16:57:49 +02:00
commit b1be74adb8
20 changed files with 4069 additions and 2309 deletions

6
Cargo.lock generated
View file

@ -7,7 +7,7 @@ version = 4
[[package]] [[package]]
name = "RustyWolf" name = "RustyWolf"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -15,10 +15,13 @@ dependencies = [
"dirs", "dirs",
"gtk", "gtk",
"image", "image",
"libc",
"serde",
"serde_json", "serde_json",
"tao", "tao",
"ureq", "ureq",
"url", "url",
"webkit2gtk",
"wry", "wry",
] ]
@ -2215,6 +2218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]

View file

@ -1,24 +1,30 @@
#Cargo.toml #Cargo.toml
[package] [package]
name = "RustyWolf" name = "RustyWolf"
version = "0.1.1" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
base64 = "0.22" base64 = "0.22"
clap = { version = "4.5", features = ["derive"] } 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" serde_json = "1.0"
tao = "0.31" tao = "0.31"
ureq = "2.12" ureq = "2.12"
url = "2.5" url = "2.5"
wry = { version = "0.47", features = ["fullscreen"] } 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" gtk = "0.18.2"
webkit2gtk = { version = "2.0", features = ["v2_40"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[package.metadata.bundle] [package.metadata.bundle]
name = "RustyWolf" name = "RustyWolf"
identifier = "com.rustywolf.browser" identifier = "com.rustywolf.browser"
icon = ["src/rustywolf.icns"] icon = ["src/rustywolf.icns"]

View file

@ -1,46 +1,68 @@
# RustyWolf # 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) (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.
<img <img
alt="image" alt="image"
src="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf/raw/branch/main/preview.png" src="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf/raw/branch/main/preview.png"
style="width: 100%; max-width: 1920px; aspect-ratio: 18 / 9; object-fit: contain; height: auto;" style="width: 100%; max-width: 1920px; aspect-ratio: 18 / 9; object-fit: contain; height: auto;"
/> />
## 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 ## 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 - 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
- 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 - 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
- THATS 3.04 TIMES AS MUCH RAM, so yeah it is light - 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
- 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. - 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 - 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`) - Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`)
- Built-in custom adblock with top-bar toggle (`ADS`) - Built-in custom adblock with top-bar toggle (`ADS`), persisted across restarts
- Popup permission gate: asks before a site opens a new tab - 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 - Download support with live progress panel (`D`) and session download history
- Properties page with session browsing history + download history - Properties page with session browsing history + download history
- Default window size `1000x600` (still fully resizable) - 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 ## Run
```bash ```bash
cargo run -- cargo run --
```
```bash # pick a URL / window title
cargo run -- --url https://example.com cargo run -- --url https://example.com
cargo run -- --url example.com cargo run -- --url example.com
cargo run -- --title "RustyWolf" 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 -- --private
cargo run -- --dry-run cargo run -- --dry-run
@ -48,18 +70,29 @@ cargo run -- --dry-run
cargo run -- --linux-backend x11 cargo run -- --linux-backend x11
cargo run -- --linux-backend wayland cargo run -- --linux-backend wayland
cargo run -- --linux-disable-dmabuf 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 ## Privacy Notes
RustyWolf applies baseline hardening in page context: WebKit mode applies baseline hardening in page context:
- `navigator.doNotTrack = "1"` - `navigator.doNotTrack = "1"`
- disables legacy `openDatabase` - disables legacy `openDatabase`
- disables `RTCPeerConnection` - 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) # Linux Setup (prerequisites)
@ -96,6 +129,8 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env" 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 For NVIDIA, make the DRM modeset setting part of the boot command line, not only
`/etc/modprobe.d`, then reboot: `/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 /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 # MacOS
## installing Homebrew (skip if already installed) ## 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)" /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 # 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/ - WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/
- Rust toolchain target: `x86_64-pc-windows-msvc` - Rust toolchain target: `x86_64-pc-windows-msvc`
Gecko mode finds Firefox under `Program Files` / `%LOCALAPPDATA%\Mozilla Firefox`, or use `--firefox`.
## Dev Quick Checks ## Dev Quick Checks
```bash ```bash
cargo fmt cargo fmt
cargo check cargo check
cargo test cargo test
cargo clippy --all-targets
# exercises the Gecko handoff against a real headless Firefox
cargo test -- --ignored --nocapture
``` ```

View file

@ -1,6 +1,30 @@
//cli.rs //! Command line interface definitions.
use clap::{Parser, ValueEnum};
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)] #[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LinuxBackend { pub enum LinuxBackend {
Auto, Auto,
@ -11,38 +35,50 @@ pub enum LinuxBackend {
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
name = "rustywolf", name = "rustywolf",
about = "Privacy-focused WebKit browser", about = "Lightweight privacy browser (WebKit + Gecko engines)",
long_about = "RustyWolf launches an embedded WebKit browser window.", long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.",
version version
)] )]
pub struct Cli { pub struct Cli {
#[arg( /// Startup URL (defaults to https://duckduckgo.com)
long, #[arg(long)]
help = "Optional startup URL (defaults to https://duckduckgo.com)"
)]
pub url: Option<String>, pub url: Option<String>,
#[arg(long, help = "Set a custom window title")] /// Window title (WebKit mode)
#[arg(long)]
pub title: Option<String>, pub title: Option<String>,
#[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<Engine>,
/// Start in private/incognito mode
#[arg(long)]
pub private: bool, 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, pub dry_run: bool,
#[arg( /// Firefox binary used by Gecko mode (persisted)
long, #[arg(long, value_name = "PATH")]
value_enum, pub firefox: Option<PathBuf>,
default_value_t = LinuxBackend::Auto,
help = "Linux only: select a GTK windowing backend (auto, x11, wayland)" /// 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, pub linux_backend: LinuxBackend,
#[arg( /// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
long, #[arg(long)]
help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
)]
pub linux_disable_dmabuf: bool, pub linux_disable_dmabuf: bool,
} }

88
src/config.rs Normal file
View file

@ -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<PathBuf>,
}
/// `~/.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);
}
}

78
src/gecko/home.html Normal file
View file

@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustyWolf</title>
<style>
:root { color-scheme: dark; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: #101012; color: #e8e8ed;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
main { width: min(620px, 92vw); padding: 32px 0; }
h1 { font-size: 30px; letter-spacing: .5px; }
h1 span { color: #6fc16f; }
.sub { margin-top: 6px; font-size: 13px; opacity: .72; }
.engine { margin-top: 26px; display: flex; align-items: center; gap: 14px;
border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.04);
padding: 14px 16px; }
.leaf { width: 46px; height: 46px; display: inline-flex; align-items: center;
justify-content: center; font-size: 24px; cursor: pointer; flex-shrink: 0;
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06); }
.leaf:hover { background: rgba(255,255,255,.12); }
.leaf.gecko { filter: grayscale(1); opacity: .75; }
.engine-text { font-size: 13px; line-height: 1.55; }
.engine-text b { font-size: 14px; }
.muted { opacity: .7; }
form { margin-top: 22px; display: flex; gap: 8px; }
input[type="search"] { flex: 1; height: 40px; padding: 0 12px; font-size: 14px;
color: #f5f5f7; background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.18); outline: none; }
input[type="search"]:focus { border-color: rgba(90,140,255,.7); background: rgba(255,255,255,.1); }
button.search { height: 40px; padding: 0 16px; font-size: 14px; color: #e8e8ed;
cursor: pointer; background: rgba(90,140,255,.7); border: 1px solid rgba(90,140,255,.5); }
.status { display: block; margin-top: 8px; font-size: 12px; min-height: 16px; color: #9fd49f; }
.notes { margin-top: 30px; font-size: 12px; line-height: 1.7; opacity: .68; }
.notes code { color: #d7d7e0; }
</style>
</head>
<body>
<main>
<h1>Rusty<span>Wolf</span></h1>
<p class="sub">Gecko mode &mdash; powered by Firefox, managed by RustyWolf.</p>
<div class="engine">
<button class="leaf gecko" id="leaf" title="Switch to the WebKit engine">&#127811;</button>
<div class="engine-text">
<b>Engine: Gecko (Firefox)</b><br>
<span class="muted">Click the leaf to switch back to the light WebKit engine.</span>
<span class="status" id="status"></span>
</div>
</div>
<form action="https://duckduckgo.com/" method="get">
<input type="search" name="q" placeholder="Search with DuckDuckGo" autofocus autocomplete="off">
<button class="search" type="submit">Search</button>
</form>
<p class="notes">
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.<br>
WebKit mode extras: the built-in adblock toggle (<code>ADS</code>), keep-in-ram tabs
(<code>K</code>), the popup gate and the properties page. In Gecko mode Firefox handles
browsing natively.
</p>
</main>
<script>
const leaf = document.getElementById('leaf');
const status = document.getElementById('status');
leaf.addEventListener('click', () => {
leaf.disabled = true;
status.textContent = 'Switching to WebKit\u2026 your tabs are being carried over.';
fetch('/switch?token={{TOKEN}}', { cache: 'no-store' })
.catch(() => { location.href = '/switch?token={{TOKEN}}'; });
});
</script>
</body>
</html>

330
src/gecko/mod.rs Normal file
View file

@ -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<String> {
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<String> = 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<Child> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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);
}
}
}

161
src/gecko/prefs.rs Normal file
View file

@ -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);
}
}

170
src/gecko/server.rs Normal file
View file

@ -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<Self> {
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<Connection> {
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);
}
}

350
src/handoff.rs Normal file
View file

@ -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<HandoffTab>,
}
impl Handoff {
pub fn new(private: bool, mut active: usize, mut tabs: Vec<HandoffTab>) -> 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<Handoff> {
take_fresh_from(&config::handoff_path(), MAX_HANDOFF_AGE_SECS)
}
pub(crate) fn take_fresh_from(path: &Path, max_age_secs: u64) -> Option<Handoff> {
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<Handoff> {
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::<serde_json::Value>(&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<HandoffTab>, 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<Vec<u8>> {
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<Vec<u8>> {
let mut out: Vec<u8> = 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);
}
}

View file

@ -1,8 +1,16 @@
//main.rs //! RustyWolf entry point: load config, pick the engine, go.
mod cli; mod cli;
mod webview; mod config;
mod gecko;
mod handoff;
mod switch;
mod util;
mod webkit;
use anyhow::Result; use anyhow::Result;
use cli::{Cli, Engine};
use std::path::PathBuf;
fn main() { fn main() {
if let Err(err) = run() { if let Err(err) = run() {
@ -12,6 +20,63 @@ fn main() {
} }
fn run() -> Result<()> { fn run() -> Result<()> {
let cli = cli::Cli::parse_args(); let cli = Cli::parse_args();
webview::launch_webkit(&cli) 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<PathBuf> =
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()
);
}
}
} }

59
src/switch.rs Normal file
View file

@ -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(())
}

BIN
src/testdata/recovery.jsonlz4 vendored Normal file

Binary file not shown.

247
src/util.rs Normal file
View file

@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// 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");
}
}

360
src/webkit/chrome.html Normal file
View file

@ -0,0 +1,360 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #101012;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-user-select: none; user-select: none; }
#bar { display: flex; flex-direction: column; height: 100%;
padding: 6px 8px; gap: 6px;
border-bottom: 1px solid rgba(255,255,255,0.15); }
#tabs-top { display: flex; align-items: center; gap: 6px; height: 30px; }
#tabs-row { flex: 1; display: flex; align-items: center; gap: 4px;
height: 30px; overflow-x: auto; overflow-y: hidden; }
#nav-row { display: flex; align-items: center; gap: 6px; height: 36px; }
#downloads-row { display: none; flex-direction: column; align-items: stretch; gap: 6px; overflow-y: auto;
border: 1px solid rgba(255,255,255,0.18); border-radius: 0; padding: 8px;
background: rgba(255,255,255,0.07); min-height: 92px; }
.dl-empty { font-size: 12px; opacity: .75; }
.dl-item { display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(255,255,255,0.16);
border-radius: 0; padding: 6px 8px; background: rgba(0,0,0,0.2); color: #e8e8ed; }
.dl-title { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dl-meta { font-size: 11px; opacity: .82; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dl-track { width: 100%; height: 6px; background: rgba(255,255,255,0.18); border-radius: 0; overflow: hidden; }
.dl-fill { height: 100%; width: 0%; border-radius: 0; background: linear-gradient(90deg, #4f8bff, #72d8ff);
transform-origin: left center; }
.dl-fill.indeterminate { width: 35%; animation: glide 1.05s ease-in-out infinite; }
@keyframes glide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }
button { flex-shrink: 0; height: 30px; min-width: 30px; padding: 0 8px;
border: 1px solid rgba(255,255,255,0.2); border-radius: 0;
background: rgba(255,255,255,0.1); color: #e8e8ed;
font-size: 14px; cursor: pointer; outline: none; }
button:active { background: rgba(255,255,255,0.2); }
#url-input { flex: 1; height: 30px; border: 1px solid rgba(255,255,255,0.2);
border-radius: 0; padding: 4px 10px; font-size: 13px; outline: none;
color: #f5f5f7; background: rgba(255,255,255,0.07);
-webkit-user-select: text; user-select: text; }
#url-input:focus { border-color: rgba(90,140,255,0.7); background: rgba(255,255,255,0.1); }
.tab-btn { position: relative; display: inline-flex; align-items: center;
max-width: 170px; height: 26px; font-size: 12px;
padding-right: 42px; }
.tab-btn.unloaded:not(.active) { opacity: .72; }
.tab-title { display: block; width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tab-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
.kir-btn { position: absolute; right: 22px; top: 2px;
width: 18px; height: 18px; min-width: 18px; border-radius: 0;
border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.25);
color: #fff; font-size: 10px; line-height: 16px; padding: 0; }
.kir-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
.close-btn { position: absolute; right: 3px; top: 2px;
width: 18px; height: 18px; min-width: 18px; border-radius: 0;
border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.25);
color: #fff; font-size: 11px; line-height: 16px; padding: 0; }
#add-btn { height: 26px; min-width: 28px; font-size: 16px; }
#engine-btn { height: 26px; min-width: 32px; font-size: 14px; }
#engine-btn.active { background: rgba(70,190,90,0.75); border-color: rgba(70,190,90,0.5); }
#adblock-btn { height: 26px; min-width: 40px; font-size: 12px; font-weight: 700; }
#adblock-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
#props-btn { height: 26px; min-width: 88px; font-size: 12px; }
#downloads-btn { height: 26px; min-width: 28px; font-size: 12px; font-weight: 700; }
#downloads-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
#popup-row { display: none; align-items: center; justify-content: space-between; gap: 8px;
min-height: 34px; padding: 6px 8px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06); color: #e8e8ed; font-size: 12px; }
#popup-message { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#popup-actions { display: inline-flex; align-items: center; gap: 6px; }
#popup-yes, #popup-no { height: 24px; min-width: 44px; font-size: 12px; }
</style>
</head>
<body>
<div id="bar">
<div id="tabs-top">
<div id="tabs-row"></div>
<button id="engine-btn" class="active" title="Engine: WebKit">&#127811;</button>
<button id="adblock-btn" title="Toggle adblock">ADS</button>
<button id="downloads-btn" title="Downloads">D</button>
<button id="props-btn" title="History and downloads">Properties</button>
</div>
<div id="nav-row">
<button id="back" title="Back">&#8592;</button>
<button id="forward" title="Forward">&#8594;</button>
<button id="reload" title="Reload">&#8635;</button>
<input id="url-input" type="text" placeholder="Search or enter address"
autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div id="downloads-row"></div>
<div id="popup-row">
<div id="popup-message"></div>
<div id="popup-actions">
<button id="popup-yes">Yes</button>
<button id="popup-no">No</button>
</div>
</div>
</div>
<script>
const post = (msg) => window.ipc && window.ipc.postMessage(msg);
const normalize = (raw) => {
const value = (raw || '').trim();
if (!value) return '';
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)) return value;
const looksLikeAddress =
value.includes('.') || value.includes(':') ||
value.startsWith('localhost') || value.startsWith('127.') ||
value.startsWith('[') || value.startsWith('/');
return looksLikeAddress ? 'https://' + value
: 'https://duckduckgo.com/?q=' + encodeURIComponent(value);
};
const input = document.getElementById('url-input');
const isEditableTarget = (target) => {
if (!target || !(target instanceof Element)) return false;
if (target === input) return true;
const tag = (target.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || target.isContentEditable;
};
document.getElementById('back').addEventListener('click', () => post('back'));
document.getElementById('forward').addEventListener('click', () => post('forward'));
document.getElementById('reload').addEventListener('click', () => post('reload'));
document.getElementById('props-btn').addEventListener('click', () => post('open_properties'));
document.getElementById('adblock-btn').addEventListener('click', () => post('toggle_adblock'));
document.getElementById('downloads-btn').addEventListener('click', () => post('toggle_downloads'));
document.getElementById('engine-btn').addEventListener('click', () => post('toggle_engine'));
document.getElementById('popup-yes').addEventListener('click', () => post('popup_allow_yes'));
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
const next = normalize(input.value);
if (next) post('navigate:' + next);
}
});
const shortTitle = (tab, i) => {
if (tab.title && tab.title.trim()) return tab.title.trim().slice(0, 22);
try { return new URL(tab.url).hostname || 'Tab ' + (i + 1); }
catch (_) { return 'Tab ' + (i + 1); }
};
window.__rustywolfSetTabs = (state) => {
const row = document.getElementById('tabs-row');
row.innerHTML = '';
state.tabs.forEach((tab, i) => {
const btn = document.createElement('button');
btn.className =
'tab-btn' +
(tab.id === state.activeId ? ' active' : '') +
(tab.loaded ? '' : ' unloaded');
btn.title = tab.url;
btn.addEventListener('click', () => post('switch_tab:' + tab.id));
const title = document.createElement('span');
title.className = 'tab-title';
title.textContent = shortTitle(tab, i) + (tab.loaded ? '' : ' [S]');
btn.appendChild(title);
const kir = document.createElement('button');
kir.className = 'kir-btn' + (tab.kirEnabled ? ' active' : '');
kir.textContent = 'K';
kir.title = tab.kirEnabled ? 'KIR On' : 'KIR Off';
kir.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
post('toggle_kir:' + tab.id);
});
btn.appendChild(kir);
const x = document.createElement('button');
x.className = 'close-btn';
x.textContent = '-'; x.title = 'Close tab';
x.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
post('close_tab:' + tab.id);
});
btn.appendChild(x);
row.appendChild(btn);
});
const add = document.createElement('button');
add.id = 'add-btn'; add.textContent = '+'; add.title = 'New tab';
add.addEventListener('click', () => post('new_tab'));
row.appendChild(add);
const active = state.tabs.find(t => t.id === state.activeId);
if (active) input.value = (active.url && active.url !== 'about:blank') ? active.url : '';
};
window.__rustywolfSetEngine = (engine) => {
const button = document.getElementById('engine-btn');
if (!button) return;
const isWebkit = engine === 'webkit';
button.classList.toggle('active', isWebkit);
button.title = isWebkit
? 'Engine: WebKit (click to switch to Gecko/Firefox)'
: 'Engine: Gecko';
};
const shortPath = (value) => {
if (!value) return 'Unknown destination';
if (value.length <= 42) return value;
return '...' + value.slice(-39);
};
window.__rustywolfSetDownloads = (state) => {
const row = document.getElementById('downloads-row');
const button = document.getElementById('downloads-btn');
const items = (state && Array.isArray(state.downloads)) ? state.downloads : [];
const panelOpen = !!(state && state.panelOpen);
row.innerHTML = '';
if (!panelOpen) {
row.style.display = 'none';
button.classList.remove('active');
return;
}
button.classList.add('active');
row.style.display = 'flex';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'dl-empty';
empty.textContent = 'No active downloads';
row.appendChild(empty);
return;
}
const formatBytes = (value) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let num = Number(value) || 0;
let unit = 0;
while (num >= 1024 && unit < units.length - 1) {
num /= 1024;
unit += 1;
}
return (unit === 0 ? Math.round(num).toString() : num.toFixed(1)) + ' ' + units[unit];
};
items.slice(0, 6).forEach((entry) => {
const card = document.createElement('div');
card.className = 'dl-item';
const title = document.createElement('div');
title.className = 'dl-title';
title.textContent = shortPath(entry.filePath);
const meta = document.createElement('div');
meta.className = 'dl-meta';
let progressText = formatBytes(entry.downloadedBytes || 0) + ' downloaded';
const total = Number(entry.totalBytes || 0);
const done = Number(entry.downloadedBytes || 0);
if (total > 0) {
const pct = Math.max(0, Math.min(100, Math.floor((done / total) * 100)));
progressText = formatBytes(done) + ' / ' + formatBytes(total) + ' (' + pct + '%)';
}
meta.textContent = entry.status + ' - ' + progressText;
const track = document.createElement('div');
track.className = 'dl-track';
const fill = document.createElement('div');
fill.className = 'dl-fill';
if (entry.inProgress) {
const totalBytes = Number(entry.totalBytes || 0);
const downloadedBytes = Number(entry.downloadedBytes || 0);
if (totalBytes > 0) {
const pct = Math.max(0, Math.min(100, (downloadedBytes / totalBytes) * 100));
fill.style.width = pct.toFixed(1) + '%';
} else {
fill.style.width = '35%';
fill.classList.add('indeterminate');
}
} else if (entry.status === 'Completed') {
fill.style.width = '100%';
fill.style.background = 'linear-gradient(90deg, #36c977, #52e39a)';
} else {
fill.style.width = '100%';
fill.style.background = 'linear-gradient(90deg, #b85b5b, #e47979)';
}
const src = document.createElement('div');
src.className = 'dl-meta';
src.textContent = entry.url || '';
track.appendChild(fill);
card.appendChild(title);
card.appendChild(meta);
card.appendChild(track);
card.appendChild(src);
row.appendChild(card);
});
};
window.__rustywolfSetAdblock = (enabled) => {
const button = document.getElementById('adblock-btn');
if (!button) return;
if (enabled) {
button.classList.add('active');
button.title = 'Adblock: On';
} else {
button.classList.remove('active');
button.title = 'Adblock: Off';
}
};
window.__rustywolfSetPopupPrompt = (state) => {
const row = document.getElementById('popup-row');
const msg = document.getElementById('popup-message');
if (!row || !msg) return;
const visible = !!(state && state.visible);
if (!visible) {
row.style.display = 'none';
msg.textContent = '';
return;
}
row.style.display = 'flex';
const host = (state && state.host) ? String(state.host) : 'this website';
msg.textContent = 'Allow ' + host + ' to open a new tab?';
};
document.addEventListener('keydown', (ev) => {
const key = ev.key.toLowerCase();
const wantsReload =
key === 'f5' ||
((ev.metaKey || ev.ctrlKey) && key === 'r');
if (!ev.defaultPrevented && wantsReload) {
ev.preventDefault();
post('reload');
return;
}
const mod = ev.metaKey || ev.ctrlKey;
if (!mod || ev.altKey || ev.defaultPrevented) return;
if (key === 'l') {
ev.preventDefault();
input.focus();
input.select();
return;
}
if (isEditableTarget(ev.target)) return;
if (key === 't') {
ev.preventDefault();
post('new_tab');
return;
}
if (key === 'w') {
ev.preventDefault();
post('close_active_tab');
}
});
</script>
</body>
</html>

195
src/webkit/js/adblock.js Normal file
View file

@ -0,0 +1,195 @@
(() => {
if (window.__rustywolfAdblockBooted) return;
window.__rustywolfAdblockBooted = true;
// The Rust side bakes the current toggle state in before this script runs.
window.__rustywolfAdblockEnabled = window.__rustywolfAdblockInitEnabled !== false;
try { delete window.__rustywolfAdblockInitEnabled; } catch (_) {}
const blockedHosts = [
'doubleclick.net',
'googlesyndication.com',
'adservice.google.com',
'google-analytics.com',
'googletagmanager.com',
'facebook.net',
'ads.yahoo.com',
'taboola.com',
'outbrain.com'
];
const cosmeticSelectors = [
'[id^="ad-"]',
'[id^="ads-"]',
'[class^="ad-"]',
'[class^="ads-"]',
'[class*=" ad-"]',
'[class*=" ads-"]',
'[class*="advert"]',
'[id*="sponsored"]',
'[class*="sponsored"]',
'iframe[src*="doubleclick"]',
'iframe[src*="adservice"]'
];
const isEnabled = () => window.__rustywolfAdblockEnabled !== false;
const matchesBlockedHost = (host) => {
const lower = (host || '').toLowerCase();
return blockedHosts.some((blocked) => lower === blocked || lower.endsWith('.' + blocked));
};
const shouldBlockUrl = (rawUrl) => {
if (!isEnabled() || !rawUrl) return false;
try {
const parsed = new URL(rawUrl, location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (matchesBlockedHost(parsed.hostname)) return true;
const full = parsed.href.toLowerCase();
return full.includes('/ads') || full.includes('adserver') || full.includes('tracking');
} catch (_) {
return false;
}
};
const removeAds = () => {
if (!isEnabled()) return;
for (const selector of cosmeticSelectors) {
document.querySelectorAll(selector).forEach((el) => {
if (!el.hasAttribute('data-rw-adblock-hidden')) {
el.setAttribute('data-rw-adblock-hidden', '1');
el.setAttribute('data-rw-prev-display', el.style.getPropertyValue('display') || '');
el.setAttribute('data-rw-prev-visibility', el.style.getPropertyValue('visibility') || '');
}
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('visibility', 'hidden', 'important');
el.setAttribute('aria-hidden', 'true');
});
}
};
const restoreAds = () => {
document.querySelectorAll('[data-rw-adblock-hidden="1"]').forEach((el) => {
const prevDisplay = el.getAttribute('data-rw-prev-display') || '';
const prevVisibility = el.getAttribute('data-rw-prev-visibility') || '';
if (prevDisplay) {
el.style.setProperty('display', prevDisplay);
} else {
el.style.removeProperty('display');
}
if (prevVisibility) {
el.style.setProperty('visibility', prevVisibility);
} else {
el.style.removeProperty('visibility');
}
el.removeAttribute('data-rw-adblock-hidden');
el.removeAttribute('data-rw-prev-display');
el.removeAttribute('data-rw-prev-visibility');
if (el.getAttribute('aria-hidden') === 'true') {
el.removeAttribute('aria-hidden');
}
});
};
let adObserver = null;
let scanQueued = false;
const scheduleAdScan = () => {
if (!isEnabled() || scanQueued) return;
scanQueued = true;
const run = () => {
scanQueued = false;
removeAds();
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(run);
} else {
setTimeout(run, 16);
}
};
const stopObserver = () => {
if (!adObserver) return;
adObserver.disconnect();
adObserver = null;
};
const startObserver = () => {
stopObserver();
if (!isEnabled()) return;
const root = document.body || document.documentElement;
if (!root) return;
adObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes && mutation.addedNodes.length > 0) {
scheduleAdScan();
return;
}
}
});
adObserver.observe(root, { childList: true, subtree: true });
};
window.__rustywolfSetAdblockEnabled = (enabled) => {
window.__rustywolfAdblockEnabled = !!enabled;
if (window.__rustywolfAdblockEnabled) {
startObserver();
scheduleAdScan();
} else {
stopObserver();
restoreAds();
}
};
const originalFetch = window.fetch;
if (typeof originalFetch === 'function') {
window.fetch = function patchedFetch(input, init) {
const target = typeof input === 'string' ? input : (input && input.url) || '';
if (shouldBlockUrl(target)) {
return Promise.resolve(new Response('', { status: 204, statusText: 'Blocked by RustyWolf' }));
}
return originalFetch.call(this, input, init);
};
}
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__rustywolfUrl = url;
return originalXhrOpen.apply(this, arguments);
};
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function patchedSend() {
if (shouldBlockUrl(this.__rustywolfUrl)) {
this.abort();
return;
}
return originalXhrSend.apply(this, arguments);
};
const patchSrcSetter = (ctor) => {
if (!ctor || !ctor.prototype) return;
const desc = Object.getOwnPropertyDescriptor(ctor.prototype, 'src');
if (!desc || typeof desc.set !== 'function' || typeof desc.get !== 'function') return;
Object.defineProperty(ctor.prototype, 'src', {
configurable: true,
enumerable: desc.enumerable,
get: desc.get,
set(value) {
if (shouldBlockUrl(value)) {
return desc.set.call(this, 'about:blank');
}
return desc.set.call(this, value);
}
});
};
patchSrcSetter(window.HTMLImageElement);
patchSrcSetter(window.HTMLScriptElement);
patchSrcSetter(window.HTMLIFrameElement);
if (window.__rustywolfAdblockEnabled) {
startObserver();
scheduleAdScan();
}
})();

27
src/webkit/js/privacy.js Normal file
View file

@ -0,0 +1,27 @@
(() => {
try {
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, get: () => '1' });
} catch (_) {}
try {
Object.defineProperty(window, 'openDatabase', { configurable: true, value: undefined });
} catch (_) {}
try {
if ('BatteryManager' in window) window.BatteryManager = undefined;
} catch (_) {}
try {
if ('RTCPeerConnection' in window) window.RTCPeerConnection = undefined;
} catch (_) {}
// Keep reload shortcuts working when focus is inside the page webview.
window.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
const key = (ev.key || '').toLowerCase();
const wantsReload =
key === 'f5' ||
((ev.metaKey || ev.ctrlKey) && key === 'r');
if (wantsReload) {
ev.preventDefault();
location.reload();
}
}, true);
})();

1636
src/webkit/mod.rs Normal file

File diff suppressed because it is too large Load diff

164
src/webkit/popup.rs Normal file
View file

@ -0,0 +1,164 @@
//! Real popup windows for Linux/WebKitGTK content views.
//!
//! wry does not connect WebKitGTK's `create` signal, so new-window requests
//! would never produce a window with a live `window.opener`. That breaks
//! popup-based logins ("Sign in with Google" and friends). Here we attach the
//! signal to the raw `WebKitWebView` and build proper related-view popups.
use super::{NavKind, NewWindowDecision, UserEvent, decide_new_window};
use gtk::prelude::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use tao::event_loop::EventLoopProxy;
use webkit2gtk::{URIRequestExt, UserContentManagerExt, WebViewExt};
pub struct PopupHub {
pub proxy: EventLoopProxy<UserEvent>,
pub adblock_enabled: Cell<bool>,
pub popups: RefCell<Vec<gtk::glib::WeakRef<webkit2gtk::WebView>>>,
}
impl PopupHub {
pub fn new(proxy: EventLoopProxy<UserEvent>, adblock_enabled: bool) -> Self {
Self {
proxy,
adblock_enabled: Cell::new(adblock_enabled),
popups: RefCell::new(Vec::new()),
}
}
}
/// Hook the `create` signal of a content webview.
pub fn attach(view: &webkit2gtk::WebView, hub: &Rc<PopupHub>) {
let hub = hub.clone();
view.connect_create(move |view, action| handle_create(view, action, &hub));
}
/// Push the adblock toggle into popup pages that are already open.
pub fn set_popup_adblock(hub: &PopupHub, enabled: bool) {
hub.adblock_enabled.set(enabled);
let script = format!(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
);
let mut alive = Vec::new();
for weak in hub.popups.borrow_mut().drain(..) {
if let Some(view) = weak.upgrade() {
view.evaluate_javascript(&script, None, None, None::<&gtk::gio::Cancellable>, |_| {});
alive.push(view.downgrade());
}
}
*hub.popups.borrow_mut() = alive;
}
fn handle_create(
view: &webkit2gtk::WebView,
action: &webkit2gtk::NavigationAction,
hub: &Rc<PopupHub>,
) -> Option<gtk::Widget> {
let url = action
.request()
.and_then(|request| request.uri())
.map(|uri| uri.to_string())
.unwrap_or_default();
let user_gesture = action.is_user_gesture();
let nav = match action.navigation_type() {
webkit2gtk::NavigationType::LinkClicked => NavKind::Link,
webkit2gtk::NavigationType::FormSubmitted | webkit2gtk::NavigationType::FormResubmitted => {
NavKind::Form
}
_ => NavKind::Other,
};
match decide_new_window(&url, nav, user_gesture) {
NewWindowDecision::Deny => None,
NewWindowDecision::Tab => {
let _ = hub.proxy.send_event(UserEvent::NewWindow { url });
None
}
NewWindowDecision::Ask => {
let _ = hub.proxy.send_event(UserEvent::NewWindowAsk { url });
None
}
NewWindowDecision::Popup => create_popup(view, &url, hub),
}
}
fn create_popup(
opener: &webkit2gtk::WebView,
url: &str,
hub: &Rc<PopupHub>,
) -> Option<gtk::Widget> {
let view = webkit2gtk::WebView::with_related_view(opener);
if let Some(settings) = WebViewExt::settings(opener) {
view.set_settings(&settings);
}
install_scripts(&view, hub.adblock_enabled.get());
let title = url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(ToString::to_string))
.unwrap_or_else(|| "RustyWolf".to_string());
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title(&title);
window.set_default_size(960, 700);
window.set_position(gtk::WindowPosition::Center);
window.add(&view);
// Popups can spawn popups (SSO hand-offs often do).
let nested_hub = hub.clone();
view.connect_create(move |view, action| handle_create(view, action, &nested_hub));
// Keep the window title in sync with the page.
let window_weak = window.downgrade();
view.connect_title_notify(move |view| {
if let Some(window) = window_weak.upgrade()
&& let Some(title) = view.title()
{
window.set_title(&title);
}
});
// Minimal popup shortcuts: Ctrl+W closes, F5 / Ctrl+R reload.
let view_weak = view.downgrade();
window.connect_key_press_event(move |window, event| {
use gtk::gdk::keys::constants as key;
let ctrl = event.state().contains(gtk::gdk::ModifierType::CONTROL_MASK);
let keyval = event.keyval();
if ctrl && keyval == key::w {
window.close();
return gtk::glib::Propagation::Stop;
}
if keyval == key::F5 || (ctrl && keyval == key::r) {
if let Some(view) = view_weak.upgrade() {
view.reload();
}
return gtk::glib::Propagation::Stop;
}
gtk::glib::Propagation::Proceed
});
window.show_all();
hub.popups.borrow_mut().push(view.downgrade());
Some(window.upcast())
}
fn install_scripts(view: &webkit2gtk::WebView, adblock_enabled: bool) {
let Some(manager) = view.user_content_manager() else {
return;
};
let source = format!(
"{}\nwindow.__rustywolfAdblockInitEnabled = {adblock_enabled};\n{}",
super::js::PRIVACY_JS,
super::js::ADBLOCK_JS
);
let script = webkit2gtk::UserScript::new(
&source,
webkit2gtk::UserContentInjectedFrames::AllFrames,
webkit2gtk::UserScriptInjectionTime::Start,
&[],
&[],
);
manager.add_script(&script);
}

File diff suppressed because it is too large Load diff