use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use gtk4::prelude::*;
use webkit6::prelude::*;
use webkit6::{
CacheModel, Download, FindOptions, GeolocationPermissionRequest, HardwareAccelerationPolicy,
MemoryPressureSettings, NavigationPolicyDecision, NetworkSession, NotificationPermissionRequest,
PermissionRequest, PolicyDecisionType, Settings, TLSErrorsPolicy, UserContentFilter,
UserContentFilterStore, UserContentInjectedFrames, UserMediaPermissionRequest, UserScript,
UserScriptInjectionTime, WebContext, WebView,
};
use crate::adblock;
use crate::core::command::{Command, OpenTarget};
use crate::core::msg::{LoadEvent, Msg};
use crate::core::runtime::Mailbox;
use crate::core::state::{Capability, PermissionPolicy, Permissions, TabId};
type PendingPermissions = Rc<RefCell<HashMap<u64, PermissionRequest>>>;
use super::traits::EngineView;
pub type PermissionMirror = Rc<RefCell<Permissions>>;
const MAX_FIND_MATCHES: u32 = 1000;
type ViewRegistry = Rc<RefCell<Vec<(Option<String>, glib::object::WeakRef<WebView>)>>>;
pub struct WebKitEngine {
settings: Settings,
context: WebContext,
session: NetworkSession,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
_filter_store: UserContentFilterStore,
filter: Rc<RefCell<Option<UserContentFilter>>>,
views: ViewRegistry,
pending_permissions: PendingPermissions,
next_permission_id: Rc<Cell<u64>>,
}
impl WebKitEngine {
pub fn new(
debug: bool,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
filter_store_dir: &Path,
downloads_dir: &Path,
mailbox: Mailbox,
) -> Self {
let _ = std::fs::create_dir_all(filter_store_dir);
let store = UserContentFilterStore::new(&filter_store_dir.to_string_lossy());
let filter: Rc<RefCell<Option<UserContentFilter>>> = Rc::new(RefCell::new(None));
let views: ViewRegistry = Rc::new(RefCell::new(Vec::new()));
let json = adblock::content_filter_json(&blocklist);
let bytes = glib::Bytes::from(json.as_bytes());
let filter_cb = filter.clone();
let views_cb = views.clone();
store.save(
"qbrsh-adblock",
&bytes,
None::<>k4::gio::Cancellable>,
move |result| match result {
Ok(compiled) => {
for view in views_cb
.borrow()
.iter()
.filter_map(|(_, w)| w.upgrade())
{
if let Some(ucm) = view.user_content_manager() {
ucm.add_filter(&compiled);
}
}
*filter_cb.borrow_mut() = Some(compiled);
}
Err(e) => eprintln!("[qbrsh] content filter compile failed: {e}"),
},
);
let context = WebContext::builder()
.memory_pressure_settings(&MemoryPressureSettings::new())
.build();
context.set_cache_model(CacheModel::DocumentBrowser);
let mut net_pressure = MemoryPressureSettings::new();
NetworkSession::set_memory_pressure_settings(&mut net_pressure);
let session = NetworkSession::default().expect("default network session");
session.set_tls_errors_policy(TLSErrorsPolicy::Fail);
let dl_dir = downloads_dir.to_path_buf();
let dl_mailbox = mailbox.clone();
let dl_id = Rc::new(Cell::new(0u64));
session.connect_download_started(move |_session, download| {
let id = dl_id.get();
dl_id.set(id + 1);
wire_download(download, id, dl_dir.clone(), dl_mailbox.clone());
});
Self {
settings: default_settings(debug),
context,
session,
blocklist,
permissions,
_filter_store: store,
filter,
views,
pending_permissions: Rc::new(RefCell::new(HashMap::new())),
next_permission_id: Rc::new(Cell::new(0)),
}
}
pub fn resolve_permission(&self, id: u64, allow: bool) {
if let Some(request) = self.pending_permissions.borrow_mut().remove(&id) {
if allow {
request.allow();
} else {
request.deny();
}
}
}
pub fn create_view(&self, tab: TabId, uri: &str, mailbox: Mailbox) -> Box<dyn EngineView> {
let site = adblock::site_of(uri);
let related = {
let mut views = self.views.borrow_mut();
views.retain(|(_, w)| w.upgrade().is_some());
site.as_deref().and_then(|s| {
views
.iter()
.find(|(vs, _)| vs.as_deref() == Some(s))
.and_then(|(_, w)| w.upgrade())
})
};
let builder = WebView::builder()
.settings(&self.settings)
.network_session(&self.session);
let builder = match related {
Some(ref r) => builder.related_view(r),
None => builder.web_context(&self.context),
};
let view = builder.build();
view.set_vexpand(true);
view.set_hexpand(true);
if let Some(ucm) = view.user_content_manager()
&& let Some(compiled) = self.filter.borrow().as_ref()
{
ucm.add_filter(compiled);
}
self.views.borrow_mut().push((site, view.downgrade()));
connect_signals(
&view,
tab,
mailbox,
self.blocklist.clone(),
self.permissions.clone(),
self.pending_permissions.clone(),
self.next_permission_id.clone(),
);
Box::new(WebKitView { view })
}
}
fn connect_signals(
view: &WebView,
tab: TabId,
mailbox: Mailbox,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
pending_permissions: PendingPermissions,
next_permission_id: Rc<Cell<u64>>,
) {
view.connect_decide_policy(move |_v, decision, decision_type| {
if matches!(
decision_type,
PolicyDecisionType::NavigationAction | PolicyDecisionType::NewWindowAction
) && let Some(nav) = decision.downcast_ref::<NavigationPolicyDecision>()
&& let Some(uri) = nav
.navigation_action()
.and_then(|a| a.request())
.and_then(|r| r.uri())
&& adblock::is_blocked(&uri, &blocklist)
{
decision.ignore();
return true;
}
false
});
let mb = mailbox.clone();
view.connect_load_changed(move |_v, event| {
let event = match event {
webkit6::LoadEvent::Started => LoadEvent::Started,
webkit6::LoadEvent::Committed => LoadEvent::Committed,
webkit6::LoadEvent::Finished => LoadEvent::Finished,
_ => return,
};
mb.send(Msg::Load { tab, event });
});
let mb = mailbox.clone();
view.connect_uri_notify(move |v| {
if let Some(uri) = v.uri() {
mb.send(Msg::UriChanged {
tab,
uri: uri.to_string(),
});
}
});
let mb = mailbox.clone();
view.connect_title_notify(move |v| {
mb.send(Msg::TitleChanged {
tab,
title: v.title().map(|t| t.to_string()).unwrap_or_default(),
});
});
let mb = mailbox.clone();
view.connect_estimated_load_progress_notify(move |v| {
mb.send(Msg::Progress {
tab,
fraction: v.estimated_load_progress(),
});
});
let mb = mailbox.clone();
view.connect_web_process_terminated(move |_v, _reason| {
mb.send(Msg::Crashed { tab });
});
let mb = mailbox.clone();
view.connect_permission_request(move |v, request| {
let host = v
.uri()
.and_then(|u| adblock::host_of(&u).map(str::to_string))
.unwrap_or_default();
let Some(capability) = classify_permission(request) else {
request.deny();
return true;
};
match permissions.borrow().policy_for(&host, capability) {
PermissionPolicy::Allow => request.allow(),
PermissionPolicy::Deny => request.deny(),
PermissionPolicy::Ask => {
let id = next_permission_id.get();
next_permission_id.set(id + 1);
pending_permissions.borrow_mut().insert(id, request.clone());
mb.send(Msg::PermissionRequested {
id,
host,
capability,
});
}
}
true
});
if let Some(fc) = view.find_controller() {
let mb = mailbox.clone();
fc.connect_counted_matches(move |_fc, count| {
mb.send(Msg::FindResult { tab, matches: count });
});
let mb = mailbox.clone();
fc.connect_failed_to_find_text(move |_fc| {
mb.send(Msg::FindResult { tab, matches: 0 });
});
}
view.connect_load_failed(|v, _event, uri, error| {
if error.matches(webkit6::NetworkError::Cancelled) {
return false;
}
v.load_html(&error_page_html(uri, &error.to_string()), Some(uri));
true
});
let mb = mailbox.clone();
view.connect_create(move |_v, nav_action| {
if let Some(uri) = nav_action.request().and_then(|r| r.uri()) {
mb.send(Msg::Command(Command::Open {
target: OpenTarget::Tab,
input: uri.to_string(),
}));
}
None
});
let ucm = view
.user_content_manager()
.expect("web view has a user content manager");
ucm.add_script(&UserScript::new(
INSERT_MODE_DETECT_JS,
UserContentInjectedFrames::TopFrame,
UserScriptInjectionTime::End,
&[],
&[],
));
ucm.add_script(&UserScript::new(
HINTS_JS,
UserContentInjectedFrames::TopFrame,
UserScriptInjectionTime::End,
&[],
&[],
));
ucm.register_script_message_handler("qbrshMode", None);
let mb = mailbox.clone();
ucm.connect_script_message_received(Some("qbrshMode"), move |_ucm, value| {
let focused = match value.to_str().as_str() {
"insert" => true,
"normal" => false,
_ => return,
};
mb.send(Msg::InputFocusChanged { tab, focused });
});
}
fn wire_download(download: &Download, id: u64, dir: PathBuf, mailbox: Mailbox) {
let started = mailbox.clone();
download.connect_decide_destination(move |dl, suggested| {
let dest = safe_download_path(&dir, suggested);
let filename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
dl.set_destination(&dest.to_string_lossy());
started.send(Msg::DownloadStarted { id, filename });
true
});
let finished = mailbox.clone();
download.connect_finished(move |dl| {
let path = dl.destination().map(|s| s.to_string()).unwrap_or_default();
finished.send(Msg::DownloadFinished { id, path });
});
download.connect_failed(move |_dl, error| {
mailbox.send(Msg::DownloadFailed {
id,
error: error.to_string(),
});
});
}
fn safe_download_path(dir: &Path, suggested: &str) -> PathBuf {
let name = sanitize_download_name(suggested);
let mut candidate = dir.join(&name);
let stem = Path::new(&name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("download")
.to_string();
let ext = Path::new(&name)
.extension()
.and_then(|s| s.to_str())
.map(str::to_string);
let mut n = 1;
while candidate.exists() && n < 10_000 {
let renamed = match &ext {
Some(e) => format!("{stem}-{n}.{e}"),
None => format!("{stem}-{n}"),
};
candidate = dir.join(renamed);
n += 1;
}
candidate
}
fn sanitize_download_name(suggested: &str) -> String {
let base = Path::new(suggested)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.trim();
if base.is_empty() || base == "." || base == ".." {
"download".to_string()
} else {
base.to_string()
}
}
fn classify_permission(request: &PermissionRequest) -> Option<Capability> {
if request.is::<GeolocationPermissionRequest>() {
Some(Capability::Geolocation)
} else if request.is::<NotificationPermissionRequest>() {
Some(Capability::Notifications)
} else if let Some(media) = request.downcast_ref::<UserMediaPermissionRequest>() {
if media.is_for_video_device() {
Some(Capability::Camera)
} else if media.is_for_audio_device() {
Some(Capability::Microphone)
} else {
None
}
} else {
None
}
}
const HINTS_JS: &str = include_str!("../../js/hints.js");
const INSERT_MODE_DETECT_JS: &str = r#"(function(){
function editable(el){
if(!el) return false;
var t=(el.tagName||'').toLowerCase();
if(t==='input'){
var ty=(el.type||'text').toLowerCase();
return !['button','submit','reset','checkbox','radio','file','image','hidden','range','color'].includes(ty);
}
return t==='textarea'||t==='select'||el.isContentEditable;
}
document.addEventListener('focusin',function(e){if(editable(e.target))window.webkit.messageHandlers.qbrshMode.postMessage('insert');},true);
document.addEventListener('focusout',function(e){if(editable(e.target))window.webkit.messageHandlers.qbrshMode.postMessage('normal');},true);
})();"#;
fn error_page_html(uri: &str, error: &str) -> String {
let uri = html_escape(uri);
let error = html_escape(error);
format!(
r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Load failed</title>
<style>body{{background:#1a1a2e;color:#e0e0e0;font-family:system-ui,sans-serif;text-align:center;padding:4em 2em}}
h1{{color:#e06c75}}code{{background:#2a2a4a;padding:3px 8px;border-radius:4px;word-break:break-all}}
.err{{color:#e5c07b;margin-top:1em}}.hint{{color:#888;margin-top:2.5em;font-size:.9em}}</style></head>
<body><h1>Page load failed</h1><p><code>{uri}</code></p><p class="err">{error}</p>
<p class="hint">Press <b>r</b> to retry or <b>H</b> to go back.</p></body></html>"#
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn default_settings(debug: bool) -> Settings {
let settings = Settings::new();
settings.set_hardware_acceleration_policy(HardwareAccelerationPolicy::Always);
settings.set_enable_page_cache(false);
settings.set_enable_smooth_scrolling(true);
settings.set_enable_javascript(true);
settings.set_enable_media_stream(true);
settings.set_enable_webrtc(true);
settings.set_enable_developer_extras(true);
settings.set_enable_write_console_messages_to_stdout(debug);
settings
}
struct WebKitView {
view: WebView,
}
impl EngineView for WebKitView {
fn load_uri(&self, uri: &str) {
WebViewExt::load_uri(&self.view, uri);
}
fn reload(&self, bypass_cache: bool) {
if bypass_cache {
self.view.reload_bypass_cache();
} else {
WebViewExt::reload(&self.view);
}
}
fn stop(&self) {
WebViewExt::stop_loading(&self.view);
}
fn go_back(&self) {
WebViewExt::go_back(&self.view);
}
fn go_forward(&self) {
WebViewExt::go_forward(&self.view);
}
fn evaluate_js(&self, script: &str, on_done: Box<dyn FnOnce(Result<String, String>)>) {
self.view.evaluate_javascript(
script,
None,
None::<&str>,
None::<>k4::gio::Cancellable>,
move |result| {
on_done(match result {
Ok(value) => Ok(value.to_str().to_string()),
Err(e) => Err(e.to_string()),
});
},
);
}
fn set_zoom(&self, level: f64) {
self.view.set_zoom_level(level);
}
fn find(&self, text: &str) {
if let Some(fc) = self.view.find_controller() {
let opts = (FindOptions::CASE_INSENSITIVE | FindOptions::WRAP_AROUND).bits();
fc.count_matches(text, FindOptions::CASE_INSENSITIVE.bits(), MAX_FIND_MATCHES);
fc.search(text, opts, MAX_FIND_MATCHES);
}
}
fn find_next(&self) {
if let Some(fc) = self.view.find_controller() {
fc.search_next();
}
}
fn find_previous(&self) {
if let Some(fc) = self.view.find_controller() {
fc.search_previous();
}
}
fn find_clear(&self) {
if let Some(fc) = self.view.find_controller() {
fc.search_finish();
}
}
fn widget(&self) -> gtk4::Widget {
self.view.clone().upcast::<gtk4::Widget>()
}
}
#[cfg(test)]
mod tests {
use super::{safe_download_path, sanitize_download_name};
#[test]
fn sanitize_reduces_to_safe_component() {
assert_eq!(sanitize_download_name("a.txt"), "a.txt");
assert_eq!(sanitize_download_name("../../etc/passwd"), "passwd");
assert_eq!(sanitize_download_name("/abs/dir/file.bin"), "file.bin");
assert_eq!(sanitize_download_name(""), "download");
assert_eq!(sanitize_download_name(".."), "download");
}
#[test]
fn safe_path_dedups_collisions() {
let dir = std::env::temp_dir().join(format!("qbrsh-dl-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let first = safe_download_path(&dir, "x.txt");
assert_eq!(first.file_name().unwrap().to_str().unwrap(), "x.txt");
std::fs::write(&first, b"a").unwrap();
let second = safe_download_path(&dir, "x.txt");
assert_ne!(first, second);
assert_eq!(second.file_name().unwrap().to_str().unwrap(), "x-1.txt");
let _ = std::fs::remove_dir_all(&dir);
}
}