Complete engine rewrite - now featuring Gecko!
This commit is contained in:
parent
d6b604639d
commit
b1be74adb8
20 changed files with 4069 additions and 2309 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -7,7 +7,7 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "RustyWolf"
|
name = "RustyWolf"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -15,10 +15,13 @@ dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"gtk",
|
"gtk",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tao",
|
"tao",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
|
"webkit2gtk",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2215,6 +2218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
14
Cargo.toml
14
Cargo.toml
|
|
@ -1,24 +1,30 @@
|
||||||
#Cargo.toml
|
#Cargo.toml
|
||||||
[package]
|
[package]
|
||||||
name = "RustyWolf"
|
name = "RustyWolf"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
dirs = "6.0"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tao = "0.31"
|
tao = "0.31"
|
||||||
ureq = "2.12"
|
ureq = "2.12"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
wry = { version = "0.47", features = ["fullscreen"] }
|
wry = { version = "0.47", features = ["fullscreen"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
|
|
||||||
dirs = "6.0"
|
[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
|
webkit2gtk = { version = "2.0", features = ["v2_40"] }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[package.metadata.bundle]
|
[package.metadata.bundle]
|
||||||
name = "RustyWolf"
|
name = "RustyWolf"
|
||||||
identifier = "com.rustywolf.browser"
|
identifier = "com.rustywolf.browser"
|
||||||
icon = ["src/rustywolf.icns"]
|
icon = ["src/rustywolf.icns"]
|
||||||
|
|
||||||
|
|
|
||||||
85
README.md
85
README.md
|
|
@ -1,46 +1,68 @@
|
||||||
# RustyWolf
|
# RustyWolf
|
||||||
|
|
||||||
RustyWolf is a minimal, privacy-focused browser shell built with Rust + system webviews.
|
RustyWolf is a minimal, privacy-focused browser shell built in Rust with two engine modes:
|
||||||
|
|
||||||
|
- **WebKit mode (default, light)** — the system webview: `WebKitGTK` on Linux, `WKWebView` on macOS, `WebView2` on Windows. No browser install needed.
|
||||||
|
- **Gecko mode (opt-in)** — real Firefox (Gecko) engine through a RustyWolf-managed profile. Requires an installed Firefox.
|
||||||
|
|
||||||
|
Switch between them any time with the **leaf toggle** in the toolbar (or the leaf on the RustyWolf home page in Gecko mode). Open tabs carry over in both directions.
|
||||||
|
|
||||||
(complete honesty, this whole thing is vibecoded, however I never paid for any ai service (I got a free student plan for a year) so if you wanna avoid AI slop, just skip this but I never contributed to the AI BOOM with my money, if you wanna contribute, feel free to do so)
|
(complete honesty, this whole thing is vibecoded, however I never paid for any ai service (I got a free student plan for a year) so if you wanna avoid AI slop, just skip this but I never contributed to the AI BOOM with my money, if you wanna contribute, feel free to do so)
|
||||||
|
|
||||||
- macOS: native `WKWebView`
|
|
||||||
- Linux: `WebKitGTK`
|
|
||||||
- Windows: `WebView2`
|
|
||||||
|
|
||||||
No Firefox install. No extension system. Fast startup, clean UI.
|
|
||||||
|
|
||||||
<img
|
<img
|
||||||
alt="image"
|
alt="image"
|
||||||
src="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf/raw/branch/main/preview.png"
|
src="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf/raw/branch/main/preview.png"
|
||||||
style="width: 100%; max-width: 1920px; aspect-ratio: 18 / 9; object-fit: contain; height: auto;"
|
style="width: 100%; max-width: 1920px; aspect-ratio: 18 / 9; object-fit: contain; height: auto;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
## Engines
|
||||||
|
|
||||||
|
| | WebKit mode | Gecko mode |
|
||||||
|
|---|---|---|
|
||||||
|
| Engine | WebKitGTK / WKWebView / WebView2 | Firefox (Gecko), driven by RustyWolf |
|
||||||
|
| Weight | lightest — typically ~300 MB with a tab open | Firefox-weight |
|
||||||
|
| RustyWolf UI | full chrome (tabs, URL bar, ADS, K, D, Properties) | Firefox's own UI + RustyWolf home page |
|
||||||
|
| Adblock | built-in lightweight blocker (ADS toggle) | Firefox strict tracking protection |
|
||||||
|
| Extras | keep-in-ram tabs (`K`), popup gate, download panel, properties page | Firefox features, managed profile, tab handoff |
|
||||||
|
|
||||||
|
The leaf button shows the current engine: **lit green = WebKit**, **dim = Gecko**.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- Really, I mean really lightweight! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
|
- Really, I mean really lightweight in WebKit mode! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
|
||||||
- So light in fact in my gentoo(openrc)+i3wm setup it took only 285mb ram with one tab open (dvorakmortonxd.dev) compared to firefox which took 866mb ram
|
- in a gentoo(openrc)+i3wm setup it took only ~285 MB RAM with one tab open (dvorakmortonxd.dev) compared to firefox which took ~866 MB
|
||||||
- THATS 3.04 TIMES AS MUCH RAM, so yeah it is light
|
- tabs have a toggle for Keeping-in-ram (`K`): with it off the tab is unloaded, only the URL is kept, and it reloads on click
|
||||||
- tabs have a toggle for Keeping-in-ram (`K`), it basically just saves the url if its off and reloads it on reclick, if you toggle it on it keeps the whole page in ram.
|
- background tabs are suspended automatically, and the download ticker only runs while something is downloading
|
||||||
- Privacy-first defaults: DuckDuckGo home + search fallback from the URL bar
|
- Privacy-first defaults: DuckDuckGo home + search fallback from the URL bar
|
||||||
- Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`)
|
- Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`)
|
||||||
- Built-in custom adblock with top-bar toggle (`ADS`)
|
- Built-in custom adblock with top-bar toggle (`ADS`), persisted across restarts
|
||||||
- Popup permission gate: asks before a site opens a new tab
|
- Popup handling that behaves like a real browser: clicked links open tabs, clicked `window.open` popups (OAuth/"Sign in with Google", SoundCloud, ...) open real popup windows with `window.opener` intact, and scripted popups go through the permission gate
|
||||||
|
- Native user agent by default, so sites (and their login flows) see an honest browser; `--ua-chrome` is available for stubborn sites
|
||||||
- Download support with live progress panel (`D`) and session download history
|
- Download support with live progress panel (`D`) and session download history
|
||||||
- Properties page with session browsing history + download history
|
- Properties page with session browsing history + download history
|
||||||
- Default window size `1000x600` (still fully resizable)
|
- Default window size `1000x600` (still fully resizable)
|
||||||
- Optional `--private` mode when backend support is available
|
- Optional `--private` mode in both engines
|
||||||
|
- Engine switch with tab handoff: WebKit tabs become Firefox tabs and vice versa
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --
|
cargo run --
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
# pick a URL / window title
|
||||||
cargo run -- --url https://example.com
|
cargo run -- --url https://example.com
|
||||||
cargo run -- --url example.com
|
cargo run -- --url example.com
|
||||||
cargo run -- --title "RustyWolf"
|
cargo run -- --title "RustyWolf"
|
||||||
|
|
||||||
|
# engine selection (persisted in ~/.config/RustyWolf/config.json)
|
||||||
|
cargo run -- --engine webkit
|
||||||
|
cargo run -- --engine gecko
|
||||||
|
cargo run -- --engine gecko --firefox /path/to/firefox
|
||||||
|
|
||||||
|
# compatible user agent for picky sites (WebKit mode)
|
||||||
|
cargo run -- --ua-chrome
|
||||||
|
|
||||||
|
# misc
|
||||||
cargo run -- --private
|
cargo run -- --private
|
||||||
cargo run -- --dry-run
|
cargo run -- --dry-run
|
||||||
|
|
||||||
|
|
@ -48,18 +70,29 @@ cargo run -- --dry-run
|
||||||
cargo run -- --linux-backend x11
|
cargo run -- --linux-backend x11
|
||||||
cargo run -- --linux-backend wayland
|
cargo run -- --linux-backend wayland
|
||||||
cargo run -- --linux-disable-dmabuf
|
cargo run -- --linux-disable-dmabuf
|
||||||
cargo run -- --linux-backend x11 --linux-disable-dmabuf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The leaf button in the toolbar switches engines immediately; the choice is remembered for the next launch.
|
||||||
|
|
||||||
|
## Gecko Mode (real Firefox engine)
|
||||||
|
|
||||||
|
There is no embeddable Gecko in 2026, so Gecko mode supervises a real Firefox with a dedicated profile under `~/.config/RustyWolf/gecko-profile`:
|
||||||
|
|
||||||
|
- hardening prefs are rewritten on every launch (strict tracking protection, DNT/GPC, no telemetry, no sponsored content, no Pocket)
|
||||||
|
- downloads go straight to your Downloads folder
|
||||||
|
- new tabs stay blank; the RustyWolf home page is the start page and carries the leaf button back to WebKit
|
||||||
|
- `browser.sessionstore.interval` is shortened so RustyWolf can hand your tabs back to WebKit
|
||||||
|
- Firefox is launched with `--no-remote --new-instance`, so your normal Firefox session is never touched (`--firefox` / `RUSTYWOLF_FIREFOX` to point at a specific binary)
|
||||||
|
|
||||||
## Privacy Notes
|
## Privacy Notes
|
||||||
|
|
||||||
RustyWolf applies baseline hardening in page context:
|
WebKit mode applies baseline hardening in page context:
|
||||||
|
|
||||||
- `navigator.doNotTrack = "1"`
|
- `navigator.doNotTrack = "1"`
|
||||||
- disables legacy `openDatabase`
|
- disables legacy `openDatabase`
|
||||||
- disables `RTCPeerConnection`
|
- disables `RTCPeerConnection`
|
||||||
|
|
||||||
Adblocking is built-in and lightweight. It is not full uBlock Origin parity.
|
Adblocking is built-in and lightweight. It is not full uBlock Origin parity. Gecko mode uses Firefox's own strict tracking protection instead.
|
||||||
|
|
||||||
# Linux Setup (prerequisites)
|
# Linux Setup (prerequisites)
|
||||||
|
|
||||||
|
|
@ -96,6 +129,8 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
source "$HOME/.cargo/env"
|
source "$HOME/.cargo/env"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For Gecko mode just make sure Firefox is installed (`emerge www-client/firefox` or the binary release).
|
||||||
|
|
||||||
For NVIDIA, make the DRM modeset setting part of the boot command line, not only
|
For NVIDIA, make the DRM modeset setting part of the boot command line, not only
|
||||||
`/etc/modprobe.d`, then reboot:
|
`/etc/modprobe.d`, then reboot:
|
||||||
|
|
||||||
|
|
@ -179,6 +214,8 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from
|
||||||
/etc/xdg/gtk-3.0/settings.ini
|
/etc/xdg/gtk-3.0/settings.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If RustyWolf segfaults instantly with `Couldn't register with accessibility bus` when launched from a bare TTY/SSH shell, there is no session D-Bus for the GTK accessibility bridge (`libatk-bridge`). Launch it from your graphical session, or make `DBUS_SESSION_BUS_ADDRESS` available (on many systems `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus`).
|
||||||
|
|
||||||
# MacOS
|
# MacOS
|
||||||
|
|
||||||
## installing Homebrew (skip if already installed)
|
## installing Homebrew (skip if already installed)
|
||||||
|
|
@ -187,17 +224,25 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Gecko mode finds Firefox in `/Applications/Firefox.app` automatically; WebKit mode uses the system `WKWebView`.
|
||||||
|
|
||||||
# Windows Setup
|
# Windows Setup
|
||||||
|
|
||||||
Install the Microsoft Edge WebView2 Runtime (Evergreen), then build normally with Rust MSVC toolchain.
|
Install the Microsoft Edge WebView2 Runtime (Evergreen) for WebKit mode, then build normally with the Rust MSVC toolchain.
|
||||||
|
|
||||||
- WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/
|
- WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/
|
||||||
- Rust toolchain target: `x86_64-pc-windows-msvc`
|
- Rust toolchain target: `x86_64-pc-windows-msvc`
|
||||||
|
|
||||||
|
Gecko mode finds Firefox under `Program Files` / `%LOCALAPPDATA%\Mozilla Firefox`, or use `--firefox`.
|
||||||
|
|
||||||
## Dev Quick Checks
|
## Dev Quick Checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt
|
cargo fmt
|
||||||
cargo check
|
cargo check
|
||||||
cargo test
|
cargo test
|
||||||
|
cargo clippy --all-targets
|
||||||
|
|
||||||
|
# exercises the Gecko handoff against a real headless Firefox
|
||||||
|
cargo test -- --ignored --nocapture
|
||||||
```
|
```
|
||||||
|
|
|
||||||
78
src/cli.rs
78
src/cli.rs
|
|
@ -1,6 +1,30 @@
|
||||||
//cli.rs
|
//! Command line interface definitions.
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
|
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Browsing engine.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Engine {
|
||||||
|
/// Native system webview (WebKitGTK / WKWebView / WebView2). Lightest.
|
||||||
|
#[default]
|
||||||
|
Webkit,
|
||||||
|
/// Firefox (Gecko) through a RustyWolf-managed profile. Requires Firefox.
|
||||||
|
Gecko,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Engine::Webkit => "webkit",
|
||||||
|
Engine::Gecko => "gecko",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK windowing backend (Linux only).
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
pub enum LinuxBackend {
|
pub enum LinuxBackend {
|
||||||
Auto,
|
Auto,
|
||||||
|
|
@ -11,38 +35,50 @@ pub enum LinuxBackend {
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "rustywolf",
|
name = "rustywolf",
|
||||||
about = "Privacy-focused WebKit browser",
|
about = "Lightweight privacy browser (WebKit + Gecko engines)",
|
||||||
long_about = "RustyWolf launches an embedded WebKit browser window.",
|
long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.",
|
||||||
version
|
version
|
||||||
)]
|
)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[arg(
|
/// Startup URL (defaults to https://duckduckgo.com)
|
||||||
long,
|
#[arg(long)]
|
||||||
help = "Optional startup URL (defaults to https://duckduckgo.com)"
|
|
||||||
)]
|
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
|
||||||
#[arg(long, help = "Set a custom window title")]
|
/// Window title (WebKit mode)
|
||||||
|
#[arg(long)]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
|
||||||
#[arg(long, help = "Try private/incognito mode when backend supports it")]
|
/// Select the browsing engine (persisted for the next launch too)
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub engine: Option<Engine>,
|
||||||
|
|
||||||
|
/// Start in private/incognito mode
|
||||||
|
#[arg(long)]
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
|
|
||||||
#[arg(long, help = "Print launch settings without opening a window")]
|
/// Print launch settings without opening a window
|
||||||
|
#[arg(long)]
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
|
|
||||||
#[arg(
|
/// Firefox binary used by Gecko mode (persisted)
|
||||||
long,
|
#[arg(long, value_name = "PATH")]
|
||||||
value_enum,
|
pub firefox: Option<PathBuf>,
|
||||||
default_value_t = LinuxBackend::Auto,
|
|
||||||
help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
|
/// WebKit mode: send a Chrome-like user agent for sites that
|
||||||
)]
|
/// misbehave with the native WebKitGTK user agent
|
||||||
|
#[arg(long)]
|
||||||
|
pub ua_chrome: bool,
|
||||||
|
|
||||||
|
/// Internal: restore the tab set from the last engine handoff
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
pub resume: bool,
|
||||||
|
|
||||||
|
/// Linux only: select a GTK windowing backend (auto, x11, wayland)
|
||||||
|
#[arg(long, value_enum, default_value_t = LinuxBackend::Auto)]
|
||||||
pub linux_backend: LinuxBackend,
|
pub linux_backend: LinuxBackend,
|
||||||
|
|
||||||
#[arg(
|
/// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
|
||||||
long,
|
#[arg(long)]
|
||||||
help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
|
|
||||||
)]
|
|
||||||
pub linux_disable_dmabuf: bool,
|
pub linux_disable_dmabuf: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
88
src/config.rs
Normal file
88
src/config.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
//! Persisted user configuration (engine choice, adblock, Firefox path).
|
||||||
|
|
||||||
|
use crate::cli::Engine;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub engine: Engine,
|
||||||
|
pub adblock: bool,
|
||||||
|
pub firefox_bin: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.config/RustyWolf` (or the platform equivalent).
|
||||||
|
pub fn base_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("RustyWolf")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> PathBuf {
|
||||||
|
base_dir().join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handoff_path() -> PathBuf {
|
||||||
|
base_dir().join("handoff.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gecko_profile_dir() -> PathBuf {
|
||||||
|
base_dir().join("gecko-profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Config {
|
||||||
|
match fs::read_to_string(config_path()) {
|
||||||
|
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|err| {
|
||||||
|
eprintln!("rustywolf: ignoring malformed config: {err}");
|
||||||
|
Config::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Config::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(cfg: &Config) -> Result<()> {
|
||||||
|
let dir = base_dir();
|
||||||
|
fs::create_dir_all(&dir).with_context(|| format!("could not create {}", dir.display()))?;
|
||||||
|
let raw = serde_json::to_string_pretty(cfg)?;
|
||||||
|
fs::write(config_path(), raw).with_context(|| "could not write config.json")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_to_webkit_without_adblock() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.engine, Engine::Webkit);
|
||||||
|
assert!(!cfg.adblock);
|
||||||
|
assert!(cfg.firefox_bin.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_through_json() {
|
||||||
|
let cfg = Config {
|
||||||
|
engine: Engine::Gecko,
|
||||||
|
adblock: true,
|
||||||
|
firefox_bin: Some(PathBuf::from("/usr/bin/firefox-bin")),
|
||||||
|
};
|
||||||
|
let raw = serde_json::to_string(&cfg).unwrap();
|
||||||
|
let back: Config = serde_json::from_str(&raw).unwrap();
|
||||||
|
assert_eq!(back.engine, Engine::Gecko);
|
||||||
|
assert!(back.adblock);
|
||||||
|
assert_eq!(
|
||||||
|
back.firefox_bin.as_deref(),
|
||||||
|
Some(std::path::Path::new("/usr/bin/firefox-bin"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_missing_fields() {
|
||||||
|
let cfg: Config = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(cfg.engine, Engine::Webkit);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/gecko/home.html
Normal file
78
src/gecko/home.html
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>RustyWolf</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #101012; color: #e8e8ed;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
main { width: min(620px, 92vw); padding: 32px 0; }
|
||||||
|
h1 { font-size: 30px; letter-spacing: .5px; }
|
||||||
|
h1 span { color: #6fc16f; }
|
||||||
|
.sub { margin-top: 6px; font-size: 13px; opacity: .72; }
|
||||||
|
.engine { margin-top: 26px; display: flex; align-items: center; gap: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.04);
|
||||||
|
padding: 14px 16px; }
|
||||||
|
.leaf { width: 46px; height: 46px; display: inline-flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 24px; cursor: pointer; flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06); }
|
||||||
|
.leaf:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.leaf.gecko { filter: grayscale(1); opacity: .75; }
|
||||||
|
.engine-text { font-size: 13px; line-height: 1.55; }
|
||||||
|
.engine-text b { font-size: 14px; }
|
||||||
|
.muted { opacity: .7; }
|
||||||
|
form { margin-top: 22px; display: flex; gap: 8px; }
|
||||||
|
input[type="search"] { flex: 1; height: 40px; padding: 0 12px; font-size: 14px;
|
||||||
|
color: #f5f5f7; background: rgba(255,255,255,.07);
|
||||||
|
border: 1px solid rgba(255,255,255,.18); outline: none; }
|
||||||
|
input[type="search"]:focus { border-color: rgba(90,140,255,.7); background: rgba(255,255,255,.1); }
|
||||||
|
button.search { height: 40px; padding: 0 16px; font-size: 14px; color: #e8e8ed;
|
||||||
|
cursor: pointer; background: rgba(90,140,255,.7); border: 1px solid rgba(90,140,255,.5); }
|
||||||
|
.status { display: block; margin-top: 8px; font-size: 12px; min-height: 16px; color: #9fd49f; }
|
||||||
|
.notes { margin-top: 30px; font-size: 12px; line-height: 1.7; opacity: .68; }
|
||||||
|
.notes code { color: #d7d7e0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Rusty<span>Wolf</span></h1>
|
||||||
|
<p class="sub">Gecko mode — powered by Firefox, managed by RustyWolf.</p>
|
||||||
|
|
||||||
|
<div class="engine">
|
||||||
|
<button class="leaf gecko" id="leaf" title="Switch to the WebKit engine">🍃</button>
|
||||||
|
<div class="engine-text">
|
||||||
|
<b>Engine: Gecko (Firefox)</b><br>
|
||||||
|
<span class="muted">Click the leaf to switch back to the light WebKit engine.</span>
|
||||||
|
<span class="status" id="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="https://duckduckgo.com/" method="get">
|
||||||
|
<input type="search" name="q" placeholder="Search with DuckDuckGo" autofocus autocomplete="off">
|
||||||
|
<button class="search" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="notes">
|
||||||
|
This dedicated profile uses strict tracking protection, no telemetry, and downloads
|
||||||
|
straight to your Downloads folder. Your open tabs carry over when you switch engines.<br>
|
||||||
|
WebKit mode extras: the built-in adblock toggle (<code>ADS</code>), keep-in-ram tabs
|
||||||
|
(<code>K</code>), the popup gate and the properties page. In Gecko mode Firefox handles
|
||||||
|
browsing natively.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const leaf = document.getElementById('leaf');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
leaf.addEventListener('click', () => {
|
||||||
|
leaf.disabled = true;
|
||||||
|
status.textContent = 'Switching to WebKit\u2026 your tabs are being carried over.';
|
||||||
|
fetch('/switch?token={{TOKEN}}', { cache: 'no-store' })
|
||||||
|
.catch(() => { location.href = '/switch?token={{TOKEN}}'; });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
330
src/gecko/mod.rs
Normal file
330
src/gecko/mod.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
//! Gecko mode: supervise a RustyWolf-managed Firefox (real Gecko engine).
|
||||||
|
//!
|
||||||
|
//! There is no embeddable Gecko, so RustyWolf runs Firefox with a dedicated
|
||||||
|
//! profile and keeps the experience coherent:
|
||||||
|
//! - the profile carries RustyWolf hardening prefs and the control home page,
|
||||||
|
//! - a loopback control server lets the home page switch back to WebKit,
|
||||||
|
//! - the Firefox session store supplies the tab handoff to the WebKit engine.
|
||||||
|
//!
|
||||||
|
//! This supervisor is tiny (a listener, a child process handle, a poll loop),
|
||||||
|
//! keeping RustyWolf's own RAM/CPU footprint minimal even in Gecko mode.
|
||||||
|
|
||||||
|
mod prefs;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Engine};
|
||||||
|
use crate::config::{self, Config};
|
||||||
|
use crate::handoff;
|
||||||
|
use crate::switch;
|
||||||
|
use crate::util;
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use server::{ControlServer, Request};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const FAVICON: &[u8] = include_bytes!("../rustywolf.ico");
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(120);
|
||||||
|
const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> {
|
||||||
|
let firefox = resolve_firefox(cli, cfg)?;
|
||||||
|
let profile = config::gecko_profile_dir();
|
||||||
|
fs::create_dir_all(&profile)
|
||||||
|
.with_context(|| format!("could not create {}", profile.display()))?;
|
||||||
|
|
||||||
|
let server = ControlServer::bind()?;
|
||||||
|
let homepage = format!("http://127.0.0.1:{}/?token={}", server.port, server.token);
|
||||||
|
prefs::write_user_js(&profile, &homepage, dirs::download_dir().as_deref())?;
|
||||||
|
|
||||||
|
let urls = startup_urls(cli);
|
||||||
|
let mut child = spawn_firefox(&firefox, &profile, &urls, cli.private)?;
|
||||||
|
eprintln!(
|
||||||
|
"rustywolf: gecko engine (Firefox {}), profile {}",
|
||||||
|
firefox.display(),
|
||||||
|
profile.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let home_html = include_str!("home.html")
|
||||||
|
.replace("{{TOKEN}}", &server.token)
|
||||||
|
.into_bytes();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => return Ok(()), // user closed Firefox
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => return Err(err).context("lost track of the Firefox process"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mut conn) = server.poll() else {
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match conn.request {
|
||||||
|
Request::Home => {
|
||||||
|
conn.respond("200 OK", "text/html; charset=utf-8", &home_html);
|
||||||
|
}
|
||||||
|
Request::Favicon => {
|
||||||
|
conn.respond("200 OK", "image/x-icon", FAVICON);
|
||||||
|
}
|
||||||
|
Request::Switch => {
|
||||||
|
let captured = handoff::capture_firefox_session(&profile, cli.private);
|
||||||
|
match switch::switch_to(Engine::Webkit, cfg, captured.as_ref(), cli.private) {
|
||||||
|
Ok(()) => {
|
||||||
|
conn.respond_204();
|
||||||
|
terminate(&mut child);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let body = format!("RustyWolf could not switch engines: {err:#}");
|
||||||
|
conn.respond(
|
||||||
|
"500 Internal Server Error",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
body.as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::NotFound => {
|
||||||
|
conn.respond("404 Not Found", "text/plain; charset=utf-8", b"not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabs to open: explicit `--url` wins, then a pending handoff, then the home page.
|
||||||
|
fn startup_urls(cli: &Cli) -> Vec<String> {
|
||||||
|
if let Some(url) = &cli.url {
|
||||||
|
return vec![util::normalize_url(url)];
|
||||||
|
}
|
||||||
|
if cli.resume
|
||||||
|
&& let Some(handoff) = handoff::take_fresh()
|
||||||
|
{
|
||||||
|
let mut urls: Vec<String> = handoff.tabs.into_iter().map(|tab| tab.url).collect();
|
||||||
|
if !urls.is_empty() {
|
||||||
|
// Firefox selects the first URL argument; bring the previously
|
||||||
|
// active tab to the front, then the rest follows.
|
||||||
|
let active = handoff.active.min(urls.len() - 1);
|
||||||
|
urls.swap(0, active);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![util::DEFAULT_HOME.to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_firefox(
|
||||||
|
firefox: &PathBuf,
|
||||||
|
profile: &PathBuf,
|
||||||
|
urls: &[String],
|
||||||
|
private: bool,
|
||||||
|
) -> Result<Child> {
|
||||||
|
let mut cmd = Command::new(firefox);
|
||||||
|
cmd.arg("--no-remote")
|
||||||
|
.arg("--new-instance")
|
||||||
|
.arg("--profile")
|
||||||
|
.arg(profile);
|
||||||
|
if private {
|
||||||
|
// Private sessions are ephemeral; only the active URL comes along.
|
||||||
|
cmd.arg("--private-window").arg(&urls[0]);
|
||||||
|
} else {
|
||||||
|
for url in urls {
|
||||||
|
cmd.arg(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.env("MOZ_CRASHREPORTER_DISABLE", "1")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
|
cmd.spawn()
|
||||||
|
.with_context(|| format!("could not launch Firefox ({})", firefox.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask Firefox to quit, with a hard kill as a last resort.
|
||||||
|
fn terminate(child: &mut Child) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + SHUTDOWN_GRACE;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => return,
|
||||||
|
_ => std::thread::sleep(Duration::from_millis(50)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Firefox discovery -------------------------------------------
|
||||||
|
|
||||||
|
fn resolve_firefox(cli: &Cli, cfg: &mut Config) -> Result<PathBuf> {
|
||||||
|
if let Some(path) = &cli.firefox {
|
||||||
|
if path.is_file() {
|
||||||
|
cfg.firefox_bin = Some(path.clone());
|
||||||
|
return Ok(path.clone());
|
||||||
|
}
|
||||||
|
bail!("--firefox path does not exist: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(found) = locate_firefox(cfg) {
|
||||||
|
return Ok(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!(
|
||||||
|
"Firefox was not found. Install Firefox or point RustyWolf at it with --firefox /path/to/firefox"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort Firefox discovery (also used by `--dry-run`).
|
||||||
|
pub fn locate_firefox(cfg: &Config) -> Option<PathBuf> {
|
||||||
|
if let Some(path) = &cfg.firefox_bin {
|
||||||
|
if path.is_file() {
|
||||||
|
return Some(path.clone());
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"rustywolf: configured Firefox path no longer exists: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = std::env::var_os("RUSTYWOLF_FIREFOX") {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
if path.is_file() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(found) = find_in_path(&["firefox-bin", "firefox", "firefox-esr"]) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_candidates()
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_in_path(names: &[&str]) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
for dir in std::env::split_paths(&path) {
|
||||||
|
for name in names {
|
||||||
|
let candidate = dir.join(name);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn platform_candidates() -> Vec<PathBuf> {
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox"),
|
||||||
|
PathBuf::from("/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn platform_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for base in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
|
||||||
|
if let Some(root) = std::env::var_os(base) {
|
||||||
|
candidates.push(
|
||||||
|
PathBuf::from(root)
|
||||||
|
.join("Mozilla Firefox")
|
||||||
|
.join("firefox.exe"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
fn platform_candidates() -> Vec<PathBuf> {
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/usr/bin/firefox-bin"),
|
||||||
|
PathBuf::from("/usr/bin/firefox"),
|
||||||
|
PathBuf::from("/usr/local/bin/firefox-bin"),
|
||||||
|
PathBuf::from("/usr/local/bin/firefox"),
|
||||||
|
PathBuf::from("/snap/bin/firefox"),
|
||||||
|
PathBuf::from("/var/lib/flatpak/exports/bin/org.mozilla.firefox"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
fn cli_from(args: &[&str]) -> Cli {
|
||||||
|
let mut full = vec!["rustywolf"];
|
||||||
|
full.extend_from_slice(args);
|
||||||
|
Cli::try_parse_from(full).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_url_wins_over_handoff() {
|
||||||
|
let cli = cli_from(&["--url", "example.com", "--resume"]);
|
||||||
|
assert_eq!(startup_urls(&cli), vec!["https://example.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_home_without_handoff() {
|
||||||
|
let cli = cli_from(&[]);
|
||||||
|
assert_eq!(startup_urls(&cli), vec![util::DEFAULT_HOME]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launches a real headless Firefox, then captures its tabs for handoff.
|
||||||
|
/// Run explicitly: `cargo test -- --ignored --nocapture`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "launches a real headless Firefox"]
|
||||||
|
fn gecko_session_handoff_roundtrip() {
|
||||||
|
let Some(firefox) = locate_firefox(&Config::default()) else {
|
||||||
|
eprintln!("skipping: no Firefox found");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let profile =
|
||||||
|
std::env::temp_dir().join(format!("rustywolf-gecko-test-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&profile);
|
||||||
|
fs::create_dir_all(&profile).unwrap();
|
||||||
|
prefs::write_user_js(&profile, "about:blank", None).unwrap();
|
||||||
|
|
||||||
|
let mut child = Command::new(&firefox)
|
||||||
|
.arg("--headless")
|
||||||
|
.arg("--no-remote")
|
||||||
|
.arg("--new-instance")
|
||||||
|
.arg("--profile")
|
||||||
|
.arg(&profile)
|
||||||
|
.arg("https://example.com")
|
||||||
|
.arg("https://example.org")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.expect("Firefox should start");
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_secs(10));
|
||||||
|
terminate(&mut child);
|
||||||
|
|
||||||
|
let captured = handoff::capture_firefox_session(&profile, false);
|
||||||
|
let _ = fs::remove_dir_all(&profile);
|
||||||
|
|
||||||
|
let captured = captured.expect("session handoff should be captured");
|
||||||
|
assert!(
|
||||||
|
!captured.tabs.is_empty(),
|
||||||
|
"expected captured tabs, got none"
|
||||||
|
);
|
||||||
|
for tab in &captured.tabs {
|
||||||
|
eprintln!("captured tab: {}", tab.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/gecko/prefs.rs
Normal file
161
src/gecko/prefs.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! Managed Firefox profile: the `user.js` preference set.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Gecko launches rewrite `user.js` so the profile stays RustyWolf-managed.
|
||||||
|
pub fn write_user_js(profile: &Path, homepage: &str, download_dir: Option<&Path>) -> Result<()> {
|
||||||
|
let mut prefs: Vec<(&str, Value)> = vec![
|
||||||
|
// Tab handoff relies on a reasonably fresh session store, and handoff
|
||||||
|
// freshness is a lightness trade-off (2s writes, not the 15s default).
|
||||||
|
("browser.sessionstore.interval", Value::from(2000)),
|
||||||
|
("browser.sessionstore.resume_from_crash", Value::from(true)),
|
||||||
|
// Start on the RustyWolf control page; new tabs stay blank and light.
|
||||||
|
("browser.startup.page", Value::from(1)),
|
||||||
|
("browser.startup.homepage", Value::from(homepage)),
|
||||||
|
(
|
||||||
|
"browser.startup.homepage_override.mstone",
|
||||||
|
Value::from("ignore"),
|
||||||
|
),
|
||||||
|
("startup.homepage_welcome_url", Value::from("")),
|
||||||
|
("startup.homepage_welcome_url.additional", Value::from("")),
|
||||||
|
("startup.homepage_override_url", Value::from("")),
|
||||||
|
("browser.aboutwelcome.enabled", Value::from(false)),
|
||||||
|
("browser.newtabpage.enabled", Value::from(false)),
|
||||||
|
(
|
||||||
|
"browser.newtabpage.activity-stream.enabled",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"browser.newtabpage.activity-stream.showSponsored",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"browser.newtabpage.activity-stream.showSponsoredTopSites",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"browser.newtabpage.activity-stream.feeds.telemetry",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
("browser.shell.checkDefaultBrowser", Value::from(false)),
|
||||||
|
("browser.discovery.enabled", Value::from(false)),
|
||||||
|
("browser.tabs.warnOnClose", Value::from(false)),
|
||||||
|
("browser.tabs.warnOnCloseOtherTabs", Value::from(false)),
|
||||||
|
("browser.warnOnQuit", Value::from(false)),
|
||||||
|
// Lightness: let Firefox unload stale background tabs under pressure.
|
||||||
|
("browser.tabs.unloadOnLowMemory", Value::from(true)),
|
||||||
|
(
|
||||||
|
"browser.tabs.min_inactive_duration_before_unload",
|
||||||
|
Value::from(600_000),
|
||||||
|
),
|
||||||
|
// Downloads land in the system download folder without extra dialogs.
|
||||||
|
("browser.download.useDownloadDir", Value::from(true)),
|
||||||
|
("browser.download.alwaysOpenPanel", Value::from(false)),
|
||||||
|
// Privacy hardening: strict tracking protection, no telemetry, no sponsored content.
|
||||||
|
("privacy.donottrackheader.enabled", Value::from(true)),
|
||||||
|
("privacy.globalprivacycontrol.enabled", Value::from(true)),
|
||||||
|
("browser.contentblocking.category", Value::from("strict")),
|
||||||
|
("privacy.trackingprotection.enabled", Value::from(true)),
|
||||||
|
(
|
||||||
|
"privacy.trackingprotection.pbmode.enabled",
|
||||||
|
Value::from(true),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"privacy.trackingprotection.socialtracking.enabled",
|
||||||
|
Value::from(true),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"privacy.trackingprotection.cryptomining.enabled",
|
||||||
|
Value::from(true),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"privacy.trackingprotection.fingerprinting.enabled",
|
||||||
|
Value::from(true),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"privacy.annotate_channels.strict_list.enabled",
|
||||||
|
Value::from(true),
|
||||||
|
),
|
||||||
|
("toolkit.telemetry.enabled", Value::from(false)),
|
||||||
|
("toolkit.telemetry.unified", Value::from(false)),
|
||||||
|
(
|
||||||
|
"toolkit.telemetry.shutdownPingSender.enabled",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"datareporting.healthreport.uploadEnabled",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"datareporting.policy.dataSubmissionEnabled",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
("app.shield.optoutstudies.enabled", Value::from(false)),
|
||||||
|
(
|
||||||
|
"browser.crashReports.unsubmittedCheck.autoSubmit2",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
// A quiet address bar: no sponsored suggestions, no search-suggestion calls.
|
||||||
|
("browser.urlbar.quicksuggest.enabled", Value::from(false)),
|
||||||
|
(
|
||||||
|
"browser.urlbar.suggest.quicksuggest.sponsored",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"browser.urlbar.suggest.quicksuggest.nonsponsored",
|
||||||
|
Value::from(false),
|
||||||
|
),
|
||||||
|
("browser.urlbar.suggest.topsites", Value::from(false)),
|
||||||
|
("browser.search.suggest.enabled", Value::from(false)),
|
||||||
|
("browser.urlbar.suggest.searches", Value::from(false)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(dir) = download_dir {
|
||||||
|
prefs.push((
|
||||||
|
"browser.download.dir",
|
||||||
|
Value::from(dir.to_string_lossy().into_owned()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::from("// Managed by RustyWolf - rewritten on every Gecko launch.\n");
|
||||||
|
for (key, value) in prefs {
|
||||||
|
out.push_str("user_pref(");
|
||||||
|
out.push_str(&serde_json::to_string(key).unwrap());
|
||||||
|
out.push_str(", ");
|
||||||
|
out.push_str(&value.to_string());
|
||||||
|
out.push_str(");\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(profile.join("user.js"), out)
|
||||||
|
.with_context(|| format!("could not write {}/user.js", profile.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_managed_prefs() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("rustywolf-prefs-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
write_user_js(
|
||||||
|
&dir,
|
||||||
|
"http://127.0.0.1:4242/?token=deadbeef",
|
||||||
|
Some(Path::new("/home/user/Downloads")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let raw = std::fs::read_to_string(dir.join("user.js")).unwrap();
|
||||||
|
assert!(raw.contains("Managed by RustyWolf"));
|
||||||
|
assert!(
|
||||||
|
raw.contains("\"browser.startup.homepage\", \"http://127.0.0.1:4242/?token=deadbeef\"")
|
||||||
|
);
|
||||||
|
assert!(raw.contains("\"privacy.trackingprotection.enabled\", true"));
|
||||||
|
assert!(raw.contains("\"browser.download.dir\", \"/home/user/Downloads\""));
|
||||||
|
assert!(raw.contains("\"browser.sessionstore.interval\", 2000"));
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/gecko/server.rs
Normal file
170
src/gecko/server.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
//! Tiny loopback HTTP control server for Gecko mode.
|
||||||
|
//!
|
||||||
|
//! The RustyWolf home page (served from here) can ask the supervisor to
|
||||||
|
//! switch back to the WebKit engine. Everything is bound to 127.0.0.1 and
|
||||||
|
//! gated behind a per-launch random token.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Request {
|
||||||
|
Home,
|
||||||
|
Switch,
|
||||||
|
Favicon,
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ControlServer {
|
||||||
|
listener: TcpListener,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Connection {
|
||||||
|
stream: TcpStream,
|
||||||
|
pub request: Request,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlServer {
|
||||||
|
pub fn bind() -> Result<Self> {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).context("could not bind 127.0.0.1")?;
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
Ok(Self {
|
||||||
|
listener,
|
||||||
|
port,
|
||||||
|
token: random_token(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking: `None` means "no request right now".
|
||||||
|
pub fn poll(&self) -> Option<Connection> {
|
||||||
|
let (stream, _) = self.listener.accept().ok()?;
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
|
||||||
|
let request = read_request(&stream, &self.token);
|
||||||
|
Some(Connection { stream, request })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
pub fn respond(&mut self, status: &str, content_type: &str, body: &[u8]) {
|
||||||
|
let header = format!(
|
||||||
|
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = self.stream.write_all(header.as_bytes());
|
||||||
|
let _ = self.stream.write_all(body);
|
||||||
|
let _ = self.stream.flush();
|
||||||
|
let _ = self.stream.shutdown(Shutdown::Write);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn respond_204(&mut self) {
|
||||||
|
let _ = self.stream.write_all(
|
||||||
|
b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
let _ = self.stream.flush();
|
||||||
|
let _ = self.stream.shutdown(Shutdown::Write);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_request(stream: &TcpStream, expected_token: &str) -> Request {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let mut used = 0usize;
|
||||||
|
let mut reader = match stream.try_clone() {
|
||||||
|
Ok(clone) => clone,
|
||||||
|
Err(_) => return Request::NotFound,
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if used >= buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match reader.read(&mut buf[used..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
used += n;
|
||||||
|
if buf[..used].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = String::from_utf8_lossy(&buf[..used]);
|
||||||
|
let Some(first_line) = head.lines().next() else {
|
||||||
|
return Request::NotFound;
|
||||||
|
};
|
||||||
|
let mut parts = first_line.split_whitespace();
|
||||||
|
let method = parts.next().unwrap_or_default();
|
||||||
|
let target = parts.next().unwrap_or_default();
|
||||||
|
if method != "GET" {
|
||||||
|
return Request::NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, query) = match target.split_once('?') {
|
||||||
|
Some((path, query)) => (path, query),
|
||||||
|
None => (target, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
match path {
|
||||||
|
"/" => Request::Home,
|
||||||
|
"/favicon.ico" => Request::Favicon,
|
||||||
|
"/switch" => {
|
||||||
|
let token_ok = query
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|pair| pair.split_once('='))
|
||||||
|
.any(|(key, value)| key == "token" && value == expected_token);
|
||||||
|
if token_ok {
|
||||||
|
Request::Switch
|
||||||
|
} else {
|
||||||
|
Request::NotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Request::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
let from_urandom = File::open("/dev/urandom")
|
||||||
|
.and_then(|mut file| file.read_exact(&mut bytes))
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
if !from_urandom {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
std::process::id().hash(&mut hasher);
|
||||||
|
std::thread::current().id().hash(&mut hasher);
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.hash(&mut hasher);
|
||||||
|
let a = hasher.finish().to_le_bytes();
|
||||||
|
bytes[..8].copy_from_slice(&a);
|
||||||
|
std::process::id().rotate_left(7).hash(&mut hasher);
|
||||||
|
let b = hasher.finish().to_le_bytes();
|
||||||
|
bytes[8..].copy_from_slice(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_are_hex_and_unique() {
|
||||||
|
let a = random_token();
|
||||||
|
let b = random_token();
|
||||||
|
assert_eq!(a.len(), 32);
|
||||||
|
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/handoff.rs
Normal file
350
src/handoff.rs
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
//! Engine handoff: pass the open tab set between WebKit and Gecko mode,
|
||||||
|
//! plus Firefox session-store reading (mozlz4 = magic + u32 size + LZ4 block).
|
||||||
|
//!
|
||||||
|
//! No external decompression dependency: the session file format is decoded
|
||||||
|
//! with a small in-tree LZ4 block decompressor (see `lz4_block_decompress`).
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::util::now_unix;
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Only hand over this many tabs at most (keeps the switch snappy and light).
|
||||||
|
pub const MAX_HANDOFF_TABS: usize = 32;
|
||||||
|
/// A handoff older than this is ignored.
|
||||||
|
pub const MAX_HANDOFF_AGE_SECS: u64 = 600;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HandoffTab {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Handoff {
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub private: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tabs: Vec<HandoffTab>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handoff {
|
||||||
|
pub fn new(private: bool, mut active: usize, mut tabs: Vec<HandoffTab>) -> Self {
|
||||||
|
tabs.truncate(MAX_HANDOFF_TABS);
|
||||||
|
if active >= tabs.len() {
|
||||||
|
active = tabs.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
created_at: now_unix(),
|
||||||
|
private,
|
||||||
|
active,
|
||||||
|
tabs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(handoff: &Handoff) -> Result<()> {
|
||||||
|
save_to(&config::handoff_path(), handoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_to(path: &Path, handoff: &Handoff) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("could not create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let raw = serde_json::to_string(handoff)?;
|
||||||
|
fs::write(path, raw).with_context(|| format!("could not write {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and consume a fresh handoff (the file is removed either way).
|
||||||
|
pub fn take_fresh() -> Option<Handoff> {
|
||||||
|
take_fresh_from(&config::handoff_path(), MAX_HANDOFF_AGE_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_fresh_from(path: &Path, max_age_secs: u64) -> Option<Handoff> {
|
||||||
|
let raw = fs::read_to_string(path).ok()?;
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
let handoff: Handoff = serde_json::from_str(&raw).ok()?;
|
||||||
|
let now = now_unix();
|
||||||
|
if handoff.created_at > now || now.saturating_sub(handoff.created_at) > max_age_secs {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if handoff.tabs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(handoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the tabs of a (possibly still running) Firefox profile.
|
||||||
|
pub fn capture_firefox_session(profile_dir: &Path, private: bool) -> Option<Handoff> {
|
||||||
|
let candidates = [
|
||||||
|
profile_dir
|
||||||
|
.join("sessionstore-backups")
|
||||||
|
.join("recovery.jsonlz4"),
|
||||||
|
profile_dir.join("sessionstore.jsonlz4"),
|
||||||
|
];
|
||||||
|
for candidate in candidates {
|
||||||
|
let Ok(raw) = fs::read(&candidate) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(json) = decode_mozlz4(&raw) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&json) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (tabs, active) = extract_tabs(&doc);
|
||||||
|
if tabs.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some(Handoff::new(private, active, tabs));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull (url, title) pairs out of a decoded session-store document.
|
||||||
|
pub(crate) fn extract_tabs(doc: &serde_json::Value) -> (Vec<HandoffTab>, usize) {
|
||||||
|
let mut tabs = Vec::new();
|
||||||
|
let mut active = 0usize;
|
||||||
|
let selected_window = doc["selectedWindow"].as_u64().unwrap_or(1);
|
||||||
|
|
||||||
|
let Some(windows) = doc["windows"].as_array() else {
|
||||||
|
return (tabs, active);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (wi, window) in windows.iter().enumerate() {
|
||||||
|
let window_selected = window["selected"].as_u64().unwrap_or(1);
|
||||||
|
let Some(tab_list) = window["tabs"].as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (ti, tab) in tab_list.iter().enumerate() {
|
||||||
|
let entries = tab["entries"].as_array();
|
||||||
|
let index = tab["index"].as_u64().unwrap_or(0) as usize; // 1-based
|
||||||
|
let current = entries.and_then(|list| {
|
||||||
|
let idx = index.saturating_sub(1);
|
||||||
|
list.get(idx).or_else(|| list.last())
|
||||||
|
});
|
||||||
|
let Some(current) = current else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let url = current["url"].as_str().unwrap_or_default().trim();
|
||||||
|
if !usable_handoff_url(url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if tabs.len() >= MAX_HANDOFF_TABS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let title = tab["title"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| current["title"].as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
if wi as u64 + 1 == selected_window && ti as u64 + 1 == window_selected {
|
||||||
|
active = tabs.len();
|
||||||
|
}
|
||||||
|
tabs.push(HandoffTab {
|
||||||
|
url: url.to_string(),
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(tabs, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usable_handoff_url(url: &str) -> bool {
|
||||||
|
if url.is_empty() || url.eq_ignore_ascii_case("about:blank") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = url.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("about:")
|
||||||
|
|| lower.starts_with("chrome:")
|
||||||
|
|| lower.starts_with("javascript:")
|
||||||
|
|| lower.starts_with("data:")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The RustyWolf control page of a previous Gecko session.
|
||||||
|
if lower.starts_with("http://127.0.0.1:") && url.contains("/?token=") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- mozlz4 ------------------------------------------------------
|
||||||
|
|
||||||
|
const MOZLZ4_MAGIC: &[u8; 8] = b"mozLz40\0";
|
||||||
|
|
||||||
|
pub(crate) fn decode_mozlz4(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
if bytes.len() < 12 || &bytes[..8] != MOZLZ4_MAGIC {
|
||||||
|
bail!("not a mozlz4 file");
|
||||||
|
}
|
||||||
|
let declared = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
|
||||||
|
let decoded = lz4_block_decompress(&bytes[12..])?;
|
||||||
|
if decoded.len() != declared {
|
||||||
|
bail!(
|
||||||
|
"mozlz4 size mismatch: header says {declared}, decoded {}",
|
||||||
|
decoded.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain LZ4 block decompression (the codec behind "mozLz4").
|
||||||
|
pub(crate) fn lz4_block_decompress(src: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
let mut pos = 0usize;
|
||||||
|
|
||||||
|
while pos < src.len() {
|
||||||
|
let token = src[pos];
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
let mut literal_len = (token >> 4) as usize;
|
||||||
|
if literal_len == 15 {
|
||||||
|
loop {
|
||||||
|
let extra = *src.get(pos).context("lz4: truncated literal length")?;
|
||||||
|
pos += 1;
|
||||||
|
literal_len += extra as usize;
|
||||||
|
if extra != 255 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pos + literal_len > src.len() {
|
||||||
|
bail!("lz4: truncated literals");
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&src[pos..pos + literal_len]);
|
||||||
|
pos += literal_len;
|
||||||
|
|
||||||
|
if pos >= src.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos + 2 > src.len() {
|
||||||
|
bail!("lz4: truncated match offset");
|
||||||
|
}
|
||||||
|
let offset = u16::from_le_bytes([src[pos], src[pos + 1]]) as usize;
|
||||||
|
pos += 2;
|
||||||
|
if offset == 0 || offset > out.len() {
|
||||||
|
bail!("lz4: match offset out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut match_len = (token & 0x0f) as usize;
|
||||||
|
if match_len == 15 {
|
||||||
|
loop {
|
||||||
|
let extra = *src.get(pos).context("lz4: truncated match length")?;
|
||||||
|
pos += 1;
|
||||||
|
match_len += extra as usize;
|
||||||
|
if extra != 255 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match_len += 4;
|
||||||
|
|
||||||
|
let start = out.len() - offset;
|
||||||
|
for i in 0..match_len {
|
||||||
|
let byte = out[start + i];
|
||||||
|
out.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lz4_decodes_literals_only() {
|
||||||
|
// token 0x30 => 3 literal bytes, no match section
|
||||||
|
let src = [0x30, b'a', b'b', b'c'];
|
||||||
|
assert_eq!(lz4_block_decompress(&src).unwrap(), b"abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lz4_decodes_backreferences() {
|
||||||
|
// 4 literals "abcd", then offset 4, length 4 => "abcdabcd"; then 1 literal "x"
|
||||||
|
let src = [0x40, b'a', b'b', b'c', b'd', 0x04, 0x00, 0x10, b'x'];
|
||||||
|
assert_eq!(lz4_block_decompress(&src).unwrap(), b"abcdabcdx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_real_firefox_session_fixture() {
|
||||||
|
let raw = include_bytes!("testdata/recovery.jsonlz4");
|
||||||
|
let json = decode_mozlz4(raw).expect("fixture must decode");
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(&json).expect("fixture must be JSON");
|
||||||
|
let (tabs, active) = extract_tabs(&doc);
|
||||||
|
assert_eq!(tabs.len(), 2);
|
||||||
|
assert_eq!(tabs[0].url, "https://example.com/");
|
||||||
|
assert_eq!(tabs[1].url, "https://example.org/");
|
||||||
|
// Firefox's `selected` is 1-based; the first tab was focused.
|
||||||
|
assert_eq!(active, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_container() {
|
||||||
|
assert!(decode_mozlz4(b"not a mozlz4 file at all").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handoff_roundtrip_and_freshness() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("rustywolf-test-{}", std::process::id()));
|
||||||
|
let path = dir.join("handoff.json");
|
||||||
|
let handoff = Handoff::new(
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
HandoffTab {
|
||||||
|
url: "https://example.com/".into(),
|
||||||
|
title: "Example".into(),
|
||||||
|
},
|
||||||
|
HandoffTab {
|
||||||
|
url: "https://example.org/".into(),
|
||||||
|
title: "".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
save_to(&path, &handoff).unwrap();
|
||||||
|
|
||||||
|
let loaded = take_fresh_from(&path, 60).expect("fresh handoff");
|
||||||
|
assert_eq!(loaded.tabs.len(), 2);
|
||||||
|
assert_eq!(loaded.active, 1);
|
||||||
|
assert!(!path.exists(), "handoff is consumed on read");
|
||||||
|
|
||||||
|
// Stale handoffs are dropped.
|
||||||
|
let stale = Handoff {
|
||||||
|
created_at: now_unix().saturating_sub(9999),
|
||||||
|
..handoff
|
||||||
|
};
|
||||||
|
save_to(&path, &stale).unwrap();
|
||||||
|
assert!(take_fresh_from(&path, 60).is_none());
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_unusable_session_urls() {
|
||||||
|
let doc = serde_json::json!({
|
||||||
|
"selectedWindow": 1,
|
||||||
|
"windows": [{
|
||||||
|
"selected": 2,
|
||||||
|
"tabs": [
|
||||||
|
{"index": 1, "entries": [{"url": "about:newtab"}]},
|
||||||
|
{"index": 1, "entries": [{"url": "https://ok.example/"}]},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let (tabs, active) = extract_tabs(&doc);
|
||||||
|
assert_eq!(tabs.len(), 1);
|
||||||
|
assert_eq!(tabs[0].url, "https://ok.example/");
|
||||||
|
assert_eq!(active, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main.rs
73
src/main.rs
|
|
@ -1,8 +1,16 @@
|
||||||
//main.rs
|
//! RustyWolf entry point: load config, pick the engine, go.
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod webview;
|
mod config;
|
||||||
|
mod gecko;
|
||||||
|
mod handoff;
|
||||||
|
mod switch;
|
||||||
|
mod util;
|
||||||
|
mod webkit;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use cli::{Cli, Engine};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if let Err(err) = run() {
|
if let Err(err) = run() {
|
||||||
|
|
@ -12,6 +20,63 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run() -> Result<()> {
|
fn run() -> Result<()> {
|
||||||
let cli = cli::Cli::parse_args();
|
let cli = Cli::parse_args();
|
||||||
webview::launch_webkit(&cli)
|
let mut cfg = config::load();
|
||||||
|
|
||||||
|
if cli.dry_run {
|
||||||
|
let engine = cli.engine.unwrap_or(cfg.engine);
|
||||||
|
print_dry_run(&cli, &cfg, engine);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(engine) = cli.engine
|
||||||
|
&& cfg.engine != engine
|
||||||
|
{
|
||||||
|
cfg.engine = engine;
|
||||||
|
config::save(&cfg)?;
|
||||||
|
}
|
||||||
|
if let Some(firefox) = &cli.firefox {
|
||||||
|
cfg.firefox_bin = Some(firefox.clone());
|
||||||
|
config::save(&cfg)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match cli.engine.unwrap_or(cfg.engine) {
|
||||||
|
Engine::Webkit => webkit::launch(&cli, &mut cfg),
|
||||||
|
Engine::Gecko => gecko::launch(&cli, &mut cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) {
|
||||||
|
let url = cli
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.map(util::normalize_url)
|
||||||
|
.unwrap_or_else(|| util::DEFAULT_HOME.to_string());
|
||||||
|
|
||||||
|
match engine {
|
||||||
|
Engine::Webkit => {
|
||||||
|
let effective_dmabuf =
|
||||||
|
std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string());
|
||||||
|
println!(
|
||||||
|
"engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}",
|
||||||
|
cli.private,
|
||||||
|
cfg.adblock,
|
||||||
|
if cli.ua_chrome { "chrome" } else { "native" },
|
||||||
|
cli.linux_backend,
|
||||||
|
cli.linux_disable_dmabuf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Engine::Gecko => {
|
||||||
|
let firefox: Option<PathBuf> =
|
||||||
|
cli.firefox.clone().or_else(|| gecko::locate_firefox(cfg));
|
||||||
|
let firefox = firefox
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.unwrap_or_else(|| "not-found".to_string());
|
||||||
|
println!(
|
||||||
|
"engine=gecko url={url} private={} firefox={firefox} profile={}",
|
||||||
|
cli.private,
|
||||||
|
config::gecko_profile_dir().display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
src/switch.rs
Normal file
59
src/switch.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
//! Engine switching: hand the tab set to the other engine and relaunch.
|
||||||
|
|
||||||
|
use crate::cli::Engine;
|
||||||
|
use crate::config::{self, Config};
|
||||||
|
use crate::handoff::{self, Handoff};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// Persist the handoff, relaunch RustyWolf in `target` mode, remember the choice.
|
||||||
|
pub fn switch_to(
|
||||||
|
target: Engine,
|
||||||
|
cfg: &mut Config,
|
||||||
|
handoff: Option<&Handoff>,
|
||||||
|
private: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let has_handoff = match handoff {
|
||||||
|
Some(handoff) if !handoff.tabs.is_empty() => {
|
||||||
|
handoff::save(handoff)?;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
spawn_detached(target, has_handoff, private)?;
|
||||||
|
cfg.engine = target;
|
||||||
|
if let Err(err) = config::save(cfg) {
|
||||||
|
eprintln!("rustywolf: could not persist the engine choice: {err:#}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start another RustyWolf process in the background (survives our exit).
|
||||||
|
pub fn spawn_detached(target: Engine, resume: bool, private: bool) -> Result<()> {
|
||||||
|
let exe = std::env::current_exe().context("could not locate the RustyWolf binary")?;
|
||||||
|
let mut cmd = Command::new(exe);
|
||||||
|
cmd.arg("--engine").arg(target.as_str());
|
||||||
|
if resume {
|
||||||
|
cmd.arg("--resume");
|
||||||
|
}
|
||||||
|
if private {
|
||||||
|
cmd.arg("--private");
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
cmd.pre_exec(|| {
|
||||||
|
// New session so the switched process survives this one's exit.
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.spawn()
|
||||||
|
.with_context(|| format!("could not launch the {} engine", target.as_str()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
BIN
src/testdata/recovery.jsonlz4
vendored
Normal file
BIN
src/testdata/recovery.jsonlz4
vendored
Normal file
Binary file not shown.
247
src/util.rs
Normal file
247
src/util.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
//! Small shared helpers: URL normalization, filenames, timestamps.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub const DEFAULT_HOME: &str = "https://duckduckgo.com";
|
||||||
|
pub const NEW_TAB_URL: &str = "https://duckduckgo.com";
|
||||||
|
pub const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
|
||||||
|
|
||||||
|
/// Turn a URL-bar entry into a URL (search terms go to DuckDuckGo).
|
||||||
|
pub fn normalize_url(input: &str) -> String {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed == "about:blank" {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
if looks_like_address(trimmed) {
|
||||||
|
return format!("https://{trimmed}");
|
||||||
|
}
|
||||||
|
let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
|
||||||
|
format!("{SEARCH_URL_PREFIX}{encoded}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn looks_like_address(value: &str) -> bool {
|
||||||
|
value.contains('.')
|
||||||
|
|| value.contains(':')
|
||||||
|
|| value.starts_with("localhost")
|
||||||
|
|| value.starts_with("127.")
|
||||||
|
|| value.starts_with('[')
|
||||||
|
|| value.starts_with('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New-window targets RustyWolf refuses to open as tabs/popups.
|
||||||
|
pub fn should_open_new_window_url(raw: &str) -> bool {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("about:")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(parsed) = url::Url::parse(trimmed) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuckDuckGo's in-page "open in a new window" helpers are not real links.
|
||||||
|
let host = parsed.host_str().unwrap_or_default();
|
||||||
|
let path = parsed.path().to_ascii_lowercase();
|
||||||
|
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
|
||||||
|
&& (path == "/post3.html" || path == "/page3" || path == "/page3.html")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_filename(raw: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(raw.len());
|
||||||
|
for ch in raw.chars() {
|
||||||
|
let invalid =
|
||||||
|
matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
|
||||||
|
out.push(if invalid { '_' } else { ch });
|
||||||
|
}
|
||||||
|
let trimmed = out.trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"download.bin".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filename_from_url(url: &str) -> String {
|
||||||
|
let raw = url::Url::parse(url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| {
|
||||||
|
u.path_segments().and_then(|mut parts| {
|
||||||
|
parts
|
||||||
|
.next_back()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "download.bin".to_string());
|
||||||
|
|
||||||
|
sanitize_filename(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_unique_download_path(path: PathBuf) -> PathBuf {
|
||||||
|
if !path.exists() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("download")
|
||||||
|
.to_string();
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.filter(|e| !e.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
|
||||||
|
for idx in 1..=9999 {
|
||||||
|
let file_name = match &ext {
|
||||||
|
Some(ext) => format!("{stem} ({idx}).{ext}"),
|
||||||
|
None => format!("{stem} ({idx})"),
|
||||||
|
};
|
||||||
|
let candidate = parent.join(file_name);
|
||||||
|
if !candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.join(format!("{stem}-{}", now_unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_to_string(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human timestamp used by history/downloads rows.
|
||||||
|
pub fn now_stamp() -> String {
|
||||||
|
now_unix().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn escape_html(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
360
src/webkit/chrome.html
Normal file
360
src/webkit/chrome.html
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; overflow: hidden; background: #101012;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-user-select: none; user-select: none; }
|
||||||
|
#bar { display: flex; flex-direction: column; height: 100%;
|
||||||
|
padding: 6px 8px; gap: 6px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.15); }
|
||||||
|
#tabs-top { display: flex; align-items: center; gap: 6px; height: 30px; }
|
||||||
|
#tabs-row { flex: 1; display: flex; align-items: center; gap: 4px;
|
||||||
|
height: 30px; overflow-x: auto; overflow-y: hidden; }
|
||||||
|
#nav-row { display: flex; align-items: center; gap: 6px; height: 36px; }
|
||||||
|
#downloads-row { display: none; flex-direction: column; align-items: stretch; gap: 6px; overflow-y: auto;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18); border-radius: 0; padding: 8px;
|
||||||
|
background: rgba(255,255,255,0.07); min-height: 92px; }
|
||||||
|
.dl-empty { font-size: 12px; opacity: .75; }
|
||||||
|
.dl-item { display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(255,255,255,0.16);
|
||||||
|
border-radius: 0; padding: 6px 8px; background: rgba(0,0,0,0.2); color: #e8e8ed; }
|
||||||
|
.dl-title { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dl-meta { font-size: 11px; opacity: .82; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dl-track { width: 100%; height: 6px; background: rgba(255,255,255,0.18); border-radius: 0; overflow: hidden; }
|
||||||
|
.dl-fill { height: 100%; width: 0%; border-radius: 0; background: linear-gradient(90deg, #4f8bff, #72d8ff);
|
||||||
|
transform-origin: left center; }
|
||||||
|
.dl-fill.indeterminate { width: 35%; animation: glide 1.05s ease-in-out infinite; }
|
||||||
|
@keyframes glide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }
|
||||||
|
button { flex-shrink: 0; height: 30px; min-width: 30px; padding: 0 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2); border-radius: 0;
|
||||||
|
background: rgba(255,255,255,0.1); color: #e8e8ed;
|
||||||
|
font-size: 14px; cursor: pointer; outline: none; }
|
||||||
|
button:active { background: rgba(255,255,255,0.2); }
|
||||||
|
#url-input { flex: 1; height: 30px; border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 0; padding: 4px 10px; font-size: 13px; outline: none;
|
||||||
|
color: #f5f5f7; background: rgba(255,255,255,0.07);
|
||||||
|
-webkit-user-select: text; user-select: text; }
|
||||||
|
#url-input:focus { border-color: rgba(90,140,255,0.7); background: rgba(255,255,255,0.1); }
|
||||||
|
.tab-btn { position: relative; display: inline-flex; align-items: center;
|
||||||
|
max-width: 170px; height: 26px; font-size: 12px;
|
||||||
|
padding-right: 42px; }
|
||||||
|
.tab-btn.unloaded:not(.active) { opacity: .72; }
|
||||||
|
.tab-title { display: block; width: 100%;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tab-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
|
||||||
|
.kir-btn { position: absolute; right: 22px; top: 2px;
|
||||||
|
width: 18px; height: 18px; min-width: 18px; border-radius: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.25);
|
||||||
|
color: #fff; font-size: 10px; line-height: 16px; padding: 0; }
|
||||||
|
.kir-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
|
||||||
|
.close-btn { position: absolute; right: 3px; top: 2px;
|
||||||
|
width: 18px; height: 18px; min-width: 18px; border-radius: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.25);
|
||||||
|
color: #fff; font-size: 11px; line-height: 16px; padding: 0; }
|
||||||
|
#add-btn { height: 26px; min-width: 28px; font-size: 16px; }
|
||||||
|
#engine-btn { height: 26px; min-width: 32px; font-size: 14px; }
|
||||||
|
#engine-btn.active { background: rgba(70,190,90,0.75); border-color: rgba(70,190,90,0.5); }
|
||||||
|
#adblock-btn { height: 26px; min-width: 40px; font-size: 12px; font-weight: 700; }
|
||||||
|
#adblock-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
|
||||||
|
#props-btn { height: 26px; min-width: 88px; font-size: 12px; }
|
||||||
|
#downloads-btn { height: 26px; min-width: 28px; font-size: 12px; font-weight: 700; }
|
||||||
|
#downloads-btn.active { background: rgba(90,140,255,0.75); border-color: rgba(90,140,255,0.5); }
|
||||||
|
#popup-row { display: none; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
min-height: 34px; padding: 6px 8px; border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(255,255,255,0.06); color: #e8e8ed; font-size: 12px; }
|
||||||
|
#popup-message { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
#popup-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
#popup-yes, #popup-no { height: 24px; min-width: 44px; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="bar">
|
||||||
|
<div id="tabs-top">
|
||||||
|
<div id="tabs-row"></div>
|
||||||
|
<button id="engine-btn" class="active" title="Engine: WebKit">🍃</button>
|
||||||
|
<button id="adblock-btn" title="Toggle adblock">ADS</button>
|
||||||
|
<button id="downloads-btn" title="Downloads">D</button>
|
||||||
|
<button id="props-btn" title="History and downloads">Properties</button>
|
||||||
|
</div>
|
||||||
|
<div id="nav-row">
|
||||||
|
<button id="back" title="Back">←</button>
|
||||||
|
<button id="forward" title="Forward">→</button>
|
||||||
|
<button id="reload" title="Reload">↻</button>
|
||||||
|
<input id="url-input" type="text" placeholder="Search or enter address"
|
||||||
|
autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div id="downloads-row"></div>
|
||||||
|
<div id="popup-row">
|
||||||
|
<div id="popup-message"></div>
|
||||||
|
<div id="popup-actions">
|
||||||
|
<button id="popup-yes">Yes</button>
|
||||||
|
<button id="popup-no">No</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const post = (msg) => window.ipc && window.ipc.postMessage(msg);
|
||||||
|
|
||||||
|
const normalize = (raw) => {
|
||||||
|
const value = (raw || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)) return value;
|
||||||
|
const looksLikeAddress =
|
||||||
|
value.includes('.') || value.includes(':') ||
|
||||||
|
value.startsWith('localhost') || value.startsWith('127.') ||
|
||||||
|
value.startsWith('[') || value.startsWith('/');
|
||||||
|
return looksLikeAddress ? 'https://' + value
|
||||||
|
: 'https://duckduckgo.com/?q=' + encodeURIComponent(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = document.getElementById('url-input');
|
||||||
|
|
||||||
|
const isEditableTarget = (target) => {
|
||||||
|
if (!target || !(target instanceof Element)) return false;
|
||||||
|
if (target === input) return true;
|
||||||
|
const tag = (target.tagName || '').toLowerCase();
|
||||||
|
return tag === 'input' || tag === 'textarea' || target.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('back').addEventListener('click', () => post('back'));
|
||||||
|
document.getElementById('forward').addEventListener('click', () => post('forward'));
|
||||||
|
document.getElementById('reload').addEventListener('click', () => post('reload'));
|
||||||
|
document.getElementById('props-btn').addEventListener('click', () => post('open_properties'));
|
||||||
|
document.getElementById('adblock-btn').addEventListener('click', () => post('toggle_adblock'));
|
||||||
|
document.getElementById('downloads-btn').addEventListener('click', () => post('toggle_downloads'));
|
||||||
|
document.getElementById('engine-btn').addEventListener('click', () => post('toggle_engine'));
|
||||||
|
document.getElementById('popup-yes').addEventListener('click', () => post('popup_allow_yes'));
|
||||||
|
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
const next = normalize(input.value);
|
||||||
|
if (next) post('navigate:' + next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shortTitle = (tab, i) => {
|
||||||
|
if (tab.title && tab.title.trim()) return tab.title.trim().slice(0, 22);
|
||||||
|
try { return new URL(tab.url).hostname || 'Tab ' + (i + 1); }
|
||||||
|
catch (_) { return 'Tab ' + (i + 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetTabs = (state) => {
|
||||||
|
const row = document.getElementById('tabs-row');
|
||||||
|
row.innerHTML = '';
|
||||||
|
state.tabs.forEach((tab, i) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className =
|
||||||
|
'tab-btn' +
|
||||||
|
(tab.id === state.activeId ? ' active' : '') +
|
||||||
|
(tab.loaded ? '' : ' unloaded');
|
||||||
|
btn.title = tab.url;
|
||||||
|
btn.addEventListener('click', () => post('switch_tab:' + tab.id));
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'tab-title';
|
||||||
|
title.textContent = shortTitle(tab, i) + (tab.loaded ? '' : ' [S]');
|
||||||
|
btn.appendChild(title);
|
||||||
|
|
||||||
|
const kir = document.createElement('button');
|
||||||
|
kir.className = 'kir-btn' + (tab.kirEnabled ? ' active' : '');
|
||||||
|
kir.textContent = 'K';
|
||||||
|
kir.title = tab.kirEnabled ? 'KIR On' : 'KIR Off';
|
||||||
|
kir.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
post('toggle_kir:' + tab.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(kir);
|
||||||
|
|
||||||
|
const x = document.createElement('button');
|
||||||
|
x.className = 'close-btn';
|
||||||
|
x.textContent = '-'; x.title = 'Close tab';
|
||||||
|
x.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
post('close_tab:' + tab.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(x);
|
||||||
|
row.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = document.createElement('button');
|
||||||
|
add.id = 'add-btn'; add.textContent = '+'; add.title = 'New tab';
|
||||||
|
add.addEventListener('click', () => post('new_tab'));
|
||||||
|
row.appendChild(add);
|
||||||
|
|
||||||
|
const active = state.tabs.find(t => t.id === state.activeId);
|
||||||
|
if (active) input.value = (active.url && active.url !== 'about:blank') ? active.url : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetEngine = (engine) => {
|
||||||
|
const button = document.getElementById('engine-btn');
|
||||||
|
if (!button) return;
|
||||||
|
const isWebkit = engine === 'webkit';
|
||||||
|
button.classList.toggle('active', isWebkit);
|
||||||
|
button.title = isWebkit
|
||||||
|
? 'Engine: WebKit (click to switch to Gecko/Firefox)'
|
||||||
|
: 'Engine: Gecko';
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortPath = (value) => {
|
||||||
|
if (!value) return 'Unknown destination';
|
||||||
|
if (value.length <= 42) return value;
|
||||||
|
return '...' + value.slice(-39);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetDownloads = (state) => {
|
||||||
|
const row = document.getElementById('downloads-row');
|
||||||
|
const button = document.getElementById('downloads-btn');
|
||||||
|
const items = (state && Array.isArray(state.downloads)) ? state.downloads : [];
|
||||||
|
const panelOpen = !!(state && state.panelOpen);
|
||||||
|
row.innerHTML = '';
|
||||||
|
if (!panelOpen) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
button.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.classList.add('active');
|
||||||
|
row.style.display = 'flex';
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'dl-empty';
|
||||||
|
empty.textContent = 'No active downloads';
|
||||||
|
row.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (value) => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let num = Number(value) || 0;
|
||||||
|
let unit = 0;
|
||||||
|
while (num >= 1024 && unit < units.length - 1) {
|
||||||
|
num /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return (unit === 0 ? Math.round(num).toString() : num.toFixed(1)) + ' ' + units[unit];
|
||||||
|
};
|
||||||
|
|
||||||
|
items.slice(0, 6).forEach((entry) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'dl-item';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'dl-title';
|
||||||
|
title.textContent = shortPath(entry.filePath);
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'dl-meta';
|
||||||
|
let progressText = formatBytes(entry.downloadedBytes || 0) + ' downloaded';
|
||||||
|
const total = Number(entry.totalBytes || 0);
|
||||||
|
const done = Number(entry.downloadedBytes || 0);
|
||||||
|
if (total > 0) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.floor((done / total) * 100)));
|
||||||
|
progressText = formatBytes(done) + ' / ' + formatBytes(total) + ' (' + pct + '%)';
|
||||||
|
}
|
||||||
|
meta.textContent = entry.status + ' - ' + progressText;
|
||||||
|
|
||||||
|
const track = document.createElement('div');
|
||||||
|
track.className = 'dl-track';
|
||||||
|
const fill = document.createElement('div');
|
||||||
|
fill.className = 'dl-fill';
|
||||||
|
if (entry.inProgress) {
|
||||||
|
const totalBytes = Number(entry.totalBytes || 0);
|
||||||
|
const downloadedBytes = Number(entry.downloadedBytes || 0);
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (downloadedBytes / totalBytes) * 100));
|
||||||
|
fill.style.width = pct.toFixed(1) + '%';
|
||||||
|
} else {
|
||||||
|
fill.style.width = '35%';
|
||||||
|
fill.classList.add('indeterminate');
|
||||||
|
}
|
||||||
|
} else if (entry.status === 'Completed') {
|
||||||
|
fill.style.width = '100%';
|
||||||
|
fill.style.background = 'linear-gradient(90deg, #36c977, #52e39a)';
|
||||||
|
} else {
|
||||||
|
fill.style.width = '100%';
|
||||||
|
fill.style.background = 'linear-gradient(90deg, #b85b5b, #e47979)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = document.createElement('div');
|
||||||
|
src.className = 'dl-meta';
|
||||||
|
src.textContent = entry.url || '';
|
||||||
|
|
||||||
|
track.appendChild(fill);
|
||||||
|
card.appendChild(title);
|
||||||
|
card.appendChild(meta);
|
||||||
|
card.appendChild(track);
|
||||||
|
card.appendChild(src);
|
||||||
|
row.appendChild(card);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetAdblock = (enabled) => {
|
||||||
|
const button = document.getElementById('adblock-btn');
|
||||||
|
if (!button) return;
|
||||||
|
if (enabled) {
|
||||||
|
button.classList.add('active');
|
||||||
|
button.title = 'Adblock: On';
|
||||||
|
} else {
|
||||||
|
button.classList.remove('active');
|
||||||
|
button.title = 'Adblock: Off';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetPopupPrompt = (state) => {
|
||||||
|
const row = document.getElementById('popup-row');
|
||||||
|
const msg = document.getElementById('popup-message');
|
||||||
|
if (!row || !msg) return;
|
||||||
|
const visible = !!(state && state.visible);
|
||||||
|
if (!visible) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
msg.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.style.display = 'flex';
|
||||||
|
const host = (state && state.host) ? String(state.host) : 'this website';
|
||||||
|
msg.textContent = 'Allow ' + host + ' to open a new tab?';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (ev) => {
|
||||||
|
const key = ev.key.toLowerCase();
|
||||||
|
const wantsReload =
|
||||||
|
key === 'f5' ||
|
||||||
|
((ev.metaKey || ev.ctrlKey) && key === 'r');
|
||||||
|
if (!ev.defaultPrevented && wantsReload) {
|
||||||
|
ev.preventDefault();
|
||||||
|
post('reload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = ev.metaKey || ev.ctrlKey;
|
||||||
|
if (!mod || ev.altKey || ev.defaultPrevented) return;
|
||||||
|
|
||||||
|
if (key === 'l') {
|
||||||
|
ev.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditableTarget(ev.target)) return;
|
||||||
|
|
||||||
|
if (key === 't') {
|
||||||
|
ev.preventDefault();
|
||||||
|
post('new_tab');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'w') {
|
||||||
|
ev.preventDefault();
|
||||||
|
post('close_active_tab');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
src/webkit/js/adblock.js
Normal file
195
src/webkit/js/adblock.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
(() => {
|
||||||
|
if (window.__rustywolfAdblockBooted) return;
|
||||||
|
window.__rustywolfAdblockBooted = true;
|
||||||
|
|
||||||
|
// The Rust side bakes the current toggle state in before this script runs.
|
||||||
|
window.__rustywolfAdblockEnabled = window.__rustywolfAdblockInitEnabled !== false;
|
||||||
|
try { delete window.__rustywolfAdblockInitEnabled; } catch (_) {}
|
||||||
|
|
||||||
|
const blockedHosts = [
|
||||||
|
'doubleclick.net',
|
||||||
|
'googlesyndication.com',
|
||||||
|
'adservice.google.com',
|
||||||
|
'google-analytics.com',
|
||||||
|
'googletagmanager.com',
|
||||||
|
'facebook.net',
|
||||||
|
'ads.yahoo.com',
|
||||||
|
'taboola.com',
|
||||||
|
'outbrain.com'
|
||||||
|
];
|
||||||
|
|
||||||
|
const cosmeticSelectors = [
|
||||||
|
'[id^="ad-"]',
|
||||||
|
'[id^="ads-"]',
|
||||||
|
'[class^="ad-"]',
|
||||||
|
'[class^="ads-"]',
|
||||||
|
'[class*=" ad-"]',
|
||||||
|
'[class*=" ads-"]',
|
||||||
|
'[class*="advert"]',
|
||||||
|
'[id*="sponsored"]',
|
||||||
|
'[class*="sponsored"]',
|
||||||
|
'iframe[src*="doubleclick"]',
|
||||||
|
'iframe[src*="adservice"]'
|
||||||
|
];
|
||||||
|
|
||||||
|
const isEnabled = () => window.__rustywolfAdblockEnabled !== false;
|
||||||
|
|
||||||
|
const matchesBlockedHost = (host) => {
|
||||||
|
const lower = (host || '').toLowerCase();
|
||||||
|
return blockedHosts.some((blocked) => lower === blocked || lower.endsWith('.' + blocked));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldBlockUrl = (rawUrl) => {
|
||||||
|
if (!isEnabled() || !rawUrl) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl, location.href);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
||||||
|
if (matchesBlockedHost(parsed.hostname)) return true;
|
||||||
|
const full = parsed.href.toLowerCase();
|
||||||
|
return full.includes('/ads') || full.includes('adserver') || full.includes('tracking');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAds = () => {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
for (const selector of cosmeticSelectors) {
|
||||||
|
document.querySelectorAll(selector).forEach((el) => {
|
||||||
|
if (!el.hasAttribute('data-rw-adblock-hidden')) {
|
||||||
|
el.setAttribute('data-rw-adblock-hidden', '1');
|
||||||
|
el.setAttribute('data-rw-prev-display', el.style.getPropertyValue('display') || '');
|
||||||
|
el.setAttribute('data-rw-prev-visibility', el.style.getPropertyValue('visibility') || '');
|
||||||
|
}
|
||||||
|
el.style.setProperty('display', 'none', 'important');
|
||||||
|
el.style.setProperty('visibility', 'hidden', 'important');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreAds = () => {
|
||||||
|
document.querySelectorAll('[data-rw-adblock-hidden="1"]').forEach((el) => {
|
||||||
|
const prevDisplay = el.getAttribute('data-rw-prev-display') || '';
|
||||||
|
const prevVisibility = el.getAttribute('data-rw-prev-visibility') || '';
|
||||||
|
if (prevDisplay) {
|
||||||
|
el.style.setProperty('display', prevDisplay);
|
||||||
|
} else {
|
||||||
|
el.style.removeProperty('display');
|
||||||
|
}
|
||||||
|
if (prevVisibility) {
|
||||||
|
el.style.setProperty('visibility', prevVisibility);
|
||||||
|
} else {
|
||||||
|
el.style.removeProperty('visibility');
|
||||||
|
}
|
||||||
|
el.removeAttribute('data-rw-adblock-hidden');
|
||||||
|
el.removeAttribute('data-rw-prev-display');
|
||||||
|
el.removeAttribute('data-rw-prev-visibility');
|
||||||
|
if (el.getAttribute('aria-hidden') === 'true') {
|
||||||
|
el.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let adObserver = null;
|
||||||
|
let scanQueued = false;
|
||||||
|
|
||||||
|
const scheduleAdScan = () => {
|
||||||
|
if (!isEnabled() || scanQueued) return;
|
||||||
|
scanQueued = true;
|
||||||
|
const run = () => {
|
||||||
|
scanQueued = false;
|
||||||
|
removeAds();
|
||||||
|
};
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
requestAnimationFrame(run);
|
||||||
|
} else {
|
||||||
|
setTimeout(run, 16);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopObserver = () => {
|
||||||
|
if (!adObserver) return;
|
||||||
|
adObserver.disconnect();
|
||||||
|
adObserver = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startObserver = () => {
|
||||||
|
stopObserver();
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
const root = document.body || document.documentElement;
|
||||||
|
if (!root) return;
|
||||||
|
adObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === 'childList' && mutation.addedNodes && mutation.addedNodes.length > 0) {
|
||||||
|
scheduleAdScan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
adObserver.observe(root, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rustywolfSetAdblockEnabled = (enabled) => {
|
||||||
|
window.__rustywolfAdblockEnabled = !!enabled;
|
||||||
|
if (window.__rustywolfAdblockEnabled) {
|
||||||
|
startObserver();
|
||||||
|
scheduleAdScan();
|
||||||
|
} else {
|
||||||
|
stopObserver();
|
||||||
|
restoreAds();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
if (typeof originalFetch === 'function') {
|
||||||
|
window.fetch = function patchedFetch(input, init) {
|
||||||
|
const target = typeof input === 'string' ? input : (input && input.url) || '';
|
||||||
|
if (shouldBlockUrl(target)) {
|
||||||
|
return Promise.resolve(new Response('', { status: 204, statusText: 'Blocked by RustyWolf' }));
|
||||||
|
}
|
||||||
|
return originalFetch.call(this, input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||||
|
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
|
||||||
|
this.__rustywolfUrl = url;
|
||||||
|
return originalXhrOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalXhrSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.send = function patchedSend() {
|
||||||
|
if (shouldBlockUrl(this.__rustywolfUrl)) {
|
||||||
|
this.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return originalXhrSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchSrcSetter = (ctor) => {
|
||||||
|
if (!ctor || !ctor.prototype) return;
|
||||||
|
const desc = Object.getOwnPropertyDescriptor(ctor.prototype, 'src');
|
||||||
|
if (!desc || typeof desc.set !== 'function' || typeof desc.get !== 'function') return;
|
||||||
|
Object.defineProperty(ctor.prototype, 'src', {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: desc.enumerable,
|
||||||
|
get: desc.get,
|
||||||
|
set(value) {
|
||||||
|
if (shouldBlockUrl(value)) {
|
||||||
|
return desc.set.call(this, 'about:blank');
|
||||||
|
}
|
||||||
|
return desc.set.call(this, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
patchSrcSetter(window.HTMLImageElement);
|
||||||
|
patchSrcSetter(window.HTMLScriptElement);
|
||||||
|
patchSrcSetter(window.HTMLIFrameElement);
|
||||||
|
|
||||||
|
if (window.__rustywolfAdblockEnabled) {
|
||||||
|
startObserver();
|
||||||
|
scheduleAdScan();
|
||||||
|
}
|
||||||
|
})();
|
||||||
27
src/webkit/js/privacy.js
Normal file
27
src/webkit/js/privacy.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, get: () => '1' });
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
Object.defineProperty(window, 'openDatabase', { configurable: true, value: undefined });
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if ('BatteryManager' in window) window.BatteryManager = undefined;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if ('RTCPeerConnection' in window) window.RTCPeerConnection = undefined;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Keep reload shortcuts working when focus is inside the page webview.
|
||||||
|
window.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.defaultPrevented) return;
|
||||||
|
const key = (ev.key || '').toLowerCase();
|
||||||
|
const wantsReload =
|
||||||
|
key === 'f5' ||
|
||||||
|
((ev.metaKey || ev.ctrlKey) && key === 'r');
|
||||||
|
if (wantsReload) {
|
||||||
|
ev.preventDefault();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
1636
src/webkit/mod.rs
Normal file
1636
src/webkit/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
164
src/webkit/popup.rs
Normal file
164
src/webkit/popup.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
//! Real popup windows for Linux/WebKitGTK content views.
|
||||||
|
//!
|
||||||
|
//! wry does not connect WebKitGTK's `create` signal, so new-window requests
|
||||||
|
//! would never produce a window with a live `window.opener`. That breaks
|
||||||
|
//! popup-based logins ("Sign in with Google" and friends). Here we attach the
|
||||||
|
//! signal to the raw `WebKitWebView` and build proper related-view popups.
|
||||||
|
|
||||||
|
use super::{NavKind, NewWindowDecision, UserEvent, decide_new_window};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use tao::event_loop::EventLoopProxy;
|
||||||
|
use webkit2gtk::{URIRequestExt, UserContentManagerExt, WebViewExt};
|
||||||
|
|
||||||
|
pub struct PopupHub {
|
||||||
|
pub proxy: EventLoopProxy<UserEvent>,
|
||||||
|
pub adblock_enabled: Cell<bool>,
|
||||||
|
pub popups: RefCell<Vec<gtk::glib::WeakRef<webkit2gtk::WebView>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopupHub {
|
||||||
|
pub fn new(proxy: EventLoopProxy<UserEvent>, adblock_enabled: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
proxy,
|
||||||
|
adblock_enabled: Cell::new(adblock_enabled),
|
||||||
|
popups: RefCell::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook the `create` signal of a content webview.
|
||||||
|
pub fn attach(view: &webkit2gtk::WebView, hub: &Rc<PopupHub>) {
|
||||||
|
let hub = hub.clone();
|
||||||
|
view.connect_create(move |view, action| handle_create(view, action, &hub));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the adblock toggle into popup pages that are already open.
|
||||||
|
pub fn set_popup_adblock(hub: &PopupHub, enabled: bool) {
|
||||||
|
hub.adblock_enabled.set(enabled);
|
||||||
|
let script = format!(
|
||||||
|
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut alive = Vec::new();
|
||||||
|
for weak in hub.popups.borrow_mut().drain(..) {
|
||||||
|
if let Some(view) = weak.upgrade() {
|
||||||
|
view.evaluate_javascript(&script, None, None, None::<>k::gio::Cancellable>, |_| {});
|
||||||
|
alive.push(view.downgrade());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*hub.popups.borrow_mut() = alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_create(
|
||||||
|
view: &webkit2gtk::WebView,
|
||||||
|
action: &webkit2gtk::NavigationAction,
|
||||||
|
hub: &Rc<PopupHub>,
|
||||||
|
) -> Option<gtk::Widget> {
|
||||||
|
let url = action
|
||||||
|
.request()
|
||||||
|
.and_then(|request| request.uri())
|
||||||
|
.map(|uri| uri.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let user_gesture = action.is_user_gesture();
|
||||||
|
let nav = match action.navigation_type() {
|
||||||
|
webkit2gtk::NavigationType::LinkClicked => NavKind::Link,
|
||||||
|
webkit2gtk::NavigationType::FormSubmitted | webkit2gtk::NavigationType::FormResubmitted => {
|
||||||
|
NavKind::Form
|
||||||
|
}
|
||||||
|
_ => NavKind::Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
match decide_new_window(&url, nav, user_gesture) {
|
||||||
|
NewWindowDecision::Deny => None,
|
||||||
|
NewWindowDecision::Tab => {
|
||||||
|
let _ = hub.proxy.send_event(UserEvent::NewWindow { url });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
NewWindowDecision::Ask => {
|
||||||
|
let _ = hub.proxy.send_event(UserEvent::NewWindowAsk { url });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
NewWindowDecision::Popup => create_popup(view, &url, hub),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_popup(
|
||||||
|
opener: &webkit2gtk::WebView,
|
||||||
|
url: &str,
|
||||||
|
hub: &Rc<PopupHub>,
|
||||||
|
) -> Option<gtk::Widget> {
|
||||||
|
let view = webkit2gtk::WebView::with_related_view(opener);
|
||||||
|
if let Some(settings) = WebViewExt::settings(opener) {
|
||||||
|
view.set_settings(&settings);
|
||||||
|
}
|
||||||
|
install_scripts(&view, hub.adblock_enabled.get());
|
||||||
|
|
||||||
|
let title = url::Url::parse(url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| parsed.host_str().map(ToString::to_string))
|
||||||
|
.unwrap_or_else(|| "RustyWolf".to_string());
|
||||||
|
let window = gtk::Window::new(gtk::WindowType::Toplevel);
|
||||||
|
window.set_title(&title);
|
||||||
|
window.set_default_size(960, 700);
|
||||||
|
window.set_position(gtk::WindowPosition::Center);
|
||||||
|
window.add(&view);
|
||||||
|
|
||||||
|
// Popups can spawn popups (SSO hand-offs often do).
|
||||||
|
let nested_hub = hub.clone();
|
||||||
|
view.connect_create(move |view, action| handle_create(view, action, &nested_hub));
|
||||||
|
|
||||||
|
// Keep the window title in sync with the page.
|
||||||
|
let window_weak = window.downgrade();
|
||||||
|
view.connect_title_notify(move |view| {
|
||||||
|
if let Some(window) = window_weak.upgrade()
|
||||||
|
&& let Some(title) = view.title()
|
||||||
|
{
|
||||||
|
window.set_title(&title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal popup shortcuts: Ctrl+W closes, F5 / Ctrl+R reload.
|
||||||
|
let view_weak = view.downgrade();
|
||||||
|
window.connect_key_press_event(move |window, event| {
|
||||||
|
use gtk::gdk::keys::constants as key;
|
||||||
|
let ctrl = event.state().contains(gtk::gdk::ModifierType::CONTROL_MASK);
|
||||||
|
let keyval = event.keyval();
|
||||||
|
if ctrl && keyval == key::w {
|
||||||
|
window.close();
|
||||||
|
return gtk::glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
if keyval == key::F5 || (ctrl && keyval == key::r) {
|
||||||
|
if let Some(view) = view_weak.upgrade() {
|
||||||
|
view.reload();
|
||||||
|
}
|
||||||
|
return gtk::glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
gtk::glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
|
||||||
|
window.show_all();
|
||||||
|
hub.popups.borrow_mut().push(view.downgrade());
|
||||||
|
Some(window.upcast())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_scripts(view: &webkit2gtk::WebView, adblock_enabled: bool) {
|
||||||
|
let Some(manager) = view.user_content_manager() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let source = format!(
|
||||||
|
"{}\nwindow.__rustywolfAdblockInitEnabled = {adblock_enabled};\n{}",
|
||||||
|
super::js::PRIVACY_JS,
|
||||||
|
super::js::ADBLOCK_JS
|
||||||
|
);
|
||||||
|
let script = webkit2gtk::UserScript::new(
|
||||||
|
&source,
|
||||||
|
webkit2gtk::UserContentInjectedFrames::AllFrames,
|
||||||
|
webkit2gtk::UserScriptInjectionTime::Start,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
manager.add_script(&script);
|
||||||
|
}
|
||||||
2261
src/webview.rs
2261
src/webview.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue