Revert "Complete engine rewrite - now featuring Gecko!"
This reverts commit b1be74adb8.
This commit is contained in:
parent
b1be74adb8
commit
e4bb4aa8c4
20 changed files with 2309 additions and 4069 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -7,7 +7,7 @@ version = 4
|
|||
|
||||
[[package]]
|
||||
name = "RustyWolf"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -15,13 +15,10 @@ dependencies = [
|
|||
"dirs",
|
||||
"gtk",
|
||||
"image",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tao",
|
||||
"ureq",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"wry",
|
||||
]
|
||||
|
||||
|
|
@ -2218,7 +2215,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
14
Cargo.toml
14
Cargo.toml
|
|
@ -1,30 +1,24 @@
|
|||
#Cargo.toml
|
||||
[package]
|
||||
name = "RustyWolf"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
dirs = "6.0"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tao = "0.31"
|
||||
ureq = "2.12"
|
||||
url = "2.5"
|
||||
wry = { version = "0.47", features = ["fullscreen"] }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "ico", "png"] }
|
||||
dirs = "6.0"
|
||||
gtk = "0.18.2"
|
||||
webkit2gtk = { version = "2.0", features = ["v2_40"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustyWolf"
|
||||
identifier = "com.rustywolf.browser"
|
||||
icon = ["src/rustywolf.icns"]
|
||||
|
||||
|
|
|
|||
85
README.md
85
README.md
|
|
@ -1,68 +1,46 @@
|
|||
# RustyWolf
|
||||
|
||||
RustyWolf is a minimal, privacy-focused browser shell built in Rust with two engine modes:
|
||||
|
||||
- **WebKit mode (default, light)** — the system webview: `WebKitGTK` on Linux, `WKWebView` on macOS, `WebView2` on Windows. No browser install needed.
|
||||
- **Gecko mode (opt-in)** — real Firefox (Gecko) engine through a RustyWolf-managed profile. Requires an installed Firefox.
|
||||
|
||||
Switch between them any time with the **leaf toggle** in the toolbar (or the leaf on the RustyWolf home page in Gecko mode). Open tabs carry over in both directions.
|
||||
RustyWolf is a minimal, privacy-focused browser shell built with Rust + system webviews.
|
||||
|
||||
(complete honesty, this whole thing is vibecoded, however I never paid for any ai service (I got a free student plan for a year) so if you wanna avoid AI slop, just skip this but I never contributed to the AI BOOM with my money, if you wanna contribute, feel free to do so)
|
||||
|
||||
- macOS: native `WKWebView`
|
||||
- Linux: `WebKitGTK`
|
||||
- Windows: `WebView2`
|
||||
|
||||
No Firefox install. No extension system. Fast startup, clean UI.
|
||||
|
||||
<img
|
||||
alt="image"
|
||||
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;"
|
||||
/>
|
||||
|
||||
## Engines
|
||||
|
||||
| | WebKit mode | Gecko mode |
|
||||
|---|---|---|
|
||||
| Engine | WebKitGTK / WKWebView / WebView2 | Firefox (Gecko), driven by RustyWolf |
|
||||
| Weight | lightest — typically ~300 MB with a tab open | Firefox-weight |
|
||||
| RustyWolf UI | full chrome (tabs, URL bar, ADS, K, D, Properties) | Firefox's own UI + RustyWolf home page |
|
||||
| Adblock | built-in lightweight blocker (ADS toggle) | Firefox strict tracking protection |
|
||||
| Extras | keep-in-ram tabs (`K`), popup gate, download panel, properties page | Firefox features, managed profile, tab handoff |
|
||||
|
||||
The leaf button shows the current engine: **lit green = WebKit**, **dim = Gecko**.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Really, I mean really lightweight in WebKit mode! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
|
||||
- in a gentoo(openrc)+i3wm setup it took only ~285 MB RAM with one tab open (dvorakmortonxd.dev) compared to firefox which took ~866 MB
|
||||
- tabs have a toggle for Keeping-in-ram (`K`): with it off the tab is unloaded, only the URL is kept, and it reloads on click
|
||||
- background tabs are suspended automatically, and the download ticker only runs while something is downloading
|
||||
- Really, I mean really lightweight! the reason I even made this is because I need a lightweight browser to run on my old macbook unibody 2010 4gb ddr3
|
||||
- So light in fact in my gentoo(openrc)+i3wm setup it took only 285mb ram with one tab open (dvorakmortonxd.dev) compared to firefox which took 866mb ram
|
||||
- THATS 3.04 TIMES AS MUCH RAM, so yeah it is light
|
||||
- tabs have a toggle for Keeping-in-ram (`K`), it basically just saves the url if its off and reloads it on reclick, if you toggle it on it keeps the whole page in ram.
|
||||
- Privacy-first defaults: DuckDuckGo home + search fallback from the URL bar
|
||||
- Multi-tab browsing with back, forward, reload, and keyboard shortcuts (`Ctrl/Cmd+T`, `W`, `L`)
|
||||
- Built-in custom adblock with top-bar toggle (`ADS`), persisted across restarts
|
||||
- Popup handling that behaves like a real browser: clicked links open tabs, clicked `window.open` popups (OAuth/"Sign in with Google", SoundCloud, ...) open real popup windows with `window.opener` intact, and scripted popups go through the permission gate
|
||||
- Native user agent by default, so sites (and their login flows) see an honest browser; `--ua-chrome` is available for stubborn sites
|
||||
- Built-in custom adblock with top-bar toggle (`ADS`)
|
||||
- Popup permission gate: asks before a site opens a new tab
|
||||
- Download support with live progress panel (`D`) and session download history
|
||||
- Properties page with session browsing history + download history
|
||||
- Default window size `1000x600` (still fully resizable)
|
||||
- Optional `--private` mode in both engines
|
||||
- Engine switch with tab handoff: WebKit tabs become Firefox tabs and vice versa
|
||||
- Optional `--private` mode when backend support is available
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run --
|
||||
```
|
||||
|
||||
# pick a URL / window title
|
||||
```bash
|
||||
cargo run -- --url https://example.com
|
||||
cargo run -- --url example.com
|
||||
cargo run -- --title "RustyWolf"
|
||||
|
||||
# engine selection (persisted in ~/.config/RustyWolf/config.json)
|
||||
cargo run -- --engine webkit
|
||||
cargo run -- --engine gecko
|
||||
cargo run -- --engine gecko --firefox /path/to/firefox
|
||||
|
||||
# compatible user agent for picky sites (WebKit mode)
|
||||
cargo run -- --ua-chrome
|
||||
|
||||
# misc
|
||||
cargo run -- --private
|
||||
cargo run -- --dry-run
|
||||
|
||||
|
|
@ -70,29 +48,18 @@ cargo run -- --dry-run
|
|||
cargo run -- --linux-backend x11
|
||||
cargo run -- --linux-backend wayland
|
||||
cargo run -- --linux-disable-dmabuf
|
||||
cargo run -- --linux-backend x11 --linux-disable-dmabuf
|
||||
```
|
||||
|
||||
The leaf button in the toolbar switches engines immediately; the choice is remembered for the next launch.
|
||||
|
||||
## Gecko Mode (real Firefox engine)
|
||||
|
||||
There is no embeddable Gecko in 2026, so Gecko mode supervises a real Firefox with a dedicated profile under `~/.config/RustyWolf/gecko-profile`:
|
||||
|
||||
- hardening prefs are rewritten on every launch (strict tracking protection, DNT/GPC, no telemetry, no sponsored content, no Pocket)
|
||||
- downloads go straight to your Downloads folder
|
||||
- new tabs stay blank; the RustyWolf home page is the start page and carries the leaf button back to WebKit
|
||||
- `browser.sessionstore.interval` is shortened so RustyWolf can hand your tabs back to WebKit
|
||||
- Firefox is launched with `--no-remote --new-instance`, so your normal Firefox session is never touched (`--firefox` / `RUSTYWOLF_FIREFOX` to point at a specific binary)
|
||||
|
||||
## Privacy Notes
|
||||
|
||||
WebKit mode applies baseline hardening in page context:
|
||||
RustyWolf applies baseline hardening in page context:
|
||||
|
||||
- `navigator.doNotTrack = "1"`
|
||||
- disables legacy `openDatabase`
|
||||
- disables `RTCPeerConnection`
|
||||
|
||||
Adblocking is built-in and lightweight. It is not full uBlock Origin parity. Gecko mode uses Firefox's own strict tracking protection instead.
|
||||
Adblocking is built-in and lightweight. It is not full uBlock Origin parity.
|
||||
|
||||
# Linux Setup (prerequisites)
|
||||
|
||||
|
|
@ -129,8 +96,6 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
For Gecko mode just make sure Firefox is installed (`emerge www-client/firefox` or the binary release).
|
||||
|
||||
For NVIDIA, make the DRM modeset setting part of the boot command line, not only
|
||||
`/etc/modprobe.d`, then reboot:
|
||||
|
||||
|
|
@ -214,8 +179,6 @@ If you do not want global appmenu integration, remove `appmenu-gtk-module` from
|
|||
/etc/xdg/gtk-3.0/settings.ini
|
||||
```
|
||||
|
||||
If RustyWolf segfaults instantly with `Couldn't register with accessibility bus` when launched from a bare TTY/SSH shell, there is no session D-Bus for the GTK accessibility bridge (`libatk-bridge`). Launch it from your graphical session, or make `DBUS_SESSION_BUS_ADDRESS` available (on many systems `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus`).
|
||||
|
||||
# MacOS
|
||||
|
||||
## installing Homebrew (skip if already installed)
|
||||
|
|
@ -224,25 +187,17 @@ If RustyWolf segfaults instantly with `Couldn't register with accessibility bus`
|
|||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
Gecko mode finds Firefox in `/Applications/Firefox.app` automatically; WebKit mode uses the system `WKWebView`.
|
||||
|
||||
# Windows Setup
|
||||
|
||||
Install the Microsoft Edge WebView2 Runtime (Evergreen) for WebKit mode, then build normally with the Rust MSVC toolchain.
|
||||
Install the Microsoft Edge WebView2 Runtime (Evergreen), then build normally with Rust MSVC toolchain.
|
||||
|
||||
- WebView2 Runtime: https://developer.microsoft.com/microsoft-edge/webview2/
|
||||
- Rust toolchain target: `x86_64-pc-windows-msvc`
|
||||
|
||||
Gecko mode finds Firefox under `Program Files` / `%LOCALAPPDATA%\Mozilla Firefox`, or use `--firefox`.
|
||||
|
||||
## Dev Quick Checks
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo test
|
||||
cargo clippy --all-targets
|
||||
|
||||
# exercises the Gecko handoff against a real headless Firefox
|
||||
cargo test -- --ignored --nocapture
|
||||
```
|
||||
|
|
|
|||
76
src/cli.rs
76
src/cli.rs
|
|
@ -1,30 +1,6 @@
|
|||
//! Command line interface definitions.
|
||||
|
||||
//cli.rs
|
||||
use clap::{Parser, ValueEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Browsing engine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Engine {
|
||||
/// Native system webview (WebKitGTK / WKWebView / WebView2). Lightest.
|
||||
#[default]
|
||||
Webkit,
|
||||
/// Firefox (Gecko) through a RustyWolf-managed profile. Requires Firefox.
|
||||
Gecko,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Engine::Webkit => "webkit",
|
||||
Engine::Gecko => "gecko",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GTK windowing backend (Linux only).
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum LinuxBackend {
|
||||
Auto,
|
||||
|
|
@ -35,50 +11,38 @@ pub enum LinuxBackend {
|
|||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "rustywolf",
|
||||
about = "Lightweight privacy browser (WebKit + Gecko engines)",
|
||||
long_about = "RustyWolf runs a light system-webview browser by default and can switch to real Gecko (Firefox) via the leaf toggle.",
|
||||
about = "Privacy-focused WebKit browser",
|
||||
long_about = "RustyWolf launches an embedded WebKit browser window.",
|
||||
version
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Startup URL (defaults to https://duckduckgo.com)
|
||||
#[arg(long)]
|
||||
#[arg(
|
||||
long,
|
||||
help = "Optional startup URL (defaults to https://duckduckgo.com)"
|
||||
)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Window title (WebKit mode)
|
||||
#[arg(long)]
|
||||
#[arg(long, help = "Set a custom window title")]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// 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)]
|
||||
#[arg(long, help = "Try private/incognito mode when backend supports it")]
|
||||
pub private: bool,
|
||||
|
||||
/// Print launch settings without opening a window
|
||||
#[arg(long)]
|
||||
#[arg(long, help = "Print launch settings without opening a window")]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Firefox binary used by Gecko mode (persisted)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub firefox: Option<PathBuf>,
|
||||
|
||||
/// WebKit mode: send a Chrome-like user agent for sites that
|
||||
/// misbehave with the native WebKitGTK user agent
|
||||
#[arg(long)]
|
||||
pub ua_chrome: bool,
|
||||
|
||||
/// Internal: restore the tab set from the last engine handoff
|
||||
#[arg(long, hide = true)]
|
||||
pub resume: bool,
|
||||
|
||||
/// Linux only: select a GTK windowing backend (auto, x11, wayland)
|
||||
#[arg(long, value_enum, default_value_t = LinuxBackend::Auto)]
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = LinuxBackend::Auto,
|
||||
help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
|
||||
)]
|
||||
pub linux_backend: LinuxBackend,
|
||||
|
||||
/// Linux only: disable the WebKit dmabuf renderer (helps some NVIDIA setups)
|
||||
#[arg(long)]
|
||||
#[arg(
|
||||
long,
|
||||
help = "Linux only: disable WebKit dmabuf renderer (helps some NVIDIA/Wayland setups)"
|
||||
)]
|
||||
pub linux_disable_dmabuf: bool,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
//! Persisted user configuration (engine choice, adblock, Firefox path).
|
||||
|
||||
use crate::cli::Engine;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub engine: Engine,
|
||||
pub adblock: bool,
|
||||
pub firefox_bin: Option<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<!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
330
src/gecko/mod.rs
|
|
@ -1,330 +0,0 @@
|
|||
//! Gecko mode: supervise a RustyWolf-managed Firefox (real Gecko engine).
|
||||
//!
|
||||
//! There is no embeddable Gecko, so RustyWolf runs Firefox with a dedicated
|
||||
//! profile and keeps the experience coherent:
|
||||
//! - the profile carries RustyWolf hardening prefs and the control home page,
|
||||
//! - a loopback control server lets the home page switch back to WebKit,
|
||||
//! - the Firefox session store supplies the tab handoff to the WebKit engine.
|
||||
//!
|
||||
//! This supervisor is tiny (a listener, a child process handle, a poll loop),
|
||||
//! keeping RustyWolf's own RAM/CPU footprint minimal even in Gecko mode.
|
||||
|
||||
mod prefs;
|
||||
mod server;
|
||||
|
||||
use crate::cli::{Cli, Engine};
|
||||
use crate::config::{self, Config};
|
||||
use crate::handoff;
|
||||
use crate::switch;
|
||||
use crate::util;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use server::{ControlServer, Request};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FAVICON: &[u8] = include_bytes!("../rustywolf.ico");
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(120);
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
pub fn launch(cli: &Cli, cfg: &mut Config) -> Result<()> {
|
||||
let firefox = resolve_firefox(cli, cfg)?;
|
||||
let profile = config::gecko_profile_dir();
|
||||
fs::create_dir_all(&profile)
|
||||
.with_context(|| format!("could not create {}", profile.display()))?;
|
||||
|
||||
let server = ControlServer::bind()?;
|
||||
let homepage = format!("http://127.0.0.1:{}/?token={}", server.port, server.token);
|
||||
prefs::write_user_js(&profile, &homepage, dirs::download_dir().as_deref())?;
|
||||
|
||||
let urls = startup_urls(cli);
|
||||
let mut child = spawn_firefox(&firefox, &profile, &urls, cli.private)?;
|
||||
eprintln!(
|
||||
"rustywolf: gecko engine (Firefox {}), profile {}",
|
||||
firefox.display(),
|
||||
profile.display()
|
||||
);
|
||||
|
||||
let home_html = include_str!("home.html")
|
||||
.replace("{{TOKEN}}", &server.token)
|
||||
.into_bytes();
|
||||
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return Ok(()), // user closed Firefox
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(err).context("lost track of the Firefox process"),
|
||||
}
|
||||
|
||||
let Some(mut conn) = server.poll() else {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
continue;
|
||||
};
|
||||
|
||||
match conn.request {
|
||||
Request::Home => {
|
||||
conn.respond("200 OK", "text/html; charset=utf-8", &home_html);
|
||||
}
|
||||
Request::Favicon => {
|
||||
conn.respond("200 OK", "image/x-icon", FAVICON);
|
||||
}
|
||||
Request::Switch => {
|
||||
let captured = handoff::capture_firefox_session(&profile, cli.private);
|
||||
match switch::switch_to(Engine::Webkit, cfg, captured.as_ref(), cli.private) {
|
||||
Ok(()) => {
|
||||
conn.respond_204();
|
||||
terminate(&mut child);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
let body = format!("RustyWolf could not switch engines: {err:#}");
|
||||
conn.respond(
|
||||
"500 Internal Server Error",
|
||||
"text/plain; charset=utf-8",
|
||||
body.as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Request::NotFound => {
|
||||
conn.respond("404 Not Found", "text/plain; charset=utf-8", b"not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabs to open: explicit `--url` wins, then a pending handoff, then the home page.
|
||||
fn startup_urls(cli: &Cli) -> Vec<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
//! Managed Firefox profile: the `user.js` preference set.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Gecko launches rewrite `user.js` so the profile stays RustyWolf-managed.
|
||||
pub fn write_user_js(profile: &Path, homepage: &str, download_dir: Option<&Path>) -> Result<()> {
|
||||
let mut prefs: Vec<(&str, Value)> = vec![
|
||||
// Tab handoff relies on a reasonably fresh session store, and handoff
|
||||
// freshness is a lightness trade-off (2s writes, not the 15s default).
|
||||
("browser.sessionstore.interval", Value::from(2000)),
|
||||
("browser.sessionstore.resume_from_crash", Value::from(true)),
|
||||
// Start on the RustyWolf control page; new tabs stay blank and light.
|
||||
("browser.startup.page", Value::from(1)),
|
||||
("browser.startup.homepage", Value::from(homepage)),
|
||||
(
|
||||
"browser.startup.homepage_override.mstone",
|
||||
Value::from("ignore"),
|
||||
),
|
||||
("startup.homepage_welcome_url", Value::from("")),
|
||||
("startup.homepage_welcome_url.additional", Value::from("")),
|
||||
("startup.homepage_override_url", Value::from("")),
|
||||
("browser.aboutwelcome.enabled", Value::from(false)),
|
||||
("browser.newtabpage.enabled", Value::from(false)),
|
||||
(
|
||||
"browser.newtabpage.activity-stream.enabled",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"browser.newtabpage.activity-stream.showSponsored",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"browser.newtabpage.activity-stream.showSponsoredTopSites",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"browser.newtabpage.activity-stream.feeds.telemetry",
|
||||
Value::from(false),
|
||||
),
|
||||
("browser.shell.checkDefaultBrowser", Value::from(false)),
|
||||
("browser.discovery.enabled", Value::from(false)),
|
||||
("browser.tabs.warnOnClose", Value::from(false)),
|
||||
("browser.tabs.warnOnCloseOtherTabs", Value::from(false)),
|
||||
("browser.warnOnQuit", Value::from(false)),
|
||||
// Lightness: let Firefox unload stale background tabs under pressure.
|
||||
("browser.tabs.unloadOnLowMemory", Value::from(true)),
|
||||
(
|
||||
"browser.tabs.min_inactive_duration_before_unload",
|
||||
Value::from(600_000),
|
||||
),
|
||||
// Downloads land in the system download folder without extra dialogs.
|
||||
("browser.download.useDownloadDir", Value::from(true)),
|
||||
("browser.download.alwaysOpenPanel", Value::from(false)),
|
||||
// Privacy hardening: strict tracking protection, no telemetry, no sponsored content.
|
||||
("privacy.donottrackheader.enabled", Value::from(true)),
|
||||
("privacy.globalprivacycontrol.enabled", Value::from(true)),
|
||||
("browser.contentblocking.category", Value::from("strict")),
|
||||
("privacy.trackingprotection.enabled", Value::from(true)),
|
||||
(
|
||||
"privacy.trackingprotection.pbmode.enabled",
|
||||
Value::from(true),
|
||||
),
|
||||
(
|
||||
"privacy.trackingprotection.socialtracking.enabled",
|
||||
Value::from(true),
|
||||
),
|
||||
(
|
||||
"privacy.trackingprotection.cryptomining.enabled",
|
||||
Value::from(true),
|
||||
),
|
||||
(
|
||||
"privacy.trackingprotection.fingerprinting.enabled",
|
||||
Value::from(true),
|
||||
),
|
||||
(
|
||||
"privacy.annotate_channels.strict_list.enabled",
|
||||
Value::from(true),
|
||||
),
|
||||
("toolkit.telemetry.enabled", Value::from(false)),
|
||||
("toolkit.telemetry.unified", Value::from(false)),
|
||||
(
|
||||
"toolkit.telemetry.shutdownPingSender.enabled",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"datareporting.healthreport.uploadEnabled",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"datareporting.policy.dataSubmissionEnabled",
|
||||
Value::from(false),
|
||||
),
|
||||
("app.shield.optoutstudies.enabled", Value::from(false)),
|
||||
(
|
||||
"browser.crashReports.unsubmittedCheck.autoSubmit2",
|
||||
Value::from(false),
|
||||
),
|
||||
// A quiet address bar: no sponsored suggestions, no search-suggestion calls.
|
||||
("browser.urlbar.quicksuggest.enabled", Value::from(false)),
|
||||
(
|
||||
"browser.urlbar.suggest.quicksuggest.sponsored",
|
||||
Value::from(false),
|
||||
),
|
||||
(
|
||||
"browser.urlbar.suggest.quicksuggest.nonsponsored",
|
||||
Value::from(false),
|
||||
),
|
||||
("browser.urlbar.suggest.topsites", Value::from(false)),
|
||||
("browser.search.suggest.enabled", Value::from(false)),
|
||||
("browser.urlbar.suggest.searches", Value::from(false)),
|
||||
];
|
||||
|
||||
if let Some(dir) = download_dir {
|
||||
prefs.push((
|
||||
"browser.download.dir",
|
||||
Value::from(dir.to_string_lossy().into_owned()),
|
||||
));
|
||||
}
|
||||
|
||||
let mut out = String::from("// Managed by RustyWolf - rewritten on every Gecko launch.\n");
|
||||
for (key, value) in prefs {
|
||||
out.push_str("user_pref(");
|
||||
out.push_str(&serde_json::to_string(key).unwrap());
|
||||
out.push_str(", ");
|
||||
out.push_str(&value.to_string());
|
||||
out.push_str(");\n");
|
||||
}
|
||||
|
||||
fs::write(profile.join("user.js"), out)
|
||||
.with_context(|| format!("could not write {}/user.js", profile.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_managed_prefs() {
|
||||
let dir = std::env::temp_dir().join(format!("rustywolf-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
write_user_js(
|
||||
&dir,
|
||||
"http://127.0.0.1:4242/?token=deadbeef",
|
||||
Some(Path::new("/home/user/Downloads")),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = std::fs::read_to_string(dir.join("user.js")).unwrap();
|
||||
assert!(raw.contains("Managed by RustyWolf"));
|
||||
assert!(
|
||||
raw.contains("\"browser.startup.homepage\", \"http://127.0.0.1:4242/?token=deadbeef\"")
|
||||
);
|
||||
assert!(raw.contains("\"privacy.trackingprotection.enabled\", true"));
|
||||
assert!(raw.contains("\"browser.download.dir\", \"/home/user/Downloads\""));
|
||||
assert!(raw.contains("\"browser.sessionstore.interval\", 2000"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
//! Tiny loopback HTTP control server for Gecko mode.
|
||||
//!
|
||||
//! The RustyWolf home page (served from here) can ask the supervisor to
|
||||
//! switch back to the WebKit engine. Everything is bound to 127.0.0.1 and
|
||||
//! gated behind a per-launch random token.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Home,
|
||||
Switch,
|
||||
Favicon,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
pub struct ControlServer {
|
||||
listener: TcpListener,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
stream: TcpStream,
|
||||
pub request: Request,
|
||||
}
|
||||
|
||||
impl ControlServer {
|
||||
pub fn bind() -> Result<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
350
src/handoff.rs
|
|
@ -1,350 +0,0 @@
|
|||
//! Engine handoff: pass the open tab set between WebKit and Gecko mode,
|
||||
//! plus Firefox session-store reading (mozlz4 = magic + u32 size + LZ4 block).
|
||||
//!
|
||||
//! No external decompression dependency: the session file format is decoded
|
||||
//! with a small in-tree LZ4 block decompressor (see `lz4_block_decompress`).
|
||||
|
||||
use crate::config;
|
||||
use crate::util::now_unix;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Only hand over this many tabs at most (keeps the switch snappy and light).
|
||||
pub const MAX_HANDOFF_TABS: usize = 32;
|
||||
/// A handoff older than this is ignored.
|
||||
pub const MAX_HANDOFF_AGE_SECS: u64 = 600;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandoffTab {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Handoff {
|
||||
pub created_at: u64,
|
||||
#[serde(default)]
|
||||
pub private: bool,
|
||||
#[serde(default)]
|
||||
pub active: usize,
|
||||
#[serde(default)]
|
||||
pub tabs: Vec<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,16 +1,8 @@
|
|||
//! RustyWolf entry point: load config, pick the engine, go.
|
||||
|
||||
//main.rs
|
||||
mod cli;
|
||||
mod config;
|
||||
mod gecko;
|
||||
mod handoff;
|
||||
mod switch;
|
||||
mod util;
|
||||
mod webkit;
|
||||
mod webview;
|
||||
|
||||
use anyhow::Result;
|
||||
use cli::{Cli, Engine};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
|
|
@ -20,63 +12,6 @@ fn main() {
|
|||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse_args();
|
||||
let mut cfg = config::load();
|
||||
|
||||
if cli.dry_run {
|
||||
let engine = cli.engine.unwrap_or(cfg.engine);
|
||||
print_dry_run(&cli, &cfg, engine);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(engine) = cli.engine
|
||||
&& cfg.engine != engine
|
||||
{
|
||||
cfg.engine = engine;
|
||||
config::save(&cfg)?;
|
||||
}
|
||||
if let Some(firefox) = &cli.firefox {
|
||||
cfg.firefox_bin = Some(firefox.clone());
|
||||
config::save(&cfg)?;
|
||||
}
|
||||
|
||||
match cli.engine.unwrap_or(cfg.engine) {
|
||||
Engine::Webkit => webkit::launch(&cli, &mut cfg),
|
||||
Engine::Gecko => gecko::launch(&cli, &mut cfg),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_dry_run(cli: &Cli, cfg: &config::Config, engine: Engine) {
|
||||
let url = cli
|
||||
.url
|
||||
.as_deref()
|
||||
.map(util::normalize_url)
|
||||
.unwrap_or_else(|| util::DEFAULT_HOME.to_string());
|
||||
|
||||
match engine {
|
||||
Engine::Webkit => {
|
||||
let effective_dmabuf =
|
||||
std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string());
|
||||
println!(
|
||||
"engine=webkit url={url} private={} adblock={} ua={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}",
|
||||
cli.private,
|
||||
cfg.adblock,
|
||||
if cli.ua_chrome { "chrome" } else { "native" },
|
||||
cli.linux_backend,
|
||||
cli.linux_disable_dmabuf,
|
||||
);
|
||||
}
|
||||
Engine::Gecko => {
|
||||
let firefox: Option<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()
|
||||
);
|
||||
}
|
||||
}
|
||||
let cli = cli::Cli::parse_args();
|
||||
webview::launch_webkit(&cli)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
//! Engine switching: hand the tab set to the other engine and relaunch.
|
||||
|
||||
use crate::cli::Engine;
|
||||
use crate::config::{self, Config};
|
||||
use crate::handoff::{self, Handoff};
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Persist the handoff, relaunch RustyWolf in `target` mode, remember the choice.
|
||||
pub fn switch_to(
|
||||
target: Engine,
|
||||
cfg: &mut Config,
|
||||
handoff: Option<&Handoff>,
|
||||
private: bool,
|
||||
) -> Result<()> {
|
||||
let has_handoff = match handoff {
|
||||
Some(handoff) if !handoff.tabs.is_empty() => {
|
||||
handoff::save(handoff)?;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
spawn_detached(target, has_handoff, private)?;
|
||||
cfg.engine = target;
|
||||
if let Err(err) = config::save(cfg) {
|
||||
eprintln!("rustywolf: could not persist the engine choice: {err:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start another RustyWolf process in the background (survives our exit).
|
||||
pub fn spawn_detached(target: Engine, resume: bool, private: bool) -> Result<()> {
|
||||
let exe = std::env::current_exe().context("could not locate the RustyWolf binary")?;
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("--engine").arg(target.as_str());
|
||||
if resume {
|
||||
cmd.arg("--resume");
|
||||
}
|
||||
if private {
|
||||
cmd.arg("--private");
|
||||
}
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.pre_exec(|| {
|
||||
// New session so the switched process survives this one's exit.
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
cmd.spawn()
|
||||
.with_context(|| format!("could not launch the {} engine", target.as_str()))?;
|
||||
Ok(())
|
||||
}
|
||||
BIN
src/testdata/recovery.jsonlz4
vendored
BIN
src/testdata/recovery.jsonlz4
vendored
Binary file not shown.
247
src/util.rs
247
src/util.rs
|
|
@ -1,247 +0,0 @@
|
|||
//! Small shared helpers: URL normalization, filenames, timestamps.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const DEFAULT_HOME: &str = "https://duckduckgo.com";
|
||||
pub const NEW_TAB_URL: &str = "https://duckduckgo.com";
|
||||
pub const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
|
||||
|
||||
/// Turn a URL-bar entry into a URL (search terms go to DuckDuckGo).
|
||||
pub fn normalize_url(input: &str) -> String {
|
||||
let trimmed = input.trim();
|
||||
if trimmed == "about:blank" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if looks_like_address(trimmed) {
|
||||
return format!("https://{trimmed}");
|
||||
}
|
||||
let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
|
||||
format!("{SEARCH_URL_PREFIX}{encoded}")
|
||||
}
|
||||
|
||||
pub fn looks_like_address(value: &str) -> bool {
|
||||
value.contains('.')
|
||||
|| value.contains(':')
|
||||
|| value.starts_with("localhost")
|
||||
|| value.starts_with("127.")
|
||||
|| value.starts_with('[')
|
||||
|| value.starts_with('/')
|
||||
}
|
||||
|
||||
/// New-window targets RustyWolf refuses to open as tabs/popups.
|
||||
pub fn should_open_new_window_url(raw: &str) -> bool {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") {
|
||||
return false;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("about:")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(parsed) = url::Url::parse(trimmed) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// DuckDuckGo's in-page "open in a new window" helpers are not real links.
|
||||
let host = parsed.host_str().unwrap_or_default();
|
||||
let path = parsed.path().to_ascii_lowercase();
|
||||
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
|
||||
&& (path == "/post3.html" || path == "/page3" || path == "/page3.html")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn sanitize_filename(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
for ch in raw.chars() {
|
||||
let invalid =
|
||||
matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
|
||||
out.push(if invalid { '_' } else { ch });
|
||||
}
|
||||
let trimmed = out.trim_matches('.').trim();
|
||||
if trimmed.is_empty() {
|
||||
"download.bin".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename_from_url(url: &str) -> String {
|
||||
let raw = url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments().and_then(|mut parts| {
|
||||
parts
|
||||
.next_back()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "download.bin".to_string());
|
||||
|
||||
sanitize_filename(&raw)
|
||||
}
|
||||
|
||||
pub fn ensure_unique_download_path(path: PathBuf) -> PathBuf {
|
||||
if !path.exists() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.filter(|e| !e.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
for idx in 1..=9999 {
|
||||
let file_name = match &ext {
|
||||
Some(ext) => format!("{stem} ({idx}).{ext}"),
|
||||
None => format!("{stem} ({idx})"),
|
||||
};
|
||||
let candidate = parent.join(file_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
parent.join(format!("{stem}-{}", now_unix()))
|
||||
}
|
||||
|
||||
pub fn path_to_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Human timestamp used by history/downloads rows.
|
||||
pub fn now_stamp() -> String {
|
||||
now_unix().to_string()
|
||||
}
|
||||
|
||||
pub fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// RustyWolf's own generated pages (properties, etc).
|
||||
pub fn is_internal_page(url: &str) -> bool {
|
||||
url.starts_with("data:text/html;base64,")
|
||||
}
|
||||
|
||||
pub fn path_string_to_file_url(path: &str) -> String {
|
||||
url::Url::from_file_path(Path::new(path))
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|_| format!("file://{path}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_scheme() {
|
||||
assert_eq!(normalize_url("https://example.com"), "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_https_for_domains() {
|
||||
assert_eq!(normalize_url("example.com"), "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_about_blank() {
|
||||
assert_eq!(normalize_url("about:blank"), "about:blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_search_terms_to_duckduckgo_query() {
|
||||
assert_eq!(
|
||||
normalize_url("privacy browser"),
|
||||
"https://duckduckgo.com/?q=privacy+browser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_single_token_to_duckduckgo_query() {
|
||||
assert_eq!(normalize_url("rust"), "https://duckduckgo.com/?q=rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_before_search() {
|
||||
assert_eq!(
|
||||
normalize_url(" rust webview "),
|
||||
"https://duckduckgo.com/?q=rust+webview"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_web_new_window_targets() {
|
||||
assert!(!should_open_new_window_url(""));
|
||||
assert!(!should_open_new_window_url(" about:blank "));
|
||||
assert!(!should_open_new_window_url("javascript:alert(1)"));
|
||||
assert!(!should_open_new_window_url("data:text/html,hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duckduckgo_internal_popup_paths() {
|
||||
assert!(!should_open_new_window_url(
|
||||
"https://duckduckgo.com/post3.html"
|
||||
));
|
||||
assert!(!should_open_new_window_url("https://duckduckgo.com/page3"));
|
||||
assert!(!should_open_new_window_url(
|
||||
"https://duckduckgo.com/page3.html"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_regular_https_new_window_targets() {
|
||||
assert!(should_open_new_window_url("https://example.com/"));
|
||||
assert!(should_open_new_window_url("https://duckduckgo.com/?q=rust"));
|
||||
assert!(should_open_new_window_url(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_windows_unsafe_filename_chars() {
|
||||
assert_eq!(sanitize_filename("report:2026?.pdf"), "report_2026_.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_filename_becomes_empty() {
|
||||
assert_eq!(sanitize_filename("..."), "download.bin");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
(() => {
|
||||
if (window.__rustywolfAdblockBooted) return;
|
||||
window.__rustywolfAdblockBooted = true;
|
||||
|
||||
// The Rust side bakes the current toggle state in before this script runs.
|
||||
window.__rustywolfAdblockEnabled = window.__rustywolfAdblockInitEnabled !== false;
|
||||
try { delete window.__rustywolfAdblockInitEnabled; } catch (_) {}
|
||||
|
||||
const blockedHosts = [
|
||||
'doubleclick.net',
|
||||
'googlesyndication.com',
|
||||
'adservice.google.com',
|
||||
'google-analytics.com',
|
||||
'googletagmanager.com',
|
||||
'facebook.net',
|
||||
'ads.yahoo.com',
|
||||
'taboola.com',
|
||||
'outbrain.com'
|
||||
];
|
||||
|
||||
const cosmeticSelectors = [
|
||||
'[id^="ad-"]',
|
||||
'[id^="ads-"]',
|
||||
'[class^="ad-"]',
|
||||
'[class^="ads-"]',
|
||||
'[class*=" ad-"]',
|
||||
'[class*=" ads-"]',
|
||||
'[class*="advert"]',
|
||||
'[id*="sponsored"]',
|
||||
'[class*="sponsored"]',
|
||||
'iframe[src*="doubleclick"]',
|
||||
'iframe[src*="adservice"]'
|
||||
];
|
||||
|
||||
const isEnabled = () => window.__rustywolfAdblockEnabled !== false;
|
||||
|
||||
const matchesBlockedHost = (host) => {
|
||||
const lower = (host || '').toLowerCase();
|
||||
return blockedHosts.some((blocked) => lower === blocked || lower.endsWith('.' + blocked));
|
||||
};
|
||||
|
||||
const shouldBlockUrl = (rawUrl) => {
|
||||
if (!isEnabled() || !rawUrl) return false;
|
||||
try {
|
||||
const parsed = new URL(rawUrl, location.href);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
||||
if (matchesBlockedHost(parsed.hostname)) return true;
|
||||
const full = parsed.href.toLowerCase();
|
||||
return full.includes('/ads') || full.includes('adserver') || full.includes('tracking');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeAds = () => {
|
||||
if (!isEnabled()) return;
|
||||
for (const selector of cosmeticSelectors) {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
if (!el.hasAttribute('data-rw-adblock-hidden')) {
|
||||
el.setAttribute('data-rw-adblock-hidden', '1');
|
||||
el.setAttribute('data-rw-prev-display', el.style.getPropertyValue('display') || '');
|
||||
el.setAttribute('data-rw-prev-visibility', el.style.getPropertyValue('visibility') || '');
|
||||
}
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
el.style.setProperty('visibility', 'hidden', 'important');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAds = () => {
|
||||
document.querySelectorAll('[data-rw-adblock-hidden="1"]').forEach((el) => {
|
||||
const prevDisplay = el.getAttribute('data-rw-prev-display') || '';
|
||||
const prevVisibility = el.getAttribute('data-rw-prev-visibility') || '';
|
||||
if (prevDisplay) {
|
||||
el.style.setProperty('display', prevDisplay);
|
||||
} else {
|
||||
el.style.removeProperty('display');
|
||||
}
|
||||
if (prevVisibility) {
|
||||
el.style.setProperty('visibility', prevVisibility);
|
||||
} else {
|
||||
el.style.removeProperty('visibility');
|
||||
}
|
||||
el.removeAttribute('data-rw-adblock-hidden');
|
||||
el.removeAttribute('data-rw-prev-display');
|
||||
el.removeAttribute('data-rw-prev-visibility');
|
||||
if (el.getAttribute('aria-hidden') === 'true') {
|
||||
el.removeAttribute('aria-hidden');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let adObserver = null;
|
||||
let scanQueued = false;
|
||||
|
||||
const scheduleAdScan = () => {
|
||||
if (!isEnabled() || scanQueued) return;
|
||||
scanQueued = true;
|
||||
const run = () => {
|
||||
scanQueued = false;
|
||||
removeAds();
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(run);
|
||||
} else {
|
||||
setTimeout(run, 16);
|
||||
}
|
||||
};
|
||||
|
||||
const stopObserver = () => {
|
||||
if (!adObserver) return;
|
||||
adObserver.disconnect();
|
||||
adObserver = null;
|
||||
};
|
||||
|
||||
const startObserver = () => {
|
||||
stopObserver();
|
||||
if (!isEnabled()) return;
|
||||
const root = document.body || document.documentElement;
|
||||
if (!root) return;
|
||||
adObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes && mutation.addedNodes.length > 0) {
|
||||
scheduleAdScan();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
adObserver.observe(root, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
window.__rustywolfSetAdblockEnabled = (enabled) => {
|
||||
window.__rustywolfAdblockEnabled = !!enabled;
|
||||
if (window.__rustywolfAdblockEnabled) {
|
||||
startObserver();
|
||||
scheduleAdScan();
|
||||
} else {
|
||||
stopObserver();
|
||||
restoreAds();
|
||||
}
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
if (typeof originalFetch === 'function') {
|
||||
window.fetch = function patchedFetch(input, init) {
|
||||
const target = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (shouldBlockUrl(target)) {
|
||||
return Promise.resolve(new Response('', { status: 204, statusText: 'Blocked by RustyWolf' }));
|
||||
}
|
||||
return originalFetch.call(this, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
|
||||
this.__rustywolfUrl = url;
|
||||
return originalXhrOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
const originalXhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function patchedSend() {
|
||||
if (shouldBlockUrl(this.__rustywolfUrl)) {
|
||||
this.abort();
|
||||
return;
|
||||
}
|
||||
return originalXhrSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
const patchSrcSetter = (ctor) => {
|
||||
if (!ctor || !ctor.prototype) return;
|
||||
const desc = Object.getOwnPropertyDescriptor(ctor.prototype, 'src');
|
||||
if (!desc || typeof desc.set !== 'function' || typeof desc.get !== 'function') return;
|
||||
Object.defineProperty(ctor.prototype, 'src', {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: desc.get,
|
||||
set(value) {
|
||||
if (shouldBlockUrl(value)) {
|
||||
return desc.set.call(this, 'about:blank');
|
||||
}
|
||||
return desc.set.call(this, value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
patchSrcSetter(window.HTMLImageElement);
|
||||
patchSrcSetter(window.HTMLScriptElement);
|
||||
patchSrcSetter(window.HTMLIFrameElement);
|
||||
|
||||
if (window.__rustywolfAdblockEnabled) {
|
||||
startObserver();
|
||||
scheduleAdScan();
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
(() => {
|
||||
try {
|
||||
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, get: () => '1' });
|
||||
} catch (_) {}
|
||||
try {
|
||||
Object.defineProperty(window, 'openDatabase', { configurable: true, value: undefined });
|
||||
} catch (_) {}
|
||||
try {
|
||||
if ('BatteryManager' in window) window.BatteryManager = undefined;
|
||||
} catch (_) {}
|
||||
try {
|
||||
if ('RTCPeerConnection' in window) window.RTCPeerConnection = undefined;
|
||||
} catch (_) {}
|
||||
|
||||
// Keep reload shortcuts working when focus is inside the page webview.
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
const key = (ev.key || '').toLowerCase();
|
||||
const wantsReload =
|
||||
key === 'f5' ||
|
||||
((ev.metaKey || ev.ctrlKey) && key === 'r');
|
||||
if (wantsReload) {
|
||||
ev.preventDefault();
|
||||
location.reload();
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
1636
src/webkit/mod.rs
1636
src/webkit/mod.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,164 +0,0 @@
|
|||
//! Real popup windows for Linux/WebKitGTK content views.
|
||||
//!
|
||||
//! wry does not connect WebKitGTK's `create` signal, so new-window requests
|
||||
//! would never produce a window with a live `window.opener`. That breaks
|
||||
//! popup-based logins ("Sign in with Google" and friends). Here we attach the
|
||||
//! signal to the raw `WebKitWebView` and build proper related-view popups.
|
||||
|
||||
use super::{NavKind, NewWindowDecision, UserEvent, decide_new_window};
|
||||
use gtk::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use tao::event_loop::EventLoopProxy;
|
||||
use webkit2gtk::{URIRequestExt, UserContentManagerExt, WebViewExt};
|
||||
|
||||
pub struct PopupHub {
|
||||
pub proxy: EventLoopProxy<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
Normal file
2261
src/webview.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue