RustyWolf/src/webview.rs
dvorakmortonxd 5c7f28b656 redesign browser chrome UI: unified surface, real icons, polished tabs and omnibox
- single design-token block (colors/spacing/radii/type) drives every chrome surface
- tab strip and toolbar merged into one cohesive surface instead of stacked bars
- inline SVG icons replace text glyphs (back/forward/reload, ads, downloads, properties, new tab, KIR pin, close) with tooltips and aria labels
- tabs: rounded shapes, favicon letter badges, masked title truncation, hover close/pin, clear active/hover/suspended states
- URL bar: pill with site-info indicator (HTTPS lock / HTTP warning / internal page), focus glow, select-all on focus
- right-side controls grouped into one capsule; adblock and downloads show clear on/off states
- downloads panel and permission prompt restyled; properties page matches the same tokens
- behavior unchanged: all existing buttons, IPC commands and shortcuts (Ctrl+L/T/W, F5, Ctrl+R) work as before
2026-10-11 18:05:26 +02:00

2568 lines
88 KiB
Rust

//webview.rs
use crate::cli::Cli;
use anyhow::{Result, anyhow};
use base64::Engine;
#[cfg(target_os = "linux")]
use gtk::prelude::*;
use serde_json::json;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tao::dpi::LogicalSize;
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy};
#[cfg(target_os = "linux")]
use tao::platform::unix::WindowExtUnix;
use tao::window::{Icon, Window, WindowBuilder};
#[cfg(target_os = "linux")]
use wry::WebViewBuilderExtUnix;
use wry::{PageLoadEvent, Rect, WebView, WebViewBuilder};
const DEFAULT_HOME: &str = "https://duckduckgo.com";
const NEW_TAB_URL: &str = "https://duckduckgo.com";
const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
const MODERN_BROWSER_UA_BASE: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
const CHROME_HEIGHT_BASE: f64 = 84.0;
const CHROME_HEIGHT_DOWNLOADS_EXTRA: f64 = 104.0;
const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0;
const MAX_HISTORY_ENTRIES: usize = 250;
const MAX_DOWNLOAD_ENTRIES: usize = 200;
const PROPERTIES_MAX_ROWS: usize = 120;
const MAX_TAB_NAV_ENTRIES: usize = 100;
// ---------- Event types -----------------------------------------------
#[derive(Debug)]
enum UserEvent {
ChromeIpc(String),
TitleChanged {
tab_id: u32,
title: String,
},
PageLoaded {
tab_id: u32,
url: String,
},
DownloadStarted {
url: String,
file_path: String,
},
DownloadTotal {
file_path: String,
total_bytes: Option<u64>,
},
DownloadTick,
DownloadCompleted {
url: String,
file_path: Option<String>,
success: bool,
},
OpenInNewTab {
url: String,
},
Quit,
}
// ---------- State -------------------------------------------------------
struct Tab {
id: u32,
title: String,
url: String,
kir_enabled: bool,
webview: Option<WebView>,
back_stack: Vec<String>,
forward_stack: Vec<String>,
pending_nav_from_stack: bool,
}
struct BrowserState {
chrome: WebView,
host_mode: WebViewHostMode,
#[cfg(target_os = "linux")]
gtk_chrome_container: Option<gtk::Box>,
#[cfg(target_os = "linux")]
gtk_content_container: Option<gtk::Box>,
tabs: Vec<Tab>,
history: Vec<HistoryEntry>,
downloads: Vec<DownloadEntry>,
adblock_enabled: bool,
popup_allow_hosts: HashSet<String>,
pending_popup: Option<PendingPopup>,
downloads_panel_open: bool,
active: usize,
next_id: u32,
private: bool,
}
struct PendingPopup {
url: String,
host: String,
}
struct HistoryEntry {
title: String,
url: String,
visited_at: String,
}
struct DownloadEntry {
url: String,
file_path: String,
status: String,
in_progress: bool,
downloaded_bytes: u64,
total_bytes: Option<u64>,
updated_at: String,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WebViewHostMode {
Child,
Window,
}
impl BrowserState {
fn alloc_id(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
id
}
fn find_index(&self, tab_id: u32) -> Option<usize> {
self.tabs.iter().position(|t| t.id == tab_id)
}
}
// ---------- Chrome HTML (static, separate webview) --------------------
const CHROME_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark">
<style>
:root {
--bg-top: #1d1e23;
--bg-bottom: #191a1e;
--surface: rgba(255,255,255,.05);
--surface-hover: rgba(255,255,255,.08);
--surface-active: rgba(255,255,255,.12);
--well: rgba(0,0,0,.30);
--border: rgba(255,255,255,.09);
--border-strong: rgba(255,255,255,.15);
--text: #eef0f4;
--text-dim: #9aa1af;
--accent: #4c7dff;
--accent-strong: #5d8bff;
--accent-soft: rgba(76,125,255,.25);
--accent-text: #aac2ff;
--warn: #e0b25c;
--ok: #46c07a;
--danger: #e06a6a;
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 12px;
--radius-pill: 999px;
--speed: 120ms;
--ease: cubic-bezier(.2,.8,.3,1);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg-bottom);
color: var(--text);
font-family: var(--font);
font-size: 13px;
-webkit-user-select: none; user-select: none;
}
#chrome {
display: flex; flex-direction: column; gap: 4px;
height: 100%; padding: 6px 8px 5px;
background: linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
border-bottom: 1px solid var(--border);
}
/* ---------- Icon buttons ---------- */
.icon-btn {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; padding: 0;
border: 1px solid transparent; border-radius: var(--radius-sm);
background: transparent; color: var(--text-dim);
cursor: pointer; outline: none;
transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
border-color var(--speed) var(--ease), transform var(--speed) var(--ease);
}
.icon-btn svg { display: block; width: 16px; height: 16px; }
.icon-btn:hover { background: var(--surface-hover); color: var(--text); }
.icon-btn:active { background: var(--surface-active); transform: scale(.94); }
.icon-btn:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); color: var(--text); }
.icon-btn.active { background: var(--accent-soft); color: var(--accent-text); }
/* ---------- Tab strip ---------- */
#tabs-row {
display: flex; align-items: center; gap: 6px;
flex: 0 0 34px; height: 34px; min-width: 0;
}
#tabs {
display: flex; align-items: center; gap: 4px;
flex: 1 1 auto; min-width: 0; height: 100%;
overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
}
#tabs::-webkit-scrollbar { display: none; }
#add-btn { width: 28px; height: 28px; }
#add-btn svg { width: 15px; height: 15px; }
.tab {
position: relative;
display: flex; align-items: center; gap: 4px;
flex: 1 1 auto; min-width: 112px; max-width: 220px; height: 30px;
padding: 0 6px 0 8px;
border: 1px solid transparent; border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: default; outline: none;
transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
border-color var(--speed) var(--ease);
}
.tab:hover { background: var(--surface-hover); color: var(--text); }
.tab.active { background: var(--surface-active); border-color: var(--border); color: var(--text); }
.tab:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft); }
.tab-favicon {
flex: 0 0 auto; width: 16px; height: 16px; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; line-height: 1; color: #fff;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
}
.tab-title {
flex: 1 1 auto; min-width: 0; font-size: 12.5px; line-height: 1;
white-space: nowrap; overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, black calc(100% - 14px), transparent);
mask-image: linear-gradient(90deg, black calc(100% - 14px), transparent);
}
.tab.sleeping .tab-title { opacity: .6; }
.tab.sleeping .tab-favicon { opacity: .55; }
.tab-sleep {
flex: 0 0 auto; display: none; align-items: center; justify-content: center;
color: var(--warn); opacity: .85;
}
.tab-sleep svg { display: block; width: 12px; height: 12px; }
.tab.sleeping .tab-sleep { display: inline-flex; }
.tab-pin, .tab-close {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; padding: 0;
border: 0; border-radius: var(--radius-xs);
background: transparent; color: var(--text-dim);
cursor: pointer; outline: none; opacity: 0; pointer-events: none;
transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
opacity var(--speed) var(--ease);
}
.tab-pin svg, .tab-close svg { display: block; width: 12px; height: 12px; }
.tab:hover .tab-pin, .tab:hover .tab-close,
.tab.active .tab-pin, .tab.active .tab-close,
.tab:focus-within .tab-pin, .tab:focus-within .tab-close {
opacity: .85; pointer-events: auto;
}
.tab-pin:hover, .tab-close:hover { background: var(--surface-active); color: var(--text); opacity: 1; }
.tab-pin.on { opacity: 1; pointer-events: auto; color: var(--accent-text); background: var(--accent-soft); }
.tab-pin.on:hover { color: #c9d8ff; background: rgba(76,125,255,.35); }
/* ---------- Toolbar ---------- */
#nav-row {
display: flex; align-items: center; gap: 4px;
flex: 0 0 34px; height: 34px; min-width: 0;
}
#url-pill {
flex: 1 1 auto; min-width: 0; margin: 0 4px;
display: flex; align-items: center; gap: 7px;
height: 32px; padding: 0 12px;
background: var(--well);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
transition: border-color var(--speed) var(--ease), background-color var(--speed) var(--ease),
box-shadow var(--speed) var(--ease);
}
#url-pill:hover { border-color: var(--border-strong); }
#url-pill:focus-within {
border-color: var(--accent);
background: rgba(0,0,0,.42);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.site-icon {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; color: var(--text-dim);
transition: color var(--speed) var(--ease);
}
.site-icon svg { display: none; width: 14px; height: 14px; }
.site-icon[data-state='search'] .i-search,
.site-icon[data-state='secure'] .i-secure,
.site-icon[data-state='insecure'] .i-insecure,
.site-icon[data-state='internal'] .i-internal { display: block; }
.site-icon[data-state='secure'] { color: #8fd6a8; }
.site-icon[data-state='insecure'] { color: var(--warn); }
.site-icon[data-state='internal'] { color: var(--accent-text); }
#url-input {
flex: 1 1 auto; min-width: 0; height: 100%; padding: 0;
border: 0; outline: none; background: transparent;
color: var(--text); font: inherit; font-size: 13px;
-webkit-user-select: text; user-select: text;
}
#url-input::placeholder { color: var(--text-dim); opacity: .85; }
#actions {
flex: 0 0 auto; margin-left: 4px;
display: inline-flex; align-items: center; gap: 2px; padding: 2px;
border: 1px solid var(--border); border-radius: var(--radius-md);
background: var(--surface);
}
#actions .icon-btn { width: 28px; height: 28px; border-radius: 7px; }
#adblock-btn .icon-on { display: none; }
#adblock-btn.active .icon-on { display: block; }
#adblock-btn.active .icon-off { display: none; }
/* ---------- Downloads panel ---------- */
#downloads-row {
display: none; flex: 1 1 auto; min-height: 0;
flex-direction: column; gap: 6px; overflow-y: auto; padding: 8px;
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--surface);
}
#downloads-row::-webkit-scrollbar { width: 8px; }
#downloads-row::-webkit-scrollbar-thumb { background: rgba(255,255,255,.16); border-radius: var(--radius-pill); }
.dl-empty {
display: flex; flex: 1 1 auto; flex-direction: column;
align-items: center; justify-content: center; gap: 6px;
color: var(--text-dim); font-size: 12px;
}
.dl-empty svg { width: 20px; height: 20px; opacity: .7; }
.dl-item {
flex: 0 0 auto;
display: flex; flex-direction: column; gap: 5px;
padding: 8px 10px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: rgba(0,0,0,.22);
}
.dl-title {
font-size: 12px; font-weight: 600; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dl-meta {
font-size: 11px; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dl-track { width: 100%; height: 5px; border-radius: var(--radius-pill); background: rgba(255,255,255,.10); overflow: hidden; }
.dl-fill { height: 100%; width: 0%; border-radius: var(--radius-pill); 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%); } }
/* ---------- Popup permission prompt ---------- */
#popup-row {
display: none; align-items: center; gap: 10px;
flex: 0 0 40px; height: 40px; padding: 0 10px 0 12px;
border: 1px solid rgba(76,125,255,.35); border-radius: var(--radius-md);
background: rgba(76,125,255,.12); font-size: 12.5px; color: var(--text);
}
.popup-glyph { flex: 0 0 auto; width: 15px; height: 15px; color: var(--accent-text); }
#popup-message { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#popup-actions { flex: 0 0 auto; display: inline-flex; gap: 6px; }
.btn {
height: 26px; padding: 0 13px;
border: 1px solid var(--border-strong); border-radius: var(--radius-pill);
background: transparent; color: var(--text);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer; outline: none;
transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease),
border-color var(--speed) var(--ease), transform var(--speed) var(--ease);
}
.btn:hover { background: var(--surface-hover); }
.btn:active { transform: scale(.96); }
.btn:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent-strong); }
</style>
</head>
<body>
<div id="chrome">
<div id="tabs-row">
<div id="tabs" role="tablist" aria-label="Tabs"></div>
<button id="add-btn" class="icon-btn" title="New tab (Ctrl+T)" aria-label="New tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
</div>
<div id="nav-row">
<button id="back" class="icon-btn" title="Back" aria-label="Back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
</button>
<button id="forward" class="icon-btn" title="Forward" aria-label="Forward">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</button>
<button id="reload" class="icon-btn" title="Reload (F5 / Ctrl+R)" aria-label="Reload">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<div id="url-pill">
<span id="site-icon" class="site-icon" data-state="search" title="Search or enter address">
<svg class="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<svg class="i-secure" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
<svg class="i-insecure" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<svg class="i-internal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</span>
<input id="url-input" type="text" placeholder="Search or enter address"
autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div id="actions" role="group" aria-label="Browser tools">
<button id="adblock-btn" class="icon-btn" title="Ad blocking: Off" aria-label="Toggle ad blocking" aria-pressed="false">
<svg class="icon-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
<svg class="icon-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9.5 14.5 5-5"/><path d="m14.5 14.5-5-5"/></svg>
</button>
<button id="downloads-btn" class="icon-btn" title="Downloads" aria-label="Downloads" aria-pressed="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg>
</button>
<button id="props-btn" class="icon-btn" title="Properties - history and downloads" aria-label="Properties">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
</div>
</div>
<div id="downloads-row" aria-label="Downloads"></div>
<div id="popup-row" role="alert">
<svg class="popup-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
<div id="popup-message"></div>
<div id="popup-actions">
<button id="popup-no" class="btn">Not now</button>
<button id="popup-yes" class="btn primary">Allow</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 tabStrip = document.getElementById('tabs');
const siteIcon = document.getElementById('site-icon');
let currentUrl = '';
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('add-btn').addEventListener('click', () => post('new_tab'));
document.getElementById('popup-yes').addEventListener('click', () => post('popup_allow_yes'));
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
input.addEventListener('focus', () => input.select());
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
const next = normalize(input.value);
if (next) {
post('navigate:' + next);
input.blur();
}
return;
}
if (ev.key === 'Escape') {
ev.preventDefault();
input.value = currentUrl;
input.blur();
}
});
// Plain mouse wheel scrolls the tab strip horizontally.
tabStrip.addEventListener('wheel', (ev) => {
if (ev.deltaY === 0) return;
ev.preventDefault();
tabStrip.scrollLeft += ev.deltaY;
}, { passive: false });
const setSiteIcon = (url) => {
let state = 'search';
let hint = 'Search or enter address';
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:') { state = 'secure'; hint = 'Connection is secure (HTTPS)'; }
else if (parsed.protocol === 'http:') { state = 'insecure'; hint = 'Not secure (HTTP)'; }
else if (parsed.protocol === 'data:' || parsed.protocol === 'file:' || parsed.protocol === 'about:') {
state = 'internal'; hint = 'RustyWolf internal page';
}
} catch (_) {}
siteIcon.dataset.state = state;
siteIcon.title = hint;
};
const hostOf = (url) => {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return ''; }
};
const hashHue = (text) => {
let hash = 0;
for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) % 360;
return hash;
};
const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>';
const ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
const ICON_MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
const shortTitle = (tab, i) => {
if (tab.title && tab.title.trim()) return tab.title.trim();
const host = hostOf(tab.url);
return host || 'Tab ' + (i + 1);
};
const makeTab = (tab, i, activeId) => {
const isActive = tab.id === activeId;
const el = document.createElement('div');
el.className = 'tab' + (isActive ? ' active' : '') + (tab.loaded ? '' : ' sleeping');
el.title = tab.url;
el.setAttribute('role', 'tab');
el.setAttribute('aria-selected', isActive ? 'true' : 'false');
el.tabIndex = 0;
el.addEventListener('click', () => post('switch_tab:' + tab.id));
el.addEventListener('keydown', (ev) => {
if (ev.target !== el) return;
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
post('switch_tab:' + tab.id);
}
});
el.addEventListener('auxclick', (ev) => {
if (ev.button === 1) {
ev.preventDefault();
post('close_tab:' + tab.id);
}
});
const host = hostOf(tab.url);
const favicon = document.createElement('span');
favicon.className = 'tab-favicon';
if (host) {
const hue = hashHue(host);
favicon.textContent = host.charAt(0).toUpperCase();
favicon.style.background = 'linear-gradient(135deg, hsl(' + hue + ', 62%, 50%), hsl(' + hue + ', 68%, 36%))';
favicon.title = host;
} else if ((tab.url || '').startsWith('data:')) {
favicon.textContent = 'R';
favicon.style.background = 'linear-gradient(135deg, hsl(218, 62%, 52%), hsl(218, 68%, 38%))';
favicon.title = 'RustyWolf internal page';
} else {
favicon.textContent = '?';
favicon.style.background = 'linear-gradient(135deg, hsl(220, 10%, 38%), hsl(220, 12%, 28%))';
favicon.title = 'New tab';
}
el.appendChild(favicon);
const title = document.createElement('span');
title.className = 'tab-title';
title.textContent = shortTitle(tab, i);
el.appendChild(title);
if (!tab.loaded) {
const sleep = document.createElement('span');
sleep.className = 'tab-sleep';
sleep.title = 'Suspended to save memory - click the tab to reload it';
sleep.innerHTML = ICON_MOON;
el.appendChild(sleep);
}
const pin = document.createElement('button');
pin.className = 'tab-pin' + (tab.kirEnabled ? ' on' : '');
pin.title = tab.kirEnabled
? 'Keeping in RAM (KIR): on - click to let it suspend when inactive'
: 'Keeping in RAM (KIR): off - click to keep this tab loaded in the background';
pin.setAttribute('aria-label', 'Toggle keeping in RAM');
pin.setAttribute('aria-pressed', tab.kirEnabled ? 'true' : 'false');
pin.innerHTML = ICON_PIN;
pin.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
post('toggle_kir:' + tab.id);
});
el.appendChild(pin);
const close = document.createElement('button');
close.className = 'tab-close';
close.title = 'Close tab (Ctrl+W)';
close.setAttribute('aria-label', 'Close tab');
close.innerHTML = ICON_CLOSE;
close.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
post('close_tab:' + tab.id);
});
el.appendChild(close);
return el;
};
window.__rustywolfSetTabs = (state) => {
tabStrip.innerHTML = '';
state.tabs.forEach((tab, i) => tabStrip.appendChild(makeTab(tab, i, state.activeId)));
const active = state.tabs.find((t) => t.id === state.activeId);
currentUrl = (active && active.url && active.url !== 'about:blank') ? active.url : '';
setSiteIcon(currentUrl);
if (document.activeElement !== input) input.value = currentUrl;
};
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 = '';
button.classList.toggle('active', panelOpen);
button.setAttribute('aria-pressed', panelOpen ? 'true' : 'false');
if (!panelOpen) {
row.style.display = 'none';
return;
}
row.style.display = 'flex';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'dl-empty';
const glyph = document.createElement('span');
glyph.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg>';
const label = document.createElement('span');
label.textContent = 'No active downloads';
empty.appendChild(glyph);
empty.appendChild(label);
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;
button.classList.toggle('active', !!enabled);
button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
button.title = enabled
? 'Ad blocking: on - click to turn off'
: 'Ad blocking: off - click to turn on';
};
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>"#;
// ---------- Privacy-only init script for content webviews ------------
const PRIVACY_JS: &str = r#"
Object.defineProperty(navigator, 'doNotTrack', { get: () => '1' });
Object.defineProperty(window, 'openDatabase', { value: undefined });
if ('BatteryManager' in window) window.BatteryManager = undefined;
if ('RTCPeerConnection' in window) window.RTCPeerConnection = undefined;
// Keep reload shortcuts working when focus is inside the page webview.
window.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
const key = (ev.key || '').toLowerCase();
const wantsReload =
key === 'f5' ||
((ev.metaKey || ev.ctrlKey) && key === 'r');
if (wantsReload) {
ev.preventDefault();
location.reload();
}
}, true);
"#;
const ADBLOCK_JS: &str = r#"
(() => {
if (window.__rustywolfAdblockBooted) return;
window.__rustywolfAdblockBooted = true;
const storageKey = '__rustywolf_adblock_enabled';
const readPersistedEnabled = () => {
try {
const raw = window.localStorage && window.localStorage.getItem(storageKey);
if (raw === '0') return false;
if (raw === '1') return true;
} catch (_) {}
return false;
};
const persistEnabled = (enabled) => {
try {
if (window.localStorage) {
window.localStorage.setItem(storageKey, enabled ? '1' : '0');
}
} catch (_) {}
};
window.__rustywolfAdblockEnabled = readPersistedEnabled();
const blockedHosts = [
'doubleclick.net',
'googlesyndication.com',
'adservice.google.com',
'google-analytics.com',
'googletagmanager.com',
'facebook.net',
'ads.yahoo.com',
'taboola.com',
'outbrain.com'
];
const cosmeticSelectors = [
'[id^="ad-"]',
'[id^="ads-"]',
'[class^="ad-"]',
'[class^="ads-"]',
'[class*=" ad-"]',
'[class*=" ads-"]',
'[class*="advert"]',
'[id*="sponsored"]',
'[class*="sponsored"]',
'iframe[src*="doubleclick"]',
'iframe[src*="adservice"]'
];
const isEnabled = () => window.__rustywolfAdblockEnabled !== false;
const matchesBlockedHost = (host) => {
const lower = (host || '').toLowerCase();
return blockedHosts.some((blocked) => lower === blocked || lower.endsWith('.' + blocked));
};
const shouldBlockUrl = (rawUrl) => {
if (!isEnabled() || !rawUrl) return false;
try {
const parsed = new URL(rawUrl, location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (matchesBlockedHost(parsed.hostname)) return true;
const full = parsed.href.toLowerCase();
return full.includes('/ads') || full.includes('adserver') || full.includes('tracking');
} catch (_) {
return false;
}
};
const removeAds = () => {
if (!isEnabled()) return;
for (const selector of cosmeticSelectors) {
document.querySelectorAll(selector).forEach((el) => {
if (!el.hasAttribute('data-rw-adblock-hidden')) {
el.setAttribute('data-rw-adblock-hidden', '1');
el.setAttribute('data-rw-prev-display', el.style.getPropertyValue('display') || '');
el.setAttribute('data-rw-prev-visibility', el.style.getPropertyValue('visibility') || '');
}
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('visibility', 'hidden', 'important');
el.setAttribute('aria-hidden', 'true');
});
}
};
const restoreAds = () => {
document.querySelectorAll('[data-rw-adblock-hidden="1"]').forEach((el) => {
const prevDisplay = el.getAttribute('data-rw-prev-display') || '';
const prevVisibility = el.getAttribute('data-rw-prev-visibility') || '';
if (prevDisplay) {
el.style.setProperty('display', prevDisplay);
} else {
el.style.removeProperty('display');
}
if (prevVisibility) {
el.style.setProperty('visibility', prevVisibility);
} else {
el.style.removeProperty('visibility');
}
el.removeAttribute('data-rw-adblock-hidden');
el.removeAttribute('data-rw-prev-display');
el.removeAttribute('data-rw-prev-visibility');
if (el.getAttribute('aria-hidden') === 'true') {
el.removeAttribute('aria-hidden');
}
});
};
let adObserver = null;
let scanQueued = false;
const scheduleAdScan = () => {
if (!isEnabled() || scanQueued) return;
scanQueued = true;
const run = () => {
scanQueued = false;
removeAds();
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(run);
} else {
setTimeout(run, 16);
}
};
const stopObserver = () => {
if (!adObserver) return;
adObserver.disconnect();
adObserver = null;
};
const startObserver = () => {
stopObserver();
if (!isEnabled()) return;
const root = document.body || document.documentElement;
if (!root) return;
adObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes && mutation.addedNodes.length > 0) {
scheduleAdScan();
return;
}
}
});
adObserver.observe(root, { childList: true, subtree: true });
};
window.__rustywolfSetAdblockEnabled = (enabled) => {
window.__rustywolfAdblockEnabled = !!enabled;
persistEnabled(window.__rustywolfAdblockEnabled);
if (window.__rustywolfAdblockEnabled) {
startObserver();
scheduleAdScan();
} else {
stopObserver();
restoreAds();
}
};
const originalFetch = window.fetch;
if (typeof originalFetch === 'function') {
window.fetch = function patchedFetch(input, init) {
const target = typeof input === 'string' ? input : (input && input.url) || '';
if (shouldBlockUrl(target)) {
return Promise.resolve(new Response('', { status: 204, statusText: 'Blocked by RustyWolf' }));
}
return originalFetch.call(this, input, init);
};
}
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__rustywolfUrl = url;
return originalXhrOpen.apply(this, arguments);
};
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function patchedSend() {
if (shouldBlockUrl(this.__rustywolfUrl)) {
this.abort();
return;
}
return originalXhrSend.apply(this, arguments);
};
const patchSrcSetter = (ctor) => {
if (!ctor || !ctor.prototype) return;
const desc = Object.getOwnPropertyDescriptor(ctor.prototype, 'src');
if (!desc || typeof desc.set !== 'function' || typeof desc.get !== 'function') return;
Object.defineProperty(ctor.prototype, 'src', {
configurable: true,
enumerable: desc.enumerable,
get: desc.get,
set(value) {
if (shouldBlockUrl(value)) {
return desc.set.call(this, 'about:blank');
}
return desc.set.call(this, value);
}
});
};
patchSrcSetter(window.HTMLImageElement);
patchSrcSetter(window.HTMLScriptElement);
patchSrcSetter(window.HTMLIFrameElement);
startObserver();
scheduleAdScan();
})();
"#;
// ---------- Entry point ------------------------------------------------
pub fn launch_webkit(args: &Cli) -> Result<()> {
apply_linux_runtime_overrides(args);
let mut host_mode = resolve_webview_host_mode(args);
let start_url = args
.url
.as_deref()
.map(normalize_url)
.unwrap_or_else(|| DEFAULT_HOME.to_string());
let title = args
.title
.clone()
.unwrap_or_else(|| "RustyWolf".to_string());
if args.dry_run {
let effective_dmabuf =
std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "0".to_string());
println!(
"engine=webkit url={start_url} private={} linux_backend={:?} linux_disable_dmabuf={} effective_webkit_disable_dmabuf={effective_dmabuf}",
args.private, args.linux_backend, args.linux_disable_dmabuf
);
return Ok(());
}
let event_loop: EventLoop<UserEvent> = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
let window = WindowBuilder::new()
.with_title(title)
.with_window_icon(load_window_icon())
.with_inner_size(LogicalSize::new(1000.0_f64, 600.0_f64))
.build(&event_loop)
.map_err(|err| anyhow!("Failed to create window: {err}"))?;
#[cfg(target_os = "linux")]
let (gtk_chrome_container, gtk_content_container) = if host_mode == WebViewHostMode::Window {
let (chrome, content) = create_linux_webview_containers(&window)?;
set_linux_chrome_container_height(&chrome, CHROME_HEIGHT_BASE);
(Some(chrome), Some(content))
} else {
(None, None)
};
let (chrome, effective_host_mode) = build_chrome(
&window,
&proxy,
host_mode,
#[cfg(target_os = "linux")]
gtk_chrome_container.as_ref(),
)?;
host_mode = effective_host_mode;
let mut state = BrowserState {
chrome,
host_mode,
#[cfg(target_os = "linux")]
gtk_chrome_container,
#[cfg(target_os = "linux")]
gtk_content_container,
tabs: Vec::new(),
history: Vec::new(),
downloads: Vec::new(),
adblock_enabled: false,
popup_allow_hosts: HashSet::new(),
pending_popup: None,
downloads_panel_open: false,
active: 0,
next_id: 1,
private: args.private,
};
open_tab(&mut state, &window, &proxy, start_url)?;
let tick_proxy = proxy.clone();
thread::spawn(move || {
loop {
thread::sleep(Duration::from_millis(300));
if tick_proxy.send_event(UserEvent::DownloadTick).is_err() {
break;
}
}
});
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(ev) => {
if matches!(ev, UserEvent::Quit) {
*control_flow = ControlFlow::Exit;
} else if let Err(e) = handle_event(ev, &window, &proxy, &mut state) {
eprintln!("event error: {e:#}");
}
}
Event::WindowEvent {
event: WindowEvent::Resized(_size),
..
} => {
reflow_layout(&window, &state);
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
*control_flow = ControlFlow::Exit;
}
_ => {}
}
});
}
fn apply_linux_runtime_overrides(_args: &Cli) {
#[cfg(target_os = "linux")]
{
sanitize_gtk_modules_env();
match _args.linux_backend {
crate::cli::LinuxBackend::Auto => {}
crate::cli::LinuxBackend::X11 => unsafe {
std::env::set_var("WINIT_UNIX_BACKEND", "x11");
std::env::set_var("GDK_BACKEND", "x11");
},
crate::cli::LinuxBackend::Wayland => unsafe {
std::env::set_var("WINIT_UNIX_BACKEND", "wayland");
std::env::set_var("GDK_BACKEND", "wayland");
},
}
if should_disable_webkit_dmabuf(_args) {
// WebKitGTK + NVIDIA can fail with dmabuf/gbm allocation on some systems.
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
if !_args.linux_disable_dmabuf {
eprintln!(
"Detected NVIDIA X11 WebKitGTK; disabled broken dmabuf renderer. Media codecs remain enabled."
);
}
}
}
}
}
#[cfg(target_os = "linux")]
fn should_disable_webkit_dmabuf(args: &Cli) -> bool {
if args.linux_disable_dmabuf {
return true;
}
if std::env::var("WEBKIT_FORCE_DMABUF_RENDERER")
.ok()
.as_deref()
== Some("1")
{
return false;
}
is_linux_x11_session() && is_linux_nvidia_graphics()
}
#[cfg(target_os = "linux")]
fn is_linux_x11_session() -> bool {
std::env::var("XDG_SESSION_TYPE")
.map(|value| value.eq_ignore_ascii_case("x11"))
.unwrap_or(false)
|| (std::env::var_os("DISPLAY").is_some() && std::env::var_os("WAYLAND_DISPLAY").is_none())
}
#[cfg(target_os = "linux")]
fn is_linux_nvidia_graphics() -> bool {
env_value_contains("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|| env_value_contains("GBM_BACKEND", "nvidia")
|| env_value_contains("__NV_PRIME_RENDER_OFFLOAD", "1")
|| Path::new("/proc/driver/nvidia/version").exists()
|| Path::new("/sys/module/nvidia/version").exists()
|| Path::new("/dev/nvidiactl").exists()
}
#[cfg(target_os = "linux")]
fn env_value_contains(name: &str, needle: &str) -> bool {
std::env::var(name)
.map(|value| value.to_ascii_lowercase().contains(needle))
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn sanitize_gtk_modules_env() {
fn is_appmenu_module(token: &str) -> bool {
let normalized = token.trim().trim_end_matches(".so").to_ascii_lowercase();
normalized == "appmenu-gtk-module"
|| normalized == "appmenu"
|| normalized.ends_with("/appmenu-gtk-module")
}
fn sanitize_var(name: &str) {
match std::env::var(name) {
Ok(raw) => {
let mut kept = Vec::new();
for token in
raw.split(|c: char| c == ':' || c == ';' || c == ',' || c.is_ascii_whitespace())
{
let trimmed = token.trim();
if trimmed.is_empty() || is_appmenu_module(trimmed) {
continue;
}
kept.push(trimmed.to_string());
}
let next = kept.join(":");
if next == raw {
return;
}
unsafe {
// Keep GTK from auto-loading the missing appmenu module on desktops
// that export it globally.
std::env::set_var(name, next);
}
}
Err(_) => unsafe {
// Force an explicit empty override so GTK does not inherit a desktop-level
// appmenu module setting from system configuration.
std::env::set_var(name, "");
},
}
}
sanitize_var("GTK_MODULES");
sanitize_var("GTK3_MODULES");
unsafe {
std::env::set_var("UBUNTU_MENUPROXY", "0");
}
}
fn resolve_webview_host_mode(args: &Cli) -> WebViewHostMode {
#[cfg(target_os = "linux")]
{
let _ = args;
WebViewHostMode::Window
}
#[cfg(not(target_os = "linux"))]
{
let _ = args;
WebViewHostMode::Child
}
}
// ---------- Event handling ---------------------------------------------
fn handle_event(
ev: UserEvent,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
state: &mut BrowserState,
) -> Result<()> {
match ev {
UserEvent::ChromeIpc(msg) => handle_chrome_ipc(&msg, window, proxy, state)?,
UserEvent::TitleChanged { tab_id, title } => {
if let Some(i) = state.find_index(tab_id) {
if !title.trim().is_empty() {
state.tabs[i].title = title;
push_tabs(state);
}
}
}
UserEvent::PageLoaded { tab_id, url } => {
if let Some(i) = state.find_index(tab_id) {
if !url.is_empty() && url != "about:blank" {
let previous = state.tabs[i].url.clone();
if url != previous {
if state.tabs[i].pending_nav_from_stack {
state.tabs[i].pending_nav_from_stack = false;
} else if !previous.is_empty()
&& previous != "about:blank"
&& !is_internal_page(&previous)
{
state.tabs[i].back_stack.push(previous);
trim_tab_nav_stack(&mut state.tabs[i].back_stack);
state.tabs[i].forward_stack.clear();
}
state.tabs[i].url = url;
} else {
state.tabs[i].pending_nav_from_stack = false;
}
if !is_internal_page(&state.tabs[i].url) {
append_history(
state,
state.tabs[i].title.clone(),
state.tabs[i].url.clone(),
);
}
let enabled = if state.adblock_enabled {
"true"
} else {
"false"
};
if let Some(webview) = state.tabs[i].webview.as_ref() {
let _ = webview.evaluate_script(&format!(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
));
}
push_tabs(state);
}
}
}
UserEvent::DownloadStarted { url, file_path } => {
state.downloads.push(DownloadEntry {
url,
file_path,
status: "In progress".to_string(),
in_progress: true,
downloaded_bytes: 0,
total_bytes: None,
updated_at: now_stamp(),
});
trim_downloads(state);
push_downloads(state);
}
UserEvent::DownloadTotal {
file_path,
total_bytes,
} => {
if let Some(entry) = state
.downloads
.iter_mut()
.rev()
.find(|d| d.file_path == file_path && d.in_progress)
{
entry.total_bytes = total_bytes;
push_downloads(state);
}
}
UserEvent::DownloadTick => {
let mut changed = false;
for entry in state.downloads.iter_mut().filter(|d| d.in_progress) {
if let Ok(meta) = fs::metadata(&entry.file_path) {
let bytes = meta.len();
if bytes != entry.downloaded_bytes {
entry.downloaded_bytes = bytes;
changed = true;
}
}
}
if changed {
push_downloads(state);
}
}
UserEvent::DownloadCompleted {
url,
file_path,
success,
} => {
let path = file_path.unwrap_or_else(|| "Unknown destination".to_string());
if let Some(entry) = state
.downloads
.iter_mut()
.rev()
.find(|d| d.url == url && d.in_progress)
{
entry.file_path = path;
entry.status = if success {
"Completed".to_string()
} else {
"Failed".to_string()
};
entry.in_progress = false;
if let Ok(meta) = fs::metadata(&entry.file_path) {
entry.downloaded_bytes = meta.len();
}
entry.updated_at = now_stamp();
} else {
state.downloads.push(DownloadEntry {
url,
file_path: path,
status: if success {
"Completed".to_string()
} else {
"Failed".to_string()
},
in_progress: false,
downloaded_bytes: 0,
total_bytes: None,
updated_at: now_stamp(),
});
trim_downloads(state);
}
push_downloads(state);
}
UserEvent::OpenInNewTab { url } => {
if should_open_new_tab_url(&url) {
let trimmed = url.trim();
let host = url::Url::parse(trimmed)
.ok()
.and_then(|u| u.host_str().map(ToString::to_string))
.unwrap_or_default();
if !host.is_empty() && state.popup_allow_hosts.contains(&host) {
open_tab(state, window, proxy, trimmed.to_string())?;
} else if state.pending_popup.is_none() {
state.pending_popup = Some(PendingPopup {
url: trimmed.to_string(),
host,
});
push_popup_prompt(state);
reflow_layout(window, state);
}
}
}
UserEvent::Quit => {}
}
Ok(())
}
fn handle_chrome_ipc(
message: &str,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
state: &mut BrowserState,
) -> Result<()> {
if let Some(raw_url) = message.strip_prefix("navigate:") {
ensure_active_tab_loaded(state, window, proxy)?;
if let Some(tab) = active_tab_mut(state) {
let next = normalize_url(raw_url);
if next != tab.url && !tab.url.is_empty() && tab.url != "about:blank" {
tab.back_stack.push(tab.url.clone());
trim_tab_nav_stack(&mut tab.back_stack);
tab.forward_stack.clear();
}
tab.pending_nav_from_stack = false;
tab.url = next.clone();
tab.title = "Loading…".to_string();
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.load_url(&next);
}
push_tabs(state);
}
return Ok(());
}
if message == "back" {
ensure_active_tab_loaded(state, window, proxy)?;
if let Some(tab) = active_tab_mut(state) {
if let Some(target) = tab.back_stack.pop() {
if !tab.url.is_empty() && tab.url != "about:blank" {
tab.forward_stack.push(tab.url.clone());
trim_tab_nav_stack(&mut tab.forward_stack);
}
tab.pending_nav_from_stack = true;
tab.url = target.clone();
tab.title = "Loading…".to_string();
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.load_url(&target);
}
push_tabs(state);
} else {
run_script_on_active_tab(state, "history.back();");
}
}
return Ok(());
}
if message == "forward" {
ensure_active_tab_loaded(state, window, proxy)?;
if let Some(tab) = active_tab_mut(state) {
if let Some(target) = tab.forward_stack.pop() {
if !tab.url.is_empty() && tab.url != "about:blank" {
tab.back_stack.push(tab.url.clone());
trim_tab_nav_stack(&mut tab.back_stack);
}
tab.pending_nav_from_stack = true;
tab.url = target.clone();
tab.title = "Loading…".to_string();
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.load_url(&target);
}
push_tabs(state);
} else {
run_script_on_active_tab(state, "history.forward();");
}
}
return Ok(());
}
if message == "reload" {
run_script_on_active_tab(state, "location.reload();");
return Ok(());
}
if message == "new_tab" {
open_tab(state, window, proxy, NEW_TAB_URL.to_string())?;
return Ok(());
}
if message == "open_properties" {
let properties_url = properties_data_url(state);
open_tab(state, window, proxy, properties_url)?;
if let Some(tab) = state.tabs.last_mut() {
tab.title = "Properties".to_string();
}
push_tabs(state);
return Ok(());
}
if message == "toggle_downloads" {
state.downloads_panel_open = !state.downloads_panel_open;
reflow_layout(window, state);
push_downloads(state);
return Ok(());
}
if message == "popup_allow_yes" {
if let Some(pending) = state.pending_popup.take() {
if !pending.host.is_empty() {
state.popup_allow_hosts.insert(pending.host);
}
push_popup_prompt(state);
reflow_layout(window, state);
open_tab(state, window, proxy, pending.url)?;
}
return Ok(());
}
if message == "popup_allow_no" {
state.pending_popup = None;
push_popup_prompt(state);
reflow_layout(window, state);
return Ok(());
}
if message == "toggle_adblock" {
state.adblock_enabled = !state.adblock_enabled;
apply_adblock_setting_to_tabs(state);
push_adblock(state);
return Ok(());
}
if message == "close_active_tab" {
if !state.tabs.is_empty() {
let tab_id = state.tabs[state.active].id;
if close_tab(state, window, proxy, tab_id)? {
let _ = proxy.send_event(UserEvent::Quit);
}
}
return Ok(());
}
if let Some(raw) = message.strip_prefix("toggle_kir:") {
if let Ok(target_id) = raw.parse::<u32>() {
if let Some(i) = state.find_index(target_id) {
state.tabs[i].kir_enabled = !state.tabs[i].kir_enabled;
if i != state.active && !state.tabs[i].kir_enabled {
state.tabs[i].webview = None;
}
apply_visibility(state);
push_tabs(state);
}
}
return Ok(());
}
if let Some(raw) = message.strip_prefix("switch_tab:") {
if let Ok(target_id) = raw.parse::<u32>() {
if let Some(i) = state.find_index(target_id) {
state.active = i;
ensure_active_tab_loaded(state, window, proxy)?;
suspend_background_tabs(state);
apply_visibility(state);
push_tabs(state);
}
}
return Ok(());
}
if let Some(raw) = message.strip_prefix("close_tab:") {
if let Ok(target_id) = raw.parse::<u32>() {
if close_tab(state, window, proxy, target_id)? {
let _ = proxy.send_event(UserEvent::Quit);
}
}
return Ok(());
}
Ok(())
}
// ---------- Webview builders -------------------------------------------
fn build_chrome(
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
host_mode: WebViewHostMode,
#[cfg(target_os = "linux")] gtk_chrome_container: Option<&gtk::Box>,
) -> Result<(WebView, WebViewHostMode)> {
let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE, host_mode);
let builder = || {
let chrome_proxy = proxy.clone();
WebViewBuilder::new()
.with_html(CHROME_HTML)
.with_bounds(chrome_rect)
.with_ipc_handler(move |req| {
let _ = chrome_proxy.send_event(UserEvent::ChromeIpc(req.body().to_string()));
})
};
match host_mode {
WebViewHostMode::Window => {
#[cfg(target_os = "linux")]
{
let container = gtk_chrome_container.ok_or_else(|| {
anyhow!("Failed to create chrome webview: missing Linux GTK chrome container")
})?;
builder()
.build_gtk(container)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| {
anyhow!("Failed to create chrome webview with GTK hosting: {err}")
})
}
#[cfg(not(target_os = "linux"))]
{
builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| anyhow!("Failed to create chrome webview: {err}"))
}
}
WebViewHostMode::Child => match builder().build_as_child(window) {
Ok(webview) => Ok((webview, WebViewHostMode::Child)),
Err(err) => {
let err_text = err.to_string();
if is_child_webview_unsupported_error(&err_text) {
eprintln!(
"child chrome webview unsupported on this backend; falling back to window-hosted chrome"
);
#[cfg(target_os = "linux")]
{
if let Some(container) = gtk_chrome_container {
return builder()
.build_gtk(container)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|gtk_err| {
anyhow!(
"Failed to create chrome webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
)
});
}
return Err(anyhow!(
"Failed to create chrome webview: child failed ({err_text}); missing Linux GTK chrome container"
));
}
#[cfg(not(target_os = "linux"))]
{
return builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|fallback_err| {
anyhow!(
"Failed to create chrome webview: child failed ({err_text}); window fallback failed ({fallback_err})"
)
});
}
}
Err(anyhow!("Failed to create chrome webview: {err_text}"))
}
},
}
}
fn is_child_webview_unsupported_error(err_text: &str) -> bool {
#[cfg(target_os = "linux")]
{
let lower = err_text.to_ascii_lowercase();
lower.contains("window handle kind is not supported")
|| (lower.contains("child") && lower.contains("not supported"))
}
#[cfg(not(target_os = "linux"))]
{
let _ = err_text;
false
}
}
fn build_tab_webview(
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
host_mode: WebViewHostMode,
#[cfg(target_os = "linux")] gtk_content_container: Option<&gtk::Box>,
private: bool,
tab_id: u32,
url: &str,
bounds: Rect,
) -> Result<(WebView, WebViewHostMode)> {
let init_script = format!("{PRIVACY_JS}\n{ADBLOCK_JS}");
let user_agent = browser_user_agent();
let builder = || {
let title_proxy = proxy.clone();
let page_proxy = proxy.clone();
let download_started_proxy = proxy.clone();
let download_completed_proxy = proxy.clone();
let download_total_proxy = proxy.clone();
let new_window_proxy = proxy.clone();
WebViewBuilder::new()
.with_url(url)
.with_user_agent(&user_agent)
.with_incognito(private)
.with_initialization_script(&init_script)
.with_bounds(bounds)
.with_new_window_req_handler(move |target| {
let _ = new_window_proxy.send_event(UserEvent::OpenInNewTab { url: target });
false
})
.with_document_title_changed_handler(move |title| {
let _ = title_proxy.send_event(UserEvent::TitleChanged { tab_id, title });
})
.with_on_page_load_handler(move |event, url| {
if matches!(event, PageLoadEvent::Finished) {
let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, url });
}
})
.with_download_started_handler(move |url, destination| {
let path = ensure_unique_download_path(default_download_path_for(&url));
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
*destination = path.clone();
let path_string = path_to_string(&path);
fetch_download_total(
url.clone(),
path_string.clone(),
download_total_proxy.clone(),
);
let _ = download_started_proxy.send_event(UserEvent::DownloadStarted {
url,
file_path: path_string,
});
true
})
.with_download_completed_handler(move |url, path, success| {
let _ = download_completed_proxy.send_event(UserEvent::DownloadCompleted {
url,
file_path: path.map(|p| path_to_string(&p)),
success,
});
})
};
match host_mode {
WebViewHostMode::Window => {
#[cfg(target_os = "linux")]
{
let container = gtk_content_container.ok_or_else(|| {
anyhow!("Failed to create tab webview: missing Linux GTK content container")
})?;
builder()
.build_gtk(container)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| anyhow!("Failed to create tab webview with GTK hosting: {err}"))
}
#[cfg(not(target_os = "linux"))]
{
builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| anyhow!("Failed to create tab webview: {err}"))
}
}
WebViewHostMode::Child => match builder().build_as_child(window) {
Ok(webview) => Ok((webview, WebViewHostMode::Child)),
Err(err) => {
let err_text = err.to_string();
if is_child_webview_unsupported_error(&err_text) {
eprintln!(
"child tab webview unsupported on this backend; falling back to window-hosted tabs"
);
#[cfg(target_os = "linux")]
{
if let Some(container) = gtk_content_container {
return builder()
.build_gtk(container)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|gtk_err| {
anyhow!(
"Failed to create tab webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
)
});
}
return Err(anyhow!(
"Failed to create tab webview: child failed ({err_text}); missing Linux GTK content container"
));
}
#[cfg(not(target_os = "linux"))]
{
return builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|fallback_err| {
anyhow!(
"Failed to create tab webview: child failed ({err_text}); window fallback failed ({fallback_err})"
)
});
}
}
Err(anyhow!("Failed to create tab webview: {err_text}"))
}
},
}
}
fn browser_user_agent() -> String {
let platform = if cfg!(target_os = "macos") {
"Macintosh; Intel Mac OS X 14_0"
} else if cfg!(target_os = "windows") {
"Windows NT 10.0; Win64; x64"
} else {
"X11; Linux x86_64"
};
// Keep broad compatibility while explicitly identifying RustyWolf.
format!(
"Mozilla/5.0 ({platform}; rv:128.0) {MODERN_BROWSER_UA_BASE} RustyWolf/{}",
env!("CARGO_PKG_VERSION")
)
}
fn open_tab(
state: &mut BrowserState,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
url: String,
) -> Result<()> {
let tab_id = state.alloc_id();
let chrome_h = chrome_height(state);
let tab_bounds = content_bounds(window, chrome_h, state.host_mode);
let (webview, effective_host_mode) = build_tab_webview(
window,
proxy,
state.host_mode,
#[cfg(target_os = "linux")]
state.gtk_content_container.as_ref(),
state.private,
tab_id,
&url,
tab_bounds,
)?;
state.host_mode = effective_host_mode;
state.tabs.push(Tab {
id: tab_id,
title: "New Tab".into(),
url,
kir_enabled: false,
webview: Some(webview),
back_stack: Vec::new(),
forward_stack: Vec::new(),
pending_nav_from_stack: false,
});
state.active = state.tabs.len() - 1;
suspend_background_tabs(state);
apply_visibility(state);
push_tabs(state);
push_downloads(state);
push_adblock(state);
push_popup_prompt(state);
if state.adblock_enabled {
if let Some(webview) = state.tabs[state.active].webview.as_ref() {
let _ = webview.evaluate_script(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled(true);",
);
}
}
Ok(())
}
fn close_tab(
state: &mut BrowserState,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
tab_id: u32,
) -> Result<bool> {
let Some(index) = state.find_index(tab_id) else {
return Ok(false);
};
state.tabs.remove(index);
if state.tabs.is_empty() {
return Ok(true);
}
if state.active >= state.tabs.len() {
state.active = state.tabs.len() - 1;
} else if index < state.active {
state.active -= 1;
}
ensure_active_tab_loaded(state, window, proxy)?;
suspend_background_tabs(state);
apply_visibility(state);
push_tabs(state);
Ok(false)
}
// ---------- Helpers ----------------------------------------------------
fn active_tab_mut(state: &mut BrowserState) -> Option<&mut Tab> {
state.tabs.get_mut(state.active)
}
fn ensure_active_tab_loaded(
state: &mut BrowserState,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
) -> Result<()> {
if state.tabs.is_empty() {
return Ok(());
}
ensure_tab_loaded(state, window, proxy, state.active)
}
fn ensure_tab_loaded(
state: &mut BrowserState,
window: &Window,
proxy: &EventLoopProxy<UserEvent>,
index: usize,
) -> Result<()> {
if index >= state.tabs.len() || state.tabs[index].webview.is_some() {
return Ok(());
}
let chrome_h = chrome_height(state);
let tab_bounds = content_bounds(window, chrome_h, state.host_mode);
let tab_id = state.tabs[index].id;
let tab_url = state.tabs[index].url.clone();
let (webview, effective_host_mode) = build_tab_webview(
window,
proxy,
state.host_mode,
#[cfg(target_os = "linux")]
state.gtk_content_container.as_ref(),
state.private,
tab_id,
&tab_url,
tab_bounds,
)?;
state.host_mode = effective_host_mode;
state.tabs[index].webview = Some(webview);
Ok(())
}
fn suspend_background_tabs(state: &mut BrowserState) {
for (i, tab) in state.tabs.iter_mut().enumerate() {
if i != state.active && !tab.kir_enabled {
tab.webview = None;
}
}
}
fn run_script_on_active_tab(state: &BrowserState, script: &str) {
if let Some(tab) = state.tabs.get(state.active) {
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.evaluate_script(script);
}
}
}
fn apply_visibility(state: &BrowserState) {
for (i, tab) in state.tabs.iter().enumerate() {
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.set_visible(i == state.active);
}
}
}
fn push_tabs(state: &BrowserState) {
if state.tabs.is_empty() {
return;
}
let payload = json!({
"tabs": state.tabs.iter().map(|t| json!({
"id": t.id,
"title": t.title,
"url": t.url,
"kirEnabled": t.kir_enabled,
"loaded": t.webview.is_some(),
})).collect::<Vec<_>>(),
"activeId": state.tabs[state.active].id,
})
.to_string();
let script = format!("window.__rustywolfSetTabs && window.__rustywolfSetTabs({payload});");
let _ = state.chrome.evaluate_script(&script);
}
fn push_downloads(state: &BrowserState) {
let payload = json!({
"panelOpen": state.downloads_panel_open,
"downloads": state
.downloads
.iter()
.rev()
.filter(|d| d.in_progress)
.take(12)
.map(|d| json!({
"url": d.url,
"filePath": d.file_path,
"status": d.status,
"inProgress": d.in_progress,
"downloadedBytes": d.downloaded_bytes,
"totalBytes": d.total_bytes,
}))
.collect::<Vec<_>>(),
})
.to_string();
let script =
format!("window.__rustywolfSetDownloads && window.__rustywolfSetDownloads({payload});");
let _ = state.chrome.evaluate_script(&script);
}
fn push_adblock(state: &BrowserState) {
let enabled = if state.adblock_enabled {
"true"
} else {
"false"
};
let script =
format!("window.__rustywolfSetAdblock && window.__rustywolfSetAdblock({enabled});");
let _ = state.chrome.evaluate_script(&script);
}
fn push_popup_prompt(state: &BrowserState) {
let payload = match &state.pending_popup {
Some(pending) => json!({
"visible": true,
"host": if pending.host.is_empty() { "this website" } else { &pending.host },
}),
None => json!({ "visible": false }),
}
.to_string();
let script =
format!("window.__rustywolfSetPopupPrompt && window.__rustywolfSetPopupPrompt({payload});");
let _ = state.chrome.evaluate_script(&script);
}
fn apply_adblock_setting_to_tabs(state: &BrowserState) {
let enabled = if state.adblock_enabled {
"true"
} else {
"false"
};
let script = format!(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
);
for tab in &state.tabs {
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.evaluate_script(&script);
}
}
}
fn logical_size(window: &Window) -> (f64, f64) {
let scale = window.scale_factor();
let phys = window.inner_size();
(phys.width as f64 / scale, phys.height as f64 / scale)
}
fn chrome_bounds(window: &Window, chrome_h: f64, _host_mode: WebViewHostMode) -> Rect {
let (logical_w, _logical_h) = logical_size(window);
Rect {
position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(),
size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new(logical_w, chrome_h)),
}
}
fn content_bounds(window: &Window, chrome_h: f64, _host_mode: WebViewHostMode) -> Rect {
#[cfg(target_os = "linux")]
{
if _host_mode == WebViewHostMode::Window {
let (logical_w, logical_h) = logical_size(window);
return Rect {
// Content webviews are inside a dedicated GTK content container.
position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(),
size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new(
logical_w,
(logical_h - chrome_h).max(0.0),
)),
};
}
}
let (logical_w, logical_h) = logical_size(window);
Rect {
position: wry::dpi::LogicalPosition::new(0.0, chrome_h).into(),
size: wry::dpi::Size::Logical(wry::dpi::LogicalSize::new(
logical_w,
(logical_h - chrome_h).max(0.0),
)),
}
}
fn reflow_layout(window: &Window, state: &BrowserState) {
let chrome_h = chrome_height(state);
#[cfg(target_os = "linux")]
{
if let Some(container) = state.gtk_chrome_container.as_ref() {
set_linux_chrome_container_height(container, chrome_h);
}
if state.host_mode == WebViewHostMode::Window && state.gtk_chrome_container.is_some() {
// GTK container layout controls bounds in window-host mode.
return;
}
}
let _ = state
.chrome
.set_bounds(chrome_bounds(window, chrome_h, state.host_mode));
let cb = content_bounds(window, chrome_h, state.host_mode);
for tab in &state.tabs {
if let Some(webview) = tab.webview.as_ref() {
let _ = webview.set_bounds(cb);
}
}
}
#[cfg(target_os = "linux")]
fn create_linux_webview_containers(window: &Window) -> Result<(gtk::Box, gtk::Box)> {
let vbox = window.default_vbox().ok_or_else(|| {
anyhow!("Failed to access default GTK vbox required for Linux webview hosting")
})?;
let chrome = gtk::Box::new(gtk::Orientation::Vertical, 0);
chrome.set_hexpand(true);
chrome.set_vexpand(false);
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_hexpand(true);
content.set_vexpand(true);
vbox.pack_start(&chrome, false, false, 0);
vbox.pack_start(&content, true, true, 0);
chrome.show_all();
content.show_all();
vbox.show_all();
Ok((chrome, content))
}
#[cfg(target_os = "linux")]
fn set_linux_chrome_container_height(container: &gtk::Box, chrome_h: f64) {
container.set_size_request(-1, chrome_h.max(0.0).round() as i32);
}
fn load_window_icon() -> Option<Icon> {
let image = image::load_from_memory_with_format(
include_bytes!("rustywolf.ico"),
image::ImageFormat::Ico,
)
.ok()?
.into_rgba8();
let (width, height) = image.dimensions();
Icon::from_rgba(image.into_raw(), width, height).ok()
}
fn chrome_height(state: &BrowserState) -> f64 {
let mut height = CHROME_HEIGHT_BASE;
if state.downloads_panel_open {
height += CHROME_HEIGHT_DOWNLOADS_EXTRA;
}
if state.pending_popup.is_some() {
height += CHROME_HEIGHT_POPUP_PROMPT_EXTRA;
}
height
}
fn normalize_url(input: &str) -> String {
let trimmed = input.trim();
if trimmed == "about:blank" {
return trimmed.to_string();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
if looks_like_address(trimmed) {
return format!("https://{trimmed}");
}
let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
format!("{SEARCH_URL_PREFIX}{encoded}")
}
fn should_open_new_tab_url(raw: &str) -> bool {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("about:blank") {
return false;
}
if trimmed.to_ascii_lowercase().starts_with("javascript:") {
return false;
}
let Ok(parsed) = url::Url::parse(trimmed) else {
return false;
};
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return false;
}
let host = parsed.host_str().unwrap_or_default();
let path = parsed.path().to_ascii_lowercase();
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
&& (path == "/post3.html" || path == "/page3" || path == "/page3.html")
{
return false;
}
true
}
fn properties_data_url(state: &BrowserState) -> String {
let html = render_properties_html(state);
let encoded = base64::engine::general_purpose::STANDARD.encode(html.as_bytes());
format!("data:text/html;base64,{encoded}")
}
fn render_properties_html(state: &BrowserState) -> String {
let history_is_truncated = state.history.len() > PROPERTIES_MAX_ROWS;
let downloads_are_truncated = state.downloads.len() > PROPERTIES_MAX_ROWS;
let history_rows = if state.history.is_empty() {
"<tr><td colspan=\"3\">No history yet.</td></tr>".to_string()
} else {
state
.history
.iter()
.rev()
.take(PROPERTIES_MAX_ROWS)
.map(|entry| {
format!(
"<tr><td>{}</td><td><a href=\"{}\">{}</a></td><td>{}</td></tr>",
escape_html(&entry.visited_at),
escape_html(&entry.url),
escape_html(&entry.title),
escape_html(&entry.url),
)
})
.collect::<Vec<_>>()
.join("")
};
let download_rows = if state.downloads.is_empty() {
"<tr><td colspan=\"4\">No downloads yet.</td></tr>".to_string()
} else {
state
.downloads
.iter()
.rev()
.take(PROPERTIES_MAX_ROWS)
.map(|entry| {
let file_url = path_string_to_file_url(&entry.file_path);
format!(
"<tr><td>{}</td><td>{}</td><td><a href=\"{}\">{}</a></td><td>{}</td></tr>",
escape_html(&entry.updated_at),
escape_html(&entry.status),
escape_html(&file_url),
escape_html(&entry.file_path),
escape_html(&entry.url),
)
})
.collect::<Vec<_>>()
.join("")
};
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>RustyWolf Properties</title>\
<style>*{{box-sizing:border-box}}:root{{color-scheme:dark}}\
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Roboto,sans-serif;background:#15161a;color:#eef0f4;margin:0;padding:28px 20px}}\
.wrap{{max-width:1080px;margin:0 auto}}\
h1{{font-size:22px;font-weight:700;margin:0 0 4px}} .sub{{color:#9aa1af;font-size:13px;margin:0 0 4px}}\
h2{{font-size:15px;font-weight:600;margin:26px 0 10px}}\
.muted{{color:#9aa1af;font-size:12px;margin:0 0 8px}}\
.card{{background:#1c1d22;border:1px solid rgba(255,255,255,.09);border-radius:12px;overflow:hidden}}\
table{{width:100%;border-collapse:collapse}}\
th,td{{padding:9px 12px;border-bottom:1px solid rgba(255,255,255,.07);font-size:13px;text-align:left;vertical-align:top;overflow-wrap:anywhere}}\
th{{background:rgba(255,255,255,.04);color:#c6cbd6;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}}\
tr:last-child td{{border-bottom:none}} tr:hover td{{background:rgba(255,255,255,.03)}}\
a{{color:#8ab4ff;text-decoration:none}} a:hover{{text-decoration:underline}}</style></head>\
<body><div class=\"wrap\"><h1>Browser Properties</h1><p class=\"sub\">Session browsing history and download activity.</p>\
<h2>Browsing History</h2><p class=\"muted\">{}</p><div class=\"card\"><table><thead><tr><th>Visited</th><th>Title</th><th>URL</th></tr></thead><tbody>{history_rows}</tbody></table></div>\
<h2>Download History</h2><p class=\"muted\">{}</p><div class=\"card\"><table><thead><tr><th>Updated</th><th>Status</th><th>Saved To</th><th>Source URL</th></tr></thead><tbody>{download_rows}</tbody></table></div>\
</div></body></html>",
if history_is_truncated {
format!("Showing latest {PROPERTIES_MAX_ROWS} entries.")
} else {
"Showing all entries.".to_string()
},
if downloads_are_truncated {
format!("Showing latest {PROPERTIES_MAX_ROWS} entries.")
} else {
"Showing all entries.".to_string()
}
)
}
fn append_history(state: &mut BrowserState, title: String, url: String) {
if url.is_empty() {
return;
}
state.history.push(HistoryEntry {
title: if title.trim().is_empty() {
url.clone()
} else {
title
},
url,
visited_at: now_stamp(),
});
if state.history.len() > MAX_HISTORY_ENTRIES {
let remove_count = state.history.len() - MAX_HISTORY_ENTRIES;
state.history.drain(0..remove_count);
}
}
fn trim_downloads(state: &mut BrowserState) {
if state.downloads.len() > MAX_DOWNLOAD_ENTRIES {
let remove_count = state.downloads.len() - MAX_DOWNLOAD_ENTRIES;
state.downloads.drain(0..remove_count);
}
}
fn trim_tab_nav_stack(stack: &mut Vec<String>) {
if stack.len() > MAX_TAB_NAV_ENTRIES {
let remove_count = stack.len() - MAX_TAB_NAV_ENTRIES;
stack.drain(0..remove_count);
}
}
fn default_download_path_for(url: &str) -> PathBuf {
let mut dir = dirs::download_dir()
.or_else(dirs::home_dir)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(std::env::temp_dir);
dir.push(filename_from_url(url));
dir
}
fn ensure_unique_download_path(path: PathBuf) -> PathBuf {
if !path.exists() {
return path;
}
let parent = path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("download")
.to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.filter(|e| !e.is_empty())
.map(ToString::to_string);
for idx in 1..=9999 {
let file_name = match &ext {
Some(ext) => format!("{stem} ({idx}).{ext}"),
None => format!("{stem} ({idx})"),
};
let candidate = parent.join(file_name);
if !candidate.exists() {
return candidate;
}
}
parent.join(format!("{stem}-{}", now_stamp()))
}
fn filename_from_url(url: &str) -> String {
let raw = url::Url::parse(url)
.ok()
.and_then(|u| {
u.path_segments().and_then(|mut parts| {
parts
.next_back()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string)
})
})
.unwrap_or_else(|| "download.bin".to_string());
sanitize_filename(&raw)
}
fn path_to_string(path: &PathBuf) -> String {
path.to_string_lossy().into_owned()
}
fn now_stamp() -> String {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs().to_string(),
Err(_) => "0".to_string(),
}
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn is_internal_page(url: &str) -> bool {
url.starts_with("data:text/html;base64,")
}
fn path_string_to_file_url(path: &str) -> String {
url::Url::from_file_path(Path::new(path))
.map(|u| u.to_string())
.unwrap_or_else(|_| format!("file://{path}"))
}
fn sanitize_filename(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
let invalid =
matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
out.push(if invalid { '_' } else { ch });
}
let trimmed = out.trim_matches('.').trim();
if trimmed.is_empty() {
"download.bin".to_string()
} else {
trimmed.to_string()
}
}
fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy<UserEvent>) {
thread::spawn(move || {
let total_bytes = ureq::head(&url).call().ok().and_then(|resp| {
resp.header("Content-Length")
.and_then(|v| v.parse::<u64>().ok())
});
let _ = proxy.send_event(UserEvent::DownloadTotal {
file_path,
total_bytes,
});
});
}
fn looks_like_address(value: &str) -> bool {
value.contains('.')
|| value.contains(':')
|| value.starts_with("localhost")
|| value.starts_with("127.")
|| value.starts_with('[')
|| value.starts_with('/')
}
// ---------- Tests -------------------------------------------------------
#[cfg(test)]
mod tests {
use super::{normalize_url, sanitize_filename, should_open_new_tab_url};
#[test]
fn keeps_existing_scheme() {
assert_eq!(normalize_url("https://example.com"), "https://example.com");
}
#[test]
fn adds_https_for_domains() {
assert_eq!(normalize_url("example.com"), "https://example.com");
}
#[test]
fn keeps_about_blank() {
assert_eq!(normalize_url("about:blank"), "about:blank");
}
#[test]
fn converts_search_terms_to_duckduckgo_query() {
assert_eq!(
normalize_url("privacy browser"),
"https://duckduckgo.com/?q=privacy+browser"
);
}
#[test]
fn converts_single_token_to_duckduckgo_query() {
assert_eq!(normalize_url("rust"), "https://duckduckgo.com/?q=rust");
}
#[test]
fn trims_before_search() {
assert_eq!(
normalize_url(" rust webview "),
"https://duckduckgo.com/?q=rust+webview"
);
}
#[test]
fn blocks_blank_and_about_blank_new_tabs() {
assert!(!should_open_new_tab_url(""));
assert!(!should_open_new_tab_url(" about:blank "));
}
#[test]
fn blocks_duckduckgo_internal_popup_paths() {
assert!(!should_open_new_tab_url(
"https://duckduckgo.com/post3.html"
));
assert!(!should_open_new_tab_url("https://duckduckgo.com/page3"));
assert!(!should_open_new_tab_url(
"https://duckduckgo.com/page3.html"
));
}
#[test]
fn allows_regular_https_new_tab_targets() {
assert!(should_open_new_tab_url("https://example.com/"));
assert!(should_open_new_tab_url("https://duckduckgo.com/?q=rust"));
}
#[test]
fn sanitizes_windows_unsafe_filename_chars() {
assert_eq!(sanitize_filename("report:2026?.pdf"), "report_2026_.pdf");
}
#[test]
fn falls_back_when_filename_becomes_empty() {
assert_eq!(sanitize_filename("..."), "download.bin");
}
}