diff --git a/Cargo.lock b/Cargo.lock index 65e1207..cd6028f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,7 +7,7 @@ version = 4 [[package]] name = "RustyWolf" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "base64", @@ -15,10 +15,13 @@ dependencies = [ "dirs", "gtk", "image", + "libc", + "serde", "serde_json", "tao", "ureq", "url", + "webkit2gtk", "wry", ] @@ -2215,6 +2218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index efd8bab..26842cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,30 @@ #Cargo.toml [package] name = "RustyWolf" -version = "0.1.1" +version = "0.2.0" edition = "2024" [dependencies] anyhow = "1.0" base64 = "0.22" clap = { version = "4.5", features = ["derive"] } +dirs = "6.0" +image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] } +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tao = "0.31" ureq = "2.12" url = "2.5" wry = { version = "0.47", features = ["fullscreen"] } -image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] } -dirs = "6.0" + +[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] gtk = "0.18.2" +webkit2gtk = { version = "2.0", features = ["v2_40"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" [package.metadata.bundle] name = "RustyWolf" identifier = "com.rustywolf.browser" icon = ["src/rustywolf.icns"] - diff --git a/README.md b/README.md index 8f9c024..8c53075 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,68 @@ # RustyWolf -RustyWolf is a minimal, privacy-focused browser shell built with Rust + system webviews. +RustyWolf is a minimal, privacy-focused browser shell built in Rust with two engine modes: + +- **WebKit mode (default, light)** — the system webview: `WebKitGTK` on Linux, `WKWebView` on macOS, `WebView2` on Windows. No browser install needed. +- **Gecko mode (opt-in)** — real Firefox (Gecko) engine through a RustyWolf-managed profile. Requires an installed Firefox. + +Switch between them any time with the **leaf toggle** in the toolbar (or the leaf on the RustyWolf home page in Gecko mode). Open tabs carry over in both directions. (complete honesty, this whole thing is vibecoded, however I never paid for any ai service (I got a free student plan for a year) so if you wanna avoid AI slop, just skip this but I never contributed to the AI BOOM with my money, if you wanna contribute, feel free to do so) -- macOS: native `WKWebView` -- Linux: `WebKitGTK` -- Windows: `WebView2` - -No Firefox install. No extension system. Fast startup, clean UI. - image +## Engines + +| | WebKit mode | Gecko mode | +|---|---|---| +| Engine | WebKitGTK / WKWebView / WebView2 | Firefox (Gecko), driven by RustyWolf | +| Weight | lightest — typically ~300 MB with a tab open | Firefox-weight | +| RustyWolf UI | full chrome (tabs, URL bar, ADS, K, D, Properties) | Firefox's own UI + RustyWolf home page | +| Adblock | built-in lightweight blocker (ADS toggle) | Firefox strict tracking protection | +| Extras | keep-in-ram tabs (`K`), popup gate, download panel, properties page | Firefox features, managed profile, tab handoff | + +The leaf button shows the current engine: **lit green = WebKit**, **dim = Gecko**. + ## What It Does -- Really, I mean really lightweight! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3 - - So light in fact in my gentoo(openrc)+i3wm setup it took only 285mb ram with one tab open (dvorakmortonxd.dev) compared to firefox which took 866mb ram - - THATS 3.04 TIMES AS MUCH RAM, so yeah it is light - - tabs have a toggle for Keeping-in-ram (`K`), it basically just saves the url if its off and reloads it on reclick, if you toggle it on it keeps the whole page in ram. +- Really, I mean really lightweight in WebKit mode! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3 + - in a gentoo(openrc)+i3wm setup it took only ~285 MB RAM with one tab open (dvorakmortonxd.dev) compared to firefox which took ~866 MB + - tabs have a toggle for Keeping-in-ram (`K`): with it off the tab is unloaded, only the URL is kept, and it reloads on click + - background tabs are suspended automatically, and the download ticker only runs while something is downloading - Privacy-first defaults: DuckDuckGo home + search fallback from the URL bar - Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`) -- Built-in custom adblock with top-bar toggle (`ADS`) -- Popup permission gate: asks before a site opens a new tab +- Built-in custom adblock with top-bar toggle (`ADS`), persisted across restarts +- Popup handling that behaves like a real browser: clicked links open tabs, clicked `window.open` popups (OAuth/"Sign in with Google", SoundCloud, ...) open real popup windows with `window.opener` intact, and scripted popups go through the permission gate +- Native user agent by default, so sites (and their login flows) see an honest browser; `--ua-chrome` is available for stubborn sites - Download support with live progress panel (`D`) and session download history - Properties page with session browsing history + download history - Default window size `1000x600` (still fully resizable) -- Optional `--private` mode when backend support is available +- Optional `--private` mode in both engines +- Engine switch with tab handoff: WebKit tabs become Firefox tabs and vice versa ## Run ```bash cargo run -- -``` -```bash +# pick a URL / window title cargo run -- --url https://example.com cargo run -- --url example.com cargo run -- --title "RustyWolf" + +# engine selection (persisted in ~/.config/RustyWolf/config.json) +cargo run -- --engine webkit +cargo run -- --engine gecko +cargo run -- --engine gecko --firefox /path/to/firefox + +# compatible user agent for picky sites (WebKit mode) +cargo run -- --ua-chrome + +# misc cargo run -- --private cargo run -- --dry-run @@ -48,18 +70,29 @@ cargo run -- --dry-run cargo run -- --linux-backend x11 cargo run -- --linux-backend wayland cargo run -- --linux-disable-dmabuf -cargo run -- --linux-backend x11 --linux-disable-dmabuf ``` +The leaf button in the toolbar switches engines immediately; the choice is remembered for the next launch. + +## Gecko Mode (real Firefox engine) + +There is no embeddable Gecko in 2026, so Gecko mode supervises a real Firefox with a dedicated profile under `~/.config/RustyWolf/gecko-profile`: + +- hardening prefs are rewritten on every launch (strict tracking protection, DNT/GPC, no telemetry, no sponsored content, no Pocket) +- downloads go straight to your Downloads folder +- new tabs stay blank; the RustyWolf home page is the start page and carries the leaf button back to WebKit +- `browser.sessionstore.interval` is shortened so RustyWolf can hand your tabs back to WebKit +- Firefox is launched with `--no-remote --new-instance`, so your normal Firefox session is never touched (`--firefox` / `RUSTYWOLF_FIREFOX` to point at a specific binary) + ## Privacy Notes -RustyWolf applies baseline hardening in page context: +WebKit mode applies baseline hardening in page context: - `navigator.doNotTrack = "1"` - disables legacy `openDatabase` - disables `RTCPeerConnection` -Adblocking is built-in and lightweight. It is not full uBlock Origin parity. +Adblocking is built-in and lightweight. It is not full uBlock Origin parity. Gecko mode uses Firefox's own strict tracking protection instead. # Linux Setup (prerequisites) @@ -96,6 +129,8 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" ``` +For Gecko mode just make sure Firefox is installed (`emerge www-client/firefox` or the binary release). + For NVIDIA, make the DRM modeset setting part of the boot command line, not only `/etc/modprobe.d`, then reboot: @@ -179,6 +214,8 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from /etc/xdg/gtk-3.0/settings.ini ``` +If RustyWolf segfaults instantly with `Couldn't register with accessibility bus` when launched from a bare TTY/SSH shell, there is no session D-Bus for the GTK accessibility bridge (`libatk-bridge`). Launch it from your graphical session, or make `DBUS_SESSION_BUS_ADDRESS` available (on many systems `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus`). + # MacOS ## installing Homebrew (skip if already installed) @@ -187,17 +224,25 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` +Gecko mode finds Firefox in `/Applications/Firefox.app` automatically; WebKit mode uses the system `WKWebView`. + # Windows Setup -Install the Microsoft Edge WebView2 Runtime (Evergreen), then build normally with Rust MSVC toolchain. +Install the Microsoft Edge WebView2 Runtime (Evergreen) for WebKit mode, then build normally with the Rust MSVC toolchain. - WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/ - Rust toolchain target: `x86_64-pc-windows-msvc` +Gecko mode finds Firefox under `Program Files` / `%LOCALAPPDATA%\Mozilla Firefox`, or use `--firefox`. + ## Dev Quick Checks ```bash cargo fmt cargo check cargo test +cargo clippy --all-targets + +# exercises the Gecko handoff against a real headless Firefox +cargo test -- --ignored --nocapture ``` diff --git a/src/cli.rs b/src/cli.rs index 87c4ff7..c6879ab 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,30 @@ -//cli.rs -use clap::{Parser, ValueEnum}; +//! Command line interface definitions. +use clap::{Parser, ValueEnum}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Browsing engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum Engine { + /// Native system webview (WebKitGTK / WKWebView / WebView2). Lightest. + #[default] + Webkit, + /// Firefox (Gecko) through a RustyWolf-managed profile. Requires Firefox. + Gecko, +} + +impl Engine { + pub fn as_str(self) -> &'static str { + match self { + Engine::Webkit => "webkit", + Engine::Gecko => "gecko", + } + } +} + +/// GTK windowing backend (Linux only). #[derive(Debug, Clone, Copy, ValueEnum)] pub enum LinuxBackend { Auto, @@ -11,38 +35,50 @@ pub enum LinuxBackend { #[derive(Debug, Parser)] #[command( name = "rustywolf", - about = "Privacy-focused WebKit browser", - long_about = "RustyWolf launches an embedded WebKit browser window.", + about = "Lightweight privacy browser (WebKit + Gecko engines)", + long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.", version )] pub struct Cli { - #[arg( - long, - help = "Optional startup URL (defaults to https://duckduckgo.com)" - )] + /// Startup URL (defaults to https://duckduckgo.com) + #[arg(long)] pub url: Option, - #[arg(long, help = "Set a custom window title")] + /// Window title (WebKit mode) + #[arg(long)] pub title: Option, - #[arg(long, help = "Try private/incognito mode when backend supports it")] + /// Select the browsing engine (persisted for the next launch too) + #[arg(long, value_enum)] + pub engine: Option, + + /// Start in private/incognito mode + #[arg(long)] pub private: bool, - #[arg(long, help = "Print launch settings without opening a window")] + /// Print launch settings without opening a window + #[arg(long)] pub dry_run: bool, - #[arg( - long, - value_enum, - default_value_t = LinuxBackend::Auto, - help = "Linux only: select a GTK windowing backend (auto, x11, wayland)" - )] + /// Firefox binary used by Gecko mode (persisted) + #[arg(long, value_name = "PATH")] + pub firefox: Option, + + /// WebKit mode: send a Chrome-like user agent for sites that + /// misbehave with the native WebKitGTK user agent + #[arg(long)] + pub ua_chrome: bool, + + /// Internal: restore the tab set from the last engine handoff + #[arg(long, hide = true)] + pub resume: bool, + + /// Linux only: select a GTK windowing backend (auto, x11, wayland) + #[arg(long, value_enum, default_value_t = LinuxBackend::Auto)] pub linux_backend: LinuxBackend, - #[arg( - long, - help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)" - )] + /// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups) + #[arg(long)] pub linux_disable_dmabuf: bool, } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..180655e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,88 @@ +//! Persisted user configuration (engine choice, adblock, Firefox path). + +use crate::cli::Engine; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub engine: Engine, + pub adblock: bool, + pub firefox_bin: Option, +} + +/// `~/.config/RustyWolf` (or the platform equivalent). +pub fn base_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("RustyWolf") +} + +pub fn config_path() -> PathBuf { + base_dir().join("config.json") +} + +pub fn handoff_path() -> PathBuf { + base_dir().join("handoff.json") +} + +pub fn gecko_profile_dir() -> PathBuf { + base_dir().join("gecko-profile") +} + +pub fn load() -> Config { + match fs::read_to_string(config_path()) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|err| { + eprintln!("rustywolf: ignoring malformed config: {err}"); + Config::default() + }), + Err(_) => Config::default(), + } +} + +pub fn save(cfg: &Config) -> Result<()> { + let dir = base_dir(); + fs::create_dir_all(&dir).with_context(|| format!("could not create {}", dir.display()))?; + let raw = serde_json::to_string_pretty(cfg)?; + fs::write(config_path(), raw).with_context(|| "could not write config.json")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_webkit_without_adblock() { + let cfg = Config::default(); + assert_eq!(cfg.engine, Engine::Webkit); + assert!(!cfg.adblock); + assert!(cfg.firefox_bin.is_none()); + } + + #[test] + fn roundtrips_through_json() { + let cfg = Config { + engine: Engine::Gecko, + adblock: true, + firefox_bin: Some(PathBuf::from("/usr/bin/firefox-bin")), + }; + let raw = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&raw).unwrap(); + assert_eq!(back.engine, Engine::Gecko); + assert!(back.adblock); + assert_eq!( + back.firefox_bin.as_deref(), + Some(std::path::Path::new("/usr/bin/firefox-bin")) + ); + } + + #[test] + fn tolerates_missing_fields() { + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert_eq!(cfg.engine, Engine::Webkit); + } +} diff --git a/src/gecko/home.html b/src/gecko/home.html new file mode 100644 index 0000000..343d543 --- /dev/null +++ b/src/gecko/home.html @@ -0,0 +1,78 @@ + + + + + +RustyWolf + + + +
+

RustyWolf

+

Gecko mode — powered by Firefox, managed by RustyWolf.

+ +
+ +
+ Engine: Gecko (Firefox)
+ Click the leaf to switch back to the light WebKit engine. + +
+
+ +
+ + +
+ +

+ This dedicated profile uses strict tracking protection, no telemetry, and downloads + straight to your Downloads folder. Your open tabs carry over when you switch engines.
+ WebKit mode extras: the built-in adblock toggle (ADS), keep-in-ram tabs + (K), the popup gate and the properties page. In Gecko mode Firefox handles + browsing natively. +

+
+ + + diff --git a/src/gecko/mod.rs b/src/gecko/mod.rs new file mode 100644 index 0000000..6f0cdc8 --- /dev/null +++ b/src/gecko/mod.rs @@ -0,0 +1,330 @@ +//! Gecko mode: supervise a RustyWolf-managed Firefox (real Gecko engine). +//! +//! There is no embeddable Gecko, so RustyWolf runs Firefox with a dedicated +//! profile and keeps the experience coherent: +//! - the profile carries RustyWolf hardening prefs and the control home page, +//! - a loopback control server lets the home page switch back to WebKit, +//! - the Firefox session store supplies the tab handoff to the WebKit engine. +//! +//! This supervisor is tiny (a listener, a child process handle, a poll loop), +//! keeping RustyWolf's own RAM/CPU footprint minimal even in Gecko mode. + +mod prefs; +mod server; + +use crate::cli::{Cli, Engine}; +use crate::config::{self, Config}; +use crate::handoff; +use crate::switch; +use crate::util; +use anyhow::{Context, Result, bail}; +use server::{ControlServer, Request}; +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +const FAVICON: &[u8] = include_bytes!("../rustywolf.ico"); +const POLL_INTERVAL: Duration = Duration::from_millis(120); +const SHUTDOWN_GRACE: Duration = Duration::from_secs(3); + +pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> { + let firefox = resolve_firefox(cli, cfg)?; + let profile = config::gecko_profile_dir(); + fs::create_dir_all(&profile) + .with_context(|| format!("could not create {}", profile.display()))?; + + let server = ControlServer::bind()?; + let homepage = format!("http://127.0.0.1:{}/?token={}", server.port, server.token); + prefs::write_user_js(&profile, &homepage, dirs::download_dir().as_deref())?; + + let urls = startup_urls(cli); + let mut child = spawn_firefox(&firefox, &profile, &urls, cli.private)?; + eprintln!( + "rustywolf: gecko engine (Firefox {}), profile {}", + firefox.display(), + profile.display() + ); + + let home_html = include_str!("home.html") + .replace("{{TOKEN}}", &server.token) + .into_bytes(); + + loop { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), // user closed Firefox + Ok(None) => {} + Err(err) => return Err(err).context("lost track of the Firefox process"), + } + + let Some(mut conn) = server.poll() else { + std::thread::sleep(POLL_INTERVAL); + continue; + }; + + match conn.request { + Request::Home => { + conn.respond("200 OK", "text/html; charset=utf-8", &home_html); + } + Request::Favicon => { + conn.respond("200 OK", "image/x-icon", FAVICON); + } + Request::Switch => { + let captured = handoff::capture_firefox_session(&profile, cli.private); + match switch::switch_to(Engine::Webkit, cfg, captured.as_ref(), cli.private) { + Ok(()) => { + conn.respond_204(); + terminate(&mut child); + return Ok(()); + } + Err(err) => { + let body = format!("RustyWolf could not switch engines: {err:#}"); + conn.respond( + "500 Internal Server Error", + "text/plain; charset=utf-8", + body.as_bytes(), + ); + } + } + } + Request::NotFound => { + conn.respond("404 Not Found", "text/plain; charset=utf-8", b"not found"); + } + } + } +} + +/// Tabs to open: explicit `--url` wins, then a pending handoff, then the home page. +fn startup_urls(cli: &Cli) -> Vec { + if let Some(url) = &cli.url { + return vec![util::normalize_url(url)]; + } + if cli.resume + && let Some(handoff) = handoff::take_fresh() + { + let mut urls: Vec = handoff.tabs.into_iter().map(|tab| tab.url).collect(); + if !urls.is_empty() { + // Firefox selects the first URL argument; bring the previously + // active tab to the front, then the rest follows. + let active = handoff.active.min(urls.len() - 1); + urls.swap(0, active); + return urls; + } + } + vec![util::DEFAULT_HOME.to_string()] +} + +fn spawn_firefox( + firefox: &PathBuf, + profile: &PathBuf, + urls: &[String], + private: bool, +) -> Result { + let mut cmd = Command::new(firefox); + cmd.arg("--no-remote") + .arg("--new-instance") + .arg("--profile") + .arg(profile); + if private { + // Private sessions are ephemeral; only the active URL comes along. + cmd.arg("--private-window").arg(&urls[0]); + } else { + for url in urls { + cmd.arg(url); + } + } + cmd.env("MOZ_CRASHREPORTER_DISABLE", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + cmd.spawn() + .with_context(|| format!("could not launch Firefox ({})", firefox.display())) +} + +/// Ask Firefox to quit, with a hard kill as a last resort. +fn terminate(child: &mut Child) { + #[cfg(unix)] + unsafe { + libc::kill(child.id() as i32, libc::SIGTERM); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + + let deadline = Instant::now() + SHUTDOWN_GRACE; + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(_)) => return, + _ => std::thread::sleep(Duration::from_millis(50)), + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +// ---------- Firefox discovery ------------------------------------------- + +fn resolve_firefox(cli: &Cli, cfg: &mut Config) -> Result { + if let Some(path) = &cli.firefox { + if path.is_file() { + cfg.firefox_bin = Some(path.clone()); + return Ok(path.clone()); + } + bail!("--firefox path does not exist: {}", path.display()); + } + + if let Some(found) = locate_firefox(cfg) { + return Ok(found); + } + + bail!( + "Firefox was not found. Install Firefox or point RustyWolf at it with --firefox /path/to/firefox" + ) +} + +/// Best-effort Firefox discovery (also used by `--dry-run`). +pub fn locate_firefox(cfg: &Config) -> Option { + if let Some(path) = &cfg.firefox_bin { + if path.is_file() { + return Some(path.clone()); + } + eprintln!( + "rustywolf: configured Firefox path no longer exists: {}", + path.display() + ); + } + + if let Some(path) = std::env::var_os("RUSTYWOLF_FIREFOX") { + let path = PathBuf::from(path); + if path.is_file() { + return Some(path); + } + } + + if let Some(found) = find_in_path(&["firefox-bin", "firefox", "firefox-esr"]) { + return Some(found); + } + + platform_candidates() + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn find_in_path(names: &[&str]) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn platform_candidates() -> Vec { + vec![ + PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox"), + PathBuf::from("/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"), + ] +} + +#[cfg(target_os = "windows")] +fn platform_candidates() -> Vec { + let mut candidates = Vec::new(); + for base in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] { + if let Some(root) = std::env::var_os(base) { + candidates.push( + PathBuf::from(root) + .join("Mozilla Firefox") + .join("firefox.exe"), + ); + } + } + candidates +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn platform_candidates() -> Vec { + vec![ + PathBuf::from("/usr/bin/firefox-bin"), + PathBuf::from("/usr/bin/firefox"), + PathBuf::from("/usr/local/bin/firefox-bin"), + PathBuf::from("/usr/local/bin/firefox"), + PathBuf::from("/snap/bin/firefox"), + PathBuf::from("/var/lib/flatpak/exports/bin/org.mozilla.firefox"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn cli_from(args: &[&str]) -> Cli { + let mut full = vec!["rustywolf"]; + full.extend_from_slice(args); + Cli::try_parse_from(full).unwrap() + } + + #[test] + fn explicit_url_wins_over_handoff() { + let cli = cli_from(&["--url", "example.com", "--resume"]); + assert_eq!(startup_urls(&cli), vec!["https://example.com"]); + } + + #[test] + fn falls_back_to_home_without_handoff() { + let cli = cli_from(&[]); + assert_eq!(startup_urls(&cli), vec![util::DEFAULT_HOME]); + } + + /// Launches a real headless Firefox, then captures its tabs for handoff. + /// Run explicitly: `cargo test -- --ignored --nocapture` + #[test] + #[ignore = "launches a real headless Firefox"] + fn gecko_session_handoff_roundtrip() { + let Some(firefox) = locate_firefox(&Config::default()) else { + eprintln!("skipping: no Firefox found"); + return; + }; + let profile = + std::env::temp_dir().join(format!("rustywolf-gecko-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&profile); + fs::create_dir_all(&profile).unwrap(); + prefs::write_user_js(&profile, "about:blank", None).unwrap(); + + let mut child = Command::new(&firefox) + .arg("--headless") + .arg("--no-remote") + .arg("--new-instance") + .arg("--profile") + .arg(&profile) + .arg("https://example.com") + .arg("https://example.org") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Firefox should start"); + + std::thread::sleep(Duration::from_secs(10)); + terminate(&mut child); + + let captured = handoff::capture_firefox_session(&profile, false); + let _ = fs::remove_dir_all(&profile); + + let captured = captured.expect("session handoff should be captured"); + assert!( + !captured.tabs.is_empty(), + "expected captured tabs, got none" + ); + for tab in &captured.tabs { + eprintln!("captured tab: {}", tab.url); + } + } +} diff --git a/src/gecko/prefs.rs b/src/gecko/prefs.rs new file mode 100644 index 0000000..96d0c7b --- /dev/null +++ b/src/gecko/prefs.rs @@ -0,0 +1,161 @@ +//! Managed Firefox profile: the `user.js` preference set. + +use anyhow::{Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::Path; + +/// Gecko launches rewrite `user.js` so the profile stays RustyWolf-managed. +pub fn write_user_js(profile: &Path, homepage: &str, download_dir: Option<&Path>) -> Result<()> { + let mut prefs: Vec<(&str, Value)> = vec![ + // Tab handoff relies on a reasonably fresh session store, and handoff + // freshness is a lightness trade-off (2s writes, not the 15s default). + ("browser.sessionstore.interval", Value::from(2000)), + ("browser.sessionstore.resume_from_crash", Value::from(true)), + // Start on the RustyWolf control page; new tabs stay blank and light. + ("browser.startup.page", Value::from(1)), + ("browser.startup.homepage", Value::from(homepage)), + ( + "browser.startup.homepage_override.mstone", + Value::from("ignore"), + ), + ("startup.homepage_welcome_url", Value::from("")), + ("startup.homepage_welcome_url.additional", Value::from("")), + ("startup.homepage_override_url", Value::from("")), + ("browser.aboutwelcome.enabled", Value::from(false)), + ("browser.newtabpage.enabled", Value::from(false)), + ( + "browser.newtabpage.activity-stream.enabled", + Value::from(false), + ), + ( + "browser.newtabpage.activity-stream.showSponsored", + Value::from(false), + ), + ( + "browser.newtabpage.activity-stream.showSponsoredTopSites", + Value::from(false), + ), + ( + "browser.newtabpage.activity-stream.feeds.telemetry", + Value::from(false), + ), + ("browser.shell.checkDefaultBrowser", Value::from(false)), + ("browser.discovery.enabled", Value::from(false)), + ("browser.tabs.warnOnClose", Value::from(false)), + ("browser.tabs.warnOnCloseOtherTabs", Value::from(false)), + ("browser.warnOnQuit", Value::from(false)), + // Lightness: let Firefox unload stale background tabs under pressure. + ("browser.tabs.unloadOnLowMemory", Value::from(true)), + ( + "browser.tabs.min_inactive_duration_before_unload", + Value::from(600_000), + ), + // Downloads land in the system download folder without extra dialogs. + ("browser.download.useDownloadDir", Value::from(true)), + ("browser.download.alwaysOpenPanel", Value::from(false)), + // Privacy hardening: strict tracking protection, no telemetry, no sponsored content. + ("privacy.donottrackheader.enabled", Value::from(true)), + ("privacy.globalprivacycontrol.enabled", Value::from(true)), + ("browser.contentblocking.category", Value::from("strict")), + ("privacy.trackingprotection.enabled", Value::from(true)), + ( + "privacy.trackingprotection.pbmode.enabled", + Value::from(true), + ), + ( + "privacy.trackingprotection.socialtracking.enabled", + Value::from(true), + ), + ( + "privacy.trackingprotection.cryptomining.enabled", + Value::from(true), + ), + ( + "privacy.trackingprotection.fingerprinting.enabled", + Value::from(true), + ), + ( + "privacy.annotate_channels.strict_list.enabled", + Value::from(true), + ), + ("toolkit.telemetry.enabled", Value::from(false)), + ("toolkit.telemetry.unified", Value::from(false)), + ( + "toolkit.telemetry.shutdownPingSender.enabled", + Value::from(false), + ), + ( + "datareporting.healthreport.uploadEnabled", + Value::from(false), + ), + ( + "datareporting.policy.dataSubmissionEnabled", + Value::from(false), + ), + ("app.shield.optoutstudies.enabled", Value::from(false)), + ( + "browser.crashReports.unsubmittedCheck.autoSubmit2", + Value::from(false), + ), + // A quiet address bar: no sponsored suggestions, no search-suggestion calls. + ("browser.urlbar.quicksuggest.enabled", Value::from(false)), + ( + "browser.urlbar.suggest.quicksuggest.sponsored", + Value::from(false), + ), + ( + "browser.urlbar.suggest.quicksuggest.nonsponsored", + Value::from(false), + ), + ("browser.urlbar.suggest.topsites", Value::from(false)), + ("browser.search.suggest.enabled", Value::from(false)), + ("browser.urlbar.suggest.searches", Value::from(false)), + ]; + + if let Some(dir) = download_dir { + prefs.push(( + "browser.download.dir", + Value::from(dir.to_string_lossy().into_owned()), + )); + } + + let mut out = String::from("// Managed by RustyWolf - rewritten on every Gecko launch.\n"); + for (key, value) in prefs { + out.push_str("user_pref("); + out.push_str(&serde_json::to_string(key).unwrap()); + out.push_str(", "); + out.push_str(&value.to_string()); + out.push_str(");\n"); + } + + fs::write(profile.join("user.js"), out) + .with_context(|| format!("could not write {}/user.js", profile.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_managed_prefs() { + let dir = std::env::temp_dir().join(format!("rustywolf-prefs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + write_user_js( + &dir, + "http://127.0.0.1:4242/?token=deadbeef", + Some(Path::new("/home/user/Downloads")), + ) + .unwrap(); + let raw = std::fs::read_to_string(dir.join("user.js")).unwrap(); + assert!(raw.contains("Managed by RustyWolf")); + assert!( + raw.contains("\"browser.startup.homepage\", \"http://127.0.0.1:4242/?token=deadbeef\"") + ); + assert!(raw.contains("\"privacy.trackingprotection.enabled\", true")); + assert!(raw.contains("\"browser.download.dir\", \"/home/user/Downloads\"")); + assert!(raw.contains("\"browser.sessionstore.interval\", 2000")); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/src/gecko/server.rs b/src/gecko/server.rs new file mode 100644 index 0000000..b5a8e79 --- /dev/null +++ b/src/gecko/server.rs @@ -0,0 +1,170 @@ +//! Tiny loopback HTTP control server for Gecko mode. +//! +//! The RustyWolf home page (served from here) can ask the supervisor to +//! switch back to the WebKit engine. Everything is bound to 127.0.0.1 and +//! gated behind a per-launch random token. + +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Request { + Home, + Switch, + Favicon, + NotFound, +} + +pub struct ControlServer { + listener: TcpListener, + pub port: u16, + pub token: String, +} + +pub struct Connection { + stream: TcpStream, + pub request: Request, +} + +impl ControlServer { + pub fn bind() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)).context("could not bind 127.0.0.1")?; + listener.set_nonblocking(true)?; + let port = listener.local_addr()?.port(); + Ok(Self { + listener, + port, + token: random_token(), + }) + } + + /// Non-blocking: `None` means "no request right now". + pub fn poll(&self) -> Option { + let (stream, _) = self.listener.accept().ok()?; + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let request = read_request(&stream, &self.token); + Some(Connection { stream, request }) + } +} + +impl Connection { + pub fn respond(&mut self, status: &str, content_type: &str, body: &[u8]) { + let header = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = self.stream.write_all(header.as_bytes()); + let _ = self.stream.write_all(body); + let _ = self.stream.flush(); + let _ = self.stream.shutdown(Shutdown::Write); + } + + pub fn respond_204(&mut self) { + let _ = self.stream.write_all( + b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + ); + let _ = self.stream.flush(); + let _ = self.stream.shutdown(Shutdown::Write); + } +} + +fn read_request(stream: &TcpStream, expected_token: &str) -> Request { + let mut buf = [0u8; 4096]; + let mut used = 0usize; + let mut reader = match stream.try_clone() { + Ok(clone) => clone, + Err(_) => return Request::NotFound, + }; + + loop { + if used >= buf.len() { + break; + } + match reader.read(&mut buf[used..]) { + Ok(0) => break, + Ok(n) => { + used += n; + if buf[..used].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + Err(_) => break, + } + } + + let head = String::from_utf8_lossy(&buf[..used]); + let Some(first_line) = head.lines().next() else { + return Request::NotFound; + }; + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let target = parts.next().unwrap_or_default(); + if method != "GET" { + return Request::NotFound; + } + + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path, query), + None => (target, ""), + }; + + match path { + "/" => Request::Home, + "/favicon.ico" => Request::Favicon, + "/switch" => { + let token_ok = query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .any(|(key, value)| key == "token" && value == expected_token); + if token_ok { + Request::Switch + } else { + Request::NotFound + } + } + _ => Request::NotFound, + } +} + +fn random_token() -> String { + let mut bytes = [0u8; 16]; + let from_urandom = File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .is_ok(); + + if !from_urandom { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::process::id().hash(&mut hasher); + std::thread::current().id().hash(&mut hasher); + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + .hash(&mut hasher); + let a = hasher.finish().to_le_bytes(); + bytes[..8].copy_from_slice(&a); + std::process::id().rotate_left(7).hash(&mut hasher); + let b = hasher.finish().to_le_bytes(); + bytes[8..].copy_from_slice(&b); + } + + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokens_are_hex_and_unique() { + let a = random_token(); + let b = random_token(); + assert_eq!(a.len(), 32); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(a, b); + } +} diff --git a/src/handoff.rs b/src/handoff.rs new file mode 100644 index 0000000..357baa4 --- /dev/null +++ b/src/handoff.rs @@ -0,0 +1,350 @@ +//! Engine handoff: pass the open tab set between WebKit and Gecko mode, +//! plus Firefox session-store reading (mozlz4 = magic + u32 size + LZ4 block). +//! +//! No external decompression dependency: the session file format is decoded +//! with a small in-tree LZ4 block decompressor (see `lz4_block_decompress`). + +use crate::config; +use crate::util::now_unix; +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Only hand over this many tabs at most (keeps the switch snappy and light). +pub const MAX_HANDOFF_TABS: usize = 32; +/// A handoff older than this is ignored. +pub const MAX_HANDOFF_AGE_SECS: u64 = 600; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandoffTab { + pub url: String, + #[serde(default)] + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Handoff { + pub created_at: u64, + #[serde(default)] + pub private: bool, + #[serde(default)] + pub active: usize, + #[serde(default)] + pub tabs: Vec, +} + +impl Handoff { + pub fn new(private: bool, mut active: usize, mut tabs: Vec) -> Self { + tabs.truncate(MAX_HANDOFF_TABS); + if active >= tabs.len() { + active = tabs.len().saturating_sub(1); + } + Self { + created_at: now_unix(), + private, + active, + tabs, + } + } +} + +pub fn save(handoff: &Handoff) -> Result<()> { + save_to(&config::handoff_path(), handoff) +} + +pub fn save_to(path: &Path, handoff: &Handoff) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } + let raw = serde_json::to_string(handoff)?; + fs::write(path, raw).with_context(|| format!("could not write {}", path.display()))?; + Ok(()) +} + +/// Load and consume a fresh handoff (the file is removed either way). +pub fn take_fresh() -> Option { + take_fresh_from(&config::handoff_path(), MAX_HANDOFF_AGE_SECS) +} + +pub(crate) fn take_fresh_from(path: &Path, max_age_secs: u64) -> Option { + let raw = fs::read_to_string(path).ok()?; + let _ = fs::remove_file(path); + let handoff: Handoff = serde_json::from_str(&raw).ok()?; + let now = now_unix(); + if handoff.created_at > now || now.saturating_sub(handoff.created_at) > max_age_secs { + return None; + } + if handoff.tabs.is_empty() { + return None; + } + Some(handoff) +} + +/// Snapshot the tabs of a (possibly still running) Firefox profile. +pub fn capture_firefox_session(profile_dir: &Path, private: bool) -> Option { + let candidates = [ + profile_dir + .join("sessionstore-backups") + .join("recovery.jsonlz4"), + profile_dir.join("sessionstore.jsonlz4"), + ]; + for candidate in candidates { + let Ok(raw) = fs::read(&candidate) else { + continue; + }; + let Ok(json) = decode_mozlz4(&raw) else { + continue; + }; + let Ok(doc) = serde_json::from_slice::(&json) else { + continue; + }; + let (tabs, active) = extract_tabs(&doc); + if tabs.is_empty() { + continue; + } + return Some(Handoff::new(private, active, tabs)); + } + None +} + +/// Pull (url, title) pairs out of a decoded session-store document. +pub(crate) fn extract_tabs(doc: &serde_json::Value) -> (Vec, usize) { + let mut tabs = Vec::new(); + let mut active = 0usize; + let selected_window = doc["selectedWindow"].as_u64().unwrap_or(1); + + let Some(windows) = doc["windows"].as_array() else { + return (tabs, active); + }; + + for (wi, window) in windows.iter().enumerate() { + let window_selected = window["selected"].as_u64().unwrap_or(1); + let Some(tab_list) = window["tabs"].as_array() else { + continue; + }; + for (ti, tab) in tab_list.iter().enumerate() { + let entries = tab["entries"].as_array(); + let index = tab["index"].as_u64().unwrap_or(0) as usize; // 1-based + let current = entries.and_then(|list| { + let idx = index.saturating_sub(1); + list.get(idx).or_else(|| list.last()) + }); + let Some(current) = current else { + continue; + }; + let url = current["url"].as_str().unwrap_or_default().trim(); + if !usable_handoff_url(url) { + continue; + } + if tabs.len() >= MAX_HANDOFF_TABS { + break; + } + let title = tab["title"] + .as_str() + .or_else(|| current["title"].as_str()) + .unwrap_or_default() + .to_string(); + if wi as u64 + 1 == selected_window && ti as u64 + 1 == window_selected { + active = tabs.len(); + } + tabs.push(HandoffTab { + url: url.to_string(), + title, + }); + } + } + + (tabs, active) +} + +fn usable_handoff_url(url: &str) -> bool { + if url.is_empty() || url.eq_ignore_ascii_case("about:blank") { + return false; + } + let lower = url.to_ascii_lowercase(); + if lower.starts_with("about:") + || lower.starts_with("chrome:") + || lower.starts_with("javascript:") + || lower.starts_with("data:") + { + return false; + } + // The RustyWolf control page of a previous Gecko session. + if lower.starts_with("http://127.0.0.1:") && url.contains("/?token=") { + return false; + } + lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://") +} + +// ---------- mozlz4 ------------------------------------------------------ + +const MOZLZ4_MAGIC: &[u8; 8] = b"mozLz40\0"; + +pub(crate) fn decode_mozlz4(bytes: &[u8]) -> Result> { + if bytes.len() < 12 || &bytes[..8] != MOZLZ4_MAGIC { + bail!("not a mozlz4 file"); + } + let declared = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize; + let decoded = lz4_block_decompress(&bytes[12..])?; + if decoded.len() != declared { + bail!( + "mozlz4 size mismatch: header says {declared}, decoded {}", + decoded.len() + ); + } + Ok(decoded) +} + +/// Plain LZ4 block decompression (the codec behind "mozLz4"). +pub(crate) fn lz4_block_decompress(src: &[u8]) -> Result> { + let mut out: Vec = Vec::new(); + let mut pos = 0usize; + + while pos < src.len() { + let token = src[pos]; + pos += 1; + + let mut literal_len = (token >> 4) as usize; + if literal_len == 15 { + loop { + let extra = *src.get(pos).context("lz4: truncated literal length")?; + pos += 1; + literal_len += extra as usize; + if extra != 255 { + break; + } + } + } + if pos + literal_len > src.len() { + bail!("lz4: truncated literals"); + } + out.extend_from_slice(&src[pos..pos + literal_len]); + pos += literal_len; + + if pos >= src.len() { + break; + } + + if pos + 2 > src.len() { + bail!("lz4: truncated match offset"); + } + let offset = u16::from_le_bytes([src[pos], src[pos + 1]]) as usize; + pos += 2; + if offset == 0 || offset > out.len() { + bail!("lz4: match offset out of range"); + } + + let mut match_len = (token & 0x0f) as usize; + if match_len == 15 { + loop { + let extra = *src.get(pos).context("lz4: truncated match length")?; + pos += 1; + match_len += extra as usize; + if extra != 255 { + break; + } + } + } + match_len += 4; + + let start = out.len() - offset; + for i in 0..match_len { + let byte = out[start + i]; + out.push(byte); + } + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lz4_decodes_literals_only() { + // token 0x30 => 3 literal bytes, no match section + let src = [0x30, b'a', b'b', b'c']; + assert_eq!(lz4_block_decompress(&src).unwrap(), b"abc"); + } + + #[test] + fn lz4_decodes_backreferences() { + // 4 literals "abcd", then offset 4, length 4 => "abcdabcd"; then 1 literal "x" + let src = [0x40, b'a', b'b', b'c', b'd', 0x04, 0x00, 0x10, b'x']; + assert_eq!(lz4_block_decompress(&src).unwrap(), b"abcdabcdx"); + } + + #[test] + fn decodes_real_firefox_session_fixture() { + let raw = include_bytes!("testdata/recovery.jsonlz4"); + let json = decode_mozlz4(raw).expect("fixture must decode"); + let doc: serde_json::Value = serde_json::from_slice(&json).expect("fixture must be JSON"); + let (tabs, active) = extract_tabs(&doc); + assert_eq!(tabs.len(), 2); + assert_eq!(tabs[0].url, "https://example.com/"); + assert_eq!(tabs[1].url, "https://example.org/"); + // Firefox's `selected` is 1-based; the first tab was focused. + assert_eq!(active, 0); + } + + #[test] + fn rejects_unknown_container() { + assert!(decode_mozlz4(b"not a mozlz4 file at all").is_err()); + } + + #[test] + fn handoff_roundtrip_and_freshness() { + let dir = std::env::temp_dir().join(format!("rustywolf-test-{}", std::process::id())); + let path = dir.join("handoff.json"); + let handoff = Handoff::new( + false, + 1, + vec![ + HandoffTab { + url: "https://example.com/".into(), + title: "Example".into(), + }, + HandoffTab { + url: "https://example.org/".into(), + title: "".into(), + }, + ], + ); + save_to(&path, &handoff).unwrap(); + + let loaded = take_fresh_from(&path, 60).expect("fresh handoff"); + assert_eq!(loaded.tabs.len(), 2); + assert_eq!(loaded.active, 1); + assert!(!path.exists(), "handoff is consumed on read"); + + // Stale handoffs are dropped. + let stale = Handoff { + created_at: now_unix().saturating_sub(9999), + ..handoff + }; + save_to(&path, &stale).unwrap(); + assert!(take_fresh_from(&path, 60).is_none()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn skips_unusable_session_urls() { + let doc = serde_json::json!({ + "selectedWindow": 1, + "windows": [{ + "selected": 2, + "tabs": [ + {"index": 1, "entries": [{"url": "about:newtab"}]}, + {"index": 1, "entries": [{"url": "https://ok.example/"}]}, + ], + }], + }); + let (tabs, active) = extract_tabs(&doc); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs[0].url, "https://ok.example/"); + assert_eq!(active, 0); + } +} diff --git a/src/main.rs b/src/main.rs index 4a2b658..7396308 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,16 @@ -//main.rs +//! RustyWolf entry point: load config, pick the engine, go. + mod cli; -mod webview; +mod config; +mod gecko; +mod handoff; +mod switch; +mod util; +mod webkit; use anyhow::Result; +use cli::{Cli, Engine}; +use std::path::PathBuf; fn main() { if let Err(err) = run() { @@ -12,6 +20,63 @@ fn main() { } fn run() -> Result<()> { - let cli = cli::Cli::parse_args(); - webview::launch_webkit(&cli) + let cli = Cli::parse_args(); + let mut cfg = config::load(); + + if cli.dry_run { + let engine = cli.engine.unwrap_or(cfg.engine); + print_dry_run(&cli, &cfg, engine); + return Ok(()); + } + + if let Some(engine) = cli.engine + && cfg.engine != engine + { + cfg.engine = engine; + config::save(&cfg)?; + } + if let Some(firefox) = &cli.firefox { + cfg.firefox_bin = Some(firefox.clone()); + config::save(&cfg)?; + } + + match cli.engine.unwrap_or(cfg.engine) { + Engine::Webkit => webkit::launch(&cli, &mut cfg), + Engine::Gecko => gecko::launch(&cli, &mut cfg), + } +} + +fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) { + let url = cli + .url + .as_deref() + .map(util::normalize_url) + .unwrap_or_else(|| util::DEFAULT_HOME.to_string()); + + match engine { + Engine::Webkit => { + let effective_dmabuf = + std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string()); + println!( + "engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}", + cli.private, + cfg.adblock, + if cli.ua_chrome { "chrome" } else { "native" }, + cli.linux_backend, + cli.linux_disable_dmabuf, + ); + } + Engine::Gecko => { + let firefox: Option = + cli.firefox.clone().or_else(|| gecko::locate_firefox(cfg)); + let firefox = firefox + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "not-found".to_string()); + println!( + "engine=gecko url={url} private={} firefox={firefox} profile={}", + cli.private, + config::gecko_profile_dir().display() + ); + } + } } diff --git a/src/switch.rs b/src/switch.rs new file mode 100644 index 0000000..23b02d5 --- /dev/null +++ b/src/switch.rs @@ -0,0 +1,59 @@ +//! Engine switching: hand the tab set to the other engine and relaunch. + +use crate::cli::Engine; +use crate::config::{self, Config}; +use crate::handoff::{self, Handoff}; +use anyhow::{Context, Result}; +use std::process::{Command, Stdio}; + +/// Persist the handoff, relaunch RustyWolf in `target` mode, remember the choice. +pub fn switch_to( + target: Engine, + cfg: &mut Config, + handoff: Option<&Handoff>, + private: bool, +) -> Result<()> { + let has_handoff = match handoff { + Some(handoff) if !handoff.tabs.is_empty() => { + handoff::save(handoff)?; + true + } + _ => false, + }; + spawn_detached(target, has_handoff, private)?; + cfg.engine = target; + if let Err(err) = config::save(cfg) { + eprintln!("rustywolf: could not persist the engine choice: {err:#}"); + } + Ok(()) +} + +/// Start another RustyWolf process in the background (survives our exit). +pub fn spawn_detached(target: Engine, resume: bool, private: bool) -> Result<()> { + let exe = std::env::current_exe().context("could not locate the RustyWolf binary")?; + let mut cmd = Command::new(exe); + cmd.arg("--engine").arg(target.as_str()); + if resume { + cmd.arg("--resume"); + } + if private { + cmd.arg("--private"); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(unix)] + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + // New session so the switched process survives this one's exit. + libc::setsid(); + Ok(()) + }); + } + + cmd.spawn() + .with_context(|| format!("could not launch the {} engine", target.as_str()))?; + Ok(()) +} diff --git a/src/testdata/recovery.jsonlz4 b/src/testdata/recovery.jsonlz4 new file mode 100644 index 0000000..7cd7c26 Binary files /dev/null and b/src/testdata/recovery.jsonlz4 differ diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..e44fd65 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,247 @@ +//! Small shared helpers: URL normalization, filenames, timestamps. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const DEFAULT_HOME: &str = "https://duckduckgo.com"; +pub const NEW_TAB_URL: &str = "https://duckduckgo.com"; +pub const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q="; + +/// Turn a URL-bar entry into a URL (search terms go to DuckDuckGo). +pub fn normalize_url(input: &str) -> String { + let trimmed = input.trim(); + if trimmed == "about:blank" { + return trimmed.to_string(); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return trimmed.to_string(); + } + if looks_like_address(trimmed) { + return format!("https://{trimmed}"); + } + let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect(); + format!("{SEARCH_URL_PREFIX}{encoded}") +} + +pub fn looks_like_address(value: &str) -> bool { + value.contains('.') + || value.contains(':') + || value.starts_with("localhost") + || value.starts_with("127.") + || value.starts_with('[') + || value.starts_with('/') +} + +/// New-window targets RustyWolf refuses to open as tabs/popups. +pub fn should_open_new_window_url(raw: &str) -> bool { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") { + return false; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("about:") + { + return false; + } + + let Ok(parsed) = url::Url::parse(trimmed) else { + return false; + }; + + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return false; + } + + // DuckDuckGo's in-page "open in a new window" helpers are not real links. + let host = parsed.host_str().unwrap_or_default(); + let path = parsed.path().to_ascii_lowercase(); + if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com")) + && (path == "/post3.html" || path == "/page3" || path == "/page3.html") + { + return false; + } + + true +} + +pub fn sanitize_filename(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + let invalid = + matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control(); + out.push(if invalid { '_' } else { ch }); + } + let trimmed = out.trim_matches('.').trim(); + if trimmed.is_empty() { + "download.bin".to_string() + } else { + trimmed.to_string() + } +} + +pub fn filename_from_url(url: &str) -> String { + let raw = url::Url::parse(url) + .ok() + .and_then(|u| { + u.path_segments().and_then(|mut parts| { + parts + .next_back() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) + }) + }) + .unwrap_or_else(|| "download.bin".to_string()); + + sanitize_filename(&raw) +} + +pub fn ensure_unique_download_path(path: PathBuf) -> PathBuf { + if !path.exists() { + return path; + } + + let parent = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("download") + .to_string(); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .filter(|e| !e.is_empty()) + .map(ToString::to_string); + + for idx in 1..=9999 { + let file_name = match &ext { + Some(ext) => format!("{stem} ({idx}).{ext}"), + None => format!("{stem} ({idx})"), + }; + let candidate = parent.join(file_name); + if !candidate.exists() { + return candidate; + } + } + + parent.join(format!("{stem}-{}", now_unix())) +} + +pub fn path_to_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Human timestamp used by history/downloads rows. +pub fn now_stamp() -> String { + now_unix().to_string() +} + +pub fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// RustyWolf's own generated pages (properties, etc). +pub fn is_internal_page(url: &str) -> bool { + url.starts_with("data:text/html;base64,") +} + +pub fn path_string_to_file_url(path: &str) -> String { + url::Url::from_file_path(Path::new(path)) + .map(|u| u.to_string()) + .unwrap_or_else(|_| format!("file://{path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_existing_scheme() { + assert_eq!(normalize_url("https://example.com"), "https://example.com"); + } + + #[test] + fn adds_https_for_domains() { + assert_eq!(normalize_url("example.com"), "https://example.com"); + } + + #[test] + fn keeps_about_blank() { + assert_eq!(normalize_url("about:blank"), "about:blank"); + } + + #[test] + fn converts_search_terms_to_duckduckgo_query() { + assert_eq!( + normalize_url("privacy browser"), + "https://duckduckgo.com/?q=privacy+browser" + ); + } + + #[test] + fn converts_single_token_to_duckduckgo_query() { + assert_eq!(normalize_url("rust"), "https://duckduckgo.com/?q=rust"); + } + + #[test] + fn trims_before_search() { + assert_eq!( + normalize_url(" rust webview "), + "https://duckduckgo.com/?q=rust+webview" + ); + } + + #[test] + fn rejects_non_web_new_window_targets() { + assert!(!should_open_new_window_url("")); + assert!(!should_open_new_window_url(" about:blank ")); + assert!(!should_open_new_window_url("javascript:alert(1)")); + assert!(!should_open_new_window_url("data:text/html,hi")); + } + + #[test] + fn rejects_duckduckgo_internal_popup_paths() { + assert!(!should_open_new_window_url( + "https://duckduckgo.com/post3.html" + )); + assert!(!should_open_new_window_url("https://duckduckgo.com/page3")); + assert!(!should_open_new_window_url( + "https://duckduckgo.com/page3.html" + )); + } + + #[test] + fn allows_regular_https_new_window_targets() { + assert!(should_open_new_window_url("https://example.com/")); + assert!(should_open_new_window_url("https://duckduckgo.com/?q=rust")); + assert!(should_open_new_window_url( + "https://accounts.google.com/o/oauth2/v2/auth" + )); + } + + #[test] + fn sanitizes_windows_unsafe_filename_chars() { + assert_eq!(sanitize_filename("report:2026?.pdf"), "report_2026_.pdf"); + } + + #[test] + fn falls_back_when_filename_becomes_empty() { + assert_eq!(sanitize_filename("..."), "download.bin"); + } +} diff --git a/src/webkit/chrome.html b/src/webkit/chrome.html new file mode 100644 index 0000000..2909bce --- /dev/null +++ b/src/webkit/chrome.html @@ -0,0 +1,360 @@ + + + + + + + +
+
+
+ + + + +
+ +
+ +
+ + + diff --git a/src/webkit/js/adblock.js b/src/webkit/js/adblock.js new file mode 100644 index 0000000..ebb45a8 --- /dev/null +++ b/src/webkit/js/adblock.js @@ -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(); + } +})(); diff --git a/src/webkit/js/privacy.js b/src/webkit/js/privacy.js new file mode 100644 index 0000000..6b5c87d --- /dev/null +++ b/src/webkit/js/privacy.js @@ -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); +})(); diff --git a/src/webkit/mod.rs b/src/webkit/mod.rs new file mode 100644 index 0000000..52fe84e --- /dev/null +++ b/src/webkit/mod.rs @@ -0,0 +1,1636 @@ +//! WebKit mode: the RustyWolf chrome webview plus system-webview tabs (wry). +//! +//! Platform mapping: +//! - Linux: GTK boxes host the chrome and content webviews (WebKitGTK). +//! Popups are real `window.opener`-preserving windows (see `popup`). +//! - macOS / Windows: WKWebView / WebView2 through wry; new-window requests +//! go through the RustyWolf popup gate and open as tabs. + +mod popup; + +mod js { + pub const PRIVACY_JS: &str = include_str!("js/privacy.js"); + pub const ADBLOCK_JS: &str = include_str!("js/adblock.js"); +} + +use crate::cli::{Cli, Engine}; +use crate::config::{self, Config}; +use crate::handoff::{self, Handoff, HandoffTab}; +use crate::switch; +use crate::util::{ + self, DEFAULT_HOME, NEW_TAB_URL, ensure_unique_download_path, escape_html, filename_from_url, + is_internal_page, now_stamp, path_string_to_file_url, path_to_string, + should_open_new_window_url, +}; +use anyhow::{Result, anyhow}; +use base64::Engine as _; +#[cfg(target_os = "linux")] +use gtk::prelude::*; +use serde_json::json; +use std::collections::HashSet; +use std::fs; +#[cfg(target_os = "linux")] +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; +use tao::dpi::LogicalSize; +use tao::event::{Event, WindowEvent}; +use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; +#[cfg(target_os = "linux")] +use tao::platform::unix::WindowExtUnix; +use tao::window::{Icon, Window, WindowBuilder}; +use wry::{PageLoadEvent, Rect, WebView, WebViewBuilder}; +#[cfg(target_os = "linux")] +use wry::{WebViewBuilderExtUnix, WebViewExtUnix}; + +const CHROME_HTML: &str = include_str!("chrome.html"); + +const CHROME_HEIGHT_BASE: f64 = 84.0; +const CHROME_HEIGHT_DOWNLOADS_EXTRA: f64 = 104.0; +const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0; +const MAX_HISTORY_ENTRIES: usize = 250; +const MAX_DOWNLOAD_ENTRIES: usize = 200; +const PROPERTIES_MAX_ROWS: usize = 120; +const MAX_TAB_NAV_ENTRIES: usize = 100; +const DOWNLOAD_TICK_MS: u64 = 500; + +/// Only used with `--ua-chrome`: some sites reject any non-Chromium UA. +const CHROME_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + +// ---------- New-window policy ------------------------------------------ + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavKind { + Link, + Form, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NewWindowDecision { + /// Not a web URL (javascript:, data:, about:, filtered hosts, ...). + Deny, + /// Open a RustyWolf tab (user-clicked links and form targets). + Tab, + /// Scripted popup: ask through the chrome popup gate first. + Ask, + /// Open a real popup window (user-clicked `window.open`, e.g. OAuth). + Popup, +} + +/// Policy for `window.open` / `target=_blank` requests. +/// +/// User-gesture requests behave like a real browser: clicked links open tabs, +/// clicked `window.open` calls become real popups (this is what makes +/// popup-based logins such as "Sign in with Google" work). Scripted popups +/// still go through the RustyWolf permission gate. +pub(crate) fn decide_new_window(url: &str, nav: NavKind, user_gesture: bool) -> NewWindowDecision { + if !should_open_new_window_url(url) { + return NewWindowDecision::Deny; + } + if !user_gesture { + return NewWindowDecision::Ask; + } + match nav { + NavKind::Link | NavKind::Form => NewWindowDecision::Tab, + NavKind::Other => NewWindowDecision::Popup, + } +} + +// ---------- Event types ------------------------------------------------ + +#[derive(Debug)] +enum UserEvent { + ChromeIpc(String), + TitleChanged { + tab_id: u32, + title: String, + }, + PageLoaded { + tab_id: u32, + url: String, + }, + NewWindow { + url: String, + }, + NewWindowAsk { + url: String, + }, + DownloadStarted { + url: String, + file_path: String, + }, + DownloadTotal { + file_path: String, + total_bytes: Option, + }, + DownloadTick, + DownloadCompleted { + url: String, + file_path: Option, + success: bool, + }, +} + +enum Outcome { + Continue, + Exit, +} + +// ---------- State ------------------------------------------------------ + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WebViewHostMode { + Child, + Window, +} + +struct Tab { + id: u32, + title: String, + url: String, + kir_enabled: bool, + webview: Option, + back_stack: Vec, + forward_stack: Vec, + pending_nav_from_stack: bool, +} + +struct PendingPopup { + url: String, + host: String, +} + +struct HistoryEntry { + title: String, + url: String, + visited_at: String, +} + +struct DownloadEntry { + url: String, + file_path: String, + status: String, + in_progress: bool, + downloaded_bytes: u64, + total_bytes: Option, + updated_at: String, +} + +struct App { + cfg: Config, + chrome: WebView, + host_mode: WebViewHostMode, + #[cfg(target_os = "linux")] + gtk_chrome_container: Option, + #[cfg(target_os = "linux")] + gtk_content_container: Option, + #[cfg(target_os = "linux")] + popup_hub: Rc, + tabs: Vec, + history: Vec, + downloads: Vec, + popup_pending: Option, + popup_allow: HashSet, + downloads_panel: bool, + active: usize, + next_id: u32, + ticker: Arc, + private: bool, + ua_chrome: bool, +} + +// ---------- Entry point ------------------------------------------------ + +pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> { + apply_linux_runtime_overrides(cli); + let title = cli.title.clone().unwrap_or_else(|| "RustyWolf".to_string()); + let start_url = cli + .url + .as_deref() + .map(util::normalize_url) + .unwrap_or_else(|| DEFAULT_HOME.to_string()); + + let event_loop: EventLoop = EventLoopBuilder::::with_user_event().build(); + let proxy = event_loop.create_proxy(); + + let window = WindowBuilder::new() + .with_title(title) + .with_window_icon(load_window_icon()) + .with_inner_size(LogicalSize::new(1000.0_f64, 600.0_f64)) + .build(&event_loop) + .map_err(|err| anyhow!("failed to create the window: {err}"))?; + + #[cfg(target_os = "linux")] + let (gtk_chrome_container, gtk_content_container) = create_linux_webview_containers(&window)?; + #[cfg(target_os = "linux")] + let popup_hub = Rc::new(popup::PopupHub::new(proxy.clone(), cfg.adblock)); + + let (chrome, host_mode) = build_chrome( + &window, + &proxy, + resolve_host_mode(), + #[cfg(target_os = "linux")] + Some(>k_chrome_container), + )?; + + let mut app = App { + cfg: std::mem::take(cfg), + chrome, + host_mode, + #[cfg(target_os = "linux")] + gtk_chrome_container: Some(gtk_chrome_container), + #[cfg(target_os = "linux")] + gtk_content_container: Some(gtk_content_container), + #[cfg(target_os = "linux")] + popup_hub, + tabs: Vec::new(), + history: Vec::new(), + downloads: Vec::new(), + popup_pending: None, + popup_allow: HashSet::new(), + downloads_panel: false, + active: 0, + next_id: 1, + ticker: Arc::new(AtomicBool::new(false)), + private: cli.private, + ua_chrome: cli.ua_chrome, + }; + + let resumed = if cli.url.is_none() && cli.resume { + handoff::take_fresh() + } else { + None + }; + match resumed { + Some(handoff) => app.resume_tabs(&window, &proxy, handoff)?, + None => app.open_tab(&window, &proxy, start_url)?, + } + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + match event { + Event::UserEvent(ev) => match handle_event(ev, &window, &proxy, &mut app) { + Ok(Outcome::Continue) => {} + Ok(Outcome::Exit) => *control_flow = ControlFlow::Exit, + Err(err) => eprintln!("rustywolf: event error: {err:#}"), + }, + Event::WindowEvent { + event: WindowEvent::Resized(_), + .. + } => reflow_layout(&window, &app), + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => {} + } + }); +} + +// ---------- App -------------------------------------------------------- + +impl App { + fn alloc_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn find_index(&self, tab_id: u32) -> Option { + self.tabs.iter().position(|t| t.id == tab_id) + } + + fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active) + } + + fn open_tab( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + url: String, + ) -> Result<()> { + let tab_id = self.alloc_id(); + let bounds = content_bounds(window, chrome_height(self), self.host_mode); + let (webview, host_mode) = build_tab_webview( + window, + proxy, + self.host_mode, + #[cfg(target_os = "linux")] + self.gtk_content_container.as_ref(), + #[cfg(target_os = "linux")] + Some(&self.popup_hub), + self.private, + self.ua_chrome, + self.cfg.adblock, + tab_id, + &url, + bounds, + )?; + self.host_mode = host_mode; + + self.tabs.push(Tab { + id: tab_id, + title: "New Tab".into(), + url, + kir_enabled: false, + webview: Some(webview), + back_stack: Vec::new(), + forward_stack: Vec::new(), + pending_nav_from_stack: false, + }); + self.active = self.tabs.len() - 1; + + self.suspend_background_tabs(); + self.apply_visibility(); + self.push_all(); + Ok(()) + } + + fn resume_tabs( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + handoff: Handoff, + ) -> Result<()> { + let tabs = handoff.tabs; + if tabs.is_empty() { + return self.open_tab(window, proxy, DEFAULT_HOME.to_string()); + } + let active = handoff.active.min(tabs.len() - 1); + + // Restored background tabs stay unloaded until clicked (lightness). + for tab in tabs { + let id = self.alloc_id(); + let title = if tab.title.trim().is_empty() { + "New Tab".to_string() + } else { + tab.title + }; + self.tabs.push(Tab { + id, + title, + url: tab.url, + kir_enabled: false, + webview: None, + back_stack: Vec::new(), + forward_stack: Vec::new(), + pending_nav_from_stack: false, + }); + } + self.active = active; + self.ensure_active_tab_loaded(window, proxy)?; + self.push_all(); + Ok(()) + } + + fn ensure_active_tab_loaded( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + ) -> Result<()> { + if self.tabs.is_empty() { + return Ok(()); + } + self.ensure_tab_loaded(window, proxy, self.active) + } + + fn ensure_tab_loaded( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + index: usize, + ) -> Result<()> { + if index >= self.tabs.len() || self.tabs[index].webview.is_some() { + return Ok(()); + } + let tab_id = self.tabs[index].id; + let url = self.tabs[index].url.clone(); + let bounds = content_bounds(window, chrome_height(self), self.host_mode); + let (webview, host_mode) = build_tab_webview( + window, + proxy, + self.host_mode, + #[cfg(target_os = "linux")] + self.gtk_content_container.as_ref(), + #[cfg(target_os = "linux")] + Some(&self.popup_hub), + self.private, + self.ua_chrome, + self.cfg.adblock, + tab_id, + &url, + bounds, + )?; + self.host_mode = host_mode; + self.tabs[index].webview = Some(webview); + self.apply_visibility(); + Ok(()) + } + + /// Unload every background tab that is not pinned with KIR. + fn suspend_background_tabs(&mut self) { + for (i, tab) in self.tabs.iter_mut().enumerate() { + if i != self.active && !tab.kir_enabled { + tab.webview = None; + } + } + } + + fn apply_visibility(&self) { + for (i, tab) in self.tabs.iter().enumerate() { + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.set_visible(i == self.active); + } + } + } + + fn close_tab( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + tab_id: u32, + ) -> Result { + let Some(index) = self.find_index(tab_id) else { + return Ok(false); + }; + self.tabs.remove(index); + + if self.tabs.is_empty() { + return Ok(true); + } + if self.active >= self.tabs.len() { + self.active = self.tabs.len() - 1; + } else if index < self.active { + self.active -= 1; + } + self.ensure_active_tab_loaded(window, proxy)?; + self.suspend_background_tabs(); + self.apply_visibility(); + self.push_tabs(); + Ok(false) + } + + fn handle_page_loaded(&mut self, tab_id: u32, url: &str) { + if url.is_empty() || url == "about:blank" { + return; + } + let Some(i) = self.find_index(tab_id) else { + return; + }; + + let previous = self.tabs[i].url.clone(); + if url != previous { + if self.tabs[i].pending_nav_from_stack { + self.tabs[i].pending_nav_from_stack = false; + } else if !previous.is_empty() + && previous != "about:blank" + && !is_internal_page(&previous) + { + self.tabs[i].back_stack.push(previous); + trim_nav_stack(&mut self.tabs[i].back_stack); + self.tabs[i].forward_stack.clear(); + } + self.tabs[i].url = url.to_string(); + } else { + self.tabs[i].pending_nav_from_stack = false; + } + + if !is_internal_page(&self.tabs[i].url) { + let title = self.tabs[i].title.clone(); + let current = self.tabs[i].url.clone(); + self.append_history(title, current); + } + self.push_tabs(); + } + + /// Scripted popup coming from the page: use the chrome permission gate. + fn ask_new_window( + &mut self, + window: &Window, + proxy: &EventLoopProxy, + url: String, + ) -> Result<()> { + if !should_open_new_window_url(&url) { + return Ok(()); + } + let host = host_of(&url); + if !host.is_empty() && self.popup_allow.contains(&host) { + return self.open_tab(window, proxy, url); + } + if self.popup_pending.is_none() { + self.popup_pending = Some(PendingPopup { url, host }); + self.push_popup_prompt(); + reflow_layout(window, self); + } + Ok(()) + } + + fn append_history(&mut self, title: String, url: String) { + if url.is_empty() { + return; + } + self.history.push(HistoryEntry { + title: if title.trim().is_empty() { + url.clone() + } else { + title + }, + url, + visited_at: now_stamp(), + }); + let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES); + if overflow > 0 { + self.history.drain(0..overflow); + } + } + + fn trim_downloads(&mut self) { + let overflow = self.downloads.len().saturating_sub(MAX_DOWNLOAD_ENTRIES); + if overflow > 0 { + self.downloads.drain(0..overflow); + } + } + + fn build_handoff(&self) -> Handoff { + let mut tabs = Vec::new(); + let mut active = 0usize; + for (i, tab) in self.tabs.iter().enumerate() { + if tab.url.is_empty() || tab.url == "about:blank" || is_internal_page(&tab.url) { + continue; + } + if i == self.active { + active = tabs.len(); + } + tabs.push(HandoffTab { + url: tab.url.clone(), + title: tab.title.clone(), + }); + } + Handoff::new(self.private, active, tabs) + } + + fn push_all(&self) { + self.push_tabs(); + self.push_downloads(); + self.push_adblock(); + self.push_engine(); + self.push_popup_prompt(); + } + + fn push_tabs(&self) { + if self.tabs.is_empty() { + return; + } + let payload = json!({ + "tabs": self.tabs.iter().map(|t| json!({ + "id": t.id, + "title": t.title, + "url": t.url, + "kirEnabled": t.kir_enabled, + "loaded": t.webview.is_some(), + })).collect::>(), + "activeId": self.tabs[self.active].id, + }) + .to_string(); + let script = format!("window.__rustywolfSetTabs && window.__rustywolfSetTabs({payload});"); + let _ = self.chrome.evaluate_script(&script); + } + + fn push_downloads(&self) { + let payload = json!({ + "panelOpen": self.downloads_panel, + "downloads": self + .downloads + .iter() + .rev() + .filter(|d| d.in_progress) + .take(12) + .map(|d| json!({ + "url": d.url, + "filePath": d.file_path, + "status": d.status, + "inProgress": d.in_progress, + "downloadedBytes": d.downloaded_bytes, + "totalBytes": d.total_bytes, + })) + .collect::>(), + }) + .to_string(); + let script = + format!("window.__rustywolfSetDownloads && window.__rustywolfSetDownloads({payload});"); + let _ = self.chrome.evaluate_script(&script); + } + + fn push_adblock(&self) { + let script = format!( + "window.__rustywolfSetAdblock && window.__rustywolfSetAdblock({});", + self.cfg.adblock + ); + let _ = self.chrome.evaluate_script(&script); + } + + fn push_engine(&self) { + let script = format!( + "window.__rustywolfSetEngine && window.__rustywolfSetEngine('{}');", + self.cfg.engine.as_str() + ); + let _ = self.chrome.evaluate_script(&script); + } + + fn push_popup_prompt(&self) { + let payload = match &self.popup_pending { + Some(pending) => json!({ + "visible": true, + "host": if pending.host.is_empty() { "this website" } else { &pending.host }, + }), + None => json!({ "visible": false }), + } + .to_string(); + let script = format!( + "window.__rustywolfSetPopupPrompt && window.__rustywolfSetPopupPrompt({payload});" + ); + let _ = self.chrome.evaluate_script(&script); + } + + fn apply_adblock_to_tabs(&self) { + let script = format!( + "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({});", + self.cfg.adblock + ); + for tab in &self.tabs { + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.evaluate_script(&script); + } + } + #[cfg(target_os = "linux")] + popup::set_popup_adblock(&self.popup_hub, self.cfg.adblock); + } +} + +// ---------- Event handling --------------------------------------------- + +fn handle_event( + ev: UserEvent, + window: &Window, + proxy: &EventLoopProxy, + app: &mut App, +) -> Result { + match ev { + UserEvent::ChromeIpc(msg) => handle_chrome_ipc(&msg, window, proxy, app), + UserEvent::TitleChanged { tab_id, title } => { + if let Some(i) = app.find_index(tab_id) + && !title.trim().is_empty() + && app.tabs[i].title != title + { + app.tabs[i].title = title; + app.push_tabs(); + } + Ok(Outcome::Continue) + } + UserEvent::PageLoaded { tab_id, url } => { + app.handle_page_loaded(tab_id, &url); + Ok(Outcome::Continue) + } + UserEvent::NewWindow { url } => { + if should_open_new_window_url(&url) { + app.open_tab(window, proxy, url)?; + } + Ok(Outcome::Continue) + } + UserEvent::NewWindowAsk { url } => { + app.ask_new_window(window, proxy, url)?; + Ok(Outcome::Continue) + } + UserEvent::DownloadStarted { url, file_path } => { + app.downloads.push(DownloadEntry { + url, + file_path, + status: "In progress".to_string(), + in_progress: true, + downloaded_bytes: 0, + total_bytes: None, + updated_at: now_stamp(), + }); + app.trim_downloads(); + app.push_downloads(); + kick_download_ticker(app, proxy); + Ok(Outcome::Continue) + } + UserEvent::DownloadTotal { + file_path, + total_bytes, + } => { + if let Some(entry) = app + .downloads + .iter_mut() + .rev() + .find(|d| d.file_path == file_path && d.in_progress) + { + entry.total_bytes = total_bytes; + app.push_downloads(); + } + Ok(Outcome::Continue) + } + UserEvent::DownloadTick => { + let mut changed = false; + for entry in app.downloads.iter_mut().filter(|d| d.in_progress) { + if let Ok(meta) = fs::metadata(&entry.file_path) { + let bytes = meta.len(); + if bytes != entry.downloaded_bytes { + entry.downloaded_bytes = bytes; + changed = true; + } + } + } + if !app.downloads.iter().any(|d| d.in_progress) { + app.ticker.store(false, Ordering::SeqCst); + } + if changed { + app.push_downloads(); + } + Ok(Outcome::Continue) + } + UserEvent::DownloadCompleted { + url, + file_path, + success, + } => { + let path = file_path.unwrap_or_else(|| "Unknown destination".to_string()); + if let Some(entry) = app + .downloads + .iter_mut() + .rev() + .find(|d| d.url == url && d.in_progress) + { + entry.file_path = path; + entry.status = if success { + "Completed".to_string() + } else { + "Failed".to_string() + }; + entry.in_progress = false; + if let Ok(meta) = fs::metadata(&entry.file_path) { + entry.downloaded_bytes = meta.len(); + } + entry.updated_at = now_stamp(); + } else { + app.downloads.push(DownloadEntry { + url, + file_path: path, + status: if success { + "Completed".to_string() + } else { + "Failed".to_string() + }, + in_progress: false, + downloaded_bytes: 0, + total_bytes: None, + updated_at: now_stamp(), + }); + app.trim_downloads(); + } + if !app.downloads.iter().any(|d| d.in_progress) { + app.ticker.store(false, Ordering::SeqCst); + } + app.push_downloads(); + Ok(Outcome::Continue) + } + } +} + +fn handle_chrome_ipc( + message: &str, + window: &Window, + proxy: &EventLoopProxy, + app: &mut App, +) -> Result { + if let Some(raw_url) = message.strip_prefix("navigate:") { + app.ensure_active_tab_loaded(window, proxy)?; + if let Some(tab) = app.active_tab_mut() { + let next = util::normalize_url(raw_url); + if next != tab.url && !tab.url.is_empty() && tab.url != "about:blank" { + tab.back_stack.push(tab.url.clone()); + trim_nav_stack(&mut tab.back_stack); + tab.forward_stack.clear(); + } + tab.pending_nav_from_stack = false; + tab.url = next.clone(); + tab.title = "Loading…".to_string(); + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.load_url(&next); + } + app.push_tabs(); + } + return Ok(Outcome::Continue); + } + + if message == "back" { + app.ensure_active_tab_loaded(window, proxy)?; + if let Some(tab) = app.active_tab_mut() { + if let Some(target) = tab.back_stack.pop() { + if !tab.url.is_empty() && tab.url != "about:blank" { + tab.forward_stack.push(tab.url.clone()); + trim_nav_stack(&mut tab.forward_stack); + } + tab.pending_nav_from_stack = true; + tab.url = target.clone(); + tab.title = "Loading…".to_string(); + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.load_url(&target); + } + app.push_tabs(); + } else { + run_script_on_active_tab(app, "history.back();"); + } + } + return Ok(Outcome::Continue); + } + + if message == "forward" { + app.ensure_active_tab_loaded(window, proxy)?; + if let Some(tab) = app.active_tab_mut() { + if let Some(target) = tab.forward_stack.pop() { + if !tab.url.is_empty() && tab.url != "about:blank" { + tab.back_stack.push(tab.url.clone()); + trim_nav_stack(&mut tab.back_stack); + } + tab.pending_nav_from_stack = true; + tab.url = target.clone(); + tab.title = "Loading…".to_string(); + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.load_url(&target); + } + app.push_tabs(); + } else { + run_script_on_active_tab(app, "history.forward();"); + } + } + return Ok(Outcome::Continue); + } + + if message == "reload" { + run_script_on_active_tab(app, "location.reload();"); + return Ok(Outcome::Continue); + } + + if message == "new_tab" { + app.open_tab(window, proxy, NEW_TAB_URL.to_string())?; + return Ok(Outcome::Continue); + } + + if message == "open_properties" { + let properties_url = properties_data_url(app); + app.open_tab(window, proxy, properties_url)?; + if let Some(tab) = app.tabs.last_mut() { + tab.title = "Properties".to_string(); + } + app.push_tabs(); + return Ok(Outcome::Continue); + } + + if message == "toggle_downloads" { + app.downloads_panel = !app.downloads_panel; + reflow_layout(window, app); + app.push_downloads(); + return Ok(Outcome::Continue); + } + + if message == "toggle_engine" { + let handoff = app.build_handoff(); + let handoff_ref = if handoff.tabs.is_empty() { + None + } else { + Some(&handoff) + }; + match switch::switch_to(Engine::Gecko, &mut app.cfg, handoff_ref, app.private) { + Ok(()) => return Ok(Outcome::Exit), + Err(err) => eprintln!("rustywolf: could not switch to the Gecko engine: {err:#}"), + } + return Ok(Outcome::Continue); + } + + if message == "popup_allow_yes" { + if let Some(pending) = app.popup_pending.take() { + if !pending.host.is_empty() { + app.popup_allow.insert(pending.host); + } + app.push_popup_prompt(); + reflow_layout(window, app); + app.open_tab(window, proxy, pending.url)?; + } + return Ok(Outcome::Continue); + } + + if message == "popup_allow_no" { + app.popup_pending = None; + app.push_popup_prompt(); + reflow_layout(window, app); + return Ok(Outcome::Continue); + } + + if message == "toggle_adblock" { + app.cfg.adblock = !app.cfg.adblock; + if let Err(err) = config::save(&app.cfg) { + eprintln!("rustywolf: could not persist the adblock choice: {err:#}"); + } + app.apply_adblock_to_tabs(); + app.push_adblock(); + return Ok(Outcome::Continue); + } + + if message == "close_active_tab" { + if !app.tabs.is_empty() { + let tab_id = app.tabs[app.active].id; + if app.close_tab(window, proxy, tab_id)? { + return Ok(Outcome::Exit); + } + } + return Ok(Outcome::Continue); + } + + if let Some(raw) = message.strip_prefix("toggle_kir:") { + if let Ok(target_id) = raw.parse::() + && let Some(i) = app.find_index(target_id) + { + app.tabs[i].kir_enabled = !app.tabs[i].kir_enabled; + if i != app.active && !app.tabs[i].kir_enabled { + app.tabs[i].webview = None; + } + app.apply_visibility(); + app.push_tabs(); + } + return Ok(Outcome::Continue); + } + + if let Some(raw) = message.strip_prefix("switch_tab:") { + if let Ok(target_id) = raw.parse::() + && let Some(i) = app.find_index(target_id) + { + app.active = i; + app.ensure_active_tab_loaded(window, proxy)?; + app.suspend_background_tabs(); + app.apply_visibility(); + app.push_tabs(); + } + return Ok(Outcome::Continue); + } + + if let Some(raw) = message.strip_prefix("close_tab:") { + if let Ok(target_id) = raw.parse::() + && app.close_tab(window, proxy, target_id)? + { + return Ok(Outcome::Exit); + } + return Ok(Outcome::Continue); + } + + Ok(Outcome::Continue) +} + +fn run_script_on_active_tab(app: &App, script: &str) { + if let Some(tab) = app.tabs.get(app.active) + && let Some(webview) = tab.webview.as_ref() + { + let _ = webview.evaluate_script(script); + } +} + +/// Downloads only tick while something is actually downloading. +fn kick_download_ticker(app: &App, proxy: &EventLoopProxy) { + if !app.ticker.swap(true, Ordering::SeqCst) { + let active = app.ticker.clone(); + let proxy = proxy.clone(); + thread::spawn(move || { + loop { + thread::sleep(Duration::from_millis(DOWNLOAD_TICK_MS)); + if !active.load(Ordering::SeqCst) + || proxy.send_event(UserEvent::DownloadTick).is_err() + { + break; + } + } + }); + } +} + +// ---------- Webview builders ------------------------------------------- + +fn resolve_host_mode() -> WebViewHostMode { + #[cfg(target_os = "linux")] + { + // Linux hosts every webview inside GTK containers. + WebViewHostMode::Window + } + #[cfg(not(target_os = "linux"))] + { + WebViewHostMode::Child + } +} + +fn build_chrome( + window: &Window, + proxy: &EventLoopProxy, + host_mode: WebViewHostMode, + #[cfg(target_os = "linux")] gtk_chrome_container: Option<>k::Box>, +) -> Result<(WebView, WebViewHostMode)> { + let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE); + let make_builder = |proxy: &EventLoopProxy| { + let proxy = proxy.clone(); + WebViewBuilder::new() + .with_html(CHROME_HTML) + .with_bounds(chrome_rect) + .with_ipc_handler(move |req| { + let _ = proxy.send_event(UserEvent::ChromeIpc(req.body().to_string())); + }) + }; + + match host_mode { + WebViewHostMode::Window => { + #[cfg(target_os = "linux")] + { + let container = gtk_chrome_container + .ok_or_else(|| anyhow!("missing Linux GTK chrome container"))?; + make_builder(proxy) + .build_gtk(container) + .map(|webview| (webview, WebViewHostMode::Window)) + .map_err(|err| anyhow!("failed to create the chrome webview: {err}")) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = gtk_chrome_container; + make_builder(proxy) + .build(window) + .map(|webview| (webview, WebViewHostMode::Window)) + .map_err(|err| anyhow!("failed to create the chrome webview: {err}")) + } + } + WebViewHostMode::Child => match make_builder(proxy).build_as_child(window) { + Ok(webview) => Ok((webview, WebViewHostMode::Child)), + Err(err) => { + eprintln!( + "rustywolf: child chrome webview unsupported; hosting in the window instead ({err})" + ); + make_builder(proxy) + .build(window) + .map(|webview| (webview, WebViewHostMode::Window)) + .map_err(|fallback| { + anyhow!( + "failed to create the chrome webview: {err}; window fallback failed: {fallback}" + ) + }) + } + }, + } +} + +#[allow(clippy::too_many_arguments)] +fn build_tab_webview( + window: &Window, + proxy: &EventLoopProxy, + host_mode: WebViewHostMode, + #[cfg(target_os = "linux")] gtk_content_container: Option<>k::Box>, + #[cfg(target_os = "linux")] popup_hub: Option<&Rc>, + private: bool, + ua_chrome: bool, + adblock_enabled: bool, + tab_id: u32, + url: &str, + bounds: Rect, +) -> Result<(WebView, WebViewHostMode)> { + let init_script = format!( + "window.__rustywolfAdblockInitEnabled = {adblock_enabled};\n{}\n{}", + js::PRIVACY_JS, + js::ADBLOCK_JS + ); + + #[allow(clippy::too_many_arguments)] + fn make_builder<'a>( + proxy: &EventLoopProxy, + private: bool, + ua_chrome: bool, + tab_id: u32, + url: &'a str, + bounds: Rect, + init_script: &'a str, + ) -> WebViewBuilder<'a> { + let title_proxy = proxy.clone(); + let page_proxy = proxy.clone(); + let download_started_proxy = proxy.clone(); + let download_completed_proxy = proxy.clone(); + let download_total_proxy = proxy.clone(); + let new_window_proxy = proxy.clone(); + + let builder = WebViewBuilder::new() + .with_url(url) + .with_incognito(private) + .with_initialization_script(init_script) + .with_bounds(bounds) + .with_document_title_changed_handler(move |title| { + let _ = title_proxy.send_event(UserEvent::TitleChanged { tab_id, title }); + }) + .with_on_page_load_handler(move |event, url| { + if matches!(event, PageLoadEvent::Finished) { + let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, url }); + } + }) + .with_download_started_handler(move |url, destination| { + let path = ensure_unique_download_path(default_download_path_for(&url)); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + *destination = path.clone(); + let path_string = path_to_string(&path); + fetch_download_total( + url.clone(), + path_string.clone(), + download_total_proxy.clone(), + ); + let _ = download_started_proxy.send_event(UserEvent::DownloadStarted { + url, + file_path: path_string, + }); + true + }) + .with_download_completed_handler(move |url, path, success| { + let _ = download_completed_proxy.send_event(UserEvent::DownloadCompleted { + url, + file_path: path.map(|p| path_to_string(&p)), + success, + }); + }); + + let builder = if ua_chrome { + builder.with_user_agent(CHROME_UA) + } else { + builder + }; + + #[cfg(not(target_os = "linux"))] + let builder = builder.with_new_window_req_handler(move |url| { + let _ = new_window_proxy.send_event(UserEvent::NewWindowAsk { url }); + false + }); + #[cfg(target_os = "linux")] + let _ = new_window_proxy; + + builder + } + + match host_mode { + WebViewHostMode::Window => { + #[cfg(target_os = "linux")] + { + let container = gtk_content_container + .ok_or_else(|| anyhow!("missing Linux GTK content container"))?; + let webview = + make_builder(proxy, private, ua_chrome, tab_id, url, bounds, &init_script) + .build_gtk(container) + .map_err(|err| anyhow!("failed to create the tab webview: {err}"))?; + + if let Some(hub) = popup_hub { + popup::attach(&webview.webview(), hub); + } + Ok((webview, WebViewHostMode::Window)) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = gtk_content_container; + let _ = popup_hub; + make_builder(proxy, private, ua_chrome, tab_id, url, bounds, &init_script) + .build(window) + .map(|webview| (webview, WebViewHostMode::Window)) + .map_err(|err| anyhow!("failed to create the tab webview: {err}")) + } + } + WebViewHostMode::Child => { + match make_builder(proxy, private, ua_chrome, tab_id, url, bounds, &init_script) + .build_as_child(window) + { + Ok(webview) => Ok((webview, WebViewHostMode::Child)), + Err(err) => { + eprintln!( + "rustywolf: child tab webview unsupported; hosting in the window instead ({err})" + ); + make_builder(proxy, private, ua_chrome, tab_id, url, bounds, &init_script) + .build(window) + .map(|webview| (webview, WebViewHostMode::Window)) + .map_err(|fallback| { + anyhow!( + "failed to create the tab webview: {err}; window fallback failed: {fallback}" + ) + }) + } + } + } + } +} + +// ---------- Layout ----------------------------------------------------- + +fn logical_size(window: &Window) -> (f64, f64) { + let scale = window.scale_factor(); + let phys = window.inner_size(); + (phys.width as f64 / scale, phys.height as f64 / scale) +} + +fn chrome_bounds(window: &Window, chrome_h: f64) -> Rect { + let (logical_w, _) = logical_size(window); + Rect { + position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), + size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new(logical_w, chrome_h)), + } +} + +fn content_bounds(window: &Window, chrome_h: f64, host_mode: WebViewHostMode) -> Rect { + #[cfg(target_os = "linux")] + { + if host_mode == WebViewHostMode::Window { + // Content webviews live in their own GTK container. + let (logical_w, logical_h) = logical_size(window); + return Rect { + position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), + size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new( + logical_w, + (logical_h - chrome_h).max(0.0), + )), + }; + } + } + + let (logical_w, logical_h) = logical_size(window); + Rect { + position: wry::dpi::LogicalPosition::new(0.0, chrome_h).into(), + size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new( + logical_w, + (logical_h - chrome_h).max(0.0), + )), + } +} + +fn reflow_layout(window: &Window, app: &App) { + let chrome_h = chrome_height(app); + #[cfg(target_os = "linux")] + { + if let Some(container) = app.gtk_chrome_container.as_ref() { + set_linux_chrome_container_height(container, chrome_h); + } + if app.host_mode == WebViewHostMode::Window && app.gtk_chrome_container.is_some() { + // GTK container layout controls bounds in window-host mode. + return; + } + } + let _ = app.chrome.set_bounds(chrome_bounds(window, chrome_h)); + let bounds = content_bounds(window, chrome_h, app.host_mode); + for tab in &app.tabs { + if let Some(webview) = tab.webview.as_ref() { + let _ = webview.set_bounds(bounds); + } + } +} + +fn chrome_height(app: &App) -> f64 { + let mut height = CHROME_HEIGHT_BASE; + if app.downloads_panel { + height += CHROME_HEIGHT_DOWNLOADS_EXTRA; + } + if app.popup_pending.is_some() { + height += CHROME_HEIGHT_POPUP_PROMPT_EXTRA; + } + height +} + +#[cfg(target_os = "linux")] +fn create_linux_webview_containers(window: &Window) -> Result<(gtk::Box, gtk::Box)> { + let vbox = window + .default_vbox() + .ok_or_else(|| anyhow!("could not access the GTK vbox for webview hosting"))?; + + let chrome = gtk::Box::new(gtk::Orientation::Vertical, 0); + chrome.set_hexpand(true); + chrome.set_vexpand(false); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + + vbox.pack_start(&chrome, false, false, 0); + vbox.pack_start(&content, true, true, 0); + + chrome.show_all(); + content.show_all(); + vbox.show_all(); + + Ok((chrome, content)) +} + +#[cfg(target_os = "linux")] +fn set_linux_chrome_container_height(container: >k::Box, chrome_h: f64) { + container.set_size_request(-1, chrome_h.max(0.0).round() as i32); +} + +// ---------- Linux runtime tweaks --------------------------------------- + +fn apply_linux_runtime_overrides(_args: &Cli) { + #[cfg(target_os = "linux")] + { + sanitize_gtk_modules_env(); + + match _args.linux_backend { + crate::cli::LinuxBackend::Auto => {} + crate::cli::LinuxBackend::X11 => unsafe { + std::env::set_var("WINIT_UNIX_BACKEND", "x11"); + std::env::set_var("GDK_BACKEND", "x11"); + }, + crate::cli::LinuxBackend::Wayland => unsafe { + std::env::set_var("WINIT_UNIX_BACKEND", "wayland"); + std::env::set_var("GDK_BACKEND", "wayland"); + }, + } + + if should_disable_webkit_dmabuf(_args) { + // WebKitGTK + NVIDIA can fail with dmabuf/gbm allocation errors. + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + unsafe { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + if !_args.linux_disable_dmabuf { + eprintln!( + "rustywolf: NVIDIA X11 detected; disabled the broken WebKit dmabuf renderer (media codecs stay enabled)" + ); + } + } + } + } +} + +#[cfg(target_os = "linux")] +fn should_disable_webkit_dmabuf(args: &Cli) -> bool { + if args.linux_disable_dmabuf { + return true; + } + if std::env::var("WEBKIT_FORCE_DMABUF_RENDERER") + .ok() + .as_deref() + == Some("1") + { + return false; + } + is_linux_x11_session() && is_linux_nvidia_graphics() +} + +#[cfg(target_os = "linux")] +fn is_linux_x11_session() -> bool { + std::env::var("XDG_SESSION_TYPE") + .map(|value| value.eq_ignore_ascii_case("x11")) + .unwrap_or(false) + || (std::env::var_os("DISPLAY").is_some() && std::env::var_os("WAYLAND_DISPLAY").is_none()) +} + +#[cfg(target_os = "linux")] +fn is_linux_nvidia_graphics() -> bool { + env_value_contains("__GLX_VENDOR_LIBRARY_NAME", "nvidia") + || env_value_contains("GBM_BACKEND", "nvidia") + || env_value_contains("__NV_PRIME_RENDER_OFFLOAD", "1") + || std::path::Path::new("/proc/driver/nvidia/version").exists() + || std::path::Path::new("/sys/module/nvidia/version").exists() + || std::path::Path::new("/dev/nvidiactl").exists() +} + +#[cfg(target_os = "linux")] +fn env_value_contains(name: &str, needle: &str) -> bool { + std::env::var(name) + .map(|value| value.to_ascii_lowercase().contains(needle)) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn sanitize_gtk_modules_env() { + fn is_appmenu_module(token: &str) -> bool { + let normalized = token.trim().trim_end_matches(".so").to_ascii_lowercase(); + normalized == "appmenu-gtk-module" + || normalized == "appmenu" + || normalized.ends_with("/appmenu-gtk-module") + } + + fn sanitize_var(name: &str) { + match std::env::var(name) { + Ok(raw) => { + let mut kept = Vec::new(); + for token in + raw.split(|c: char| c == ':' || c == ';' || c == ',' || c.is_ascii_whitespace()) + { + let trimmed = token.trim(); + if trimmed.is_empty() || is_appmenu_module(trimmed) { + continue; + } + kept.push(trimmed.to_string()); + } + let next = kept.join(":"); + if next == raw { + return; + } + unsafe { + std::env::set_var(name, next); + } + } + Err(_) => unsafe { + std::env::set_var(name, ""); + }, + } + } + + sanitize_var("GTK_MODULES"); + sanitize_var("GTK3_MODULES"); + + unsafe { + std::env::set_var("UBUNTU_MENUPROXY", "0"); + } +} + +// ---------- Shared helpers --------------------------------------------- + +fn host_of(raw_url: &str) -> String { + url::Url::parse(raw_url) + .ok() + .and_then(|parsed| parsed.host_str().map(ToString::to_string)) + .unwrap_or_default() +} + +fn trim_nav_stack(stack: &mut Vec) { + let overflow = stack.len().saturating_sub(MAX_TAB_NAV_ENTRIES); + if overflow > 0 { + stack.drain(0..overflow); + } +} + +fn load_window_icon() -> Option { + let image = image::load_from_memory_with_format( + include_bytes!("../rustywolf.ico"), + image::ImageFormat::Ico, + ) + .ok()? + .into_rgba8(); + let (width, height) = image.dimensions(); + Icon::from_rgba(image.into_raw(), width, height).ok() +} + +fn default_download_path_for(url: &str) -> std::path::PathBuf { + let mut dir = dirs::download_dir() + .or_else(dirs::home_dir) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(std::env::temp_dir); + dir.push(filename_from_url(url)); + dir +} + +fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy) { + thread::spawn(move || { + let total_bytes = ureq::head(&url).call().ok().and_then(|resp| { + resp.header("Content-Length") + .and_then(|value| value.parse::().ok()) + }); + let _ = proxy.send_event(UserEvent::DownloadTotal { + file_path, + total_bytes, + }); + }); +} + +fn properties_data_url(app: &App) -> String { + let html = render_properties_html(app); + let encoded = base64::engine::general_purpose::STANDARD.encode(html.as_bytes()); + format!("data:text/html;base64,{encoded}") +} + +fn render_properties_html(app: &App) -> String { + let history_is_truncated = app.history.len() > PROPERTIES_MAX_ROWS; + let downloads_are_truncated = app.downloads.len() > PROPERTIES_MAX_ROWS; + let history_rows = if app.history.is_empty() { + "No history yet.".to_string() + } else { + app.history + .iter() + .rev() + .take(PROPERTIES_MAX_ROWS) + .map(|entry| { + format!( + "{}{}{}", + escape_html(&entry.visited_at), + escape_html(&entry.url), + escape_html(&entry.title), + escape_html(&entry.url), + ) + }) + .collect::>() + .join("") + }; + + let download_rows = if app.downloads.is_empty() { + "No downloads yet.".to_string() + } else { + app.downloads + .iter() + .rev() + .take(PROPERTIES_MAX_ROWS) + .map(|entry| { + let file_url = path_string_to_file_url(&entry.file_path); + format!( + "{}{}{}{}", + escape_html(&entry.updated_at), + escape_html(&entry.status), + escape_html(&file_url), + escape_html(&entry.file_path), + escape_html(&entry.url), + ) + }) + .collect::>() + .join("") + }; + + format!( + "RustyWolf Properties\ + \ +

Browser Properties

Session history and download activity. Engine: WebKit.

\ +

Browsing History

{}

{history_rows}
VisitedTitleURL
\ +

Download History

{}

{download_rows}
UpdatedStatusSaved ToSource URL
\ + ", + if history_is_truncated { + format!("Showing latest {PROPERTIES_MAX_ROWS} entries.") + } else { + "Showing all entries.".to_string() + }, + if downloads_are_truncated { + format!("Showing latest {PROPERTIES_MAX_ROWS} entries.") + } else { + "Showing all entries.".to_string() + } + ) +} + +// ---------- Tests ------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clicked_links_open_tabs() { + assert_eq!( + decide_new_window("https://example.com/", NavKind::Link, true), + NewWindowDecision::Tab + ); + } + + #[test] + fn clicked_window_open_becomes_popup() { + // "Sign in with Google"-style buttons. + assert_eq!( + decide_new_window( + "https://accounts.google.com/o/oauth2/v2/auth?client_id=x", + NavKind::Other, + true + ), + NewWindowDecision::Popup + ); + } + + #[test] + fn scripted_popups_go_through_the_gate() { + assert_eq!( + decide_new_window("https://ads.example/popup", NavKind::Other, false), + NewWindowDecision::Ask + ); + } + + #[test] + fn unsupported_schemes_are_denied() { + assert_eq!( + decide_new_window("javascript:alert(1)", NavKind::Link, true), + NewWindowDecision::Deny + ); + assert_eq!( + decide_new_window("data:text/html,hi", NavKind::Other, true), + NewWindowDecision::Deny + ); + } + + #[test] + fn duckduckgo_popup_helpers_are_denied() { + assert_eq!( + decide_new_window("https://duckduckgo.com/post3.html", NavKind::Other, true), + NewWindowDecision::Deny + ); + } +} diff --git a/src/webkit/popup.rs b/src/webkit/popup.rs new file mode 100644 index 0000000..a51c9a4 --- /dev/null +++ b/src/webkit/popup.rs @@ -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, + pub adblock_enabled: Cell, + pub popups: RefCell>>, +} + +impl PopupHub { + pub fn new(proxy: EventLoopProxy, 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) { + 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::<>k::gio::Cancellable>, |_| {}); + alive.push(view.downgrade()); + } + } + *hub.popups.borrow_mut() = alive; +} + +fn handle_create( + view: &webkit2gtk::WebView, + action: &webkit2gtk::NavigationAction, + hub: &Rc, +) -> Option { + 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, +) -> Option { + 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); +} diff --git a/src/webview.rs b/src/webview.rs deleted file mode 100644 index f682850..0000000 --- a/src/webview.rs +++ /dev/null @@ -1,2261 +0,0 @@ -//webview.rs -use crate::cli::Cli; -use anyhow::{Result, anyhow}; -use base64::Engine; -#[cfg(target_os = "linux")] -use gtk::prelude::*; -use serde_json::json; -use std::collections::HashSet; -use std::fs; -use std::path::{Path, PathBuf}; -use std::thread; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; -use tao::dpi::LogicalSize; -use tao::event::{Event, WindowEvent}; -use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; -#[cfg(target_os = "linux")] -use tao::platform::unix::WindowExtUnix; -use tao::window::{Icon, Window, WindowBuilder}; -#[cfg(target_os = "linux")] -use wry::WebViewBuilderExtUnix; -use wry::{PageLoadEvent, Rect, WebView, WebViewBuilder}; - -const DEFAULT_HOME: &str = "https://duckduckgo.com"; -const NEW_TAB_URL: &str = "https://duckduckgo.com"; -const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q="; -const MODERN_BROWSER_UA_BASE: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; -const CHROME_HEIGHT_BASE: f64 = 84.0; -const CHROME_HEIGHT_DOWNLOADS_EXTRA: f64 = 104.0; -const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0; -const MAX_HISTORY_ENTRIES: usize = 250; -const MAX_DOWNLOAD_ENTRIES: usize = 200; -const PROPERTIES_MAX_ROWS: usize = 120; -const MAX_TAB_NAV_ENTRIES: usize = 100; - -// ---------- Event types ----------------------------------------------- - -#[derive(Debug)] -enum UserEvent { - ChromeIpc(String), - TitleChanged { - tab_id: u32, - title: String, - }, - PageLoaded { - tab_id: u32, - url: String, - }, - DownloadStarted { - url: String, - file_path: String, - }, - DownloadTotal { - file_path: String, - total_bytes: Option, - }, - DownloadTick, - DownloadCompleted { - url: String, - file_path: Option, - success: bool, - }, - OpenInNewTab { - url: String, - }, - Quit, -} - -// ---------- State ------------------------------------------------------- - -struct Tab { - id: u32, - title: String, - url: String, - kir_enabled: bool, - webview: Option, - back_stack: Vec, - forward_stack: Vec, - pending_nav_from_stack: bool, -} - -struct BrowserState { - chrome: WebView, - host_mode: WebViewHostMode, - #[cfg(target_os = "linux")] - gtk_chrome_container: Option, - #[cfg(target_os = "linux")] - gtk_content_container: Option, - tabs: Vec, - history: Vec, - downloads: Vec, - adblock_enabled: bool, - popup_allow_hosts: HashSet, - pending_popup: Option, - downloads_panel_open: bool, - active: usize, - next_id: u32, - private: bool, -} - -struct PendingPopup { - url: String, - host: String, -} - -struct HistoryEntry { - title: String, - url: String, - visited_at: String, -} - -struct DownloadEntry { - url: String, - file_path: String, - status: String, - in_progress: bool, - downloaded_bytes: u64, - total_bytes: Option, - updated_at: String, -} - -#[allow(dead_code)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum WebViewHostMode { - Child, - Window, -} - -impl BrowserState { - fn alloc_id(&mut self) -> u32 { - let id = self.next_id; - self.next_id += 1; - id - } - fn find_index(&self, tab_id: u32) -> Option { - self.tabs.iter().position(|t| t.id == tab_id) - } -} - -// ---------- Chrome HTML (static, separate webview) -------------------- - -const CHROME_HTML: &str = r#" - - - - - - -
-
-
- - - -
- -
- -
- - -"#; - -// ---------- Privacy-only init script for content webviews ------------ - -const PRIVACY_JS: &str = r#" -Object.defineProperty(navigator, 'doNotTrack', { get: () => '1' }); -Object.defineProperty(window, 'openDatabase', { value: undefined }); -if ('BatteryManager' in window) window.BatteryManager = undefined; -if ('RTCPeerConnection' in window) window.RTCPeerConnection = undefined; - -// 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); -"#; - -const ADBLOCK_JS: &str = r#" -(() => { - if (window.__rustywolfAdblockBooted) return; - window.__rustywolfAdblockBooted = true; - - const storageKey = '__rustywolf_adblock_enabled'; - const readPersistedEnabled = () => { - try { - const raw = window.localStorage && window.localStorage.getItem(storageKey); - if (raw === '0') return false; - if (raw === '1') return true; - } catch (_) {} - return false; - }; - const persistEnabled = (enabled) => { - try { - if (window.localStorage) { - window.localStorage.setItem(storageKey, enabled ? '1' : '0'); - } - } catch (_) {} - }; - - window.__rustywolfAdblockEnabled = readPersistedEnabled(); - - 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; - persistEnabled(window.__rustywolfAdblockEnabled); - 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); - - startObserver(); - scheduleAdScan(); -})(); -"#; - -// ---------- Entry point ------------------------------------------------ - -pub fn launch_webkit(args: &Cli) -> Result<()> { - apply_linux_runtime_overrides(args); - let mut host_mode = resolve_webview_host_mode(args); - - let start_url = args - .url - .as_deref() - .map(normalize_url) - .unwrap_or_else(|| DEFAULT_HOME.to_string()); - let title = args - .title - .clone() - .unwrap_or_else(|| "RustyWolf".to_string()); - - if args.dry_run { - let effective_dmabuf = - std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string()); - println!( - "engine=webkit url={start_url} private={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}", - args.private, args.linux_backend, args.linux_disable_dmabuf - ); - return Ok(()); - } - - let event_loop: EventLoop = EventLoopBuilder::::with_user_event().build(); - let proxy = event_loop.create_proxy(); - - let window = WindowBuilder::new() - .with_title(title) - .with_window_icon(load_window_icon()) - .with_inner_size(LogicalSize::new(1000.0_f64, 600.0_f64)) - .build(&event_loop) - .map_err(|err| anyhow!("Failed to create window: {err}"))?; - - #[cfg(target_os = "linux")] - let (gtk_chrome_container, gtk_content_container) = if host_mode == WebViewHostMode::Window { - let (chrome, content) = create_linux_webview_containers(&window)?; - set_linux_chrome_container_height(&chrome, CHROME_HEIGHT_BASE); - (Some(chrome), Some(content)) - } else { - (None, None) - }; - - let (chrome, effective_host_mode) = build_chrome( - &window, - &proxy, - host_mode, - #[cfg(target_os = "linux")] - gtk_chrome_container.as_ref(), - )?; - host_mode = effective_host_mode; - - let mut state = BrowserState { - chrome, - host_mode, - #[cfg(target_os = "linux")] - gtk_chrome_container, - #[cfg(target_os = "linux")] - gtk_content_container, - tabs: Vec::new(), - history: Vec::new(), - downloads: Vec::new(), - adblock_enabled: false, - popup_allow_hosts: HashSet::new(), - pending_popup: None, - downloads_panel_open: false, - active: 0, - next_id: 1, - private: args.private, - }; - - open_tab(&mut state, &window, &proxy, start_url)?; - - let tick_proxy = proxy.clone(); - thread::spawn(move || { - loop { - thread::sleep(Duration::from_millis(300)); - if tick_proxy.send_event(UserEvent::DownloadTick).is_err() { - break; - } - } - }); - - event_loop.run(move |event, _, control_flow| { - *control_flow = ControlFlow::Wait; - match event { - Event::UserEvent(ev) => { - if matches!(ev, UserEvent::Quit) { - *control_flow = ControlFlow::Exit; - } else if let Err(e) = handle_event(ev, &window, &proxy, &mut state) { - eprintln!("event error: {e:#}"); - } - } - Event::WindowEvent { - event: WindowEvent::Resized(_size), - .. - } => { - reflow_layout(&window, &state); - } - Event::WindowEvent { - event: WindowEvent::CloseRequested, - .. - } => { - *control_flow = ControlFlow::Exit; - } - _ => {} - } - }); -} - -fn apply_linux_runtime_overrides(_args: &Cli) { - #[cfg(target_os = "linux")] - { - sanitize_gtk_modules_env(); - - match _args.linux_backend { - crate::cli::LinuxBackend::Auto => {} - crate::cli::LinuxBackend::X11 => unsafe { - std::env::set_var("WINIT_UNIX_BACKEND", "x11"); - std::env::set_var("GDK_BACKEND", "x11"); - }, - crate::cli::LinuxBackend::Wayland => unsafe { - std::env::set_var("WINIT_UNIX_BACKEND", "wayland"); - std::env::set_var("GDK_BACKEND", "wayland"); - }, - } - - if should_disable_webkit_dmabuf(_args) { - // WebKitGTK + NVIDIA can fail with dmabuf/gbm allocation on some systems. - if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { - unsafe { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - - if !_args.linux_disable_dmabuf { - eprintln!( - "Detected NVIDIA X11 WebKitGTK; disabled broken dmabuf renderer. Media codecs remain enabled." - ); - } - } - } - } -} - -#[cfg(target_os = "linux")] -fn should_disable_webkit_dmabuf(args: &Cli) -> bool { - if args.linux_disable_dmabuf { - return true; - } - - if std::env::var("WEBKIT_FORCE_DMABUF_RENDERER") - .ok() - .as_deref() - == Some("1") - { - return false; - } - - is_linux_x11_session() && is_linux_nvidia_graphics() -} - -#[cfg(target_os = "linux")] -fn is_linux_x11_session() -> bool { - std::env::var("XDG_SESSION_TYPE") - .map(|value| value.eq_ignore_ascii_case("x11")) - .unwrap_or(false) - || (std::env::var_os("DISPLAY").is_some() && std::env::var_os("WAYLAND_DISPLAY").is_none()) -} - -#[cfg(target_os = "linux")] -fn is_linux_nvidia_graphics() -> bool { - env_value_contains("__GLX_VENDOR_LIBRARY_NAME", "nvidia") - || env_value_contains("GBM_BACKEND", "nvidia") - || env_value_contains("__NV_PRIME_RENDER_OFFLOAD", "1") - || Path::new("/proc/driver/nvidia/version").exists() - || Path::new("/sys/module/nvidia/version").exists() - || Path::new("/dev/nvidiactl").exists() -} - -#[cfg(target_os = "linux")] -fn env_value_contains(name: &str, needle: &str) -> bool { - std::env::var(name) - .map(|value| value.to_ascii_lowercase().contains(needle)) - .unwrap_or(false) -} - -#[cfg(target_os = "linux")] -fn sanitize_gtk_modules_env() { - fn is_appmenu_module(token: &str) -> bool { - let normalized = token.trim().trim_end_matches(".so").to_ascii_lowercase(); - normalized == "appmenu-gtk-module" - || normalized == "appmenu" - || normalized.ends_with("/appmenu-gtk-module") - } - - fn sanitize_var(name: &str) { - match std::env::var(name) { - Ok(raw) => { - let mut kept = Vec::new(); - for token in - raw.split(|c: char| c == ':' || c == ';' || c == ',' || c.is_ascii_whitespace()) - { - let trimmed = token.trim(); - if trimmed.is_empty() || is_appmenu_module(trimmed) { - continue; - } - kept.push(trimmed.to_string()); - } - - let next = kept.join(":"); - if next == raw { - return; - } - - unsafe { - // Keep GTK from auto-loading the missing appmenu module on desktops - // that export it globally. - std::env::set_var(name, next); - } - } - Err(_) => unsafe { - // Force an explicit empty override so GTK does not inherit a desktop-level - // appmenu module setting from system configuration. - std::env::set_var(name, ""); - }, - } - } - - sanitize_var("GTK_MODULES"); - sanitize_var("GTK3_MODULES"); - - unsafe { - std::env::set_var("UBUNTU_MENUPROXY", "0"); - } -} - -fn resolve_webview_host_mode(args: &Cli) -> WebViewHostMode { - #[cfg(target_os = "linux")] - { - let _ = args; - WebViewHostMode::Window - } - - #[cfg(not(target_os = "linux"))] - { - let _ = args; - WebViewHostMode::Child - } -} - -// ---------- Event handling --------------------------------------------- - -fn handle_event( - ev: UserEvent, - window: &Window, - proxy: &EventLoopProxy, - state: &mut BrowserState, -) -> Result<()> { - match ev { - UserEvent::ChromeIpc(msg) => handle_chrome_ipc(&msg, window, proxy, state)?, - UserEvent::TitleChanged { tab_id, title } => { - if let Some(i) = state.find_index(tab_id) { - if !title.trim().is_empty() { - state.tabs[i].title = title; - push_tabs(state); - } - } - } - UserEvent::PageLoaded { tab_id, url } => { - if let Some(i) = state.find_index(tab_id) { - if !url.is_empty() && url != "about:blank" { - let previous = state.tabs[i].url.clone(); - if url != previous { - if state.tabs[i].pending_nav_from_stack { - state.tabs[i].pending_nav_from_stack = false; - } else if !previous.is_empty() - && previous != "about:blank" - && !is_internal_page(&previous) - { - state.tabs[i].back_stack.push(previous); - trim_tab_nav_stack(&mut state.tabs[i].back_stack); - state.tabs[i].forward_stack.clear(); - } - state.tabs[i].url = url; - } else { - state.tabs[i].pending_nav_from_stack = false; - } - if !is_internal_page(&state.tabs[i].url) { - append_history( - state, - state.tabs[i].title.clone(), - state.tabs[i].url.clone(), - ); - } - let enabled = if state.adblock_enabled { - "true" - } else { - "false" - }; - if let Some(webview) = state.tabs[i].webview.as_ref() { - let _ = webview.evaluate_script(&format!( - "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});" - )); - } - push_tabs(state); - } - } - } - UserEvent::DownloadStarted { url, file_path } => { - state.downloads.push(DownloadEntry { - url, - file_path, - status: "In progress".to_string(), - in_progress: true, - downloaded_bytes: 0, - total_bytes: None, - updated_at: now_stamp(), - }); - trim_downloads(state); - push_downloads(state); - } - UserEvent::DownloadTotal { - file_path, - total_bytes, - } => { - if let Some(entry) = state - .downloads - .iter_mut() - .rev() - .find(|d| d.file_path == file_path && d.in_progress) - { - entry.total_bytes = total_bytes; - push_downloads(state); - } - } - UserEvent::DownloadTick => { - let mut changed = false; - for entry in state.downloads.iter_mut().filter(|d| d.in_progress) { - if let Ok(meta) = fs::metadata(&entry.file_path) { - let bytes = meta.len(); - if bytes != entry.downloaded_bytes { - entry.downloaded_bytes = bytes; - changed = true; - } - } - } - if changed { - push_downloads(state); - } - } - UserEvent::DownloadCompleted { - url, - file_path, - success, - } => { - let path = file_path.unwrap_or_else(|| "Unknown destination".to_string()); - if let Some(entry) = state - .downloads - .iter_mut() - .rev() - .find(|d| d.url == url && d.in_progress) - { - entry.file_path = path; - entry.status = if success { - "Completed".to_string() - } else { - "Failed".to_string() - }; - entry.in_progress = false; - if let Ok(meta) = fs::metadata(&entry.file_path) { - entry.downloaded_bytes = meta.len(); - } - entry.updated_at = now_stamp(); - } else { - state.downloads.push(DownloadEntry { - url, - file_path: path, - status: if success { - "Completed".to_string() - } else { - "Failed".to_string() - }, - in_progress: false, - downloaded_bytes: 0, - total_bytes: None, - updated_at: now_stamp(), - }); - trim_downloads(state); - } - push_downloads(state); - } - UserEvent::OpenInNewTab { url } => { - if should_open_new_tab_url(&url) { - let trimmed = url.trim(); - let host = url::Url::parse(trimmed) - .ok() - .and_then(|u| u.host_str().map(ToString::to_string)) - .unwrap_or_default(); - - if !host.is_empty() && state.popup_allow_hosts.contains(&host) { - open_tab(state, window, proxy, trimmed.to_string())?; - } else if state.pending_popup.is_none() { - state.pending_popup = Some(PendingPopup { - url: trimmed.to_string(), - host, - }); - push_popup_prompt(state); - reflow_layout(window, state); - } - } - } - UserEvent::Quit => {} - } - Ok(()) -} - -fn handle_chrome_ipc( - message: &str, - window: &Window, - proxy: &EventLoopProxy, - state: &mut BrowserState, -) -> Result<()> { - if let Some(raw_url) = message.strip_prefix("navigate:") { - ensure_active_tab_loaded(state, window, proxy)?; - if let Some(tab) = active_tab_mut(state) { - let next = normalize_url(raw_url); - if next != tab.url && !tab.url.is_empty() && tab.url != "about:blank" { - tab.back_stack.push(tab.url.clone()); - trim_tab_nav_stack(&mut tab.back_stack); - tab.forward_stack.clear(); - } - tab.pending_nav_from_stack = false; - tab.url = next.clone(); - tab.title = "Loading…".to_string(); - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.load_url(&next); - } - push_tabs(state); - } - return Ok(()); - } - - if message == "back" { - ensure_active_tab_loaded(state, window, proxy)?; - if let Some(tab) = active_tab_mut(state) { - if let Some(target) = tab.back_stack.pop() { - if !tab.url.is_empty() && tab.url != "about:blank" { - tab.forward_stack.push(tab.url.clone()); - trim_tab_nav_stack(&mut tab.forward_stack); - } - tab.pending_nav_from_stack = true; - tab.url = target.clone(); - tab.title = "Loading…".to_string(); - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.load_url(&target); - } - push_tabs(state); - } else { - run_script_on_active_tab(state, "history.back();"); - } - } - return Ok(()); - } - - if message == "forward" { - ensure_active_tab_loaded(state, window, proxy)?; - if let Some(tab) = active_tab_mut(state) { - if let Some(target) = tab.forward_stack.pop() { - if !tab.url.is_empty() && tab.url != "about:blank" { - tab.back_stack.push(tab.url.clone()); - trim_tab_nav_stack(&mut tab.back_stack); - } - tab.pending_nav_from_stack = true; - tab.url = target.clone(); - tab.title = "Loading…".to_string(); - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.load_url(&target); - } - push_tabs(state); - } else { - run_script_on_active_tab(state, "history.forward();"); - } - } - return Ok(()); - } - - if message == "reload" { - run_script_on_active_tab(state, "location.reload();"); - return Ok(()); - } - - if message == "new_tab" { - open_tab(state, window, proxy, NEW_TAB_URL.to_string())?; - return Ok(()); - } - - if message == "open_properties" { - let properties_url = properties_data_url(state); - open_tab(state, window, proxy, properties_url)?; - if let Some(tab) = state.tabs.last_mut() { - tab.title = "Properties".to_string(); - } - push_tabs(state); - return Ok(()); - } - - if message == "toggle_downloads" { - state.downloads_panel_open = !state.downloads_panel_open; - reflow_layout(window, state); - push_downloads(state); - return Ok(()); - } - - if message == "popup_allow_yes" { - if let Some(pending) = state.pending_popup.take() { - if !pending.host.is_empty() { - state.popup_allow_hosts.insert(pending.host); - } - push_popup_prompt(state); - reflow_layout(window, state); - open_tab(state, window, proxy, pending.url)?; - } - return Ok(()); - } - - if message == "popup_allow_no" { - state.pending_popup = None; - push_popup_prompt(state); - reflow_layout(window, state); - return Ok(()); - } - - if message == "toggle_adblock" { - state.adblock_enabled = !state.adblock_enabled; - apply_adblock_setting_to_tabs(state); - push_adblock(state); - return Ok(()); - } - - if message == "close_active_tab" { - if !state.tabs.is_empty() { - let tab_id = state.tabs[state.active].id; - if close_tab(state, window, proxy, tab_id)? { - let _ = proxy.send_event(UserEvent::Quit); - } - } - return Ok(()); - } - - if let Some(raw) = message.strip_prefix("toggle_kir:") { - if let Ok(target_id) = raw.parse::() { - if let Some(i) = state.find_index(target_id) { - state.tabs[i].kir_enabled = !state.tabs[i].kir_enabled; - if i != state.active && !state.tabs[i].kir_enabled { - state.tabs[i].webview = None; - } - apply_visibility(state); - push_tabs(state); - } - } - return Ok(()); - } - - if let Some(raw) = message.strip_prefix("switch_tab:") { - if let Ok(target_id) = raw.parse::() { - if let Some(i) = state.find_index(target_id) { - state.active = i; - ensure_active_tab_loaded(state, window, proxy)?; - suspend_background_tabs(state); - apply_visibility(state); - push_tabs(state); - } - } - return Ok(()); - } - - if let Some(raw) = message.strip_prefix("close_tab:") { - if let Ok(target_id) = raw.parse::() { - if close_tab(state, window, proxy, target_id)? { - let _ = proxy.send_event(UserEvent::Quit); - } - } - return Ok(()); - } - - Ok(()) -} - -// ---------- Webview builders ------------------------------------------- - -fn build_chrome( - window: &Window, - proxy: &EventLoopProxy, - host_mode: WebViewHostMode, - #[cfg(target_os = "linux")] gtk_chrome_container: Option<>k::Box>, -) -> Result<(WebView, WebViewHostMode)> { - let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE, host_mode); - let builder = || { - let chrome_proxy = proxy.clone(); - WebViewBuilder::new() - .with_html(CHROME_HTML) - .with_bounds(chrome_rect) - .with_ipc_handler(move |req| { - let _ = chrome_proxy.send_event(UserEvent::ChromeIpc(req.body().to_string())); - }) - }; - - match host_mode { - WebViewHostMode::Window => { - #[cfg(target_os = "linux")] - { - let container = gtk_chrome_container.ok_or_else(|| { - anyhow!("Failed to create chrome webview: missing Linux GTK chrome container") - })?; - builder() - .build_gtk(container) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|err| { - anyhow!("Failed to create chrome webview with GTK hosting: {err}") - }) - } - - #[cfg(not(target_os = "linux"))] - { - builder() - .build(window) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|err| anyhow!("Failed to create chrome webview: {err}")) - } - } - WebViewHostMode::Child => match builder().build_as_child(window) { - Ok(webview) => Ok((webview, WebViewHostMode::Child)), - Err(err) => { - let err_text = err.to_string(); - if is_child_webview_unsupported_error(&err_text) { - eprintln!( - "child chrome webview unsupported on this backend; falling back to window-hosted chrome" - ); - #[cfg(target_os = "linux")] - { - if let Some(container) = gtk_chrome_container { - return builder() - .build_gtk(container) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|gtk_err| { - anyhow!( - "Failed to create chrome webview: child failed ({err_text}); GTK fallback failed ({gtk_err})" - ) - }); - } - return Err(anyhow!( - "Failed to create chrome webview: child failed ({err_text}); missing Linux GTK chrome container" - )); - } - - #[cfg(not(target_os = "linux"))] - { - return builder() - .build(window) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|fallback_err| { - anyhow!( - "Failed to create chrome webview: child failed ({err_text}); window fallback failed ({fallback_err})" - ) - }); - } - } - Err(anyhow!("Failed to create chrome webview: {err_text}")) - } - }, - } -} - -fn is_child_webview_unsupported_error(err_text: &str) -> bool { - #[cfg(target_os = "linux")] - { - let lower = err_text.to_ascii_lowercase(); - lower.contains("window handle kind is not supported") - || (lower.contains("child") && lower.contains("not supported")) - } - - #[cfg(not(target_os = "linux"))] - { - let _ = err_text; - false - } -} - -fn build_tab_webview( - window: &Window, - proxy: &EventLoopProxy, - host_mode: WebViewHostMode, - #[cfg(target_os = "linux")] gtk_content_container: Option<>k::Box>, - private: bool, - tab_id: u32, - url: &str, - bounds: Rect, -) -> Result<(WebView, WebViewHostMode)> { - let init_script = format!("{PRIVACY_JS}\n{ADBLOCK_JS}"); - let user_agent = browser_user_agent(); - - let builder = || { - let title_proxy = proxy.clone(); - let page_proxy = proxy.clone(); - let download_started_proxy = proxy.clone(); - let download_completed_proxy = proxy.clone(); - let download_total_proxy = proxy.clone(); - let new_window_proxy = proxy.clone(); - - WebViewBuilder::new() - .with_url(url) - .with_user_agent(&user_agent) - .with_incognito(private) - .with_initialization_script(&init_script) - .with_bounds(bounds) - .with_new_window_req_handler(move |target| { - let _ = new_window_proxy.send_event(UserEvent::OpenInNewTab { url: target }); - false - }) - .with_document_title_changed_handler(move |title| { - let _ = title_proxy.send_event(UserEvent::TitleChanged { tab_id, title }); - }) - .with_on_page_load_handler(move |event, url| { - if matches!(event, PageLoadEvent::Finished) { - let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, url }); - } - }) - .with_download_started_handler(move |url, destination| { - let path = ensure_unique_download_path(default_download_path_for(&url)); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - *destination = path.clone(); - let path_string = path_to_string(&path); - fetch_download_total( - url.clone(), - path_string.clone(), - download_total_proxy.clone(), - ); - let _ = download_started_proxy.send_event(UserEvent::DownloadStarted { - url, - file_path: path_string, - }); - true - }) - .with_download_completed_handler(move |url, path, success| { - let _ = download_completed_proxy.send_event(UserEvent::DownloadCompleted { - url, - file_path: path.map(|p| path_to_string(&p)), - success, - }); - }) - }; - - match host_mode { - WebViewHostMode::Window => { - #[cfg(target_os = "linux")] - { - let container = gtk_content_container.ok_or_else(|| { - anyhow!("Failed to create tab webview: missing Linux GTK content container") - })?; - builder() - .build_gtk(container) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|err| anyhow!("Failed to create tab webview with GTK hosting: {err}")) - } - - #[cfg(not(target_os = "linux"))] - { - builder() - .build(window) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|err| anyhow!("Failed to create tab webview: {err}")) - } - } - WebViewHostMode::Child => match builder().build_as_child(window) { - Ok(webview) => Ok((webview, WebViewHostMode::Child)), - Err(err) => { - let err_text = err.to_string(); - if is_child_webview_unsupported_error(&err_text) { - eprintln!( - "child tab webview unsupported on this backend; falling back to window-hosted tabs" - ); - #[cfg(target_os = "linux")] - { - if let Some(container) = gtk_content_container { - return builder() - .build_gtk(container) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|gtk_err| { - anyhow!( - "Failed to create tab webview: child failed ({err_text}); GTK fallback failed ({gtk_err})" - ) - }); - } - return Err(anyhow!( - "Failed to create tab webview: child failed ({err_text}); missing Linux GTK content container" - )); - } - - #[cfg(not(target_os = "linux"))] - { - return builder() - .build(window) - .map(|webview| (webview, WebViewHostMode::Window)) - .map_err(|fallback_err| { - anyhow!( - "Failed to create tab webview: child failed ({err_text}); window fallback failed ({fallback_err})" - ) - }); - } - } - Err(anyhow!("Failed to create tab webview: {err_text}")) - } - }, - } -} - -fn browser_user_agent() -> String { - let platform = if cfg!(target_os = "macos") { - "Macintosh; Intel Mac OS X 14_0" - } else if cfg!(target_os = "windows") { - "Windows NT 10.0; Win64; x64" - } else { - "X11; Linux x86_64" - }; - - // Keep broad compatibility while explicitly identifying RustyWolf. - format!( - "Mozilla/5.0 ({platform}; rv:128.0) {MODERN_BROWSER_UA_BASE} RustyWolf/{}", - env!("CARGO_PKG_VERSION") - ) -} - -fn open_tab( - state: &mut BrowserState, - window: &Window, - proxy: &EventLoopProxy, - url: String, -) -> Result<()> { - let tab_id = state.alloc_id(); - let chrome_h = chrome_height(state); - let tab_bounds = content_bounds(window, chrome_h, state.host_mode); - let (webview, effective_host_mode) = build_tab_webview( - window, - proxy, - state.host_mode, - #[cfg(target_os = "linux")] - state.gtk_content_container.as_ref(), - state.private, - tab_id, - &url, - tab_bounds, - )?; - state.host_mode = effective_host_mode; - - state.tabs.push(Tab { - id: tab_id, - title: "New Tab".into(), - url, - kir_enabled: false, - webview: Some(webview), - back_stack: Vec::new(), - forward_stack: Vec::new(), - pending_nav_from_stack: false, - }); - state.active = state.tabs.len() - 1; - - suspend_background_tabs(state); - - apply_visibility(state); - push_tabs(state); - push_downloads(state); - push_adblock(state); - push_popup_prompt(state); - if state.adblock_enabled { - if let Some(webview) = state.tabs[state.active].webview.as_ref() { - let _ = webview.evaluate_script( - "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled(true);", - ); - } - } - Ok(()) -} - -fn close_tab( - state: &mut BrowserState, - window: &Window, - proxy: &EventLoopProxy, - tab_id: u32, -) -> Result { - let Some(index) = state.find_index(tab_id) else { - return Ok(false); - }; - state.tabs.remove(index); - - if state.tabs.is_empty() { - return Ok(true); - } - if state.active >= state.tabs.len() { - state.active = state.tabs.len() - 1; - } else if index < state.active { - state.active -= 1; - } - ensure_active_tab_loaded(state, window, proxy)?; - suspend_background_tabs(state); - apply_visibility(state); - push_tabs(state); - Ok(false) -} - -// ---------- Helpers ---------------------------------------------------- - -fn active_tab_mut(state: &mut BrowserState) -> Option<&mut Tab> { - state.tabs.get_mut(state.active) -} - -fn ensure_active_tab_loaded( - state: &mut BrowserState, - window: &Window, - proxy: &EventLoopProxy, -) -> Result<()> { - if state.tabs.is_empty() { - return Ok(()); - } - ensure_tab_loaded(state, window, proxy, state.active) -} - -fn ensure_tab_loaded( - state: &mut BrowserState, - window: &Window, - proxy: &EventLoopProxy, - index: usize, -) -> Result<()> { - if index >= state.tabs.len() || state.tabs[index].webview.is_some() { - return Ok(()); - } - let chrome_h = chrome_height(state); - let tab_bounds = content_bounds(window, chrome_h, state.host_mode); - let tab_id = state.tabs[index].id; - let tab_url = state.tabs[index].url.clone(); - let (webview, effective_host_mode) = build_tab_webview( - window, - proxy, - state.host_mode, - #[cfg(target_os = "linux")] - state.gtk_content_container.as_ref(), - state.private, - tab_id, - &tab_url, - tab_bounds, - )?; - state.host_mode = effective_host_mode; - state.tabs[index].webview = Some(webview); - Ok(()) -} - -fn suspend_background_tabs(state: &mut BrowserState) { - for (i, tab) in state.tabs.iter_mut().enumerate() { - if i != state.active && !tab.kir_enabled { - tab.webview = None; - } - } -} - -fn run_script_on_active_tab(state: &BrowserState, script: &str) { - if let Some(tab) = state.tabs.get(state.active) { - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.evaluate_script(script); - } - } -} - -fn apply_visibility(state: &BrowserState) { - for (i, tab) in state.tabs.iter().enumerate() { - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.set_visible(i == state.active); - } - } -} - -fn push_tabs(state: &BrowserState) { - if state.tabs.is_empty() { - return; - } - let payload = json!({ - "tabs": state.tabs.iter().map(|t| json!({ - "id": t.id, - "title": t.title, - "url": t.url, - "kirEnabled": t.kir_enabled, - "loaded": t.webview.is_some(), - })).collect::>(), - "activeId": state.tabs[state.active].id, - }) - .to_string(); - let script = format!("window.__rustywolfSetTabs && window.__rustywolfSetTabs({payload});"); - let _ = state.chrome.evaluate_script(&script); -} - -fn push_downloads(state: &BrowserState) { - let payload = json!({ - "panelOpen": state.downloads_panel_open, - "downloads": state - .downloads - .iter() - .rev() - .filter(|d| d.in_progress) - .take(12) - .map(|d| json!({ - "url": d.url, - "filePath": d.file_path, - "status": d.status, - "inProgress": d.in_progress, - "downloadedBytes": d.downloaded_bytes, - "totalBytes": d.total_bytes, - })) - .collect::>(), - }) - .to_string(); - let script = - format!("window.__rustywolfSetDownloads && window.__rustywolfSetDownloads({payload});"); - let _ = state.chrome.evaluate_script(&script); -} - -fn push_adblock(state: &BrowserState) { - let enabled = if state.adblock_enabled { - "true" - } else { - "false" - }; - let script = - format!("window.__rustywolfSetAdblock && window.__rustywolfSetAdblock({enabled});"); - let _ = state.chrome.evaluate_script(&script); -} - -fn push_popup_prompt(state: &BrowserState) { - let payload = match &state.pending_popup { - Some(pending) => json!({ - "visible": true, - "host": if pending.host.is_empty() { "this website" } else { &pending.host }, - }), - None => json!({ "visible": false }), - } - .to_string(); - let script = - format!("window.__rustywolfSetPopupPrompt && window.__rustywolfSetPopupPrompt({payload});"); - let _ = state.chrome.evaluate_script(&script); -} - -fn apply_adblock_setting_to_tabs(state: &BrowserState) { - let enabled = if state.adblock_enabled { - "true" - } else { - "false" - }; - let script = format!( - "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});" - ); - for tab in &state.tabs { - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.evaluate_script(&script); - } - } -} - -fn logical_size(window: &Window) -> (f64, f64) { - let scale = window.scale_factor(); - let phys = window.inner_size(); - (phys.width as f64 / scale, phys.height as f64 / scale) -} - -fn chrome_bounds(window: &Window, chrome_h: f64, _host_mode: WebViewHostMode) -> Rect { - let (logical_w, _logical_h) = logical_size(window); - Rect { - position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), - size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new(logical_w, chrome_h)), - } -} - -fn content_bounds(window: &Window, chrome_h: f64, _host_mode: WebViewHostMode) -> Rect { - #[cfg(target_os = "linux")] - { - if _host_mode == WebViewHostMode::Window { - let (logical_w, logical_h) = logical_size(window); - return Rect { - // Content webviews are inside a dedicated GTK content container. - position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), - size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new( - logical_w, - (logical_h - chrome_h).max(0.0), - )), - }; - } - } - - let (logical_w, logical_h) = logical_size(window); - Rect { - position: wry::dpi::LogicalPosition::new(0.0, chrome_h).into(), - size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new( - logical_w, - (logical_h - chrome_h).max(0.0), - )), - } -} - -fn reflow_layout(window: &Window, state: &BrowserState) { - let chrome_h = chrome_height(state); - #[cfg(target_os = "linux")] - { - if let Some(container) = state.gtk_chrome_container.as_ref() { - set_linux_chrome_container_height(container, chrome_h); - } - if state.host_mode == WebViewHostMode::Window && state.gtk_chrome_container.is_some() { - // GTK container layout controls bounds in window-host mode. - return; - } - } - let _ = state - .chrome - .set_bounds(chrome_bounds(window, chrome_h, state.host_mode)); - let cb = content_bounds(window, chrome_h, state.host_mode); - for tab in &state.tabs { - if let Some(webview) = tab.webview.as_ref() { - let _ = webview.set_bounds(cb); - } - } -} - -#[cfg(target_os = "linux")] -fn create_linux_webview_containers(window: &Window) -> Result<(gtk::Box, gtk::Box)> { - let vbox = window.default_vbox().ok_or_else(|| { - anyhow!("Failed to access default GTK vbox required for Linux webview hosting") - })?; - - let chrome = gtk::Box::new(gtk::Orientation::Vertical, 0); - chrome.set_hexpand(true); - chrome.set_vexpand(false); - - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); - content.set_hexpand(true); - content.set_vexpand(true); - - vbox.pack_start(&chrome, false, false, 0); - vbox.pack_start(&content, true, true, 0); - - chrome.show_all(); - content.show_all(); - vbox.show_all(); - - Ok((chrome, content)) -} - -#[cfg(target_os = "linux")] -fn set_linux_chrome_container_height(container: >k::Box, chrome_h: f64) { - container.set_size_request(-1, chrome_h.max(0.0).round() as i32); -} - -fn load_window_icon() -> Option { - let image = image::load_from_memory_with_format( - include_bytes!("rustywolf.ico"), - image::ImageFormat::Ico, - ) - .ok()? - .into_rgba8(); - let (width, height) = image.dimensions(); - Icon::from_rgba(image.into_raw(), width, height).ok() -} - -fn chrome_height(state: &BrowserState) -> f64 { - let mut height = CHROME_HEIGHT_BASE; - if state.downloads_panel_open { - height += CHROME_HEIGHT_DOWNLOADS_EXTRA; - } - if state.pending_popup.is_some() { - height += CHROME_HEIGHT_POPUP_PROMPT_EXTRA; - } - height -} - -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}") -} - -fn should_open_new_tab_url(raw: &str) -> bool { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") { - return false; - } - if trimmed.to_ascii_lowercase().starts_with("javascript:") { - return false; - } - - let Ok(parsed) = url::Url::parse(trimmed) else { - return false; - }; - - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return false; - } - - 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 -} - -fn properties_data_url(state: &BrowserState) -> String { - let html = render_properties_html(state); - let encoded = base64::engine::general_purpose::STANDARD.encode(html.as_bytes()); - format!("data:text/html;base64,{encoded}") -} - -fn render_properties_html(state: &BrowserState) -> String { - let history_is_truncated = state.history.len() > PROPERTIES_MAX_ROWS; - let downloads_are_truncated = state.downloads.len() > PROPERTIES_MAX_ROWS; - let history_rows = if state.history.is_empty() { - "No history yet.".to_string() - } else { - state - .history - .iter() - .rev() - .take(PROPERTIES_MAX_ROWS) - .map(|entry| { - format!( - "{}{}{}", - escape_html(&entry.visited_at), - escape_html(&entry.url), - escape_html(&entry.title), - escape_html(&entry.url), - ) - }) - .collect::>() - .join("") - }; - - let download_rows = if state.downloads.is_empty() { - "No downloads yet.".to_string() - } else { - state - .downloads - .iter() - .rev() - .take(PROPERTIES_MAX_ROWS) - .map(|entry| { - let file_url = path_string_to_file_url(&entry.file_path); - format!( - "{}{}{}{}", - escape_html(&entry.updated_at), - escape_html(&entry.status), - escape_html(&file_url), - escape_html(&entry.file_path), - escape_html(&entry.url), - ) - }) - .collect::>() - .join("") - }; - - format!( - "RustyWolf Properties\ - \ -

Browser Properties

Session history and download activity.

\ -

Browsing History

{}

{history_rows}
VisitedTitleURL
\ -

Download History

{}

{download_rows}
UpdatedStatusSaved ToSource URL
\ - ", - if history_is_truncated { - format!("Showing latest {PROPERTIES_MAX_ROWS} entries.") - } else { - "Showing all entries.".to_string() - }, - if downloads_are_truncated { - format!("Showing latest {PROPERTIES_MAX_ROWS} entries.") - } else { - "Showing all entries.".to_string() - } - ) -} - -fn append_history(state: &mut BrowserState, title: String, url: String) { - if url.is_empty() { - return; - } - state.history.push(HistoryEntry { - title: if title.trim().is_empty() { - url.clone() - } else { - title - }, - url, - visited_at: now_stamp(), - }); - if state.history.len() > MAX_HISTORY_ENTRIES { - let remove_count = state.history.len() - MAX_HISTORY_ENTRIES; - state.history.drain(0..remove_count); - } -} - -fn trim_downloads(state: &mut BrowserState) { - if state.downloads.len() > MAX_DOWNLOAD_ENTRIES { - let remove_count = state.downloads.len() - MAX_DOWNLOAD_ENTRIES; - state.downloads.drain(0..remove_count); - } -} - -fn trim_tab_nav_stack(stack: &mut Vec) { - if stack.len() > MAX_TAB_NAV_ENTRIES { - let remove_count = stack.len() - MAX_TAB_NAV_ENTRIES; - stack.drain(0..remove_count); - } -} - -fn default_download_path_for(url: &str) -> PathBuf { - let mut dir = dirs::download_dir() - .or_else(dirs::home_dir) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(std::env::temp_dir); - dir.push(filename_from_url(url)); - dir -} - -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("/tmp")); - 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_stamp())) -} - -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) -} - -fn path_to_string(path: &PathBuf) -> String { - path.to_string_lossy().into_owned() -} - -fn now_stamp() -> String { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(duration) => duration.as_secs().to_string(), - Err(_) => "0".to_string(), - } -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -fn is_internal_page(url: &str) -> bool { - url.starts_with("data:text/html;base64,") -} - -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}")) -} - -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() - } -} - -fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy) { - thread::spawn(move || { - let total_bytes = ureq::head(&url).call().ok().and_then(|resp| { - resp.header("Content-Length") - .and_then(|v| v.parse::().ok()) - }); - let _ = proxy.send_event(UserEvent::DownloadTotal { - file_path, - total_bytes, - }); - }); -} - -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('/') -} - -// ---------- Tests ------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{normalize_url, sanitize_filename, should_open_new_tab_url}; - - #[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 blocks_blank_and_about_blank_new_tabs() { - assert!(!should_open_new_tab_url("")); - assert!(!should_open_new_tab_url(" about:blank ")); - } - - #[test] - fn blocks_duckduckgo_internal_popup_paths() { - assert!(!should_open_new_tab_url( - "https://duckduckgo.com/post3.html" - )); - assert!(!should_open_new_tab_url("https://duckduckgo.com/page3")); - assert!(!should_open_new_tab_url( - "https://duckduckgo.com/page3.html" - )); - } - - #[test] - fn allows_regular_https_new_tab_targets() { - assert!(should_open_new_tab_url("https://example.com/")); - assert!(should_open_new_tab_url("https://duckduckgo.com/?q=rust")); - } - - #[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"); - } -}