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

View file

@ -1,6 +1,30 @@
//cli.rs
use clap::{Parser, ValueEnum};
//! Command line interface definitions.
use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Browsing engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Engine {
/// Native system webview (WebKitGTK / WKWebView / WebView2). Lightest.
#[default]
Webkit,
/// Firefox (Gecko) through a RustyWolf-managed profile. Requires Firefox.
Gecko,
}
impl Engine {
pub fn as_str(self) -> &'static str {
match self {
Engine::Webkit => "webkit",
Engine::Gecko => "gecko",
}
}
}
/// GTK windowing backend (Linux only).
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LinuxBackend {
Auto,
@ -11,38 +35,50 @@ pub enum LinuxBackend {
#[derive(Debug, Parser)]
#[command(
name = "rustywolf",
about = "Privacy-focused WebKit browser",
long_about = "RustyWolf launches an embedded WebKit browser window.",
about = "Lightweight privacy browser (WebKit + Gecko engines)",
long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.",
version
)]
pub struct Cli {
#[arg(
long,
help = "Optional startup URL (defaults to https://duckduckgo.com)"
)]
/// Startup URL (defaults to https://duckduckgo.com)
#[arg(long)]
pub url: Option<String>,
#[arg(long, help = "Set a custom window title")]
/// Window title (WebKit mode)
#[arg(long)]
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,
#[arg(long, help = "Print launch settings without opening a window")]
/// Print launch settings without opening a window
#[arg(long)]
pub dry_run: bool,
#[arg(
long,
value_enum,
default_value_t = LinuxBackend::Auto,
help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
)]
/// Firefox binary used by Gecko mode (persisted)
#[arg(long, value_name = "PATH")]
pub firefox: Option<PathBuf>,
/// WebKit mode: send a Chrome-like user agent for sites that
/// misbehave with the native WebKitGTK user agent
#[arg(long)]
pub ua_chrome: bool,
/// Internal: restore the tab set from the last engine handoff
#[arg(long, hide = true)]
pub resume: bool,
/// Linux only: select a GTK windowing backend (auto, x11, wayland)
#[arg(long, value_enum, default_value_t = LinuxBackend::Auto)]
pub linux_backend: LinuxBackend,
#[arg(
long,
help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
)]
/// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
#[arg(long)]
pub linux_disable_dmabuf: bool,
}

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 webview;
mod config;
mod gecko;
mod handoff;
mod switch;
mod util;
mod webkit;
use anyhow::Result;
use cli::{Cli, Engine};
use std::path::PathBuf;
fn main() {
if let Err(err) = run() {
@ -12,6 +20,63 @@ fn main() {
}
fn run() -> Result<()> {
let cli = cli::Cli::parse_args();
webview::launch_webkit(&cli)
let cli = Cli::parse_args();
let mut cfg = config::load();
if cli.dry_run {
let engine = cli.engine.unwrap_or(cfg.engine);
print_dry_run(&cli, &cfg, engine);
return Ok(());
}
if let Some(engine) = cli.engine
&& cfg.engine != engine
{
cfg.engine = engine;
config::save(&cfg)?;
}
if let Some(firefox) = &cli.firefox {
cfg.firefox_bin = Some(firefox.clone());
config::save(&cfg)?;
}
match cli.engine.unwrap_or(cfg.engine) {
Engine::Webkit => webkit::launch(&cli, &mut cfg),
Engine::Gecko => gecko::launch(&cli, &mut cfg),
}
}
fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) {
let url = cli
.url
.as_deref()
.map(util::normalize_url)
.unwrap_or_else(|| util::DEFAULT_HOME.to_string());
match engine {
Engine::Webkit => {
let effective_dmabuf =
std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string());
println!(
"engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}",
cli.private,
cfg.adblock,
if cli.ua_chrome { "chrome" } else { "native" },
cli.linux_backend,
cli.linux_disable_dmabuf,
);
}
Engine::Gecko => {
let firefox: Option<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