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

RustyWolf

-

Gecko mode — powered by Firefox, managed by RustyWolf.

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

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

-
- - - diff --git a/src/gecko/mod.rs b/src/gecko/mod.rs deleted file mode 100644 index 6f0cdc8..0000000 --- a/src/gecko/mod.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! Gecko mode: supervise a RustyWolf-managed Firefox (real Gecko engine). -//! -//! There is no embeddable Gecko, so RustyWolf runs Firefox with a dedicated -//! profile and keeps the experience coherent: -//! - the profile carries RustyWolf hardening prefs and the control home page, -//! - a loopback control server lets the home page switch back to WebKit, -//! - the Firefox session store supplies the tab handoff to the WebKit engine. -//! -//! This supervisor is tiny (a listener, a child process handle, a poll loop), -//! keeping RustyWolf's own RAM/CPU footprint minimal even in Gecko mode. - -mod prefs; -mod server; - -use crate::cli::{Cli, Engine}; -use crate::config::{self, Config}; -use crate::handoff; -use crate::switch; -use crate::util; -use anyhow::{Context, Result, bail}; -use server::{ControlServer, Request}; -use std::fs; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -const FAVICON: &[u8] = include_bytes!("../rustywolf.ico"); -const POLL_INTERVAL: Duration = Duration::from_millis(120); -const SHUTDOWN_GRACE: Duration = Duration::from_secs(3); - -pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> { - let firefox = resolve_firefox(cli, cfg)?; - let profile = config::gecko_profile_dir(); - fs::create_dir_all(&profile) - .with_context(|| format!("could not create {}", profile.display()))?; - - let server = ControlServer::bind()?; - let homepage = format!("http://127.0.0.1:{}/?token={}", server.port, server.token); - prefs::write_user_js(&profile, &homepage, dirs::download_dir().as_deref())?; - - let urls = startup_urls(cli); - let mut child = spawn_firefox(&firefox, &profile, &urls, cli.private)?; - eprintln!( - "rustywolf: gecko engine (Firefox {}), profile {}", - firefox.display(), - profile.display() - ); - - let home_html = include_str!("home.html") - .replace("{{TOKEN}}", &server.token) - .into_bytes(); - - loop { - match child.try_wait() { - Ok(Some(_)) => return Ok(()), // user closed Firefox - Ok(None) => {} - Err(err) => return Err(err).context("lost track of the Firefox process"), - } - - let Some(mut conn) = server.poll() else { - std::thread::sleep(POLL_INTERVAL); - continue; - }; - - match conn.request { - Request::Home => { - conn.respond("200 OK", "text/html; charset=utf-8", &home_html); - } - Request::Favicon => { - conn.respond("200 OK", "image/x-icon", FAVICON); - } - Request::Switch => { - let captured = handoff::capture_firefox_session(&profile, cli.private); - match switch::switch_to(Engine::Webkit, cfg, captured.as_ref(), cli.private) { - Ok(()) => { - conn.respond_204(); - terminate(&mut child); - return Ok(()); - } - Err(err) => { - let body = format!("RustyWolf could not switch engines: {err:#}"); - conn.respond( - "500 Internal Server Error", - "text/plain; charset=utf-8", - body.as_bytes(), - ); - } - } - } - Request::NotFound => { - conn.respond("404 Not Found", "text/plain; charset=utf-8", b"not found"); - } - } - } -} - -/// Tabs to open: explicit `--url` wins, then a pending handoff, then the home page. -fn startup_urls(cli: &Cli) -> Vec { - if let Some(url) = &cli.url { - return vec![util::normalize_url(url)]; - } - if cli.resume - && let Some(handoff) = handoff::take_fresh() - { - let mut urls: Vec = handoff.tabs.into_iter().map(|tab| tab.url).collect(); - if !urls.is_empty() { - // Firefox selects the first URL argument; bring the previously - // active tab to the front, then the rest follows. - let active = handoff.active.min(urls.len() - 1); - urls.swap(0, active); - return urls; - } - } - vec![util::DEFAULT_HOME.to_string()] -} - -fn spawn_firefox( - firefox: &PathBuf, - profile: &PathBuf, - urls: &[String], - private: bool, -) -> Result { - let mut cmd = Command::new(firefox); - cmd.arg("--no-remote") - .arg("--new-instance") - .arg("--profile") - .arg(profile); - if private { - // Private sessions are ephemeral; only the active URL comes along. - cmd.arg("--private-window").arg(&urls[0]); - } else { - for url in urls { - cmd.arg(url); - } - } - cmd.env("MOZ_CRASHREPORTER_DISABLE", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - cmd.spawn() - .with_context(|| format!("could not launch Firefox ({})", firefox.display())) -} - -/// Ask Firefox to quit, with a hard kill as a last resort. -fn terminate(child: &mut Child) { - #[cfg(unix)] - unsafe { - libc::kill(child.id() as i32, libc::SIGTERM); - } - #[cfg(not(unix))] - { - let _ = child.kill(); - } - - let deadline = Instant::now() + SHUTDOWN_GRACE; - while Instant::now() < deadline { - match child.try_wait() { - Ok(Some(_)) => return, - _ => std::thread::sleep(Duration::from_millis(50)), - } - } - let _ = child.kill(); - let _ = child.wait(); -} - -// ---------- Firefox discovery ------------------------------------------- - -fn resolve_firefox(cli: &Cli, cfg: &mut Config) -> Result { - if let Some(path) = &cli.firefox { - if path.is_file() { - cfg.firefox_bin = Some(path.clone()); - return Ok(path.clone()); - } - bail!("--firefox path does not exist: {}", path.display()); - } - - if let Some(found) = locate_firefox(cfg) { - return Ok(found); - } - - bail!( - "Firefox was not found. Install Firefox or point RustyWolf at it with --firefox /path/to/firefox" - ) -} - -/// Best-effort Firefox discovery (also used by `--dry-run`). -pub fn locate_firefox(cfg: &Config) -> Option { - if let Some(path) = &cfg.firefox_bin { - if path.is_file() { - return Some(path.clone()); - } - eprintln!( - "rustywolf: configured Firefox path no longer exists: {}", - path.display() - ); - } - - if let Some(path) = std::env::var_os("RUSTYWOLF_FIREFOX") { - let path = PathBuf::from(path); - if path.is_file() { - return Some(path); - } - } - - if let Some(found) = find_in_path(&["firefox-bin", "firefox", "firefox-esr"]) { - return Some(found); - } - - platform_candidates() - .into_iter() - .find(|candidate| candidate.is_file()) -} - -fn find_in_path(names: &[&str]) -> Option { - let path = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path) { - for name in names { - let candidate = dir.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -#[cfg(target_os = "macos")] -fn platform_candidates() -> Vec { - vec![ - PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox"), - PathBuf::from("/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"), - ] -} - -#[cfg(target_os = "windows")] -fn platform_candidates() -> Vec { - let mut candidates = Vec::new(); - for base in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] { - if let Some(root) = std::env::var_os(base) { - candidates.push( - PathBuf::from(root) - .join("Mozilla Firefox") - .join("firefox.exe"), - ); - } - } - candidates -} - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn platform_candidates() -> Vec { - vec![ - PathBuf::from("/usr/bin/firefox-bin"), - PathBuf::from("/usr/bin/firefox"), - PathBuf::from("/usr/local/bin/firefox-bin"), - PathBuf::from("/usr/local/bin/firefox"), - PathBuf::from("/snap/bin/firefox"), - PathBuf::from("/var/lib/flatpak/exports/bin/org.mozilla.firefox"), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - fn cli_from(args: &[&str]) -> Cli { - let mut full = vec!["rustywolf"]; - full.extend_from_slice(args); - Cli::try_parse_from(full).unwrap() - } - - #[test] - fn explicit_url_wins_over_handoff() { - let cli = cli_from(&["--url", "example.com", "--resume"]); - assert_eq!(startup_urls(&cli), vec!["https://example.com"]); - } - - #[test] - fn falls_back_to_home_without_handoff() { - let cli = cli_from(&[]); - assert_eq!(startup_urls(&cli), vec![util::DEFAULT_HOME]); - } - - /// Launches a real headless Firefox, then captures its tabs for handoff. - /// Run explicitly: `cargo test -- --ignored --nocapture` - #[test] - #[ignore = "launches a real headless Firefox"] - fn gecko_session_handoff_roundtrip() { - let Some(firefox) = locate_firefox(&Config::default()) else { - eprintln!("skipping: no Firefox found"); - return; - }; - let profile = - std::env::temp_dir().join(format!("rustywolf-gecko-test-{}", std::process::id())); - let _ = fs::remove_dir_all(&profile); - fs::create_dir_all(&profile).unwrap(); - prefs::write_user_js(&profile, "about:blank", None).unwrap(); - - let mut child = Command::new(&firefox) - .arg("--headless") - .arg("--no-remote") - .arg("--new-instance") - .arg("--profile") - .arg(&profile) - .arg("https://example.com") - .arg("https://example.org") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("Firefox should start"); - - std::thread::sleep(Duration::from_secs(10)); - terminate(&mut child); - - let captured = handoff::capture_firefox_session(&profile, false); - let _ = fs::remove_dir_all(&profile); - - let captured = captured.expect("session handoff should be captured"); - assert!( - !captured.tabs.is_empty(), - "expected captured tabs, got none" - ); - for tab in &captured.tabs { - eprintln!("captured tab: {}", tab.url); - } - } -} diff --git a/src/gecko/prefs.rs b/src/gecko/prefs.rs deleted file mode 100644 index 96d0c7b..0000000 --- a/src/gecko/prefs.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Managed Firefox profile: the `user.js` preference set. - -use anyhow::{Context, Result}; -use serde_json::Value; -use std::fs; -use std::path::Path; - -/// Gecko launches rewrite `user.js` so the profile stays RustyWolf-managed. -pub fn write_user_js(profile: &Path, homepage: &str, download_dir: Option<&Path>) -> Result<()> { - let mut prefs: Vec<(&str, Value)> = vec![ - // Tab handoff relies on a reasonably fresh session store, and handoff - // freshness is a lightness trade-off (2s writes, not the 15s default). - ("browser.sessionstore.interval", Value::from(2000)), - ("browser.sessionstore.resume_from_crash", Value::from(true)), - // Start on the RustyWolf control page; new tabs stay blank and light. - ("browser.startup.page", Value::from(1)), - ("browser.startup.homepage", Value::from(homepage)), - ( - "browser.startup.homepage_override.mstone", - Value::from("ignore"), - ), - ("startup.homepage_welcome_url", Value::from("")), - ("startup.homepage_welcome_url.additional", Value::from("")), - ("startup.homepage_override_url", Value::from("")), - ("browser.aboutwelcome.enabled", Value::from(false)), - ("browser.newtabpage.enabled", Value::from(false)), - ( - "browser.newtabpage.activity-stream.enabled", - Value::from(false), - ), - ( - "browser.newtabpage.activity-stream.showSponsored", - Value::from(false), - ), - ( - "browser.newtabpage.activity-stream.showSponsoredTopSites", - Value::from(false), - ), - ( - "browser.newtabpage.activity-stream.feeds.telemetry", - Value::from(false), - ), - ("browser.shell.checkDefaultBrowser", Value::from(false)), - ("browser.discovery.enabled", Value::from(false)), - ("browser.tabs.warnOnClose", Value::from(false)), - ("browser.tabs.warnOnCloseOtherTabs", Value::from(false)), - ("browser.warnOnQuit", Value::from(false)), - // Lightness: let Firefox unload stale background tabs under pressure. - ("browser.tabs.unloadOnLowMemory", Value::from(true)), - ( - "browser.tabs.min_inactive_duration_before_unload", - Value::from(600_000), - ), - // Downloads land in the system download folder without extra dialogs. - ("browser.download.useDownloadDir", Value::from(true)), - ("browser.download.alwaysOpenPanel", Value::from(false)), - // Privacy hardening: strict tracking protection, no telemetry, no sponsored content. - ("privacy.donottrackheader.enabled", Value::from(true)), - ("privacy.globalprivacycontrol.enabled", Value::from(true)), - ("browser.contentblocking.category", Value::from("strict")), - ("privacy.trackingprotection.enabled", Value::from(true)), - ( - "privacy.trackingprotection.pbmode.enabled", - Value::from(true), - ), - ( - "privacy.trackingprotection.socialtracking.enabled", - Value::from(true), - ), - ( - "privacy.trackingprotection.cryptomining.enabled", - Value::from(true), - ), - ( - "privacy.trackingprotection.fingerprinting.enabled", - Value::from(true), - ), - ( - "privacy.annotate_channels.strict_list.enabled", - Value::from(true), - ), - ("toolkit.telemetry.enabled", Value::from(false)), - ("toolkit.telemetry.unified", Value::from(false)), - ( - "toolkit.telemetry.shutdownPingSender.enabled", - Value::from(false), - ), - ( - "datareporting.healthreport.uploadEnabled", - Value::from(false), - ), - ( - "datareporting.policy.dataSubmissionEnabled", - Value::from(false), - ), - ("app.shield.optoutstudies.enabled", Value::from(false)), - ( - "browser.crashReports.unsubmittedCheck.autoSubmit2", - Value::from(false), - ), - // A quiet address bar: no sponsored suggestions, no search-suggestion calls. - ("browser.urlbar.quicksuggest.enabled", Value::from(false)), - ( - "browser.urlbar.suggest.quicksuggest.sponsored", - Value::from(false), - ), - ( - "browser.urlbar.suggest.quicksuggest.nonsponsored", - Value::from(false), - ), - ("browser.urlbar.suggest.topsites", Value::from(false)), - ("browser.search.suggest.enabled", Value::from(false)), - ("browser.urlbar.suggest.searches", Value::from(false)), - ]; - - if let Some(dir) = download_dir { - prefs.push(( - "browser.download.dir", - Value::from(dir.to_string_lossy().into_owned()), - )); - } - - let mut out = String::from("// Managed by RustyWolf - rewritten on every Gecko launch.\n"); - for (key, value) in prefs { - out.push_str("user_pref("); - out.push_str(&serde_json::to_string(key).unwrap()); - out.push_str(", "); - out.push_str(&value.to_string()); - out.push_str(");\n"); - } - - fs::write(profile.join("user.js"), out) - .with_context(|| format!("could not write {}/user.js", profile.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn writes_managed_prefs() { - let dir = std::env::temp_dir().join(format!("rustywolf-prefs-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - write_user_js( - &dir, - "http://127.0.0.1:4242/?token=deadbeef", - Some(Path::new("/home/user/Downloads")), - ) - .unwrap(); - let raw = std::fs::read_to_string(dir.join("user.js")).unwrap(); - assert!(raw.contains("Managed by RustyWolf")); - assert!( - raw.contains("\"browser.startup.homepage\", \"http://127.0.0.1:4242/?token=deadbeef\"") - ); - assert!(raw.contains("\"privacy.trackingprotection.enabled\", true")); - assert!(raw.contains("\"browser.download.dir\", \"/home/user/Downloads\"")); - assert!(raw.contains("\"browser.sessionstore.interval\", 2000")); - let _ = std::fs::remove_dir_all(dir); - } -} diff --git a/src/gecko/server.rs b/src/gecko/server.rs deleted file mode 100644 index b5a8e79..0000000 --- a/src/gecko/server.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Tiny loopback HTTP control server for Gecko mode. -//! -//! The RustyWolf home page (served from here) can ask the supervisor to -//! switch back to the WebKit engine. Everything is bound to 127.0.0.1 and -//! gated behind a per-launch random token. - -use anyhow::{Context, Result}; -use std::fs::File; -use std::io::{Read, Write}; -use std::net::{Shutdown, TcpListener, TcpStream}; -use std::time::Duration; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Request { - Home, - Switch, - Favicon, - NotFound, -} - -pub struct ControlServer { - listener: TcpListener, - pub port: u16, - pub token: String, -} - -pub struct Connection { - stream: TcpStream, - pub request: Request, -} - -impl ControlServer { - pub fn bind() -> Result { - let listener = TcpListener::bind(("127.0.0.1", 0)).context("could not bind 127.0.0.1")?; - listener.set_nonblocking(true)?; - let port = listener.local_addr()?.port(); - Ok(Self { - listener, - port, - token: random_token(), - }) - } - - /// Non-blocking: `None` means "no request right now". - pub fn poll(&self) -> Option { - let (stream, _) = self.listener.accept().ok()?; - let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); - let request = read_request(&stream, &self.token); - Some(Connection { stream, request }) - } -} - -impl Connection { - pub fn respond(&mut self, status: &str, content_type: &str, body: &[u8]) { - let header = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = self.stream.write_all(header.as_bytes()); - let _ = self.stream.write_all(body); - let _ = self.stream.flush(); - let _ = self.stream.shutdown(Shutdown::Write); - } - - pub fn respond_204(&mut self) { - let _ = self.stream.write_all( - b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", - ); - let _ = self.stream.flush(); - let _ = self.stream.shutdown(Shutdown::Write); - } -} - -fn read_request(stream: &TcpStream, expected_token: &str) -> Request { - let mut buf = [0u8; 4096]; - let mut used = 0usize; - let mut reader = match stream.try_clone() { - Ok(clone) => clone, - Err(_) => return Request::NotFound, - }; - - loop { - if used >= buf.len() { - break; - } - match reader.read(&mut buf[used..]) { - Ok(0) => break, - Ok(n) => { - used += n; - if buf[..used].windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - - let head = String::from_utf8_lossy(&buf[..used]); - let Some(first_line) = head.lines().next() else { - return Request::NotFound; - }; - let mut parts = first_line.split_whitespace(); - let method = parts.next().unwrap_or_default(); - let target = parts.next().unwrap_or_default(); - if method != "GET" { - return Request::NotFound; - } - - let (path, query) = match target.split_once('?') { - Some((path, query)) => (path, query), - None => (target, ""), - }; - - match path { - "/" => Request::Home, - "/favicon.ico" => Request::Favicon, - "/switch" => { - let token_ok = query - .split('&') - .filter_map(|pair| pair.split_once('=')) - .any(|(key, value)| key == "token" && value == expected_token); - if token_ok { - Request::Switch - } else { - Request::NotFound - } - } - _ => Request::NotFound, - } -} - -fn random_token() -> String { - let mut bytes = [0u8; 16]; - let from_urandom = File::open("/dev/urandom") - .and_then(|mut file| file.read_exact(&mut bytes)) - .is_ok(); - - if !from_urandom { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - std::process::id().hash(&mut hasher); - std::thread::current().id().hash(&mut hasher); - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - .hash(&mut hasher); - let a = hasher.finish().to_le_bytes(); - bytes[..8].copy_from_slice(&a); - std::process::id().rotate_left(7).hash(&mut hasher); - let b = hasher.finish().to_le_bytes(); - bytes[8..].copy_from_slice(&b); - } - - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tokens_are_hex_and_unique() { - let a = random_token(); - let b = random_token(); - assert_eq!(a.len(), 32); - assert!(a.chars().all(|c| c.is_ascii_hexdigit())); - assert_ne!(a, b); - } -} diff --git a/src/handoff.rs b/src/handoff.rs deleted file mode 100644 index 357baa4..0000000 --- a/src/handoff.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! Engine handoff: pass the open tab set between WebKit and Gecko mode, -//! plus Firefox session-store reading (mozlz4 = magic + u32 size + LZ4 block). -//! -//! No external decompression dependency: the session file format is decoded -//! with a small in-tree LZ4 block decompressor (see `lz4_block_decompress`). - -use crate::config; -use crate::util::now_unix; -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::Path; - -/// Only hand over this many tabs at most (keeps the switch snappy and light). -pub const MAX_HANDOFF_TABS: usize = 32; -/// A handoff older than this is ignored. -pub const MAX_HANDOFF_AGE_SECS: u64 = 600; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HandoffTab { - pub url: String, - #[serde(default)] - pub title: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Handoff { - pub created_at: u64, - #[serde(default)] - pub private: bool, - #[serde(default)] - pub active: usize, - #[serde(default)] - pub tabs: Vec, -} - -impl Handoff { - pub fn new(private: bool, mut active: usize, mut tabs: Vec) -> Self { - tabs.truncate(MAX_HANDOFF_TABS); - if active >= tabs.len() { - active = tabs.len().saturating_sub(1); - } - Self { - created_at: now_unix(), - private, - active, - tabs, - } - } -} - -pub fn save(handoff: &Handoff) -> Result<()> { - save_to(&config::handoff_path(), handoff) -} - -pub fn save_to(path: &Path, handoff: &Handoff) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("could not create {}", parent.display()))?; - } - let raw = serde_json::to_string(handoff)?; - fs::write(path, raw).with_context(|| format!("could not write {}", path.display()))?; - Ok(()) -} - -/// Load and consume a fresh handoff (the file is removed either way). -pub fn take_fresh() -> Option { - take_fresh_from(&config::handoff_path(), MAX_HANDOFF_AGE_SECS) -} - -pub(crate) fn take_fresh_from(path: &Path, max_age_secs: u64) -> Option { - let raw = fs::read_to_string(path).ok()?; - let _ = fs::remove_file(path); - let handoff: Handoff = serde_json::from_str(&raw).ok()?; - let now = now_unix(); - if handoff.created_at > now || now.saturating_sub(handoff.created_at) > max_age_secs { - return None; - } - if handoff.tabs.is_empty() { - return None; - } - Some(handoff) -} - -/// Snapshot the tabs of a (possibly still running) Firefox profile. -pub fn capture_firefox_session(profile_dir: &Path, private: bool) -> Option { - let candidates = [ - profile_dir - .join("sessionstore-backups") - .join("recovery.jsonlz4"), - profile_dir.join("sessionstore.jsonlz4"), - ]; - for candidate in candidates { - let Ok(raw) = fs::read(&candidate) else { - continue; - }; - let Ok(json) = decode_mozlz4(&raw) else { - continue; - }; - let Ok(doc) = serde_json::from_slice::(&json) else { - continue; - }; - let (tabs, active) = extract_tabs(&doc); - if tabs.is_empty() { - continue; - } - return Some(Handoff::new(private, active, tabs)); - } - None -} - -/// Pull (url, title) pairs out of a decoded session-store document. -pub(crate) fn extract_tabs(doc: &serde_json::Value) -> (Vec, usize) { - let mut tabs = Vec::new(); - let mut active = 0usize; - let selected_window = doc["selectedWindow"].as_u64().unwrap_or(1); - - let Some(windows) = doc["windows"].as_array() else { - return (tabs, active); - }; - - for (wi, window) in windows.iter().enumerate() { - let window_selected = window["selected"].as_u64().unwrap_or(1); - let Some(tab_list) = window["tabs"].as_array() else { - continue; - }; - for (ti, tab) in tab_list.iter().enumerate() { - let entries = tab["entries"].as_array(); - let index = tab["index"].as_u64().unwrap_or(0) as usize; // 1-based - let current = entries.and_then(|list| { - let idx = index.saturating_sub(1); - list.get(idx).or_else(|| list.last()) - }); - let Some(current) = current else { - continue; - }; - let url = current["url"].as_str().unwrap_or_default().trim(); - if !usable_handoff_url(url) { - continue; - } - if tabs.len() >= MAX_HANDOFF_TABS { - break; - } - let title = tab["title"] - .as_str() - .or_else(|| current["title"].as_str()) - .unwrap_or_default() - .to_string(); - if wi as u64 + 1 == selected_window && ti as u64 + 1 == window_selected { - active = tabs.len(); - } - tabs.push(HandoffTab { - url: url.to_string(), - title, - }); - } - } - - (tabs, active) -} - -fn usable_handoff_url(url: &str) -> bool { - if url.is_empty() || url.eq_ignore_ascii_case("about:blank") { - return false; - } - let lower = url.to_ascii_lowercase(); - if lower.starts_with("about:") - || lower.starts_with("chrome:") - || lower.starts_with("javascript:") - || lower.starts_with("data:") - { - return false; - } - // The RustyWolf control page of a previous Gecko session. - if lower.starts_with("http://127.0.0.1:") && url.contains("/?token=") { - return false; - } - lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://") -} - -// ---------- mozlz4 ------------------------------------------------------ - -const MOZLZ4_MAGIC: &[u8; 8] = b"mozLz40\0"; - -pub(crate) fn decode_mozlz4(bytes: &[u8]) -> Result> { - if bytes.len() < 12 || &bytes[..8] != MOZLZ4_MAGIC { - bail!("not a mozlz4 file"); - } - let declared = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize; - let decoded = lz4_block_decompress(&bytes[12..])?; - if decoded.len() != declared { - bail!( - "mozlz4 size mismatch: header says {declared}, decoded {}", - decoded.len() - ); - } - Ok(decoded) -} - -/// Plain LZ4 block decompression (the codec behind "mozLz4"). -pub(crate) fn lz4_block_decompress(src: &[u8]) -> Result> { - let mut out: Vec = Vec::new(); - let mut pos = 0usize; - - while pos < src.len() { - let token = src[pos]; - pos += 1; - - let mut literal_len = (token >> 4) as usize; - if literal_len == 15 { - loop { - let extra = *src.get(pos).context("lz4: truncated literal length")?; - pos += 1; - literal_len += extra as usize; - if extra != 255 { - break; - } - } - } - if pos + literal_len > src.len() { - bail!("lz4: truncated literals"); - } - out.extend_from_slice(&src[pos..pos + literal_len]); - pos += literal_len; - - if pos >= src.len() { - break; - } - - if pos + 2 > src.len() { - bail!("lz4: truncated match offset"); - } - let offset = u16::from_le_bytes([src[pos], src[pos + 1]]) as usize; - pos += 2; - if offset == 0 || offset > out.len() { - bail!("lz4: match offset out of range"); - } - - let mut match_len = (token & 0x0f) as usize; - if match_len == 15 { - loop { - let extra = *src.get(pos).context("lz4: truncated match length")?; - pos += 1; - match_len += extra as usize; - if extra != 255 { - break; - } - } - } - match_len += 4; - - let start = out.len() - offset; - for i in 0..match_len { - let byte = out[start + i]; - out.push(byte); - } - } - - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lz4_decodes_literals_only() { - // token 0x30 => 3 literal bytes, no match section - let src = [0x30, b'a', b'b', b'c']; - assert_eq!(lz4_block_decompress(&src).unwrap(), b"abc"); - } - - #[test] - fn lz4_decodes_backreferences() { - // 4 literals "abcd", then offset 4, length 4 => "abcdabcd"; then 1 literal "x" - let src = [0x40, b'a', b'b', b'c', b'd', 0x04, 0x00, 0x10, b'x']; - assert_eq!(lz4_block_decompress(&src).unwrap(), b"abcdabcdx"); - } - - #[test] - fn decodes_real_firefox_session_fixture() { - let raw = include_bytes!("testdata/recovery.jsonlz4"); - let json = decode_mozlz4(raw).expect("fixture must decode"); - let doc: serde_json::Value = serde_json::from_slice(&json).expect("fixture must be JSON"); - let (tabs, active) = extract_tabs(&doc); - assert_eq!(tabs.len(), 2); - assert_eq!(tabs[0].url, "https://example.com/"); - assert_eq!(tabs[1].url, "https://example.org/"); - // Firefox's `selected` is 1-based; the first tab was focused. - assert_eq!(active, 0); - } - - #[test] - fn rejects_unknown_container() { - assert!(decode_mozlz4(b"not a mozlz4 file at all").is_err()); - } - - #[test] - fn handoff_roundtrip_and_freshness() { - let dir = std::env::temp_dir().join(format!("rustywolf-test-{}", std::process::id())); - let path = dir.join("handoff.json"); - let handoff = Handoff::new( - false, - 1, - vec![ - HandoffTab { - url: "https://example.com/".into(), - title: "Example".into(), - }, - HandoffTab { - url: "https://example.org/".into(), - title: "".into(), - }, - ], - ); - save_to(&path, &handoff).unwrap(); - - let loaded = take_fresh_from(&path, 60).expect("fresh handoff"); - assert_eq!(loaded.tabs.len(), 2); - assert_eq!(loaded.active, 1); - assert!(!path.exists(), "handoff is consumed on read"); - - // Stale handoffs are dropped. - let stale = Handoff { - created_at: now_unix().saturating_sub(9999), - ..handoff - }; - save_to(&path, &stale).unwrap(); - assert!(take_fresh_from(&path, 60).is_none()); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn skips_unusable_session_urls() { - let doc = serde_json::json!({ - "selectedWindow": 1, - "windows": [{ - "selected": 2, - "tabs": [ - {"index": 1, "entries": [{"url": "about:newtab"}]}, - {"index": 1, "entries": [{"url": "https://ok.example/"}]}, - ], - }], - }); - let (tabs, active) = extract_tabs(&doc); - assert_eq!(tabs.len(), 1); - assert_eq!(tabs[0].url, "https://ok.example/"); - assert_eq!(active, 0); - } -} diff --git a/src/main.rs b/src/main.rs index 7396308..4a2b658 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,8 @@ -//! RustyWolf entry point: load config, pick the engine, go. - +//main.rs mod cli; -mod config; -mod gecko; -mod handoff; -mod switch; -mod util; -mod webkit; +mod webview; use anyhow::Result; -use cli::{Cli, Engine}; -use std::path::PathBuf; fn main() { if let Err(err) = run() { @@ -20,63 +12,6 @@ fn main() { } fn run() -> Result<()> { - let cli = Cli::parse_args(); - let mut cfg = config::load(); - - if cli.dry_run { - let engine = cli.engine.unwrap_or(cfg.engine); - print_dry_run(&cli, &cfg, engine); - return Ok(()); - } - - if let Some(engine) = cli.engine - && cfg.engine != engine - { - cfg.engine = engine; - config::save(&cfg)?; - } - if let Some(firefox) = &cli.firefox { - cfg.firefox_bin = Some(firefox.clone()); - config::save(&cfg)?; - } - - match cli.engine.unwrap_or(cfg.engine) { - Engine::Webkit => webkit::launch(&cli, &mut cfg), - Engine::Gecko => gecko::launch(&cli, &mut cfg), - } -} - -fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) { - let url = cli - .url - .as_deref() - .map(util::normalize_url) - .unwrap_or_else(|| util::DEFAULT_HOME.to_string()); - - match engine { - Engine::Webkit => { - let effective_dmabuf = - std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string()); - println!( - "engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}", - cli.private, - cfg.adblock, - if cli.ua_chrome { "chrome" } else { "native" }, - cli.linux_backend, - cli.linux_disable_dmabuf, - ); - } - Engine::Gecko => { - let firefox: Option = - cli.firefox.clone().or_else(|| gecko::locate_firefox(cfg)); - let firefox = firefox - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "not-found".to_string()); - println!( - "engine=gecko url={url} private={} firefox={firefox} profile={}", - cli.private, - config::gecko_profile_dir().display() - ); - } - } + let cli = cli::Cli::parse_args(); + webview::launch_webkit(&cli) } diff --git a/src/switch.rs b/src/switch.rs deleted file mode 100644 index 23b02d5..0000000 --- a/src/switch.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Engine switching: hand the tab set to the other engine and relaunch. - -use crate::cli::Engine; -use crate::config::{self, Config}; -use crate::handoff::{self, Handoff}; -use anyhow::{Context, Result}; -use std::process::{Command, Stdio}; - -/// Persist the handoff, relaunch RustyWolf in `target` mode, remember the choice. -pub fn switch_to( - target: Engine, - cfg: &mut Config, - handoff: Option<&Handoff>, - private: bool, -) -> Result<()> { - let has_handoff = match handoff { - Some(handoff) if !handoff.tabs.is_empty() => { - handoff::save(handoff)?; - true - } - _ => false, - }; - spawn_detached(target, has_handoff, private)?; - cfg.engine = target; - if let Err(err) = config::save(cfg) { - eprintln!("rustywolf: could not persist the engine choice: {err:#}"); - } - Ok(()) -} - -/// Start another RustyWolf process in the background (survives our exit). -pub fn spawn_detached(target: Engine, resume: bool, private: bool) -> Result<()> { - let exe = std::env::current_exe().context("could not locate the RustyWolf binary")?; - let mut cmd = Command::new(exe); - cmd.arg("--engine").arg(target.as_str()); - if resume { - cmd.arg("--resume"); - } - if private { - cmd.arg("--private"); - } - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - #[cfg(unix)] - unsafe { - use std::os::unix::process::CommandExt; - cmd.pre_exec(|| { - // New session so the switched process survives this one's exit. - libc::setsid(); - Ok(()) - }); - } - - cmd.spawn() - .with_context(|| format!("could not launch the {} engine", target.as_str()))?; - Ok(()) -} diff --git a/src/testdata/recovery.jsonlz4 b/src/testdata/recovery.jsonlz4 deleted file mode 100644 index 7cd7c26..0000000 Binary files a/src/testdata/recovery.jsonlz4 and /dev/null differ diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index e44fd65..0000000 --- a/src/util.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! Small shared helpers: URL normalization, filenames, timestamps. - -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub const DEFAULT_HOME: &str = "https://duckduckgo.com"; -pub const NEW_TAB_URL: &str = "https://duckduckgo.com"; -pub const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q="; - -/// Turn a URL-bar entry into a URL (search terms go to DuckDuckGo). -pub fn normalize_url(input: &str) -> String { - let trimmed = input.trim(); - if trimmed == "about:blank" { - return trimmed.to_string(); - } - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - return trimmed.to_string(); - } - if looks_like_address(trimmed) { - return format!("https://{trimmed}"); - } - let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect(); - format!("{SEARCH_URL_PREFIX}{encoded}") -} - -pub fn looks_like_address(value: &str) -> bool { - value.contains('.') - || value.contains(':') - || value.starts_with("localhost") - || value.starts_with("127.") - || value.starts_with('[') - || value.starts_with('/') -} - -/// New-window targets RustyWolf refuses to open as tabs/popups. -pub fn should_open_new_window_url(raw: &str) -> bool { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") { - return false; - } - let lower = trimmed.to_ascii_lowercase(); - if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("about:") - { - return false; - } - - let Ok(parsed) = url::Url::parse(trimmed) else { - return false; - }; - - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return false; - } - - // DuckDuckGo's in-page "open in a new window" helpers are not real links. - let host = parsed.host_str().unwrap_or_default(); - let path = parsed.path().to_ascii_lowercase(); - if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com")) - && (path == "/post3.html" || path == "/page3" || path == "/page3.html") - { - return false; - } - - true -} - -pub fn sanitize_filename(raw: &str) -> String { - let mut out = String::with_capacity(raw.len()); - for ch in raw.chars() { - let invalid = - matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control(); - out.push(if invalid { '_' } else { ch }); - } - let trimmed = out.trim_matches('.').trim(); - if trimmed.is_empty() { - "download.bin".to_string() - } else { - trimmed.to_string() - } -} - -pub fn filename_from_url(url: &str) -> String { - let raw = url::Url::parse(url) - .ok() - .and_then(|u| { - u.path_segments().and_then(|mut parts| { - parts - .next_back() - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(ToString::to_string) - }) - }) - .unwrap_or_else(|| "download.bin".to_string()); - - sanitize_filename(&raw) -} - -pub fn ensure_unique_download_path(path: PathBuf) -> PathBuf { - if !path.exists() { - return path; - } - - let parent = path - .parent() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("download") - .to_string(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .filter(|e| !e.is_empty()) - .map(ToString::to_string); - - for idx in 1..=9999 { - let file_name = match &ext { - Some(ext) => format!("{stem} ({idx}).{ext}"), - None => format!("{stem} ({idx})"), - }; - let candidate = parent.join(file_name); - if !candidate.exists() { - return candidate; - } - } - - parent.join(format!("{stem}-{}", now_unix())) -} - -pub fn path_to_string(path: &Path) -> String { - path.to_string_lossy().into_owned() -} - -pub fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Human timestamp used by history/downloads rows. -pub fn now_stamp() -> String { - now_unix().to_string() -} - -pub fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -/// RustyWolf's own generated pages (properties, etc). -pub fn is_internal_page(url: &str) -> bool { - url.starts_with("data:text/html;base64,") -} - -pub fn path_string_to_file_url(path: &str) -> String { - url::Url::from_file_path(Path::new(path)) - .map(|u| u.to_string()) - .unwrap_or_else(|_| format!("file://{path}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keeps_existing_scheme() { - assert_eq!(normalize_url("https://example.com"), "https://example.com"); - } - - #[test] - fn adds_https_for_domains() { - assert_eq!(normalize_url("example.com"), "https://example.com"); - } - - #[test] - fn keeps_about_blank() { - assert_eq!(normalize_url("about:blank"), "about:blank"); - } - - #[test] - fn converts_search_terms_to_duckduckgo_query() { - assert_eq!( - normalize_url("privacy browser"), - "https://duckduckgo.com/?q=privacy+browser" - ); - } - - #[test] - fn converts_single_token_to_duckduckgo_query() { - assert_eq!(normalize_url("rust"), "https://duckduckgo.com/?q=rust"); - } - - #[test] - fn trims_before_search() { - assert_eq!( - normalize_url(" rust webview "), - "https://duckduckgo.com/?q=rust+webview" - ); - } - - #[test] - fn rejects_non_web_new_window_targets() { - assert!(!should_open_new_window_url("")); - assert!(!should_open_new_window_url(" about:blank ")); - assert!(!should_open_new_window_url("javascript:alert(1)")); - assert!(!should_open_new_window_url("data:text/html,hi")); - } - - #[test] - fn rejects_duckduckgo_internal_popup_paths() { - assert!(!should_open_new_window_url( - "https://duckduckgo.com/post3.html" - )); - assert!(!should_open_new_window_url("https://duckduckgo.com/page3")); - assert!(!should_open_new_window_url( - "https://duckduckgo.com/page3.html" - )); - } - - #[test] - fn allows_regular_https_new_window_targets() { - assert!(should_open_new_window_url("https://example.com/")); - assert!(should_open_new_window_url("https://duckduckgo.com/?q=rust")); - assert!(should_open_new_window_url( - "https://accounts.google.com/o/oauth2/v2/auth" - )); - } - - #[test] - fn sanitizes_windows_unsafe_filename_chars() { - assert_eq!(sanitize_filename("report:2026?.pdf"), "report_2026_.pdf"); - } - - #[test] - fn falls_back_when_filename_becomes_empty() { - assert_eq!(sanitize_filename("..."), "download.bin"); - } -} diff --git a/src/webkit/chrome.html b/src/webkit/chrome.html deleted file mode 100644 index 2909bce..0000000 --- a/src/webkit/chrome.html +++ /dev/null @@ -1,360 +0,0 @@ - - - - - - - -
-
-
- - - - -
- -
- -
- - - diff --git a/src/webkit/js/adblock.js b/src/webkit/js/adblock.js deleted file mode 100644 index ebb45a8..0000000 --- a/src/webkit/js/adblock.js +++ /dev/null @@ -1,195 +0,0 @@ -(() => { - 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 deleted file mode 100644 index 6b5c87d..0000000 --- a/src/webkit/js/privacy.js +++ /dev/null @@ -1,27 +0,0 @@ -(() => { - 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 deleted file mode 100644 index 52fe84e..0000000 --- a/src/webkit/mod.rs +++ /dev/null @@ -1,1636 +0,0 @@ -//! 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 deleted file mode 100644 index a51c9a4..0000000 --- a/src/webkit/popup.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! 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 new file mode 100644 index 0000000..f682850 --- /dev/null +++ b/src/webview.rs @@ -0,0 +1,2261 @@ +//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"); + } +}