- 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
2568 lines
88 KiB
Rust
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<>k::Box>,
|
|
) -> Result<(WebView, WebViewHostMode)> {
|
|
let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE, host_mode);
|
|
let builder = || {
|
|
let chrome_proxy = proxy.clone();
|
|
WebViewBuilder::new()
|
|
.with_html(CHROME_HTML)
|
|
.with_bounds(chrome_rect)
|
|
.with_ipc_handler(move |req| {
|
|
let _ = chrome_proxy.send_event(UserEvent::ChromeIpc(req.body().to_string()));
|
|
})
|
|
};
|
|
|
|
match host_mode {
|
|
WebViewHostMode::Window => {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let container = gtk_chrome_container.ok_or_else(|| {
|
|
anyhow!("Failed to create chrome webview: missing Linux GTK chrome container")
|
|
})?;
|
|
builder()
|
|
.build_gtk(container)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|err| {
|
|
anyhow!("Failed to create chrome webview with GTK hosting: {err}")
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
builder()
|
|
.build(window)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|err| anyhow!("Failed to create chrome webview: {err}"))
|
|
}
|
|
}
|
|
WebViewHostMode::Child => match builder().build_as_child(window) {
|
|
Ok(webview) => Ok((webview, WebViewHostMode::Child)),
|
|
Err(err) => {
|
|
let err_text = err.to_string();
|
|
if is_child_webview_unsupported_error(&err_text) {
|
|
eprintln!(
|
|
"child chrome webview unsupported on this backend; falling back to window-hosted chrome"
|
|
);
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Some(container) = gtk_chrome_container {
|
|
return builder()
|
|
.build_gtk(container)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|gtk_err| {
|
|
anyhow!(
|
|
"Failed to create chrome webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
|
|
)
|
|
});
|
|
}
|
|
return Err(anyhow!(
|
|
"Failed to create chrome webview: child failed ({err_text}); missing Linux GTK chrome container"
|
|
));
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
return builder()
|
|
.build(window)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|fallback_err| {
|
|
anyhow!(
|
|
"Failed to create chrome webview: child failed ({err_text}); window fallback failed ({fallback_err})"
|
|
)
|
|
});
|
|
}
|
|
}
|
|
Err(anyhow!("Failed to create chrome webview: {err_text}"))
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn is_child_webview_unsupported_error(err_text: &str) -> bool {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let lower = err_text.to_ascii_lowercase();
|
|
lower.contains("window handle kind is not supported")
|
|
|| (lower.contains("child") && lower.contains("not supported"))
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
let _ = err_text;
|
|
false
|
|
}
|
|
}
|
|
|
|
fn build_tab_webview(
|
|
window: &Window,
|
|
proxy: &EventLoopProxy<UserEvent>,
|
|
host_mode: WebViewHostMode,
|
|
#[cfg(target_os = "linux")] gtk_content_container: Option<>k::Box>,
|
|
private: bool,
|
|
tab_id: u32,
|
|
url: &str,
|
|
bounds: Rect,
|
|
) -> Result<(WebView, WebViewHostMode)> {
|
|
let init_script = format!("{PRIVACY_JS}\n{ADBLOCK_JS}");
|
|
let user_agent = browser_user_agent();
|
|
|
|
let builder = || {
|
|
let title_proxy = proxy.clone();
|
|
let page_proxy = proxy.clone();
|
|
let download_started_proxy = proxy.clone();
|
|
let download_completed_proxy = proxy.clone();
|
|
let download_total_proxy = proxy.clone();
|
|
let new_window_proxy = proxy.clone();
|
|
|
|
WebViewBuilder::new()
|
|
.with_url(url)
|
|
.with_user_agent(&user_agent)
|
|
.with_incognito(private)
|
|
.with_initialization_script(&init_script)
|
|
.with_bounds(bounds)
|
|
.with_new_window_req_handler(move |target| {
|
|
let _ = new_window_proxy.send_event(UserEvent::OpenInNewTab { url: target });
|
|
false
|
|
})
|
|
.with_document_title_changed_handler(move |title| {
|
|
let _ = title_proxy.send_event(UserEvent::TitleChanged { tab_id, title });
|
|
})
|
|
.with_on_page_load_handler(move |event, url| {
|
|
if matches!(event, PageLoadEvent::Finished) {
|
|
let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, url });
|
|
}
|
|
})
|
|
.with_download_started_handler(move |url, destination| {
|
|
let path = ensure_unique_download_path(default_download_path_for(&url));
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
*destination = path.clone();
|
|
let path_string = path_to_string(&path);
|
|
fetch_download_total(
|
|
url.clone(),
|
|
path_string.clone(),
|
|
download_total_proxy.clone(),
|
|
);
|
|
let _ = download_started_proxy.send_event(UserEvent::DownloadStarted {
|
|
url,
|
|
file_path: path_string,
|
|
});
|
|
true
|
|
})
|
|
.with_download_completed_handler(move |url, path, success| {
|
|
let _ = download_completed_proxy.send_event(UserEvent::DownloadCompleted {
|
|
url,
|
|
file_path: path.map(|p| path_to_string(&p)),
|
|
success,
|
|
});
|
|
})
|
|
};
|
|
|
|
match host_mode {
|
|
WebViewHostMode::Window => {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let container = gtk_content_container.ok_or_else(|| {
|
|
anyhow!("Failed to create tab webview: missing Linux GTK content container")
|
|
})?;
|
|
builder()
|
|
.build_gtk(container)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|err| anyhow!("Failed to create tab webview with GTK hosting: {err}"))
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
builder()
|
|
.build(window)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|err| anyhow!("Failed to create tab webview: {err}"))
|
|
}
|
|
}
|
|
WebViewHostMode::Child => match builder().build_as_child(window) {
|
|
Ok(webview) => Ok((webview, WebViewHostMode::Child)),
|
|
Err(err) => {
|
|
let err_text = err.to_string();
|
|
if is_child_webview_unsupported_error(&err_text) {
|
|
eprintln!(
|
|
"child tab webview unsupported on this backend; falling back to window-hosted tabs"
|
|
);
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Some(container) = gtk_content_container {
|
|
return builder()
|
|
.build_gtk(container)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|gtk_err| {
|
|
anyhow!(
|
|
"Failed to create tab webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
|
|
)
|
|
});
|
|
}
|
|
return Err(anyhow!(
|
|
"Failed to create tab webview: child failed ({err_text}); missing Linux GTK content container"
|
|
));
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
return builder()
|
|
.build(window)
|
|
.map(|webview| (webview, WebViewHostMode::Window))
|
|
.map_err(|fallback_err| {
|
|
anyhow!(
|
|
"Failed to create tab webview: child failed ({err_text}); window fallback failed ({fallback_err})"
|
|
)
|
|
});
|
|
}
|
|
}
|
|
Err(anyhow!("Failed to create tab webview: {err_text}"))
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn browser_user_agent() -> String {
|
|
let platform = if cfg!(target_os = "macos") {
|
|
"Macintosh; Intel Mac OS X 14_0"
|
|
} else if cfg!(target_os = "windows") {
|
|
"Windows NT 10.0; Win64; x64"
|
|
} else {
|
|
"X11; Linux x86_64"
|
|
};
|
|
|
|
// Keep broad compatibility while explicitly identifying RustyWolf.
|
|
format!(
|
|
"Mozilla/5.0 ({platform}; rv:128.0) {MODERN_BROWSER_UA_BASE} RustyWolf/{}",
|
|
env!("CARGO_PKG_VERSION")
|
|
)
|
|
}
|
|
|
|
fn open_tab(
|
|
state: &mut BrowserState,
|
|
window: &Window,
|
|
proxy: &EventLoopProxy<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: >k::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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
fn is_internal_page(url: &str) -> bool {
|
|
url.starts_with("data:text/html;base64,")
|
|
}
|
|
|
|
fn path_string_to_file_url(path: &str) -> String {
|
|
url::Url::from_file_path(Path::new(path))
|
|
.map(|u| u.to_string())
|
|
.unwrap_or_else(|_| format!("file://{path}"))
|
|
}
|
|
|
|
fn sanitize_filename(raw: &str) -> String {
|
|
let mut out = String::with_capacity(raw.len());
|
|
for ch in raw.chars() {
|
|
let invalid =
|
|
matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
|
|
out.push(if invalid { '_' } else { ch });
|
|
}
|
|
let trimmed = out.trim_matches('.').trim();
|
|
if trimmed.is_empty() {
|
|
"download.bin".to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy<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");
|
|
}
|
|
}
|