use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use buffr_config::DownloadsConfig;
use buffr_downloads::Downloads;
use buffr_history::History;
use buffr_permissions::Permissions;
use buffr_zoom::ZoomStore;
use std::path::Path;
use cef::{
BrowserSettings, CefString, CefStringUtf16, ImplBrowser, ImplBrowserHost, ImplFrame,
RequestContextSettings, WindowInfo, browser_host_create_browser_sync,
request_context_create_context,
};
use tracing::{info, warn};
use crate::audio::{
AudioEventQueue, AudioStateSink, any_audio_active as audio_any_active,
drain_audio_events as audio_drain_events, new_audio_event_queue, new_audio_state_sink,
};
use crate::handlers;
use crate::osr::{
OsrFrame, OsrViewState, PopupFrameMap, SharedOsrFrame, SharedOsrViewState,
make_osr_paint_handler,
};
use crate::permissions::PermissionsQueue;
use crate::{
PendingPopupAlloc, PopupCloseSink, PopupCreateSink, PopupQueue, new_pending_popup_alloc,
new_popup_close_sink, new_popup_create_sink, new_popup_queue,
};
use buffr_core::CoreError;
use buffr_core::context_menu::{ContextMenuRequest, ContextMenuSink, new_context_menu_sink};
use buffr_core::cursor::{CursorState, SharedCursorState};
use buffr_core::download_notice::DownloadNoticeQueue;
use buffr_core::edit::EditEventSink;
use buffr_core::favicon::favicon_is_enabled;
use buffr_core::favicon::{FaviconEnabled, FaviconSink, new_favicon_enabled, new_favicon_sink};
use buffr_core::find::FindResultSink;
use buffr_core::hint::{
DEFAULT_HINT_SELECTORS, Hint, HintAlphabet, HintEventSink, HintSession, build_inject_script,
};
use buffr_core::telemetry::{KEY_TABS_OPENED, UsageCounters};
use buffr_engine::{HintAction, HintStatus};
#[derive(Debug, Default, Clone)]
pub struct TabSession {
pub find_query: Option<String>,
pub hint_session: Option<HintSession>,
}
use buffr_engine::{TabId, TabSummary};
pub const BUFFR_SRC_PREFIX: &str = "buffr-src:";
fn to_cef_navigation_url(url: &str) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(url)
}
fn to_display_url(url: &str) -> std::borrow::Cow<'_, str> {
if let Some(rest) = url.strip_prefix("view-source:") {
std::borrow::Cow::Owned(format!("{BUFFR_SRC_PREFIX}{rest}"))
} else {
std::borrow::Cow::Borrowed(url)
}
}
fn merge_navigation_url(current: &str, incoming: &str) -> Option<String> {
let normalized = to_display_url(incoming);
if normalized == current {
None
} else {
Some(normalized.into_owned())
}
}
pub struct Tab {
pub id: TabId,
pub browser: cef::Browser,
pub url: String,
pub title: Option<String>,
pub progress: f32,
pub is_loading: bool,
pub pinned: bool,
pub session: TabSession,
}
impl Tab {
pub fn display_title(&self) -> String {
if let Some(t) = self.title.as_ref()
&& !t.is_empty()
{
return t.clone();
}
if !self.url.is_empty() {
return self.url.clone();
}
format!("{}", self.id)
}
}
pub type AddressSink = Arc<Mutex<VecDeque<(i32, String)>>>;
pub struct BrowserHost {
tabs: Mutex<Vec<Tab>>,
active: Mutex<Option<usize>>,
next_id: AtomicU64,
last_size: Mutex<(u32, u32)>,
private: bool,
history: Arc<History>,
downloads: Arc<Downloads>,
downloads_config: Arc<DownloadsConfig>,
zoom: Arc<ZoomStore>,
permissions: Arc<Permissions>,
permissions_queue: PermissionsQueue,
notice_queue: DownloadNoticeQueue,
find_sink: FindResultSink,
hint_sink: HintEventSink,
edit_sink: EditEventSink,
hint_alphabet: HintAlphabet,
counters: Option<Arc<UsageCounters>>,
osr_frame: SharedOsrFrame,
osr_view: SharedOsrViewState,
clipboard: Option<Arc<hjkl_clipboard::Clipboard>>,
loading_busy: Arc<AtomicBool>,
popup_queue: PopupQueue,
address_sink: AddressSink,
closed_stack: Mutex<Vec<ClosedTab>>,
popup_frames: PopupFrameMap,
pending_popup_alloc: PendingPopupAlloc,
popup_create_sink: PopupCreateSink,
popup_close_sink: PopupCloseSink,
popup_browsers: Arc<Mutex<HashMap<i32, cef::Browser>>>,
popup_address_sink: Arc<Mutex<VecDeque<(i32, String)>>>,
popup_title_sink: Arc<Mutex<VecDeque<(i32, String)>>>,
cursor_state: SharedCursorState,
favicon_sink: FaviconSink,
favicon_enabled: FaviconEnabled,
audio_sink: AudioStateSink,
audio_queue: AudioEventQueue,
video_active: Arc<AtomicBool>,
context_menu_sink: ContextMenuSink,
request_context: Mutex<Option<cef::RequestContext>>,
neutral_permissions_queue: buffr_engine::PermissionsQueue,
cef_callback_registry: crate::permissions::CefCallbackRegistry,
}
struct ClosedTab {
tab: Tab,
index: usize,
}
const CLOSED_STACK_CAP: usize = 8;
#[derive(Clone)]
pub struct ClipboardReader(Arc<hjkl_clipboard::Clipboard>);
impl ClipboardReader {
pub fn read_text(&self) -> Option<String> {
use hjkl_clipboard::{MimeType, Selection};
match self.0.get(Selection::Clipboard, MimeType::Text) {
Ok(bytes) => String::from_utf8(bytes).ok().filter(|s| !s.is_empty()),
Err(err) => {
tracing::debug!(error = %err, "ClipboardReader::read_text: read failed");
None
}
}
}
}
impl buffr_engine::ClipboardRead for ClipboardReader {
fn read_text(&self) -> Option<String> {
self.read_text()
}
}
impl BrowserHost {
#[allow(clippy::too_many_arguments)]
pub fn new(
url: &str,
history: Arc<History>,
downloads: Arc<Downloads>,
downloads_config: Arc<DownloadsConfig>,
zoom: Arc<ZoomStore>,
permissions: Arc<Permissions>,
permissions_queue: PermissionsQueue,
notice_queue: DownloadNoticeQueue,
find_sink: FindResultSink,
hint_sink: HintEventSink,
edit_sink: EditEventSink,
hint_alphabet: HintAlphabet,
initial_size: (u32, u32),
) -> Result<Self, CoreError> {
Self::new_with_options(
url,
history,
downloads,
downloads_config,
zoom,
permissions,
permissions_queue,
notice_queue,
find_sink,
hint_sink,
edit_sink,
hint_alphabet,
initial_size,
false,
None,
true,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_options(
url: &str,
history: Arc<History>,
downloads: Arc<Downloads>,
downloads_config: Arc<DownloadsConfig>,
zoom: Arc<ZoomStore>,
permissions: Arc<Permissions>,
permissions_queue: PermissionsQueue,
notice_queue: DownloadNoticeQueue,
find_sink: FindResultSink,
hint_sink: HintEventSink,
edit_sink: EditEventSink,
hint_alphabet: HintAlphabet,
initial_size: (u32, u32),
private: bool,
counters: Option<Arc<UsageCounters>>,
show_favicons: bool,
data_dir: Option<&Path>,
) -> Result<Self, CoreError> {
info!(target: "buffr_core::host", "creating CEF browser (initial tab) in OSR mode");
tracing::debug!(target: "buffr_core::host", %url, "creating CEF browser (initial tab) — url");
let (osr_w, osr_h) = initial_size;
let osr_view = Arc::new(OsrViewState::new());
osr_view
.width
.store(osr_w, std::sync::atomic::Ordering::Relaxed);
osr_view
.height
.store(osr_h, std::sync::atomic::Ordering::Relaxed);
let osr_frame = Arc::new(Mutex::new(OsrFrame::new(osr_w, osr_h)));
let request_context: Option<cef::RequestContext> = if let Some(dir) = data_dir {
let cache_path_str = dir.to_string_lossy();
let ctx_settings = RequestContextSettings {
cache_path: CefString::from(cache_path_str.as_ref()),
..RequestContextSettings::default()
};
tracing::info!(
cache_path = %cache_path_str,
"creating per-engine CEF RequestContext"
);
match request_context_create_context(Some(&ctx_settings), None) {
Some(ctx) => Some(ctx),
None => {
tracing::warn!(
cache_path = %cache_path_str,
"request_context_create_context returned None; \
falling back to global context"
);
None
}
}
} else {
None
};
let popup_queue = new_popup_queue();
let address_sink: AddressSink = Arc::new(Mutex::new(VecDeque::new()));
let popup_frames: PopupFrameMap = Arc::new(Mutex::new(HashMap::new()));
let popup_create_sink = new_popup_create_sink();
let popup_close_sink = new_popup_close_sink();
let pending_popup_alloc = new_pending_popup_alloc();
let host = Self {
tabs: Mutex::new(Vec::new()),
active: Mutex::new(None),
next_id: AtomicU64::new(0),
last_size: Mutex::new(initial_size),
private,
history,
downloads,
downloads_config,
zoom,
permissions,
permissions_queue,
notice_queue,
find_sink,
hint_sink,
edit_sink,
hint_alphabet,
counters,
osr_frame,
osr_view,
clipboard: match hjkl_clipboard::Clipboard::new() {
Ok(cb) => Some(Arc::new(cb)),
Err(err) => {
tracing::warn!(error = %err, "clipboard: init failed; yank/paste disabled");
None
}
},
popup_queue,
address_sink,
closed_stack: Mutex::new(Vec::new()),
popup_frames,
pending_popup_alloc,
popup_create_sink,
popup_close_sink,
popup_browsers: Arc::new(Mutex::new(HashMap::new())),
popup_address_sink: Arc::new(Mutex::new(VecDeque::new())),
popup_title_sink: Arc::new(Mutex::new(VecDeque::new())),
cursor_state: Arc::new(CursorState::new()),
favicon_sink: new_favicon_sink(),
favicon_enabled: new_favicon_enabled(show_favicons),
loading_busy: Arc::new(AtomicBool::new(false)),
audio_sink: new_audio_state_sink(),
audio_queue: new_audio_event_queue(),
video_active: Arc::new(AtomicBool::new(false)),
context_menu_sink: new_context_menu_sink(),
request_context: Mutex::new(request_context),
neutral_permissions_queue: buffr_engine::permissions::new_queue(),
cef_callback_registry: Arc::new(Mutex::new(std::collections::HashMap::new())),
};
host.open_tab(url)?;
Ok(host)
}
pub fn permissions(&self) -> &Arc<Permissions> {
&self.permissions
}
pub fn permissions_queue(&self) -> &PermissionsQueue {
&self.permissions_queue
}
pub fn popup_queue(&self) -> PopupQueue {
self.popup_queue.clone()
}
pub fn active_tab_live_url(&self) -> String {
let Ok(tabs) = self.tabs.lock() else {
return String::new();
};
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
{
t.url.clone()
} else {
String::new()
}
}
pub fn pump_address_changes(&self) -> bool {
let changes: Vec<(i32, String)> = {
let Ok(mut guard) = self.address_sink.lock() else {
return false;
};
guard.drain(..).collect()
};
if changes.is_empty() {
return false;
}
let Ok(mut tabs) = self.tabs.lock() else {
return false;
};
let mut changed = false;
let mut popup_changes: Vec<(i32, String)> = Vec::new();
for (browser_id, url) in changes {
let mut matched = false;
for t in tabs.iter_mut() {
if t.browser.identifier() == browser_id {
if let Some(new_url) = merge_navigation_url(&t.url, &url) {
t.url = new_url;
changed = true;
}
matched = true;
break;
}
}
if !matched {
popup_changes.push((browser_id, url));
}
}
if !popup_changes.is_empty()
&& let Ok(mut sink) = self.popup_address_sink.lock()
{
for entry in popup_changes {
sink.push_back(entry);
}
}
changed
}
pub fn popup_drain_address_changes(&self) -> Vec<(i32, String)> {
if let Ok(mut sink) = self.popup_address_sink.lock() {
sink.drain(..).collect()
} else {
Vec::new()
}
}
pub fn popup_drain_title_changes(&self) -> Vec<(i32, String)> {
if let Ok(mut sink) = self.popup_title_sink.lock() {
sink.drain(..).collect()
} else {
Vec::new()
}
}
pub fn active_zoom_level(&self) -> f64 {
let Ok(tabs) = self.tabs.lock() else {
return 0.0;
};
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
host.zoom_level()
} else {
0.0
}
}
pub fn osr_frame(&self) -> SharedOsrFrame {
self.osr_frame.clone()
}
pub fn osr_view(&self) -> SharedOsrViewState {
self.osr_view.clone()
}
pub fn cursor_state(&self) -> SharedCursorState {
self.cursor_state.clone()
}
pub fn favicon_sink(&self) -> FaviconSink {
self.favicon_sink.clone()
}
pub fn favicons_enabled(&self) -> bool {
favicon_is_enabled(&self.favicon_enabled)
}
pub fn set_favicon_enabled(&self, value: bool) {
buffr_core::favicon::set_favicon_enabled(&self.favicon_enabled, value);
}
pub fn set_osr_wake(&self, wake: Arc<dyn Fn() + Send + Sync>) {
let _ = self.osr_view.wake.set(wake);
}
pub fn popup_create_sink(&self) -> PopupCreateSink {
self.popup_create_sink.clone()
}
pub fn popup_close_sink(&self) -> PopupCloseSink {
self.popup_close_sink.clone()
}
pub fn popup_resize(&self, browser_id: i32, width: u32, height: u32) {
if let Ok(map) = self.popup_frames.lock()
&& let Some((_, view)) = map.get(&browser_id)
{
view.width.store(width, Ordering::Relaxed);
view.height.store(height, Ordering::Relaxed);
}
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
host.was_resized();
host.invalidate(cef::PaintElementType::VIEW);
}
tracing::debug!(browser_id, width, height, "popup_resize");
}
pub fn popup_history_back(&self, browser_id: i32) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
{
b.go_back();
}
}
pub fn popup_history_forward(&self, browser_id: i32) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
{
b.go_forward();
}
}
pub fn popup_close(&self, browser_id: i32) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
host.close_browser(0);
}
tracing::debug!(browser_id, "popup_close requested");
}
fn popup_browsers_arc(&self) -> Arc<Mutex<HashMap<i32, cef::Browser>>> {
self.popup_browsers.clone()
}
pub fn popup_osr_mouse_move(&self, browser_id: i32, x: i32, y: i32, modifiers: u32) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_move_event(Some(&event), 0);
}
}
#[allow(clippy::too_many_arguments)]
pub fn popup_osr_mouse_click(
&self,
browser_id: i32,
x: i32,
y: i32,
button: buffr_engine::MouseButton,
mouse_up: bool,
click_count: i32,
modifiers: u32,
) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
let cef_button = crate::convert::neutral_to_cef_button(button);
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_click_event(Some(&event), cef_button, mouse_up as i32, click_count);
}
}
pub fn popup_osr_mouse_wheel(
&self,
browser_id: i32,
x: i32,
y: i32,
delta_x: i32,
delta_y: i32,
modifiers: u32,
) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_wheel_event(Some(&event), delta_x, delta_y);
}
}
pub fn popup_osr_key_event(&self, browser_id: i32, event: buffr_engine::NeutralKeyEvent) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
let cef_ev = crate::convert::neutral_to_cef_key(event);
host.send_key_event(Some(&cef_ev));
}
}
pub fn popup_osr_focus(&self, browser_id: i32, focused: bool) {
if let Ok(browsers) = self.popup_browsers.lock()
&& let Some(b) = browsers.get(&browser_id)
&& let Some(host) = b.host()
{
host.set_focus(if focused { 1 } else { 0 });
}
}
pub fn osr_mouse_move(&self, x: i32, y: i32, modifiers: u32) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_move_event(Some(&event), 0);
}
}
pub fn osr_mouse_click(
&self,
x: i32,
y: i32,
button: buffr_engine::MouseButton,
mouse_up: bool,
click_count: i32,
modifiers: u32,
) {
let cef_button = crate::convert::neutral_to_cef_button(button);
tracing::debug!(
target: "buffr_core::host",
x, y, ?cef_button, mouse_up, click_count, modifiers,
"osr_mouse_click"
);
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_click_event(Some(&event), cef_button, mouse_up as i32, click_count);
} else {
warn!(target: "buffr_core::host", "osr_mouse_click: no active browser host — click dropped");
}
}
pub fn osr_mouse_leave(&self, modifiers: u32) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
let event = cef::MouseEvent {
x: 0,
y: 0,
modifiers,
};
host.send_mouse_move_event(Some(&event), 1);
}
}
pub fn notify_screen_info_changed(&self) {
let Ok(tabs) = self.tabs.lock() else {
tracing::debug!("notify_screen_info_changed: tabs mutex poisoned");
return;
};
for t in tabs.iter() {
if let Some(host) = t.browser.host() {
host.notify_screen_info_changed();
}
}
tracing::debug!("notify_screen_info_changed: dispatched to all tabs");
}
pub fn set_device_scale(&self, scale: f32) {
let old = self.osr_view.scale();
tracing::debug!(old_scale = old, new_scale = scale, "set_device_scale");
self.osr_view.set_scale(scale);
self.notify_screen_info_changed();
}
pub fn set_frame_rate(&self, hz: u32) {
let hz = hz.max(1);
self.osr_view.frame_rate_hz.store(hz, Ordering::Relaxed);
if let Ok(tabs) = self.tabs.lock() {
for t in tabs.iter() {
if let Some(host) = t.browser.host() {
host.set_windowless_frame_rate(hz as i32);
}
}
}
tracing::debug!(hz, "set_frame_rate: applied to live browsers");
}
pub fn close_all_browsers(&self) {
let tabs = match self.tabs.lock() {
Ok(g) => g,
Err(_) => return,
};
for t in tabs.iter() {
if let Some(host) = t.browser.host() {
if host.has_dev_tools() != 0 {
host.close_dev_tools();
}
host.close_browser(1);
}
}
if let Ok(stack) = self.closed_stack.lock() {
for c in stack.iter() {
if let Some(host) = c.tab.browser.host() {
if host.has_dev_tools() != 0 {
host.close_dev_tools();
}
host.close_browser(1);
}
}
}
if let Ok(browsers) = self.popup_browsers.lock() {
for b in browsers.values() {
if let Some(host) = b.host() {
host.close_browser(1);
}
}
}
tracing::info!("close_all_browsers: dispatched");
}
pub fn osr_mouse_wheel(&self, x: i32, y: i32, delta_x: i32, delta_y: i32, modifiers: u32) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
let event = cef::MouseEvent { x, y, modifiers };
host.send_mouse_wheel_event(Some(&event), delta_x, delta_y);
}
}
pub fn osr_key_event(&self, event: buffr_engine::NeutralKeyEvent) {
let cef_ev = crate::convert::neutral_to_cef_key(event);
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
host.send_key_event(Some(&cef_ev));
}
}
pub fn osr_focus(&self, focused: bool) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
host.set_focus(if focused { 1 } else { 0 });
}
}
pub fn osr_resize(&self, width: u32, height: u32) {
self.osr_view.width.store(width, Ordering::Relaxed);
self.osr_view.height.store(height, Ordering::Relaxed);
if let Ok(mut last) = self.last_size.lock() {
*last = (width, height);
}
if let Ok(mut frame) = self.osr_frame.lock() {
frame.needs_fresh = true;
}
self.notify_was_resized(width, height);
}
fn notify_was_resized(&self, width: u32, height: u32) {
let Ok(tabs) = self.tabs.lock() else {
tracing::debug!(width, height, "notify_was_resized: tabs mutex poisoned");
return;
};
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
tracing::debug!(
width,
height,
idx,
"notify_was_resized: calling was_resized"
);
host.was_resized();
host.invalidate(cef::PaintElementType::VIEW);
} else {
tracing::debug!(
width,
height,
?active_idx,
tab_count = tabs.len(),
"notify_was_resized: no active browser host",
);
}
}
fn mint_id(&self) -> TabId {
let n = self.next_id.fetch_add(1, Ordering::SeqCst);
TabId(n)
}
pub fn open_tab(&self, url: &str) -> Result<TabId, CoreError> {
let id = self.create_browser(url, false)?;
Ok(id)
}
pub fn open_tab_background(&self, url: &str) -> Result<TabId, CoreError> {
self.create_browser(url, true)
}
pub fn reopen_closed_tab(&self) -> Result<Option<TabId>, CoreError> {
let entry = match self.closed_stack.lock() {
Ok(mut s) => s.pop(),
Err(_) => None,
};
let Some(entry) = entry else {
return Ok(None);
};
let id = entry.tab.id;
let final_idx = {
let mut tabs = self
.tabs
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
let clamped = entry.index.min(tabs.len());
tabs.insert(clamped, entry.tab);
clamped
};
if let Ok(tabs) = self.tabs.lock()
&& let Some(t) = tabs.get(final_idx)
&& let Some(host) = t.browser.host()
{
host.was_hidden(0);
host.was_resized();
}
self.set_active_index(final_idx);
Ok(Some(id))
}
pub fn closed_stack_len(&self) -> usize {
self.closed_stack.lock().map(|s| s.len()).unwrap_or(0)
}
pub fn open_tab_at(&self, url: &str, insert_idx: usize) -> Result<TabId, CoreError> {
let id = self.create_browser(url, true)?;
{
let mut tabs = self
.tabs
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
let appended_idx = tabs.len() - 1;
let clamped = insert_idx.min(appended_idx);
if clamped != appended_idx {
let tab = tabs.remove(appended_idx);
tabs.insert(clamped, tab);
let mut active = self
.active
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
if let Some(a) = *active {
if a >= clamped && a < appended_idx {
*active = Some(a + 1);
}
}
}
}
let final_idx = {
let tabs = self
.tabs
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
tabs.iter().position(|t| t.id == id)
};
if let Some(idx) = final_idx {
self.set_active_index(idx);
}
Ok(id)
}
fn create_browser(&self, url: &str, background: bool) -> Result<TabId, CoreError> {
let (init_w, init_h) = match self.last_size.lock() {
Ok(g) => *g,
Err(_) => return Err(CoreError::CreateBrowserFailed),
};
let mut window_info = WindowInfo {
bounds: cef::Rect {
x: 0,
y: 0,
width: init_w as i32,
height: init_h as i32,
},
runtime_style: cef::sys::cef_runtime_style_t::CEF_RUNTIME_STYLE_ALLOY.into(),
..WindowInfo::default()
};
window_info.windowless_rendering_enabled = 1;
tracing::info!(
bounds_w = window_info.bounds.width,
bounds_h = window_info.bounds.height,
windowless_rendering_enabled = window_info.windowless_rendering_enabled,
runtime_style = ?window_info.runtime_style,
"create_browser: window_info"
);
let cef_navigation_url = to_cef_navigation_url(url);
let cef_url = CefString::from(cef_navigation_url.as_ref());
let mut settings = BrowserSettings::default();
let hz = self.osr_view.frame_rate_hz.load(Ordering::Relaxed);
settings.windowless_frame_rate = hz.max(1) as i32;
settings.background_color = 0xFFFFFFFF;
let render_handler = Some(make_osr_paint_handler(
self.osr_frame.clone(),
self.osr_view.clone(),
self.popup_frames.clone(),
self.loading_busy.clone(),
));
let mut client = handlers::make_client(
self.history.clone(),
self.downloads.clone(),
self.downloads_config.clone(),
self.zoom.clone(),
self.permissions.clone(),
self.permissions_queue.clone(),
self.neutral_permissions_queue.clone(),
self.cef_callback_registry.clone(),
self.find_sink.clone(),
self.hint_sink.clone(),
self.edit_sink.clone(),
self.counters.clone(),
self.notice_queue.clone(),
render_handler,
self.popup_queue.clone(),
self.address_sink.clone(),
self.popup_title_sink.clone(),
self.popup_frames.clone(),
self.pending_popup_alloc.clone(),
self.popup_create_sink.clone(),
self.popup_close_sink.clone(),
self.popup_browsers_arc(),
self.cursor_state.clone(),
self.favicon_sink.clone(),
self.favicon_enabled.clone(),
self.loading_busy.clone(),
self.audio_sink.clone(),
self.audio_queue.clone(),
self.video_active.clone(),
self.context_menu_sink.clone(),
);
let mut rc_guard = self
.request_context
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
let browser = browser_host_create_browser_sync(
Some(&window_info),
Some(&mut client),
Some(&cef_url),
Some(&settings),
None,
rc_guard.as_mut(),
)
.ok_or(CoreError::CreateBrowserFailed)?;
drop(rc_guard);
let id = self.mint_id();
let tab = Tab {
id,
browser,
url: to_display_url(url).into_owned(),
title: None,
progress: 1.0,
is_loading: false,
pinned: false,
session: TabSession::default(),
};
let mut tabs = self
.tabs
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
tabs.push(tab);
let new_idx = tabs.len() - 1;
drop(tabs);
if background {
if let Ok(tabs) = self.tabs.lock()
&& let Some(host) = tabs[new_idx].browser.host()
{
host.was_hidden(1);
}
} else {
self.set_active_index(new_idx);
}
info!(target: "buffr_core::host", %id, background, "tab opened");
tracing::debug!(target: "buffr_core::host", %url, "tab opened — url");
if let Some(c) = self.counters.as_ref() {
c.increment(KEY_TABS_OPENED);
}
Ok(id)
}
fn set_active_index(&self, new_idx: usize) {
let mut active = match self.active.lock() {
Ok(g) => g,
Err(_) => return,
};
let tabs = match self.tabs.lock() {
Ok(g) => g,
Err(_) => return,
};
if new_idx >= tabs.len() {
return;
}
if let Some(prev) = *active
&& prev < tabs.len()
&& prev != new_idx
&& let Some(host) = tabs[prev].browser.host()
{
host.was_hidden(1);
host.set_focus(0);
}
if let Some(host) = tabs[new_idx].browser.host() {
host.was_hidden(0);
host.was_resized();
host.invalidate(cef::PaintElementType::VIEW);
host.set_focus(1);
}
*active = Some(new_idx);
}
pub fn select_tab(&self, id: TabId) {
let idx = match self.tabs.lock() {
Ok(g) => g.iter().position(|t| t.id == id),
Err(_) => None,
};
if let Some(idx) = idx {
self.set_active_index(idx);
}
}
pub fn tab_count(&self) -> usize {
self.tabs.lock().map(|g| g.len()).unwrap_or(0)
}
pub fn pinned_count(&self) -> usize {
self.tabs
.lock()
.map(|g| g.iter().filter(|t| t.pinned).count())
.unwrap_or(0)
}
pub fn active_tab(&self) -> Option<TabSummary> {
let tabs = self.tabs.lock().ok()?;
let idx = (*self.active.lock().ok()?)?;
tabs.get(idx).map(|t| self.summarize(t))
}
pub fn tabs_summary(&self) -> Vec<TabSummary> {
let Ok(tabs) = self.tabs.lock() else {
return Vec::new();
};
tabs.iter().map(|t| self.summarize(t)).collect()
}
pub fn active_index(&self) -> Option<usize> {
self.active.lock().ok().and_then(|g| *g)
}
pub fn force_repaint_active(&self) {
let Ok(tabs) = self.tabs.lock() else {
tracing::debug!("force_repaint_active: tabs mutex poisoned");
return;
};
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
tracing::debug!(idx, "force_repaint_active: was_hidden cycle");
host.was_hidden(1);
host.was_hidden(0);
host.was_resized();
host.invalidate(cef::PaintElementType::VIEW);
}
}
pub fn osr_sleep(&self, sleep: bool) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
host.was_hidden(if sleep { 1 } else { 0 });
tracing::debug!(
target: "buffr_core::host",
sleep,
idx,
"osr_sleep"
);
}
}
pub fn osr_invalidate_view(&self) {
let Ok(tabs) = self.tabs.lock() else { return };
let active_idx = self.active.lock().ok().and_then(|g| *g);
if let Some(idx) = active_idx
&& let Some(t) = tabs.get(idx)
&& let Some(host) = t.browser.host()
{
host.was_resized();
host.invalidate(cef::PaintElementType::VIEW);
tracing::debug!(
target: "buffr_core::host",
idx,
"osr_invalidate_view"
);
}
}
pub fn any_audio_active(&self) -> bool {
audio_any_active(&self.audio_sink)
}
pub fn any_video_active(&self) -> bool {
self.video_active.load(Ordering::Relaxed)
}
pub fn drain_audio_events(&self) -> Vec<crate::audio::AudioEvent> {
audio_drain_events(&self.audio_queue)
}
pub fn drain_context_menu_requests(&self) -> Vec<ContextMenuRequest> {
if let Ok(mut q) = self.context_menu_sink.lock() {
q.drain(..).collect()
} else {
Vec::new()
}
}
fn summarize(&self, t: &Tab) -> TabSummary {
TabSummary {
id: t.id,
browser_id: t.browser.identifier(),
title: t.display_title(),
url: t.url.clone(),
progress: t.progress,
is_loading: t.is_loading,
pinned: t.pinned,
private: self.private,
}
}
pub fn next_tab(&self) {
let len = self.tab_count();
if len <= 1 {
return;
}
let cur = self.active_index().unwrap_or(0);
let next = (cur + 1) % len;
self.set_active_index(next);
}
pub fn prev_tab(&self) {
let len = self.tab_count();
if len <= 1 {
return;
}
let cur = self.active_index().unwrap_or(0);
let prev = if cur == 0 { len - 1 } else { cur - 1 };
self.set_active_index(prev);
}
pub fn close_active(&self) -> Result<bool, CoreError> {
let idx = self.active_index().ok_or(CoreError::CreateBrowserFailed)?;
self.close_index(idx)
}
pub fn close_tab(&self, id: TabId) -> Result<bool, CoreError> {
let idx = match self.tabs.lock() {
Ok(g) => g.iter().position(|t| t.id == id),
Err(_) => None,
};
match idx {
Some(i) => self.close_index(i),
None => Ok(true),
}
}
fn close_index(&self, idx: usize) -> Result<bool, CoreError> {
let removed = {
let mut tabs = self
.tabs
.lock()
.map_err(|_| CoreError::CreateBrowserFailed)?;
if idx >= tabs.len() {
return Ok(true);
}
tabs.remove(idx)
};
let stashable = !removed.url.is_empty() && removed.url != "about:blank";
if stashable {
if let Some(host) = removed.browser.host() {
host.was_hidden(1);
host.set_focus(0);
}
let evicted: Vec<Tab> = if let Ok(mut stack) = self.closed_stack.lock() {
stack.push(ClosedTab {
tab: removed,
index: idx,
});
let extra = stack.len().saturating_sub(CLOSED_STACK_CAP);
if extra > 0 {
stack.drain(0..extra).map(|c| c.tab).collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
for t in evicted {
if let Some(host) = t.browser.host() {
host.close_browser(1);
}
}
} else {
if let Some(host) = removed.browser.host() {
host.close_browser(1);
}
}
let len = self.tab_count();
if len == 0 {
if let Ok(mut a) = self.active.lock() {
*a = None;
}
return Ok(false);
}
let new_idx = if idx >= len { len - 1 } else { idx };
self.set_active_index(new_idx);
Ok(true)
}
pub fn move_tab(&self, from: usize, to: usize) {
let mut tabs = match self.tabs.lock() {
Ok(g) => g,
Err(_) => return,
};
let len = tabs.len();
if len == 0 || from == to || from >= len {
return;
}
let pinned_count = tabs.iter().filter(|t| t.pinned).count();
let (region_lo, region_hi) = if tabs[from].pinned {
(0_usize, pinned_count.saturating_sub(1))
} else {
(pinned_count, len - 1)
};
let to = to.clamp(region_lo, region_hi);
if to == from {
return;
}
let tab = tabs.remove(from);
tabs.insert(to, tab);
let mut active = match self.active.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(a) = *active {
let new_a = if a == from {
to
} else if from < a && to >= a {
a - 1
} else if from > a && to <= a {
a + 1
} else {
a
};
*active = Some(new_a);
}
}
pub fn duplicate_active(&self) -> Result<TabId, CoreError> {
let url = match self.active_tab() {
Some(t) => t.url,
None => return Err(CoreError::CreateBrowserFailed),
};
let target = if url.is_empty() {
"about:blank".to_string()
} else {
url
};
self.open_tab(&target)
}
pub fn toggle_pin_active(&self) {
let id = match self.active.lock().ok().and_then(|g| *g) {
Some(idx) => match self.tabs.lock() {
Ok(mut tabs) => match tabs.get_mut(idx) {
Some(t) => {
t.pinned = !t.pinned;
t.id
}
None => return,
},
Err(_) => return,
},
None => return,
};
let _ = id;
self.enforce_pinned_ordering();
}
pub fn set_pinned(&self, id: TabId, pinned: bool) {
if let Ok(mut tabs) = self.tabs.lock()
&& let Some(t) = tabs.iter_mut().find(|t| t.id == id)
{
t.pinned = pinned;
}
self.enforce_pinned_ordering();
}
fn enforce_pinned_ordering(&self) {
let active_id = {
let Ok(active) = self.active.lock() else {
return;
};
let Ok(tabs) = self.tabs.lock() else {
return;
};
(*active).and_then(|i| tabs.get(i).map(|t| t.id))
};
let Ok(mut tabs) = self.tabs.lock() else {
return;
};
let drained: Vec<Tab> = tabs.drain(..).collect();
let (pinned, unpinned): (Vec<Tab>, Vec<Tab>) = drained.into_iter().partition(|t| t.pinned);
tabs.extend(pinned);
tabs.extend(unpinned);
let new_idx = active_id.and_then(|id| tabs.iter().position(|t| t.id == id));
drop(tabs);
if let Ok(mut a) = self.active.lock() {
*a = new_idx;
}
}
pub fn record_url(&self, cef_id: i32, url: &str) -> Option<TabId> {
let mut tabs = self.tabs.lock().ok()?;
for t in tabs.iter_mut() {
if t.browser.identifier() == cef_id {
if let Some(new_url) = merge_navigation_url(&t.url, url) {
t.url = new_url;
}
return Some(t.id);
}
}
None
}
pub fn resize(&self, width: u32, height: u32) {
if let Ok(mut last) = self.last_size.lock() {
*last = (width, height);
}
let Ok(tabs) = self.tabs.lock() else {
return;
};
for t in tabs.iter() {
if let Some(host) = t.browser.host() {
host.was_resized();
}
}
}
pub fn navigate(&self, url: &str) -> Result<(), CoreError> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(CoreError::InvalidUrl(String::new()));
}
self.with_active(|t| {
let Some(frame) = t.browser.main_frame() else {
warn!("navigate: main frame unavailable");
return Err(CoreError::CreateBrowserFailed);
};
let cef_navigation_url = to_cef_navigation_url(trimmed);
let cef_url = CefString::from(cef_navigation_url.as_ref());
frame.load_url(Some(&cef_url));
t.url = to_display_url(trimmed).into_owned();
tracing::debug!(target: "buffr_core::host", url = %trimmed, "navigate");
Ok(())
})
.ok_or(CoreError::CreateBrowserFailed)?
}
fn with_active<R>(&self, f: impl FnOnce(&mut Tab) -> R) -> Option<R> {
let mut tabs = self.tabs.lock().ok()?;
let idx = (*self.active.lock().ok()?)?;
let t = tabs.get_mut(idx)?;
Some(f(t))
}
pub fn start_find(&self, query: &str, forward: bool) {
if query.is_empty() {
self.stop_find();
return;
}
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("start_find: browser.host() returned None");
return;
};
let cef_query = CefString::from(query);
host.find(Some(&cef_query), forward as i32, 0, 0);
t.session.find_query = Some(query.to_string());
});
}
pub fn stop_find(&self) {
self.with_active(|t| {
if let Some(host) = t.browser.host() {
host.stop_finding(1);
}
t.session.find_query = None;
});
}
fn find_step(&self, forward: bool) {
self.with_active(|t| {
let Some(query) = t.session.find_query.clone() else {
tracing::info!("find_step: no active query — call start_find first");
return;
};
let Some(host) = t.browser.host() else {
warn!("find_step: browser.host() returned None");
return;
};
let cef_query = CefString::from(query.as_str());
host.find(Some(&cef_query), forward as i32, 0, 1);
});
}
pub fn dispatch(&self, action: &buffr_modal::PageAction) {
use buffr_modal::PageAction as A;
match action {
A::ScrollUp(n) => self.scroll_by(0, -(STEP_PX * (*n as i64))),
A::ScrollDown(n) => self.scroll_by(0, STEP_PX * (*n as i64)),
A::ScrollLeft(n) => self.scroll_by(-(STEP_PX * (*n as i64)), 0),
A::ScrollRight(n) => self.scroll_by(STEP_PX * (*n as i64), 0),
A::ScrollPageDown | A::ScrollFullPageDown => {
self.run_js("window.scrollBy(0, window.innerHeight * 0.9);")
}
A::ScrollPageUp | A::ScrollFullPageUp => {
self.run_js("window.scrollBy(0, -window.innerHeight * 0.9);")
}
A::ScrollHalfPageDown => self.run_js("window.scrollBy(0, window.innerHeight * 0.5);"),
A::ScrollHalfPageUp => self.run_js("window.scrollBy(0, -window.innerHeight * 0.5);"),
A::ScrollTop => self.run_js("window.scrollTo(0, 0);"),
A::ScrollBottom => self.run_js("window.scrollTo(0, document.body.scrollHeight);"),
A::HistoryBack => {
self.with_active(|t| t.browser.go_back());
}
A::HistoryForward => {
self.with_active(|t| t.browser.go_forward());
}
A::Reload => {
self.with_active(|t| t.browser.reload());
}
A::ReloadHard => {
self.with_active(|t| t.browser.reload_ignore_cache());
}
A::StopLoading => {
self.with_active(|t| t.browser.stop_load());
}
A::ZoomIn => self.adjust_zoom(0.25),
A::ZoomOut => self.adjust_zoom(-0.25),
A::ZoomReset => self.reset_zoom(),
A::OpenDevTools => {
self.with_active(|t| {
if let Some(host) = t.browser.host() {
let window_info = WindowInfo::default();
let settings = BrowserSettings::default();
host.show_dev_tools(Some(&window_info), None, Some(&settings), None);
} else {
warn!("OpenDevTools: browser.host() returned None");
}
});
}
A::Find { forward } => {
tracing::trace!(
forward = *forward,
"Find: intercepted at apps layer; host dispatch is a no-op."
);
}
A::FindNext => self.find_step(true),
A::FindPrev => self.find_step(false),
A::TabNext => self.next_tab(),
A::TabPrev => self.prev_tab(),
A::TabClose => {
let _ = self.close_active();
}
A::TabNew => {
let _ = self.open_tab("about:blank");
}
A::TabNewRight | A::TabNewLeft => {
let _ = self.open_tab("about:blank");
}
A::PinTab => self.toggle_pin_active(),
A::ReopenClosedTab => match self.reopen_closed_tab() {
Ok(Some(_)) => {}
Ok(None) => tracing::debug!("reopen_closed_tab: stack empty"),
Err(err) => tracing::warn!(error = %err, "reopen_closed_tab: failed"),
},
A::PasteUrl { .. } => {
tracing::debug!("PasteUrl reached host dispatch — apps layer should handle it");
}
A::TabReorder { from, to } => self.move_tab(*from as usize, *to as usize),
A::MoveTabLeft => {
if let Some(idx) = self.active_index()
&& idx > 0
{
self.move_tab(idx, idx - 1);
}
}
A::MoveTabRight => {
if let Some(idx) = self.active_index() {
let last = self.tab_count().saturating_sub(1);
if idx < last {
self.move_tab(idx, idx + 1);
}
}
}
A::OpenOmnibar | A::OpenCommandLine => {
tracing::info!("UI action — overlay rendering owned by apps layer");
}
A::EnterHintMode => self.enter_hint_mode(false),
A::EnterHintModeBackground => self.enter_hint_mode(true),
A::EnterMode(mode) => {
tracing::info!(?mode, "EnterMode — engine tracks mode internally");
}
A::EnterInsertMode => {
tracing::info!(
"insert-mode requested — hjkl-engine integration is Phase 2b \
(blocked on hjkl Host trait)"
);
}
A::FocusFirstInput => {
self.run_js("window.__buffrUserGesture = true;");
self.run_js(buffr_core::scripts::FOCUS_FIRST_INPUT);
}
A::ExitInsertMode => {
self.run_js(buffr_core::scripts::EXIT_INSERT);
}
A::ClearCompletedDownloads => match self.downloads.clear_completed() {
Ok(n) => tracing::info!(removed = n, "downloads: cleared completed"),
Err(err) => tracing::warn!(error = %err, "downloads: clear_completed failed"),
},
A::YankUrl => {
self.with_active(|t| {
if let Some(frame) = t.browser.main_frame() {
let url = CefStringUtf16::from(&frame.url()).to_string();
if self.clipboard_set_text(&url) {
tracing::debug!(url, "yanked URL to clipboard");
} else {
tracing::warn!(url, "yank failed: clipboard write failed");
}
} else {
tracing::warn!("YankUrl: main frame unavailable");
}
});
}
A::YankSelection => {
self.run_main_frame_js(
"if (window.__buffrEmitSelection) window.__buffrEmitSelection()",
"buffr://yank-selection",
);
}
A::Engine(_id) => {
tracing::debug!("Engine action reached host dispatch — apps layer should handle");
}
}
}
pub fn hint_status(&self) -> Option<HintStatus> {
let tabs = self.tabs.lock().ok()?;
let idx = (*self.active.lock().ok()?)?;
let t = tabs.get(idx)?;
let s = t.session.hint_session.as_ref()?;
Some(HintStatus {
typed: s.typed.clone(),
match_count: s.match_count(),
background: s.background,
})
}
pub fn is_hint_mode(&self) -> bool {
self.with_active(|t| t.session.hint_session.is_some())
.unwrap_or(false)
}
pub fn enter_hint_mode(&self, background: bool) {
const LABEL_BUDGET: usize = 256;
let labels = self.hint_alphabet.labels_for(LABEL_BUDGET);
let alphabet_str = self.hint_alphabet.as_string();
let script = build_inject_script(&alphabet_str, &labels, DEFAULT_HINT_SELECTORS);
let alphabet = self.hint_alphabet.clone();
let mut bail = false;
self.with_active(|t| {
t.session.hint_session = Some(HintSession::new(alphabet, Vec::new(), background));
let Some(frame) = t.browser.main_frame() else {
warn!("enter_hint_mode: main frame unavailable");
bail = true;
return;
};
let url = CefStringUtf16::from(&frame.url()).to_string();
let cef_script = CefString::from(script.as_str());
let cef_url = CefString::from(url.as_str());
frame.execute_java_script(Some(&cef_script), Some(&cef_url), 1);
info!(
background,
label_budget = LABEL_BUDGET,
"hint mode: injected"
);
});
if bail {
self.cancel_hint();
}
}
pub fn pump_hint_events(&self) -> bool {
let Some(event) = buffr_core::hint::take_hint_event(&self.hint_sink) else {
return false;
};
match event {
buffr_core::hint::HintConsoleEvent::Ready { hints, alphabet: _ } => {
let alphabet = self.hint_alphabet.clone();
self.with_active(|t| {
if let Some(existing) = t.session.hint_session.as_mut() {
let background = existing.background;
*existing = HintSession::new(alphabet, hints, background);
}
});
true
}
buffr_core::hint::HintConsoleEvent::Error { message } => {
warn!(message, "hint mode: renderer reported error");
self.cancel_hint();
true
}
}
}
pub fn feed_hint_key(&self, ch: char) -> Option<HintAction> {
let mut commit_id: Option<u32> = None;
let mut filter_typed: Option<String> = None;
let mut clear = false;
let mut cancel = false;
let result = self.with_active(|t| {
let session = t.session.hint_session.as_mut()?;
let action = session.feed(ch);
let typed = session.typed.clone();
match &action {
HintAction::Filter => filter_typed = Some(typed),
HintAction::Click(id) | HintAction::OpenInBackground(id) => {
if matches!(action, HintAction::OpenInBackground(_)) {
tracing::warn!(
element_id = *id,
"hint background commit: routes through `open_tab_background`",
);
}
commit_id = Some(*id);
clear = true;
}
HintAction::Cancel => {
cancel = true;
}
}
Some(action)
});
let action = result.flatten()?;
if let Some(typed) = filter_typed {
self.run_hint_js(&format!(
"if (window.__buffrHintFilter) window.__buffrHintFilter({})",
json_string_literal(&typed)
));
}
if let Some(id) = commit_id {
self.run_hint_js(&format!(
"if (window.__buffrHintCommit) window.__buffrHintCommit({id})"
));
}
if clear {
self.with_active(|t| {
t.session.hint_session = None;
});
}
if cancel {
self.cancel_hint();
}
Some(action)
}
pub fn backspace_hint(&self) -> Option<HintAction> {
let mut filter_typed: Option<String> = None;
let mut cancel = false;
let result = self.with_active(|t| {
let session = t.session.hint_session.as_mut()?;
let action = session.backspace();
let typed = session.typed.clone();
match &action {
HintAction::Filter => filter_typed = Some(typed),
HintAction::Cancel => cancel = true,
_ => {}
}
Some(action)
});
let action = result.flatten()?;
if let Some(typed) = filter_typed {
self.run_hint_js(&format!(
"if (window.__buffrHintFilter) window.__buffrHintFilter({})",
json_string_literal(&typed)
));
}
if cancel {
self.cancel_hint();
}
Some(action)
}
pub fn cancel_hint(&self) {
self.run_hint_js("if (window.__buffrHintCancel) window.__buffrHintCancel()");
self.with_active(|t| {
t.session.hint_session = None;
});
}
fn run_hint_js(&self, code: &str) {
self.run_main_frame_js(code, "buffr://hint");
}
pub fn run_main_frame_js(&self, code: &str, url: &str) {
self.with_active(|t| {
let Some(frame) = t.browser.main_frame() else {
return;
};
let cef_code = CefString::from(code);
let cef_url = CefString::from(url);
frame.execute_java_script(Some(&cef_code), Some(&cef_url), 1);
});
}
pub fn run_edit_apply(&self, field_id: &str, value: &str) {
let escaped_id = serde_json::to_string(field_id).unwrap_or_else(|_| "\"\"".to_string());
let escaped_value = serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string());
self.run_main_frame_js(
&format!("if (window.__buffrEditApply) window.__buffrEditApply({escaped_id}, {escaped_value})"),
"buffr://edit",
);
}
pub fn run_edit_attach(&self, field_id: &str) {
let escaped_id = serde_json::to_string(field_id).unwrap_or_else(|_| "\"\"".to_string());
self.run_main_frame_js(
&format!("if (window.__buffrEditAttach) window.__buffrEditAttach({escaped_id})"),
"buffr://edit",
);
}
pub fn run_edit_focus(&self, field_id: &str) {
let escaped_id = serde_json::to_string(field_id).unwrap_or_else(|_| "\"\"".to_string());
self.run_main_frame_js(
&format!(
"window.__buffrUserGesture = true; \
if (window.__buffrEditFocus) window.__buffrEditFocus({escaped_id})"
),
"buffr://edit",
);
}
pub fn clipboard_handle(&self) -> Option<ClipboardReader> {
self.clipboard.clone().map(ClipboardReader)
}
pub fn is_loading(&self) -> bool {
self.loading_busy.load(Ordering::Relaxed)
}
pub fn clipboard_text(&self) -> Option<String> {
use hjkl_clipboard::{MimeType, Selection};
let cb = self.clipboard.as_ref()?;
match cb.get(Selection::Clipboard, MimeType::Text) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(s) if !s.is_empty() => Some(s),
Ok(_) => None,
Err(err) => {
tracing::debug!(error = %err, "clipboard_text: non-utf8 payload");
None
}
},
Err(err) => {
tracing::debug!(error = %err, "clipboard_text: read failed");
None
}
}
}
pub fn clipboard_set_text(&self, text: &str) -> bool {
use hjkl_clipboard::{MimeType, Selection};
let Some(cb) = self.clipboard.as_ref() else {
return false;
};
match cb.set(Selection::Clipboard, MimeType::Text, text.as_bytes()) {
Ok(()) => true,
Err(err) => {
tracing::warn!(error = %err, "clipboard_set_text: write failed");
false
}
}
}
pub fn copy_image_url_to_clipboard(&self, url: &str) {
buffr_core::image_copy::copy_image_to_clipboard(url.to_string());
}
pub fn run_edit_cycle(&self, forward: bool) {
let arg = if forward { "true" } else { "false" };
self.run_main_frame_js(
&format!("if (window.__buffrCycleInput) window.__buffrCycleInput({arg})"),
"buffr://edit",
);
}
pub fn run_edit_detach(&self, field_id: &str) {
let escaped_id = serde_json::to_string(field_id).unwrap_or_else(|_| "\"\"".to_string());
self.run_main_frame_js(
&format!("if (window.__buffrEditDetach) window.__buffrEditDetach({escaped_id})"),
"buffr://edit",
);
}
pub fn run_js(&self, code: &str) {
self.with_active(|t| {
let Some(frame) = t.browser.main_frame() else {
warn!("run_js: main frame unavailable");
return;
};
let code = CefString::from(code);
let script_url = CefString::from("buffr://page-action");
frame.execute_java_script(Some(&code), Some(&script_url), 0);
});
}
fn with_focused_frame<R>(&self, f: impl FnOnce(cef::Frame) -> R) -> Option<R> {
self.with_active(|t| {
let frame = t
.browser
.focused_frame()
.or_else(|| t.browser.main_frame())?;
Some(f(frame))
})?
}
pub fn frame_undo(&self) {
if self.with_focused_frame(|f| f.undo()).is_none() {
warn!("frame_undo: no focused frame");
}
}
pub fn frame_redo(&self) {
if self.with_focused_frame(|f| f.redo()).is_none() {
warn!("frame_redo: no focused frame");
}
}
pub fn frame_cut(&self) {
if self.with_focused_frame(|f| f.cut()).is_none() {
warn!("frame_cut: no focused frame");
}
}
pub fn frame_copy(&self) {
if self.with_focused_frame(|f| f.copy()).is_none() {
warn!("frame_copy: no focused frame");
}
}
pub fn frame_paste(&self) {
if self.with_focused_frame(|f| f.paste()).is_none() {
warn!("frame_paste: no focused frame");
}
}
pub fn frame_paste_plain(&self) {
if self
.with_focused_frame(|f| f.paste_and_match_style())
.is_none()
{
warn!("frame_paste_plain: no focused frame");
}
}
pub fn frame_del(&self) {
if self.with_focused_frame(|f| f.del()).is_none() {
warn!("frame_del: no focused frame");
}
}
pub fn frame_select_all(&self) {
if self.with_focused_frame(|f| f.select_all()).is_none() {
warn!("frame_select_all: no focused frame");
}
}
pub fn reload_ignore_cache_active(&self) {
self.with_active(|t| t.browser.reload_ignore_cache());
}
pub fn print_active(&self) {
self.with_active(|t| {
if let Some(host) = t.browser.host() {
host.print();
} else {
warn!("print_active: browser.host() returned None");
}
});
}
pub fn start_download(&self, url: &str) {
self.with_active(|t| {
if let Some(host) = t.browser.host() {
let cef_url = CefString::from(url);
host.start_download(Some(&cef_url));
} else {
warn!("start_download: browser.host() returned None");
}
});
}
pub fn show_dev_tools_at(&self, x: Option<i32>, y: Option<i32>) {
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("show_dev_tools_at: browser.host() returned None");
return;
};
let window_info = WindowInfo::default();
let settings = BrowserSettings::default();
let point = x.zip(y).map(|(px, py)| cef::Point { x: px, y: py });
host.show_dev_tools(Some(&window_info), None, Some(&settings), point.as_ref());
});
}
pub fn ime_set_composition(&self, text: &str, cursor: Option<(usize, usize)>) {
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("ime_set_composition: browser.host() returned None");
return;
};
let cef_text = cef::CefString::from(text);
let (sel_from, sel_to) =
cursor
.map(|(s, e)| (s as u32, e as u32))
.unwrap_or_else(|| {
let end = text.len() as u32;
(end, end)
});
let selection_range = cef::Range {
from: sel_from,
to: sel_to,
};
host.ime_set_composition(Some(&cef_text), Some(&[]), None, Some(&selection_range));
});
}
pub fn ime_commit(&self, text: &str) {
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("ime_commit: browser.host() returned None");
return;
};
let cef_text = cef::CefString::from(text);
host.ime_commit_text(Some(&cef_text), None, 0);
});
}
pub fn ime_cancel(&self) {
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("ime_cancel: browser.host() returned None");
return;
};
host.ime_cancel_composition();
});
}
pub fn run_media_probe(&self) {
self.run_js(buffr_core::scripts::MEDIA_PROBE_POLL_JS);
}
pub fn read_media_probe_result(&self) {
}
fn scroll_by(&self, dx: i64, dy: i64) {
let code = format!("window.scrollBy({dx}, {dy});");
self.run_js(&code);
}
fn adjust_zoom(&self, delta: f64) {
let domain = self.current_domain();
let new_level = self
.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("adjust_zoom: browser.host() returned None");
return None;
};
let new_level = host.zoom_level() + delta;
host.set_zoom_level(new_level);
Some(new_level)
})
.flatten();
if let Some(level) = new_level
&& let Err(err) = self.zoom.set(&domain, level)
{
warn!(error = %err, %domain, "zoom: persist failed");
}
}
fn reset_zoom(&self) {
let domain = self.current_domain();
self.with_active(|t| {
let Some(host) = t.browser.host() else {
warn!("reset_zoom: browser.host() returned None");
return;
};
host.set_zoom_level(0.0);
});
if let Err(err) = self.zoom.remove(&domain) {
warn!(error = %err, %domain, "zoom: remove failed");
}
}
pub fn media_play_pause(&self, x: i32, y: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
if(el.paused)el.play();else el.pause();\
}})({x},{y});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
pub fn media_toggle_mute(&self, x: i32, y: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.muted=!el.muted;\
}})({x},{y});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
pub fn media_toggle_loop(&self, x: i32, y: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.loop=!el.loop;\
}})({x},{y});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
pub fn media_toggle_controls(&self, x: i32, y: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.controls=!el.controls;\
}})({x},{y});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
pub fn media_picture_in_picture(&self, x: i32, y: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y){{\
try{{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLVideoElement))el=el.parentElement;\
if(!el)el=document.querySelector('video');\
if(!el)return;\
if(document.pictureInPictureElement===el){{\
document.exitPictureInPicture();\
}}else{{\
el.requestPictureInPicture();\
}}\
}}catch(e){{}}\
}})({x},{y});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
pub fn image_rotate(&self, x: i32, y: i32, delta_deg: i32) {
let x = serde_json::to_string(&x).unwrap_or_else(|_| "0".to_string());
let y = serde_json::to_string(&y).unwrap_or_else(|_| "0".to_string());
let delta = serde_json::to_string(&delta_deg).unwrap_or_else(|_| "0".to_string());
let js = format!(
"(function(x,y,delta){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLImageElement))el=el.parentElement;\
if(!el)return;\
var cur=parseInt(el.dataset.buffrRotate||'0',10);\
cur=(cur+delta)%360;\
if(cur<0)cur+=360;\
el.dataset.buffrRotate=String(cur);\
el.style.transform='rotate('+cur+'deg)';\
}})({x},{y},{delta});"
);
self.run_main_frame_js(&js, "buffr://context-menu");
}
fn current_domain(&self) -> String {
self.with_active(|t| {
t.browser
.main_frame()
.map(|f| {
let url = CefStringUtf16::from(&f.url()).to_string();
buffr_zoom::domain_for_url(&url)
})
.unwrap_or_else(|| buffr_zoom::GLOBAL_KEY.to_string())
})
.unwrap_or_else(|| buffr_zoom::GLOBAL_KEY.to_string())
}
}
fn json_string_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_ascii_graphic() || c == ' ' => out.push(c),
c => {
let mut buf = [0u16; 2];
for unit in c.encode_utf16(&mut buf).iter() {
out.push_str(&format!("\\u{unit:04x}"));
}
}
}
}
out.push('"');
out
}
#[allow(dead_code)]
fn _hint_used(_: Hint) {}
const STEP_PX: i64 = 40;
impl buffr_engine::BrowserEngine for BrowserHost {
fn close_all_browsers(&self) {
self.close_all_browsers()
}
fn open_tab(&self, url: &str) -> Result<TabId, buffr_engine::EngineError> {
self.open_tab(url)
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn open_tab_background(&self, url: &str) -> Result<TabId, buffr_engine::EngineError> {
self.open_tab_background(url)
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn open_tab_at(
&self,
url: &str,
insert_idx: usize,
) -> Result<TabId, buffr_engine::EngineError> {
self.open_tab_at(url, insert_idx)
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn close_tab(&self, id: TabId) -> Result<bool, buffr_engine::EngineError> {
self.close_tab(id)
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn close_active(&self) -> Result<bool, buffr_engine::EngineError> {
self.close_active()
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn select_tab(&self, id: TabId) {
self.select_tab(id)
}
fn next_tab(&self) {
self.next_tab()
}
fn prev_tab(&self) {
self.prev_tab()
}
fn move_tab(&self, from: usize, to: usize) {
self.move_tab(from, to)
}
fn duplicate_active(&self) -> Result<TabId, buffr_engine::EngineError> {
self.duplicate_active()
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn toggle_pin_active(&self) {
self.toggle_pin_active()
}
fn set_pinned(&self, id: TabId, pinned: bool) {
self.set_pinned(id, pinned)
}
fn reopen_closed_tab(&self) -> Result<Option<TabId>, buffr_engine::EngineError> {
self.reopen_closed_tab()
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn closed_stack_len(&self) -> usize {
self.closed_stack_len()
}
fn active_tab(&self) -> Option<TabSummary> {
self.active_tab()
}
fn tabs_summary(&self) -> Vec<TabSummary> {
self.tabs_summary()
}
fn tab_count(&self) -> usize {
self.tab_count()
}
fn pinned_count(&self) -> usize {
self.pinned_count()
}
fn active_index(&self) -> Option<usize> {
self.active_index()
}
fn navigate(&self, url: &str) -> Result<(), buffr_engine::EngineError> {
self.navigate(url)
.map_err(|e| buffr_engine::EngineError::Other(e.to_string()))
}
fn active_tab_live_url(&self) -> String {
self.active_tab_live_url()
}
fn pump_address_changes(&self) -> bool {
self.pump_address_changes()
}
fn resize(&self, width: u32, height: u32) {
self.resize(width, height)
}
fn set_device_scale(&self, scale: f32) {
self.set_device_scale(scale)
}
fn set_frame_rate(&self, hz: u32) {
self.set_frame_rate(hz)
}
fn notify_screen_info_changed(&self) {
self.notify_screen_info_changed()
}
fn osr_resize(&self, width: u32, height: u32) {
self.osr_resize(width, height)
}
fn osr_key_event(&self, event: buffr_engine::NeutralKeyEvent) {
self.osr_key_event(event)
}
fn osr_mouse_move(&self, x: i32, y: i32, modifiers: u32) {
self.osr_mouse_move(x, y, modifiers)
}
fn osr_mouse_click(
&self,
x: i32,
y: i32,
button: buffr_engine::MouseButton,
mouse_up: bool,
click_count: i32,
modifiers: u32,
) {
self.osr_mouse_click(x, y, button, mouse_up, click_count, modifiers)
}
fn osr_mouse_leave(&self, modifiers: u32) {
self.osr_mouse_leave(modifiers)
}
fn osr_mouse_wheel(&self, x: i32, y: i32, delta_x: i32, delta_y: i32, modifiers: u32) {
self.osr_mouse_wheel(x, y, delta_x, delta_y, modifiers)
}
fn osr_focus(&self, focused: bool) {
self.osr_focus(focused)
}
fn osr_frame(&self) -> buffr_engine::SharedOsrFrame {
self.osr_frame()
}
fn osr_view(&self) -> buffr_engine::SharedOsrViewState {
self.osr_view()
}
fn force_repaint_active(&self) {
self.force_repaint_active()
}
fn osr_sleep(&self, sleep: bool) {
self.osr_sleep(sleep)
}
fn osr_invalidate_view(&self) {
self.osr_invalidate_view()
}
fn set_osr_wake(&self, wake: std::sync::Arc<dyn Fn() + Send + Sync>) {
self.set_osr_wake(wake)
}
fn open_devtools(&self, _tab: buffr_engine::TabId) -> Result<(), buffr_engine::EngineError> {
self.show_dev_tools_at(None, None);
Ok(())
}
fn start_find(&self, query: &str, forward: bool) {
self.start_find(query, forward)
}
fn stop_find(&self) {
self.stop_find()
}
fn active_zoom_level(&self) -> f64 {
self.active_zoom_level()
}
fn any_audio_active(&self) -> bool {
self.any_audio_active()
}
fn any_video_active(&self) -> bool {
self.any_video_active()
}
fn popup_queue(&self) -> buffr_engine::popup::PopupQueue {
self.popup_queue()
}
fn popup_create_sink(&self) -> buffr_engine::popup::PopupCreateSink {
self.popup_create_sink()
}
fn popup_close_sink(&self) -> buffr_engine::popup::PopupCloseSink {
self.popup_close_sink()
}
fn popup_resize(&self, browser_id: i32, width: u32, height: u32) {
self.popup_resize(browser_id, width, height)
}
fn popup_close(&self, browser_id: i32) {
self.popup_close(browser_id)
}
fn popup_drain_address_changes(&self) -> Vec<(i32, String)> {
self.popup_drain_address_changes()
}
fn popup_drain_title_changes(&self) -> Vec<(i32, String)> {
self.popup_drain_title_changes()
}
fn popup_history_back(&self, browser_id: i32) {
self.popup_history_back(browser_id)
}
fn popup_history_forward(&self, browser_id: i32) {
self.popup_history_forward(browser_id)
}
fn popup_osr_focus(&self, browser_id: i32, focused: bool) {
self.popup_osr_focus(browser_id, focused)
}
fn popup_osr_key_event(&self, browser_id: i32, event: buffr_engine::NeutralKeyEvent) {
self.popup_osr_key_event(browser_id, event)
}
fn popup_osr_mouse_click(
&self,
browser_id: i32,
x: i32,
y: i32,
button: buffr_engine::MouseButton,
mouse_up: bool,
click_count: i32,
modifiers: u32,
) {
self.popup_osr_mouse_click(browser_id, x, y, button, mouse_up, click_count, modifiers)
}
fn popup_osr_mouse_move(&self, browser_id: i32, x: i32, y: i32, modifiers: u32) {
self.popup_osr_mouse_move(browser_id, x, y, modifiers)
}
fn popup_osr_mouse_wheel(
&self,
browser_id: i32,
x: i32,
y: i32,
delta_x: i32,
delta_y: i32,
modifiers: u32,
) {
self.popup_osr_mouse_wheel(browser_id, x, y, delta_x, delta_y, modifiers)
}
fn is_hint_mode(&self) -> bool {
self.is_hint_mode()
}
fn hint_status(&self) -> Option<buffr_engine::HintStatus> {
self.hint_status()
}
fn pump_hint_events(&self) -> bool {
self.pump_hint_events()
}
fn feed_hint_key(&self, c: char) -> Option<buffr_engine::HintAction> {
self.feed_hint_key(c)
}
fn backspace_hint(&self) -> Option<buffr_engine::HintAction> {
self.backspace_hint()
}
fn cancel_hint(&self) {
self.cancel_hint()
}
fn frame_undo(&self) {
self.frame_undo()
}
fn frame_redo(&self) {
self.frame_redo()
}
fn frame_cut(&self) {
self.frame_cut()
}
fn frame_copy(&self) {
self.frame_copy()
}
fn frame_paste(&self) {
self.frame_paste()
}
fn frame_paste_plain(&self) {
self.frame_paste_plain()
}
fn frame_select_all(&self) {
self.frame_select_all()
}
fn media_play_pause(&self, x: i32, y: i32) {
self.media_play_pause(x, y)
}
fn media_picture_in_picture(&self, x: i32, y: i32) {
self.media_picture_in_picture(x, y)
}
fn media_toggle_controls(&self, x: i32, y: i32) {
self.media_toggle_controls(x, y)
}
fn media_toggle_loop(&self, x: i32, y: i32) {
self.media_toggle_loop(x, y)
}
fn media_toggle_mute(&self, x: i32, y: i32) {
self.media_toggle_mute(x, y)
}
fn image_rotate(&self, x: i32, y: i32, delta_deg: i32) {
self.image_rotate(x, y, delta_deg)
}
fn run_js(&self, code: &str) -> Result<(), buffr_engine::EngineError> {
self.run_js(code);
Ok(())
}
fn run_main_frame_js(&self, code: &str, url: &str) -> Result<(), buffr_engine::EngineError> {
self.run_main_frame_js(code, url);
Ok(())
}
fn run_edit_attach(&self, field_id: &str) {
self.run_edit_attach(field_id)
}
fn run_edit_cycle(&self, forward: bool) {
self.run_edit_cycle(forward)
}
fn run_edit_detach(&self, field_id: &str) {
self.run_edit_detach(field_id)
}
fn run_edit_focus(&self, field_id: &str) {
self.run_edit_focus(field_id)
}
fn run_media_probe(&self) {
self.run_media_probe()
}
fn start_download(&self, url: &str) {
self.start_download(url)
}
fn show_dev_tools_at(&self, x: i32, y: i32) {
self.show_dev_tools_at(Some(x), Some(y))
}
fn clipboard_handle(&self) -> Option<buffr_engine::ClipboardReader> {
self.clipboard_handle()
.map(|r| -> buffr_engine::ClipboardReader { std::sync::Arc::new(r) })
}
fn clipboard_text(&self) -> Option<String> {
self.clipboard_text()
}
fn clipboard_set_text(&self, text: &str) -> bool {
self.clipboard_set_text(text)
}
fn copy_image_url_to_clipboard(&self, url: &str) {
self.copy_image_url_to_clipboard(url)
}
fn drain_audio_events(&self) -> Vec<buffr_engine::AudioEvent> {
self.drain_audio_events()
.into_iter()
.map(|e| buffr_engine::AudioEvent {
browser_id: e.browser_id,
active: e.active,
})
.collect()
}
fn take_cursor_change(&self) -> Option<(i32, u32)> {
self.cursor_state().take()
}
fn drain_favicon_updates(&self) -> Vec<buffr_engine::FaviconUpdate> {
buffr_core::drain_favicon_updates(&self.favicon_sink())
.into_iter()
.map(|u| buffr_engine::FaviconUpdate {
browser_id: u.browser_id,
width: u.width,
height: u.height,
pixels: u.pixels,
})
.collect()
}
fn is_loading(&self) -> bool {
self.is_loading()
}
fn can_go_back(&self) -> bool {
self.with_active(|t| t.browser.can_go_back() != 0)
.unwrap_or(false)
}
fn can_go_forward(&self) -> bool {
self.with_active(|t| t.browser.can_go_forward() != 0)
.unwrap_or(false)
}
fn drain_context_menu_requests(&self) -> Vec<buffr_engine::ContextMenuRequest> {
use buffr_engine::types::MediaType;
self.drain_context_menu_requests()
.into_iter()
.map(|r| {
let source = if r.source_url.is_empty() {
None
} else {
Some(r.source_url)
};
let is_editable = r.items.iter().any(|i| {
matches!(
i,
buffr_core::ContextMenuItem::Cut | buffr_core::ContextMenuItem::Paste
)
});
let has_image = r
.items
.iter()
.any(|i| matches!(i, buffr_core::ContextMenuItem::CopyImage));
let has_media_controls = r.items.iter().any(|i| {
matches!(
i,
buffr_core::ContextMenuItem::MediaPlayPause { .. }
| buffr_core::ContextMenuItem::MediaMute { .. }
)
});
let media_type = if has_image {
MediaType::Image
} else if has_media_controls {
MediaType::Video
} else {
MediaType::None
};
buffr_engine::ContextMenuRequest {
browser_id: r.browser_id,
x: r.x,
y: r.y,
page_url: String::new(),
frame_url: String::new(),
link_url: if r.link_url.is_empty() {
None
} else {
Some(r.link_url)
},
image_url: source.clone(),
media_url: source,
selection_text: if r.selection_text.is_empty() {
None
} else {
Some(r.selection_text)
},
is_editable,
has_image_contents: has_image,
media_type,
}
})
.collect()
}
fn dispatch(&self, action: &buffr_modal::PageAction) {
self.dispatch(action)
}
fn ime_set_composition(&self, text: &str, cursor: Option<(usize, usize)>) {
self.ime_set_composition(text, cursor)
}
fn ime_commit(&self, text: &str) {
self.ime_commit(text)
}
fn ime_cancel(&self) {
self.ime_cancel()
}
fn permissions_queue(&self) -> buffr_engine::PermissionsQueue {
self.neutral_permissions_queue.clone()
}
fn resolve_permission(&self, resolve_id: Option<&str>, outcome: buffr_engine::PromptOutcome) {
let Some(id) = resolve_id else {
tracing::debug!("permissions: resolve_permission called with no resolve_id (no-op)");
return;
};
let pending = match self.cef_callback_registry.lock() {
Ok(mut reg) => reg.remove(id),
Err(_) => {
tracing::warn!(id, "permissions: callback registry mutex poisoned");
return;
}
};
let Some(pending) = pending else {
tracing::debug!(
id,
"permissions: resolve_id not found in registry (already resolved?)"
);
return;
};
if let Err(err) = pending.resolve(outcome, &self.permissions) {
tracing::warn!(error = %err, id, "permissions: resolve failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_id_displays_with_prefix() {
assert_eq!(format!("{}", TabId(0)), "tab#0");
assert_eq!(format!("{}", TabId(42)), "tab#42");
}
#[test]
fn cef_navigation_is_identity() {
assert_eq!(
to_cef_navigation_url("buffr-src:https://example.com").as_ref(),
"buffr-src:https://example.com"
);
assert_eq!(
to_cef_navigation_url("https://example.com").as_ref(),
"https://example.com"
);
assert_eq!(to_cef_navigation_url("").as_ref(), "");
}
#[test]
fn display_url_translates_view_source_prefix() {
assert_eq!(
to_display_url("view-source:https://example.com").as_ref(),
"buffr-src:https://example.com"
);
assert_eq!(
to_display_url("https://example.com").as_ref(),
"https://example.com"
);
assert_eq!(
to_display_url("buffr-src:https://example.com").as_ref(),
"buffr-src:https://example.com"
);
}
#[test]
fn merge_normalizes_view_source_to_buffr_src() {
assert_eq!(
merge_navigation_url("https://old.com", "view-source:https://new.com"),
Some("buffr-src:https://new.com".to_string())
);
}
#[test]
fn merge_buffr_src_address_change_no_op() {
assert_eq!(
merge_navigation_url("buffr-src:https://x.com", "buffr-src:https://x.com"),
None
);
}
#[test]
fn merge_replaces_on_real_navigation_away() {
assert_eq!(
merge_navigation_url("buffr-src:https://x.com", "https://other.com"),
Some("https://other.com".to_string())
);
}
#[test]
fn merge_no_op_when_unchanged() {
assert_eq!(merge_navigation_url("https://x.com", "https://x.com"), None);
}
#[test]
fn tab_session_default_is_empty() {
let s = TabSession::default();
assert!(s.find_query.is_none());
assert!(s.hint_session.is_none());
}
#[test]
fn tab_summary_carries_pinned_and_private_flags() {
let summary = TabSummary {
id: TabId(7),
browser_id: 0,
title: "x".into(),
url: "https://x".into(),
progress: 1.0,
is_loading: false,
pinned: true,
private: true,
};
assert_eq!(summary.id, TabId(7));
assert!(summary.pinned);
assert!(summary.private);
}
#[test]
fn tab_id_ordering() {
assert!(TabId(1) < TabId(2));
assert!(TabId(99) > TabId(7));
}
fn build_media_play_pause_js(x: i32, y: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
if(el.paused)el.play();else el.pause();\
}})({x},{y});"
)
}
fn build_media_mute_js(x: i32, y: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.muted=!el.muted;\
}})({x},{y});"
)
}
fn build_media_loop_js(x: i32, y: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.loop=!el.loop;\
}})({x},{y});"
)
}
fn build_media_controls_js(x: i32, y: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
format!(
"(function(x,y){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLMediaElement))el=el.parentElement;\
if(!el)el=document.querySelector('video, audio');\
if(!el)return;\
el.controls=!el.controls;\
}})({x},{y});"
)
}
fn build_pip_js(x: i32, y: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
format!(
"(function(x,y){{\
try{{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLVideoElement))el=el.parentElement;\
if(!el)el=document.querySelector('video');\
if(!el)return;\
if(document.pictureInPictureElement===el){{\
document.exitPictureInPicture();\
}}else{{\
el.requestPictureInPicture();\
}}\
}}catch(e){{}}\
}})({x},{y});"
)
}
fn build_image_rotate_js(x: i32, y: i32, delta_deg: i32) -> String {
let x = serde_json::to_string(&x).unwrap();
let y = serde_json::to_string(&y).unwrap();
let delta = serde_json::to_string(&delta_deg).unwrap();
format!(
"(function(x,y,delta){{\
var el=document.elementFromPoint(x,y);\
while(el&&!(el instanceof HTMLImageElement))el=el.parentElement;\
if(!el)return;\
var cur=parseInt(el.dataset.buffrRotate||'0',10);\
cur=(cur+delta)%360;\
if(cur<0)cur+=360;\
el.dataset.buffrRotate=String(cur);\
el.style.transform='rotate('+cur+'deg)';\
}})({x},{y},{delta});"
)
}
#[test]
fn media_play_pause_js_contains_coords_and_toggle() {
let js = build_media_play_pause_js(100, 200);
assert!(js.contains("100"), "x coord missing");
assert!(js.contains("200"), "y coord missing");
assert!(js.contains("el.paused"), "paused check missing");
assert!(js.contains("el.play()"), "play() call missing");
assert!(js.contains("el.pause()"), "pause() call missing");
assert!(js.contains("HTMLMediaElement"), "media type guard missing");
assert!(
js.contains("querySelector('video, audio')"),
"overlay-sibling fallback missing"
);
}
#[test]
fn media_mute_js_contains_coords_and_toggle() {
let js = build_media_mute_js(10, 20);
assert!(js.contains("10"), "x coord missing");
assert!(js.contains("20"), "y coord missing");
assert!(js.contains("el.muted=!el.muted"), "mute toggle missing");
assert!(js.contains("HTMLMediaElement"), "media type guard missing");
assert!(
js.contains("querySelector('video, audio')"),
"overlay-sibling fallback missing"
);
}
#[test]
fn media_loop_js_contains_toggle() {
let js = build_media_loop_js(0, 0);
assert!(js.contains("el.loop=!el.loop"), "loop toggle missing");
assert!(js.contains("HTMLMediaElement"), "media type guard missing");
assert!(
js.contains("querySelector('video, audio')"),
"overlay-sibling fallback missing"
);
}
#[test]
fn media_controls_js_contains_toggle() {
let js = build_media_controls_js(0, 0);
assert!(
js.contains("el.controls=!el.controls"),
"controls toggle missing"
);
assert!(js.contains("HTMLMediaElement"), "media type guard missing");
assert!(
js.contains("querySelector('video, audio')"),
"overlay-sibling fallback missing"
);
}
#[test]
fn pip_js_contains_pip_api_and_try_catch() {
let js = build_pip_js(50, 60);
assert!(js.contains("pictureInPictureElement"), "PiP check missing");
assert!(js.contains("requestPictureInPicture"), "requestPiP missing");
assert!(js.contains("exitPictureInPicture"), "exitPiP missing");
assert!(js.contains("try{"), "try/catch missing");
assert!(js.contains("HTMLVideoElement"), "video type guard missing");
assert!(
js.contains("querySelector('video')"),
"overlay-sibling fallback missing"
);
}
#[test]
fn image_rotate_js_clockwise_90() {
let js = build_image_rotate_js(30, 40, 90);
assert!(js.contains("90"), "delta missing");
assert!(js.contains("buffrRotate"), "dataset key missing");
assert!(js.contains("rotate("), "transform missing");
assert!(js.contains("HTMLImageElement"), "image type guard missing");
}
#[test]
fn image_rotate_js_counterclockwise_handles_negative() {
let js = build_image_rotate_js(0, 0, -90);
assert!(js.contains("-90"), "negative delta missing");
assert!(
js.contains("if(cur<0)cur+=360"),
"negative wrap-around missing"
);
}
}