use std::cell::RefCell;
use std::collections::HashSet;
use std::path::Path;
use std::rc::Rc;
use gtk4::prelude::*;
use webkit6::prelude::*;
use webkit6::{
CacheModel, HardwareAccelerationPolicy, MemoryPressureSettings, NavigationPolicyDecision,
NetworkSession, PolicyDecisionType, Settings, UserContentFilter, UserContentFilterStore,
UserContentInjectedFrames, 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::{PermissionPolicy, Permissions, TabId};
use super::traits::EngineView;
pub type PermissionMirror = Rc<RefCell<Permissions>>;
pub struct WebKitEngine {
settings: Settings,
context: WebContext,
session: NetworkSession,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
_filter_store: UserContentFilterStore,
filter: Rc<RefCell<Option<UserContentFilter>>>,
views: Rc<RefCell<Vec<glib::object::WeakRef<WebView>>>>,
}
impl WebKitEngine {
pub fn new(
debug: bool,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
filter_store_dir: &Path,
) -> 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: Rc<RefCell<Vec<glib::object::WeakRef<WebView>>>> =
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(glib::object::WeakRef::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);
Self {
settings: default_settings(debug),
context,
session: NetworkSession::default().expect("default network session"),
blocklist,
permissions,
_filter_store: store,
filter,
views,
}
}
pub fn create_view(&self, tab: TabId, mailbox: Mailbox) -> Box<dyn EngineView> {
let related = {
let mut views = self.views.borrow_mut();
views.retain(|w| w.upgrade().is_some());
views.first().and_then(glib::object::WeakRef::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(view.downgrade());
connect_signals(
&view,
tab,
mailbox,
self.blocklist.clone(),
self.permissions.clone(),
);
Box::new(WebKitView { view })
}
}
fn connect_signals(
view: &WebView,
tab: TabId,
mailbox: Mailbox,
blocklist: Rc<HashSet<String>>,
permissions: PermissionMirror,
) {
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 });
});
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();
match permissions.borrow().policy_for(&host) {
PermissionPolicy::Allow => request.allow(),
PermissionPolicy::Ask | PermissionPolicy::Deny => request.deny(),
}
true
});
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 });
});
}
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('>', ">")
}
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_developer_extras(debug);
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 widget(&self) -> gtk4::Widget {
self.view.clone().upcast::<gtk4::Widget>()
}
}