use crate::agents::{
AgentCommandManifestEntry, AgentControl, AgentControlError, AgentId, AgentLogStore,
agent_command_manifest,
};
use crate::core::{TabId, WindowId};
use crate::downloads::{
DownloadCommand, DownloadId, DownloadManager, DownloadRecord, DownloadStatus,
MAX_ACTIVE_DOWNLOADS, format_bytes,
};
use crate::plugins::{
PluginCapability, PluginCheckReport, PluginCheckSeverity, PluginId, PluginManifest,
PluginModule, PluginPermission, PluginStatus, bundled_plugin_modules, check_plugin_registry,
};
use crate::privacy::{
DEFAULT_MAX_BLOCKED_HOSTS, DEFAULT_MAX_SITE_ALLOWLIST, PrivacyReport, SearchProvider,
};
use crate::reader::{
AgentReaderArtifact, AgentReaderTool, agent_reader_artifact, extract_article,
format_agent_reader_output,
};
use crate::recording::{
BrowserRecorder, BrowserRecordingEventKind, BrowserRecordingStore, FinishedRecording,
PendingFrame,
};
use crate::session::{BrowserSession, SessionTab, SessionWindow};
use crate::storage::{
BrowserTheme, CustomTheme, CustomThemeDensity, CustomThemeRadius, MediaPlaybackPolicy, Profile,
ProfileStore, StartupBehavior, WebAccelerationPolicy, sanitize_hex_color,
};
use anyhow::Context;
use chrono::{DateTime, Utc};
use directories::UserDirs;
use gtk::prelude::*;
use serde::Serialize;
use serde_json::json;
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, AtomicUsize, Ordering},
mpsc::{self, SyncSender},
};
use url::Url;
use uuid::Uuid;
use webkit2gtk::{
CacheModel, ContextMenu, ContextMenuAction, ContextMenuExt, ContextMenuItemExt, Download,
DownloadExt, FindControllerExt, FindOptions, HardwareAccelerationPolicy, HitTestResult,
HitTestResultExt, LoadEvent, NavigationPolicyDecision, NavigationPolicyDecisionExt,
PolicyDecisionExt, PolicyDecisionType, Settings, SettingsExt, SnapshotOptions, SnapshotRegion,
URIRequest, URIRequestExt, URIResponseExt, WebContext, WebContextExt, WebView, WebViewExt,
WebsiteDataManager, WebsiteDataManagerExt,
};
type SharedUi = Rc<RefCell<BrowserUi>>;
struct InternalRouteParts<'a> {
scheme: &'a str,
host: Option<&'a str>,
path: &'a str,
}
pub const MAX_OPEN_TABS: usize = 100;
pub const MAX_CLOSED_TABS: usize = 25;
const SAVE_DEBOUNCE_MS: u64 = 700;
pub const SAVE_QUEUE_CAPACITY: usize = 16;
pub const MAX_PENDING_HUMAN_WAITS: usize = 16;
const ADDRESS_FOCUS_STATUS: &str = "Address bar focused";
const READER_MAX_REDIRECTS: usize = 8;
const CEPHAS_START_URI: &str = "cephas://start";
const CEPHAS_START_DOCUMENT_URI: &str = "https://cephas.local/start";
const CEPHAS_AI_TOGGLE_URI: &str = "cephas://start/ai";
const CEPHAS_START_THEME_URI: &str = "cephas://start/theme";
const CEPHAS_SETTINGS_URI: &str = "cephas://settings";
const CEPHAS_SETTINGS_DOCUMENT_URI: &str = "https://cephas.local/settings";
const CEPHAS_SETTINGS_APPLY_URI: &str = "cephas://settings/apply";
const CEPHAS_SETTINGS_STARTUP_URI: &str = "cephas://settings/startup";
const CEPHAS_SETTINGS_THEME_URI: &str = "cephas://settings/theme";
const CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI: &str = "cephas://settings/theme-preset";
const CEPHAS_SETTINGS_CUSTOM_THEME_URI: &str = "cephas://settings/custom-theme";
const CEPHAS_DIAGNOSTICS_URI: &str = "cephas://diagnostics";
const CEPHAS_DIAGNOSTICS_DOCUMENT_URI: &str = "https://cephas.local/diagnostics";
const CEPHAS_PLUGINS_URI: &str = "cephas://plugins";
const CEPHAS_PLUGINS_DOCUMENT_URI: &str = "https://cephas.local/plugins";
const CEPHAS_PLUGINS_INSTALL_URI: &str = "cephas://plugins/install";
const CEPHAS_PLUGINS_INSTALL_ALL_URI: &str = "cephas://plugins/install-all";
const CEPHAS_PLUGINS_SYNC_URI: &str = "cephas://plugins/sync";
const CEPHAS_PLUGINS_SYNC_ALL_URI: &str = "cephas://plugins/sync-all";
const CEPHAS_PLUGINS_ENABLE_URI: &str = "cephas://plugins/enable";
const CEPHAS_PLUGINS_DISABLE_URI: &str = "cephas://plugins/disable";
const CEPHAS_PLUGINS_REMOVE_URI: &str = "cephas://plugins/remove";
const CEPHAS_AGENT_URI: &str = "cephas://agent";
const CEPHAS_AGENT_DOCUMENT_URI: &str = "https://cephas.local/agent";
const CEPHAS_AGENT_DOCS_URI: &str = "cephas://agent/docs";
const CEPHAS_AGENT_DOCS_DOCUMENT_URI: &str = "https://cephas.local/agent/docs";
const CEPHAS_AGENT_DELEGATE_URI: &str = "cephas://agent/delegate";
const CEPHAS_AGENT_RECORDING_START_URI: &str = "cephas://agent/recording/start";
const CEPHAS_AGENT_RECORDING_STOP_URI: &str = "cephas://agent/recording/stop";
const CEPHAS_AGENT_RECORDING_SNAPSHOT_URI: &str = "cephas://agent/recording/snapshot";
const CEPHAS_AGENT_READER_URI: &str = "cephas://agent/reader";
const CEPHAS_AGENT_HUMAN_URI: &str = "cephas://agent/human";
const CEPHAS_AGENT_EMULATE_URI: &str = "cephas://agent/emulate";
const CEPHAS_READER_DOCUMENT_URI: &str = "https://cephas.local/reader";
const CEPHAS_AI_READER_DOCUMENT_URI: &str = "https://cephas.local/ai-reader";
static AGENT_COMMAND_MANIFEST_CACHE: OnceLock<Vec<AgentCommandManifestEntry>> = OnceLock::new();
static AGENT_COMMAND_ROWS_CACHE: OnceLock<String> = OnceLock::new();
static AGENT_COMMAND_MANIFEST_JSON_CACHE: OnceLock<String> = OnceLock::new();
static AGENT_EMULATION_CARDS_CACHE: OnceLock<String> = OnceLock::new();
static AGENT_HUMAN_CONTROL_EXAMPLES_CACHE: OnceLock<String> = OnceLock::new();
static AGENT_READER_TOOLS_ENABLED_CACHE: OnceLock<String> = OnceLock::new();
static AGENT_READER_TOOLS_DISABLED_CACHE: OnceLock<String> = OnceLock::new();
static DEVELOPER_EXTRAS_ENABLED: OnceLock<bool> = OnceLock::new();
#[derive(Clone)]
enum StartTarget {
Landing,
Url(Url),
}
fn chrome_button(label: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::with_label(label);
button.set_tooltip_text(Some(tooltip));
button
}
fn chrome_icon_button(icon_name: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::from_icon_name(Some(icon_name), gtk::IconSize::Button);
button.set_tooltip_text(Some(tooltip));
button
}
fn apply_webkit_gpu_workarounds() {
#[cfg(target_os = "linux")]
webkit2gtk_nvidia_quirk::apply_workaround_with_options(
webkit2gtk_nvidia_quirk::ApplyWorkaroundOptions::default(),
);
}
struct BrowserUi {
window: gtk::Window,
web_context: WebContext,
root: gtk::Box,
tabs_box: gtk::Box,
bookmarkbar: gtk::Box,
stack: gtk::Stack,
entry: gtk::Entry,
find_bar: gtk::Box,
find_entry: gtk::Entry,
status: gtk::Label,
progress: gtk::ProgressBar,
progress_fraction: f64,
progress_generation: u64,
zoom_label: gtk::Label,
downloads_button: gtk::Button,
menu_popover: Option<gtk::Popover>,
custom_css_provider: gtk::CssProvider,
profile_save_pending: bool,
session_save_pending: bool,
downloads_save_pending: bool,
profile_save_generation: Arc<AtomicU64>,
session_save_generation: Arc<AtomicU64>,
downloads_save_generation: Arc<AtomicU64>,
save_lock: Arc<Mutex<()>>,
save_sender: SyncSender<SaveJob>,
save_queue_depth: Arc<AtomicUsize>,
download_active_count: Option<usize>,
pending_downloads: usize,
agents: Rc<RefCell<Option<AgentControl>>>,
recorder: Option<BrowserRecorder>,
recording_capture_pending: bool,
human_control_enabled: bool,
human_control_generation: u64,
pending_human_waits: usize,
agent_emulation: Option<AgentEmulationState>,
agent_target_url: Option<Url>,
agent_target_title: Option<String>,
profile: Rc<RefCell<Profile>>,
store: ProfileStore,
downloads: Rc<RefCell<DownloadManager>>,
active_downloads: Rc<RefCell<BTreeMap<DownloadId, Download>>>,
tabs: Vec<Tab>,
closed_tabs: Vec<ClosedTabSnapshot>,
active_id: Option<usize>,
next_id: usize,
}
#[derive(Clone)]
struct AgentEmulationState {
command_id: String,
label: String,
description: String,
category: &'static str,
coverage: &'static str,
example: String,
result: String,
request_json: String,
}
struct Tab {
id: usize,
stack_name: String,
webview: WebView,
tab_item: gtk::Box,
tab_button: gtk::Button,
pinned: bool,
private: bool,
}
#[derive(Clone)]
struct TabOptions {
pinned: bool,
private: bool,
title: Option<String>,
zoom: f64,
}
impl Default for TabOptions {
fn default() -> Self {
Self {
pinned: false,
private: false,
title: None,
zoom: 1.0,
}
}
}
struct ClosedTabSnapshot {
target: StartTarget,
title: String,
pinned: bool,
private: bool,
zoom: f64,
}
struct RestoredSessionTab {
saved_id: TabId,
target: StartTarget,
options: TabOptions,
}
struct RecordingFrameRequest {
webview: WebView,
pending: PendingFrame,
tab_id: usize,
url: Option<Url>,
title: Option<String>,
}
struct ReaderFetch {
body: String,
truncated: bool,
}
enum SaveJob {
Profile {
path: PathBuf,
value: Box<Profile>,
generation: Arc<AtomicU64>,
expected_generation: u64,
},
Session {
path: PathBuf,
value: Box<BrowserSession>,
generation: Arc<AtomicU64>,
expected_generation: u64,
},
Downloads {
path: PathBuf,
value: Box<DownloadManager>,
generation: Arc<AtomicU64>,
expected_generation: u64,
},
}
impl SaveJob {
fn label(&self) -> &'static str {
match self {
Self::Profile { .. } => "profile",
Self::Session { .. } => "session",
Self::Downloads { .. } => "downloads",
}
}
fn write(self, save_lock: &Mutex<()>) -> anyhow::Result<()> {
match self {
Self::Profile {
path,
value,
generation,
expected_generation,
} => write_latest_json(
&path,
value.as_ref(),
&generation,
expected_generation,
save_lock,
),
Self::Session {
path,
value,
generation,
expected_generation,
} => write_latest_json(
&path,
value.as_ref(),
&generation,
expected_generation,
save_lock,
),
Self::Downloads {
path,
value,
generation,
expected_generation,
} => write_latest_json(
&path,
value.as_ref(),
&generation,
expected_generation,
save_lock,
),
}
}
}
fn start_save_worker(
save_lock: Arc<Mutex<()>>,
save_queue_depth: Arc<AtomicUsize>,
) -> anyhow::Result<SyncSender<SaveJob>> {
let (sender, receiver) = mpsc::sync_channel::<SaveJob>(SAVE_QUEUE_CAPACITY);
std::thread::Builder::new()
.name("cephas-save-worker".to_string())
.spawn(move || {
while let Ok(job) = receiver.recv() {
decrement_atomic_usize(&save_queue_depth);
let label = job.label();
if let Err(err) = job.write(&save_lock) {
eprintln!("failed to save {label}: {err:#}");
}
}
})
.context("failed to start Cephas save worker")?;
Ok(sender)
}
pub fn launch(profile: Profile, store: ProfileStore, target: Option<String>) -> anyhow::Result<()> {
apply_webkit_gpu_workarounds();
gtk::init().context("failed to initialize GTK; is a desktop session available?")?;
install_css()?;
let web_context = configure_web_context(&store)?;
let explicit_target = target
.as_deref()
.map(str::trim)
.filter(|target| !target.is_empty());
let profile = Rc::new(RefCell::new(profile));
let initial = match explicit_target {
Some(target) => Some(initial_target(&profile.borrow(), Some(target))?),
None => None,
};
let downloads = Rc::new(RefCell::new(match store.download_store().load() {
Ok(downloads) => downloads,
Err(err) => {
eprintln!("failed to load downloads: {err:#}");
DownloadManager::default()
}
}));
let active_downloads = Rc::new(RefCell::new(BTreeMap::new()));
let agents = Rc::new(RefCell::new(None));
let custom_css_provider = install_custom_css_provider()?;
let save_lock = Arc::new(Mutex::new(()));
let save_queue_depth = Arc::new(AtomicUsize::new(0));
let save_sender = start_save_worker(Arc::clone(&save_lock), Arc::clone(&save_queue_depth))?;
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title("Cephas");
window.set_default_size(1320, 900);
window.set_decorated(false);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
root.style_context().add_class("bawn-root");
update_custom_css(&custom_css_provider, &profile.borrow())?;
apply_theme(&root, profile.borrow().theme);
let tabbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
tabbar.style_context().add_class("bawn-tabbar");
let tabs_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
tabs_box.set_hexpand(true);
tabs_box.style_context().add_class("bawn-tabs");
let new_tab = chrome_icon_button("list-add-symbolic", "New tab");
new_tab.style_context().add_class("bawn-tab-new");
let window_controls = gtk::Box::new(gtk::Orientation::Horizontal, 2);
window_controls
.style_context()
.add_class("bawn-window-controls");
let minimize = chrome_icon_button("window-minimize-symbolic", "Minimize");
let maximize = chrome_icon_button("window-maximize-symbolic", "Maximize or restore");
let close = chrome_icon_button("window-close-symbolic", "Close");
for button in [&minimize, &maximize, &close] {
button.style_context().add_class("bawn-window-control");
}
close.style_context().add_class("bawn-window-close");
window_controls.pack_start(&minimize, false, false, 0);
window_controls.pack_start(&maximize, false, false, 0);
window_controls.pack_start(&close, false, false, 0);
tabbar.pack_start(&tabs_box, true, true, 0);
tabbar.pack_start(&new_tab, false, false, 0);
tabbar.pack_end(&window_controls, false, false, 0);
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
toolbar.style_context().add_class("bawn-topbar");
let bookmarkbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
bookmarkbar.set_no_show_all(true);
bookmarkbar.style_context().add_class("bawn-bookmarkbar");
let back = chrome_icon_button("go-previous-symbolic", "Back");
let forward = chrome_icon_button("go-next-symbolic", "Forward");
let reload = chrome_icon_button("view-refresh-symbolic", "Reload");
let home = chrome_icon_button("go-home-symbolic", "Start page");
let bookmark = chrome_icon_button("user-bookmarks-symbolic", "Bookmark current page");
let downloads_button = chrome_icon_button("folder-download-symbolic", "Downloads");
let reader = chrome_icon_button("text-x-generic-symbolic", "Reader mode");
let ai_reader = chrome_button("Agent", "Agent control panel");
let clean_link = chrome_icon_button("edit-copy-symbolic", "Copy clean link");
let armour = chrome_icon_button("security-high-symbolic", "Site Armour");
let menu = chrome_icon_button("open-menu-symbolic", "Browser menu");
for button in [
&back,
&forward,
&reload,
&home,
&bookmark,
&reader,
&ai_reader,
&clean_link,
&downloads_button,
&menu,
] {
button.style_context().add_class("bawn-nav");
}
armour.style_context().add_class("bawn-armour");
let entry = gtk::Entry::new();
entry.set_hexpand(true);
entry.set_placeholder_text(Some("Search or enter a URL"));
entry.set_icon_from_icon_name(
gtk::EntryIconPosition::Primary,
Some("preferences-system-symbolic"),
);
entry.set_icon_tooltip_text(gtk::EntryIconPosition::Primary, Some("Cephas address bar"));
entry.style_context().add_class("bawn-url");
if let Some(StartTarget::Url(url)) = &initial {
entry.set_text(url.as_str());
}
let status = gtk::Label::new(None);
status.set_xalign(0.0);
status.set_halign(gtk::Align::Start);
status.set_valign(gtk::Align::End);
status.set_margin_start(12);
status.set_margin_bottom(12);
status.set_opacity(0.0);
status.style_context().add_class("bawn-status");
let zoom_label = gtk::Label::new(Some("100%"));
zoom_label.style_context().add_class("bawn-zoom-label");
let find_bar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
find_bar.style_context().add_class("bawn-findbar");
find_bar.set_no_show_all(true);
let find_entry = gtk::Entry::new();
find_entry.set_hexpand(true);
find_entry.set_placeholder_text(Some("Find in page"));
find_entry.style_context().add_class("bawn-url");
let find_prev = chrome_button("<", "Previous match");
let find_next = chrome_button(">", "Next match");
let find_close = chrome_button("X", "Close find bar");
for button in [&find_prev, &find_next, &find_close] {
button.style_context().add_class("bawn-nav");
}
find_bar.pack_start(&find_entry, true, true, 0);
find_bar.pack_start(&find_prev, false, false, 0);
find_bar.pack_start(&find_next, false, false, 0);
find_bar.pack_start(&find_close, false, false, 0);
let progress = gtk::ProgressBar::new();
progress.style_context().add_class("bawn-progress");
progress.set_no_show_all(true);
let nav_group = gtk::Box::new(gtk::Orientation::Horizontal, 3);
nav_group.style_context().add_class("bawn-control-group");
nav_group.pack_start(&back, false, false, 0);
nav_group.pack_start(&forward, false, false, 0);
nav_group.pack_start(&reload, false, false, 0);
nav_group.pack_start(&home, false, false, 0);
let address_shell = gtk::Box::new(gtk::Orientation::Horizontal, 4);
address_shell.set_hexpand(true);
address_shell
.style_context()
.add_class("bawn-address-shell");
address_shell.pack_start(&entry, true, true, 0);
let page_action_group = gtk::Box::new(gtk::Orientation::Horizontal, 2);
page_action_group
.style_context()
.add_class("bawn-page-actions");
page_action_group.pack_start(&reader, false, false, 0);
page_action_group.pack_start(&ai_reader, false, false, 0);
page_action_group.pack_start(&clean_link, false, false, 0);
page_action_group.pack_start(&armour, false, false, 0);
address_shell.pack_start(&page_action_group, false, false, 0);
let action_group = gtk::Box::new(gtk::Orientation::Horizontal, 3);
action_group.style_context().add_class("bawn-control-group");
action_group.pack_start(&downloads_button, false, false, 0);
action_group.pack_start(&menu, false, false, 0);
toolbar.pack_start(&nav_group, false, false, 0);
toolbar.pack_start(&bookmark, false, false, 0);
toolbar.pack_start(&address_shell, true, true, 0);
toolbar.pack_start(&action_group, false, false, 0);
let stack = gtk::Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
let content_overlay = gtk::Overlay::new();
content_overlay.set_hexpand(true);
content_overlay.set_vexpand(true);
content_overlay.add(&stack);
content_overlay.add_overlay(&status);
content_overlay.set_overlay_pass_through(&status, true);
root.pack_start(&tabbar, false, false, 0);
root.pack_start(&toolbar, false, false, 0);
root.pack_start(&bookmarkbar, false, false, 0);
root.pack_start(&find_bar, false, false, 0);
root.pack_start(&progress, false, false, 0);
root.pack_start(&content_overlay, true, true, 0);
window.add(&root);
let ui = Rc::new(RefCell::new(BrowserUi {
window: window.clone(),
web_context: web_context.clone(),
root: root.clone(),
tabs_box: tabs_box.clone(),
bookmarkbar: bookmarkbar.clone(),
stack: stack.clone(),
entry: entry.clone(),
find_bar: find_bar.clone(),
find_entry: find_entry.clone(),
status: status.clone(),
progress: progress.clone(),
progress_fraction: 0.0,
progress_generation: 0,
zoom_label: zoom_label.clone(),
downloads_button: downloads_button.clone(),
menu_popover: None,
custom_css_provider,
profile_save_pending: false,
session_save_pending: false,
downloads_save_pending: false,
profile_save_generation: Arc::new(AtomicU64::new(0)),
session_save_generation: Arc::new(AtomicU64::new(0)),
downloads_save_generation: Arc::new(AtomicU64::new(0)),
save_lock,
save_sender,
save_queue_depth,
download_active_count: None,
pending_downloads: 0,
agents: Rc::clone(&agents),
recorder: None,
recording_capture_pending: false,
human_control_enabled: false,
human_control_generation: 0,
pending_human_waits: 0,
agent_emulation: None,
agent_target_url: None,
agent_target_title: None,
profile: Rc::clone(&profile),
store: store.clone(),
downloads: Rc::clone(&downloads),
active_downloads: Rc::clone(&active_downloads),
tabs: Vec::new(),
closed_tabs: Vec::new(),
active_id: None,
next_id: 1,
}));
connect_address_focus(&entry, &address_shell, &ui);
connect_navigation(&entry, &ui);
connect_downloads_for_context(&web_context, &ui);
connect_toolbar(&back, &forward, &reload, &home, &armour, &ui);
connect_page_actions(&reader, &ai_reader, &clean_link, &ui);
connect_bookmark(&bookmark, &ui);
connect_downloads_button(&downloads_button, &ui);
update_downloads_button(&ui);
connect_status_bubble(&status);
connect_find_controls(&find_entry, &find_prev, &find_next, &find_close, &ui);
connect_new_tab(&new_tab, &ui);
connect_window_controls(&window, &tabbar, &minimize, &maximize, &close);
connect_browser_menu(&menu, &ui);
populate_bookmarkbar(&bookmarkbar, &ui);
connect_shortcuts(&window, &ui);
let ui_for_close = Rc::clone(&ui);
window.connect_delete_event(move |_, _| {
cancel_active_downloads_for_shutdown(&ui_for_close);
finish_recording_for_shutdown(&ui_for_close);
if let Err(err) = flush_saves_sync(&ui_for_close) {
eprintln!("failed to flush browser state: {err:#}");
}
gtk::main_quit();
gtk::glib::Propagation::Proceed
});
restore_or_create_tabs(&ui, initial);
window.show_all();
bookmarkbar.hide();
progress.hide();
gtk::main();
cancel_active_downloads_for_shutdown(&ui);
finish_recording_for_shutdown(&ui);
flush_saves_sync(&ui)?;
Ok(())
}
pub fn launch_external(profile: &Profile, target: Option<&str>) -> anyhow::Result<()> {
let url = initial_url(profile, target)?;
webbrowser::open(url.as_str()).with_context(|| format!("failed to open {url}"))?;
Ok(())
}
pub fn capture_webpage_screenshot(
profile: &Profile,
target: &str,
output: &Path,
width: i32,
height: i32,
) -> anyhow::Result<()> {
let width = width.clamp(360, 4096);
let height = height.clamp(360, 4096);
apply_webkit_gpu_workarounds();
gtk::init().context("failed to initialize GTK; is a desktop session available?")?;
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| {
format!("failed to create screenshot directory {}", parent.display())
})?;
}
let webview = create_webview(false, None);
configure_webview(&webview, profile.web_acceleration, profile.media_playback);
connect_armour_resource_headers_for_profile(&webview, profile.clone());
webview.set_size_request(width, height);
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title("Cephas screenshot");
window.set_default_size(width, height);
window.add(&webview);
window.show_all();
let finished = Rc::new(Cell::new(false));
let result = Rc::new(RefCell::new(None::<anyhow::Result<()>>));
let output_for_load = output.to_path_buf();
let finished_for_load = Rc::clone(&finished);
let result_for_load = Rc::clone(&result);
webview.connect_load_changed(move |webview, event| {
if matches!(event, LoadEvent::Finished) {
let webview = webview.clone();
let output = output_for_load.clone();
let finished = Rc::clone(&finished_for_load);
let result = Rc::clone(&result_for_load);
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(250), move || {
capture_webview_png(&webview, output, finished, result);
});
}
});
let finished_for_load_failed = Rc::clone(&finished);
let result_for_load_failed = Rc::clone(&result);
webview.connect_load_failed(move |_, _, failing_uri, error| {
finish_screenshot(
Rc::clone(&finished_for_load_failed),
Rc::clone(&result_for_load_failed),
Err(anyhow::anyhow!(
"screenshot load failed for {failing_uri}: {error}"
)),
);
true
});
let finished_for_timeout = Rc::clone(&finished);
let result_for_timeout = Rc::clone(&result);
gtk::glib::timeout_add_seconds_local(12, move || {
if !finished_for_timeout.get() {
finish_screenshot(
Rc::clone(&finished_for_timeout),
Rc::clone(&result_for_timeout),
Err(anyhow::anyhow!(
"screenshot timed out before page finished loading"
)),
);
}
gtk::glib::ControlFlow::Break
});
load_screenshot_target(&webview, profile, target)?;
gtk::main();
window.close();
result
.borrow_mut()
.take()
.unwrap_or_else(|| Err(anyhow::anyhow!("screenshot ended without a result")))
}
fn load_screenshot_target(
webview: &WebView,
profile: &Profile,
target: &str,
) -> anyhow::Result<()> {
let target = target.trim();
if target.is_empty() {
anyhow::bail!("screenshot target cannot be empty");
}
if target.starts_with(CEPHAS_START_URI) || target.starts_with(CEPHAS_START_DOCUMENT_URI) {
load_landing(webview, profile);
return Ok(());
}
if target.starts_with(CEPHAS_SETTINGS_URI) || target.starts_with(CEPHAS_SETTINGS_DOCUMENT_URI) {
load_settings_page_in(webview, profile);
return Ok(());
}
if is_diagnostics_uri(target) {
let html = diagnostics_screenshot_html(profile);
webview.load_html(&html, Some(CEPHAS_DIAGNOSTICS_DOCUMENT_URI));
return Ok(());
}
if is_plugins_uri(target) {
load_plugins_page_in(webview, profile);
return Ok(());
}
if is_agent_docs_uri(target) {
let html = agent_docs_html(profile);
webview.load_html(&html, Some(CEPHAS_AGENT_DOCS_DOCUMENT_URI));
return Ok(());
}
if target.starts_with(CEPHAS_AGENT_URI) || target.starts_with(CEPHAS_AGENT_DOCUMENT_URI) {
let html = agent_screenshot_html(profile, target);
webview.load_html(&html, Some(CEPHAS_AGENT_DOCUMENT_URI));
return Ok(());
}
let (url, _) = prepare_navigation_for_profile(profile, target)?;
webview.load_uri(url.as_str());
Ok(())
}
fn capture_webview_png(
webview: &WebView,
output: PathBuf,
finished: Rc<Cell<bool>>,
result: Rc<RefCell<Option<anyhow::Result<()>>>>,
) {
if finished.get() {
return;
}
webview.snapshot(
SnapshotRegion::Visible,
SnapshotOptions::empty(),
None::<>k::gio::Cancellable>,
move |snapshot| {
let write_result = match snapshot {
Ok(surface) => fs::File::create(&output)
.with_context(|| format!("failed to create screenshot {}", output.display()))
.and_then(|mut file| {
surface.write_to_png(&mut file).map_err(|err| {
anyhow::anyhow!("failed to encode screenshot PNG: {err}")
})
}),
Err(err) => Err(anyhow::anyhow!("WebKit screenshot failed: {err}")),
};
finish_screenshot(finished, result, write_result);
},
);
}
fn finish_screenshot(
finished: Rc<Cell<bool>>,
result: Rc<RefCell<Option<anyhow::Result<()>>>>,
value: anyhow::Result<()>,
) {
if finished.replace(true) {
return;
}
*result.borrow_mut() = Some(value);
gtk::main_quit();
}
fn initial_url(profile: &Profile, target: Option<&str>) -> anyhow::Result<Url> {
let input = target
.map(str::trim)
.filter(|target| !target.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| profile.home.to_string());
let (url, _) = prepare_navigation_for_profile(profile, &input)?;
Ok(url)
}
fn initial_target(profile: &Profile, target: Option<&str>) -> anyhow::Result<StartTarget> {
let Some(target) = target.map(str::trim).filter(|target| !target.is_empty()) else {
return Ok(StartTarget::Landing);
};
let (url, _) = prepare_navigation_for_profile(profile, target)?;
Ok(StartTarget::Url(url))
}
fn route_matches(uri: &str, route: &str) -> bool {
if !could_match_internal_route(uri, route) {
return false;
}
let Ok(uri) = Url::parse(uri) else {
return false;
};
route_matches_url(&uri, route)
}
fn route_matches_url(uri: &Url, route: &str) -> bool {
let Some(route) = internal_route_parts(route) else {
return false;
};
internal_route_origin_matches(uri, &route) && normalized_route_path(uri) == route.path
}
fn route_under(uri: &str, route: &str) -> bool {
if !could_match_internal_route(uri, route) {
return false;
}
let Ok(uri) = Url::parse(uri) else {
return false;
};
route_under_url(&uri, route)
}
fn route_under_url(uri: &Url, route: &str) -> bool {
let Some(route) = internal_route_parts(route) else {
return false;
};
if !internal_route_origin_matches(uri, &route) {
return false;
}
let path = normalized_route_path(uri);
let route_path = route.path;
path == route_path
|| path
.strip_prefix(route_path)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn internal_route_parts(route: &str) -> Option<InternalRouteParts<'_>> {
let (scheme, rest) = route.split_once("://")?;
let (host, path) = rest
.split_once('/')
.map(|(host, _path)| (host, &rest[host.len()..]))
.unwrap_or((rest, ""));
Some(InternalRouteParts {
scheme,
host: (!host.is_empty()).then_some(host),
path: normalized_route_literal_path(scheme, path),
})
}
fn internal_route_origin_matches(uri: &Url, route: &InternalRouteParts<'_>) -> bool {
uri.scheme().eq_ignore_ascii_case(route.scheme)
&& uri.host_str() == route.host
&& uri.port_or_known_default() == route_known_default_port(route.scheme)
}
fn route_known_default_port(scheme: &str) -> Option<u16> {
if scheme.eq_ignore_ascii_case("https") {
Some(443)
} else {
None
}
}
fn could_match_internal_route(uri: &str, route: &str) -> bool {
let Some((route_scheme, _)) = route.split_once(':') else {
return true;
};
let Some((uri_scheme, uri_after_scheme)) = uri.split_once(':') else {
return false;
};
if !uri_scheme.eq_ignore_ascii_case(route_scheme) {
return false;
}
if route_scheme.eq_ignore_ascii_case("cephas") {
return true;
}
if starts_with_ignore_ascii_case(route, "https://cephas.local") {
return starts_with_ignore_ascii_case(uri_after_scheme, "//cephas.local");
}
true
}
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
value.len() >= prefix.len()
&& value.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
}
fn normalized_route_literal_path<'a>(scheme: &str, path: &'a str) -> &'a str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() && scheme.eq_ignore_ascii_case("cephas") {
""
} else if trimmed.is_empty() {
"/"
} else {
trimmed
}
}
fn normalized_route_path(url: &Url) -> &str {
let path = url.path();
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() && url.scheme() == "cephas" {
""
} else if trimmed.is_empty() {
"/"
} else {
trimmed
}
}
fn is_start_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_START_URI) || route_matches(uri, CEPHAS_START_DOCUMENT_URI)
}
fn is_start_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_START_URI) || route_matches_url(uri, CEPHAS_START_DOCUMENT_URI)
}
fn is_settings_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_SETTINGS_URI) || route_matches(uri, CEPHAS_SETTINGS_DOCUMENT_URI)
}
fn is_settings_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_SETTINGS_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_DOCUMENT_URI)
}
fn is_diagnostics_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_DIAGNOSTICS_URI)
|| route_matches(uri, CEPHAS_DIAGNOSTICS_DOCUMENT_URI)
}
fn is_diagnostics_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_DIAGNOSTICS_URI)
|| route_matches_url(uri, CEPHAS_DIAGNOSTICS_DOCUMENT_URI)
}
fn is_plugins_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_PLUGINS_URI) || route_matches(uri, CEPHAS_PLUGINS_DOCUMENT_URI)
}
fn is_plugins_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_PLUGINS_URI)
|| route_matches_url(uri, CEPHAS_PLUGINS_DOCUMENT_URI)
}
fn is_agent_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_AGENT_URI) || route_matches(uri, CEPHAS_AGENT_DOCUMENT_URI)
}
fn is_agent_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_AGENT_URI) || route_matches_url(uri, CEPHAS_AGENT_DOCUMENT_URI)
}
fn is_agent_docs_uri(uri: &str) -> bool {
route_matches(uri, CEPHAS_AGENT_DOCS_URI) || route_matches(uri, CEPHAS_AGENT_DOCS_DOCUMENT_URI)
}
fn is_agent_docs_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_AGENT_DOCS_URI)
|| route_matches_url(uri, CEPHAS_AGENT_DOCS_DOCUMENT_URI)
}
fn is_internal_uri(uri: &str) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
Url::parse(uri).is_ok_and(|url| is_internal_url(&url))
}
fn could_be_internal_uri(uri: &str) -> bool {
let Some((scheme, after_scheme)) = uri.split_once(':') else {
return false;
};
scheme.eq_ignore_ascii_case("cephas")
|| (scheme.eq_ignore_ascii_case("https")
&& starts_with_ignore_ascii_case(after_scheme, "//cephas.local"))
}
fn is_internal_url(url: &Url) -> bool {
url.scheme() == "cephas" || (url.scheme() == "https" && url.host_str() == Some("cephas.local"))
}
fn is_trusted_internal_source_uri(uri: &str) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
let Ok(uri) = Url::parse(uri) else {
return false;
};
is_trusted_internal_source_url(&uri)
}
fn is_trusted_internal_source_url(uri: &Url) -> bool {
[
CEPHAS_START_URI,
CEPHAS_START_DOCUMENT_URI,
CEPHAS_SETTINGS_URI,
CEPHAS_SETTINGS_DOCUMENT_URI,
CEPHAS_DIAGNOSTICS_URI,
CEPHAS_DIAGNOSTICS_DOCUMENT_URI,
CEPHAS_PLUGINS_URI,
CEPHAS_PLUGINS_DOCUMENT_URI,
CEPHAS_AGENT_URI,
CEPHAS_AGENT_DOCUMENT_URI,
CEPHAS_AGENT_DOCS_URI,
CEPHAS_AGENT_DOCS_DOCUMENT_URI,
CEPHAS_READER_DOCUMENT_URI,
CEPHAS_AI_READER_DOCUMENT_URI,
]
.iter()
.any(|route| route_matches_url(uri, route))
}
#[cfg(test)]
fn is_mutating_internal_action_uri(uri: &str) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
let Ok(uri) = Url::parse(uri) else {
return false;
};
is_mutating_internal_action_url(&uri)
}
fn is_mutating_internal_action_url(uri: &Url) -> bool {
[
CEPHAS_AI_TOGGLE_URI,
CEPHAS_START_THEME_URI,
CEPHAS_SETTINGS_APPLY_URI,
CEPHAS_SETTINGS_STARTUP_URI,
CEPHAS_SETTINGS_THEME_URI,
CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI,
CEPHAS_SETTINGS_CUSTOM_THEME_URI,
CEPHAS_PLUGINS_INSTALL_URI,
CEPHAS_PLUGINS_INSTALL_ALL_URI,
CEPHAS_PLUGINS_SYNC_URI,
CEPHAS_PLUGINS_SYNC_ALL_URI,
CEPHAS_PLUGINS_ENABLE_URI,
CEPHAS_PLUGINS_DISABLE_URI,
CEPHAS_PLUGINS_REMOVE_URI,
CEPHAS_AGENT_DELEGATE_URI,
CEPHAS_AGENT_RECORDING_START_URI,
CEPHAS_AGENT_RECORDING_STOP_URI,
CEPHAS_AGENT_RECORDING_SNAPSHOT_URI,
CEPHAS_AGENT_READER_URI,
CEPHAS_AGENT_EMULATE_URI,
]
.iter()
.any(|route| route_matches_url(uri, route))
|| route_under_url(uri, CEPHAS_AGENT_HUMAN_URI)
}
fn create_tab(ui: &SharedUi, target: StartTarget) -> usize {
create_tab_with_options(ui, target, TabOptions::default())
}
fn create_tab_with_options(ui: &SharedUi, target: StartTarget, options: TabOptions) -> usize {
if !can_create_tab(ui) {
return ui.borrow().active_id.unwrap_or(0);
}
let web_context = ui.borrow().web_context.clone();
let webview = create_webview(options.private, Some(&web_context));
let (acceleration, media_playback) = {
let profile = ui.borrow().profile.clone();
let profile = profile.borrow();
(profile.web_acceleration, profile.media_playback)
};
configure_webview(&webview, acceleration, media_playback);
let tab_item = gtk::Box::new(gtk::Orientation::Horizontal, 0);
tab_item.style_context().add_class("bawn-tab");
apply_tab_flags(&tab_item, options.pinned, options.private);
let initial_title = options.title.as_deref().unwrap_or("Start");
let tab_button =
gtk::Button::with_label(&tab_label(initial_title, options.pinned, options.private));
tab_button.style_context().add_class("bawn-tab-title");
tab_button.set_tooltip_text(Some(initial_title));
let close_button = gtk::Button::with_label("x");
close_button.style_context().add_class("bawn-tab-close");
tab_item.pack_start(&tab_button, true, true, 0);
tab_item.pack_start(&close_button, false, false, 0);
let id = {
let mut state = ui.borrow_mut();
let id = state.next_id;
state.next_id += 1;
let stack_name = format!("tab-{id}");
state.tabs_box.pack_start(&tab_item, false, false, 0);
state.stack.add_named(&webview, &stack_name);
state.tabs.push(Tab {
id,
stack_name: stack_name.clone(),
webview: webview.clone(),
tab_item: tab_item.clone(),
tab_button: tab_button.clone(),
pinned: options.pinned,
private: options.private,
});
id
};
let ui_for_select = Rc::clone(ui);
tab_button.connect_clicked(move |_| activate_tab(&ui_for_select, id));
let ui_for_close = Rc::clone(ui);
close_button.connect_clicked(move |_| close_tab(&ui_for_close, id));
connect_policy(&webview, id, ui);
connect_armour_resource_headers(&webview, ui);
connect_fast_context_menu(&webview);
connect_page_events(&webview, id, ui);
tab_item.show_all();
webview.show_all();
webview.set_zoom_level(options.zoom.clamp(0.5, 2.5));
activate_tab(ui, id);
load_start_target(ui, &webview, &target);
if recording_active(ui) {
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"tab created",
Some(json!({
"tab_id": id,
"target": start_target_label(&target),
"private": options.private,
"pinned": options.pinned
})),
);
}
id
}
fn can_create_tab(ui: &SharedUi) -> bool {
if ui.borrow().tabs.len() >= MAX_OPEN_TABS {
let state = ui.borrow();
state
.status
.set_text(&format!("Tab limit reached ({MAX_OPEN_TABS})"));
false
} else {
true
}
}
fn start_target_label(target: &StartTarget) -> String {
match target {
StartTarget::Landing => "start".to_string(),
StartTarget::Url(url) => url.to_string(),
}
}
fn create_webview(private: bool, normal_context: Option<&WebContext>) -> WebView {
if private {
let context = WebContext::new_ephemeral();
WebView::with_context(&context)
} else if let Some(context) = normal_context {
WebView::with_context(context)
} else {
WebView::new()
}
}
fn restore_or_create_tabs(ui: &SharedUi, explicit_target: Option<StartTarget>) {
if let Some(target) = explicit_target {
create_tab(ui, target);
return;
}
let should_restore = {
let state = ui.borrow();
state.profile.borrow().startup_behavior == StartupBehavior::ContinuePreviousSession
};
if should_restore {
let session = ui.borrow().store.session_store().load();
match session {
Ok(session) => {
if restore_session_tabs(ui, session) {
ui.borrow().status.set_text("Previous session restored");
return;
}
}
Err(err) => {
ui.borrow()
.status
.set_text(&format!("Session restore skipped: {err}"));
}
}
}
create_tab(ui, StartTarget::Landing);
}
fn restore_session_tabs(ui: &SharedUi, session: BrowserSession) -> bool {
let (tabs, active_tab) = restored_session_tabs(session);
if tabs.is_empty() {
return false;
}
let mut active_id = None;
for tab in tabs {
let saved_id = tab.saved_id;
let id = create_tab_with_options(ui, tab.target, tab.options);
if Some(saved_id) == active_tab {
active_id = Some(id);
}
}
if let Some(id) = active_id {
activate_tab(ui, id);
}
true
}
fn restored_session_tabs(mut session: BrowserSession) -> (Vec<RestoredSessionTab>, Option<TabId>) {
session.enforce_limits();
let Some(window) = session.windows.into_iter().next() else {
return (Vec::new(), None);
};
let active_tab = window.active_tab;
let tabs = window
.tabs
.into_iter()
.filter(|tab| !tab.private)
.map(|tab| {
let title = if tab.title.trim().is_empty() {
None
} else {
Some(tab.title.clone())
};
RestoredSessionTab {
saved_id: tab.id,
target: tab
.url
.clone()
.map(StartTarget::Url)
.unwrap_or(StartTarget::Landing),
options: TabOptions {
pinned: tab.pinned,
private: false,
title,
zoom: tab.zoom,
},
}
})
.collect();
(tabs, active_tab)
}
fn close_tab(ui: &SharedUi, id: usize) {
let reset_last_tab = ui.borrow().tabs.len() <= 1;
if reset_last_tab {
if recording_active(ui) {
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"last tab reset to start page",
Some(json!({ "tab_id": id })),
);
}
if let Some(webview) = active_webview(ui) {
let profile = ui.borrow().profile.clone();
load_landing(&webview, &profile.borrow());
reset_tab_flags(ui, id);
set_tab_title(ui, id, "Start");
ui.borrow().status.set_text("Start page");
}
schedule_session_save(ui);
return;
}
let next = {
let mut state = ui.borrow_mut();
let Some(index) = state.tabs.iter().position(|tab| tab.id == id) else {
return;
};
let was_active = state.active_id == Some(id);
let next = if was_active {
if index + 1 < state.tabs.len() {
Some(state.tabs[index + 1].id)
} else if index > 0 {
Some(state.tabs[index - 1].id)
} else {
None
}
} else {
state.active_id
};
if let Some(snapshot) = closed_tab_snapshot(&state.tabs[index]) {
state.closed_tabs.push(snapshot);
if state.closed_tabs.len() > MAX_CLOSED_TABS {
state.closed_tabs.remove(0);
}
}
let tab = state.tabs.remove(index);
tab.webview.stop_loading();
state.stack.remove(&tab.webview);
state.tabs_box.remove(&tab.tab_item);
if was_active {
state.active_id = None;
}
next
};
if recording_active(ui) {
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"tab closed",
Some(json!({ "tab_id": id })),
);
}
if let Some(next) = next {
activate_tab(ui, next);
} else {
create_tab(ui, StartTarget::Landing);
}
schedule_session_save(ui);
}
fn activate_tab(ui: &SharedUi, id: usize) {
let active_webview = {
let mut state = ui.borrow_mut();
let Some(index) = state.tabs.iter().position(|tab| tab.id == id) else {
return;
};
state.active_id = Some(id);
state
.stack
.set_visible_child_name(&state.tabs[index].stack_name);
for tab in &state.tabs {
let context = tab.tab_item.style_context();
if tab.id == id {
context.add_class("bawn-tab-selected");
} else {
context.remove_class("bawn-tab-selected");
}
}
state.tabs[index].webview.clone()
};
sync_location_from_webview(ui, &active_webview);
sync_window_title(ui, &active_webview);
sync_zoom_label(ui, &active_webview);
hide_load_progress_now(ui);
if recording_active(ui) {
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"tab activated",
Some(json!({ "tab_id": id })),
);
}
}
fn active_webview(ui: &SharedUi) -> Option<WebView> {
let state = ui.borrow();
let id = state.active_id?;
state
.tabs
.iter()
.find(|tab| tab.id == id)
.map(|tab| tab.webview.clone())
}
fn is_active(ui: &SharedUi, id: usize) -> bool {
ui.borrow().active_id == Some(id)
}
fn set_tab_title(ui: &SharedUi, id: usize, title: &str) {
if let Some(tab) = ui.borrow().tabs.iter().find(|tab| tab.id == id) {
let label = tab_label(title, tab.pinned, tab.private);
if tab.tab_button.label().as_deref() != Some(label.as_str()) {
tab.tab_button.set_label(&label);
}
if tab.tab_button.tooltip_text().as_deref() != Some(title) {
tab.tab_button.set_tooltip_text(Some(title));
}
}
}
fn reset_tab_flags(ui: &SharedUi, id: usize) {
if let Some(tab) = ui.borrow_mut().tabs.iter_mut().find(|tab| tab.id == id) {
tab.pinned = false;
tab.private = false;
apply_tab_flags(&tab.tab_item, false, false);
}
}
fn apply_tab_flags(tab_item: >k::Box, pinned: bool, private: bool) {
let context = tab_item.style_context();
if pinned {
context.add_class("bawn-tab-pinned");
} else {
context.remove_class("bawn-tab-pinned");
}
if private {
context.add_class("bawn-tab-private");
} else {
context.remove_class("bawn-tab-private");
}
}
fn tab_label(title: &str, pinned: bool, private: bool) -> String {
let title = compact_title(title);
match (pinned, private) {
(true, true) => format!("Private pinned {title}"),
(true, false) => format!("Pinned {title}"),
(false, true) => format!("Private {title}"),
(false, false) => title,
}
}
fn active_tab_snapshot(ui: &SharedUi) -> Option<ClosedTabSnapshot> {
let state = ui.borrow();
let id = state.active_id?;
let tab = state.tabs.iter().find(|tab| tab.id == id)?;
tab_snapshot(tab)
}
fn closed_tab_snapshot(tab: &Tab) -> Option<ClosedTabSnapshot> {
if tab.private { None } else { tab_snapshot(tab) }
}
fn tab_snapshot(tab: &Tab) -> Option<ClosedTabSnapshot> {
Some(ClosedTabSnapshot {
target: start_target_from_webview(&tab.webview)?,
title: current_tab_title(tab),
pinned: tab.pinned,
private: tab.private,
zoom: tab.webview.zoom_level(),
})
}
fn start_target_from_webview(webview: &WebView) -> Option<StartTarget> {
match webview.uri().map(|uri| uri.to_string()) {
Some(uri) if is_internal_uri(&uri) => Some(StartTarget::Landing),
Some(uri) => Url::parse(&uri).ok().map(StartTarget::Url),
None => Some(StartTarget::Landing),
}
}
fn current_tab_title(tab: &Tab) -> String {
tab.webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.or_else(|| {
tab.webview.uri().and_then(|uri| {
if is_internal_uri(&uri) {
None
} else {
Some(uri.to_string())
}
})
})
.unwrap_or_else(|| "Start".to_string())
}
fn tab_is_private(ui: &SharedUi, id: usize) -> bool {
ui.borrow()
.tabs
.iter()
.find(|tab| tab.id == id)
.map(|tab| tab.private)
.unwrap_or(false)
}
fn sync_location_from_webview(ui: &SharedUi, webview: &WebView) {
let entry = ui.borrow().entry.clone();
if let Some(uri) = webview.uri() {
if is_start_uri(&uri) {
set_entry_text_if_changed(&entry, "");
} else {
set_entry_text_if_changed(&entry, &uri);
}
} else {
set_entry_text_if_changed(&entry, "");
}
}
fn sync_window_title(ui: &SharedUi, webview: &WebView) {
let window = ui.borrow().window.clone();
let title = webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| "Cephas".to_string());
set_window_title_if_changed(&window, &format!("{title} - Cephas"));
}
fn sync_zoom_label(ui: &SharedUi, webview: &WebView) {
let zoom_label = ui.borrow().zoom_label.clone();
set_label_text_if_changed(
&zoom_label,
&format!("{:.0}%", webview.zoom_level() * 100.0),
);
}
fn set_entry_text_if_changed(entry: >k::Entry, text: &str) {
if entry.text().as_str() != text {
entry.set_text(text);
}
}
fn set_label_text_if_changed(label: >k::Label, text: &str) {
if label.label().as_str() != text {
label.set_text(text);
}
}
fn set_window_title_if_changed(window: >k::Window, title: &str) {
if window.title().as_deref() != Some(title) {
window.set_title(title);
}
}
fn compact_title(title: &str) -> String {
let title = title.trim();
let title = if title.is_empty() { "Start" } else { title };
let max = 26;
let mut out = String::new();
for (index, ch) in title.chars().enumerate() {
if index >= max {
out.push_str("...");
return out;
}
out.push(ch);
}
out
}
fn install_css() -> anyhow::Result<()> {
let provider = gtk::CssProvider::new();
provider.load_from_data(GTK_CSS.as_bytes())?;
if let Some(screen) = gtk::gdk::Screen::default() {
gtk::StyleContext::add_provider_for_screen(
&screen,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
Ok(())
}
fn install_custom_css_provider() -> anyhow::Result<gtk::CssProvider> {
let provider = gtk::CssProvider::new();
provider.load_from_data(b"")?;
if let Some(screen) = gtk::gdk::Screen::default() {
gtk::StyleContext::add_provider_for_screen(
&screen,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
Ok(provider)
}
fn update_custom_css(provider: >k::CssProvider, profile: &Profile) -> anyhow::Result<()> {
provider.load_from_data(custom_theme_gtk_css(&profile.custom_theme).as_bytes())?;
Ok(())
}
fn custom_theme_gtk_css(theme: &CustomTheme) -> String {
let theme = theme.sanitized();
let radius = custom_radius_css(theme.radius);
let control_radius = custom_control_radius_css(theme.radius);
let (bar_padding, control_height, menu_padding) = custom_density_gtk_values(theme.density);
format!(
r#"
.theme-custom.bawn-root {{ background: {page}; }}
.theme-custom .bawn-tabbar,
.theme-custom .bawn-topbar,
.theme-custom .bawn-bookmarkbar,
.theme-custom .bawn-findbar {{ background: {chrome}; border-color: {surface}; padding-top: {bar_padding}; padding-bottom: {bar_padding}; }}
.theme-custom .bawn-brand,
.theme-custom .bawn-tab,
.theme-custom .bawn-tab-selected,
.theme-custom .bawn-url,
.theme-custom .bawn-address-shell,
.theme-custom button.bawn-nav,
.theme-custom .bawn-bookmark-chip,
.theme-custom button.bawn-window-control,
.theme-custom .bawn-theme-picker button,
.theme-custom .bawn-control-group,
.theme-custom .bawn-menu-section,
.theme-custom .bawn-menu-popover,
.theme-custom .bawn-menu-content,
.theme-custom .bawn-panel-content,
.theme-custom .bawn-popover-scroll,
.theme-custom .bawn-popover-scroll viewport,
.theme-custom button.bawn-menu-action,
.theme-custom .bawn-download-popover,
.theme-custom .bawn-plugin-card,
.theme-custom .bawn-plugin-metric,
.theme-custom .bawn-plugin-status,
.theme-custom .bawn-addon-card,
.theme-custom .bawn-history-row,
.theme-custom.bawn-menu-popover,
.theme-custom.bawn-menu-content,
.theme-custom.bawn-panel-content,
.theme-custom.bawn-popover-scroll,
.theme-custom.bawn-download-popover {{ color: {text}; background: {surface}; border-color: {accent}; border-radius: {radius}; }}
.theme-custom .bawn-brand,
.theme-custom .bawn-tab,
.theme-custom .bawn-tab-selected,
.theme-custom .bawn-url,
.theme-custom .bawn-address-shell,
.theme-custom button.bawn-nav,
.theme-custom .bawn-bookmark-chip,
.theme-custom button.bawn-window-control,
.theme-custom .bawn-theme-picker button,
.theme-custom .bawn-control-group,
.theme-custom .bawn-menu-section,
.theme-custom button.bawn-menu-action,
.theme-custom .bawn-plugin-card,
.theme-custom .bawn-plugin-metric,
.theme-custom .bawn-plugin-status,
.theme-custom .bawn-addon-card,
.theme-custom .bawn-history-row {{ border-radius: {control_radius}; }}
.theme-custom .bawn-url,
.theme-custom .bawn-address-shell,
.theme-custom button.bawn-nav,
.theme-custom button.bawn-window-control,
.theme-custom .bawn-bookmark-chip,
.theme-custom .bawn-theme-picker button {{ min-height: {control_height}; }}
.theme-custom .bawn-menu-section,
.theme-custom button.bawn-menu-action {{ padding-top: {menu_padding}; padding-bottom: {menu_padding}; }}
.theme-custom .bawn-browser-menu-label,
.theme-custom .bawn-download-popover-title,
.theme-custom .bawn-download-file-name,
.theme-custom .bawn-dialog-title,
.theme-custom .bawn-plugin-title,
.theme-custom .bawn-plugin-metric-value,
.theme-custom .bawn-bookmark-label {{ color: {text}; }}
.theme-custom .bawn-browser-menu-icon,
.theme-custom .bawn-browser-menu-shortcut,
.theme-custom .bawn-download-file-meta,
.theme-custom .bawn-download-empty,
.theme-custom .bawn-dialog-copy,
.theme-custom .bawn-plugin-kind,
.theme-custom .bawn-plugin-meta,
.theme-custom .bawn-status,
.theme-custom .bawn-zoom-label,
.theme-custom .bawn-tab {{ color: {muted}; }}
.theme-custom button.bawn-download-footer {{ color: {text}; }}
.theme-custom button.bawn-browser-menu-action:hover,
.theme-custom button.bawn-browser-menu-mini:hover,
.theme-custom button.bawn-download-popover-row:hover,
.theme-custom button.bawn-download-footer:hover,
.theme-custom button.bawn-nav:hover,
.theme-custom .bawn-bookmark-chip:hover {{ color: {text}; background: {chrome}; border-color: {accent}; }}
.theme-custom button.bawn-armour,
.theme-custom button.bawn-primary-action,
.theme-custom .bawn-progress progress,
.theme-custom .bawn-bookmark-icon,
.theme-custom .bawn-plugin-mark,
.theme-custom .bawn-plugin-status.enabled {{ color: {page}; background: {accent}; border-color: {accent}; }}
.theme-custom .bawn-address-shell button.bawn-armour {{ color: {accent}; background: transparent; border-color: transparent; }}
.theme-custom .bawn-page-actions {{ border-left-color: {accent}; }}
"#,
chrome = theme.chrome,
page = theme.page,
surface = theme.surface,
text = theme.text,
muted = theme.muted,
accent = theme.accent,
radius = radius,
control_radius = control_radius,
bar_padding = bar_padding,
control_height = control_height,
menu_padding = menu_padding,
)
}
fn custom_radius_css(radius: CustomThemeRadius) -> &'static str {
match radius {
CustomThemeRadius::Square => "0px",
CustomThemeRadius::Soft => "10px",
CustomThemeRadius::Rounded => "18px",
CustomThemeRadius::Pill => "28px",
}
}
fn custom_control_radius_css(radius: CustomThemeRadius) -> &'static str {
match radius {
CustomThemeRadius::Square => "0px",
CustomThemeRadius::Soft => "8px",
CustomThemeRadius::Rounded => "14px",
CustomThemeRadius::Pill => "999px",
}
}
fn custom_density_gtk_values(
density: CustomThemeDensity,
) -> (&'static str, &'static str, &'static str) {
match density {
CustomThemeDensity::Compact => ("2px", "30px", "5px"),
CustomThemeDensity::Comfortable => ("4px", "36px", "8px"),
CustomThemeDensity::Spacious => ("7px", "42px", "11px"),
}
}
#[derive(Debug, Clone, Copy)]
struct CustomThemePreset {
id: &'static str,
label: &'static str,
description: &'static str,
chrome: &'static str,
page: &'static str,
surface: &'static str,
text: &'static str,
muted: &'static str,
accent: &'static str,
radius: CustomThemeRadius,
density: CustomThemeDensity,
}
const CUSTOM_THEME_PRESETS: [CustomThemePreset; 6] = [
CustomThemePreset {
id: "midnight-focus",
label: "Midnight Focus",
description: "Dark blue surfaces with bright command accents.",
chrome: "#0b1020",
page: "#080d18",
surface: "#111827",
text: "#eef4ff",
muted: "#9fb0c8",
accent: "#60a5fa",
radius: CustomThemeRadius::Rounded,
density: CustomThemeDensity::Comfortable,
},
CustomThemePreset {
id: "paper-ink",
label: "Paper Ink",
description: "Warm light page with crisp ink contrast.",
chrome: "#ede7dc",
page: "#faf7ef",
surface: "#ffffff",
text: "#1e293b",
muted: "#64748b",
accent: "#2556d9",
radius: CustomThemeRadius::Soft,
density: CustomThemeDensity::Comfortable,
},
CustomThemePreset {
id: "violet-glass",
label: "Violet Glass",
description: "Translucent-feeling violet palette with pill controls.",
chrome: "#211436",
page: "#130b20",
surface: "#2c1d46",
text: "#fbf7ff",
muted: "#cdb7ef",
accent: "#c084fc",
radius: CustomThemeRadius::Pill,
density: CustomThemeDensity::Spacious,
},
CustomThemePreset {
id: "forest-lab",
label: "Forest Lab",
description: "Low-glare green workspace for long sessions.",
chrome: "#102015",
page: "#09140d",
surface: "#173323",
text: "#ecfdf3",
muted: "#9bc5a7",
accent: "#22c55e",
radius: CustomThemeRadius::Rounded,
density: CustomThemeDensity::Comfortable,
},
CustomThemePreset {
id: "agent-grid",
label: "Agent Grid",
description: "High-contrast AI-readable segmentation colors.",
chrome: "#0a1220",
page: "#08111f",
surface: "#0d1728",
text: "#f8fafc",
muted: "#cbd5e1",
accent: "#facc15",
radius: CustomThemeRadius::Square,
density: CustomThemeDensity::Compact,
},
CustomThemePreset {
id: "amber-terminal",
label: "Amber Terminal",
description: "Terminal-inspired dark amber controls.",
chrome: "#1c1208",
page: "#0f0a05",
surface: "#2a1b0d",
text: "#fff7ed",
muted: "#f0c58c",
accent: "#f59e0b",
radius: CustomThemeRadius::Soft,
density: CustomThemeDensity::Compact,
},
];
fn custom_theme_preset_by_id(id: &str) -> Option<CustomThemePreset> {
CUSTOM_THEME_PRESETS
.into_iter()
.find(|preset| preset.id == id)
}
fn custom_theme_from_preset(preset: CustomThemePreset) -> CustomTheme {
CustomTheme {
name: preset.label.to_string(),
chrome: preset.chrome.to_string(),
page: preset.page.to_string(),
surface: preset.surface.to_string(),
text: preset.text.to_string(),
muted: preset.muted.to_string(),
accent: preset.accent.to_string(),
radius: preset.radius,
density: preset.density,
}
.sanitized()
}
fn custom_theme_matches_preset(theme: &CustomTheme, preset: CustomThemePreset) -> bool {
theme.sanitized() == custom_theme_from_preset(preset)
}
fn apply_theme(root: >k::Box, theme: BrowserTheme) {
apply_theme_context(&root.style_context(), theme);
}
fn apply_theme_context(context: >k::StyleContext, active_theme: BrowserTheme) {
for theme in BrowserTheme::ALL {
context.remove_class(theme.css_class());
}
context.add_class(active_theme.css_class());
}
fn next_browser_theme(current: BrowserTheme) -> BrowserTheme {
let themes = BrowserTheme::ALL;
let index = themes
.iter()
.position(|theme| *theme == current)
.unwrap_or(0);
themes[(index + 1) % themes.len()]
}
fn load_start_target(ui: &SharedUi, webview: &WebView, target: &StartTarget) {
match target {
StartTarget::Landing => {
let profile = ui.borrow().profile.clone();
load_landing(webview, &profile.borrow());
}
StartTarget::Url(url) => {
if should_block_unknown_internal_target(url.as_str()) {
let profile = ui.borrow().profile.clone();
load_landing(webview, &profile.borrow());
ui.borrow()
.status
.set_text("Unknown Cephas internal route blocked");
} else if !load_internal_page_target(ui, webview, url.as_str()) {
webview.load_uri(url.as_str());
}
}
}
}
fn should_block_unknown_internal_target(uri: &str) -> bool {
is_internal_uri(uri) && !is_direct_internal_target(uri)
}
fn is_direct_internal_target(uri: &str) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
let Ok(uri) = Url::parse(uri) else {
return false;
};
is_direct_internal_target_url(&uri)
}
fn is_direct_internal_target_url(uri: &Url) -> bool {
route_matches_url(uri, CEPHAS_AGENT_EMULATE_URI)
|| route_under_url(uri, CEPHAS_AGENT_HUMAN_URI)
|| is_agent_docs_url(uri)
|| route_matches_url(uri, CEPHAS_START_THEME_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_APPLY_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_STARTUP_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_THEME_URI)
|| route_matches_url(uri, CEPHAS_SETTINGS_CUSTOM_THEME_URI)
|| is_start_url(uri)
|| is_settings_url(uri)
|| is_diagnostics_url(uri)
|| is_plugins_url(uri)
|| is_agent_url(uri)
}
fn load_internal_page_target(ui: &SharedUi, webview: &WebView, uri: &str) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
let Ok(parsed_uri) = Url::parse(uri) else {
return false;
};
if !is_direct_internal_target_url(&parsed_uri) {
return false;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_EMULATE_URI) {
handle_agent_emulation_uri(ui, webview, uri);
return true;
}
if route_under_url(&parsed_uri, CEPHAS_AGENT_HUMAN_URI) {
handle_agent_human_control_uri(ui, webview, uri);
if webview.uri().as_deref().is_none_or(is_internal_uri) {
load_agent_page_in(ui, webview);
}
return true;
}
if is_agent_docs_url(&parsed_uri) {
load_agent_docs_page_in(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_START_THEME_URI) {
cycle_start_page_theme(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_APPLY_URI) {
save_settings_from_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_STARTUP_URI) {
save_startup_from_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI) {
apply_custom_theme_preset_from_uri(ui, uri);
load_active_settings_page(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_THEME_URI) {
apply_theme_from_uri(ui, uri);
load_active_settings_page(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_CUSTOM_THEME_URI) {
save_custom_theme_from_uri(ui, uri);
load_active_settings_page(ui, webview);
return true;
}
if is_start_url(&parsed_uri) {
let profile = ui.borrow().profile.clone();
load_landing(webview, &profile.borrow());
ui.borrow().status.set_text("Start page");
return true;
}
if is_settings_url(&parsed_uri) {
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
load_settings_page_in(webview, &profile.borrow());
status.set_text("Settings opened");
return true;
}
if is_diagnostics_url(&parsed_uri) {
load_diagnostics_page_in(ui, webview);
ui.borrow().status.set_text("Diagnostics opened");
return true;
}
if is_plugins_url(&parsed_uri) {
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
load_plugins_page_in(webview, &profile.borrow());
status.set_text("Plugins opened");
return true;
}
if is_agent_url(&parsed_uri) {
remember_agent_target_from_webview(ui, webview);
load_agent_page_in(ui, webview);
return true;
}
false
}
fn load_active_settings_page(ui: &SharedUi, webview: &WebView) {
let profile = ui.borrow().profile.clone();
load_settings_page_in(webview, &profile.borrow());
}
fn load_landing(webview: &WebView, profile: &Profile) {
let html = landing_html(profile);
webview.load_html(&html, Some(CEPHAS_START_DOCUMENT_URI));
}
fn load_settings_page(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
load_settings_page_in(&webview, &profile.borrow());
status.set_text("Settings opened");
}
fn load_settings_page_in(webview: &WebView, profile: &Profile) {
let html = settings_html(profile);
webview.load_html(&html, Some(CEPHAS_SETTINGS_DOCUMENT_URI));
}
fn load_diagnostics_page_in(ui: &SharedUi, webview: &WebView) {
let html = diagnostics_html(ui);
webview.load_html(&html, Some(CEPHAS_DIAGNOSTICS_DOCUMENT_URI));
}
fn load_plugins_page(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
load_plugins_page_in(&webview, &profile.borrow());
status.set_text("Plugins opened");
}
fn load_plugins_page_in(webview: &WebView, profile: &Profile) {
let html = plugins_html(profile);
webview.load_html(&html, Some(CEPHAS_PLUGINS_DOCUMENT_URI));
}
fn load_agent_page(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
remember_agent_target_from_webview(ui, &webview);
let html = {
let state = ui.borrow();
agent_html(&state)
};
webview.load_html(&html, Some(CEPHAS_AGENT_DOCUMENT_URI));
mark_internal_page_ready(
ui,
&webview,
CEPHAS_AGENT_DOCUMENT_URI,
"Agent control panel ready",
);
}
fn load_agent_page_in(ui: &SharedUi, webview: &WebView) {
let html = {
let state = ui.borrow();
agent_html(&state)
};
webview.load_html(&html, Some(CEPHAS_AGENT_DOCUMENT_URI));
mark_internal_page_ready(
ui,
webview,
CEPHAS_AGENT_DOCUMENT_URI,
"Agent control panel ready",
);
}
fn load_agent_docs_page_in(ui: &SharedUi, webview: &WebView) {
let html = {
let state = ui.borrow();
let profile = state.profile.borrow();
agent_docs_html(&profile)
};
webview.load_html(&html, Some(CEPHAS_AGENT_DOCS_DOCUMENT_URI));
mark_internal_page_ready(
ui,
webview,
CEPHAS_AGENT_DOCS_DOCUMENT_URI,
"Agent integration docs ready",
);
}
fn mark_internal_page_ready(
ui: &SharedUi,
webview: &WebView,
uri: &'static str,
status: &'static str,
) {
let ui = Rc::downgrade(ui);
let webview = webview.downgrade();
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(80), move || {
let (Some(ui), Some(webview)) = (ui.upgrade(), webview.upgrade()) else {
return;
};
let state = ui.borrow();
let is_active_webview = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.map(|tab| tab.webview.as_ptr() == webview.as_ptr())
.unwrap_or(false);
if !is_active_webview {
return;
}
drop(state);
complete_load_progress(&ui);
let state = ui.borrow();
set_entry_text_if_changed(&state.entry, uri);
set_label_text_if_changed(&state.status, status);
set_window_title_if_changed(&state.window, "Agent control - Cephas");
});
}
fn remember_agent_target_from_webview(ui: &SharedUi, webview: &WebView) {
let Some(uri) = webview.uri().map(|uri| uri.to_string()) else {
clear_agent_target(ui);
return;
};
if is_agent_uri(&uri) || uri.starts_with(CEPHAS_AI_READER_DOCUMENT_URI) {
return;
}
if is_internal_uri(&uri) {
clear_agent_target(ui);
return;
}
let Ok(url) = Url::parse(&uri) else {
clear_agent_target(ui);
return;
};
let title = webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| url.to_string());
let mut state = ui.borrow_mut();
state.agent_target_url = Some(url);
state.agent_target_title = Some(title);
}
fn clear_agent_target(ui: &SharedUi) {
let mut state = ui.borrow_mut();
state.agent_target_url = None;
state.agent_target_title = None;
}
fn refresh_active_agent_page_if_open(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
if webview.uri().as_deref().is_some_and(is_agent_uri) {
load_agent_page_in(ui, &webview);
}
}
fn configure_web_context(store: &ProfileStore) -> anyhow::Result<WebContext> {
let profile_dir = store.path().parent().unwrap_or_else(|| Path::new("."));
let data_dir = profile_dir.join("webkit-data");
let cache_dir = profile_dir.join("webkit-cache");
let favicon_dir = cache_dir.join("favicons");
fs::create_dir_all(&data_dir).with_context(|| {
format!(
"failed to create WebKit data directory {}",
data_dir.display()
)
})?;
fs::create_dir_all(&cache_dir).with_context(|| {
format!(
"failed to create WebKit cache directory {}",
cache_dir.display()
)
})?;
fs::create_dir_all(&favicon_dir).with_context(|| {
format!(
"failed to create WebKit favicon directory {}",
favicon_dir.display()
)
})?;
let data_dir = data_dir.to_string_lossy().into_owned();
let cache_dir = cache_dir.to_string_lossy().into_owned();
let favicon_dir = favicon_dir.to_string_lossy().into_owned();
let manager = WebsiteDataManager::builder()
.base_data_directory(data_dir)
.base_cache_directory(cache_dir)
.build();
manager.set_itp_enabled(true);
let context = WebContext::with_website_data_manager(&manager);
context.set_cache_model(CacheModel::WebBrowser);
context.set_favicon_database_directory(Some(&favicon_dir));
Ok(context)
}
fn configure_webview(
webview: &WebView,
acceleration: WebAccelerationPolicy,
media_playback: MediaPlaybackPolicy,
) {
if let Some(settings) = WebViewExt::settings(webview) {
settings.set_auto_load_images(true);
settings.set_enable_back_forward_navigation_gestures(true);
settings.set_enable_developer_extras(developer_extras_enabled());
settings.set_enable_html5_local_storage(true);
settings.set_enable_javascript(true);
settings.set_enable_smooth_scrolling(true);
settings.set_enable_page_cache(true);
settings.set_enable_site_specific_quirks(true);
apply_graphics_policy_to_settings(&settings, acceleration);
settings.set_javascript_can_open_windows_automatically(false);
apply_media_policy_to_settings(&settings, media_playback);
}
}
fn developer_extras_enabled() -> bool {
*DEVELOPER_EXTRAS_ENABLED.get_or_init(|| {
std::env::var("CEPHAS_DEVELOPER_EXTRAS").is_ok_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
})
}
fn apply_media_policy_to_settings(settings: &Settings, media_playback: MediaPlaybackPolicy) {
settings.set_enable_media(true);
settings.set_enable_mediasource(true);
settings.set_enable_media_capabilities(true);
settings.set_enable_encrypted_media(true);
settings.set_enable_fullscreen(true);
settings.set_enable_webaudio(true);
settings.set_enable_webrtc(media_playback.allows_capture());
settings.set_media_playback_allows_inline(true);
settings.set_media_playback_requires_user_gesture(media_playback.requires_user_gesture());
settings.set_enable_media_stream(media_playback.allows_capture());
}
fn apply_graphics_policy_to_settings(settings: &Settings, acceleration: WebAccelerationPolicy) {
let accelerated = acceleration != WebAccelerationPolicy::Never;
settings.set_hardware_acceleration_policy(to_webkit_acceleration_policy(acceleration));
settings.set_enable_webgl(accelerated);
set_accelerated_2d_canvas(settings, accelerated);
set_optional_bool_property(settings, "enable-webgpu", accelerated);
}
#[allow(deprecated)]
fn set_accelerated_2d_canvas(settings: &Settings, enabled: bool) {
settings.set_enable_accelerated_2d_canvas(enabled);
}
fn set_optional_bool_property(
settings: &Settings,
property_name: &'static str,
enabled: bool,
) -> bool {
if settings.has_property(property_name, None) {
settings.set_property(property_name, enabled);
true
} else {
false
}
}
fn to_webkit_acceleration_policy(policy: WebAccelerationPolicy) -> HardwareAccelerationPolicy {
match policy {
WebAccelerationPolicy::OnDemand | WebAccelerationPolicy::Always => {
HardwareAccelerationPolicy::Always
}
WebAccelerationPolicy::Never => HardwareAccelerationPolicy::Never,
}
}
fn apply_acceleration_to_all_tabs(ui: &SharedUi, policy: WebAccelerationPolicy) {
let webviews = ui
.borrow()
.tabs
.iter()
.map(|tab| tab.webview.clone())
.collect::<Vec<_>>();
for webview in webviews {
if let Some(settings) = WebViewExt::settings(&webview) {
apply_graphics_policy_to_settings(&settings, policy);
}
}
}
fn apply_media_policy_to_all_tabs(ui: &SharedUi, policy: MediaPlaybackPolicy) {
let webviews = ui
.borrow()
.tabs
.iter()
.map(|tab| tab.webview.clone())
.collect::<Vec<_>>();
for webview in webviews {
if let Some(settings) = WebViewExt::settings(&webview) {
apply_media_policy_to_settings(&settings, policy);
}
}
}
fn connect_address_focus(entry: >k::Entry, address_shell: >k::Box, ui: &SharedUi) {
let ui = Rc::clone(ui);
let shell_for_focus = address_shell.clone();
entry.connect_focus_in_event(move |entry, _| {
shell_for_focus
.style_context()
.add_class("bawn-address-focused");
let entry = entry.downgrade();
let ui = Rc::downgrade(&ui);
gtk::glib::idle_add_local_once(move || {
let (Some(entry), Some(ui)) = (entry.upgrade(), ui.upgrade()) else {
return;
};
if entry.has_focus() {
entry.select_region(0, -1);
ui.borrow().status.set_text(ADDRESS_FOCUS_STATUS);
}
});
gtk::glib::Propagation::Proceed
});
let shell_for_blur = address_shell.clone();
entry.connect_focus_out_event(move |_, _| {
shell_for_blur
.style_context()
.remove_class("bawn-address-focused");
gtk::glib::Propagation::Proceed
});
}
fn connect_navigation(entry: >k::Entry, ui: &SharedUi) {
let ui = Rc::clone(ui);
entry.connect_activate(move |entry| {
let input = entry.text().to_string();
let trimmed = input.trim();
if route_under(trimmed, CEPHAS_AGENT_HUMAN_URI) {
if let Some(webview) = active_webview(&ui) {
handle_agent_human_control_uri(&ui, &webview, trimmed);
}
return;
}
if route_matches(trimmed, CEPHAS_AGENT_EMULATE_URI) {
if let Some(webview) = active_webview(&ui) {
handle_agent_emulation_uri(&ui, &webview, trimmed);
}
return;
}
if is_agent_docs_uri(trimmed) {
if let Some(webview) = active_webview(&ui) {
load_agent_docs_page_in(&ui, &webview);
}
return;
}
if is_agent_uri(trimmed) {
load_agent_page(&ui);
return;
}
if is_plugins_uri(trimmed) {
load_plugins_page(&ui);
return;
}
if is_settings_uri(trimmed) {
load_settings_page(&ui);
return;
}
if route_matches(trimmed, CEPHAS_START_THEME_URI) {
if let Some(webview) = active_webview(&ui) {
cycle_start_page_theme(&ui, &webview);
}
return;
}
if is_start_uri(trimmed) {
if let Some(webview) = active_webview(&ui) {
let profile = ui.borrow().profile.clone();
load_landing(&webview, &profile.borrow());
ui.borrow().status.set_text("Start page");
}
return;
}
if is_internal_uri(trimmed) {
ui.borrow()
.status
.set_text("Unknown Cephas internal route blocked");
return;
}
if let Some(webview) = active_webview(&ui) {
let profile = ui.borrow().profile.clone();
let status = ui.borrow().status.clone();
navigate(&webview, &profile.borrow(), &status, &input);
}
});
}
fn connect_downloads_for_context(context: &WebContext, ui: &SharedUi) {
let ui = Rc::downgrade(ui);
context.connect_download_started(move |_, download| {
if let Some(ui) = ui.upgrade() {
register_download(&ui, download);
} else {
download.cancel();
}
});
}
fn register_download(ui: &SharedUi, download: &Download) {
let slot_reserved = {
let mut state = ui.borrow_mut();
let used = state.active_downloads.borrow().len() + state.pending_downloads;
if used >= MAX_ACTIVE_DOWNLOADS {
false
} else {
state.pending_downloads += 1;
true
}
};
if !slot_reserved {
download.cancel();
ui.borrow().status.set_text(&format!(
"Download limit reached ({MAX_ACTIVE_DOWNLOADS} active)"
));
return;
}
let id_slot = Rc::new(RefCell::new(None::<DownloadId>));
let reservation_released = Rc::new(Cell::new(false));
let ui_for_destination = Rc::downgrade(ui);
let id_for_destination = Rc::clone(&id_slot);
let reservation_for_destination = Rc::clone(&reservation_released);
download.connect_decide_destination(move |download, suggested_filename| {
let Some(ui_for_destination) = ui_for_destination.upgrade() else {
download.cancel();
return false;
};
let file_path = unique_download_path(suggested_filename);
if let Some(parent) = file_path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
release_pending_download_slot(&ui_for_destination, &reservation_for_destination);
ui_for_destination
.borrow()
.status
.set_text(&format!("Download path failed: {err}"));
return false;
}
let Some(destination) = file_uri(&file_path) else {
release_pending_download_slot(&ui_for_destination, &reservation_for_destination);
ui_for_destination
.borrow()
.status
.set_text("Download path is not a valid file URI");
return false;
};
download.set_destination(&destination);
let url = download_url(download).unwrap_or_else(about_blank_url);
let (mime_type, total_bytes) = download_metadata(download);
let start_result = {
let state = ui_for_destination.borrow();
state
.downloads
.borrow_mut()
.start(url, file_path.clone(), mime_type, total_bytes)
};
let id = match start_result {
Ok(id) => {
ui_for_destination
.borrow()
.active_downloads
.borrow_mut()
.insert(id, download.clone());
id
}
Err(err) => {
release_pending_download_slot(&ui_for_destination, &reservation_for_destination);
ui_for_destination
.borrow()
.status
.set_text(&format!("Download rejected: {err}"));
download.cancel();
return false;
}
};
release_pending_download_slot(&ui_for_destination, &reservation_for_destination);
*id_for_destination.borrow_mut() = Some(id);
update_downloads_button(&ui_for_destination);
schedule_downloads_save(&ui_for_destination);
ui_for_destination
.borrow()
.status
.set_text(&format!("Downloading {}", file_path.display()));
true
});
let ui_for_response = Rc::downgrade(ui);
let id_for_response = Rc::clone(&id_slot);
download.connect_response_notify(move |download| {
let Some(ui_for_response) = ui_for_response.upgrade() else {
return;
};
let Some(id) = *id_for_response.borrow() else {
return;
};
let (mime_type, total_bytes) = download_metadata(download);
ui_for_response
.borrow()
.downloads
.borrow_mut()
.update_metadata(id, mime_type, total_bytes);
update_downloads_button(&ui_for_response);
schedule_downloads_save(&ui_for_response);
});
let ui_for_progress = Rc::downgrade(ui);
let id_for_progress = Rc::clone(&id_slot);
download.connect_received_data(move |download, _| {
let Some(ui_for_progress) = ui_for_progress.upgrade() else {
return;
};
let Some(id) = *id_for_progress.borrow() else {
return;
};
update_download_progress(&ui_for_progress, id, download);
});
let ui_for_estimated = Rc::downgrade(ui);
let id_for_estimated = Rc::clone(&id_slot);
download.connect_estimated_progress_notify(move |download| {
let Some(ui_for_estimated) = ui_for_estimated.upgrade() else {
return;
};
let Some(id) = *id_for_estimated.borrow() else {
return;
};
update_download_progress(&ui_for_estimated, id, download);
});
let ui_for_finished = Rc::downgrade(ui);
let id_for_finished = Rc::clone(&id_slot);
let reservation_for_finished = Rc::clone(&reservation_released);
download.connect_finished(move |download| {
let Some(ui_for_finished) = ui_for_finished.upgrade() else {
return;
};
let Some(id) = *id_for_finished.borrow() else {
release_pending_download_slot(&ui_for_finished, &reservation_for_finished);
return;
};
{
let state = ui_for_finished.borrow();
let mut downloads = state.downloads.borrow_mut();
let received = download.received_data_length();
if received > 0 {
downloads.update_progress(id, received);
}
downloads.finish(id);
state.active_downloads.borrow_mut().remove(&id);
}
update_downloads_button(&ui_for_finished);
schedule_downloads_save(&ui_for_finished);
ui_for_finished
.borrow()
.status
.set_text("Download complete");
});
let ui_for_failed = Rc::downgrade(ui);
let id_for_failed = Rc::clone(&id_slot);
let reservation_for_failed = Rc::clone(&reservation_released);
download.connect_failed(move |_, error| {
let Some(ui_for_failed) = ui_for_failed.upgrade() else {
return;
};
let Some(id) = *id_for_failed.borrow() else {
release_pending_download_slot(&ui_for_failed, &reservation_for_failed);
return;
};
{
let state = ui_for_failed.borrow();
state.downloads.borrow_mut().fail(id, error.message());
state.active_downloads.borrow_mut().remove(&id);
}
update_downloads_button(&ui_for_failed);
schedule_downloads_save(&ui_for_failed);
ui_for_failed
.borrow()
.status
.set_text(&format!("Download failed: {}", error.message()));
});
}
fn release_pending_download_slot(ui: &SharedUi, released: &Cell<bool>) {
if released.replace(true) {
return;
}
let mut state = ui.borrow_mut();
state.pending_downloads = state.pending_downloads.saturating_sub(1);
}
fn connect_downloads_button(downloads_button: >k::Button, ui: &SharedUi) {
let ui = Rc::clone(ui);
downloads_button.connect_clicked(move |button| show_downloads_popover(&ui, button));
}
fn update_download_progress(ui: &SharedUi, id: DownloadId, download: &Download) {
let received = download.received_data_length();
let estimated = download.estimated_progress().clamp(0.0, 1.0);
{
let state = ui.borrow();
let mut downloads = state.downloads.borrow_mut();
if received > 0 {
downloads.update_progress(id, received);
} else if let Some(total) = downloads.get(id).and_then(|record| record.total_bytes) {
downloads.update_progress(id, (total as f64 * estimated) as u64);
}
}
}
fn show_downloads_popover(ui: &SharedUi, anchor: >k::Button) {
let records = {
let state = ui.borrow();
let mut records = state
.downloads
.borrow()
.records()
.cloned()
.collect::<Vec<_>>();
records.reverse();
records.into_iter().take(8).collect::<Vec<_>>()
};
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.style_context().add_class("bawn-download-popover");
content.set_margin_top(16);
content.set_margin_bottom(0);
content.set_margin_start(0);
content.set_margin_end(0);
let header = gtk::Box::new(gtk::Orientation::Horizontal, 12);
header
.style_context()
.add_class("bawn-download-popover-header");
let title = gtk::Label::new(Some("Recent download history"));
title.set_xalign(0.0);
title.set_hexpand(true);
title
.style_context()
.add_class("bawn-download-popover-title");
let close = gtk::Button::from_icon_name(Some("window-close-symbolic"), gtk::IconSize::Button);
close
.style_context()
.add_class("bawn-download-popover-close");
let ui_for_close = Rc::clone(ui);
close.connect_clicked(move |_| close_active_popover(&ui_for_close));
header.pack_start(&title, true, true, 0);
header.pack_start(&close, false, false, 0);
content.pack_start(&header, false, false, 0);
if records.is_empty() {
let empty = gtk::Label::new(Some("No downloads yet."));
empty.set_xalign(0.0);
empty.style_context().add_class("bawn-download-empty");
content.pack_start(&empty, false, false, 0);
} else {
for record in records {
content.pack_start(&download_popover_row(ui, record), false, false, 0);
}
}
let footer = gtk::Button::new();
footer.style_context().add_class("bawn-download-footer");
let footer_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let footer_label = gtk::Label::new(Some("Full download history"));
footer_label.set_xalign(0.0);
footer_label.set_hexpand(true);
let footer_icon = gtk::Image::from_icon_name(Some("go-next-symbolic"), gtk::IconSize::Button);
footer_icon
.style_context()
.add_class("bawn-download-footer-icon");
footer_row.pack_start(&footer_label, true, true, 0);
footer_row.pack_start(&footer_icon, false, false, 0);
footer.add(&footer_row);
let ui_for_footer = Rc::clone(ui);
footer.connect_clicked(move |_| {
close_active_popover(&ui_for_footer);
show_downloads_dialog(&ui_for_footer);
});
content.pack_start(&footer, false, false, 0);
show_chrome_popover(ui, anchor, &content, 420, 640);
}
fn download_popover_row(ui: &SharedUi, record: DownloadRecord) -> gtk::Button {
let button = gtk::Button::new();
button
.style_context()
.add_class("bawn-download-popover-row");
button.set_tooltip_text(Some(record.file_path.display().to_string().as_str()));
let row = gtk::Box::new(gtk::Orientation::Horizontal, 14);
row.set_margin_start(18);
row.set_margin_end(18);
row.set_margin_top(8);
row.set_margin_bottom(8);
let icon = gtk::Image::from_icon_name(Some(download_icon_name(&record)), gtk::IconSize::Dialog);
icon.style_context().add_class("bawn-download-file-icon");
let text = gtk::Box::new(gtk::Orientation::Vertical, 2);
text.set_hexpand(true);
let name = gtk::Label::new(Some(&record.file_name()));
name.set_xalign(0.0);
name.set_line_wrap(true);
name.style_context().add_class("bawn-download-file-name");
let meta = gtk::Label::new(Some(&download_popover_meta(&record)));
meta.set_xalign(0.0);
meta.style_context().add_class("bawn-download-file-meta");
text.pack_start(&name, false, false, 0);
text.pack_start(&meta, false, false, 0);
row.pack_start(&icon, false, false, 0);
row.pack_start(&text, true, true, 0);
button.add(&row);
let ui_for_row = Rc::clone(ui);
let id = record.id;
let command = if download_command_allowed(&record, DownloadCommand::Open) {
Some(DownloadCommand::Open)
} else if download_command_allowed(&record, DownloadCommand::ShowInFolder) {
Some(DownloadCommand::ShowInFolder)
} else if download_command_allowed(&record, DownloadCommand::Cancel) {
Some(DownloadCommand::Cancel)
} else {
None
};
button.set_sensitive(command.is_some());
button.connect_clicked(move |_| {
if let Some(command) = command {
handle_download_command(&ui_for_row, id, command);
close_active_popover(&ui_for_row);
}
});
button
}
fn download_icon_name(record: &DownloadRecord) -> &'static str {
match record.status {
DownloadStatus::InProgress => "folder-download-symbolic",
DownloadStatus::Failed(_) => "dialog-warning-symbolic",
DownloadStatus::Cancelled => "process-stop-symbolic",
_ => match record
.file_path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png" | "jpg" | "jpeg" | "gif" | "webp") => "image-x-generic-symbolic",
Some("zip" | "gz" | "tar" | "xz") => "package-x-generic-symbolic",
Some("pdf") => "application-pdf-symbolic",
Some("md" | "txt" | "rs" | "py" | "js" | "ts") => "text-x-generic-symbolic",
_ => "text-x-generic-symbolic",
},
}
}
fn download_popover_meta(record: &DownloadRecord) -> String {
let when = record.finished_at.unwrap_or(record.started_at);
format!("{} - {}", download_size(record), relative_time(when))
}
fn download_size(record: &DownloadRecord) -> String {
let bytes = if record.downloaded_bytes > 0 {
record.downloaded_bytes
} else {
record.total_bytes.unwrap_or(0)
};
format_bytes_for_ui(bytes)
}
fn format_bytes_for_ui(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let bytes = bytes as f64;
if bytes >= GB {
format!("{:.1} GB", bytes / GB)
} else if bytes >= MB {
format!("{:.1} MB", bytes / MB)
} else if bytes >= KB {
format!("{:.1} KB", bytes / KB)
} else {
format!("{} B", bytes as u64)
}
}
fn relative_time(time: DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(time);
if elapsed.num_days() >= 1 {
format!("{} days ago", elapsed.num_days())
} else if elapsed.num_hours() >= 1 {
format!("{} hours ago", elapsed.num_hours())
} else if elapsed.num_minutes() >= 1 {
format!("{} minutes ago", elapsed.num_minutes())
} else {
"just now".to_string()
}
}
fn show_downloads_dialog(ui: &SharedUi) {
let (window, records) = {
let state = ui.borrow();
let mut records = state
.downloads
.borrow()
.records()
.cloned()
.collect::<Vec<_>>();
records.reverse();
(state.window.clone(), records)
};
let dialog = gtk::Dialog::with_buttons(
Some("Cephas Downloads"),
Some(&window),
gtk::DialogFlags::MODAL,
&[("Close", gtk::ResponseType::Close)],
);
dialog.set_default_size(760, 520);
dialog.style_context().add_class("bawn-dialog");
let content = dialog.content_area();
content.set_spacing(10);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title = gtk::Label::new(Some("Download log"));
title.set_xalign(0.0);
title.style_context().add_class("bawn-dialog-title");
content.pack_start(&title, false, false, 0);
if records.is_empty() {
let empty = gtk::Label::new(Some("No downloads yet."));
empty.set_xalign(0.0);
empty.style_context().add_class("bawn-dialog-copy");
content.pack_start(&empty, false, false, 0);
}
for record in records {
content.pack_start(&download_row(ui, &dialog, record), false, false, 0);
}
dialog.connect_response(|dialog, _| dialog.close());
dialog.show_all();
}
fn diagnostics_rows(ui: &SharedUi) -> Vec<(String, String)> {
let mut diagnostics = {
let state = ui.borrow();
let profile = state.profile.borrow();
let downloads = state.downloads.borrow();
let active_downloads = state.active_downloads.borrow();
let agents = state.agents.borrow();
let (agent_delegations, max_agent_delegations, agent_events, max_agent_events) = agents
.as_ref()
.map(|agents| {
(
agents.delegation_count(),
agents.max_delegations(),
agents.events().len(),
agents.max_events(),
)
})
.unwrap_or((
0,
AgentControl::default().max_delegations(),
0,
AgentControl::default().max_events(),
));
let recording_status = state
.recorder
.as_ref()
.map(recording_diagnostics_value)
.unwrap_or_else(recording_idle_diagnostics_value);
let save_debounce_state = save_debounce_diagnostics_value(
state.profile_save_pending,
state.session_save_pending,
state.downloads_save_pending,
);
let save_queue_depth = state.save_queue_depth.load(Ordering::Acquire);
vec![
(
"Open tabs".to_string(),
format!("{} / {MAX_OPEN_TABS}", state.tabs.len()),
),
(
"Closed-tab snapshots".to_string(),
format!("{} / {MAX_CLOSED_TABS}", state.closed_tabs.len()),
),
(
"History entries".to_string(),
format!("{} / {}", profile.history.len(), profile.max_history.max(1)),
),
(
"Bookmarks".to_string(),
format!(
"{} / {}",
profile.bookmarks.len(),
profile.max_bookmarks.max(1)
),
),
(
"Plugins".to_string(),
format!(
"{} / {}, {} planned",
profile.plugins.installed_count(),
profile.plugins.max_plugins,
profile.plugins.planned_count()
),
),
(
"Media policy".to_string(),
format!(
"{}; capture {}",
profile.media_playback.label(),
if profile.media_playback.allows_capture() {
"enabled"
} else {
"disabled"
}
),
),
(
"Download records".to_string(),
format!(
"{} / {}",
downloads.records().count(),
downloads.max_records()
),
),
(
"Active downloads".to_string(),
active_downloads_diagnostics_value(active_downloads.len(), state.pending_downloads),
),
(
"Agent delegations".to_string(),
format!("{agent_delegations} / {max_agent_delegations}"),
),
(
"Agent event buffer".to_string(),
format!("{agent_events} / {max_agent_events} in memory"),
),
("Visual recording".to_string(), recording_status),
("Save debounce state".to_string(), save_debounce_state),
(
"Save queue jobs".to_string(),
format!("{save_queue_depth} / {SAVE_QUEUE_CAPACITY} queued"),
),
(
"Human-control waits".to_string(),
human_waits_diagnostics_value(
state.pending_human_waits,
state.human_control_enabled,
),
),
(
"Armour allowlist".to_string(),
format!(
"{} / {DEFAULT_MAX_SITE_ALLOWLIST}",
profile.shields.site_allowlist.len()
),
),
(
"Armour blocked hosts".to_string(),
format!(
"{} / {DEFAULT_MAX_BLOCKED_HOSTS}",
profile.shields.blocked_hosts.len()
),
),
]
};
if let Ok(store) = AgentLogStore::new(None)
&& let Ok(log) = store.diagnostics()
{
diagnostics.push((
"Agent audit log".to_string(),
format!(
"{} / {}, {} / {} archives, {} archive bytes, largest {}",
format_bytes(log.bytes),
format_bytes(log.max_bytes),
log.archives,
log.max_archives,
format_bytes(log.total_archive_bytes),
format_bytes(log.largest_archive_bytes)
),
));
}
if let Ok(store) = BrowserRecordingStore::new(None)
&& let Ok(recordings) = store.diagnostics()
{
diagnostics.push((
"Recording directories".to_string(),
format!(
"{} / {}, {} on disk",
recordings.directories,
recordings.max_directories,
format_bytes(recordings.total_bytes)
),
));
}
if let Some(bytes) = current_rss_bytes() {
diagnostics.push(("Memory RSS".to_string(), format_bytes(bytes)));
}
diagnostics
}
fn active_downloads_diagnostics_value(active: usize, pending: usize) -> String {
if pending == 0 {
format!("{active} / {MAX_ACTIVE_DOWNLOADS}")
} else {
format!("{active} active + {pending} pending / {MAX_ACTIVE_DOWNLOADS}")
}
}
fn save_debounce_diagnostics_value(profile: bool, session: bool, downloads: bool) -> String {
let pending = [
(profile, "profile"),
(session, "session"),
(downloads, "downloads"),
]
.into_iter()
.filter_map(|(pending, label)| pending.then_some(label))
.collect::<Vec<_>>();
if pending.is_empty() {
"idle".to_string()
} else {
format!("{} debounce pending", pending.join(", "))
}
}
fn human_waits_diagnostics_value(pending: usize, enabled: bool) -> String {
let state = if enabled { "enabled" } else { "disabled" };
format!("{pending} / {MAX_PENDING_HUMAN_WAITS} pending; control {state}")
}
fn recording_diagnostics_value(recorder: &BrowserRecorder) -> String {
format!(
"active; {} / {} frames, {} / {} events",
recorder.frame_count(),
recorder.max_frames(),
recorder.event_count(),
recorder.max_events()
)
}
fn recording_idle_diagnostics_value() -> String {
format!(
"idle; 0 / {} frames, 0 / {} events",
crate::recording::DEFAULT_MAX_RECORDING_FRAMES,
crate::recording::DEFAULT_MAX_RECORDING_EVENTS
)
}
fn show_diagnostics_dialog(ui: &SharedUi) {
let diagnostics = diagnostics_rows(ui);
let window = ui.borrow().window.clone();
let dialog = gtk::Dialog::with_buttons(
Some("Cephas Diagnostics"),
Some(&window),
gtk::DialogFlags::MODAL,
&[("Close", gtk::ResponseType::Close)],
);
dialog.set_default_size(520, 360);
dialog.style_context().add_class("bawn-dialog");
let content = dialog.content_area();
content.set_spacing(10);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title = gtk::Label::new(Some("Resource diagnostics"));
title.set_xalign(0.0);
title.style_context().add_class("bawn-dialog-title");
content.pack_start(&title, false, false, 0);
for (name, value) in diagnostics {
let row = gtk::Label::new(Some(&format!("{name}: {value}")));
row.set_xalign(0.0);
row.style_context().add_class("bawn-dialog-copy");
content.pack_start(&row, false, false, 0);
}
dialog.connect_response(|dialog, _| dialog.close());
dialog.show_all();
}
fn current_rss_bytes() -> Option<u64> {
#[cfg(target_os = "linux")]
{
let status = fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
let Some(value) = line.strip_prefix("VmRSS:") else {
continue;
};
let kib = value.split_whitespace().next()?.parse::<u64>().ok()?;
return Some(kib.saturating_mul(1024));
}
None
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
fn download_row(ui: &SharedUi, dialog: >k::Dialog, record: DownloadRecord) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Vertical, 6);
row.style_context().add_class("bawn-addon-card");
let name = gtk::Label::new(Some(&record.file_name()));
name.set_xalign(0.0);
name.style_context().add_class("bawn-addon-title");
row.pack_start(&name, false, false, 0);
let status = gtk::Label::new(Some(&format!(
"{} - {}",
record.status_label(),
record.file_path.display()
)));
status.set_xalign(0.0);
status.set_line_wrap(true);
status.style_context().add_class("bawn-dialog-copy");
row.pack_start(&status, false, false, 0);
if let Some(progress) = record.progress()
&& record.status == DownloadStatus::InProgress
{
let progress_bar = gtk::ProgressBar::new();
progress_bar.set_fraction(progress);
progress_bar.style_context().add_class("bawn-progress");
row.pack_start(&progress_bar, false, false, 0);
}
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
for (label, tooltip, command) in [
("OPEN", "Open downloaded file", DownloadCommand::Open),
("FOLDER", "Show in folder", DownloadCommand::ShowInFolder),
("STOP", "Cancel download", DownloadCommand::Cancel),
("COPY", "Copy download link", DownloadCommand::CopyLink),
("DROP", "Remove from list", DownloadCommand::RemoveFromList),
("DEL", "Delete local file", DownloadCommand::DeleteLocalFile),
] {
let button = chrome_button(label, tooltip);
button.style_context().add_class("bawn-nav");
button.set_sensitive(download_command_allowed(&record, command));
let ui_for_action = Rc::clone(ui);
let dialog_for_action = dialog.downgrade();
let id = record.id;
button.connect_clicked(move |_| {
handle_download_command(&ui_for_action, id, command);
if !matches!(command, DownloadCommand::CopyLink) {
if let Some(dialog) = dialog_for_action.upgrade() {
dialog.close();
}
}
});
actions.pack_start(&button, false, false, 0);
}
row.pack_start(&actions, false, false, 0);
row
}
fn handle_download_command(ui: &SharedUi, id: DownloadId, command: DownloadCommand) {
let record = {
let state = ui.borrow();
state.downloads.borrow().get(id).cloned()
};
let Some(record) = record else {
ui.borrow().status.set_text("Download no longer exists");
return;
};
if !download_command_allowed(&record, command) {
ui.borrow()
.status
.set_text("Download command is not available");
return;
}
match command {
DownloadCommand::Open => {
if download_open_requires_confirmation(&record) {
confirm_risky_download_open(ui, &record);
return;
}
if let Err(err) = open_path(&record.file_path) {
ui.borrow().status.set_text(&format!("Open failed: {err}"));
}
}
DownloadCommand::ShowInFolder => {
let folder = record.file_path.parent().unwrap_or_else(|| Path::new("."));
if let Err(err) = open_path(folder) {
ui.borrow()
.status
.set_text(&format!("Show in folder failed: {err}"));
}
}
DownloadCommand::Cancel => {
let active = ui.borrow().active_downloads.borrow_mut().remove(&id);
if let Some(download) = active {
download.cancel();
}
ui.borrow().downloads.borrow_mut().cancel(id);
ui.borrow().status.set_text("Download cancelled");
}
DownloadCommand::RemoveFromList => {
ui.borrow().downloads.borrow_mut().remove(id);
ui.borrow().status.set_text("Download removed from list");
}
DownloadCommand::DeleteLocalFile => match fs::remove_file(&record.file_path) {
Ok(()) => {
ui.borrow().downloads.borrow_mut().remove(id);
ui.borrow().status.set_text("Downloaded file deleted");
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
ui.borrow().downloads.borrow_mut().remove(id);
ui.borrow()
.status
.set_text("Download removed; file was missing");
}
Err(err) => {
ui.borrow()
.status
.set_text(&format!("Delete failed: {err}"));
}
},
DownloadCommand::CopyLink => {
copy_text(record.url.as_str());
ui.borrow().status.set_text("Download link copied");
}
}
update_downloads_button(ui);
schedule_downloads_save(ui);
}
fn download_command_allowed(record: &DownloadRecord, command: DownloadCommand) -> bool {
download_command_allowed_in_directory(record, command, &download_directory())
}
fn download_command_allowed_in_directory(
record: &DownloadRecord,
command: DownloadCommand,
directory: &Path,
) -> bool {
if !record.command_enabled(command) {
return false;
}
match command {
DownloadCommand::Open => path_inside_directory(&record.file_path, directory),
DownloadCommand::ShowInFolder | DownloadCommand::DeleteLocalFile => {
path_inside_directory(&record.file_path, directory)
}
DownloadCommand::Cancel | DownloadCommand::RemoveFromList | DownloadCommand::CopyLink => {
true
}
}
}
fn download_open_requires_confirmation(record: &DownloadRecord) -> bool {
is_risky_download_open_path(&record.file_path)
}
fn confirm_risky_download_open(ui: &SharedUi, record: &DownloadRecord) {
let (window, status) = {
let state = ui.borrow();
(state.window.clone(), state.status.clone())
};
let file_path = record.file_path.clone();
let file_name = record.file_name();
let dialog = gtk::Dialog::with_buttons(
Some("Open executable download?"),
Some(&window),
gtk::DialogFlags::MODAL,
&[
("Cancel", gtk::ResponseType::Cancel),
("Open anyway", gtk::ResponseType::Accept),
],
);
dialog.set_default_size(520, 220);
dialog.style_context().add_class("bawn-dialog");
let content = dialog.content_area();
content.set_spacing(10);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title = gtk::Label::new(Some("This download may run code"));
title.set_xalign(0.0);
title.style_context().add_class("bawn-dialog-title");
content.pack_start(&title, false, false, 0);
let copy = gtk::Label::new(Some(&format!(
"Only open {file_name} if you trust its source. Cephas verified the path is inside your downloads directory, but executable files can still change your system."
)));
copy.set_xalign(0.0);
copy.set_line_wrap(true);
copy.style_context().add_class("bawn-dialog-copy");
content.pack_start(©, false, false, 0);
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
if !path_inside_directory(&file_path, &download_directory()) {
status.set_text("Download path is no longer inside downloads directory");
} else {
match open_path(&file_path) {
Ok(()) => status.set_text("Executable download opened after confirmation"),
Err(err) => status.set_text(&format!("Open failed: {err}")),
}
}
} else {
status.set_text("Executable download open cancelled");
}
dialog.close();
});
dialog.show_all();
}
fn path_inside_directory(path: &Path, directory: &Path) -> bool {
let Ok(root) = directory.canonicalize() else {
return false;
};
let candidate = if path.exists() {
path.canonicalize()
} else {
let Some(parent) = path.parent() else {
return false;
};
let Some(file_name) = path.file_name() else {
return false;
};
parent.canonicalize().map(|parent| parent.join(file_name))
};
candidate.is_ok_and(|candidate| candidate.starts_with(root))
}
fn is_risky_download_open_path(path: &Path) -> bool {
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
if extension.as_deref().is_some_and(|extension| {
matches!(
extension,
"app" | "bat" | "cmd" | "com" | "desktop" | "exe" | "msi" | "ps1" | "scr" | "sh"
)
}) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(path)
&& metadata.permissions().mode() & 0o111 != 0
{
return true;
}
}
false
}
fn update_downloads_button(ui: &SharedUi) {
let (button, active_count, changed) = {
let mut state = ui.borrow_mut();
let active_count = {
let downloads = state.downloads.borrow();
downloads
.records()
.filter(|record| record.status == DownloadStatus::InProgress)
.count()
};
let changed = state.download_active_count != Some(active_count);
if changed {
state.download_active_count = Some(active_count);
}
(state.downloads_button.clone(), active_count, changed)
};
if !changed {
return;
}
if active_count > 0 {
button.set_tooltip_text(Some(&format!("Downloads ({active_count} active)")));
button.style_context().add_class("bawn-download-active");
} else {
button.set_tooltip_text(Some("Downloads"));
button.style_context().remove_class("bawn-download-active");
}
}
fn persist_downloads(ui: &SharedUi) -> anyhow::Result<()> {
let (download_store, downloads) = {
let state = ui.borrow();
(state.store.download_store(), state.downloads.clone())
};
download_store.save(&downloads.borrow())
}
fn persist_profile(ui: &SharedUi) -> anyhow::Result<()> {
let (store, profile) = {
let state = ui.borrow();
(state.store.clone(), state.profile.clone())
};
store.save(&profile.borrow())
}
fn save_profile_sync(ui: &SharedUi, profile: &Profile) -> anyhow::Result<()> {
let (store, save_lock, generation) = {
let state = ui.borrow();
(
state.store.clone(),
state.save_lock.clone(),
state.profile_save_generation.clone(),
)
};
let _guard = save_lock.lock().expect("save lock poisoned");
generation.fetch_add(1, Ordering::AcqRel);
store.save(profile)
}
fn flush_saves_sync(ui: &SharedUi) -> anyhow::Result<()> {
let save_lock = ui.borrow().save_lock.clone();
let _guard = save_lock.lock().expect("save lock poisoned");
invalidate_async_saves(ui);
persist_session(ui)?;
persist_downloads(ui)?;
persist_profile(ui)
}
fn cancel_active_downloads_for_shutdown(ui: &SharedUi) {
let active = {
let mut state = ui.borrow_mut();
state.pending_downloads = 0;
state.downloads.borrow_mut().mark_in_progress_interrupted();
let mut active_downloads = state.active_downloads.borrow_mut();
let active = active_downloads.values().cloned().collect::<Vec<_>>();
active_downloads.clear();
active
};
for download in active {
download.cancel();
}
update_downloads_button(ui);
}
fn invalidate_async_saves(ui: &SharedUi) {
let mut state = ui.borrow_mut();
state.profile_save_pending = false;
state.session_save_pending = false;
state.downloads_save_pending = false;
state.profile_save_generation.fetch_add(1, Ordering::AcqRel);
state.session_save_generation.fetch_add(1, Ordering::AcqRel);
state
.downloads_save_generation
.fetch_add(1, Ordering::AcqRel);
}
fn schedule_profile_save(ui: &SharedUi) {
if !mark_profile_save_pending_and_bump(ui) {
return;
}
let ui_weak = Rc::downgrade(ui);
gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(SAVE_DEBOUNCE_MS),
move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (
path,
profile,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
) = {
let mut state = ui.borrow_mut();
state.profile_save_pending = false;
let path = state.store.path().to_path_buf();
let profile = state.profile.borrow().clone();
let generation = state.profile_save_generation.clone();
let expected_generation = generation.load(Ordering::Acquire);
let save_lock = state.save_lock.clone();
let save_sender = state.save_sender.clone();
let save_queue_depth = state.save_queue_depth.clone();
(
path,
profile,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
)
};
let mut profile = profile;
profile.enforce_limits();
enqueue_save_job(
&save_sender,
&save_lock,
&save_queue_depth,
SaveJob::Profile {
path,
value: Box::new(profile),
generation,
expected_generation,
},
);
},
);
}
fn mark_profile_save_pending_and_bump(ui: &SharedUi) -> bool {
let mut state = ui.borrow_mut();
state.profile_save_generation.fetch_add(1, Ordering::AcqRel);
if state.profile_save_pending {
false
} else {
state.profile_save_pending = true;
true
}
}
fn schedule_downloads_save(ui: &SharedUi) {
if !mark_downloads_save_pending_and_bump(ui) {
return;
}
let ui_weak = Rc::downgrade(ui);
gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(SAVE_DEBOUNCE_MS),
move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (
path,
downloads,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
) = {
let mut state = ui.borrow_mut();
state.downloads_save_pending = false;
let download_store = state.store.download_store();
let path = download_store.path().to_path_buf();
let downloads = state.downloads.borrow().clone();
let generation = state.downloads_save_generation.clone();
let expected_generation = generation.load(Ordering::Acquire);
let save_lock = state.save_lock.clone();
let save_sender = state.save_sender.clone();
let save_queue_depth = state.save_queue_depth.clone();
(
path,
downloads,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
)
};
let mut downloads = downloads;
downloads.enforce_limits();
enqueue_save_job(
&save_sender,
&save_lock,
&save_queue_depth,
SaveJob::Downloads {
path,
value: Box::new(downloads),
generation,
expected_generation,
},
);
},
);
}
fn mark_downloads_save_pending_and_bump(ui: &SharedUi) -> bool {
let mut state = ui.borrow_mut();
state
.downloads_save_generation
.fetch_add(1, Ordering::AcqRel);
if state.downloads_save_pending {
false
} else {
state.downloads_save_pending = true;
true
}
}
fn enqueue_save_job(
sender: &SyncSender<SaveJob>,
save_lock: &Mutex<()>,
queue_depth: &AtomicUsize,
job: SaveJob,
) {
let label = job.label();
queue_depth.fetch_add(1, Ordering::AcqRel);
match sender.try_send(job) {
Ok(()) => {}
Err(mpsc::TrySendError::Full(job)) => {
decrement_atomic_usize(queue_depth);
eprintln!("save queue full; writing {label} synchronously");
if let Err(err) = job.write(save_lock) {
eprintln!("failed to save {label}: {err:#}");
}
}
Err(mpsc::TrySendError::Disconnected(job)) => {
decrement_atomic_usize(queue_depth);
eprintln!("save worker stopped; writing {label} synchronously");
if let Err(err) = job.write(save_lock) {
eprintln!("failed to save {label}: {err:#}");
}
}
}
}
fn decrement_atomic_usize(value: &AtomicUsize) {
let _ = value.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
Some(current.saturating_sub(1))
});
}
fn write_latest_json<T: Serialize>(
path: &Path,
value: &T,
generation: &AtomicU64,
expected_generation: u64,
save_lock: &Mutex<()>,
) -> anyhow::Result<()> {
let _guard = save_lock.lock().expect("save lock poisoned");
if generation.load(Ordering::Acquire) != expected_generation {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("tmp-{expected_generation}"));
let data = serde_json::to_vec(value)?;
if let Err(err) = fs::write(&tmp, data) {
let _ = fs::remove_file(&tmp);
return Err(err.into());
}
if generation.load(Ordering::Acquire) != expected_generation {
let _ = fs::remove_file(&tmp);
return Ok(());
}
if let Err(err) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(err.into());
}
Ok(())
}
fn download_url(download: &Download) -> Option<Url> {
download
.response()
.and_then(|response| response.uri())
.or_else(|| download.request().and_then(|request| request.uri()))
.and_then(|uri| Url::parse(&uri).ok())
}
fn download_metadata(download: &Download) -> (Option<String>, Option<u64>) {
let Some(response) = download.response() else {
return (None, None);
};
let mime_type = response.mime_type().map(|mime| mime.to_string());
let content_length = response.content_length();
let total_bytes = (content_length > 0).then_some(content_length);
(mime_type, total_bytes)
}
fn unique_download_path(suggested_filename: &str) -> PathBuf {
unique_download_path_in(&download_directory(), suggested_filename)
}
fn unique_download_path_in(directory: &Path, suggested_filename: &str) -> PathBuf {
let file_name = safe_filename(suggested_filename);
let candidate = directory.join(&file_name);
if !candidate.exists() {
return candidate;
}
let path = Path::new(&file_name);
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.unwrap_or("download");
let extension = path.extension().and_then(|extension| extension.to_str());
for index in 1..1000 {
let name = match extension {
Some(extension) => format!("{stem} ({index}).{extension}"),
None => format!("{stem} ({index})"),
};
let candidate = directory.join(name);
if !candidate.exists() {
return candidate;
}
}
for _ in 0..16 {
let suffix = Uuid::now_v7().simple().to_string();
let name = match extension {
Some(extension) => format!("{stem}-{suffix}.{extension}"),
None => format!("{stem}-{suffix}"),
};
let candidate = directory.join(name);
if !candidate.exists() {
return candidate;
}
}
let suffix = Uuid::now_v7().simple().to_string();
match extension {
Some(extension) => directory.join(format!("{stem}-{suffix}.{extension}")),
None => directory.join(format!("{stem}-{suffix}")),
}
}
fn download_directory() -> PathBuf {
UserDirs::new()
.and_then(|dirs| dirs.download_dir().map(Path::to_path_buf))
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn safe_filename(suggested_filename: &str) -> String {
let safe = suggested_filename
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' | ' ' => ch,
_ => '_',
})
.collect::<String>();
let safe = safe.trim_matches([' ', '.']);
if safe.is_empty() {
"download".to_string()
} else {
safe.to_string()
}
}
fn file_uri(path: &Path) -> Option<String> {
Url::from_file_path(path).ok().map(|url| url.to_string())
}
fn open_path(path: &Path) -> anyhow::Result<()> {
if let Some(uri) = file_uri(path)
&& webbrowser::open(&uri).is_ok()
{
return Ok(());
}
#[cfg(target_os = "linux")]
let mut command = Command::new("xdg-open");
#[cfg(target_os = "macos")]
let mut command = Command::new("open");
#[cfg(target_os = "windows")]
let mut command = Command::new("explorer");
command.arg(path).spawn()?;
Ok(())
}
fn about_blank_url() -> Url {
Url::parse("about:blank").expect("valid about URL")
}
fn connect_toolbar(
back: >k::Button,
forward: >k::Button,
reload: >k::Button,
home: >k::Button,
armour: >k::Button,
ui: &SharedUi,
) {
let ui_for_back = Rc::clone(ui);
back.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_back)
&& webview.can_go_back()
{
webview.go_back();
}
});
let ui_for_forward = Rc::clone(ui);
forward.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_forward)
&& webview.can_go_forward()
{
webview.go_forward();
}
});
let ui_for_reload = Rc::clone(ui);
reload.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_reload) {
webview.reload();
}
});
let ui_for_home = Rc::clone(ui);
home.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_home) {
let profile = ui_for_home.borrow().profile.clone();
load_landing(&webview, &profile.borrow());
ui_for_home.borrow().status.set_text("Start page");
}
});
let ui_for_armour = Rc::clone(ui);
armour.connect_clicked(move |button| show_armour_popover(&ui_for_armour, button));
}
fn connect_bookmark(bookmark: >k::Button, ui: &SharedUi) {
let ui_for_bookmark = Rc::clone(ui);
bookmark.connect_clicked(move |_| bookmark_active_tab(&ui_for_bookmark));
}
fn connect_page_actions(
reader: >k::Button,
ai_reader: >k::Button,
clean_link: >k::Button,
ui: &SharedUi,
) {
let ui_for_reader = Rc::clone(ui);
reader.connect_clicked(move |_| load_reader_mode(&ui_for_reader));
let ui_for_ai_reader = Rc::clone(ui);
ai_reader.connect_clicked(move |_| load_agent_page(&ui_for_ai_reader));
let ui_for_clean_link = Rc::clone(ui);
clean_link.connect_clicked(move |_| copy_active_clean_link(&ui_for_clean_link));
}
fn populate_bookmarkbar(bookmarkbar: >k::Box, ui: &SharedUi) {
let links = {
let profile = ui.borrow().profile.clone();
landing_links(&profile.borrow(), 10)
};
let apps = bookmark_chip("Apps", None);
let ui_for_apps = Rc::clone(ui);
apps.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_apps) {
let profile = ui_for_apps.borrow().profile.clone();
load_landing(&webview, &profile.borrow());
ui_for_apps.borrow().status.set_text("Start page");
}
});
bookmarkbar.pack_start(&apps, false, false, 0);
let separator = gtk::Separator::new(gtk::Orientation::Vertical);
separator
.style_context()
.add_class("bawn-bookmark-separator");
bookmarkbar.pack_start(&separator, false, false, 0);
for (title, url) in links {
let button = bookmark_chip(&title, Some(&url));
button.set_tooltip_text(Some(&url));
let ui_for_link = Rc::clone(ui);
button.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_link) {
let profile = ui_for_link.borrow().profile.clone();
let status = ui_for_link.borrow().status.clone();
navigate(&webview, &profile.borrow(), &status, &url);
}
});
bookmarkbar.pack_start(&button, false, false, 0);
}
let overflow = gtk::Label::new(Some(">>"));
overflow.style_context().add_class("bawn-bookmark-overflow");
bookmarkbar.pack_end(&overflow, false, false, 0);
}
fn bookmark_chip(title: &str, url: Option<&str>) -> gtk::Button {
let button = gtk::Button::new();
button.style_context().add_class("bawn-bookmark-chip");
let row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
row.set_halign(gtk::Align::Start);
let icon_text = url
.map(|url| shortcut_initial(title, url))
.unwrap_or_else(|| "D".to_string());
let icon = gtk::Label::new(Some(&icon_text));
icon.style_context().add_class("bawn-bookmark-icon");
let label = gtk::Label::new(Some(&compact_shortcut_label(title)));
label.set_xalign(0.0);
label.style_context().add_class("bawn-bookmark-label");
row.pack_start(&icon, false, false, 0);
row.pack_start(&label, false, false, 0);
button.add(&row);
button
}
fn toggle_bookmarkbar(ui: &SharedUi) {
let visible = ui.borrow().bookmarkbar.is_visible();
set_bookmarkbar_visible(ui, !visible);
}
fn set_bookmarkbar_visible(ui: &SharedUi, visible: bool) {
let (bookmarkbar, status) = {
let state = ui.borrow();
(state.bookmarkbar.clone(), state.status.clone())
};
if visible {
bookmarkbar.set_no_show_all(false);
bookmarkbar.show_all();
bookmarkbar.set_no_show_all(true);
status.set_text("Bookmarks bar shown");
} else {
bookmarkbar.hide();
status.set_text("Bookmarks bar hidden");
}
}
fn close_active_popover(ui: &SharedUi) {
let popover = ui.borrow_mut().menu_popover.take();
if let Some(popover) = popover {
popover.popdown();
}
}
fn show_chrome_popover(
ui: &SharedUi,
anchor: >k::Button,
content: >k::Box,
min_width: i32,
max_height: i32,
) {
close_active_popover(ui);
let theme = ui.borrow().profile.borrow().theme;
let popover = gtk::Popover::new(Some(anchor));
popover.set_position(gtk::PositionType::Bottom);
popover.set_constrain_to(gtk::PopoverConstraint::Window);
popover.set_modal(true);
popover.style_context().add_class("bawn-menu-popover");
apply_theme_context(&popover.style_context(), theme);
let scroller = gtk::ScrolledWindow::new(None::<>k::Adjustment>, None::<>k::Adjustment>);
scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
scroller.set_min_content_width(min_width);
scroller.set_max_content_width(min_width);
scroller.set_min_content_height(max_height.min(96));
scroller.set_max_content_height(max_height);
scroller.set_propagate_natural_height(true);
scroller.style_context().add_class("bawn-popover-scroll");
apply_theme_context(&scroller.style_context(), theme);
apply_theme_context(&content.style_context(), theme);
scroller.add(content);
popover.add(&scroller);
let ui_weak = Rc::downgrade(ui);
let popover_for_close = popover.downgrade();
popover.connect_closed(move |_| {
if let (Some(ui), Some(popover_for_close)) =
(ui_weak.upgrade(), popover_for_close.upgrade())
{
let should_clear = ui
.borrow()
.menu_popover
.as_ref()
.map(|current| current.as_ptr() == popover_for_close.as_ptr())
.unwrap_or(false);
if should_clear {
ui.borrow_mut().menu_popover = None;
}
}
});
popover.show_all();
popover.popup();
ui.borrow_mut().menu_popover = Some(popover);
}
fn bookmark_active_tab(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
let status = ui.borrow().status.clone();
let Some(uri) = webview.uri() else {
status.set_text("Nothing to bookmark");
return;
};
if is_internal_uri(&uri) {
status.set_text("Start page is already built in");
return;
}
let Ok(url) = Url::parse(&uri) else {
status.set_text("Current page cannot be bookmarked");
return;
};
let title = webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| uri.to_string());
let profile = {
let state = ui.borrow();
state.profile.clone()
};
let mut profile = profile.borrow_mut();
if profile.add_bookmark(title.clone(), url.clone()) {
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!("Bookmark added, but save failed: {err}"));
} else {
status.set_text("Bookmark added");
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"bookmark added",
Some(json!({ "title": title, "url": url })),
);
}
} else {
status.set_text("Bookmark already exists");
}
}
fn show_history_dialog(ui: &SharedUi) {
let (window, profile) = {
let state = ui.borrow();
(state.window.clone(), state.profile.clone())
};
let profile = profile.borrow();
let dialog = gtk::Dialog::with_buttons(
Some("Cephas History"),
Some(&window),
gtk::DialogFlags::MODAL,
&[("Close", gtk::ResponseType::Close)],
);
dialog.set_default_size(720, 520);
dialog.style_context().add_class("bawn-dialog");
let content = dialog.content_area();
content.set_spacing(10);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title = gtk::Label::new(Some("Recent history"));
title.set_xalign(0.0);
title.style_context().add_class("bawn-dialog-title");
content.pack_start(&title, false, false, 0);
if profile.history.is_empty() {
let empty = gtk::Label::new(Some("No history yet."));
empty.set_xalign(0.0);
empty.style_context().add_class("bawn-dialog-copy");
content.pack_start(&empty, false, false, 0);
}
for entry in profile.history.iter().rev().take(30) {
let row = gtk::Button::with_label(&format!(
"{} - {}",
entry.visited_at.format("%Y-%m-%d %H:%M"),
entry.title
));
row.set_tooltip_text(Some(entry.url.as_str()));
row.style_context().add_class("bawn-history-row");
let target = entry.url.to_string();
let ui_for_row = Rc::clone(ui);
let dialog_for_row = dialog.downgrade();
row.connect_clicked(move |_| {
if let Some(webview) = active_webview(&ui_for_row) {
webview.load_uri(&target);
if let Some(dialog) = dialog_for_row.upgrade() {
dialog.close();
}
}
});
content.pack_start(&row, false, false, 0);
}
dialog.connect_response(|dialog, _| dialog.close());
dialog.show_all();
}
fn show_armour_popover(ui: &SharedUi, anchor: >k::Button) {
let (profile, status, current) = {
let state = ui.borrow();
(
state.profile.clone(),
state.status.clone(),
active_webview(ui).and_then(|webview| current_normal_url(&webview)),
)
};
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
content.style_context().add_class("bawn-panel-content");
content.set_spacing(12);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title = gtk::Label::new(Some("Armour controls"));
title.set_xalign(0.0);
title.style_context().add_class("bawn-popover-title");
content.pack_start(&title, false, false, 0);
let site_text = current
.as_ref()
.and_then(|url| url.host_str())
.map(|host| format!("Current site: {host}"))
.unwrap_or_else(|| "Current page has no site identity.".to_string());
let site = gtk::Label::new(Some(&site_text));
site.set_xalign(0.0);
site.style_context().add_class("bawn-popover-copy");
content.pack_start(&site, false, false, 0);
let armour_for_site = gtk::CheckButton::with_label("Enable Armour for this site");
armour_for_site.set_sensitive(current.is_some());
if let Some(url) = ¤t {
armour_for_site.set_active(profile.borrow().shields.is_enabled_for(url));
}
content.pack_start(&armour_for_site, false, false, 0);
let clean = current
.as_ref()
.and_then(|url| clean_url_for_profile(&profile.borrow(), url.as_str()).ok());
let clean_text = clean
.as_ref()
.map(|url| format!("Clean URL: {url}"))
.unwrap_or_else(|| "Clean URL unavailable for this page.".to_string());
let clean_label = gtk::Label::new(Some(&clean_text));
clean_label.set_xalign(0.0);
clean_label.set_line_wrap(true);
clean_label.style_context().add_class("bawn-popover-copy");
content.pack_start(&clean_label, false, false, 0);
let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let copy_clean = gtk::Button::with_label("Copy Clean Link");
let forget_site = gtk::Button::with_label("Forget This Site");
copy_clean.style_context().add_class("bawn-nav");
forget_site.style_context().add_class("bawn-nav");
copy_clean.set_sensitive(clean.is_some());
forget_site.set_sensitive(current.is_some());
row.pack_start(©_clean, false, false, 0);
row.pack_start(&forget_site, false, false, 0);
content.pack_start(&row, false, false, 0);
let stats = {
let profile = profile.borrow();
format!(
"Armour defaults: HTTPS upgrade {}, GPC {}, DNT {}, query stripping {}, redirect debouncing {}, tracker blocking {}, De-AMP {}.",
on_off(profile.shields.https_upgrade),
on_off(profile.shields.global_privacy_control),
on_off(profile.shields.do_not_track),
on_off(profile.shields.strip_tracking_queries),
on_off(profile.shields.debounce_redirects),
on_off(profile.shields.block_trackers),
on_off(profile.shields.de_amp)
)
};
let stats = gtk::Label::new(Some(&stats));
stats.set_xalign(0.0);
stats.set_line_wrap(true);
stats.style_context().add_class("bawn-popover-copy");
content.pack_start(&stats, false, false, 0);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.set_halign(gtk::Align::End);
footer.style_context().add_class("bawn-popover-footer");
let apply = gtk::Button::with_label("Apply");
let close = gtk::Button::with_label("Close");
apply.set_sensitive(current.is_some());
apply.style_context().add_class("bawn-primary-action");
close.style_context().add_class("bawn-nav");
footer.pack_start(&close, false, false, 0);
footer.pack_start(&apply, false, false, 0);
content.pack_start(&footer, false, false, 0);
let clean_for_copy = clean.clone();
let status_for_copy = status.clone();
copy_clean.connect_clicked(move |_| {
if let Some(clean) = &clean_for_copy {
copy_text(clean.as_str());
status_for_copy.set_text("Clean link copied");
}
});
let profile_for_forget = profile.clone();
let ui_for_forget = Rc::clone(ui);
let status_for_forget = status.clone();
let current_for_forget = current.clone();
forget_site.connect_clicked(move |_| {
let Some(url) = ¤t_for_forget else {
return;
};
let Some(host) = url.host_str() else {
return;
};
let mut profile = profile_for_forget.borrow_mut();
let removed = profile.forget_history_for_host(host);
if let Err(err) = save_profile_sync(&ui_for_forget, &profile) {
status_for_forget.set_text(&format!("Forgot {removed} visits, but save failed: {err}"));
} else {
status_for_forget.set_text(&format!("Forgot {removed} visits for {host}"));
}
});
let profile_for_apply = profile.clone();
let status_for_apply = status.clone();
let current_for_apply = current.clone();
let ui_for_apply = Rc::clone(ui);
apply.connect_clicked(move |_| {
let Some(url) = ¤t_for_apply else {
return;
};
let mut profile = profile_for_apply.borrow_mut();
if armour_for_site.is_active() {
profile.shields.protect_site(url);
} else {
profile.shields.allow_site(url);
}
if let Err(err) = save_profile_sync(&ui_for_apply, &profile) {
status_for_apply.set_text(&format!("Armour updated, but save failed: {err}"));
} else {
status_for_apply.set_text("Armour updated for this site");
}
close_active_popover(&ui_for_apply);
});
let ui_for_close = Rc::clone(ui);
close.connect_clicked(move |_| close_active_popover(&ui_for_close));
show_chrome_popover(ui, anchor, &content, 420, 500);
}
fn save_settings_from_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let Ok(url) = Url::parse(uri) else {
return;
};
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
let params = url
.query_pairs()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<_>>();
let value_for = |name: &str| {
params
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
};
let is_checked = |name: &str| params.iter().any(|(key, _)| key == name);
let Some(home_input) = value_for("home") else {
status.set_text("Settings save failed: missing home URL");
return;
};
let home = match parse_settings_home_url(home_input) {
Ok(home) => home,
Err(message) => {
status.set_text(&message);
return;
}
};
let Some(web_acceleration) =
value_for("web_acceleration").and_then(WebAccelerationPolicy::from_id)
else {
status.set_text("Settings save failed: invalid GPU acceleration policy");
return;
};
let Some(media_playback) = value_for("media_playback").and_then(MediaPlaybackPolicy::from_id)
else {
status.set_text("Settings save failed: invalid media policy");
return;
};
let Some(search_provider) = value_for("search_provider").and_then(SearchProvider::from_id)
else {
status.set_text("Settings save failed: invalid search provider");
return;
};
let mut profile = profile.borrow_mut();
profile.home = home;
profile.web_acceleration = web_acceleration;
profile.media_playback = media_playback;
profile.search_provider = search_provider;
profile.shields.https_upgrade = is_checked("https");
profile.shields.global_privacy_control = is_checked("gpc");
profile.shields.do_not_track = is_checked("dnt");
profile.shields.strip_tracking_queries = is_checked("strip");
profile.shields.debounce_redirects = is_checked("debounce");
profile.shields.block_trackers = is_checked("block");
profile.shields.de_amp = is_checked("de_amp");
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!("Settings save failed: {err}"));
return;
}
apply_acceleration_to_all_tabs(ui, profile.web_acceleration);
apply_media_policy_to_all_tabs(ui, profile.media_playback);
load_settings_page_in(webview, &profile);
status.set_text("Settings saved");
}
fn parse_settings_home_url(input: &str) -> Result<Url, String> {
match Url::parse(input.trim()) {
Ok(home) if matches!(home.scheme(), "http" | "https") => Ok(home),
Ok(_) => Err("Home URL invalid: only http and https are allowed".to_string()),
Err(err) => Err(format!("Home URL invalid: {err}")),
}
}
fn save_startup_from_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let Ok(url) = Url::parse(uri) else {
return;
};
let Some(startup_behavior) = url
.query_pairs()
.find(|(key, _)| key == "startup")
.and_then(|(_, value)| StartupBehavior::from_id(value.as_ref()))
else {
ui.borrow()
.status
.set_text("Startup save failed: invalid startup behavior");
return;
};
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
let mut profile = profile.borrow_mut();
profile.startup_behavior = startup_behavior;
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!("Startup save failed: {err}"));
return;
}
load_settings_page_in(webview, &profile);
status.set_text(&format!(
"Startup behavior saved: {}",
startup_behavior.label()
));
}
fn set_ai_browsing_from_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let Ok(url) = Url::parse(uri) else {
return;
};
let Some(enabled_value) = url
.query_pairs()
.find(|(key, _)| key == "enabled")
.map(|(_, value)| value.into_owned())
else {
ui.borrow()
.status
.set_text("AI browsing save failed: missing enabled value");
return;
};
let enabled = match enabled_value.as_str() {
"1" | "true" | "on" => true,
"0" | "false" | "off" => false,
_ => {
ui.borrow()
.status
.set_text("AI browsing save failed: invalid enabled value");
return;
}
};
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
let mut profile = profile.borrow_mut();
profile.ai_browsing_enabled = enabled;
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!("AI browsing save failed: {err}"));
return;
}
load_landing(webview, &profile);
if enabled {
status.set_text("AI browsing enabled: browser control allowed");
} else {
status.set_text("AI browsing disabled");
}
}
fn handle_plugin_action_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let Ok(url) = Url::parse(uri) else {
return;
};
let action = url.path().trim_start_matches('/');
let (profile, status) = {
let state = ui.borrow();
(state.profile.clone(), state.status.clone())
};
let mut profile = profile.borrow_mut();
if action == "install-all" || action == "sync-all" {
let result = if action == "install-all" {
profile.plugins.install_all_available_bundled()
} else {
profile.plugins.sync_all_installed_bundled()
};
match result {
Ok(plugins) => {
let changed = plugins.len();
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!(
"Plugin {action} changed {changed}, but save failed: {err}"
));
} else {
status.set_text(&format!("Plugin {action}: {changed} bundled plugins"));
}
}
Err(err) => status.set_text(&format!("Plugin {action} failed: {err}")),
}
load_plugins_page_in(webview, &profile);
return;
}
let Some(plugin_id) = plugin_selector_from_uri(&url) else {
status.set_text("Plugin action failed: missing id or module");
load_plugins_page_in(webview, &profile);
return;
};
let result = match action {
"install" => profile.plugins.install_bundled(plugin_id),
"sync" => profile.plugins.sync_bundled(plugin_id),
"enable" => profile.plugins.set_status(plugin_id, PluginStatus::Enabled),
"disable" => profile
.plugins
.set_status(plugin_id, PluginStatus::Disabled),
"remove" => profile.plugins.remove(plugin_id),
_ => {
status.set_text("Plugin action failed: unknown action");
load_plugins_page_in(webview, &profile);
return;
}
};
match result {
Ok(plugin) => {
if let Err(err) = save_profile_sync(ui, &profile) {
status.set_text(&format!(
"Plugin {} updated, but save failed: {err}",
plugin.name
));
} else {
status.set_text(&format!("Plugin {}: {action}", plugin.name));
}
}
Err(err) => status.set_text(&format!("Plugin action failed: {err}")),
}
load_plugins_page_in(webview, &profile);
}
fn plugin_selector_from_uri(url: &Url) -> Option<PluginId> {
let mut id = None;
let mut module = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"id" => id = Some(value.into_owned()),
"module" => module = Some(value.into_owned()),
_ => {}
}
}
id.as_deref()
.or(module.as_deref())
.and_then(crate::plugins::resolve_plugin_selector)
}
fn cycle_start_page_theme(ui: &SharedUi, webview: &WebView) {
let was_start_page = webview.uri().as_deref().is_some_and(is_start_uri);
let theme = {
let profile = ui.borrow().profile.clone();
next_browser_theme(profile.borrow().theme)
};
let status = ui.borrow().status.clone();
match apply_browser_theme(ui, theme) {
Ok(()) => {
if !was_start_page {
let profile = ui.borrow().profile.clone();
load_landing(webview, &profile.borrow());
}
status.set_text(&format!("Theme toggled: {}", theme.label()));
}
Err(err) => status.set_text(&format!("Theme toggle failed: {err}")),
}
}
fn apply_theme_from_uri(ui: &SharedUi, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Theme apply failed: invalid request");
return;
};
let Some(theme_id) = url
.query_pairs()
.find(|(key, _)| key == "id")
.map(|(_, value)| value.to_string())
else {
status.set_text("Theme apply failed: missing theme id");
return;
};
let Some(theme) = BrowserTheme::from_id(&theme_id) else {
status.set_text("Theme apply failed: unknown theme");
return;
};
match apply_browser_theme(ui, theme) {
Ok(()) => status.set_text(&format!("Theme applied: {}", theme.label())),
Err(err) => status.set_text(&format!("Theme apply failed: {err}")),
}
}
fn apply_custom_theme_preset_from_uri(ui: &SharedUi, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Custom theme preset failed: invalid request");
return;
};
let Some(preset_id) = url
.query_pairs()
.find(|(key, _)| key == "id")
.map(|(_, value)| value.to_string())
else {
status.set_text("Custom theme preset failed: missing preset id");
return;
};
let Some(preset) = custom_theme_preset_by_id(&preset_id) else {
status.set_text("Custom theme preset failed: unknown preset");
return;
};
let profile = ui.borrow().profile.clone();
{
let mut profile = profile.borrow_mut();
profile.custom_theme = custom_theme_from_preset(preset);
}
match apply_browser_theme(ui, BrowserTheme::Custom) {
Ok(()) => status.set_text(&format!("Custom preset applied: {}", preset.label)),
Err(err) => status.set_text(&format!("Custom theme preset failed: {err}")),
}
}
fn save_custom_theme_from_uri(ui: &SharedUi, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Custom theme save failed: invalid request");
return;
};
let params = url
.query_pairs()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<_>>();
let value_for = |name: &str| {
params
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
};
let Some(name) = value_for("name") else {
status.set_text("Custom theme save failed: missing name");
return;
};
let Some(chrome) = value_for("chrome").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: chrome color must be #rrggbb");
return;
};
let Some(page) = value_for("page").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: page color must be #rrggbb");
return;
};
let Some(surface) = value_for("surface").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: surface color must be #rrggbb");
return;
};
let Some(text) = value_for("text").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: text color must be #rrggbb");
return;
};
let Some(muted) = value_for("muted").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: muted color must be #rrggbb");
return;
};
let Some(accent) = value_for("accent").and_then(sanitize_hex_color) else {
status.set_text("Custom theme save failed: accent color must be #rrggbb");
return;
};
let Some(radius) = value_for("radius").and_then(CustomThemeRadius::from_id) else {
status.set_text("Custom theme save failed: unknown radius");
return;
};
let Some(density) = value_for("density").and_then(CustomThemeDensity::from_id) else {
status.set_text("Custom theme save failed: unknown density");
return;
};
let profile = ui.borrow().profile.clone();
{
let mut profile = profile.borrow_mut();
profile.custom_theme = CustomTheme {
name: name.to_string(),
chrome,
page,
surface,
text,
muted,
accent,
radius,
density,
}
.sanitized();
}
match apply_browser_theme(ui, BrowserTheme::Custom) {
Ok(()) => status.set_text("Custom theme applied"),
Err(err) => status.set_text(&format!("Custom theme save failed: {err}")),
}
}
fn copy_active_clean_link(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
let status = ui.borrow().status.clone();
let Some(uri) = webview.uri() else {
status.set_text("No page URL to copy");
return;
};
let profile = ui.borrow().profile.clone();
match clean_url_for_profile(&profile.borrow(), &uri) {
Ok(clean) => {
copy_text(clean.as_str());
status.set_text("Clean link copied");
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"clean link copied",
Some(json!({ "url": clean })),
);
}
Err(err) => status.set_text(&format!("Could not clean URL: {err}")),
}
}
fn load_reader_mode(ui: &SharedUi) {
let Some(webview) = active_webview(ui) else {
return;
};
let status = ui.borrow().status.clone();
let Some(uri) = webview.uri() else {
status.set_text("No page to read");
return;
};
if is_internal_uri(&uri) {
status.set_text("Reader mode needs a web page");
return;
}
let Ok(url) = Url::parse(&uri) else {
status.set_text("Reader mode needs a normal URL");
return;
};
status.set_text("Building reader view...");
let profile = ui.borrow().profile.clone();
match fetch_page_for_reader(&profile.borrow(), &url) {
Ok(fetch) => {
let article = extract_article(&fetch.body, &url);
let reader = reader_html(&article.title, article.byline.as_deref(), &article.text);
webview.load_html(&reader, Some(CEPHAS_READER_DOCUMENT_URI));
if fetch.truncated {
status.set_text("Reader mode loaded from truncated source");
} else {
status.set_text("Reader mode loaded");
}
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"reader mode loaded",
Some(json!({
"source_url": url,
"title": article.title,
"source_body_truncated": fetch.truncated
})),
);
}
Err(err) => status.set_text(&format!("Reader mode failed: {err}")),
}
}
fn load_agent_reader_tool(ui: &SharedUi, tool: AgentReaderTool) {
let Some(webview) = active_webview(ui) else {
return;
};
let status = ui.borrow().status.clone();
let Some(url) = agent_reader_target_url(ui) else {
status.set_text("Open Agent control from a web page before using reader tools");
return;
};
status.set_text(&format!("Building {}...", tool.label()));
let profile = ui.borrow().profile.clone();
match fetch_page_for_reader(&profile.borrow(), &url) {
Ok(fetch) => {
let article = extract_article(&fetch.body, &url);
let artifact =
agent_reader_artifact(&article, &fetch.body, &url, tool, fetch.truncated);
let packet = format_agent_reader_output(&artifact);
let page = agent_reader_html(&article.title, tool, &packet);
webview.load_html(&page, Some(CEPHAS_AI_READER_DOCUMENT_URI));
let artifact_id = short_uuid(artifact.artifact_id);
let truncation_note = if fetch.truncated {
" from truncated source"
} else {
""
};
match log_agent_reader_artifact(ui, &artifact, packet.len()) {
Ok(0) => status.set_text(&format!(
"{} loaded as artifact {artifact_id}{truncation_note}",
tool.label()
)),
Ok(count) => status.set_text(&format!(
"{} loaded as artifact {artifact_id}{truncation_note}; logged for {count} agent(s)",
tool.label()
)),
Err(err) => status.set_text(&format!(
"{} loaded as artifact {artifact_id}; agent log failed: {err}",
tool.label()
)),
}
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"agent reader artifact generated",
Some(json!({
"artifact_id": artifact.artifact_id,
"tool": artifact.tool.id(),
"packet_bytes": packet.len(),
"source_body_truncated": artifact.source_body_truncated
})),
);
}
Err(err) => status.set_text(&format!("Agent reader failed: {err}")),
}
}
fn load_agent_reader_tool_from_uri(ui: &SharedUi, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Agent reader request invalid");
return;
};
let Some(tool_id) = url
.query_pairs()
.find(|(key, _)| key == "tool")
.map(|(_, value)| value.to_string())
else {
status.set_text("Agent reader request missing tool");
return;
};
let Some(tool) = AgentReaderTool::from_id(&tool_id) else {
status.set_text("Agent reader request has an unknown tool");
return;
};
load_agent_reader_tool(ui, tool);
}
fn agent_reader_target_url(ui: &SharedUi) -> Option<Url> {
let state = ui.borrow();
let active_url = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.and_then(|tab| tab.webview.uri())
.and_then(|uri| {
if is_internal_uri(&uri) {
None
} else {
Url::parse(&uri).ok()
}
});
active_url.or_else(|| state.agent_target_url.clone())
}
fn handle_agent_emulation_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Agent emulation request invalid");
return;
};
let command_id = url
.query_pairs()
.find(|(key, _)| key == "command")
.map(|(_, value)| value.to_string());
let Some(command_id) = command_id else {
status.set_text("Agent emulation needs command=<id>");
return;
};
let emulation = {
let state = ui.borrow();
build_agent_emulation_state(&state, &command_id)
};
match emulation {
Some(emulation) => {
let label = emulation.label.clone();
ui.borrow_mut().agent_emulation = Some(emulation);
status.set_text(&format!("Agent emulation ready: {label}"));
load_agent_page_in(ui, webview);
record_agent_emulation_event(ui, &command_id, &label);
schedule_recording_frame(ui, format!("agent-emulation:{command_id}"), 180);
}
None => status.set_text(&format!("Unknown agent command: {command_id}")),
}
}
fn record_agent_emulation_event(ui: &SharedUi, command_id: &str, label: &str) {
let emulation = {
let state = ui.borrow();
state.agent_emulation.clone()
};
let Some(emulation) = emulation else {
return;
};
let request = serde_json::from_str::<serde_json::Value>(&emulation.request_json)
.unwrap_or_else(|_| json!({ "raw": emulation.request_json }));
let details = json!({
"schema": "cephas.agent-emulation-record.v1",
"command": command_id,
"label": label,
"category": emulation.category,
"coverage": emulation.coverage,
"example": emulation.example,
"expected_result": emulation.result,
"request": request,
"recording": {
"frame_reason": format!("agent-emulation:{command_id}"),
"captured_when_visual_recording_active": true
},
"backend_delivery": {
"contract": "elixir_backend.MD",
"eligible_when": ["authenticated", "sync_recordings", "non_private_frame"]
}
});
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"agent command emulation",
Some(details.clone()),
);
let _ = log_recording_agent_event(ui, "Agent command emulation dry-run", details);
}
fn build_agent_emulation_state(state: &BrowserUi, command_id: &str) -> Option<AgentEmulationState> {
let entry = cached_agent_command_manifest()
.iter()
.find(|entry| entry.id == command_id)?;
let target = state
.agent_target_url
.as_ref()
.map(|url| url.as_str().to_string())
.or_else(|| {
state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.and_then(|tab| current_normal_url(&tab.webview))
.map(|url| url.as_str().to_string())
})
.unwrap_or_else(|| "https://example.com".to_string());
let (category, coverage, example, result) = agent_emulation_spec(entry.id, &target);
let request_json = agent_emulation_request_json(entry, &target, &example, coverage, &result);
Some(AgentEmulationState {
command_id: entry.id.to_string(),
label: entry.label.to_string(),
description: entry.description.to_string(),
category,
coverage,
example,
result,
request_json,
})
}
fn agent_emulation_request_json(
entry: &AgentCommandManifestEntry,
target: &str,
example: &str,
coverage: &str,
result: &str,
) -> String {
serde_json::to_string_pretty(&json!({
"schema": "cephas.agent-emulation.v1",
"dry_run": true,
"source": "cephas://agent#emulation",
"command": {
"id": entry.id,
"label": entry.label,
"requires_target": entry.requires_target,
"target": if entry.requires_target { Some(target) } else { None }
},
"example": example,
"coverage": coverage,
"expected_result": result
}))
.expect("serialize agent emulation request")
}
fn agent_emulation_spec(
command_id: &str,
target: &str,
) -> (&'static str, &'static str, String, String) {
match command_id {
"navigate" => (
"Navigation",
"Needs executor bridge",
format!("queue browser command navigate with target {target}"),
"The active normal tab would navigate to the target URL or search query.".to_string(),
),
"open-tab" => (
"Tabs",
"Needs executor bridge",
format!("queue browser command open-tab with target {target}"),
"A new tab would open with the target URL or search query.".to_string(),
),
"reload" => (
"Tabs",
"Needs executor bridge",
"queue browser command reload".to_string(),
"The active tab would reload.".to_string(),
),
"back" => (
"Tabs",
"Needs executor bridge",
"queue browser command back".to_string(),
"The active tab would go back when history is available.".to_string(),
),
"forward" => (
"Tabs",
"Needs executor bridge",
"queue browser command forward".to_string(),
"The active tab would go forward when history is available.".to_string(),
),
"close-tab" => (
"Tabs",
"Needs executor bridge",
"queue browser command close-tab".to_string(),
"The active tab would close through normal tab lifecycle rules.".to_string(),
),
"copy-clean-link" => (
"Privacy",
"Needs executor bridge",
"queue browser command copy-clean-link".to_string(),
"The current page URL would be stripped of known tracking parameters and copied.".to_string(),
),
"start-recording" => (
"Recording",
"Agent page route wired",
CEPHAS_AGENT_RECORDING_START_URI.to_string(),
"The real Agent page route starts visual recording for the browser window.".to_string(),
),
"stop-recording" => (
"Recording",
"Agent page route wired",
CEPHAS_AGENT_RECORDING_STOP_URI.to_string(),
"The real Agent page route stops recording and writes the replay bundle.".to_string(),
),
"capture-snapshot" => (
"Recording",
"Agent page route wired",
CEPHAS_AGENT_RECORDING_SNAPSHOT_URI.to_string(),
"The real Agent page route captures a visible WebKit frame while recording is active.".to_string(),
),
"capture-website-screenshot" => (
"Screenshot",
"CLI wired",
format!("target/debug/cephas agent-screenshot {target} --output /tmp/cephas-example.png --json"),
"The CLI renders the target into a PNG and emits schema cephas.agent-screenshot.v1.".to_string(),
),
"enable-human-control" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/enable"),
"The real route enables session-only browser-window human control.".to_string(),
),
"disable-human-control" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/disable"),
"The real route disables browser-window human control.".to_string(),
),
"human-move-pointer" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/move?x=320&y=240"),
"With human control enabled on a normal page, Cephas dispatches a mousemove event at viewport coordinates.".to_string(),
),
"human-click" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/click?x=320&y=240"),
"With human control enabled on a normal page, Cephas activates the nearest clickable element at the coordinates.".to_string(),
),
"human-type-text" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/type?text=hello%20from%20Cephas"),
"With human control enabled on a normal page, Cephas types text into the focused editable element.".to_string(),
),
"human-press-key" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/key?key=Enter"),
"With human control enabled on a normal page, Cephas dispatches keydown and keyup events.".to_string(),
),
"human-scroll" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/scroll?dy=600"),
"With human control enabled on a normal page, Cephas scrolls the viewport by the requested deltas.".to_string(),
),
"human-wait" => (
"Human Control",
"Agent page route wired",
format!("{CEPHAS_AGENT_HUMAN_URI}/wait?ms=1000"),
"With human control enabled, Cephas pauses the agent workflow without changing the page.".to_string(),
),
"reader-brief" => reader_emulation_spec("agent-brief", "Agent brief"),
"reader-full-text" => reader_emulation_spec("full-text", "Full text"),
"reader-outline" => reader_emulation_spec("outline", "Outline"),
"reader-link-map" => reader_emulation_spec("link-map", "Link map"),
"reader-data-signals" => reader_emulation_spec("data-signals", "Data signals"),
"reader-json-artifact" => reader_emulation_spec("json-artifact", "JSON artifact"),
_ => (
"Command",
"Manifest only",
format!("queue browser command {command_id}"),
"This command is present in the manifest and needs an executor-specific implementation.".to_string(),
),
}
}
fn reader_emulation_spec(
tool_id: &'static str,
label: &'static str,
) -> (&'static str, &'static str, String, String) {
(
"Reader",
"Agent page route wired",
format!("{CEPHAS_AGENT_READER_URI}?tool={tool_id}"),
format!("The real route runs the {label} reader tool against the remembered target page."),
)
}
fn handle_agent_human_control_uri(ui: &SharedUi, webview: &WebView, uri: &str) {
let status = ui.borrow().status.clone();
let Ok(url) = Url::parse(uri) else {
status.set_text("Human control request invalid");
return;
};
let path = url.path().trim_end_matches('/');
let params = url
.query_pairs()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<_>>();
let value_for = |name: &str| {
params
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
};
if path == "/human" {
status.set_text("Human control route needs /enable or /disable");
record_human_control_event(
ui,
"unknown",
Some(json!({ "result": "rejected", "reason": "missing_toggle_action" })),
);
if webview.uri().as_deref().is_some_and(is_agent_uri) {
load_agent_page_in(ui, webview);
}
return;
}
if matches!(path, "/human/enable" | "/human/disable") {
let enabled = path == "/human/enable";
set_human_control_enabled(ui, enabled);
if webview.uri().as_deref().is_some_and(is_agent_uri) {
load_agent_page_in(ui, webview);
}
return;
}
let Some(requested_action) = human_control_action_name(path) else {
status.set_text("Unknown human control action");
record_human_control_event(
ui,
"unknown",
Some(json!({ "result": "rejected", "reason": "unknown_action", "path": path })),
);
return;
};
if !ui.borrow().human_control_enabled {
status.set_text("Enable human control on the Agent page before issuing human actions");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "disabled" })),
);
return;
}
if !active_webview_matches(ui, webview) {
status.set_text("Human control actions require the active tab");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "inactive_tab" })),
);
return;
}
if active_webview_is_private(ui, webview) {
status.set_text("Human control is disabled on private tabs");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "private_tab" })),
);
return;
}
if webview.uri().as_deref().is_some_and(is_internal_uri) {
status.set_text("Human control actions need a normal web page");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "internal_page" })),
);
return;
}
match path {
"/human/move" => {
let Some((x, y)) = human_control_xy(value_for) else {
status.set_text("Human move needs x and y viewport coordinates");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "invalid_coordinates" })),
);
return;
};
run_human_control_script(
ui,
webview,
"move pointer",
&human_move_script(x, y),
Some(json!({ "x": x, "y": y })),
);
}
"/human/click" => {
let Some((x, y)) = human_control_xy(value_for) else {
status.set_text("Human click needs x and y viewport coordinates");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "invalid_coordinates" })),
);
return;
};
run_human_control_script(
ui,
webview,
"click",
&human_click_script(x, y),
Some(json!({ "x": x, "y": y })),
);
}
"/human/type" => {
let Some(text) = value_for("text") else {
status.set_text("Human type needs text");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "missing_text" })),
);
return;
};
run_human_control_script(
ui,
webview,
"type text",
&human_type_script(text),
Some(json!({ "text_len": text.chars().count() })),
);
}
"/human/key" => {
let Some(key) = value_for("key") else {
status.set_text("Human key needs key");
record_human_control_event(
ui,
requested_action,
Some(json!({ "result": "rejected", "reason": "missing_key" })),
);
return;
};
run_human_control_script(
ui,
webview,
"press key",
&human_key_script(key),
Some(json!({ "key": key })),
);
}
"/human/scroll" => {
let dx = value_for("dx")
.and_then(|value| value.parse::<i32>().ok())
.unwrap_or(0)
.clamp(-5000, 5000);
let dy = value_for("dy")
.and_then(|value| value.parse::<i32>().ok())
.unwrap_or(600)
.clamp(-5000, 5000);
run_human_control_script(
ui,
webview,
"scroll",
&human_scroll_script(dx, dy),
Some(json!({ "dx": dx, "dy": dy })),
);
}
"/human/wait" => {
let ms = value_for("ms")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(500)
.clamp(50, 30_000);
let wait_generation = {
let mut state = ui.borrow_mut();
if state.pending_human_waits >= MAX_PENDING_HUMAN_WAITS {
status.set_text(&format!(
"Human wait limit reached ({MAX_PENDING_HUMAN_WAITS} pending)"
));
drop(state);
record_human_control_event(
ui,
"wait",
Some(json!({ "result": "rejected", "reason": "wait_limit" })),
);
return;
}
state.pending_human_waits += 1;
state.human_control_generation
};
status.set_text(&format!("Human wait: {ms}ms"));
record_human_control_event(ui, "wait", Some(json!({ "result": "queued", "ms": ms })));
let ui_weak = Rc::downgrade(ui);
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let wait_still_valid = {
let mut state = ui.borrow_mut();
state.pending_human_waits = state.pending_human_waits.saturating_sub(1);
state.human_control_enabled && state.human_control_generation == wait_generation
};
if !wait_still_valid {
ui.borrow().status.set_text("Human wait cancelled");
record_human_control_event(
&ui,
"wait",
Some(json!({ "result": "cancelled", "ms": ms })),
);
return;
}
ui.borrow().status.set_text("Human wait completed");
record_human_control_event(
&ui,
"wait",
Some(json!({ "result": "completed", "ms": ms })),
);
schedule_recording_frame(&ui, "human-control:wait", 50);
});
}
_ => unreachable!("human control action was resolved before dispatch"),
}
}
fn human_control_action_name(path: &str) -> Option<&'static str> {
match path {
"/human/move" => Some("move pointer"),
"/human/click" => Some("click"),
"/human/type" => Some("type text"),
"/human/key" => Some("press key"),
"/human/scroll" => Some("scroll"),
"/human/wait" => Some("wait"),
_ => None,
}
}
fn set_human_control_enabled(ui: &SharedUi, enabled: bool) {
{
let mut state = ui.borrow_mut();
if state.human_control_enabled != enabled {
state.human_control_generation = state.human_control_generation.wrapping_add(1);
}
state.human_control_enabled = enabled;
if !enabled {
state.pending_human_waits = 0;
}
}
let message = if enabled {
"Human control enabled for this Cephas session"
} else {
"Human control disabled"
};
ui.borrow().status.set_text(message);
record_human_control_event(ui, if enabled { "enabled" } else { "disabled" }, None);
}
fn active_webview_matches(ui: &SharedUi, webview: &WebView) -> bool {
let state = ui.borrow();
state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.is_some_and(|tab| tab.webview.as_ptr() == webview.as_ptr())
}
fn active_webview_is_private(ui: &SharedUi, webview: &WebView) -> bool {
ui.borrow()
.tabs
.iter()
.find(|tab| tab.webview.as_ptr() == webview.as_ptr())
.map(|tab| tab.private)
.unwrap_or(false)
}
fn human_control_xy<'a>(value_for: impl Fn(&str) -> Option<&'a str>) -> Option<(i32, i32)> {
let x = value_for("x")?.parse::<i32>().ok()?.clamp(0, 10000);
let y = value_for("y")?.parse::<i32>().ok()?.clamp(0, 10000);
Some((x, y))
}
fn run_human_control_script(
ui: &SharedUi,
webview: &WebView,
action: &'static str,
script: &str,
details: Option<serde_json::Value>,
) {
let status = ui.borrow().status.clone();
status.set_text(&format!("Human control: {action}"));
let mut details = details.unwrap_or_else(|| json!({}));
if let Some(object) = details.as_object_mut() {
object.insert("result".to_string(), json!("queued"));
}
record_human_control_event(ui, action, Some(details));
let ui_for_result = Rc::clone(ui);
let status_for_result = status.clone();
webview.evaluate_javascript(
script,
None,
None,
None::<>k::gio::Cancellable>,
move |result| match result {
Ok(_) => {
status_for_result.set_text(&format!("Human control completed: {action}"));
record_human_control_event(
&ui_for_result,
action,
Some(json!({ "result": "completed" })),
);
schedule_recording_frame(
&ui_for_result,
format!("human-control:{}", action.replace(' ', "-")),
220,
);
}
Err(err) => {
let error = err.to_string();
status_for_result.set_text(&format!("Human control failed: {error}"));
record_human_control_event(
&ui_for_result,
action,
Some(json!({ "result": "failed", "error": error })),
);
}
},
);
}
fn record_human_control_event(
ui: &SharedUi,
action: &'static str,
details: Option<serde_json::Value>,
) {
let details = details.unwrap_or_else(|| json!({}));
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"human control action",
Some(json!({
"schema": "cephas.human-control-record.v1",
"action": action,
"details": details.clone(),
"recording": {
"frame_reason": format!("human-control:{}", action.replace(' ', "-")),
"captured_on_completion_when_visual_recording_active": true
},
"backend_delivery": {
"contract": "elixir_backend.MD",
"eligible_when": ["authenticated", "sync_recordings", "non_private_frame"]
}
})),
);
let _ = log_recording_agent_event(
ui,
"Browser human control action",
json!({
"schema": "cephas.human-control-record.v1",
"action": action,
"details": details,
"recording": {
"frame_reason": format!("human-control:{}", action.replace(' ', "-")),
"captured_on_completion_when_visual_recording_active": true
}
}),
);
}
fn js_string(value: &str) -> String {
serde_json::to_string(value).expect("serialize javascript string")
}
fn human_move_script(x: i32, y: i32) -> String {
format!(
r#"(() => {{
const x = {x};
const y = {y};
const target = document.elementFromPoint(x, y) || document.body;
const event = new MouseEvent('mousemove', {{ bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }});
target.dispatchEvent(event);
}})();"#
)
}
fn human_click_script(x: i32, y: i32) -> String {
format!(
r#"(() => {{
const x = {x};
const y = {y};
const target = document.elementFromPoint(x, y) || document.body;
const clickable = target.closest?.('a,button,input,select,textarea,label,[role="button"],[onclick]') || target;
for (const type of ['mousemove', 'mousedown', 'mouseup']) {{
target.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 }}));
}}
if (typeof clickable.focus === 'function') clickable.focus();
if (typeof clickable.click === 'function') {{
clickable.click();
}} else {{
target.dispatchEvent(new MouseEvent('click', {{ bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 }}));
}}
}})();"#
)
}
fn human_type_script(text: &str) -> String {
let text = js_string(text);
format!(
r#"(() => {{
const text = {text};
const target = document.activeElement || document.body;
if ('value' in target) {{
const start = target.selectionStart ?? target.value.length;
const end = target.selectionEnd ?? target.value.length;
target.setRangeText(text, start, end, 'end');
target.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertText', data: text }}));
target.dispatchEvent(new Event('change', {{ bubbles: true }}));
return;
}}
if (target.isContentEditable) {{
document.execCommand('insertText', false, text);
}}
}})();"#
)
}
fn human_key_script(key: &str) -> String {
let key = js_string(key);
format!(
r#"(() => {{
const key = {key};
const target = document.activeElement || document.body;
target.dispatchEvent(new KeyboardEvent('keydown', {{ bubbles: true, cancelable: true, key }}));
if (key === 'Enter' && target.tagName === 'TEXTAREA') {{
target.setRangeText('\n', target.selectionStart ?? target.value.length, target.selectionEnd ?? target.value.length, 'end');
target.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertLineBreak', data: '\n' }}));
}} else if (key === 'Backspace' && 'value' in target) {{
const start = target.selectionStart ?? target.value.length;
const end = target.selectionEnd ?? target.value.length;
if (start !== end) target.setRangeText('', start, end, 'end');
else if (start > 0) target.setRangeText('', start - 1, start, 'end');
target.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'deleteContentBackward' }}));
}}
target.dispatchEvent(new KeyboardEvent('keyup', {{ bubbles: true, cancelable: true, key }}));
}})();"#
)
}
fn human_scroll_script(dx: i32, dy: i32) -> String {
format!(
r#"(() => {{
const dx = {dx};
const dy = {dy};
window.dispatchEvent(new WheelEvent('wheel', {{ bubbles: true, cancelable: true, deltaX: dx, deltaY: dy }}));
window.scrollBy(dx, dy);
}})();"#
)
}
fn log_agent_reader_artifact(
ui: &SharedUi,
artifact: &AgentReaderArtifact,
packet_bytes: usize,
) -> anyhow::Result<usize> {
let (profile, agents) = {
let state = ui.borrow();
(state.profile.clone(), state.agents.clone())
};
if !profile.borrow().ai_browsing_enabled {
return Ok(0);
}
let mut agents_ref = agents.borrow_mut();
let Some(control) = agents_ref.as_mut() else {
return Ok(0);
};
let agent_ids = control
.active_for_window(WindowId(1))
.into_iter()
.map(|delegation| delegation.agent_id)
.collect::<Vec<_>>();
if agent_ids.is_empty() {
return Ok(0);
}
let metadata = agent_reader_event_metadata(artifact, packet_bytes);
let mut events = Vec::new();
for agent_id in agent_ids {
events.push(control.append_log(
agent_id,
"Agent reader artifact ready for browser-control workflow",
Some(metadata.clone()),
)?);
}
drop(agents_ref);
AgentLogStore::new(None)?.append_all(&events)?;
Ok(events.len())
}
fn agent_reader_event_metadata(
artifact: &AgentReaderArtifact,
packet_bytes: usize,
) -> serde_json::Value {
json!({
"artifact": {
"schema": artifact.schema.as_str(),
"artifact_id": artifact.artifact_id,
"tool": artifact.tool.id(),
"tool_label": artifact.tool.label(),
"source_url": artifact.source_url.as_str(),
"canonical_url": artifact.canonical_url.as_ref().map(Url::as_str),
"title": artifact.title.as_str(),
"source_body_truncated": artifact.source_body_truncated,
"content_truncated": artifact.content_truncated,
"packet_bytes": packet_bytes,
"counts": {
"headings": artifact.headings.len(),
"links": artifact.links.len(),
"emails": artifact.signals.emails.len(),
"dates": artifact.signals.dates.len(),
"money": artifact.signals.money.len(),
"percentages": artifact.signals.percentages.len(),
"tables": artifact.signals.tables.len()
}
},
"delivery": {
"surface": "active-tab",
"document_uri": CEPHAS_AI_READER_DOCUMENT_URI
}
})
}
fn fetch_page_for_reader(profile: &Profile, url: &Url) -> anyhow::Result<ReaderFetch> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.user_agent("Cephas/0.1 reader")
.build()?;
let mut current = url.clone();
for _ in 0..=READER_MAX_REDIRECTS {
let headers = reader_request_headers(profile, ¤t)?;
let mut response = client.get(current.clone()).headers(headers).send()?;
if response.status().is_redirection()
&& let Some(next) = reader_redirect_target(profile, response.url(), &response)?
{
current = next;
continue;
}
let mut body = Vec::new();
let cap = crate::browser::DEFAULT_MAX_PAGE_BODY_BYTES;
response
.by_ref()
.take((cap + 1) as u64)
.read_to_end(&mut body)
.with_context(|| format!("failed to read reader response body from {current}"))?;
let (body, truncated) = crate::browser::decode_limited_body_bytes(body, cap);
return Ok(ReaderFetch { body, truncated });
}
anyhow::bail!("too many redirects while building reader view from {url}")
}
fn reader_request_headers(
profile: &Profile,
url: &Url,
) -> anyhow::Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in profile.shields.request_headers(url) {
headers.insert(
reqwest::header::HeaderName::from_bytes(name.as_bytes())?,
reqwest::header::HeaderValue::from_static(value),
);
}
Ok(headers)
}
fn reader_redirect_target(
profile: &Profile,
base: &Url,
response: &reqwest::blocking::Response,
) -> anyhow::Result<Option<Url>> {
let Some(location) = response.headers().get(reqwest::header::LOCATION) else {
return Ok(None);
};
let location = location
.to_str()
.context("reader redirect location is not valid UTF-8")?;
sanitized_reader_redirect_target(profile, base, location).map(Some)
}
fn sanitized_reader_redirect_target(
profile: &Profile,
base: &Url,
location: &str,
) -> anyhow::Result<Url> {
let target = base
.join(location)
.with_context(|| format!("invalid reader redirect location {location}"))?;
let (target, _) = profile
.shields
.prepare_navigation_with_search(target.as_str(), profile.search_provider)?;
if !matches!(target.scheme(), "http" | "https") {
anyhow::bail!(
"reader redirect target uses unsupported scheme: {}",
target.scheme()
);
}
Ok(target)
}
fn reader_html(title: &str, byline: Option<&str>, text: &str) -> String {
let title = html_escape::encode_text(title);
let byline = byline
.map(html_escape::encode_text)
.map(|line| format!("<p class=\"byline\">{line}</p>"))
.unwrap_or_default();
let paragraphs = text
.split('\n')
.filter(|line| !line.trim().is_empty())
.map(|line| format!("<p>{}</p>", html_escape::encode_text(line.trim())))
.collect::<String>();
format!(
r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{title}</title><style>body{{margin:0;background:#0b1020;color:#e5eefc;font:20px/1.7 ui-serif,Georgia,serif}}main{{max-width:820px;margin:0 auto;padding:8vh 24px}}h1{{font:800 clamp(38px,6vw,74px)/.95 ui-sans-serif,system-ui,sans-serif;letter-spacing:-.06em}}.byline{{color:#93c5fd;font:700 14px ui-sans-serif,system-ui,sans-serif;text-transform:uppercase;letter-spacing:.16em}}p{{margin:1.1em 0}}</style></head><body><main><h1>{title}</h1>{byline}{paragraphs}</main></body></html>"#
)
}
fn agent_reader_html(title: &str, tool: AgentReaderTool, packet: &str) -> String {
let title = html_escape::encode_text(title);
let tool_label = html_escape::encode_text(tool.label());
let packet = html_escape::encode_text(packet);
format!(
r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agent Reader - {title}</title><style>body{{margin:0;background:#08111f;color:#dbeafe;font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}main{{max-width:1040px;margin:0 auto;padding:42px 22px 64px}}.eyebrow{{color:#7dd3fc;font:800 12px ui-sans-serif,system-ui,sans-serif;text-transform:uppercase;letter-spacing:.18em}}h1{{margin:.35em 0 .2em;color:#f8fafc;font:800 clamp(30px,5vw,56px)/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:-.055em}}.tool{{margin:0 0 24px;color:#93c5fd;font:800 14px ui-sans-serif,system-ui,sans-serif}}pre{{white-space:pre-wrap;word-break:break-word;margin:0;padding:22px;border:1px solid rgba(148,163,184,.24);border-radius:22px;background:rgba(15,23,42,.72);box-shadow:0 24px 70px rgba(0,0,0,.28)}}@media(max-width:720px){{main{{padding:28px 14px 48px}}pre{{padding:16px;border-radius:16px;font-size:13px}}}}</style></head><body><main><div class="eyebrow">Cephas Agent Reader</div><h1>{title}</h1><p class="tool">{tool_label}</p><pre>{packet}</pre></main></body></html>"#
)
}
fn short_uuid(id: uuid::Uuid) -> String {
id.to_string().chars().take(8).collect()
}
fn current_normal_url(webview: &WebView) -> Option<Url> {
let uri = webview.uri()?;
if is_internal_uri(&uri) {
return None;
}
Url::parse(&uri).ok()
}
fn clean_url_for_profile(profile: &Profile, input: &str) -> anyhow::Result<Url> {
profile.shields.clean_share_url(input)
}
fn prepare_navigation_for_profile(
profile: &Profile,
input: &str,
) -> anyhow::Result<(Url, PrivacyReport)> {
profile
.shields
.prepare_navigation_with_search(input, profile.search_provider)
}
fn copy_text(text: &str) {
let clipboard = gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD);
clipboard.set_text(text);
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn connect_find_controls(
find_entry: >k::Entry,
find_prev: >k::Button,
find_next: >k::Button,
find_close: >k::Button,
ui: &SharedUi,
) {
let ui_for_entry = Rc::clone(ui);
find_entry.connect_activate(move |_| find_in_active_tab(&ui_for_entry, false));
let ui_for_prev = Rc::clone(ui);
find_prev.connect_clicked(move |_| find_in_active_tab(&ui_for_prev, true));
let ui_for_next = Rc::clone(ui);
find_next.connect_clicked(move |_| find_in_active_tab(&ui_for_next, false));
let ui_for_close = Rc::clone(ui);
find_close.connect_clicked(move |_| hide_find_bar(&ui_for_close));
}
fn show_find_bar(ui: &SharedUi) {
let state = ui.borrow();
state.find_bar.show_all();
state.find_entry.grab_focus();
state.find_entry.select_region(0, -1);
}
fn hide_find_bar(ui: &SharedUi) {
let state = ui.borrow();
state.find_bar.hide();
if let Some(webview) = active_webview(ui)
&& let Some(controller) = webview.find_controller()
{
controller.search_finish();
}
}
fn find_in_active_tab(ui: &SharedUi, previous: bool) {
let query = ui.borrow().find_entry.text().to_string();
let Some(webview) = active_webview(ui) else {
return;
};
let Some(controller) = webview.find_controller() else {
return;
};
if query.trim().is_empty() {
controller.search_finish();
return;
}
let options = (FindOptions::CASE_INSENSITIVE | FindOptions::WRAP_AROUND).bits();
controller.search(&query, options, 1000);
if previous {
controller.search_previous();
} else {
controller.search_next();
}
ui.borrow().status.set_text("Find in page active");
}
fn zoom_active_tab(ui: &SharedUi, delta: f64) {
let Some(webview) = active_webview(ui) else {
return;
};
let next = (webview.zoom_level() + delta).clamp(0.5, 2.5);
webview.set_zoom_level(next);
sync_zoom_label(ui, &webview);
ui.borrow()
.status
.set_text(&format!("Layout zoom {:.0}%", next * 100.0));
}
fn set_active_zoom(ui: &SharedUi, zoom: f64) {
let Some(webview) = active_webview(ui) else {
return;
};
webview.set_zoom_level(zoom);
sync_zoom_label(ui, &webview);
ui.borrow()
.status
.set_text(&format!("Layout zoom {:.0}%", zoom * 100.0));
}
fn connect_new_tab(new_tab: >k::Button, ui: &SharedUi) {
let ui = Rc::clone(ui);
new_tab.connect_clicked(move |_| {
create_tab(&ui, StartTarget::Landing);
ui.borrow().status.set_text("New tab opened");
});
}
fn connect_status_bubble(status: >k::Label) {
let generation = Rc::new(Cell::new(0_u64));
let generation_for_notify = Rc::clone(&generation);
status.connect_label_notify(move |label| {
let next = generation_for_notify.get().saturating_add(1);
generation_for_notify.set(next);
if label.label().is_empty() {
label.set_opacity(0.0);
return;
}
label.set_opacity(1.0);
let label_for_timeout = label.downgrade();
let generation_for_timeout = Rc::clone(&generation_for_notify);
gtk::glib::timeout_add_seconds_local(4, move || {
if generation_for_timeout.get() == next
&& let Some(label_for_timeout) = label_for_timeout.upgrade()
{
label_for_timeout.set_opacity(0.0);
}
gtk::glib::ControlFlow::Break
});
});
}
fn connect_window_controls(
window: >k::Window,
tabbar: >k::Box,
minimize: >k::Button,
maximize: >k::Button,
close: >k::Button,
) {
let window_for_minimize = window.downgrade();
minimize.connect_clicked(move |_| {
if let Some(window) = window_for_minimize.upgrade() {
window.iconify();
}
});
let window_for_maximize = window.downgrade();
maximize.connect_clicked(move |_| {
if let Some(window) = window_for_maximize.upgrade() {
if window.is_maximized() {
window.unmaximize();
} else {
window.maximize();
}
}
});
let window_for_close = window.downgrade();
close.connect_clicked(move |_| {
if let Some(window) = window_for_close.upgrade() {
window.close();
}
});
tabbar.add_events(gtk::gdk::EventMask::BUTTON_PRESS_MASK);
let window_for_drag = window.downgrade();
tabbar.connect_button_press_event(move |_, event| {
if event.button() == 1 {
if let Some(window) = window_for_drag.upgrade() {
let (root_x, root_y) = event.root();
window.begin_move_drag(
event.button() as i32,
root_x.round() as i32,
root_y.round() as i32,
event.time(),
);
}
gtk::glib::Propagation::Stop
} else {
gtk::glib::Propagation::Proceed
}
});
}
fn connect_browser_menu(menu: >k::Button, ui: &SharedUi) {
let ui = Rc::clone(ui);
menu.connect_clicked(move |button| show_browser_menu(&ui, button));
}
fn show_browser_menu(ui: &SharedUi, anchor: >k::Button) {
let profile = ui.borrow().profile.clone();
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.style_context().add_class("bawn-menu-content");
content.set_margin_top(10);
content.set_margin_bottom(10);
let tabs = menu_group();
add_browser_menu_action(
&tabs,
"list-add-symbolic",
"New tab",
Some("Ctrl+T"),
ui,
|ui| {
create_tab(ui, StartTarget::Landing);
ui.borrow().status.set_text("New tab opened");
},
);
add_browser_menu_action(
&tabs,
"avatar-default-symbolic",
"New private tab",
Some("Ctrl+Shift+P"),
ui,
open_private_tab,
);
add_browser_menu_action(
&tabs,
"edit-undo-symbolic",
"Reopen closed tab",
Some("Ctrl+Shift+T"),
ui,
reopen_closed_tab,
);
add_browser_menu_action(
&tabs,
"edit-copy-symbolic",
"Duplicate tab",
Some("Ctrl+Shift+D"),
ui,
duplicate_active_tab,
);
content.pack_start(&tabs, false, false, 0);
add_menu_separator(&content);
let page = menu_group();
add_browser_menu_action(
&page,
"edit-find-symbolic",
"Find in page",
Some("Ctrl+F"),
ui,
show_find_bar,
);
add_browser_menu_action(
&page,
"text-x-generic-symbolic",
"Reader mode",
None,
ui,
load_reader_mode,
);
add_browser_menu_action(
&page,
"edit-copy-symbolic",
"Copy clean link",
None,
ui,
copy_active_clean_link,
);
add_browser_menu_action(
&page,
"user-bookmarks-symbolic",
"Bookmark page",
Some("Ctrl+D"),
ui,
bookmark_active_tab,
);
content.pack_start(&page, false, false, 0);
add_menu_separator(&content);
let view = menu_group();
let bookmarkbar_label = if ui.borrow().bookmarkbar.is_visible() {
"Hide bookmarks bar"
} else {
"Show bookmarks bar"
};
add_browser_menu_action(
&view,
"view-list-symbolic",
bookmarkbar_label,
Some("Ctrl+Shift+B"),
ui,
toggle_bookmarkbar,
);
add_browser_menu_action(
&view,
"emblem-favorite-symbolic",
"Pin or unpin tab",
None,
ui,
toggle_pin_active_tab,
);
add_zoom_menu_row(&view, ui);
let theme_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
theme_row.style_context().add_class("bawn-browser-menu-row");
let theme_icon = gtk::Image::from_icon_name(
Some("preferences-desktop-theme-symbolic"),
gtk::IconSize::Button,
);
theme_icon
.style_context()
.add_class("bawn-browser-menu-icon");
let theme_label = gtk::Label::new(Some("Theme"));
theme_label.set_xalign(0.0);
theme_label.set_hexpand(true);
theme_label
.style_context()
.add_class("bawn-browser-menu-label");
let theme_picker = gtk::ComboBoxText::new();
for theme in BrowserTheme::ALL {
theme_picker.append(Some(theme.id()), theme.label());
}
theme_picker.set_active_id(Some(profile.borrow().theme.id()));
theme_picker.style_context().add_class("bawn-theme-picker");
connect_theme_picker(&theme_picker, ui);
theme_row.pack_start(&theme_icon, false, false, 0);
theme_row.pack_start(&theme_label, true, true, 0);
theme_row.pack_start(&theme_picker, false, false, 0);
view.pack_start(&theme_row, false, false, 0);
content.pack_start(&view, false, false, 0);
add_menu_separator(&content);
let browser = menu_group();
add_browser_menu_action(
&browser,
"document-open-recent-symbolic",
"History",
Some("Ctrl+H"),
ui,
show_history_dialog,
);
add_browser_menu_action(
&browser,
"folder-download-symbolic",
"Downloads",
Some("Ctrl+J"),
ui,
show_downloads_dialog,
);
add_browser_menu_action(
&browser,
"applications-system-symbolic",
"Plugins",
None,
ui,
load_plugins_page,
);
add_browser_menu_action(
&browser,
"preferences-system-symbolic",
"Agent control panel",
None,
ui,
load_agent_page,
);
add_browser_menu_action(
&browser,
"applications-utilities-symbolic",
"More tools",
Some(">"),
ui,
show_diagnostics_dialog,
);
content.pack_start(&browser, false, false, 0);
add_menu_separator(&content);
let footer = menu_group();
add_browser_menu_action(
&footer,
"help-browser-symbolic",
"Help",
Some(">"),
ui,
show_diagnostics_dialog,
);
add_browser_menu_action(
&footer,
"emblem-system-symbolic",
"Settings",
None,
ui,
load_settings_page,
);
content.pack_start(&footer, false, false, 0);
show_chrome_popover(ui, anchor, &content, 420, 700);
}
fn start_recording(ui: &SharedUi) {
if ui.borrow().recorder.is_some() {
ui.borrow().status.set_text("Recording is already active");
return;
}
let title = {
let state = ui.borrow();
let active_title = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.map(current_tab_title)
.unwrap_or_else(|| "Cephas window".to_string());
format!("Cephas recording - {active_title}")
};
let recorder = match BrowserRecordingStore::new(None).and_then(|store| store.start(title)) {
Ok(recorder) => recorder,
Err(err) => {
ui.borrow()
.status
.set_text(&format!("Recording start failed: {err}"));
return;
}
};
let recording_id = recorder.recording_id();
let directory = recorder.directory().display().to_string();
{
let mut state = ui.borrow_mut();
state.recorder = Some(recorder);
state.recording_capture_pending = false;
state
.status
.set_text(&format!("Recording started: {}", short_uuid(recording_id)));
}
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"recording control: start",
None,
);
let _ = log_recording_agent_event(
ui,
"Browser visual recording started",
json!({
"recording_id": recording_id,
"directory": directory,
"format": "png-frames-jsonl-replay"
}),
);
schedule_recording_snapshots(ui);
capture_recording_frame(ui, "start");
}
fn stop_recording(ui: &SharedUi) {
if !capture_recording_frame_internal(ui, "stop", true) {
finish_recording_now(ui);
}
}
fn finish_recording_now(ui: &SharedUi) {
let (recorder, status) = {
let mut state = ui.borrow_mut();
state.recording_capture_pending = false;
(state.recorder.take(), state.status.clone())
};
let Some(recorder) = recorder else {
status.set_text("No active recording");
return;
};
match recorder.finish() {
Ok(finished) => {
status.set_text(&format!(
"Recording saved: {} frames, {} events - {}",
finished.frame_count,
finished.event_count,
finished.replay_path.display()
));
let _ = log_recording_finished(ui, &finished);
}
Err(err) => status.set_text(&format!("Recording finish failed: {err}")),
}
refresh_active_agent_page_if_open(ui);
}
fn finish_recording_for_shutdown(ui: &SharedUi) {
if ui.borrow().recorder.is_some() {
finish_recording_now(ui);
}
}
fn log_recording_finished(ui: &SharedUi, finished: &FinishedRecording) -> anyhow::Result<usize> {
log_recording_agent_event(
ui,
"Browser visual recording saved",
json!({
"recording_id": finished.recording_id,
"directory": finished.directory,
"manifest": finished.manifest_path,
"replay": finished.replay_path,
"event_count": finished.event_count,
"frame_count": finished.frame_count
}),
)
}
fn schedule_recording_snapshots(ui: &SharedUi) {
let ui_weak = Rc::downgrade(ui);
gtk::glib::timeout_add_seconds_local(2, move || {
let Some(ui) = ui_weak.upgrade() else {
return gtk::glib::ControlFlow::Break;
};
if ui.borrow().recorder.is_none() {
return gtk::glib::ControlFlow::Break;
}
if !capture_recording_frame_internal(&ui, "periodic", false)
&& ui
.borrow()
.recorder
.as_ref()
.is_some_and(|recorder| recorder.frame_limit_reached())
{
ui.borrow()
.status
.set_text("Recording frame limit reached; saving recording");
finish_recording_now(&ui);
return gtk::glib::ControlFlow::Break;
}
gtk::glib::ControlFlow::Continue
});
}
fn capture_recording_frame(ui: &SharedUi, reason: impl Into<String>) {
let _ = capture_recording_frame_internal(ui, reason, false);
}
fn schedule_recording_frame(ui: &SharedUi, reason: impl Into<String>, delay_ms: u64) {
if !recording_active(ui) {
return;
}
let ui_weak = Rc::downgrade(ui);
let reason = reason.into();
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
capture_recording_frame(&ui, reason);
});
}
fn capture_agent_recording_snapshot(ui: &SharedUi) {
if ui.borrow().recorder.is_none() {
ui.borrow().status.set_text("No active recording");
return;
}
capture_recording_frame(ui, "manual");
ui.borrow().status.set_text("Recording snapshot requested");
}
fn recording_active(ui: &SharedUi) -> bool {
ui.borrow().recorder.is_some()
}
fn capture_recording_frame_internal(
ui: &SharedUi,
reason: impl Into<String>,
finish_after: bool,
) -> bool {
let reason = reason.into();
let Some(request) = reserve_recording_frame(ui, &reason) else {
return false;
};
let RecordingFrameRequest {
webview,
pending,
tab_id,
url,
title,
} = request;
let ui_for_snapshot = Rc::clone(ui);
webview.snapshot(
SnapshotRegion::Visible,
SnapshotOptions::empty(),
None::<>k::gio::Cancellable>,
move |result| {
handle_recording_snapshot(
&ui_for_snapshot,
result,
pending,
tab_id,
url,
title,
finish_after,
);
},
);
true
}
fn reserve_recording_frame(ui: &SharedUi, reason: &str) -> Option<RecordingFrameRequest> {
let mut state = ui.borrow_mut();
if state.recorder.is_none() || state.recording_capture_pending {
return None;
}
let tab = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))?;
let webview = tab.webview.clone();
let tab_id = tab.id;
let url = webview.uri().and_then(|uri| Url::parse(&uri).ok());
let title = Some(current_tab_title(tab));
let pending = match state
.recorder
.as_mut()
.and_then(|recorder| recorder.reserve_frame(reason))
{
Some(frame) => frame,
None => {
if let Some(recorder) = state.recorder.as_mut() {
let _ = recorder.record_error(
"recording frame limit reached",
Some(tab_id),
url,
title,
Some(json!({ "reason": reason })),
);
}
return None;
}
};
state.recording_capture_pending = true;
Some(RecordingFrameRequest {
webview,
pending,
tab_id,
url,
title,
})
}
fn handle_recording_snapshot(
ui: &SharedUi,
result: Result<gtk::cairo::Surface, gtk::glib::Error>,
pending: PendingFrame,
tab_id: usize,
url: Option<Url>,
title: Option<String>,
finish_after: bool,
) {
let write_result = match result {
Ok(surface) => fs::File::create(pending.path())
.with_context(|| format!("failed to create frame {}", pending.path().display()))
.and_then(|mut file| {
surface
.write_to_png(&mut file)
.map_err(|err| anyhow::anyhow!("failed to encode PNG frame: {err}"))
}),
Err(err) => Err(anyhow::anyhow!("WebKit snapshot failed: {err}")),
};
{
let mut state = ui.borrow_mut();
state.recording_capture_pending = false;
if let Some(recorder) = state.recorder.as_mut() {
match write_result {
Ok(()) => {
let _ = recorder.record_frame(pending, Some(tab_id), url, title);
}
Err(err) => {
let _ = recorder.record_error(
"recording snapshot failed",
Some(tab_id),
url,
title,
Some(json!({
"error": err.to_string(),
"frame": pending.frame.file_name
})),
);
}
}
}
}
if finish_after {
finish_recording_now(ui);
}
}
fn record_browser_recording_event(
ui: &SharedUi,
kind: BrowserRecordingEventKind,
message: impl Into<String>,
details: Option<serde_json::Value>,
) {
if ui.borrow().recorder.is_none() {
return;
}
let (tab_id, url, title) = {
let state = ui.borrow();
state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.map(|tab| {
(
Some(tab.id),
tab.webview.uri().and_then(|uri| Url::parse(&uri).ok()),
Some(current_tab_title(tab)),
)
})
.unwrap_or((None, None, None))
};
if let Some(recorder) = ui.borrow_mut().recorder.as_mut() {
let _ = recorder.record_event(kind, message, tab_id, url, title, details);
}
}
fn log_recording_agent_event(
ui: &SharedUi,
message: impl Into<String>,
metadata: serde_json::Value,
) -> anyhow::Result<usize> {
let (profile, agents) = {
let state = ui.borrow();
(state.profile.clone(), state.agents.clone())
};
if !profile.borrow().ai_browsing_enabled {
return Ok(0);
}
let mut agents_ref = agents.borrow_mut();
let Some(control) = agents_ref.as_mut() else {
return Ok(0);
};
let agent_ids = control
.active_for_window(WindowId(1))
.into_iter()
.map(|delegation| delegation.agent_id)
.collect::<Vec<_>>();
if agent_ids.is_empty() {
return Ok(0);
}
let message = message.into();
let mut events = Vec::new();
for agent_id in agent_ids {
events.push(control.append_log(agent_id, message.clone(), Some(metadata.clone()))?);
}
drop(agents_ref);
AgentLogStore::new(None)?.append_all(&events)?;
Ok(events.len())
}
fn delegate_current_window_to_agent(ui: &SharedUi) {
let (profile, agents, status, task, metadata) = {
let state = ui.borrow();
(
state.profile.clone(),
state.agents.clone(),
state.status.clone(),
current_window_agent_task(&state),
current_window_agent_metadata(&state),
)
};
let ai_enabled = profile.borrow().ai_browsing_enabled;
let mut agents_ref = agents.borrow_mut();
let agents = agents_ref.get_or_insert_with(AgentControl::default);
let tasked = match agents.delegate_window(ai_enabled, WindowId(1), "cephas-window-agent", task)
{
Ok(event) => event,
Err(AgentControlError::AiModeDisabled) => {
status.set_text("Enable AI browsing on the start page before delegating an agent");
return;
}
Err(err) => {
status.set_text(&format!("Agent delegation failed: {err}"));
return;
}
};
let agent_id = tasked.agent_id;
let mut events = vec![tasked];
match agents.start_action(
agent_id,
"window_control_granted",
Some(
"AI browsing mode is on; this agent may operate the delegated Cephas window"
.to_string(),
),
) {
Ok(event) => events.push(event),
Err(err) => {
status.set_text(&format!("Agent activation failed: {err}"));
return;
}
}
match agents.append_log(
agent_id,
"Agent delegation active; events are JSONL for future Elixir backend delivery",
Some(metadata),
) {
Ok(event) => events.push(event),
Err(err) => {
status.set_text(&format!("Agent log failed: {err}"));
return;
}
}
drop(agents_ref);
let agent_log_store = match AgentLogStore::new(None) {
Ok(store) => store,
Err(err) => {
status.set_text(&format!(
"AI agent {} delegated, but log setup failed: {err}",
short_agent_id(agent_id)
));
return;
}
};
if let Err(err) = agent_log_store.append_all(&events) {
status.set_text(&format!(
"AI agent {} delegated, but JSONL write failed: {err}",
short_agent_id(agent_id)
));
return;
}
status.set_text(&format!(
"AI agent {} delegated to this window; JSONL audit appended",
short_agent_id(agent_id)
));
record_browser_recording_event(
ui,
BrowserRecordingEventKind::BrowserAction,
"agent delegated to window",
Some(json!({
"agent_id": agent_id,
"events": events.len()
})),
);
capture_recording_frame(ui, "agent-delegated");
}
fn current_window_agent_task(state: &BrowserUi) -> String {
let Some(tab) = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
else {
return "Handle Cephas window 1 with no active tab".to_string();
};
if tab.private {
"Handle Cephas window 1; active tab is private and page details must not be logged"
.to_string()
} else if let Some(uri) = tab.webview.uri() {
if is_agent_uri(&uri)
&& let Some(target_url) = &state.agent_target_url
{
let target_title = state.agent_target_title.as_deref().unwrap_or("target page");
return format!(
"Handle Cephas window 1 for target page '{target_title}' at {target_url}"
);
}
format!(
"Handle Cephas window 1 with active tab '{}' at {}",
current_tab_title(tab),
uri
)
} else {
format!(
"Handle Cephas window 1 with active tab '{}'",
current_tab_title(tab)
)
}
}
fn current_window_agent_metadata(state: &BrowserUi) -> serde_json::Value {
let active_tab = state
.active_id
.and_then(|id| state.tabs.iter().find(|tab| tab.id == id))
.map(|tab| {
if tab.private {
json!({
"id": tab.id,
"private": true
})
} else {
json!({
"id": tab.id,
"private": false,
"title": current_tab_title(tab),
"uri": tab.webview.uri().map(|uri| uri.to_string())
})
}
});
let reader_tools = AgentReaderTool::ALL
.into_iter()
.map(|tool| tool.id())
.collect::<Vec<_>>();
let agent_target = state.agent_target_url.as_ref().map(|url| {
json!({
"title": state.agent_target_title.as_deref(),
"uri": url.as_str()
})
});
let recording = agent_recording_context(state.recorder.as_ref());
let agent_vision = agent_vision_context(state.recorder.is_some());
json!({
"backend": {
"target": "elixir",
"delivery": "pending"
},
"capabilities": {
"agent_vision": true,
"agent_vision_schema": "cephas.agent-vision.v1",
"browser_recording_schema": crate::recording::BROWSER_RECORDING_SCHEMA,
"window_control": true,
"browser_window_human_control": true,
"tabs": true,
"navigation": true,
"visual_recording": true,
"reader_tools": reader_tools,
"reader_artifact_schema": crate::reader::AGENT_READER_SCHEMA,
"browser_commands": cached_agent_command_manifest()
},
"window": {
"id": 1,
"active_tab": active_tab,
"human_control_enabled": state.human_control_enabled,
"agent_page_target": agent_target
},
"recording": recording,
"agent_vision": agent_vision
})
}
fn agent_recording_context(recorder: Option<&BrowserRecorder>) -> serde_json::Value {
match recorder {
Some(recorder) => json!({
"active": true,
"recording_id": recorder.recording_id(),
"directory": recorder.directory().display().to_string(),
"events": {
"count": recorder.event_count(),
"max": recorder.max_events()
},
"frames": {
"count": recorder.frame_count(),
"max": recorder.max_frames()
},
"local_artifacts": ["recording.json", "events.jsonl", "frames/*.png", "index.html"]
}),
None => json!({
"active": false,
"events": {
"count": 0,
"max": crate::recording::DEFAULT_MAX_RECORDING_EVENTS
},
"frames": {
"count": 0,
"max": crate::recording::DEFAULT_MAX_RECORDING_FRAMES
},
"local_artifacts": ["recording.json", "events.jsonl", "frames/*.png", "index.html"]
}),
}
}
fn agent_vision_context(recording_active: bool) -> serde_json::Value {
json!({
"schema": "cephas.agent-vision.v1",
"source": "active WebKit visible region plus deterministic Agent context",
"recording_active": recording_active,
"recording_routes": {
"start": CEPHAS_AGENT_RECORDING_START_URI,
"snapshot": CEPHAS_AGENT_RECORDING_SNAPSHOT_URI,
"stop": CEPHAS_AGENT_RECORDING_STOP_URI
},
"command_emulation_route": format!("{CEPHAS_AGENT_EMULATE_URI}?command=<command-id>"),
"human_control_routes": [
"cephas://agent/human/enable",
"cephas://agent/human/move?x=<x>&y=<y>",
"cephas://agent/human/click?x=<x>&y=<y>",
"cephas://agent/human/type?text=<text>",
"cephas://agent/human/key?key=<key>",
"cephas://agent/human/scroll?dy=<dy>",
"cephas://agent/human/wait?ms=<ms>",
"cephas://agent/human/disable"
],
"reader_route": format!("{CEPHAS_AGENT_READER_URI}?tool=<tool-id>"),
"screenshot_cli": "target/debug/cephas agent-screenshot <target> --output <file.png> --json",
"frame_reason_links": [
"agent-emulation:<command-id>",
"human-control:<action>",
"agent-delegated",
"manual",
"periodic",
"load-finished"
],
"backend_delivery": {
"status": "local-only until authenticated backend sync is enabled",
"contract": "elixir_backend.MD",
"eligible_when": ["authenticated", "sync_recordings", "non_private_frame"]
}
})
}
fn short_agent_id(agent_id: AgentId) -> String {
agent_id.to_string().chars().take(8).collect()
}
fn menu_group() -> gtk::Box {
let group = gtk::Box::new(gtk::Orientation::Vertical, 0);
group.style_context().add_class("bawn-browser-menu-group");
group
}
fn add_menu_separator(content: >k::Box) {
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
separator
.style_context()
.add_class("bawn-browser-menu-separator");
content.pack_start(&separator, false, false, 0);
}
fn add_browser_menu_action(
group: >k::Box,
icon_name: &str,
label: &str,
shortcut: Option<&str>,
ui: &SharedUi,
action: impl Fn(&SharedUi) + 'static,
) {
let button = gtk::Button::new();
button.set_hexpand(true);
button.style_context().add_class("bawn-browser-menu-action");
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
let icon = gtk::Image::from_icon_name(Some(icon_name), gtk::IconSize::Button);
icon.style_context().add_class("bawn-browser-menu-icon");
let text = gtk::Label::new(Some(label));
text.set_xalign(0.0);
text.set_hexpand(true);
text.style_context().add_class("bawn-browser-menu-label");
let shortcut = gtk::Label::new(shortcut);
shortcut
.style_context()
.add_class("bawn-browser-menu-shortcut");
row.pack_start(&icon, false, false, 0);
row.pack_start(&text, true, true, 0);
row.pack_start(&shortcut, false, false, 0);
button.add(&row);
let ui = Rc::clone(ui);
button.connect_clicked(move |_| {
close_active_popover(&ui);
action(&ui);
});
group.pack_start(&button, false, false, 0);
}
fn add_zoom_menu_row(group: >k::Box, ui: &SharedUi) {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
row.style_context().add_class("bawn-browser-menu-row");
let icon = gtk::Image::from_icon_name(Some("zoom-in-symbolic"), gtk::IconSize::Button);
icon.style_context().add_class("bawn-browser-menu-icon");
let label = gtk::Label::new(Some("Zoom"));
label.set_xalign(0.0);
label.set_hexpand(true);
label.style_context().add_class("bawn-browser-menu-label");
let minus = gtk::Button::with_label("-");
let reset = gtk::Button::with_label(&active_zoom_text(ui));
let plus = gtk::Button::with_label("+");
for button in [&minus, &reset, &plus] {
button.style_context().add_class("bawn-browser-menu-mini");
}
let ui_for_minus = Rc::clone(ui);
let reset_for_minus = reset.clone();
minus.connect_clicked(move |_| {
zoom_active_tab(&ui_for_minus, -0.1);
reset_for_minus.set_label(&active_zoom_text(&ui_for_minus));
});
let ui_for_reset = Rc::clone(ui);
let reset_for_reset = reset.clone();
reset.connect_clicked(move |_| {
set_active_zoom(&ui_for_reset, 1.0);
reset_for_reset.set_label(&active_zoom_text(&ui_for_reset));
});
let ui_for_plus = Rc::clone(ui);
let reset_for_plus = reset.clone();
plus.connect_clicked(move |_| {
zoom_active_tab(&ui_for_plus, 0.1);
reset_for_plus.set_label(&active_zoom_text(&ui_for_plus));
});
row.pack_start(&icon, false, false, 0);
row.pack_start(&label, true, true, 0);
row.pack_start(&minus, false, false, 0);
row.pack_start(&reset, false, false, 0);
row.pack_start(&plus, false, false, 0);
group.pack_start(&row, false, false, 0);
}
fn active_zoom_text(ui: &SharedUi) -> String {
active_webview(ui)
.map(|webview| format!("{:.0}%", webview.zoom_level() * 100.0))
.unwrap_or_else(|| "100%".to_string())
}
fn duplicate_active_tab(ui: &SharedUi) {
let Some(snapshot) = active_tab_snapshot(ui) else {
ui.borrow().status.set_text("No tab to duplicate");
return;
};
if !can_create_tab(ui) {
return;
}
create_tab_with_options(
ui,
snapshot.target,
TabOptions {
pinned: snapshot.pinned,
private: snapshot.private,
title: Some(snapshot.title),
zoom: snapshot.zoom,
},
);
ui.borrow().status.set_text("Tab duplicated");
}
fn reopen_closed_tab(ui: &SharedUi) {
if ui.borrow().closed_tabs.is_empty() {
ui.borrow().status.set_text("No closed tab to reopen");
return;
}
if !can_create_tab(ui) {
return;
}
let snapshot = ui
.borrow_mut()
.closed_tabs
.pop()
.expect("closed tab checked before pop");
create_tab_with_options(
ui,
snapshot.target,
TabOptions {
pinned: snapshot.pinned,
private: false,
title: Some(snapshot.title),
zoom: snapshot.zoom,
},
);
ui.borrow().status.set_text("Closed tab reopened");
}
fn toggle_pin_active_tab(ui: &SharedUi) {
let update = {
let mut state = ui.borrow_mut();
let Some(id) = state.active_id else {
return;
};
let Some(tab) = state.tabs.iter_mut().find(|tab| tab.id == id) else {
return;
};
tab.pinned = !tab.pinned;
apply_tab_flags(&tab.tab_item, tab.pinned, tab.private);
let title = current_tab_title(tab);
Some((id, title, tab.pinned))
};
let Some((id, title, pinned)) = update else {
ui.borrow().status.set_text("No tab to pin");
return;
};
set_tab_title(ui, id, &title);
ui.borrow()
.status
.set_text(if pinned { "Tab pinned" } else { "Tab unpinned" });
schedule_session_save(ui);
}
fn open_private_tab(ui: &SharedUi) {
create_tab_with_options(
ui,
StartTarget::Landing,
TabOptions {
private: true,
title: Some("Private".to_string()),
..TabOptions::default()
},
);
ui.borrow()
.status
.set_text("Private tab opened - history and session restore disabled");
}
fn connect_theme_picker(theme_picker: >k::ComboBoxText, ui: &SharedUi) {
let ui = Rc::clone(ui);
theme_picker.connect_changed(move |picker| {
let Some(id) = picker.active_id() else {
return;
};
let Some(theme) = BrowserTheme::from_id(&id) else {
return;
};
let status = ui.borrow().status.clone();
match apply_browser_theme(&ui, theme) {
Ok(()) => status.set_text(&format!("Theme: {}", theme.label())),
Err(err) => status.set_text(&format!("Theme changed, but save failed: {err}")),
}
});
}
fn apply_browser_theme(ui: &SharedUi, theme: BrowserTheme) -> anyhow::Result<()> {
let (root, custom_css_provider, webviews, profile) = {
let state = ui.borrow();
(
state.root.clone(),
state.custom_css_provider.clone(),
state
.tabs
.iter()
.map(|tab| tab.webview.clone())
.collect::<Vec<_>>(),
state.profile.clone(),
)
};
apply_theme(&root, theme);
let profile_snapshot = {
let mut profile = profile.borrow_mut();
profile.theme = theme;
profile.clone()
};
update_custom_css(&custom_css_provider, &profile_snapshot)?;
for webview in &webviews {
reload_internal_webview(webview, &profile_snapshot);
}
save_profile_sync(ui, &profile_snapshot)
}
fn reload_internal_webview(webview: &WebView, profile: &Profile) {
let Some(uri) = webview.uri().map(|uri| uri.to_string()) else {
load_landing(webview, profile);
return;
};
if is_settings_uri(&uri) {
load_settings_page_in(webview, profile);
} else if is_plugins_uri(&uri) {
load_plugins_page_in(webview, profile);
} else if is_start_uri(&uri) || uri.starts_with("cephas://") {
load_landing(webview, profile);
}
}
fn connect_shortcuts(window: >k::Window, ui: &SharedUi) {
let ui = Rc::clone(ui);
window.connect_key_press_event(move |_, event| {
let state = event.state();
if !state.contains(gtk::gdk::ModifierType::CONTROL_MASK) {
return gtk::glib::Propagation::Proceed;
}
let shift = state.contains(gtk::gdk::ModifierType::SHIFT_MASK);
match event.keyval() {
key if key == gtk::gdk::keys::constants::t => {
if shift {
reopen_closed_tab(&ui);
} else {
create_tab(&ui, StartTarget::Landing);
}
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::w => {
if let Some(id) = ui.borrow().active_id {
close_tab(&ui, id);
}
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::l => {
let entry = ui.borrow().entry.clone();
entry.grab_focus();
entry.select_region(0, -1);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::f => {
show_find_bar(&ui);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::d => {
if shift {
duplicate_active_tab(&ui);
} else {
bookmark_active_tab(&ui);
}
gtk::glib::Propagation::Stop
}
key if shift && key == gtk::gdk::keys::constants::p => {
open_private_tab(&ui);
gtk::glib::Propagation::Stop
}
key if shift
&& (key == gtk::gdk::keys::constants::b || key == gtk::gdk::keys::constants::B) =>
{
toggle_bookmarkbar(&ui);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::h => {
show_history_dialog(&ui);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::j => {
show_downloads_dialog(&ui);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::plus
|| key == gtk::gdk::keys::constants::equal
|| key == gtk::gdk::keys::constants::KP_Add =>
{
zoom_active_tab(&ui, 0.1);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::minus
|| key == gtk::gdk::keys::constants::KP_Subtract =>
{
zoom_active_tab(&ui, -0.1);
gtk::glib::Propagation::Stop
}
key if key == gtk::gdk::keys::constants::_0 => {
set_active_zoom(&ui, 1.0);
gtk::glib::Propagation::Stop
}
_ => gtk::glib::Propagation::Proceed,
}
});
}
fn plugin_capabilities_label(capabilities: &[PluginCapability]) -> String {
if capabilities.is_empty() {
return "none".to_string();
}
capabilities
.iter()
.map(|capability| capability.label())
.collect::<Vec<_>>()
.join(", ")
}
fn plugin_permissions_label(permissions: &[PluginPermission]) -> String {
if permissions.is_empty() {
return "none".to_string();
}
permissions
.iter()
.map(|permission| permission.label())
.collect::<Vec<_>>()
.join(", ")
}
fn connect_policy(webview: &WebView, tab_id: usize, ui: &SharedUi) {
let ui = Rc::clone(ui);
webview.connect_decide_policy(move |webview, decision, decision_type| {
if !matches!(
decision_type,
PolicyDecisionType::NavigationAction | PolicyDecisionType::NewWindowAction
) {
return false;
}
let Some(navigation) = decision.downcast_ref::<NavigationPolicyDecision>() else {
return false;
};
let Some(request) = navigation
.navigation_action()
.and_then(|action| action.request())
else {
return false;
};
let Some(uri) = request.uri() else {
return false;
};
if handle_internal_navigation(&ui, webview, decision, &uri) {
return true;
}
if is_internal_uri(&uri) {
decision.ignore();
if is_active(&ui, tab_id) {
ui.borrow()
.status
.set_text("Unknown Cephas internal route blocked");
}
return true;
}
let profile = ui.borrow().profile.clone();
let profile = profile.borrow();
let Ok((clean_url, report)) = prepare_navigation_for_profile(&profile, &uri) else {
return false;
};
if clean_url.as_str() == uri.as_str() {
return false;
}
decision.ignore();
if is_active(&ui, tab_id) {
ui.borrow().status.set_text(&privacy_summary(&report));
}
webview.load_uri(clean_url.as_str());
true
});
}
fn connect_armour_resource_headers(webview: &WebView, ui: &SharedUi) {
let profile = ui.borrow().profile.clone();
webview.connect_resource_load_started(move |webview, _, request| {
let page_uri = webview.uri();
apply_armour_to_webkit_request(&profile.borrow(), page_uri.as_deref(), request);
});
}
fn connect_armour_resource_headers_for_profile(webview: &WebView, profile: Profile) {
webview.connect_resource_load_started(move |webview, _, request| {
let page_uri = webview.uri();
apply_armour_to_webkit_request(&profile, page_uri.as_deref(), request);
});
}
fn apply_armour_to_webkit_request(profile: &Profile, page_uri: Option<&str>, request: &URIRequest) {
let Some(uri) = request.uri() else {
return;
};
let Ok(url) = Url::parse(uri.as_str()) else {
return;
};
if is_internal_url(&url) {
return;
}
if should_block_webkit_request(profile, page_uri, &url) {
request.set_uri("data:,");
return;
}
let Some(request_headers) = request.http_headers() else {
return;
};
profile
.shields
.for_each_privacy_header(&url, |name, value| {
request_headers.replace(name, value);
});
}
fn should_block_webkit_request(
profile: &Profile,
page_uri: Option<&str>,
request_url: &Url,
) -> bool {
let Some(page_uri) = page_uri else {
return false;
};
let Ok(page_url) = Url::parse(page_uri) else {
return false;
};
if is_internal_url(&page_url)
|| page_url.host_str().is_some_and(|host| {
request_url
.host_str()
.is_some_and(|request_host| request_host == host)
})
{
return false;
}
profile.shields.should_block_request(&page_url, request_url)
}
#[cfg(test)]
fn armour_webkit_headers_for_uri(
profile: &Profile,
uri: &str,
) -> Vec<(&'static str, &'static str)> {
let Ok(url) = Url::parse(uri) else {
return Vec::new();
};
if is_internal_url(&url) {
return Vec::new();
}
profile.shields.privacy_headers(&url)
}
fn connect_fast_context_menu(webview: &WebView) {
webview.connect_context_menu(|_, menu, _, hit_test| {
prune_context_menu(menu, hit_test, developer_extras_enabled());
false
});
}
fn prune_context_menu(menu: &ContextMenu, hit_test: &HitTestResult, developer_extras: bool) {
let editable = hit_test.context_is_editable();
for item in menu.items() {
if should_prune_context_menu_action(item.stock_action(), editable, developer_extras) {
menu.remove(&item);
}
}
compact_context_menu_separators(menu);
}
fn should_prune_context_menu_action(
action: ContextMenuAction,
editable: bool,
developer_extras: bool,
) -> bool {
match action {
ContextMenuAction::NoAction
| ContextMenuAction::InputMethods
| ContextMenuAction::Unicode
| ContextMenuAction::FontMenu
| ContextMenuAction::Outline => true,
ContextMenuAction::InspectElement => !developer_extras,
ContextMenuAction::Bold
| ContextMenuAction::Italic
| ContextMenuAction::Underline
| ContextMenuAction::InsertEmoji
| ContextMenuAction::PasteAsPlainText
| ContextMenuAction::SpellingGuess
| ContextMenuAction::NoGuessesFound
| ContextMenuAction::IgnoreSpelling
| ContextMenuAction::LearnSpelling
| ContextMenuAction::IgnoreGrammar => !editable,
_ => false,
}
}
fn compact_context_menu_separators(menu: &ContextMenu) {
let mut previous_was_separator = true;
for item in menu.items() {
if item.is_separator() {
if previous_was_separator {
menu.remove(&item);
}
previous_was_separator = true;
} else {
previous_was_separator = false;
}
}
if let Some(item) = menu.last()
&& item.is_separator()
{
menu.remove(&item);
}
}
fn handle_internal_navigation(
ui: &SharedUi,
webview: &WebView,
decision: &webkit2gtk::PolicyDecision,
uri: &str,
) -> bool {
if !could_be_internal_uri(uri) {
return false;
}
let Ok(parsed_uri) = Url::parse(uri) else {
return false;
};
if is_mutating_internal_action_url(&parsed_uri)
&& !webview
.uri()
.as_deref()
.is_some_and(is_trusted_internal_source_uri)
{
decision.ignore();
ui.borrow()
.status
.set_text("Blocked internal action from untrusted page");
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_APPLY_URI) {
decision.ignore();
save_settings_from_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_STARTUP_URI) {
decision.ignore();
save_startup_from_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI) {
decision.ignore();
apply_custom_theme_preset_from_uri(ui, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_THEME_URI) {
decision.ignore();
apply_theme_from_uri(ui, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_SETTINGS_CUSTOM_THEME_URI) {
decision.ignore();
save_custom_theme_from_uri(ui, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_START_THEME_URI) {
decision.ignore();
cycle_start_page_theme(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AI_TOGGLE_URI) {
decision.ignore();
set_ai_browsing_from_uri(ui, webview, uri);
return true;
}
if route_under_url(&parsed_uri, CEPHAS_AGENT_HUMAN_URI) {
decision.ignore();
handle_agent_human_control_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_EMULATE_URI) {
decision.ignore();
handle_agent_emulation_uri(ui, webview, uri);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_DELEGATE_URI) {
decision.ignore();
delegate_current_window_to_agent(ui);
load_agent_page_in(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_RECORDING_START_URI) {
decision.ignore();
start_recording(ui);
load_agent_page_in(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_RECORDING_STOP_URI) {
decision.ignore();
stop_recording(ui);
load_agent_page_in(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_RECORDING_SNAPSHOT_URI) {
decision.ignore();
capture_agent_recording_snapshot(ui);
load_agent_page_in(ui, webview);
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_AGENT_READER_URI) {
decision.ignore();
load_agent_reader_tool_from_uri(ui, uri);
return true;
}
if is_agent_docs_url(&parsed_uri) {
decision.ignore();
load_agent_docs_page_in(ui, webview);
return true;
}
if is_diagnostics_url(&parsed_uri) {
decision.ignore();
load_diagnostics_page_in(ui, webview);
ui.borrow().status.set_text("Diagnostics opened");
return true;
}
if route_matches_url(&parsed_uri, CEPHAS_PLUGINS_INSTALL_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_INSTALL_ALL_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_SYNC_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_SYNC_ALL_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_ENABLE_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_DISABLE_URI)
|| route_matches_url(&parsed_uri, CEPHAS_PLUGINS_REMOVE_URI)
{
decision.ignore();
handle_plugin_action_uri(ui, webview, uri);
return true;
}
if is_plugins_url(&parsed_uri) {
decision.ignore();
let profile = ui.borrow().profile.clone();
load_plugins_page_in(webview, &profile.borrow());
ui.borrow().status.set_text("Plugins opened");
return true;
}
if is_start_url(&parsed_uri) {
decision.ignore();
let profile = ui.borrow().profile.clone();
load_landing(webview, &profile.borrow());
ui.borrow().status.set_text("Start page");
return true;
}
if is_settings_url(&parsed_uri) {
decision.ignore();
let profile = ui.borrow().profile.clone();
load_settings_page_in(webview, &profile.borrow());
ui.borrow().status.set_text("Settings opened");
return true;
}
if is_agent_url(&parsed_uri) {
decision.ignore();
remember_agent_target_from_webview(ui, webview);
load_agent_page_in(ui, webview);
return true;
}
false
}
fn connect_page_events(webview: &WebView, tab_id: usize, ui: &SharedUi) {
let ui_for_load = Rc::clone(ui);
webview.connect_load_changed(move |webview, event| match event {
LoadEvent::Started => {
if is_active(&ui_for_load, tab_id) {
set_load_progress(&ui_for_load, 0.05, true);
set_label_text_if_changed(&ui_for_load.borrow().status, "Loading...");
}
}
LoadEvent::Committed => {
if is_active(&ui_for_load, tab_id) {
sync_location_from_webview(&ui_for_load, webview);
}
}
LoadEvent::Finished => finish_tab_load(&ui_for_load, tab_id, webview),
_ => {}
});
let ui_for_title = Rc::clone(ui);
webview.connect_title_notify(move |webview| {
let title = webview
.title()
.map(|title| title.to_string())
.unwrap_or_else(|| "Start".to_string());
set_tab_title(&ui_for_title, tab_id, &title);
if is_active(&ui_for_title, tab_id) {
sync_window_title(&ui_for_title, webview);
}
});
let ui_for_progress = Rc::clone(ui);
webview.connect_estimated_load_progress_notify(move |webview| {
if !is_active(&ui_for_progress, tab_id) {
return;
}
let value = webview.estimated_load_progress();
if value < 1.0 {
set_load_progress(&ui_for_progress, value.max(0.05), false);
}
});
}
fn set_load_progress(ui: &SharedUi, value: f64, force: bool) {
let value = value.clamp(0.0, 1.0);
let mut state = ui.borrow_mut();
let value = if force {
value
} else {
value.max(state.progress_fraction)
};
if !force && (value - state.progress_fraction).abs() < 0.035 && value < 0.99 {
return;
}
state.progress_generation = state.progress_generation.saturating_add(1);
state.progress_fraction = value;
state
.progress
.style_context()
.remove_class("bawn-progress-complete");
state.progress.show();
state.progress.set_fraction(value);
}
fn complete_load_progress(ui: &SharedUi) {
let generation = {
let mut state = ui.borrow_mut();
state.progress_generation = state.progress_generation.saturating_add(1);
state.progress_fraction = 1.0;
state.progress.show();
state.progress.set_fraction(1.0);
state
.progress
.style_context()
.add_class("bawn-progress-complete");
state.progress_generation
};
let ui = Rc::downgrade(ui);
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(180), move || {
let Some(ui) = ui.upgrade() else {
return;
};
let state = ui.borrow_mut();
if state.progress_generation == generation {
state.progress.hide();
state
.progress
.style_context()
.remove_class("bawn-progress-complete");
}
});
}
fn hide_load_progress_now(ui: &SharedUi) {
let mut state = ui.borrow_mut();
state.progress_generation = state.progress_generation.saturating_add(1);
state.progress_fraction = 0.0;
state.progress.hide();
state
.progress
.style_context()
.remove_class("bawn-progress-complete");
}
fn finish_tab_load(ui: &SharedUi, tab_id: usize, webview: &WebView) {
let uri = webview.uri().map(|uri| uri.to_string()).unwrap_or_default();
let is_internal = is_internal_uri(&uri);
let is_start = is_start_uri(&uri);
let is_private = tab_is_private(ui, tab_id);
let title = webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| {
if is_start {
"Start".to_string()
} else if is_settings_uri(&uri) {
"Settings".to_string()
} else if is_plugins_uri(&uri) {
"Plugins".to_string()
} else if is_agent_uri(&uri) {
"Agent control".to_string()
} else {
uri.clone()
}
});
set_tab_title(ui, tab_id, &title);
if is_active(ui, tab_id) {
complete_load_progress(ui);
let state = ui.borrow();
if is_start {
set_entry_text_if_changed(&state.entry, "");
set_label_text_if_changed(&state.status, "Start page ready");
} else if is_agent_uri(&uri) {
set_entry_text_if_changed(&state.entry, &uri);
set_label_text_if_changed(&state.status, "Agent control panel ready");
} else if is_plugins_uri(&uri) {
set_entry_text_if_changed(&state.entry, &uri);
set_label_text_if_changed(&state.status, "Plugins ready");
} else if is_internal {
set_entry_text_if_changed(&state.entry, &uri);
set_label_text_if_changed(&state.status, "Settings ready");
} else {
set_entry_text_if_changed(&state.entry, &uri);
let shield_state = if is_private {
"Private"
} else {
Url::parse(&uri)
.ok()
.map(|url| {
if state.profile.borrow().shields.is_enabled_for(&url) {
"Armour on"
} else {
"Armour off for this site"
}
})
.unwrap_or("Loaded")
};
set_label_text_if_changed(&state.status, &format!("Loaded - {shield_state}"));
}
}
if recording_active(ui) {
record_browser_recording_event(
ui,
BrowserRecordingEventKind::Navigation,
"page load finished",
Some(json!({
"tab_id": tab_id,
"internal": is_internal,
"private": is_private,
"uri": uri.clone(),
"title": title.clone()
})),
);
capture_recording_frame(ui, "load-finished");
}
if !is_internal
&& !is_private
&& let Ok(url) = Url::parse(&uri)
{
let profile = {
let state = ui.borrow();
state.profile.clone()
};
let mut profile = profile.borrow_mut();
profile.record_visit(title, url);
drop(profile);
schedule_profile_save(ui);
}
schedule_session_save(ui);
}
fn persist_session(ui: &SharedUi) -> anyhow::Result<()> {
let (session_store, session) = {
let state = ui.borrow();
(state.store.session_store(), snapshot_session(&state))
};
session_store.save(&session)
}
fn schedule_session_save(ui: &SharedUi) {
if !mark_session_save_pending_and_bump(ui) {
return;
}
let ui_weak = Rc::downgrade(ui);
gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(SAVE_DEBOUNCE_MS),
move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (
path,
session,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
) = {
let mut state = ui.borrow_mut();
state.session_save_pending = false;
let session_store = state.store.session_store();
let path = session_store.path().to_path_buf();
let mut session = snapshot_session(&state);
session.enforce_limits();
let generation = state.session_save_generation.clone();
let expected_generation = generation.load(Ordering::Acquire);
let save_lock = state.save_lock.clone();
let save_sender = state.save_sender.clone();
let save_queue_depth = state.save_queue_depth.clone();
(
path,
session,
generation,
expected_generation,
save_lock,
save_sender,
save_queue_depth,
)
};
enqueue_save_job(
&save_sender,
&save_lock,
&save_queue_depth,
SaveJob::Session {
path,
value: Box::new(session),
generation,
expected_generation,
},
);
},
);
}
fn mark_session_save_pending_and_bump(ui: &SharedUi) -> bool {
let mut state = ui.borrow_mut();
state.session_save_generation.fetch_add(1, Ordering::AcqRel);
if state.session_save_pending {
false
} else {
state.session_save_pending = true;
true
}
}
fn snapshot_session(state: &BrowserUi) -> BrowserSession {
let tabs = state
.tabs
.iter()
.take(MAX_OPEN_TABS)
.filter_map(snapshot_tab)
.collect::<Vec<_>>();
if tabs.is_empty() {
return BrowserSession::default();
}
let active_tab = state
.active_id
.map(|id| TabId(id as u64))
.filter(|active| tabs.iter().any(|tab| tab.id == *active))
.or_else(|| tabs.first().map(|tab| tab.id));
BrowserSession {
windows: vec![SessionWindow {
id: WindowId(1),
active_tab,
tabs,
}],
}
}
fn snapshot_tab(tab: &Tab) -> Option<SessionTab> {
if tab.private {
return None;
}
let uri = tab.webview.uri().map(|uri| uri.to_string());
let url = match uri.as_deref() {
Some(uri) if is_internal_uri(uri) => None,
Some(uri) => Some(Url::parse(uri).ok()?),
None => None,
};
let title = tab
.webview
.title()
.map(|title| title.to_string())
.filter(|title| !title.trim().is_empty())
.or_else(|| url.as_ref().map(ToString::to_string))
.unwrap_or_else(|| "Start".to_string());
Some(SessionTab {
id: TabId(tab.id as u64),
url,
title,
pinned: tab.pinned,
muted: false,
private: false,
zoom: tab.webview.zoom_level(),
})
}
fn navigate(webview: &WebView, profile: &Profile, status: >k::Label, input: &str) {
match prepare_navigation_for_profile(profile, input) {
Ok((url, report)) => {
if is_pdf_navigation(&url) {
status.set_text("Opening PDF in WebKit viewer - Ctrl +/- uses layout zoom");
} else {
status.set_text(&privacy_summary(&report));
}
webview.load_uri(url.as_str());
}
Err(err) => status.set_text(&format!("Navigation failed: {err}")),
}
}
fn is_pdf_navigation(url: &Url) -> bool {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.is_some_and(|segment| segment.to_ascii_lowercase().ends_with(".pdf"))
}
fn privacy_summary(report: &PrivacyReport) -> String {
let mut actions = Vec::new();
if report.upgraded_to_https {
actions.push("upgraded HTTPS".to_string());
}
if !report.stripped_query_params.is_empty() {
actions.push(format!(
"stripped {} tracking params",
report.stripped_query_params.len()
));
}
if report.debounced_redirect.is_some() {
actions.push("bypassed redirector".to_string());
}
if actions.is_empty() {
"Navigating".to_string()
} else {
format!("Navigating - {}", actions.join(", "))
}
}
fn landing_html(profile: &Profile) -> String {
let theme = profile.theme;
let next_theme = next_browser_theme(theme);
let custom_css = custom_theme_page_css(&profile.custom_theme);
let shortcuts = landing_shortcuts(profile, 7);
let search_action =
html_escape::encode_double_quoted_attribute(profile.search_provider.search_action())
.to_string();
let search_query_name =
html_escape::encode_double_quoted_attribute(profile.search_provider.query_parameter())
.to_string();
let search_label =
html_escape::encode_double_quoted_attribute(profile.search_provider.label()).to_string();
let privacy_search_url = profile
.search_provider
.search_url("privacy tools")
.map(|url| html_escape::encode_double_quoted_attribute(url.as_str()).to_string())
.unwrap_or_else(|_| "https://www.google.com/search?q=privacy+tools".to_string());
let bookmark_count = profile.bookmarks.len().to_string();
let history_count = profile.history.len().to_string();
let plugin_count = profile.plugins.installed_count().to_string();
let ai_class = if profile.ai_browsing_enabled {
"on"
} else {
"off"
};
let ai_checked = if profile.ai_browsing_enabled {
"true"
} else {
"false"
};
let ai_next = if profile.ai_browsing_enabled {
"0"
} else {
"1"
};
let ai_state = if profile.ai_browsing_enabled {
"On"
} else {
"Off"
};
render_template(
LANDING_TEMPLATE,
&[
("__THEME_ID__", theme.id()),
("__THEME_LABEL__", theme.label()),
("__NEXT_THEME_ID__", next_theme.id()),
("__NEXT_THEME_LABEL__", next_theme.label()),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__AI_BROWSING_CLASS__", ai_class),
("__AI_BROWSING_CHECKED__", ai_checked),
("__AI_BROWSING_NEXT__", ai_next),
("__AI_BROWSING_STATE__", ai_state),
("__SHORTCUTS__", &shortcuts),
("__SEARCH_ACTION__", &search_action),
("__SEARCH_QUERY_NAME__", &search_query_name),
("__SEARCH_LABEL__", &search_label),
("__PRIVACY_SEARCH_URL__", &privacy_search_url),
("__BOOKMARK_COUNT__", &bookmark_count),
("__HISTORY_COUNT__", &history_count),
("__ADDON_COUNT__", &plugin_count),
],
)
}
fn landing_shortcuts(profile: &Profile, limit: usize) -> String {
landing_links(profile, limit)
.into_iter()
.map(|(title, url)| {
let label = compact_shortcut_label(&title);
let initial = shortcut_initial(&title, &url);
let href = html_escape::encode_double_quoted_attribute(&url);
let title = html_escape::encode_text(&label);
let initial = html_escape::encode_text(&initial);
format!(
r#"<a class="site-tile" href="{href}"><span class="site-icon">{initial}</span><span class="site-label">{title}</span></a>"#
)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn landing_links(profile: &Profile, limit: usize) -> Vec<(String, String)> {
if limit == 0 {
return Vec::new();
}
let mut links =
Vec::with_capacity(limit.min(profile.bookmarks.len() + default_shortcuts().len()));
for bookmark in profile.bookmarks.iter().rev() {
push_unique_link(
&mut links,
bookmark.title.clone(),
bookmark.url.to_string(),
limit,
);
if links.len() >= limit {
return links;
}
}
for (title, url) in default_shortcuts() {
push_unique_link(&mut links, title.to_string(), url.to_string(), limit);
if links.len() >= limit {
break;
}
}
links
}
fn push_unique_link(links: &mut Vec<(String, String)>, title: String, url: String, limit: usize) {
if links.len() >= limit || links.iter().any(|(_, existing)| existing == &url) {
return;
}
links.push((title, url));
}
fn default_shortcuts() -> [(&'static str, &'static str); 7] {
[
("Google", "https://www.google.com/"),
("Gmail", "https://mail.google.com/"),
("News", "https://news.google.com/"),
("YouTube", "https://www.youtube.com/"),
("Maps", "https://maps.google.com/"),
("GitHub", "https://github.com/"),
("crates.io", "https://crates.io/"),
]
}
fn compact_shortcut_label(title: &str) -> String {
let title = title.trim();
let title = if title.is_empty() { "Site" } else { title };
let max = 14;
let mut out = String::new();
for (index, ch) in title.chars().enumerate() {
if index >= max {
out.push_str("...");
return out;
}
out.push(ch);
}
out
}
fn shortcut_initial(title: &str, url: &str) -> String {
Url::parse(url)
.ok()
.and_then(|url| url.host_str().and_then(host_initial))
.or_else(|| host_initial(title))
.unwrap_or_else(|| "D".to_string())
}
fn host_initial(value: &str) -> Option<String> {
value
.trim_start_matches("www.")
.chars()
.find(|ch| ch.is_ascii_alphanumeric())
.map(|ch| ch.to_ascii_uppercase().to_string())
}
fn settings_html(profile: &Profile) -> String {
let custom_theme = profile.custom_theme.sanitized();
let theme_list = theme_list_html(profile.theme);
let theme_gallery = theme_gallery_html(profile.theme, &custom_theme);
let custom_css = custom_theme_page_css(&custom_theme);
let custom_name = html_escape::encode_double_quoted_attribute(&custom_theme.name).to_string();
let custom_theme_presets = custom_theme_presets_html(&custom_theme);
let custom_theme_contrast = custom_theme_contrast_html(&custom_theme);
let custom_radius_options = custom_radius_options_html(custom_theme.radius);
let custom_density_options = custom_density_options_html(custom_theme.density);
let web_acceleration_options = web_acceleration_options_html(profile.web_acceleration);
let media_playback_options = media_playback_options_html(profile.media_playback);
let search_provider_options = search_provider_options_html(profile.search_provider);
let startup_new_tab_checked = checked(profile.startup_behavior == StartupBehavior::NewTabPage);
let startup_continue_checked =
checked(profile.startup_behavior == StartupBehavior::ContinuePreviousSession);
let bundled_plugins = bundled_plugin_modules();
let plugin_check = check_plugin_registry(&profile.plugins);
let plugin_health = plugin_check_compact_html(&plugin_check);
let plugin_registry = plugin_registry_settings_html(
&profile.plugins.installed,
&bundled_plugins,
profile.plugins.max_plugins,
true,
);
let home = html_escape::encode_double_quoted_attribute(profile.home.as_str()).to_string();
let bookmark_count = profile.bookmarks.len().to_string();
let history_count = profile.history.len().to_string();
let plugin_count = profile.plugins.installed_count().to_string();
let armour_active_count = [
profile.shields.https_upgrade,
profile.shields.global_privacy_control,
profile.shields.do_not_track,
profile.shields.strip_tracking_queries,
profile.shields.debounce_redirects,
profile.shields.block_trackers,
profile.shields.de_amp,
]
.into_iter()
.filter(|enabled| *enabled)
.count()
.to_string();
let armour_allowlist_count = profile.shields.site_allowlist.len().to_string();
let armour_block_rule_count = profile.shields.blocked_hosts.len().to_string();
let armour_max_allowlist = DEFAULT_MAX_SITE_ALLOWLIST.to_string();
let armour_max_blocked_hosts = DEFAULT_MAX_BLOCKED_HOSTS.to_string();
render_template(
SETTINGS_TEMPLATE,
&[
("__THEME_ID__", profile.theme.id()),
("__THEME_LABEL__", profile.theme.label()),
("__THEME_LIST__", &theme_list),
("__THEME_GALLERY__", &theme_gallery),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__CUSTOM_THEME_NAME__", &custom_name),
("__CUSTOM_THEME_PRESETS__", &custom_theme_presets),
("__CUSTOM_THEME_CONTRAST__", &custom_theme_contrast),
("__CUSTOM_CHROME__", &custom_theme.chrome),
("__CUSTOM_PAGE__", &custom_theme.page),
("__CUSTOM_SURFACE__", &custom_theme.surface),
("__CUSTOM_TEXT__", &custom_theme.text),
("__CUSTOM_MUTED__", &custom_theme.muted),
("__CUSTOM_ACCENT__", &custom_theme.accent),
("__CUSTOM_RADIUS_OPTIONS__", &custom_radius_options),
("__CUSTOM_DENSITY_OPTIONS__", &custom_density_options),
("__WEB_ACCELERATION_OPTIONS__", &web_acceleration_options),
("__MEDIA_PLAYBACK_OPTIONS__", &media_playback_options),
("__SEARCH_PROVIDER_OPTIONS__", &search_provider_options),
("__STARTUP_NEW_TAB_CHECKED__", startup_new_tab_checked),
("__STARTUP_CONTINUE_CHECKED__", startup_continue_checked),
("__PLUGIN_HEALTH__", &plugin_health),
("__PLUGIN_REGISTRY__", &plugin_registry),
("__HOME__", &home),
("__BOOKMARK_COUNT__", &bookmark_count),
("__HISTORY_COUNT__", &history_count),
("__ADDON_COUNT__", &plugin_count),
("__ARMOUR_ACTIVE_COUNT__", &armour_active_count),
("__ARMOUR_ALLOWLIST_COUNT__", &armour_allowlist_count),
("__ARMOUR_BLOCK_RULE_COUNT__", &armour_block_rule_count),
("__ARMOUR_MAX_ALLOWLIST__", &armour_max_allowlist),
("__ARMOUR_MAX_BLOCKED_HOSTS__", &armour_max_blocked_hosts),
("__HTTPS_CHECKED__", checked(profile.shields.https_upgrade)),
(
"__GPC_CHECKED__",
checked(profile.shields.global_privacy_control),
),
("__DNT_CHECKED__", checked(profile.shields.do_not_track)),
(
"__STRIP_CHECKED__",
checked(profile.shields.strip_tracking_queries),
),
(
"__DEBOUNCE_CHECKED__",
checked(profile.shields.debounce_redirects),
),
("__BLOCK_CHECKED__", checked(profile.shields.block_trackers)),
("__DE_AMP_CHECKED__", checked(profile.shields.de_amp)),
],
)
}
fn diagnostics_html(ui: &SharedUi) -> String {
let rows = diagnostics_rows(ui);
let (theme, custom_css, profile_path) = {
let state = ui.borrow();
let profile = state.profile.borrow();
(
profile.theme,
custom_theme_page_css(&profile.custom_theme.sanitized()),
html_escape::encode_text(&state.store.path().display().to_string()).to_string(),
)
};
diagnostics_page_html(theme, &custom_css, &profile_path, &rows)
}
fn diagnostics_screenshot_html(profile: &Profile) -> String {
let rows = diagnostics_profile_rows(profile);
let custom_css = custom_theme_page_css(&profile.custom_theme.sanitized());
diagnostics_page_html(profile.theme, &custom_css, "screenshot profile", &rows)
}
fn diagnostics_profile_rows(profile: &Profile) -> Vec<(String, String)> {
vec![
(
"History entries".to_string(),
format!("{} / {}", profile.history.len(), profile.max_history.max(1)),
),
(
"Bookmarks".to_string(),
format!(
"{} / {}",
profile.bookmarks.len(),
profile.max_bookmarks.max(1)
),
),
(
"Plugins".to_string(),
format!(
"{} / {}, {} planned",
profile.plugins.installed_count(),
profile.plugins.max_plugins,
profile.plugins.planned_count()
),
),
(
"Media policy".to_string(),
format!(
"{}; capture {}",
profile.media_playback.label(),
if profile.media_playback.allows_capture() {
"enabled"
} else {
"disabled"
}
),
),
(
"Armour allowlist".to_string(),
format!(
"{} / {DEFAULT_MAX_SITE_ALLOWLIST}",
profile.shields.site_allowlist.len()
),
),
(
"Armour blocked hosts".to_string(),
format!(
"{} / {DEFAULT_MAX_BLOCKED_HOSTS}",
profile.shields.blocked_hosts.len()
),
),
]
}
fn diagnostics_page_html(
theme: BrowserTheme,
custom_css: &str,
profile_path: &str,
rows: &[(String, String)],
) -> String {
let row_count = rows.len().to_string();
let rows_html = diagnostics_rows_html(rows);
render_template(
DIAGNOSTICS_TEMPLATE,
&[
("__THEME_ID__", theme.id()),
("__CUSTOM_THEME_PAGE_CSS__", custom_css),
("__PROFILE_PATH__", profile_path),
("__ROW_COUNT__", &row_count),
("__DIAGNOSTICS_ROWS__", &rows_html),
],
)
}
fn diagnostics_rows_html(rows: &[(String, String)]) -> String {
rows.iter()
.map(|(name, value)| {
format!(
"<tr><th>{}</th><td>{}</td></tr>",
html_escape::encode_text(name),
html_escape::encode_text(value)
)
})
.collect::<Vec<_>>()
.join("")
}
fn plugin_check_compact_html(report: &PluginCheckReport) -> String {
let (state_label, state_class, state_copy) = if report.error_count > 0 {
(
"Errors",
"error",
"Plugin registry errors need attention before automation relies on this profile.",
)
} else if report.warning_count > 0 {
(
"Warnings",
"warning",
"Plugin registry warnings are present; strict automation will fail until they are resolved.",
)
} else {
(
"Healthy",
"ok",
"No plugin registry issues detected; strict checks are clean.",
)
};
let error_label = if report.error_count == 1 {
"error"
} else {
"errors"
};
let warning_label = if report.warning_count == 1 {
"warning"
} else {
"warnings"
};
format!(
r#"<div class="plugin-health">
<div><div class="row-title">Registry health</div><div class="row-copy">{state_copy} <span>{errors} {error_label}, {warnings} {warning_label}, schema {schema}.</span></div></div>
<span class="plugin-health-status {state_class}">{state_label}</span>
</div>"#,
errors = report.error_count,
error_label = error_label,
warnings = report.warning_count,
warning_label = warning_label,
schema = report.schema,
)
}
fn plugins_html(profile: &Profile) -> String {
let custom_theme = profile.custom_theme.sanitized();
let custom_css = custom_theme_page_css(&custom_theme);
let bundled_plugins = bundled_plugin_modules();
let bundled_count = bundled_plugins.len().to_string();
let plugin_check = check_plugin_registry(&profile.plugins);
let plugin_check_summary = plugin_check_summary_html(&plugin_check);
let plugin_registry = plugin_registry_settings_html(
&profile.plugins.installed,
&bundled_plugins,
profile.plugins.max_plugins,
false,
);
let plugin_count = profile.plugins.installed_count().to_string();
let planned_count = profile.plugins.planned_count().to_string();
let max_plugins = profile.plugins.max_plugins.to_string();
render_template(
PLUGINS_TEMPLATE,
&[
("__THEME_ID__", profile.theme.id()),
("__THEME_LABEL__", profile.theme.label()),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__PLUGIN_CHECK_SUMMARY__", &plugin_check_summary),
("__PLUGIN_REGISTRY__", &plugin_registry),
("__PLUGIN_COUNT__", &plugin_count),
("__PLANNED_COUNT__", &planned_count),
("__BUNDLED_COUNT__", &bundled_count),
("__MAX_PLUGINS__", &max_plugins),
],
)
}
fn plugin_check_summary_html(report: &PluginCheckReport) -> String {
let (state_label, state_class, state_copy) = if report.error_count > 0 {
(
"Errors",
"error",
"Fix these registry errors before trusting plugin state.",
)
} else if report.warning_count > 0 {
(
"Warnings",
"warning",
"Registry is usable, but strict checks will fail until warnings are resolved.",
)
} else {
("Healthy", "ok", "No plugin registry issues detected.")
};
let issue_rows = if report.issues.is_empty() {
r#"<div class="plugin-check-empty">No plugin registry issues detected.</div>"#.to_string()
} else {
report
.issues
.iter()
.map(|issue| {
let severity = match issue.severity {
PluginCheckSeverity::Error => "error",
PluginCheckSeverity::Warning => "warning",
};
let code = html_escape::encode_text(&issue.code);
let message = html_escape::encode_text(&issue.message);
let plugin_id = issue
.plugin_id
.as_deref()
.map(html_escape::encode_text)
.map(|id| format!(r#"<span>Plugin <code>{id}</code></span>"#))
.unwrap_or_default();
let module_id = issue
.module_id
.as_deref()
.map(html_escape::encode_text)
.map(|id| format!(r#"<span>Module <code>{id}</code></span>"#))
.unwrap_or_default();
format!(
r#"<div class="plugin-issue {severity}"><strong>{code}</strong><span>{message}</span><div>{plugin_id}{module_id}</div></div>"#
)
})
.collect::<Vec<_>>()
.join("\n ")
};
format!(
r#"<div class="plugin-check-head">
<div><div class="row-title">Registry health</div><div class="row-copy">{state_copy}</div></div>
<span class="plugin-check-state {state_class}">{state_label}</span>
</div>
<div class="plugin-check-stats"><span><strong>{errors}</strong> errors</span><span><strong>{warnings}</strong> warnings</span><span><strong>{available}</strong> bundled available</span><span><strong>{schema}</strong> schema</span></div>
<div class="plugin-issues">{issue_rows}</div>"#,
errors = report.error_count,
warnings = report.warning_count,
available = report.available_count,
schema = report.schema,
)
}
fn agent_html(state: &BrowserUi) -> String {
let profile = state.profile.borrow();
let ai_state = if profile.ai_browsing_enabled {
"Enabled"
} else {
"Disabled"
};
let theme_id = profile.theme.id();
let custom_css = custom_theme_page_css(&profile.custom_theme);
let active_agents = agent_active_rows(state);
let active_agent_count = agent_active_count(state);
let command_manifest = html_escape::encode_text(agent_command_manifest_json());
let window_context_json = agent_window_context_json(state);
let window_context = html_escape::encode_text(&window_context_json);
let agent_vision_packet = window_context.clone();
let target_title = state
.agent_target_title
.as_deref()
.map(html_escape::encode_text)
.map(|title| title.to_string())
.unwrap_or_else(|| "No target page selected".to_string());
let target_url = state
.agent_target_url
.as_ref()
.map(|url| html_escape::encode_text(url.as_str()).to_string())
.unwrap_or_else(|| {
"Open a web page, then press the Agent toolbar button to bind reader tools to it."
.to_string()
});
let recording_active = state.recorder.is_some();
let recording_state = if recording_active { "Active" } else { "Idle" };
let human_control_state = if state.human_control_enabled {
"Enabled"
} else {
"Disabled"
};
let recording_actions = agent_recording_actions(recording_active);
let human_control_actions = agent_human_control_actions(state.human_control_enabled);
let human_control_examples = agent_human_control_examples();
let emulation_lab = agent_emulation_lab_html(state);
let reader_tools = agent_reader_tools_html(state.agent_target_url.is_some());
let log_path = AgentLogStore::new(None)
.map(|store| store.path().display().to_string())
.unwrap_or_else(|err| format!("unavailable: {err}"));
let event_count = state
.agents
.borrow()
.as_ref()
.map(|agents| agents.events().len())
.unwrap_or(0);
let active_agent_count = active_agent_count.to_string();
let event_count = event_count.to_string();
let command_count = cached_agent_command_manifest().len().to_string();
let log_path = html_escape::encode_text(&log_path).to_string();
render_template(
AGENT_TEMPLATE,
&[
("__THEME_ID__", theme_id),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__AI_STATE__", ai_state),
("__TARGET_TITLE__", &target_title),
("__TARGET_URL__", &target_url),
("__RECORDING_STATE__", recording_state),
("__AGENT_VISION_PACKET__", agent_vision_packet.as_ref()),
("__HUMAN_CONTROL_STATE__", human_control_state),
("__HUMAN_CONTROL_ACTIONS__", &human_control_actions),
("__HUMAN_CONTROL_EXAMPLES__", human_control_examples),
("__EMULATION_LAB__", &emulation_lab),
("__RECORDING_ACTIONS__", &recording_actions),
("__READER_TOOLS__", reader_tools),
("__ACTIVE_AGENTS__", &active_agents),
("__ACTIVE_AGENT_COUNT__", &active_agent_count),
("__COMMAND_ROWS__", agent_command_rows()),
("__COMMAND_MANIFEST__", command_manifest.as_ref()),
("__WINDOW_CONTEXT__", window_context.as_ref()),
("__LOG_PATH__", &log_path),
("__EVENT_COUNT__", &event_count),
("__COMMAND_COUNT__", &command_count),
],
)
}
fn agent_screenshot_html(profile: &Profile, target: &str) -> String {
let ai_state = if profile.ai_browsing_enabled {
"Enabled"
} else {
"Disabled"
};
let custom_css = custom_theme_page_css(&profile.custom_theme);
let command_manifest = html_escape::encode_text(agent_command_manifest_json());
let window_context_json = serde_json::to_string_pretty(&json!({
"backend": {
"target": "elixir",
"delivery": "pending"
},
"capabilities": {
"agent_vision": true,
"agent_vision_schema": "cephas.agent-vision.v1",
"browser_recording_schema": crate::recording::BROWSER_RECORDING_SCHEMA,
"window_control": true,
"browser_window_human_control": true,
"visual_recording": true,
"website_screenshot": true,
"reader_tools": AgentReaderTool::ALL.into_iter().map(|tool| tool.id()).collect::<Vec<_>>(),
"reader_artifact_schema": crate::reader::AGENT_READER_SCHEMA,
"browser_commands": cached_agent_command_manifest()
},
"window": {
"id": 1,
"active_tab": {
"private": false,
"title": "Screenshot target",
"uri": target
},
"human_control_enabled": false,
"agent_page_target": null
},
"recording": agent_recording_context(None),
"agent_vision": agent_vision_context(false)
}))
.expect("serialize screenshot agent context");
let window_context = html_escape::encode_text(&window_context_json);
let agent_vision_packet = window_context.clone();
let log_path = AgentLogStore::new(None)
.map(|store| store.path().display().to_string())
.unwrap_or_else(|err| format!("unavailable: {err}"));
let escaped_target = html_escape::encode_text(target);
let human_control_actions = agent_human_control_actions(false);
let emulation_lab = agent_emulation_lab_html_for_screenshot();
let recording_actions = agent_recording_actions(false);
let log_path = html_escape::encode_text(&log_path).to_string();
let command_count = cached_agent_command_manifest().len().to_string();
render_template(
AGENT_TEMPLATE,
&[
("__THEME_ID__", profile.theme.id()),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__AI_STATE__", ai_state),
("__TARGET_TITLE__", "Visual smoke target"),
("__TARGET_URL__", escaped_target.as_ref()),
("__RECORDING_STATE__", "Idle"),
("__AGENT_VISION_PACKET__", agent_vision_packet.as_ref()),
("__HUMAN_CONTROL_STATE__", "Disabled"),
("__HUMAN_CONTROL_ACTIONS__", &human_control_actions),
("__HUMAN_CONTROL_EXAMPLES__", agent_human_control_examples()),
("__EMULATION_LAB__", &emulation_lab),
("__RECORDING_ACTIONS__", &recording_actions),
("__READER_TOOLS__", agent_reader_tools_html(false)),
(
"__ACTIVE_AGENTS__",
r#"<div class="empty">No delegated agents in screenshot mode.</div>"#,
),
("__ACTIVE_AGENT_COUNT__", "0"),
("__COMMAND_ROWS__", agent_command_rows()),
("__COMMAND_MANIFEST__", command_manifest.as_ref()),
("__WINDOW_CONTEXT__", window_context.as_ref()),
("__LOG_PATH__", &log_path),
("__EVENT_COUNT__", "0"),
("__COMMAND_COUNT__", &command_count),
],
)
}
fn agent_docs_html(profile: &Profile) -> String {
let custom_css = custom_theme_page_css(&profile.custom_theme);
let command_count = cached_agent_command_manifest().len().to_string();
let reader_tool_count = AgentReaderTool::ALL.len().to_string();
render_template(
AGENT_DOCS_TEMPLATE,
&[
("__THEME_ID__", profile.theme.id()),
("__CUSTOM_THEME_PAGE_CSS__", &custom_css),
("__COMMAND_COUNT__", &command_count),
("__READER_TOOL_COUNT__", &reader_tool_count),
],
)
}
fn render_template(template: &str, replacements: &[(&str, &str)]) -> String {
let mut rendered = String::with_capacity(template.len());
let mut cursor = 0;
while let Some(start_offset) = template[cursor..].find("__") {
let start = cursor + start_offset;
let Some(end_offset) = template[start + 2..].find("__") else {
break;
};
let end = start + 2 + end_offset + 2;
let candidate = &template[start..end];
let built_in = if candidate == "__INTERNAL_PAGE_PERFORMANCE_CSS__" {
Some(INTERNAL_PAGE_PERFORMANCE_CSS)
} else {
None
};
if let Some(value) = built_in.or_else(|| {
replacements
.iter()
.find(|(key, _)| *key == candidate)
.map(|(_, value)| *value)
}) {
rendered.push_str(&template[cursor..start]);
rendered.push_str(value);
cursor = end;
} else {
rendered.push_str(&template[cursor..start + 2]);
cursor = start + 2;
}
}
rendered.push_str(&template[cursor..]);
rendered
}
fn agent_emulation_lab_html(state: &BrowserUi) -> String {
let selected = agent_emulation_status_html(state.agent_emulation.as_ref());
format!(
r#"{selected}
<div class="lab-grid">{cards}</div>"#,
cards = agent_emulation_cards_html()
)
}
fn agent_emulation_lab_html_for_screenshot() -> String {
format!(
r#"{selected}
<div class="lab-grid">{cards}</div>"#,
selected = agent_emulation_status_html(None),
cards = agent_emulation_cards_html()
)
}
fn agent_emulation_status_html(selected: Option<&AgentEmulationState>) -> String {
match selected {
Some(selected) => {
let label = html_escape::encode_text(&selected.label);
let command_id = html_escape::encode_text(&selected.command_id);
let description = html_escape::encode_text(&selected.description);
let category = html_escape::encode_text(selected.category);
let coverage = html_escape::encode_text(selected.coverage);
let example = html_escape::encode_text(&selected.example);
let result = html_escape::encode_text(&selected.result);
let request_json = html_escape::encode_text(&selected.request_json);
format!(
r#"<div id="emulation-output" class="lab-output active">
<div><span class="eyebrow">Last emulation</span><h3>{label}</h3><div class="meta"><code>{command_id}</code></div></div>
<div class="lab-tags"><span class="pill">{category}</span><span class="pill primary">{coverage}</span></div>
<p>{description}</p>
<div class="row-copy"><strong>Example surface:</strong> <code>{example}</code></div>
<div class="row-copy"><strong>Expected result:</strong> {result}</div>
<pre>{request_json}</pre>
</div>"#
)
}
None => r#"<div id="emulation-output" class="lab-output">
<div><span class="eyebrow">Dry run</span><h3>Click a command to emulate it</h3></div>
<p>This lab does not execute destructive browser actions. It shows the command request, current integration surface, and expected result so agentic workflows can be planned visually.</p>
</div>"#
.to_string(),
}
}
fn agent_emulation_cards_html() -> &'static str {
AGENT_EMULATION_CARDS_CACHE
.get_or_init(|| {
cached_agent_command_manifest()
.iter()
.map(|entry| {
let (category, coverage, example, result) =
agent_emulation_spec(entry.id, "https://example.com");
let href = format!("{CEPHAS_AGENT_EMULATE_URI}?command={}", entry.id);
let href = html_escape::encode_double_quoted_attribute(&href);
let label = html_escape::encode_text(entry.label);
let id = html_escape::encode_text(entry.id);
let description = html_escape::encode_text(entry.description);
let category = html_escape::encode_text(category);
let coverage = html_escape::encode_text(coverage);
let example = html_escape::encode_text(&example);
let result = html_escape::encode_text(&result);
let data_label = html_escape::encode_double_quoted_attribute(entry.label);
let data_id = html_escape::encode_double_quoted_attribute(entry.id);
let data_description = html_escape::encode_double_quoted_attribute(entry.description);
let data_category = html_escape::encode_double_quoted_attribute(category.as_ref());
let data_coverage = html_escape::encode_double_quoted_attribute(coverage.as_ref());
let data_example = html_escape::encode_double_quoted_attribute(example.as_ref());
let data_result = html_escape::encode_double_quoted_attribute(result.as_ref());
let data_target_required = if entry.requires_target { "true" } else { "false" };
let target = if entry.requires_target {
"Target required"
} else {
"No target"
};
format!(
r#"<a class="lab-card" href="{href}" data-label="{data_label}" data-command="{data_id}" data-description="{data_description}" data-category="{data_category}" data-coverage="{data_coverage}" data-example="{data_example}" data-result="{data_result}" data-target-required="{data_target_required}"><span class="eyebrow">{category}</span><strong>{label}</strong><span>{description}</span><div class="meta"><code>{id}</code> - {target}</div><div class="lab-card-foot"><span>{coverage}</span><code>{example}</code></div></a>"#
)
})
.collect::<Vec<_>>()
.join("\n ")
})
.as_str()
}
fn agent_active_count(state: &BrowserUi) -> usize {
state
.agents
.borrow()
.as_ref()
.map(|agents| agents.active_for_window(WindowId(1)).len())
.unwrap_or(0)
}
fn agent_human_control_actions(enabled: bool) -> String {
if enabled {
format!(
r#"<span class="pill primary">Enabled</span><a class="pill danger" href="{base}/disable">Disable</a>"#,
base = CEPHAS_AGENT_HUMAN_URI
)
} else {
format!(
r#"<a class="pill primary" href="{base}/enable">Enable for session</a><span class="pill disabled">Actions blocked</span>"#,
base = CEPHAS_AGENT_HUMAN_URI
)
}
}
fn agent_human_control_examples() -> &'static str {
AGENT_HUMAN_CONTROL_EXAMPLES_CACHE
.get_or_init(|| {
let examples = [
format!("{CEPHAS_AGENT_HUMAN_URI}/move?x=320&y=240"),
format!("{CEPHAS_AGENT_HUMAN_URI}/click?x=320&y=240"),
format!("{CEPHAS_AGENT_HUMAN_URI}/type?text=hello%20from%20Cephas"),
format!("{CEPHAS_AGENT_HUMAN_URI}/key?key=Enter"),
format!("{CEPHAS_AGENT_HUMAN_URI}/scroll?dy=600"),
format!("{CEPHAS_AGENT_HUMAN_URI}/wait?ms=1000"),
];
examples
.into_iter()
.map(|example| format!("<code>{}</code>", html_escape::encode_text(&example)))
.collect::<Vec<_>>()
.join("\n ")
})
.as_str()
}
fn agent_recording_actions(active: bool) -> String {
if active {
format!(
r#"<a class="pill danger" href="{stop}">Stop recording</a><a class="pill" href="{snapshot}">Capture snapshot</a>"#,
stop = CEPHAS_AGENT_RECORDING_STOP_URI,
snapshot = CEPHAS_AGENT_RECORDING_SNAPSHOT_URI
)
} else {
format!(
r#"<a class="pill primary" href="{start}">Start recording</a><span class="pill disabled">Capture snapshot</span>"#,
start = CEPHAS_AGENT_RECORDING_START_URI
)
}
}
fn agent_reader_tools_html(enabled: bool) -> &'static str {
let cache = if enabled {
&AGENT_READER_TOOLS_ENABLED_CACHE
} else {
&AGENT_READER_TOOLS_DISABLED_CACHE
};
cache
.get_or_init(|| {
AgentReaderTool::ALL
.into_iter()
.map(|tool| {
let label = html_escape::encode_text(tool.label());
let description = html_escape::encode_text(tool.description());
if enabled {
let href = format!("{CEPHAS_AGENT_READER_URI}?tool={}", tool.id());
let href = html_escape::encode_double_quoted_attribute(&href);
format!(
r#"<a class="tool-card" href="{href}"><strong>{label}</strong><span>{description}</span></a>"#
)
} else {
format!(
r#"<span class="tool-card disabled"><strong>{label}</strong><span>{description}</span></span>"#
)
}
})
.collect::<Vec<_>>()
.join("\n ")
})
.as_str()
}
fn agent_active_rows(state: &BrowserUi) -> String {
let agents = state.agents.borrow();
let Some(control) = agents.as_ref() else {
return r#"<div class="empty">No delegated agents yet.</div>"#.to_string();
};
let active = control.active_for_window(WindowId(1));
if active.is_empty() {
return r#"<div class="empty">No active agents for this window.</div>"#.to_string();
}
active
.into_iter()
.map(|delegation| {
let agent_id_raw = delegation.agent_id.to_string();
let agent_id = html_escape::encode_text(&agent_id_raw);
let label = html_escape::encode_text(&delegation.agent_label);
let task = html_escape::encode_text(&delegation.task.description);
let action = delegation
.active_action
.as_ref()
.map(|action| action.name.as_str())
.unwrap_or("idle");
let action = html_escape::encode_text(action);
format!(
r#"<article class="agent-card"><div><span class="eyebrow">{label}</span><strong>{agent_id}</strong></div><p>{task}</p><div class="meta">Active action: {action}</div></article>"#
)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn agent_command_rows() -> &'static str {
AGENT_COMMAND_ROWS_CACHE
.get_or_init(|| {
cached_agent_command_manifest()
.iter()
.map(|entry| {
let id = html_escape::encode_text(entry.id);
let label = html_escape::encode_text(entry.label);
let description = html_escape::encode_text(entry.description);
let target = if entry.requires_target {
"target required"
} else {
"no target"
};
format!(
r#"<tr><td><code>{id}</code></td><td>{label}</td><td>{description}</td><td>{target}</td></tr>"#
)
})
.collect::<Vec<_>>()
.join("\n ")
})
.as_str()
}
fn agent_command_manifest_json() -> &'static str {
AGENT_COMMAND_MANIFEST_JSON_CACHE
.get_or_init(|| {
serde_json::to_string_pretty(cached_agent_command_manifest())
.expect("serialize command manifest")
})
.as_str()
}
fn cached_agent_command_manifest() -> &'static [AgentCommandManifestEntry] {
AGENT_COMMAND_MANIFEST_CACHE
.get_or_init(agent_command_manifest)
.as_slice()
}
fn agent_window_context_json(state: &BrowserUi) -> String {
serde_json::to_string_pretty(¤t_window_agent_metadata(state))
.expect("serialize window context")
}
fn checked(active: bool) -> &'static str {
if active { "checked" } else { "" }
}
fn web_acceleration_options_html(active_policy: WebAccelerationPolicy) -> String {
WebAccelerationPolicy::ALL
.into_iter()
.map(|policy| {
let selected = if policy == active_policy {
" selected"
} else {
""
};
let id = html_escape::encode_double_quoted_attribute(policy.id());
let label = html_escape::encode_text(policy.label());
format!(r#"<option value="{id}"{selected}>{label}</option>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn media_playback_options_html(active_policy: MediaPlaybackPolicy) -> String {
MediaPlaybackPolicy::ALL
.into_iter()
.map(|policy| {
let selected = if policy == active_policy {
" selected"
} else {
""
};
let id = html_escape::encode_double_quoted_attribute(policy.id());
let label = html_escape::encode_text(policy.label());
format!(r#"<option value="{id}"{selected}>{label}</option>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn search_provider_options_html(active_provider: SearchProvider) -> String {
SearchProvider::ALL
.into_iter()
.map(|provider| {
let selected = if provider == active_provider {
" selected"
} else {
""
};
let id = html_escape::encode_double_quoted_attribute(provider.id());
let label = html_escape::encode_text(provider.label());
format!(r#"<option value="{id}"{selected}>{label}</option>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn custom_radius_options_html(active_radius: CustomThemeRadius) -> String {
CustomThemeRadius::ALL
.into_iter()
.map(|radius| {
let selected = if radius == active_radius {
" selected"
} else {
""
};
let id = html_escape::encode_double_quoted_attribute(radius.id());
let label = html_escape::encode_text(radius.label());
format!(r#"<option value="{id}"{selected}>{label}</option>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn custom_density_options_html(active_density: CustomThemeDensity) -> String {
CustomThemeDensity::ALL
.into_iter()
.map(|density| {
let selected = if density == active_density {
" selected"
} else {
""
};
let id = html_escape::encode_double_quoted_attribute(density.id());
let label = html_escape::encode_text(density.label());
format!(r#"<option value="{id}"{selected}>{label}</option>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn plugin_registry_settings_html(
plugins: &[PluginManifest],
bundled_plugins: &[PluginModule],
max_plugins: usize,
include_open_link: bool,
) -> String {
let used = format!("{} / {}", plugins.len(), max_plugins.max(1));
let bundled_by_id = bundled_plugins
.iter()
.map(|module| (module.manifest.id, module))
.collect::<BTreeMap<_, _>>();
let enabled = plugins
.iter()
.filter(|plugin| plugin.status == PluginStatus::Enabled)
.count()
.to_string();
let planned = plugins
.iter()
.filter(|plugin| plugin.status == PluginStatus::Planned)
.count()
.to_string();
let available_plugins = bundled_plugins
.iter()
.filter(|module| {
!plugins
.iter()
.any(|installed| installed.id == module.manifest.id)
})
.collect::<Vec<_>>();
let has_available = !available_plugins.is_empty();
let has_installed_bundled = plugins
.iter()
.any(|plugin| !plugin.built_in && bundled_by_id.contains_key(&plugin.id));
let available = available_plugins.len().to_string();
let cards = plugins
.iter()
.map(|plugin| plugin_settings_card_html(plugin, bundled_by_id.get(&plugin.id).copied()))
.collect::<Vec<_>>()
.join("\n ");
let catalog_cards = available_plugins
.into_iter()
.map(plugin_catalog_card_html)
.collect::<Vec<_>>()
.join("\n ");
let catalog = if catalog_cards.is_empty() {
r#"<div class="plugin-catalog-empty">All bundled project plugins are installed in this profile.</div>"#
.to_string()
} else {
format!(
r#"<div class="plugin-section-title">Bundled project modules</div><div class="plugin-grid">{catalog_cards}</div>"#
)
};
let action = if include_open_link {
r#"<a class="pill primary" href="cephas://plugins">Open full registry</a>"#.to_string()
} else {
let mut links = Vec::new();
if has_available {
links.push(r#"<a class="pill primary" href="cephas://plugins/install-all">Install all available</a>"#);
}
if has_installed_bundled {
links.push(r#"<a class="pill" href="cephas://plugins/sync-all">Sync installed</a>"#);
}
if links.is_empty() {
r#"<span class="pill primary">Registry page</span>"#.to_string()
} else {
format!(r#"<div class="plugin-actions">{}</div>"#, links.join(""))
}
};
format!(
r#"<div class="plugin-hero">
<div><div class="row-title">UUIDv7 plugin registry</div><div class="row-copy">Installable blockers, media helpers, tools, reader extensions, Agent hooks, and themes are tracked with explicit capabilities, permission labels, and bounded profile storage.</div></div>
{action}
</div>
<div class="plugin-stats"><span><strong>{enabled}</strong> enabled</span><span><strong>{available}</strong> bundled available</span><span><strong>{planned}</strong> planned APIs</span><span><strong>cephas.plugins.v1</strong> schema</span></div>
<div class="plugin-slots"><span>{used} slots used</span><span>{max_plugins} maximum records</span></div>
<div class="plugin-section-title">Installed registry</div><div class="plugin-grid">{cards}</div>{catalog}"#
)
}
fn plugin_settings_card_html(
plugin: &PluginManifest,
bundled_module: Option<&PluginModule>,
) -> String {
let id_value = plugin.id.to_string();
let capabilities_value = plugin_capabilities_label(&plugin.capabilities);
let permissions_value = plugin_permissions_label(&plugin.permissions);
let mark_value = plugin_kind_mark(plugin);
let name = html_escape::encode_text(&plugin.name);
let description = html_escape::encode_text(&plugin.description);
let id = html_escape::encode_text(&id_value);
let kind = html_escape::encode_text(plugin.kind.label());
let status = html_escape::encode_text(plugin.status.label());
let status_class = plugin_status_class(plugin.status);
let origin = if plugin.built_in {
"Built-in API"
} else {
"Profile plugin"
};
let origin = html_escape::encode_text(origin);
let capabilities = html_escape::encode_text(&capabilities_value);
let permissions = html_escape::encode_text(&permissions_value);
let mark = html_escape::encode_text(&mark_value);
let actions = plugin_installed_actions_html(plugin, bundled_module);
format!(
r#"<article class="plugin-card">
<div class="plugin-card-head"><span class="plugin-mark">{mark}</span><div><strong>{name}</strong><span>{kind}</span></div><span class="plugin-status {status_class}">{status}</span></div>
<p>{description}</p>
<div class="plugin-meta"><span>{origin}</span><span>ID <code>{id}</code></span><span>Capabilities: {capabilities}</span><span>Permissions: {permissions}</span></div>
{actions}
</article>"#
)
}
fn plugin_catalog_card_html(module: &PluginModule) -> String {
let plugin = &module.manifest;
let id_value = plugin.id.to_string();
let capabilities_value = plugin_capabilities_label(&plugin.capabilities);
let permissions_value = plugin_permissions_label(&plugin.permissions);
let mark_value = plugin_kind_mark(plugin);
let name = html_escape::encode_text(&plugin.name);
let description = html_escape::encode_text(&plugin.description);
let id = html_escape::encode_text(&id_value);
let kind = html_escape::encode_text(plugin.kind.label());
let capabilities = html_escape::encode_text(&capabilities_value);
let permissions = html_escape::encode_text(&permissions_value);
let source = html_escape::encode_text(&module.source_path);
let mark = html_escape::encode_text(&mark_value);
let href = plugin_action_href_for_module("install", module);
format!(
r#"<article class="plugin-card plugin-card-available">
<div class="plugin-card-head"><span class="plugin-mark">{mark}</span><div><strong>{name}</strong><span>{kind}</span></div><span class="plugin-status installed">Available</span></div>
<p>{description}</p>
<div class="plugin-meta"><span>Bundled project module</span><span>Source <code>{source}</code></span><span>ID <code>{id}</code></span><span>Capabilities: {capabilities}</span><span>Permissions: {permissions}</span></div>
<div class="plugin-actions"><a class="pill primary" href="{href}">Install</a></div>
</article>"#
)
}
fn plugin_installed_actions_html(
plugin: &PluginManifest,
bundled_module: Option<&PluginModule>,
) -> String {
if plugin.built_in {
return String::new();
}
let mut actions = Vec::new();
match plugin.status {
PluginStatus::Enabled => actions.push(("disable", "Disable", false)),
PluginStatus::Installed | PluginStatus::Disabled => {
actions.push(("enable", "Enable", true));
}
PluginStatus::Blocked | PluginStatus::Planned => {}
}
if bundled_module.is_some() {
actions.push(("sync", "Sync", false));
}
actions.push(("remove", "Remove", false));
let links = actions
.into_iter()
.map(|(action, label, primary)| {
let class = if primary { "pill primary" } else { "pill" };
let href = plugin_action_href_for_plugin(action, plugin, bundled_module);
format!(r#"<a class="{class}" href="{href}">{label}</a>"#)
})
.collect::<Vec<_>>()
.join("\n ");
format!(r#"<div class="plugin-actions">{links}</div>"#)
}
fn plugin_action_href_for_plugin(
action: &str,
plugin: &PluginManifest,
bundled_module: Option<&PluginModule>,
) -> String {
if let Some(module) = bundled_module {
return plugin_action_href_for_module(action, module);
}
plugin_action_href(action, "id", &plugin.id.to_string())
}
fn plugin_action_href_for_module(action: &str, module: &PluginModule) -> String {
plugin_action_href(action, "module", &module.module_id)
}
fn plugin_action_href(action: &str, key: &str, value: &str) -> String {
html_escape::encode_double_quoted_attribute(&format!("cephas://plugins/{action}?{key}={value}"))
.to_string()
}
fn plugin_status_class(status: PluginStatus) -> &'static str {
match status {
PluginStatus::Planned => "planned",
PluginStatus::Installed => "installed",
PluginStatus::Enabled => "enabled",
PluginStatus::Disabled => "disabled",
PluginStatus::Blocked => "blocked",
}
}
fn plugin_kind_mark(plugin: &PluginManifest) -> String {
plugin
.kind
.label()
.chars()
.find(|ch| ch.is_ascii_alphanumeric())
.map(|ch| ch.to_ascii_uppercase().to_string())
.unwrap_or_else(|| "P".to_string())
}
fn theme_list_html(active_theme: BrowserTheme) -> String {
BrowserTheme::ALL
.into_iter()
.map(|theme| {
let active = if theme == active_theme { " active" } else { "" };
let label = html_escape::encode_text(theme.label());
let href = format!("cephas://settings/theme?id={}", theme.id());
let href = html_escape::encode_double_quoted_attribute(&href);
format!(r#"<a class="theme-chip{active}" href="{href}">{label}</a>"#)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn custom_theme_page_css(theme: &CustomTheme) -> String {
let theme = theme.sanitized();
let radius = custom_radius_css(theme.radius);
let control_radius = custom_control_radius_css(theme.radius);
let (row_padding, card_padding, control_height) = custom_density_page_values(theme.density);
let color_scheme = custom_theme_color_scheme(&theme.page);
format!(
r#":root {{ --custom-chrome: {chrome}; --custom-page: {page}; --custom-surface: {surface}; --custom-text: {text}; --custom-muted: {muted}; --custom-accent: {accent}; --custom-radius: {radius}; --custom-control-radius: {control_radius}; --custom-row-padding: {row_padding}; --custom-card-padding: {card_padding}; --custom-control-height: {control_height}; }}
body.custom {{
color-scheme: {color_scheme};
--text: {text};
--muted: {muted};
--soft: {muted};
--panel: {surface};
--panel-strong: {surface};
--tile: {surface};
--tile-hover: {accent};
--line: {accent};
--accent: {accent};
--accent-2: {accent};
--bg: {page};
--rail: {chrome};
--surface: {surface};
--surface-2: {chrome};
--armour: {accent};
background: radial-gradient(ellipse at 50% 18%, {surface}, transparent 36%), linear-gradient(180deg, {page}, {chrome});
}}
body.custom .card,
body.custom .metric,
body.custom .mini,
body.custom .quick-card,
body.custom .doc-card,
body.custom .step,
body.custom .lab-card,
body.custom .tool-card,
body.custom .agent-card,
body.custom .theme-card,
body.custom .plugin-card,
body.custom .site-icon {{ border-radius: var(--custom-radius); }}
body.custom .search,
body.custom form,
body.custom .pill,
body.custom .save-button,
body.custom .theme-chip,
body.custom .theme-apply,
body.custom .plugin-status,
body.custom .plugin-check-state,
body.custom input,
body.custom select {{ border-radius: var(--custom-control-radius); min-height: var(--custom-control-height); }}
body.custom .card {{ padding: var(--custom-card-padding); }}
body.custom .row {{ padding-top: var(--custom-row-padding); padding-bottom: var(--custom-row-padding); }}
body.custom .search,
body.custom .text-input,
body.custom select {{ background: {surface}; }}"#,
chrome = theme.chrome,
page = theme.page,
surface = theme.surface,
text = theme.text,
muted = theme.muted,
accent = theme.accent,
radius = radius,
control_radius = control_radius,
row_padding = row_padding,
card_padding = card_padding,
control_height = control_height,
color_scheme = color_scheme,
)
}
fn custom_density_page_values(
density: CustomThemeDensity,
) -> (&'static str, &'static str, &'static str) {
match density {
CustomThemeDensity::Compact => ("12px", "16px", "34px"),
CustomThemeDensity::Comfortable => ("18px", "24px", "40px"),
CustomThemeDensity::Spacious => ("24px", "30px", "46px"),
}
}
fn custom_theme_color_scheme(page_color: &str) -> &'static str {
if relative_hex_luminance(page_color).is_some_and(|luminance| luminance > 0.55) {
"light"
} else {
"dark"
}
}
fn relative_hex_luminance(color: &str) -> Option<f64> {
let color = color.strip_prefix('#').unwrap_or(color);
if color.len() != 6 {
return None;
}
let red = u8::from_str_radix(&color[0..2], 16).ok()? as f64 / 255.0;
let green = u8::from_str_radix(&color[2..4], 16).ok()? as f64 / 255.0;
let blue = u8::from_str_radix(&color[4..6], 16).ok()? as f64 / 255.0;
Some(0.2126 * linear_rgb(red) + 0.7152 * linear_rgb(green) + 0.0722 * linear_rgb(blue))
}
fn linear_rgb(value: f64) -> f64 {
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
struct ThemePreviewTokens {
background: &'static str,
surface: &'static str,
toolbar: &'static str,
tab: &'static str,
address: &'static str,
accent: &'static str,
text: &'static str,
badge: &'static str,
}
fn theme_gallery_html(active_theme: BrowserTheme, custom_theme: &CustomTheme) -> String {
BrowserTheme::ALL
.into_iter()
.map(|theme| {
let tokens = theme_preview_tokens(theme, custom_theme);
let active_class = if theme == active_theme { " active" } else { "" };
let label = html_escape::encode_text(theme.label());
let href = format!("cephas://settings/theme?id={}", theme.id());
let href = html_escape::encode_double_quoted_attribute(&href);
let action = if theme == active_theme {
r#"<span class="theme-apply active" aria-current="true">Active</span>"#.to_string()
} else {
format!(r#"<a class="theme-apply" href="{href}">Apply</a>"#)
};
format!(
r#"<article class="theme-card{active_class}" style="--p-bg:{bg};--p-surface:{surface};--p-toolbar:{toolbar};--p-tab:{tab};--p-address:{address};--p-accent:{accent};--p-text:{text};">
<div class="theme-preview" aria-hidden="true"><div class="theme-preview-toolbar"><span></span><span></span><span></span></div><div class="theme-preview-tabs"><span></span><span></span></div><div class="theme-preview-address"></div><div class="theme-preview-card"></div></div>
<div class="theme-card-meta"><div><strong>{label}</strong><span>{badge}</span></div>{action}</div>
</article>"#,
active_class = active_class,
bg = tokens.background,
surface = tokens.surface,
toolbar = tokens.toolbar,
tab = tokens.tab,
address = tokens.address,
accent = tokens.accent,
text = tokens.text,
label = label,
badge = tokens.badge,
action = action
)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn custom_theme_presets_html(active_custom_theme: &CustomTheme) -> String {
CUSTOM_THEME_PRESETS
.into_iter()
.map(|preset| {
let active_class = if custom_theme_matches_preset(active_custom_theme, preset) {
" active"
} else {
""
};
let href = format!(
"{}?id={}",
CEPHAS_SETTINGS_CUSTOM_THEME_PRESET_URI, preset.id
);
let href = html_escape::encode_double_quoted_attribute(&href);
let label = html_escape::encode_text(preset.label);
let description = html_escape::encode_text(preset.description);
let id = html_escape::encode_double_quoted_attribute(preset.id);
format!(
r##"<a class="theme-preset{active_class}" href="{href}" data-preset="{id}" style="--preset-chrome:{chrome};--preset-page:{page};--preset-surface:{surface};--preset-text:{text};--preset-muted:{muted};--preset-accent:{accent};">
<span class="theme-preset-preview" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="theme-preset-copy"><strong>{label}</strong><span>{description}</span></span>
<span class="theme-preset-action">Apply</span>
</a>"##,
active_class = active_class,
href = href,
id = id,
chrome = preset.chrome,
page = preset.page,
surface = preset.surface,
text = preset.text,
muted = preset.muted,
accent = preset.accent,
label = label,
description = description
)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn custom_theme_contrast_html(theme: &CustomTheme) -> String {
let theme = theme.sanitized();
let checks = [
(
"Text / surface",
&theme.text,
&theme.surface,
4.5,
"Main panels and cards",
),
(
"Text / page",
&theme.text,
&theme.page,
4.5,
"Start and internal-page background",
),
(
"Muted / surface",
&theme.muted,
&theme.surface,
3.0,
"Secondary labels and helper copy",
),
(
"Accent / page",
&theme.accent,
&theme.page,
3.0,
"Action outlines and active controls",
),
];
let mut all_pass = true;
let mut rows = String::new();
for (label, foreground, background, target, description) in checks {
let ratio = contrast_ratio(foreground, background).unwrap_or(1.0);
let pass = ratio >= target;
all_pass &= pass;
let state = if pass { "ok" } else { "warning" };
let state_label = if pass { "Pass" } else { "Check" };
rows.push_str(&format!(
r#"<div class="theme-contrast-row {state}"><div><strong>{label}</strong><span>{description}</span></div><span>{ratio:.1}:1</span><em>{state_label}</em></div>"#
));
}
let (state_class, state_label, state_copy) = if all_pass {
(
"ok",
"Readable",
"All measured custom colors meet the Cephas contrast targets.",
)
} else {
(
"warning",
"Needs review",
"One or more custom color pairs are below target; adjust text, muted, accent, page, or surface colors before long sessions.",
)
};
format!(
r#"<div class="theme-contrast">
<div class="theme-contrast-head"><div><div class="row-title">Custom theme contrast</div><div class="row-copy">{state_copy}</div></div><span class="theme-contrast-status {state_class}">{state_label}</span></div>
<div class="theme-contrast-grid">{rows}</div>
</div>"#
)
}
fn contrast_ratio(foreground: &str, background: &str) -> Option<f64> {
let foreground = relative_hex_luminance(foreground)?;
let background = relative_hex_luminance(background)?;
let (lighter, darker) = if foreground >= background {
(foreground, background)
} else {
(background, foreground)
};
Some((lighter + 0.05) / (darker + 0.05))
}
fn theme_preview_tokens(theme: BrowserTheme, custom_theme: &CustomTheme) -> ThemePreviewTokens {
match theme {
BrowserTheme::Professional => ThemePreviewTokens {
background: "#f3f5f9",
surface: "#ffffff",
toolbar: "#e7ebf2",
tab: "#ffffff",
address: "#ffffff",
accent: "#4f46e5",
text: "#1f2937",
badge: "Browser",
},
BrowserTheme::Paper => ThemePreviewTokens {
background: "#f5f7fb",
surface: "#ffffff",
toolbar: "#eef2f7",
tab: "#ffffff",
address: "#ffffff",
accent: "#2556d9",
text: "#172033",
badge: "Light",
},
BrowserTheme::DarkPaper => ThemePreviewTokens {
background: "#151413",
surface: "#25221d",
toolbar: "#1d1b18",
tab: "#2c2923",
address: "#25221d",
accent: "#d49a4a",
text: "#f4efe6",
badge: "Dark",
},
BrowserTheme::Cupertino => ThemePreviewTokens {
background: "#f5f5f7",
surface: "#ffffff",
toolbar: "#eff3f8",
tab: "#ffffff",
address: "#ffffff",
accent: "#007aff",
text: "#1d1d1f",
badge: "macOS",
},
BrowserTheme::Glass => ThemePreviewTokens {
background: "#dce7f6",
surface: "rgba(255,255,255,.72)",
toolbar: "rgba(255,255,255,.56)",
tab: "rgba(255,255,255,.76)",
address: "rgba(255,255,255,.82)",
accent: "#4f7cff",
text: "#172033",
badge: "Glass",
},
BrowserTheme::AgentInspect => ThemePreviewTokens {
background: "#08111f",
surface: "#0d1728",
toolbar: "#0a1220",
tab: "#111c30",
address: "#101827",
accent: "#facc15",
text: "#f8fafc",
badge: "AI",
},
BrowserTheme::Nebula => ThemePreviewTokens {
background: "#fafafa",
surface: "#ffffff",
toolbar: "#f1f1f1",
tab: "#ffffff",
address: "#ffffff",
accent: "#111827",
text: "#111827",
badge: "Minimal",
},
BrowserTheme::Ember => ThemePreviewTokens {
background: "#f2f2f2",
surface: "#ffffff",
toolbar: "#e5e5e5",
tab: "#ffffff",
address: "#ffffff",
accent: "#4b5563",
text: "#202124",
badge: "Graphite",
},
BrowserTheme::Glacier => ThemePreviewTokens {
background: "#f3f8ff",
surface: "#ffffff",
toolbar: "#e8f1fb",
tab: "#ffffff",
address: "#ffffff",
accent: "#2563eb",
text: "#172033",
badge: "Edge",
},
BrowserTheme::Ocean => ThemePreviewTokens {
background: "#f3f8ff",
surface: "#ffffff",
toolbar: "#e6f0ff",
tab: "#ffffff",
address: "#ffffff",
accent: "#2563eb",
text: "#172033",
badge: "Brave",
},
BrowserTheme::Forest => ThemePreviewTokens {
background: "#f4fbf6",
surface: "#ffffff",
toolbar: "#e8f4ec",
tab: "#ffffff",
address: "#ffffff",
accent: "#16a34a",
text: "#173323",
badge: "Vivaldi",
},
BrowserTheme::Rose => ThemePreviewTokens {
background: "#fff7f1",
surface: "#ffffff",
toolbar: "#f8ebe4",
tab: "#ffffff",
address: "#ffffff",
accent: "#e95420",
text: "#352018",
badge: "Ubuntu",
},
BrowserTheme::HighContrastLight => ThemePreviewTokens {
background: "#ffffff",
surface: "#ffffff",
toolbar: "#ffffff",
tab: "#ffffff",
address: "#ffffff",
accent: "#003cff",
text: "#000000",
badge: "A11y",
},
BrowserTheme::HighContrastDark => ThemePreviewTokens {
background: "#000000",
surface: "#000000",
toolbar: "#000000",
tab: "#000000",
address: "#000000",
accent: "#00e5ff",
text: "#ffffff",
badge: "A11y",
},
BrowserTheme::PrivateBrowsing => ThemePreviewTokens {
background: "#171123",
surface: "#241936",
toolbar: "#1d142d",
tab: "#2c2042",
address: "#241936",
accent: "#c084fc",
text: "#fbf7ff",
badge: "Private",
},
BrowserTheme::Custom => {
let _ = custom_theme;
ThemePreviewTokens {
background: "var(--custom-page)",
surface: "var(--custom-surface)",
toolbar: "var(--custom-chrome)",
tab: "var(--custom-surface)",
address: "var(--custom-surface)",
accent: "var(--custom-accent)",
text: "var(--custom-text)",
badge: "User",
}
}
}
}
const INTERNAL_PAGE_PERFORMANCE_CSS: &str = r#"
@supports (content-visibility: auto) {
.card,
.metric,
.mini,
.quick-card,
.doc-card,
.step,
.lab-card,
.tool-card,
.agent-card,
.theme-card,
.plugin-card,
.plugin-check-panel,
.plugin-panel {
content-visibility: auto;
contain-intrinsic-size: auto 220px;
}
}
@supports (contain: layout paint) {
.card,
.metric,
.mini,
.quick-card,
.doc-card,
.step,
.lab-card,
.tool-card,
.agent-card,
.theme-card,
.plugin-card,
.search,
.site-icon {
contain: layout paint;
}
}
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
.site-icon,
.newtab form,
.search,
.nav-item,
.pill,
.save-button,
.theme-chip,
.theme-card,
.plugin-card,
.doc-card,
.quick-card,
.step,
.lab-card,
.tool-card,
.agent-card,
.primary-pill,
.agent-link,
.theme-toggle,
.ai-toggle,
.settings-dot {
transition: transform .16s cubic-bezier(.2, 0, 0, 1), background-color .16s ease, border-color .16s ease, box-shadow .16s ease, color .16s ease, filter .16s ease, opacity .16s ease;
}
.site-icon { transform: translateZ(0); }
.site-tile:hover .site-icon,
.site-tile:focus-visible .site-icon {
transform: translate3d(0, -3px, 0);
}
.newtab form:focus-within,
.search:focus-within,
.nav-item:hover,
.nav-item:focus-visible,
.pill[href]:hover,
.pill[href]:focus-visible,
.save-button:hover,
.save-button:focus-visible,
.theme-chip:hover,
.theme-chip:focus-visible,
.theme-card:hover,
.theme-card:focus-within,
.plugin-card:hover,
.plugin-card:focus-within,
.doc-card:hover,
.doc-card:focus-within,
.quick-card:hover,
.quick-card:focus-within,
.step:hover,
.lab-card:hover,
.lab-card:focus-visible,
.tool-card:hover,
.tool-card:focus-visible,
.agent-card:hover,
.primary-pill:hover,
.primary-pill:focus-visible,
.agent-link:hover,
.agent-link:focus-visible,
.theme-toggle:hover,
.theme-toggle:focus-visible,
.ai-toggle:hover,
.ai-toggle:focus-visible,
.settings-dot:hover {
transform: translate3d(0, -1px, 0);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: .01ms !important;
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
}
.site-tile:hover .site-icon,
.site-tile:focus-visible .site-icon,
.newtab form:focus-within,
.search:focus-within,
.nav-item:hover,
.nav-item:focus-visible,
.pill[href]:hover,
.pill[href]:focus-visible,
.save-button:hover,
.save-button:focus-visible,
.theme-chip:hover,
.theme-chip:focus-visible,
.theme-card:hover,
.theme-card:focus-within,
.plugin-card:hover,
.plugin-card:focus-within,
.doc-card:hover,
.doc-card:focus-within,
.quick-card:hover,
.quick-card:focus-within,
.step:hover,
.lab-card:hover,
.lab-card:focus-visible,
.tool-card:hover,
.tool-card:focus-visible,
.agent-card:hover,
.primary-pill:hover,
.primary-pill:focus-visible,
.agent-link:hover,
.agent-link:focus-visible,
.theme-toggle:hover,
.theme-toggle:focus-visible,
.ai-toggle:hover,
.ai-toggle:focus-visible,
.settings-dot:hover {
transform: none !important;
}
}
@supports ((backdrop-filter: blur(16px)) or (-webkit-backdrop-filter: blur(16px))) {
body.glass .card,
body.glass .metric,
body.glass .mini,
body.glass .quick-card,
body.glass .doc-card,
body.glass .step,
body.glass .lab-card,
body.glass .tool-card,
body.glass .agent-card,
body.glass .theme-card,
body.glass .plugin-card,
body.glass .search,
body.glass .sidebar,
body.glass .site-tile,
body.glass .top-controls > * {
backdrop-filter: blur(18px) saturate(1.28);
-webkit-backdrop-filter: blur(18px) saturate(1.28);
}
}
body.agent-inspect .card,
body.agent-inspect .metric,
body.agent-inspect .mini,
body.agent-inspect .quick-card,
body.agent-inspect .doc-card,
body.agent-inspect .step,
body.agent-inspect .lab-card,
body.agent-inspect .tool-card,
body.agent-inspect .agent-card,
body.agent-inspect .theme-card,
body.agent-inspect .plugin-card,
body.agent-inspect .search,
body.agent-inspect .site-tile,
body.agent-inspect form {
border-width: 2px;
box-shadow: 0 0 0 1px rgba(250,204,21,.14);
}
body.agent-inspect .sidebar { border-right: 3px solid #38bdf8; }
body.agent-inspect .search { border-color: #facc15; }
body.agent-inspect .nav-item { border: 1px solid transparent; }
body.agent-inspect .nav-item:nth-child(3n+1) { border-left: 4px solid #38bdf8; }
body.agent-inspect .nav-item:nth-child(3n+2) { border-left: 4px solid #22c55e; }
body.agent-inspect .nav-item:nth-child(3n) { border-left: 4px solid #f472b6; }
body.agent-inspect .row { border-bottom-color: #facc15; }
body.agent-inspect .pill { border-color: #38bdf8; }
body.agent-inspect .pill.primary,
body.agent-inspect .save-button,
body.agent-inspect .primary-pill {
color: #08111f;
background: #facc15;
border-color: #facc15;
}
"#;
const LANDING_TEMPLATE: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Start</title>
<style>
:root {
color-scheme: light;
--text: #172033;
--muted: rgba(23,32,51,.7);
--soft: rgba(23,32,51,.48);
--panel: rgba(255,255,255,.74);
--panel-strong: rgba(255,255,255,.9);
--tile: rgba(255,255,255,.78);
--tile-hover: #fff;
--line: rgba(23,32,51,.12);
--accent: #2556d9;
--accent-2: #8b7cf6;
}
body.professional { --accent: #4f46e5; --accent-2: #64748b; background: radial-gradient(ellipse at 18% 16%, rgba(226,232,240,.82), transparent 34%), radial-gradient(ellipse at 84% 20%, rgba(199,210,254,.7), transparent 34%), linear-gradient(180deg, #fbfcff 0%, #eef2f7 58%, #e4e9f2 100%); }
body.nebula { --accent: #111827; --accent-2: #6b7280; --panel: rgba(255,255,255,.84); --panel-strong: rgba(255,255,255,.96); background: radial-gradient(ellipse at 18% 16%, rgba(229,231,235,.9), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(243,244,246,.88), transparent 34%), linear-gradient(180deg, #ffffff 0%, #f4f4f5 58%, #eeeeef 100%); }
body.ember { --accent: #4b5563; --accent-2: #9ca3af; background: radial-gradient(ellipse at 18% 16%, rgba(229,231,235,.82), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(209,213,219,.72), transparent 34%), linear-gradient(180deg, #fbfbfb 0%, #ededed 58%, #e5e5e5 100%); }
body.glacier { --accent: #2563eb; --accent-2: #60a5fa; background: radial-gradient(ellipse at 18% 16%, rgba(219,234,254,.86), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(191,219,254,.72), transparent 34%), linear-gradient(180deg, #fbfdff 0%, #eef6ff 58%, #e5effb 100%); }
body.paper { background: radial-gradient(ellipse at 50% 24%, rgba(255,255,255,.92), transparent 34%), radial-gradient(ellipse at 16% 18%, rgba(208,221,242,.72), transparent 34%), radial-gradient(ellipse at 82% 20%, rgba(232,219,199,.78), transparent 34%), linear-gradient(180deg, #f8fafc 0%, #eef2f7 54%, #e7ecf4 100%); }
body.dark-paper { --text: #f4efe6; --muted: rgba(244,239,230,.72); --soft: rgba(244,239,230,.52); --panel: rgba(37,34,29,.78); --panel-strong: rgba(30,28,24,.94); --tile: rgba(232,218,194,.28); --tile-hover: rgba(245,234,214,.42); --line: rgba(232,218,194,.18); --accent: #d49a4a; --accent-2: #8b7cf6; background: radial-gradient(ellipse at 50% 18%, rgba(212,154,74,.22), transparent 32%), radial-gradient(ellipse at 18% 20%, rgba(80,70,55,.56), transparent 34%), radial-gradient(ellipse at 78% 18%, rgba(46,42,35,.72), transparent 35%), linear-gradient(180deg, #181713 0%, #25221d 48%, #151413 100%); }
body.cupertino { --text: #18202e; --muted: rgba(24,32,46,.68); --soft: rgba(24,32,46,.48); --panel: rgba(255,255,255,.66); --panel-strong: rgba(255,255,255,.86); --tile: rgba(255,255,255,.74); --tile-hover: rgba(255,255,255,.94); --line: rgba(29,41,57,.13); --accent: #007aff; --accent-2: #af52de; background: radial-gradient(ellipse at 18% 16%, rgba(236,242,255,.86), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(226,232,240,.78), transparent 34%), linear-gradient(180deg, #fbfbfd 0%, #f2f4f8 58%, #e9edf4 100%); }
body.glass { --text: #172033; --muted: rgba(23,32,51,.7); --soft: rgba(23,32,51,.48); --panel: rgba(255,255,255,.48); --panel-strong: rgba(255,255,255,.76); --tile: rgba(255,255,255,.56); --tile-hover: rgba(255,255,255,.86); --line: rgba(37,86,217,.18); --accent: #4f7cff; --accent-2: #9f8cff; background: radial-gradient(ellipse at 16% 14%, rgba(191,219,254,.78), transparent 34%), radial-gradient(ellipse at 84% 20%, rgba(224,231,255,.72), transparent 34%), linear-gradient(180deg, #f8fbff 0%, #eaf2ff 54%, #dce7f6 100%); }
body.agent-inspect { color-scheme: dark; --text: #f8fafc; --muted: #cbd5e1; --soft: #94a3b8; --panel: rgba(8,17,31,.9); --panel-strong: #0d1728; --tile: rgba(15,23,42,.92); --tile-hover: #14213a; --line: #facc15; --accent: #facc15; --accent-2: #38bdf8; background: linear-gradient(135deg, #08111f 0%, #0f172a 55%, #111827 100%); }
body.ocean { --accent: #2563eb; --accent-2: #f97316; background: radial-gradient(ellipse at 18% 16%, rgba(219,234,254,.86), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(255,237,213,.74), transparent 34%), linear-gradient(180deg, #fbfdff 0%, #eef6ff 58%, #e6effb 100%); }
body.forest { --accent: #16a34a; --accent-2: #2563eb; background: radial-gradient(ellipse at 18% 16%, rgba(220,252,231,.84), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(219,234,254,.66), transparent 34%), linear-gradient(180deg, #fbfffc 0%, #eef8f1 58%, #e7f1eb 100%); }
body.rose { --accent: #e95420; --accent-2: #a855f7; background: radial-gradient(ellipse at 18% 16%, rgba(255,237,213,.84), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(233,213,255,.66), transparent 34%), linear-gradient(180deg, #fffdfb 0%, #fff1e8 58%, #f6e8df 100%); }
body.high-contrast-light { color-scheme: light; --text: #000; --muted: #111; --soft: #222; --panel: rgba(255,255,255,.92); --panel-strong: #fff; --tile: #fff; --tile-hover: #f2f2f2; --line: #000; --accent: #003cff; --accent-2: #8b00ff; }
body.high-contrast-dark { --text: #fff; --muted: #f4f4f4; --soft: #d8d8d8; --panel: rgba(0,0,0,.92); --panel-strong: #000; --tile: rgba(255,255,255,.16); --tile-hover: rgba(255,255,255,.28); --line: #fff; --accent: #00e5ff; --accent-2: #ffff00; background: #000; }
body.private-browsing { --text: #fbf7ff; --muted: rgba(251,247,255,.72); --soft: rgba(251,247,255,.5); --panel: rgba(35,19,58,.78); --panel-strong: rgba(35,19,58,.94); --tile: rgba(216,180,254,.3); --tile-hover: rgba(233,213,255,.48); --line: rgba(216,180,254,.18); --accent: #c084fc; --accent-2: #f472b6; background: radial-gradient(ellipse at 18% 16%, rgba(91,60,130,.58), transparent 34%), radial-gradient(ellipse at 82% 22%, rgba(244,114,182,.26), transparent 34%), linear-gradient(180deg, #171123 0%, #241936 58%, #130b20 100%); }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
html { min-height: 100%; background: #f4f6fb; }
body {
min-height: 100vh;
margin: 0;
font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(ellipse at 18% 18%, rgba(208,221,242,.72), transparent 34%),
radial-gradient(ellipse at 82% 20%, rgba(232,219,199,.72), transparent 34%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 56%, #e7ecf4 100%);
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.22));
}
body.dark-paper::after,
body.private-browsing::after,
body.high-contrast-dark::after,
body.agent-inspect::after { background: linear-gradient(180deg, rgba(18,15,11,.08), rgba(18,15,11,.38)); }
body.dark-paper .site-tile,
body.private-browsing .site-tile,
body.high-contrast-dark .site-tile,
body.agent-inspect .site-tile { text-shadow: none; }
a { color: inherit; text-decoration: none; }
.newtab {
position: relative;
z-index: 1;
min-height: 100vh;
display: grid;
grid-template-rows: 1fr auto;
padding: clamp(28px, 6vh, 72px) clamp(18px, 4vw, 48px) 28px;
}
.center {
align-self: start;
justify-self: center;
width: min(860px, 100%);
padding-top: min(7vh, 72px);
}
.shortcuts {
display: grid;
grid-template-columns: repeat(7, minmax(72px, 1fr));
gap: 18px;
align-items: start;
margin: 0 auto 34px;
}
.site-tile {
display: grid;
justify-items: center;
gap: 10px;
min-width: 0;
color: var(--text);
font-size: 13px;
font-weight: 700;
text-shadow: none;
}
.site-icon {
display: grid;
place-items: center;
width: 72px;
height: 72px;
border-radius: 18px;
color: #1f2329;
background: var(--tile);
box-shadow: 0 12px 36px rgba(0,0,0,.26), inset 0 1px rgba(255,255,255,.34);
backdrop-filter: blur(16px);
font-size: 29px;
font-weight: 800;
}
.site-tile:hover .site-icon { background: var(--tile-hover); transform: translateY(-1px); }
.site-label {
display: block;
width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.pager {
display: flex;
justify-content: center;
gap: 7px;
margin: -12px 0 34px;
}
.pager span {
width: 22px;
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,.95);
box-shadow: 0 3px 12px rgba(0,0,0,.35);
}
.pager span + span { width: 10px; opacity: .86; }
form {
display: flex;
align-items: center;
width: min(640px, 100%);
min-height: 66px;
margin: 0 auto;
padding: 0 18px;
border-radius: 15px;
background: var(--panel-strong);
box-shadow: 0 18px 58px rgba(34,45,70,.16);
}
.search-mark {
display: grid;
place-items: center;
width: 28px;
height: 28px;
margin-right: 12px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
font-weight: 900;
}
input, button {
border: 0;
outline: none;
background: transparent;
color: var(--text);
font: 500 20px Inter, "Segoe UI", sans-serif;
}
input { flex: 1; min-width: 0; }
input::placeholder { color: var(--soft); }
button {
min-height: 40px;
padding: 0 14px;
border-radius: 10px;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
button:hover { background: rgba(23,32,51,.08); color: var(--text); }
.top-controls {
position: fixed;
top: 36px;
right: 40px;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
max-width: calc(100vw - 80px);
}
.settings-dot {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text);
background: var(--panel);
border: 1px solid var(--line);
backdrop-filter: blur(14px);
}
.ai-toggle {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 8px 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--panel);
backdrop-filter: blur(14px);
font-size: 12px;
font-weight: 800;
}
.agent-link {
min-height: 34px;
display: inline-flex;
align-items: center;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--panel);
backdrop-filter: blur(14px);
font-size: 12px;
font-weight: 800;
}
.theme-toggle {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--panel);
backdrop-filter: blur(14px);
font-size: 12px;
font-weight: 800;
}
.theme-toggle strong {
max-width: 112px;
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-link:hover, .theme-toggle:hover { background: var(--panel-strong); color: var(--text); }
.ai-toggle:hover { background: var(--panel-strong); color: var(--text); }
.ai-toggle.on {
border-color: rgba(255,255,255,.22);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.ai-switch {
position: relative;
width: 34px;
height: 20px;
border-radius: 999px;
background: rgba(37,86,217,.18);
box-shadow: inset 0 1px 4px rgba(0,0,0,.24);
}
.ai-switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,.28);
transition: transform .16s ease;
}
.ai-toggle.on .ai-switch { background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
.ai-toggle.on .ai-switch::after { transform: translateX(14px); }
.ai-state { color: var(--soft); }
.cards {
align-self: end;
display: grid;
grid-template-columns: minmax(260px, 420px) minmax(320px, 1fr);
gap: clamp(18px, 18vw, 360px);
align-items: end;
}
.card {
min-height: 138px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
box-shadow: 0 22px 60px rgba(0,0,0,.25);
backdrop-filter: blur(18px);
}
.card h2 {
margin: 0 0 28px;
color: var(--text);
font-size: 14px;
letter-spacing: .04em;
text-transform: uppercase;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.stat strong {
display: block;
margin-bottom: 4px;
color: var(--text);
font-size: clamp(24px, 3vw, 34px);
letter-spacing: -.04em;
}
.stat:first-child strong { color: var(--accent); }
.stat:nth-child(2) strong { color: var(--accent-2); }
.stat span, .armour-copy, .credit {
color: var(--muted);
font-size: 14px;
}
.armour-card {
justify-self: end;
width: min(780px, 100%);
display: grid;
grid-template-columns: 58px 1fr auto;
gap: 20px;
align-items: center;
}
.armour-badge {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
font-weight: 900;
}
.armour-card h2 { margin-bottom: 10px; }
.armour-copy { line-height: 1.55; }
.primary-pill {
display: inline-grid;
place-items: center;
min-height: 46px;
padding: 0 22px;
border-radius: 999px;
color: #fff;
background: rgba(255,255,255,.22);
font-weight: 800;
}
.primary-pill:hover { filter: brightness(1.06); }
.credit {
position: fixed;
left: 32px;
bottom: 238px;
z-index: 2;
color: var(--soft);
}
body.nebula .site-icon, body.nebula form, body.nebula .card, body.nebula .settings-dot { border-radius: 14px; }
body.paper .site-tile { color: #172033; text-shadow: none; }
body.paper .site-icon { color: #172033; box-shadow: 0 14px 34px rgba(34,45,70,.12), inset 0 1px rgba(255,255,255,.86); }
body.paper form { background: rgba(255,255,255,.88); box-shadow: 0 18px 58px rgba(34,45,70,.14); }
body.paper input, body.paper button { color: #172033; }
body.paper input::placeholder { color: rgba(23,32,51,.46); }
body.paper button { color: #596273; }
body.paper button:hover { color: #172033; background: rgba(23,32,51,.08); }
body.paper .settings-dot, body.paper .ai-toggle, body.paper .agent-link, body.paper .theme-toggle { color: #172033; background: rgba(255,255,255,.68); border-color: rgba(23,32,51,.12); }
body.paper .theme-toggle strong { color: #172033; }
body.paper .ai-toggle:hover, body.paper .ai-toggle.on, body.paper .agent-link:hover, body.paper .theme-toggle:hover { color: #172033; background: rgba(255,255,255,.9); }
body.paper .ai-switch { background: rgba(37,86,217,.18); }
body.paper .pager span { background: rgba(23,32,51,.78); box-shadow: 0 3px 12px rgba(34,45,70,.18); }
body.paper .card h2, body.paper .stat strong { color: #172033; }
body.paper .primary-pill { color: #fff; background: #2556d9; }
body.paper .primary-pill:hover { background: #1f48b8; }
body.paper .credit { color: rgba(23,32,51,.46); }
@media (max-width: 980px) {
.shortcuts { grid-template-columns: repeat(4, minmax(66px, 1fr)); }
.cards { grid-template-columns: 1fr; gap: 18px; }
.armour-card { justify-self: stretch; grid-template-columns: 48px 1fr; }
.primary-pill { grid-column: 1 / -1; justify-self: start; }
.credit { display: none; }
}
@media (max-width: 560px) {
.newtab { padding: 22px 14px; }
.top-controls { top: 18px; left: 18px; right: 18px; justify-content: flex-end; gap: 6px; }
.ai-toggle { padding: 0 7px; }
.theme-toggle { padding: 0 10px; }
.theme-toggle strong { display: none; }
.agent-link { padding: 0 10px; }
.ai-label, .ai-state { display: none; }
.center { padding-top: 22px; }
.shortcuts { grid-template-columns: repeat(3, minmax(64px, 1fr)); gap: 14px; }
.site-icon { width: 62px; height: 62px; }
form { min-height: 58px; }
input { font-size: 16px; }
.stats-grid { grid-template-columns: 1fr; }
}
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="top-controls" aria-label="Start page controls">
<a class="agent-link" href="cephas://agent">Agent</a>
<a class="theme-toggle" href="cephas://start/theme" title="Switch to __NEXT_THEME_LABEL__" data-next-theme="__NEXT_THEME_ID__"><span>Theme</span><strong>__THEME_LABEL__</strong></a>
<a class="ai-toggle __AI_BROWSING_CLASS__" role="switch" aria-checked="__AI_BROWSING_CHECKED__" title="AI can control the browser when enabled" href="cephas://start/ai?enabled=__AI_BROWSING_NEXT__">
<span class="ai-label">AI browsing</span><span class="ai-switch" aria-hidden="true"></span><span class="ai-state">__AI_BROWSING_STATE__</span>
</a>
<span class="settings-dot" aria-label="New tab settings">*</span>
</div>
<span class="credit">Local scene / __THEME_LABEL__</span>
<main class="newtab">
<section class="center" aria-label="Start page">
<nav class="shortcuts" aria-label="Shortcuts">
__SHORTCUTS__
</nav>
<div class="pager" aria-hidden="true"><span></span><span></span></div>
<form action="__SEARCH_ACTION__" method="get">
<span class="search-mark">D</span>
<input name="__SEARCH_QUERY_NAME__" type="search" placeholder="Search __SEARCH_LABEL__ or type a URL" autofocus autocomplete="off" spellcheck="false">
<button type="submit">Search</button>
</form>
</section>
<section class="cards" aria-label="Profile status">
<article class="card">
<h2>Stats</h2>
<div class="stats-grid">
<div class="stat"><strong>__HISTORY_COUNT__</strong><span>History entries</span></div>
<div class="stat"><strong>__BOOKMARK_COUNT__</strong><span>Bookmarks saved</span></div>
<div class="stat"><strong>__ADDON_COUNT__</strong><span>Plugin slots</span></div>
</div>
</article>
<article class="card armour-card">
<div class="armour-badge">A</div>
<div><h2>Armour</h2><div class="armour-copy">HTTPS upgrades, GPC, DNT, clean links, redirect debouncing, tracker blocking, and De-AMP are available from the toolbar.</div></div>
<a class="primary-pill" href="__PRIVACY_SEARCH_URL__">Review privacy</a>
</article>
</section>
</main>
</body>
</html>"#;
const AGENT_TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Agent Control</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1014;
--rail: #191a20;
--surface: #18191f;
--surface-2: #202127;
--line: #30323a;
--line-soft: rgba(255,255,255,.08);
--text: #f0f2f6;
--muted: #a8adb7;
--soft: #7f8794;
--accent: #7ab2ff;
--danger: #ff6b5f;
--armour: #ff5d36;
}
body.professional { color-scheme: light; --bg: #f3f5f9; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7ebf2; --line: #d4dbe7; --line-soft: rgba(31,41,55,.08); --text: #1f2937; --muted: #5f6b7a; --soft: #7a8494; --accent: #4f46e5; --danger: #b42318; --armour: #4f46e5; }
body.nebula { color-scheme: light; --bg: #fafafa; --rail: #ffffff; --surface: #ffffff; --surface-2: #f1f1f1; --line: #dcdcdc; --line-soft: rgba(17,24,39,.08); --text: #111827; --muted: #5f6673; --soft: #7d8593; --accent: #111827; --danger: #b42318; --armour: #111827; }
body.ember { color-scheme: light; --bg: #f2f2f2; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7e7e7; --line: #d1d5db; --line-soft: rgba(32,33,36,.08); --text: #202124; --muted: #5f6368; --soft: #80868b; --accent: #4b5563; --danger: #b42318; --armour: #4b5563; }
body.glacier { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f1fb; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --danger: #b42318; --armour: #2563eb; }
body.paper { color-scheme: light; --bg: #f4f6fb; --rail: #ffffff; --surface: #ffffff; --surface-2: #eef2f7; --line: #d7dde7; --line-soft: rgba(16,24,40,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2556d9; --danger: #b42318; --armour: #2556d9; }
body.dark-paper { --bg: #141312; --rail: #1d1b18; --surface: #25221d; --surface-2: #2f2b25; --line: #3c382f; --line-soft: rgba(232,218,194,.1); --text: #f4efe6; --muted: #b5aa98; --soft: #8e8271; --accent: #d49a4a; --danger: #ff8a70; --armour: #d49a4a; }
body.cupertino { color-scheme: light; --bg: #f5f5f7; --rail: rgba(255,255,255,.82); --surface: rgba(255,255,255,.88); --surface-2: #eef2f8; --line: #d8dee8; --line-soft: rgba(29,41,57,.08); --text: #1d1d1f; --muted: #5f6673; --accent: #007aff; --armour: #007aff; }
body.glass { color-scheme: light; --bg: #eaf2ff; --rail: rgba(255,255,255,.58); --surface: rgba(255,255,255,.72); --surface-2: rgba(238,244,255,.78); --line: rgba(79,124,255,.24); --line-soft: rgba(79,124,255,.14); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #4f7cff; --danger: #b42318; --armour: #4f7cff; }
body.agent-inspect { color-scheme: dark; --bg: #08111f; --rail: #0a1220; --surface: #0d1728; --surface-2: #111c30; --line: #facc15; --line-soft: rgba(250,204,21,.32); --text: #f8fafc; --muted: #cbd5e1; --soft: #94a3b8; --accent: #facc15; --danger: #fb7185; --armour: #38bdf8; }
body.ocean { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e6f0ff; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --danger: #b42318; --armour: #f97316; }
body.forest { color-scheme: light; --bg: #f4fbf6; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f4ec; --line: #cfe6d6; --line-soft: rgba(22,163,74,.08); --text: #173323; --muted: #586b5f; --soft: #748579; --accent: #16a34a; --danger: #b42318; --armour: #2563eb; }
body.rose { color-scheme: light; --bg: #fff7f1; --rail: #ffffff; --surface: #ffffff; --surface-2: #f8ebe4; --line: #ead6cb; --line-soft: rgba(233,84,32,.08); --text: #352018; --muted: #715f58; --soft: #8a7972; --accent: #e95420; --danger: #b42318; --armour: #a855f7; }
body.high-contrast-light { color-scheme: light; --bg: #fff; --rail: #fff; --surface: #fff; --surface-2: #f0f0f0; --line: #000; --line-soft: #000; --text: #000; --muted: #111; --soft: #222; --accent: #003cff; --danger: #b00000; --armour: #003cff; }
body.high-contrast-dark { --bg: #000; --rail: #000; --surface: #000; --surface-2: #111; --line: #fff; --line-soft: #fff; --text: #fff; --muted: #f4f4f4; --soft: #d8d8d8; --accent: #00e5ff; --danger: #ff6060; --armour: #ffff00; }
body.private-browsing { --bg: #130b20; --rail: #1c1031; --surface: #261541; --surface-2: #321b55; --line: #5b3c82; --accent: #c084fc; --armour: #f472b6; }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
a { color: inherit; }
.agent-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 18px;
padding: 28px 18px 18px;
background: var(--rail);
border-right: 1px solid var(--line-soft);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
padding: 0 8px 10px;
font-size: 30px;
font-weight: 750;
letter-spacing: -.04em;
}
.brand-mark, .nav-icon {
display: grid;
place-items: center;
flex: 0 0 auto;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--armour), #9f8cff);
font-size: 15px;
font-weight: 900;
}
.nav { overflow: auto; padding-right: 6px; }
.nav-section { padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.nav-section:last-child { border-bottom: 0; }
.nav-item {
display: grid;
grid-template-columns: 34px 1fr;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 8px;
border-radius: 12px;
color: var(--text);
text-decoration: none;
font-size: 15px;
font-weight: 650;
}
.nav-item:hover, .nav-item.active { background: var(--surface-2); }
.nav-icon { color: var(--muted); font-size: 18px; font-weight: 800; }
.content { min-width: 0; padding: 18px 24px 54px; }
.search-wrap {
position: sticky;
top: 0;
z-index: 2;
padding: 0 0 10px;
background: linear-gradient(180deg, var(--bg) 75%, transparent);
}
.search {
width: min(820px, 76vw);
min-height: 54px;
display: flex;
align-items: center;
gap: 12px;
margin: 0 auto;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 13px;
background: #101116;
}
body.paper .search { background: #fff; }
body.dark-paper .search { background: #25221d; }
.search span { color: var(--soft); font-size: 24px; }
.search input {
width: 100%;
border: 0;
outline: 0;
color: var(--text);
background: transparent;
font-size: 20px;
}
.search input::placeholder { color: var(--soft); }
.main {
width: min(920px, calc(100vw - 340px));
margin: 0 auto;
padding-top: 22px;
}
h1 { margin: 0 0 10px; font-size: 24px; }
h2 { margin: 34px 0 16px; font-size: 21px; letter-spacing: -.02em; }
.lead { margin: 0 0 18px; color: var(--muted); font-size: 14px; line-height: 1.55; }
.card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 12px 30px rgba(0,0,0,.2);
}
.row {
min-height: 58px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
}
.row:last-child { border-bottom: 0; }
.row-title { font-size: 16px; overflow-wrap: anywhere; }
.row-copy, .meta, .empty { margin-top: 5px; color: var(--muted); font-size: 13px; line-height: 1.45; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.pill {
min-height: 38px;
display: inline-grid;
place-items: center;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
font-weight: 750;
}
.pill.primary { color: #fff; background: var(--accent); border-color: var(--accent); }
.pill.danger { color: #fff; background: var(--danger); border-color: var(--danger); }
.pill.disabled { color: var(--soft); cursor: default; opacity: .62; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.metric { padding: 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.metric strong { display: block; margin-bottom: 4px; font-size: 26px; letter-spacing: -.04em; }
.metric span { color: var(--muted); font-size: 13px; }
.overview-action { margin-top: 12px; }
.route-list { display: grid; gap: 8px; padding: 14px 20px 18px; }
.route-list code { display: block; overflow-wrap: anywhere; }
.lab-output {
display: grid;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.lab-output h3 { margin: 2px 0 0; font-size: 22px; }
.lab-output p { margin: 0; color: var(--muted); line-height: 1.5; }
.lab-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.lab-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 12px; }
.lab-card {
min-height: 168px;
display: grid;
gap: 8px;
align-content: start;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
}
.lab-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.lab-card strong { font-size: 15px; }
.lab-card span { color: var(--muted); font-size: 12px; line-height: 1.45; }
.lab-card-foot { display: grid; gap: 6px; margin-top: auto; }
.lab-card-foot code { display: block; overflow-wrap: anywhere; font-size: 11px; }
.is-hidden { display: none !important; }
.tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 12px; }
.tool-card {
min-height: 94px;
display: grid;
gap: 8px;
align-content: start;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
}
.tool-card:hover { border-color: var(--accent); }
.tool-card.disabled { color: var(--soft); opacity: .64; }
.tool-card strong { font-size: 14px; }
.tool-card span { color: var(--muted); font-size: 12px; line-height: 1.45; }
.agents { display: grid; gap: 12px; padding: 14px; }
.agent-card { padding: 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); }
.agent-card strong { display: block; margin-top: 4px; overflow-wrap: anywhere; font-size: 15px; }
.agent-card p { margin: 12px 0; color: var(--muted); line-height: 1.5; }
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .16em; text-transform: uppercase; }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--muted); background: var(--surface-2); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
td { font-size: 13px; line-height: 1.45; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { color: var(--accent); }
pre {
overflow: auto;
max-height: 420px;
margin: 0;
padding: 18px;
color: var(--text);
background: var(--surface-2);
border-top: 1px solid var(--line);
white-space: pre-wrap;
}
.json-details summary {
min-height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
color: var(--text);
cursor: pointer;
font-weight: 750;
}
.json-details summary span { color: var(--muted); font-size: 13px; font-weight: 500; }
@media (max-width: 900px) {
.agent-shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; }
.nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.nav-section { border-bottom: 0; }
.main, .search { width: 100%; }
.row { grid-template-columns: 1fr; }
.actions { justify-content: flex-start; }
.metric-grid, .tool-grid, .lab-grid { grid-template-columns: 1fr; }
th:nth-child(3), td:nth-child(3) { display: none; }
}
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="agent-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">D</span><span>Agent</span></div>
<nav class="nav" aria-label="Agent sections">
<div class="nav-section">
<a class="nav-item active" href="#overview"><span class="nav-icon">A</span><span>Overview</span></a>
<a class="nav-item" href="#vision"><span class="nav-icon">V</span><span>Agent vision</span></a>
<a class="nav-item" href="#emulation"><span class="nav-icon">E</span><span>Emulation lab</span></a>
<a class="nav-item" href="#delegate"><span class="nav-icon">D</span><span>Delegation</span></a>
<a class="nav-item" href="#human"><span class="nav-icon">H</span><span>Human control</span></a>
<a class="nav-item" href="#recording"><span class="nav-icon">R</span><span>Recording</span></a>
<a class="nav-item" href="#reader"><span class="nav-icon">T</span><span>Reader tools</span></a>
</div>
<div class="nav-section">
<a class="nav-item" href="cephas://agent/docs"><span class="nav-icon">I</span><span>Integration docs</span></a>
<a class="nav-item" href="#audit"><span class="nav-icon">L</span><span>Audit</span></a>
<a class="nav-item" href="#manifest"><span class="nav-icon">M</span><span>Manifest</span></a>
<a class="nav-item" href="#context"><span class="nav-icon">J</span><span>Context JSON</span></a>
</div>
</nav>
</aside>
<main class="content">
<div class="search-wrap"><label class="search"><span>Q</span><input id="agent-search" type="search" placeholder="Search agent controls" autocomplete="off" aria-label="Search agent controls"></label></div>
<section class="main">
<h1 id="overview">Agent Control</h1>
<p class="lead">A local, auditable command surface for browser-control agents. Delegations, command requests, reader artifacts, and recording events are opt-in and ordered for replay.</p>
<div class="metric-grid">
<div class="metric"><strong>__AI_STATE__</strong><span>AI browsing</span></div>
<div class="metric"><strong>__ACTIVE_AGENT_COUNT__</strong><span>Active delegations</span></div>
<div class="metric"><strong>__HUMAN_CONTROL_STATE__</strong><span>Human control</span></div>
<div class="metric"><strong>__RECORDING_STATE__</strong><span>Visual recording</span></div>
</div>
<div class="card overview-action">
<div class="row"><div><div class="row-title">Agentic integration docs</div><div class="row-copy">Local hook-in guide for command discovery, JSON surfaces, smoke testing, human-control safety, and code-tool porting.</div></div><div class="actions"><a class="pill primary" href="cephas://agent/docs">Open docs</a></div></div>
</div>
<h2 id="vision">Agent Vision</h2>
<div class="card">
<div class="row"><div><div class="row-title">Watchable browser state</div><div class="row-copy">Cephas exposes the active WebKit visible region through screenshots and bounded visual recordings, with Agent command metadata kept in JSONL events.</div></div><span class="pill">cephas.agent-vision.v1</span></div>
<div class="row"><div><div class="row-title">Command-to-frame hints</div><div class="row-copy">Emulated commands and completed human-control actions request recording frames with reasons such as <code>agent-emulation:<command-id></code> and <code>human-control:<action></code>.</div></div><span class="pill">Replay-ready</span></div>
<div class="row"><div><div class="row-title">Backend posture</div><div class="row-copy">Artifacts stay local until future authenticated backend sync enables <code>sync_recordings</code>; private-tab frames remain ineligible for upload.</div></div><span class="pill">Local first</span></div>
</div>
<details class="card json-details"><summary>Show Agent Vision packet <span>Current window context</span></summary><pre>__AGENT_VISION_PACKET__</pre></details>
<h2 id="emulation">Agent Emulation Lab</h2>
<p class="lead">Click any command to dry-run what an agent would ask Cephas to do. The lab updates this page with the request shape, current integration surface, and expected result without performing destructive actions.</p>
<div class="card">__EMULATION_LAB__</div>
<h2 id="delegate">Delegation</h2>
<div class="card">
<div class="row"><div><div class="row-title">Window target</div><div class="row-copy">__TARGET_TITLE__</div><div class="row-copy"><code>__TARGET_URL__</code></div></div><span class="pill">Window 1</span></div>
<div class="row"><div><div class="row-title">Delegate window to AI</div><div class="row-copy">Grants a UUIDv7-scoped agent delegation only when AI browsing is enabled.</div></div><div class="actions"><a class="pill primary" href="cephas://agent/delegate">Delegate</a></div></div>
</div>
<h2>Active Delegations</h2>
<div class="card"><div class="agents">__ACTIVE_AGENTS__</div></div>
<h2 id="human">Browser-Window Human Control</h2>
<div class="card">
<div class="row"><div><div class="row-title">Session opt-in</div><div class="row-copy">When enabled, Cephas can dispatch browser-window scoped mouse, keyboard, scroll, and wait actions into a normal active page. Full desktop control is not used.</div></div><div class="actions">__HUMAN_CONTROL_ACTIONS__</div></div>
<div class="row"><div><div class="row-title">Safety scope</div><div class="row-copy">Actions are rejected on private tabs and internal pages. Open a normal page before issuing action routes.</div></div><span class="pill">Normal pages only</span></div>
<div class="row"><div><div class="row-title">Command routes</div><div class="row-copy">Send these URLs through the active normal tab or address bar after enabling human control.</div></div><span class="pill">Audited</span></div>
<div class="route-list">__HUMAN_CONTROL_EXAMPLES__</div>
</div>
<h2 id="recording">Visual Recording</h2>
<div class="card">
<div class="row"><div><div class="row-title">Recording state</div><div class="row-copy">Stores bounded JSONL events, visible WebKit PNG frames, a manifest, and a local replay page.</div></div><span class="pill">__RECORDING_STATE__</span></div>
<div class="row"><div><div class="row-title">Recording controls</div><div class="row-copy">Recording is explicit and auditable. Snapshots are available only while a recording is active.</div></div><div class="actions">__RECORDING_ACTIONS__</div></div>
</div>
<h2 id="reader">Agent Reader Tools</h2>
<div class="card">
<div class="row"><div><div class="row-title">Reader source</div><div class="row-copy">Reader tools operate on the remembered target URL, not this Agent page.</div></div><span class="pill">Bound target</span></div>
<div class="tool-grid">__READER_TOOLS__</div>
</div>
<h2 id="audit">Audit</h2>
<div class="card">
<div class="row"><div><div class="row-title">JSONL audit path</div><div class="row-copy">Events are ready for backend delivery without inventing UI gestures.</div></div><span class="pill">__EVENT_COUNT__ events</span></div>
<pre>__LOG_PATH__</pre>
</div>
<h2 id="manifest">Command Manifest</h2>
<div class="card">
<div class="row"><div><div class="row-title">Registered browser commands</div><div class="row-copy">Agents should choose from known commands instead of synthesizing fragile gestures.</div></div><span class="pill">__COMMAND_COUNT__ commands</span></div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>Label</th><th>Description</th><th>Target</th></tr></thead><tbody>__COMMAND_ROWS__</tbody></table></div>
</div>
<h2>Manifest JSON</h2>
<details class="card json-details"><summary>Show command manifest JSON <span>Deferred until opened</span></summary><pre>__COMMAND_MANIFEST__</pre></details>
<h2 id="context">Window Context JSON</h2>
<details class="card json-details"><summary>Show window context JSON <span>Deferred until opened</span></summary><pre>__WINDOW_CONTEXT__</pre></details>
</section>
</main>
</div>
<script>
(() => {
const output = document.getElementById('emulation-output');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[ch]));
const emulationRequest = (data) => JSON.stringify({
schema: 'cephas.agent-emulation.v1',
dry_run: true,
source: 'cephas://agent#emulation',
command: {
id: data.command || '',
label: data.label || '',
requires_target: data.targetRequired === 'true',
target: data.targetRequired === 'true' ? 'https://example.com' : null
},
example: data.example || '',
coverage: data.coverage || '',
expected_result: data.result || ''
}, null, 2);
const cards = Array.from(document.querySelectorAll('.lab-card'));
for (const card of cards) {
if (!output) break;
card.addEventListener('click', (event) => {
event.preventDefault();
const data = card.dataset;
output.classList.add('active');
output.innerHTML = `
<div><span class="eyebrow">Last emulation</span><h3>${escapeHtml(data.label)}</h3><div class="meta"><code>${escapeHtml(data.command)}</code></div></div>
<div class="lab-tags"><span class="pill">${escapeHtml(data.category)}</span><span class="pill primary">${escapeHtml(data.coverage)}</span></div>
<p>${escapeHtml(data.description)}</p>
<div class="row-copy"><strong>Example surface:</strong> <code>${escapeHtml(data.example)}</code></div>
<div class="row-copy"><strong>Expected result:</strong> ${escapeHtml(data.result)}</div>
<pre>${escapeHtml(emulationRequest(data))}</pre>`;
location.hash = 'emulation';
});
};
const search = document.getElementById('agent-search');
if (search) {
const filterTargets = [
...cards,
...document.querySelectorAll('.tool-card'),
...document.querySelectorAll('#manifest tbody tr')
];
search.addEventListener('input', () => {
const query = search.value.trim().toLowerCase();
for (const item of filterTargets) {
item.classList.toggle('is-hidden', !!query && !item.textContent.toLowerCase().includes(query));
}
});
}
})();
</script>
</body>
</html>"##;
const AGENT_DOCS_TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Agent Integration Docs</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1014;
--rail: #191a20;
--surface: #18191f;
--surface-2: #202127;
--line: #30323a;
--line-soft: rgba(255,255,255,.08);
--text: #f0f2f6;
--muted: #a8adb7;
--soft: #7f8794;
--accent: #7ab2ff;
--danger: #ff6b5f;
--armour: #ff5d36;
}
body.professional { color-scheme: light; --bg: #f3f5f9; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7ebf2; --line: #d4dbe7; --line-soft: rgba(31,41,55,.08); --text: #1f2937; --muted: #5f6b7a; --soft: #7a8494; --accent: #4f46e5; --danger: #b42318; --armour: #4f46e5; }
body.nebula { color-scheme: light; --bg: #fafafa; --rail: #ffffff; --surface: #ffffff; --surface-2: #f1f1f1; --line: #dcdcdc; --line-soft: rgba(17,24,39,.08); --text: #111827; --muted: #5f6673; --soft: #7d8593; --accent: #111827; --danger: #b42318; --armour: #111827; }
body.ember { color-scheme: light; --bg: #f2f2f2; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7e7e7; --line: #d1d5db; --line-soft: rgba(32,33,36,.08); --text: #202124; --muted: #5f6368; --soft: #80868b; --accent: #4b5563; --danger: #b42318; --armour: #4b5563; }
body.glacier { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f1fb; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --danger: #b42318; --armour: #2563eb; }
body.paper { color-scheme: light; --bg: #f4f6fb; --rail: #ffffff; --surface: #ffffff; --surface-2: #eef2f7; --line: #d7dde7; --line-soft: rgba(16,24,40,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2556d9; --danger: #b42318; --armour: #2556d9; }
body.dark-paper { --bg: #141312; --rail: #1d1b18; --surface: #25221d; --surface-2: #2f2b25; --line: #3c382f; --line-soft: rgba(232,218,194,.1); --text: #f4efe6; --muted: #b5aa98; --soft: #8e8271; --accent: #d49a4a; --danger: #ff8a70; --armour: #d49a4a; }
body.cupertino { color-scheme: light; --bg: #f5f5f7; --rail: rgba(255,255,255,.82); --surface: rgba(255,255,255,.88); --surface-2: #eef2f8; --line: #d8dee8; --line-soft: rgba(29,41,57,.08); --text: #1d1d1f; --muted: #5f6673; --accent: #007aff; --armour: #007aff; }
body.glass { color-scheme: light; --bg: #eaf2ff; --rail: rgba(255,255,255,.58); --surface: rgba(255,255,255,.72); --surface-2: rgba(238,244,255,.78); --line: rgba(79,124,255,.24); --line-soft: rgba(79,124,255,.14); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #4f7cff; --danger: #b42318; --armour: #4f7cff; }
body.agent-inspect { color-scheme: dark; --bg: #08111f; --rail: #0a1220; --surface: #0d1728; --surface-2: #111c30; --line: #facc15; --line-soft: rgba(250,204,21,.32); --text: #f8fafc; --muted: #cbd5e1; --soft: #94a3b8; --accent: #facc15; --danger: #fb7185; --armour: #38bdf8; }
body.ocean { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e6f0ff; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --danger: #b42318; --armour: #f97316; }
body.forest { color-scheme: light; --bg: #f4fbf6; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f4ec; --line: #cfe6d6; --line-soft: rgba(22,163,74,.08); --text: #173323; --muted: #586b5f; --soft: #748579; --accent: #16a34a; --danger: #b42318; --armour: #2563eb; }
body.rose { color-scheme: light; --bg: #fff7f1; --rail: #ffffff; --surface: #ffffff; --surface-2: #f8ebe4; --line: #ead6cb; --line-soft: rgba(233,84,32,.08); --text: #352018; --muted: #715f58; --soft: #8a7972; --accent: #e95420; --danger: #b42318; --armour: #a855f7; }
body.high-contrast-light { color-scheme: light; --bg: #fff; --rail: #fff; --surface: #fff; --surface-2: #f0f0f0; --line: #000; --line-soft: #000; --text: #000; --muted: #111; --soft: #222; --accent: #003cff; --danger: #b00000; --armour: #003cff; }
body.high-contrast-dark { --bg: #000; --rail: #000; --surface: #000; --surface-2: #111; --line: #fff; --line-soft: #fff; --text: #fff; --muted: #f4f4f4; --soft: #d8d8d8; --accent: #00e5ff; --danger: #ff6060; --armour: #ffff00; }
body.private-browsing { --bg: #130b20; --rail: #1c1031; --surface: #261541; --surface-2: #321b55; --line: #5b3c82; --accent: #c084fc; --armour: #f472b6; }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
a { color: inherit; }
.docs-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 18px;
padding: 28px 18px 18px;
background: var(--rail);
border-right: 1px solid var(--line-soft);
}
.brand { display: flex; align-items: center; gap: 14px; padding: 0 8px 10px; font-size: 28px; font-weight: 760; letter-spacing: -.04em; }
.brand-mark, .nav-icon { display: grid; place-items: center; flex: 0 0 auto; }
.brand-mark { width: 30px; height: 30px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, var(--armour), #9f8cff); font-size: 15px; font-weight: 900; }
.nav { overflow: auto; padding-right: 6px; }
.nav-section { padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.nav-section:last-child { border-bottom: 0; }
.nav-item {
display: grid;
grid-template-columns: 34px 1fr;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 8px;
border-radius: 12px;
color: var(--text);
text-decoration: none;
font-size: 15px;
font-weight: 650;
}
.nav-item:hover, .nav-item.active { background: var(--surface-2); }
.nav-icon { color: var(--muted); font-size: 18px; font-weight: 800; }
.content { min-width: 0; padding: 34px 24px 60px; }
.main { width: min(960px, calc(100vw - 350px)); margin: 0 auto; }
.hero { padding: 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(135deg, var(--surface), var(--surface-2)); box-shadow: 0 18px 42px rgba(0,0,0,.22); }
h1 { margin: 0 0 10px; font-size: 34px; letter-spacing: -.04em; }
h2 { margin: 34px 0 16px; font-size: 22px; letter-spacing: -.02em; }
h3 { margin: 0 0 8px; font-size: 16px; }
p { color: var(--muted); line-height: 1.6; }
.lead { max-width: 760px; margin: 0; font-size: 15px; }
.quick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 22px; }
.quick-card, .card { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
.quick-card { padding: 16px; }
.quick-card strong { display: block; margin-bottom: 4px; font-size: 24px; letter-spacing: -.04em; }
.quick-card span { color: var(--muted); font-size: 13px; }
.card { overflow: hidden; }
.row {
min-height: 58px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.row:last-child { border-bottom: 0; }
.row-title { font-size: 16px; font-weight: 720; overflow-wrap: anywhere; }
.row-copy, .meta { margin-top: 5px; color: var(--muted); font-size: 13px; line-height: 1.45; }
.pill {
min-height: 36px;
display: inline-grid;
place-items: center;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
font-size: 13px;
font-weight: 760;
white-space: nowrap;
}
.pill.primary { color: #fff; background: var(--accent); border-color: var(--accent); }
.doc-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.doc-card { padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
.doc-card p { margin: 0; font-size: 13px; }
.steps { display: grid; gap: 10px; padding: 16px 20px 20px; }
.step { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); }
.step strong { display: block; margin-bottom: 5px; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { color: var(--accent); }
pre { overflow: auto; margin: 0; padding: 16px 20px; border-top: 1px solid var(--line); color: var(--text); background: var(--surface-2); white-space: pre-wrap; }
.callout { border-color: var(--accent); }
@media (max-width: 900px) {
.docs-shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; }
.nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.nav-section { border-bottom: 0; }
.main { width: 100%; }
.quick-grid, .doc-grid { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="docs-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">D</span><span>Agent Docs</span></div>
<nav class="nav" aria-label="Agent docs sections">
<div class="nav-section">
<a class="nav-item" href="cephas://agent"><span class="nav-icon">A</span><span>Control panel</span></a>
<a class="nav-item active" href="#overview"><span class="nav-icon">I</span><span>Integration</span></a>
<a class="nav-item" href="#vision"><span class="nav-icon">V</span><span>Agent vision</span></a>
<a class="nav-item" href="#surfaces"><span class="nav-icon">C</span><span>Command surfaces</span></a>
<a class="nav-item" href="#smoke"><span class="nav-icon">S</span><span>Smoke loop</span></a>
<a class="nav-item" href="#contracts"><span class="nav-icon">B</span><span>Backend contracts</span></a>
</div>
<div class="nav-section">
<a class="nav-item" href="#human"><span class="nav-icon">H</span><span>Human control</span></a>
<a class="nav-item" href="#emulation"><span class="nav-icon">E</span><span>Emulation lab</span></a>
<a class="nav-item" href="#resources"><span class="nav-icon">R</span><span>Resources</span></a>
<a class="nav-item" href="#porting"><span class="nav-icon">P</span><span>Porting checklist</span></a>
</div>
</nav>
</aside>
<main class="content">
<section class="main">
<div class="hero" id="overview">
<h1>Local Agentic Integration Guide</h1>
<p class="lead">Cephas exposes deterministic local pages and CLI commands so agentic processes can discover browser capabilities, capture visual state, use reader artifacts, and test integration changes without scraping popovers or inventing gestures.</p>
<div class="quick-grid">
<div class="quick-card"><strong>__COMMAND_COUNT__</strong><span>registered browser commands</span></div>
<div class="quick-card"><strong>__READER_TOOL_COUNT__</strong><span>deterministic reader tools</span></div>
<div class="quick-card"><strong>JSON</strong><span>portable manifests and smoke reports</span></div>
</div>
</div>
<h2 id="surfaces">Command Surfaces</h2>
<div class="card">
<div class="row"><div><div class="row-title">Discover commands</div><div class="row-copy">Use the stable command manifest before issuing browser actions. JSONL is best for streaming tools.</div></div><span class="pill">manifest</span></div>
<pre><code>target/debug/cephas agent-commands<br>target/debug/cephas agent-commands --jsonl</code></pre>
<div class="row"><div><div class="row-title">Discover integration metadata</div><div class="row-copy">Use the portable tooling manifest to import CLI forms, internal URLs, smoke cases, human-control routes, reader tools, resource caps, contract files, schemas, and integration notes.</div></div><span class="pill primary">preferred</span></div>
<pre><code>target/debug/cephas agent-tooling</code></pre>
<div class="row"><div><div class="row-title">Attach through MCP</div><div class="row-copy">Use the read-only stdio MCP bridge when a code tool supports Model Context Protocol servers. The first pass exposes <code>cephas_agent_tooling</code>, <code>cephas_agent_commands</code>, <code>cephas_diagnostics</code>, and <code>cephas_plugins</code>; it does not execute browser actions.</div></div><span class="pill">mcp</span></div>
<pre><code>target/debug/cephas mcp</code></pre>
<div class="row"><div><div class="row-title">Read resource diagnostics</div><div class="row-copy">Use <code>cephas://diagnostics</code> for a local status page with live GTK runtime counters, or the diagnostics JSON report and cap-ID-keyed <code>resource_checks</code> plus <code>unobserved_resource_caps</code> to verify persisted/offline profile, session, download, plugin, Agent log, recording, Armour, and resource-cap state without scraping popovers.</div></div><span class="pill">diagnostics</span></div>
<pre><code>target/debug/cephas launch cephas://diagnostics<br>target/debug/cephas diagnostics --json<br>target/debug/cephas diagnostics --json --strict</code></pre>
<div class="row"><div><div class="row-title">Capture visual state</div><div class="row-copy">Use WebKit screenshots for internal pages or websites. Add JSON for machine-readable success, byte count, duration, and errors.</div></div><span class="pill">screenshot</span></div>
<pre><code>target/debug/cephas agent-screenshot <target> --output <file.png> --json</code></pre>
</div>
<h2 id="vision">Agent Vision And Watchable Sessions</h2>
<div class="doc-grid">
<div class="doc-card"><h3><code>cephas.agent-vision.v1</code></h3><p>The Agent page context JSON includes screenshot, recording, reader, human-control, and emulation routes so model adapters can reason from stable surfaces instead of popover scraping.</p></div>
<div class="doc-card"><h3>Watchable dry runs</h3><p>When visual recording is active, <code>cephas://agent/emulate?command=<command-id></code> appends an emulation event and requests a frame with reason <code>agent-emulation:<command-id></code>.</p></div>
<div class="doc-card"><h3>Human-control evidence</h3><p>Completed browser-window human-control actions request frames with reason <code>human-control:<action></code>, while every attempt records an event and safety rejection reason when applicable.</p></div>
<div class="doc-card"><h3>Backend-ready local artifacts</h3><p>Recording manifests, events, frames, and replay bundles remain local until authenticated sync enables <code>sync_recordings</code>; backend upload must still reject private-tab frames.</p></div>
</div>
<h2 id="smoke">Visual Smoke Loop</h2>
<div class="card callout">
<div class="row"><div><div class="row-title">Run before and after internal-page work</div><div class="row-copy">Smoke cases render local Agent, Agent docs, Agent document, Start, Settings, Diagnostics, and Plugins pages to PNG, then write internal-route-trust and launch-safety JSON artifacts. The JSON report uses schema <code>cephas.agent-smoke.v1</code> and includes suite-level and per-case <code>duration_ms</code>.</div></div><span class="pill primary">repeatable</span></div>
<pre><code>cargo build<br>target/debug/cephas agent-smoke --output-dir /tmp/cephas-smoke<br>target/debug/cephas agent-smoke --output-dir /tmp/cephas-smoke --json</code></pre>
<div class="steps">
<div class="step"><strong>Inspect images</strong><span class="meta">Check <code>agent.png</code>, <code>agent-docs.png</code>, <code>agent-document.png</code>, <code>start.png</code>, <code>settings.png</code>, <code>diagnostics.png</code>, and <code>plugins.png</code> for blank pages, network errors, overlap, or stuck progress UI.</span></div>
<div class="step"><strong>Target one case</strong><span class="meta">Use <code>--case agent-docs</code>, <code>--case agent</code>, <code>--case agent-document</code>, <code>--case start</code>, <code>--case settings</code>, <code>--case diagnostics</code>, or <code>--case plugins</code> when debugging.</span></div>
</div>
</div>
<h2 id="contracts">Backend Contracts</h2>
<div class="doc-grid">
<div class="doc-card"><h3><code>elixir_backend.MD</code></h3><p>Optional authenticated Phoenix backend contract for local-first profile/session sync, Agent event upload, visual recording upload, retention, auth, and UUIDv7 identity.</p></div>
<div class="doc-card"><h3><code>plugins_backend.MD</code></h3><p>Backend-facing plugin supply contract for UUIDv7 manifests, permission labels, capabilities, registry limits, and package-ingestion safety boundaries.</p></div>
<div class="doc-card"><h3><code>CAPTURE_OPTIMIZATION.md</code></h3><p>Capture/render handoff for screenshots, smoke output, visual recordings, local document bases, timing fields, and capture tooling.</p></div>
<div class="doc-card"><h3>Rule of thumb</h3><p>Do not invent backend, plugin, capture, or agentic replay behavior from UI guesses. Read <code>agent-tooling</code> and the matching contract before changing integration code.</p></div>
</div>
<h2 id="human">Human-Control Safety</h2>
<div class="doc-grid">
<div class="doc-card"><h3>Opt-in only</h3><p>Browser-window human control is session-only and disabled by default. Agents must enable it from the Agent page or the local enable route.</p></div>
<div class="doc-card"><h3>Normal pages only</h3><p>Actions are rejected on private tabs and internal pages such as <code>cephas://agent</code>, <code>cephas://agent/docs</code>, <code>cephas://start</code>, and <code>cephas://settings</code>.</p></div>
<div class="doc-card"><h3>Scoped to WebKit</h3><p>Cephas dispatches actions into the active WebKit page. It does not move the OS pointer and does not control the full desktop.</p></div>
<div class="doc-card"><h3>Auditable</h3><p>Action attempts are recorded in agent logs when active and in visual recording events when recording is active.</p></div>
</div>
<h2 id="emulation">Emulation Lab</h2>
<div class="card">
<div class="row"><div><div class="row-title">Dry-run browser commands</div><div class="row-copy">Open the lab to click any registered command and inspect its request shape, integration coverage, example route or CLI form, and expected result. The lab does not execute destructive actions.</div></div><a class="pill primary" href="cephas://agent#emulation">Open lab</a></div>
<pre><code>cephas://agent#emulation<br>cephas://agent/emulate?command=<command-id></code></pre>
</div>
<h2 id="resources">Resource Stability</h2>
<div class="card">
<div class="row"><div><div class="row-title">Bound long-running state</div><div class="row-copy">Every integration feature must define an owner, maximum size, cleanup path, and diagnostics before it can run for days or weeks.</div></div><span class="pill primary">required</span></div>
<div class="steps">
<div class="step"><strong>No unbounded buffers</strong><span class="meta">Avoid unbounded collections, channels, caches, task lists, log streams, terminal buffers, and session histories.</span></div>
<div class="step"><strong>Current Cephas caps</strong><span class="meta">Read <code>agent-tooling.resource_caps</code> for machine-readable limits, <code>agent-tooling.diagnostics_surfaces</code> for live-vs-CLI diagnostics access, and <code>diagnostics --json</code> for current persisted counts plus <code>unobserved_resource_caps</code>. Plugins are capped at 128 UUIDv7 records; Agent delegations at 64, in-memory Agent events at 1,000, audit logs at 5 MiB with 5 archives, recordings at 10,000 events and 1,200 frames, pending human waits at 16, and retained recording directories at 25.</span></div>
<div class="step"><strong>Verify stabilization</strong><span class="meta">Use the Diagnostics dialog, <code>agent-smoke</code>, and process RSS checks after repeated open/close, recording, reader, and delegation cycles.</span></div>
</div>
</div>
<h2 id="porting">Code-Tool Porting Checklist</h2>
<div class="card">
<div class="steps">
<div class="step"><strong>1. Read <code>agent-tooling</code></strong><span class="meta">Import CLI forms, schemas, internal URLs, smoke cases, resource caps, and human-control routes instead of hard-coding them.</span></div>
<div class="step"><strong>2. Read <code>agent-commands --jsonl</code></strong><span class="meta">Choose from known browser commands and map each one to a safe local route or CLI form.</span></div>
<div class="step"><strong>3. Use reader artifacts for content</strong><span class="meta">Prefer <code>agent-reader json-artifact <url></code> when another process needs text, outline, links, data signals, source metadata, and the <code>source_body_truncated</code> flag.</span></div>
<div class="step"><strong>4. Read <code>plugins --jsonl</code></strong><span class="meta">Import UUIDv7 plugin IDs, capability labels, permission labels, registry limits, and <code>plugins available</code> bundled modules before adding integration-specific extension behavior.</span></div>
<div class="step"><strong>5. Use smoke and screenshots for visuals</strong><span class="meta">Capture local pages with <code>agent-screenshot</code> and verify suites with <code>agent-smoke --json</code>.</span></div>
<div class="step"><strong>6. Keep control explicit</strong><span class="meta">Require user opt-in for AI browsing, delegation, visual recording, browser-window human control, and capture-capable media policy.</span></div>
</div>
</div>
</section>
</main>
</div>
</body>
</html>"##;
const DIAGNOSTICS_TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Diagnostics</title>
<style>
:root { color-scheme: light; --bg: #f4f6fb; --surface: #ffffff; --line: #d7dde7; --text: #172033; --muted: #596273; --accent: #2556d9; --ok: #12805c; }
body.dark-paper, body.private-browsing, body.high-contrast-dark { color-scheme: dark; --bg: #141312; --surface: #25221d; --line: #3c382f; --text: #f4efe6; --muted: #b5aa98; --accent: #d49a4a; --ok: #7bd88f; }
body.cupertino { --bg: #f5f5f7; --surface: rgba(255,255,255,.9); --line: #d8dee8; --text: #1d1d1f; --muted: #5f6673; --accent: #007aff; }
body.glass { --bg: #eaf2ff; --surface: rgba(255,255,255,.72); --line: rgba(79,124,255,.24); --text: #172033; --muted: #596273; --accent: #4f7cff; }
body.agent-inspect { color-scheme: dark; --bg: #08111f; --surface: #0d1728; --line: #facc15; --text: #f8fafc; --muted: #cbd5e1; --accent: #facc15; --ok: #38bdf8; }
body.forest { --bg: #f4fbf6; --surface: #ffffff; --line: #cfe6d6; --text: #173323; --muted: #586b5f; --accent: #16a34a; }
body.rose { --bg: #fff7f1; --surface: #ffffff; --line: #ead6cb; --text: #352018; --muted: #715f58; --accent: #e95420; }
body.high-contrast-light { --bg: #fff; --surface: #fff; --line: #000; --text: #000; --muted: #111; --accent: #003cff; }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; color: var(--text); background: radial-gradient(circle at 20% 0%, rgba(37,86,217,.12), transparent 34%), var(--bg); font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif; }
.shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 34px 0 42px; }
.topbar { display: flex; justify-content: space-between; gap: 14px; align-items: center; margin-bottom: 18px; }
.brand { display: flex; align-items: center; gap: 12px; font-size: 28px; font-weight: 800; letter-spacing: -.04em; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: white; background: linear-gradient(135deg, var(--accent), #9f8cff); font-size: 16px; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; }
.nav a { color: var(--text); text-decoration: none; padding: 9px 12px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 750; }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 18px; margin-bottom: 18px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 22px; box-shadow: 0 18px 48px rgba(15,23,42,.08); overflow: hidden; }
.hero-copy { padding: 28px; }
h1 { margin: 0 0 10px; font-size: clamp(34px, 5vw, 60px); letter-spacing: -.06em; line-height: .95; }
.lead { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 760px; }
.metric { display: grid; align-content: center; gap: 8px; padding: 24px; }
.metric strong { font-size: 46px; letter-spacing: -.06em; color: var(--accent); }
.metric span { color: var(--muted); font-size: 13px; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
.grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: 18px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 14px 18px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { width: 38%; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
td { font-weight: 720; }
.side { display: grid; gap: 18px; }
.mini { padding: 20px; }
.mini h2 { margin: 0 0 8px; font-size: 18px; letter-spacing: -.03em; }
.mini p { margin: 0; color: var(--muted); line-height: 1.55; }
pre { white-space: pre-wrap; margin: 12px 0 0; padding: 13px; border-radius: 14px; color: var(--text); background: rgba(127,127,127,.1); border: 1px solid var(--line); overflow: auto; }
.status { color: var(--ok); font-weight: 850; }
@media (max-width: 860px) { .hero, .grid { grid-template-columns: 1fr; } .topbar { align-items: flex-start; flex-direction: column; } }
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="shell">
<div class="topbar">
<div class="brand"><span class="brand-mark">D</span><span>Diagnostics</span></div>
<nav class="nav"><a href="cephas://start">Start</a><a href="cephas://settings">Settings</a><a href="cephas://diagnostics">Diagnostics</a><a href="cephas://agent">Agent</a><a href="cephas://plugins">Plugins</a></nav>
</div>
<section class="hero">
<div class="card hero-copy"><h1>Cephas Resource Diagnostics</h1><p class="lead">Local bounded-resource state for tabs, profile data, downloads, Agent buffers, visual recording storage, Armour lists, and process memory. This page includes live GTK runtime counters; <code>cephas diagnostics --json</code> reports the persisted/offline subset for tools.</p></div>
<div class="card metric"><strong>__ROW_COUNT__</strong><span>resource rows</span><div class="status">Local status page</div></div>
</section>
<section class="grid">
<div class="card"><table><tbody>__DIAGNOSTICS_ROWS__</tbody></table></div>
<aside class="side">
<div class="card mini"><h2>Profile</h2><p><code>__PROFILE_PATH__</code></p></div>
<div class="card mini"><h2>Machine JSON</h2><p>Use schema <code>cephas.diagnostics.v1</code> for persisted/offline automation, cap-ID keyed <code>resource_checks</code>, and <code>unobserved_resource_caps</code>. Capture this page with <code>agent-screenshot</code> or <code>agent-smoke --output-dir <dir> --case diagnostics</code> when live GTK counters matter.</p><pre>target/debug/cephas diagnostics --json<br>target/debug/cephas diagnostics --json --strict</pre></div>
<div class="card mini"><h2>Stability Rule</h2><p>Every long-lived feature needs an owner, maximum size, cleanup path, and diagnostics before it runs for days.</p></div>
</aside>
</section>
</div>
</body>
</html>"##;
const SETTINGS_TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Settings</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1014;
--rail: #191a20;
--surface: #18191f;
--surface-2: #202127;
--line: #30323a;
--line-soft: rgba(255,255,255,.08);
--text: #f0f2f6;
--muted: #a8adb7;
--soft: #7f8794;
--accent: #7ab2ff;
--armour: #ff5d36;
}
body.professional { color-scheme: light; --bg: #f3f5f9; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7ebf2; --line: #d4dbe7; --line-soft: rgba(31,41,55,.08); --text: #1f2937; --muted: #5f6b7a; --soft: #7a8494; --accent: #4f46e5; --armour: #4f46e5; }
body.nebula { color-scheme: light; --bg: #fafafa; --rail: #ffffff; --surface: #ffffff; --surface-2: #f1f1f1; --line: #dcdcdc; --line-soft: rgba(17,24,39,.08); --text: #111827; --muted: #5f6673; --soft: #7d8593; --accent: #111827; --armour: #111827; }
body.ember { color-scheme: light; --bg: #f2f2f2; --rail: #ffffff; --surface: #ffffff; --surface-2: #e7e7e7; --line: #d1d5db; --line-soft: rgba(32,33,36,.08); --text: #202124; --muted: #5f6368; --soft: #80868b; --accent: #4b5563; --armour: #4b5563; }
body.glacier { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f1fb; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --armour: #2563eb; }
body.paper { color-scheme: light; --bg: #f4f6fb; --rail: #ffffff; --surface: #ffffff; --surface-2: #eef2f7; --line: #d7dde7; --line-soft: rgba(16,24,40,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2556d9; --armour: #2556d9; }
body.dark-paper { --bg: #141312; --rail: #1d1b18; --surface: #25221d; --surface-2: #2f2b25; --line: #3c382f; --line-soft: rgba(232,218,194,.1); --text: #f4efe6; --muted: #b5aa98; --soft: #8e8271; --accent: #d49a4a; --armour: #d49a4a; }
body.cupertino { color-scheme: light; --bg: #f5f5f7; --rail: rgba(255,255,255,.82); --surface: rgba(255,255,255,.88); --surface-2: #eef2f8; --line: #d8dee8; --line-soft: rgba(29,41,57,.08); --text: #1d1d1f; --muted: #5f6673; --soft: #7d8593; --accent: #007aff; --armour: #007aff; }
body.glass { color-scheme: light; --bg: #eaf2ff; --rail: rgba(255,255,255,.58); --surface: rgba(255,255,255,.72); --surface-2: rgba(238,244,255,.78); --line: rgba(79,124,255,.24); --line-soft: rgba(79,124,255,.14); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #4f7cff; --armour: #4f7cff; }
body.agent-inspect { color-scheme: dark; --bg: #08111f; --rail: #0a1220; --surface: #0d1728; --surface-2: #111c30; --line: #facc15; --line-soft: rgba(250,204,21,.32); --text: #f8fafc; --muted: #cbd5e1; --soft: #94a3b8; --accent: #facc15; --armour: #38bdf8; }
body.ocean { color-scheme: light; --bg: #f3f8ff; --rail: #ffffff; --surface: #ffffff; --surface-2: #e6f0ff; --line: #cbdcf2; --line-soft: rgba(37,99,235,.08); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #2563eb; --armour: #f97316; }
body.forest { color-scheme: light; --bg: #f4fbf6; --rail: #ffffff; --surface: #ffffff; --surface-2: #e8f4ec; --line: #cfe6d6; --line-soft: rgba(22,163,74,.08); --text: #173323; --muted: #586b5f; --soft: #748579; --accent: #16a34a; --armour: #2563eb; }
body.rose { color-scheme: light; --bg: #fff7f1; --rail: #ffffff; --surface: #ffffff; --surface-2: #f8ebe4; --line: #ead6cb; --line-soft: rgba(233,84,32,.08); --text: #352018; --muted: #715f58; --soft: #8a7972; --accent: #e95420; --armour: #a855f7; }
body.high-contrast-light { color-scheme: light; --bg: #fff; --rail: #fff; --surface: #fff; --surface-2: #f0f0f0; --line: #000; --line-soft: #000; --text: #000; --muted: #111; --soft: #222; --accent: #003cff; --armour: #003cff; }
body.high-contrast-dark { --bg: #000; --rail: #000; --surface: #000; --surface-2: #111; --line: #fff; --line-soft: #fff; --text: #fff; --muted: #f4f4f4; --soft: #d8d8d8; --accent: #00e5ff; --armour: #ffff00; }
body.private-browsing { --bg: #130b20; --rail: #1c1031; --surface: #261541; --surface-2: #321b55; --line: #5b3c82; --accent: #c084fc; --armour: #f472b6; }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
button, input, select { font: inherit; }
.settings-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 18px;
padding: 28px 18px 18px;
background: var(--rail);
border-right: 1px solid var(--line-soft);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
padding: 0 8px 10px;
font-size: 30px;
font-weight: 750;
letter-spacing: -.04em;
}
.brand-mark, .nav-icon {
display: grid;
place-items: center;
flex: 0 0 auto;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--armour), #9f8cff);
font-size: 15px;
font-weight: 900;
}
.nav {
overflow: auto;
padding-right: 6px;
}
.nav-section {
padding: 8px 0;
border-bottom: 1px solid var(--line-soft);
}
.nav-section:last-child { border-bottom: 0; }
.nav-item {
display: grid;
grid-template-columns: 34px 1fr;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 8px;
border-radius: 12px;
color: var(--text);
text-decoration: none;
font-size: 15px;
font-weight: 650;
}
.nav-item:hover, .nav-item.active { background: var(--surface-2); }
.nav-icon { color: var(--muted); font-size: 18px; font-weight: 800; }
.content {
min-width: 0;
padding: 18px 24px 54px;
}
.search-wrap {
position: sticky;
top: 0;
z-index: 2;
padding: 0 0 10px;
background: linear-gradient(180deg, var(--bg) 75%, transparent);
}
.search {
width: min(820px, 76vw);
min-height: 54px;
display: flex;
align-items: center;
gap: 12px;
margin: 0 auto;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 13px;
background: #101116;
}
body.paper .search { background: #fff; }
body.dark-paper .search { background: #25221d; }
.search span { color: var(--soft); font-size: 24px; }
.search input {
width: 100%;
border: 0;
outline: 0;
color: var(--text);
background: transparent;
font-size: 20px;
}
.search input::placeholder { color: var(--soft); }
.main {
width: min(860px, calc(100vw - 340px));
margin: 0 auto;
padding-top: 22px;
}
h1 { margin: 0 0 18px; font-size: 24px; }
h2 { margin: 34px 0 16px; font-size: 21px; letter-spacing: -.02em; }
.card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 12px 30px rgba(0,0,0,.2);
}
.row {
min-height: 58px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
}
.row:last-child { border-bottom: 0; }
.row-title { font-size: 16px; }
.row-copy { margin-top: 5px; color: var(--muted); font-size: 13px; line-height: 1.45; }
.chevron { color: var(--soft); font-size: 28px; }
.radio-stack { display: grid; gap: 16px; padding: 18px 20px 20px; }
.radio-row, .check-row {
display: grid;
grid-template-columns: 28px 1fr;
gap: 12px;
align-items: start;
}
input[type="radio"], input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--accent);
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
gap: 20px;
align-items: center;
}
.text-input, select {
width: 100%;
min-height: 44px;
padding: 0 13px;
color: var(--text);
background: #111217;
border: 1px solid var(--line);
border-radius: 10px;
}
body.paper .text-input, body.paper select { background: #fff; }
body.dark-paper .text-input, body.dark-paper select { background: #25221d; }
body.cupertino .text-input, body.cupertino select, body.high-contrast-light .text-input, body.high-contrast-light select { background: #fff; }
.pill {
min-height: 38px;
display: inline-grid;
place-items: center;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
font-weight: 750;
}
.pill.primary {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.settings-form { margin: 0; }
.save-bar {
position: sticky;
bottom: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
padding: 14px 0;
background: linear-gradient(0deg, var(--bg) 78%, transparent);
}
.save-button {
min-height: 42px;
padding: 0 20px;
border: 0;
border-radius: 999px;
color: #fff;
background: var(--accent);
cursor: pointer;
font-weight: 800;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.metric {
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
}
.metric strong { display: block; margin-bottom: 4px; font-size: 26px; letter-spacing: -.04em; }
.metric span { color: var(--muted); font-size: 13px; }
.armour-overview {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) repeat(2, minmax(120px, 160px));
gap: 14px;
align-items: center;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, var(--surface), var(--surface-2));
}
.armour-shield {
display: grid;
place-items: center;
width: 52px;
height: 52px;
color: #fff;
background: linear-gradient(135deg, var(--armour), var(--accent));
border-radius: 17px;
font-size: 22px;
font-weight: 950;
}
.armour-summary strong, .armour-summary span, .armour-meter strong, .armour-meter span {
display: block;
}
.armour-summary strong {
font-size: 17px;
}
.armour-summary span {
margin-top: 5px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.armour-meter {
min-height: 72px;
display: grid;
align-content: center;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
}
.armour-meter strong {
font-size: 22px;
letter-spacing: -.04em;
}
.armour-meter span {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.plugin-panel { padding: 0; }
.plugin-health {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.plugin-health-status {
min-height: 32px;
display: inline-grid;
place-items: center;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
}
.plugin-health-status.ok { color: #fff; background: #15803d; border-color: #15803d; }
.plugin-health-status.warning { color: #111827; background: #facc15; border-color: #eab308; }
.plugin-health-status.error { color: #fff; background: #b42318; border-color: #b42318; }
.plugin-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, var(--surface), var(--surface-2));
}
.plugin-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
}
.plugin-stats span {
min-height: 42px;
display: grid;
align-content: center;
padding: 0 12px;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 10px;
font-size: 12px;
}
.plugin-stats strong { color: var(--text); }
.plugin-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px 14px;
color: var(--muted);
font-size: 12px;
}
.plugin-slots span {
padding: 7px 10px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
}
.plugin-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 14px;
}
.plugin-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-2);
}
.plugin-card-head {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.plugin-card-head div > strong,
.plugin-card-head div > span { display: block; min-width: 0; }
.plugin-card-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-card-head div > span { margin-top: 2px; color: var(--muted); font-size: 12px; }
.plugin-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
color: #fff;
background: var(--accent);
border-radius: 10px;
font-weight: 900;
}
.plugin-status {
min-height: 28px;
display: inline-grid;
place-items: center;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: var(--surface);
font-size: 12px;
font-weight: 800;
}
.plugin-status.enabled { color: #fff; background: var(--accent); border-color: var(--accent); }
.plugin-status.blocked { color: #fff; background: #b42318; border-color: #b42318; }
.plugin-status.planned { color: var(--muted); }
.plugin-section-title { padding: 14px 20px 0; color: var(--text); font-size: 13px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.plugin-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.plugin-meta {
display: grid;
gap: 6px;
color: var(--soft);
font-size: 12px;
line-height: 1.4;
}
.plugin-meta code {
color: var(--text);
word-break: break-all;
}
.plugin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.plugin-actions .pill { min-height: 32px; padding: 0 12px; font-size: 12px; }
.plugin-catalog-empty { margin: 14px; padding: 14px; color: var(--muted); background: var(--surface-2); border: 1px dashed var(--line); border-radius: 12px; }
.theme-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.theme-chip {
min-height: 32px;
display: inline-grid;
place-items: center;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--surface-2);
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
.theme-chip:hover, .theme-chip:focus-visible { color: var(--text); border-color: var(--accent); }
.theme-chip.active {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.theme-gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 12px;
}
.theme-card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-2);
}
.theme-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 18px 40px rgba(0,0,0,.18);
}
.theme-preview {
min-height: 98px;
padding: 10px;
background: var(--p-bg);
}
.theme-preview-toolbar {
height: 18px;
display: flex;
gap: 5px;
align-items: center;
padding: 0 7px;
border-radius: 8px 8px 0 0;
background: var(--p-toolbar);
}
.theme-preview-toolbar span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--p-accent);
}
.theme-preview-tabs {
height: 23px;
display: flex;
align-items: end;
gap: 5px;
padding: 0 7px;
background: var(--p-toolbar);
}
.theme-preview-tabs span {
width: 58px;
height: 18px;
border-radius: 7px 7px 0 0;
background: var(--p-tab);
}
.theme-preview-tabs span + span {
opacity: .58;
}
.theme-preview-address {
height: 19px;
margin: 8px 2px;
border: 1px solid rgba(255,255,255,.18);
border-radius: 999px;
background: var(--p-address);
}
.theme-preview-card {
height: 20px;
width: 58%;
border-radius: 8px;
background: var(--p-surface);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
}
.theme-card-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 10px;
}
.theme-card-meta strong,
.theme-card-meta span {
display: block;
}
.theme-card-meta strong {
color: var(--text);
font-size: 13px;
}
.theme-card-meta span {
color: var(--muted);
font-size: 12px;
}
.theme-apply {
min-height: 28px;
display: inline-grid;
place-items: center;
padding: 0 10px;
border-radius: 999px;
color: #fff;
background: var(--accent);
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.theme-apply.active {
color: var(--text);
background: transparent;
border: 1px solid var(--line);
}
.theme-preset-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 12px;
}
.theme-preset {
display: grid;
grid-template-columns: 92px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 86px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--text);
background: var(--surface-2);
text-decoration: none;
}
.theme-preset:hover, .theme-preset:focus-visible, .theme-preset.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 16px 34px rgba(0,0,0,.14);
}
.theme-preset-preview {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 18px 1fr;
overflow: hidden;
min-height: 56px;
border-radius: 12px;
background: var(--preset-page);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.16);
}
.theme-preset-preview i { display: block; }
.theme-preset-preview i:nth-child(1) { grid-column: 1 / -1; background: var(--preset-chrome); }
.theme-preset-preview i:nth-child(2) { margin: 8px 4px 8px 8px; border-radius: 7px; background: var(--preset-surface); }
.theme-preset-preview i:nth-child(3) { margin: 8px 8px 8px 4px; border-radius: 7px; background: var(--preset-accent); }
.theme-preset-copy { min-width: 0; }
.theme-preset-copy strong, .theme-preset-copy span { display: block; }
.theme-preset-copy strong { font-size: 14px; }
.theme-preset-copy span { margin-top: 4px; color: var(--muted); font-size: 12px; line-height: 1.35; }
.theme-preset-action {
min-height: 28px;
display: inline-grid;
place-items: center;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 850;
}
.theme-preset.active .theme-preset-action {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.theme-contrast {
border-bottom: 1px solid var(--line);
}
.theme-contrast-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 14px;
}
.theme-contrast-status {
min-height: 30px;
display: inline-grid;
place-items: center;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 900;
}
.theme-contrast-status.ok { color: #fff; background: #15803d; border-color: #15803d; }
.theme-contrast-status.warning { color: #111827; background: #facc15; border-color: #eab308; }
.theme-contrast-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 0 14px 14px;
}
.theme-contrast-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-2);
}
.theme-contrast-row strong,
.theme-contrast-row span,
.theme-contrast-row em { display: block; }
.theme-contrast-row strong { color: var(--text); font-size: 13px; }
.theme-contrast-row div span { margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.35; }
.theme-contrast-row > span { color: var(--text); font-weight: 900; }
.theme-contrast-row em {
min-width: 48px;
padding: 5px 8px;
border-radius: 999px;
color: #fff;
background: #15803d;
font-size: 11px;
font-style: normal;
font-weight: 900;
text-align: center;
}
.theme-contrast-row.warning { border-color: #eab308; }
.theme-contrast-row.warning em { color: #111827; background: #facc15; }
.color-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.custom-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.color-field {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.color-field input {
width: 100%;
min-height: 34px;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
}
.custom-option-grid select { width: 100%; }
.save-bar.compact {
padding: 10px 14px;
margin-top: 0;
}
@media (max-width: 900px) {
.settings-shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; }
.nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.nav-section { border-bottom: 0; }
.main, .search { width: 100%; }
.field-row { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: 1fr; }
.armour-overview { grid-template-columns: 52px minmax(0, 1fr); }
.armour-meter { grid-column: 1 / -1; }
.plugin-health, .plugin-hero, .plugin-stats, .plugin-grid { grid-template-columns: 1fr; }
.plugin-card-head { grid-template-columns: 34px minmax(0, 1fr); }
.plugin-status { grid-column: 2; justify-self: start; }
.theme-gallery { grid-template-columns: 1fr; }
.theme-preset-grid { grid-template-columns: 1fr; }
.theme-preset { grid-template-columns: 76px minmax(0, 1fr); }
.theme-preset-action { grid-column: 2; justify-self: start; }
.theme-contrast-head { grid-template-columns: 1fr; }
.theme-contrast-grid { grid-template-columns: 1fr; }
.color-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.custom-option-grid { grid-template-columns: 1fr; }
}
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="settings-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">D</span><span>Settings</span></div>
<nav class="nav" aria-label="Settings sections">
<div class="nav-section">
<a class="nav-item active" href="#get-started"><span class="nav-icon">*</span><span>Get started</span></a>
<a class="nav-item" href="#appearance"><span class="nav-icon">A</span><span>Appearance</span></a>
<a class="nav-item" href="#themes"><span class="nav-icon">T</span><span>Themes</span></a>
<a class="nav-item" href="#content"><span class="nav-icon">C</span><span>Content</span></a>
<a class="nav-item" href="#armour"><span class="nav-icon">S</span><span>Armour</span></a>
<a class="nav-item" href="#privacy"><span class="nav-icon">P</span><span>Privacy and security</span></a>
<a class="nav-item" href="#search"><span class="nav-icon">Q</span><span>Search engine</span></a>
<a class="nav-item" href="#extensions"><span class="nav-icon">X</span><span>Plugins</span></a>
</div>
<div class="nav-section">
<a class="nav-item" href="#downloads"><span class="nav-icon">D</span><span>Downloads</span></a>
<a class="nav-item" href="#accessibility"><span class="nav-icon">U</span><span>Accessibility</span></a>
<a class="nav-item" href="#system"><span class="nav-icon">G</span><span>System</span></a>
</div>
</nav>
</aside>
<main class="content">
<div class="search-wrap"><label class="search"><span>Q</span><input type="search" placeholder="Search settings" autocomplete="off"></label></div>
<section class="main">
<h1 id="get-started">Get started</h1>
<div class="card">
<div class="row"><div><div class="row-title">Profile name and icon</div><div class="row-copy">Local Cephas profile using the __THEME_LABEL__ theme.</div></div><span class="chevron">></span></div>
<div class="row"><div><div class="row-title">Import bookmarks and settings</div><div class="row-copy">Bookmark storage is ready. Import flows can land here without changing browser chrome.</div></div><span class="chevron">></span></div>
<div class="row"><div><div class="row-title">Cephas is your desktop browser shell</div><div class="row-copy">Native GTK/WebKit runtime, local profile storage, and internal browser pages.</div></div></div>
<form action="cephas://settings/startup" method="get">
<div class="radio-stack">
<label class="radio-row"><input type="radio" name="startup" value="new-tab-page" __STARTUP_NEW_TAB_CHECKED__><span>Open the New Tab page</span></label>
<label class="radio-row"><input type="radio" name="startup" value="continue-previous-session" __STARTUP_CONTINUE_CHECKED__><span>Continue where you left off</span></label>
<label class="radio-row"><input type="radio" name="startup" disabled><span>Open a specific page or set of pages</span></label>
</div>
<div class="save-bar compact"><button class="save-button" type="submit">Save startup behavior</button></div>
</form>
</div>
<h2>New Tab Page</h2>
<div class="card">
<div class="row field-row"><div><div class="row-title">New tab page shows</div><div class="row-copy">A local image-backed dashboard with search, shortcuts, stats, and Armour status.</div></div><select disabled><option>Dashboard</option></select></div>
<div class="row"><div><div class="row-title">Customize new tab page</div><div class="row-copy">Uses your saved bookmarks first, then Cephas defaults.</div></div><span class="chevron">></span></div>
</div>
<h2 id="appearance">Appearance</h2>
<div class="card">
<div class="row"><div><div class="row-title">Theme</div><div class="row-copy">Current theme: __THEME_LABEL__. Toggle it from Start, use the browser menu picker, or apply any theme below.</div></div></div>
<div class="row"><div><div class="row-title">Theme library</div><div class="row-copy theme-list">__THEME_LIST__</div></div></div>
<div class="row"><div><div class="row-title">Show bookmarks bar</div><div class="row-copy">Hidden by default. Toggle it from View or with Ctrl+Shift+B.</div></div></div>
</div>
<h2 id="themes">Themes</h2>
<div class="card"><div class="theme-gallery">__THEME_GALLERY__</div></div>
<h2 id="custom-theme-presets">Custom Theme Presets</h2>
<div class="card"><div class="theme-preset-grid">__CUSTOM_THEME_PRESETS__</div></div>
<form class="custom-theme-form" action="cephas://settings/custom-theme" method="get">
<h2 id="custom-theme">Custom Theme</h2>
<div class="card">
<div class="row field-row"><div><div class="row-title">Theme name</div><div class="row-copy">Saved in your local profile and applied immediately.</div></div><input class="text-input" name="name" type="text" maxlength="32" value="__CUSTOM_THEME_NAME__" required></div>
<div class="color-grid">
<label class="color-field"><span>Chrome</span><input type="color" name="chrome" value="__CUSTOM_CHROME__"></label>
<label class="color-field"><span>Page</span><input type="color" name="page" value="__CUSTOM_PAGE__"></label>
<label class="color-field"><span>Surface</span><input type="color" name="surface" value="__CUSTOM_SURFACE__"></label>
<label class="color-field"><span>Text</span><input type="color" name="text" value="__CUSTOM_TEXT__"></label>
<label class="color-field"><span>Muted</span><input type="color" name="muted" value="__CUSTOM_MUTED__"></label>
<label class="color-field"><span>Accent</span><input type="color" name="accent" value="__CUSTOM_ACCENT__"></label>
</div>
<div class="custom-option-grid">
<label class="color-field"><span>Corner radius</span><select name="radius">__CUSTOM_RADIUS_OPTIONS__</select></label>
<label class="color-field"><span>Density</span><select name="density">__CUSTOM_DENSITY_OPTIONS__</select></label>
</div>
__CUSTOM_THEME_CONTRAST__
<div class="save-bar compact"><button class="save-button" type="submit">Save and apply custom theme</button></div>
</div>
</form>
<h2 id="content">Content</h2>
<div class="card">
<div class="row"><div><div class="row-title">Reader mode</div><div class="row-copy">Extract clean article text from pages via the browser menu.</div></div></div>
<div class="row"><div><div class="row-title">Agent reader tools</div><div class="row-copy">Build UUIDv7 artifacts for briefs, full text, outlines, link maps, data signals, and JSON handoff.</div></div></div>
<div class="row"><div><div class="row-title">Page zoom</div><div class="row-copy">Use menu zoom controls or keyboard shortcuts to scale the active tab.</div></div></div>
</div>
<form class="settings-form" action="cephas://settings/apply" method="get">
<h2 id="search">Search engine and home</h2>
<div class="card">
<div class="row field-row"><div><div class="row-title">Home URL</div><div class="row-copy">Used by external fallback mode and future homepage behavior.</div></div><input class="text-input" name="home" type="url" value="__HOME__" required></div>
<div class="row field-row"><div><div class="row-title">Address bar search</div><div class="row-copy">Choose which search engine handles address-bar text that is not a URL or domain.</div></div><select name="search_provider">__SEARCH_PROVIDER_OPTIONS__</select></div>
</div>
<h2 id="armour">Armour</h2>
<div class="card">
<div class="armour-overview" aria-label="Armour summary">
<div class="armour-shield">A</div>
<div class="armour-summary"><strong>Browser Armour at a glance</strong><span>__ARMOUR_ACTIVE_COUNT__ of 7 protections are enabled for normal browsing. Sites with Armour off stay bounded and visible here.</span></div>
<div class="armour-meter"><strong>__ARMOUR_ALLOWLIST_COUNT__ / __ARMOUR_MAX_ALLOWLIST__</strong><span>Sites with Armour off</span></div>
<div class="armour-meter"><strong>__ARMOUR_BLOCK_RULE_COUNT__ / __ARMOUR_MAX_BLOCKED_HOSTS__</strong><span>Blocked-host rules</span></div>
</div>
<label class="row check-row"><input type="checkbox" name="https" __HTTPS_CHECKED__><span><span class="row-title">Upgrade HTTP navigations to HTTPS</span><span class="row-copy">Prefer encrypted pages before loading.</span></span></label>
<label class="row check-row"><input type="checkbox" name="gpc" __GPC_CHECKED__><span><span class="row-title">Send Global Privacy Control</span><span class="row-copy">Advertise opt-out intent to supported sites.</span></span></label>
<label class="row check-row"><input type="checkbox" name="dnt" __DNT_CHECKED__><span><span class="row-title">Send Do Not Track</span><span class="row-copy">Send the DNT header on page requests.</span></span></label>
<label class="row check-row"><input type="checkbox" name="strip" __STRIP_CHECKED__><span><span class="row-title">Strip tracking query parameters</span><span class="row-copy">Remove known campaign and click identifiers from navigations.</span></span></label>
<label class="row check-row"><input type="checkbox" name="debounce" __DEBOUNCE_CHECKED__><span><span class="row-title">Debounce known redirectors</span><span class="row-copy">Skip common tracking redirect URLs when the final target is visible.</span></span></label>
<label class="row check-row"><input type="checkbox" name="block" __BLOCK_CHECKED__><span><span class="row-title">Block tracker-like subresources</span><span class="row-copy">Stop known tracker hosts from loading inside pages.</span></span></label>
<label class="row check-row"><input type="checkbox" name="de_amp" __DE_AMP_CHECKED__><span><span class="row-title">Redirect AMP pages to canonical pages</span><span class="row-copy">Prefer original publisher pages when a canonical URL exists.</span></span></label>
</div>
<h2 id="system">System</h2>
<div class="card">
<div class="row field-row"><div><div class="row-title">GPU acceleration</div><div class="row-copy">Always enables WebKit GPU compositing, WebGL, accelerated 2D canvas, and best-effort WebGPU when the installed WebKit runtime exposes it. Choose Never only for driver compatibility.</div></div><select name="web_acceleration">__WEB_ACCELERATION_OPTIONS__</select></div>
<div class="row field-row"><div><div class="row-title">Media playback</div><div class="row-copy">Balanced enables modern playback, MediaSource, encrypted media, fullscreen, and inline video. Full media also enables camera and microphone capture prompts.</div></div><select name="media_playback">__MEDIA_PLAYBACK_OPTIONS__</select></div>
<div class="row"><div><div class="row-title">PDF documents</div><div class="row-copy">PDF URLs stay inside the WebKit browser surface when the runtime supports internal PDF viewing. Browser zoom uses WebKit layout zoom so text stays crisp instead of scaling the chrome bitmap.</div></div><span class="pill">Internal viewer</span></div>
<div class="row"><div><div class="row-title">Resource policy</div><div class="row-copy">Open tabs, history, bookmarks, sessions, downloads, and response buffers are all capped for predictable long-running behavior.</div></div></div>
</div>
<div class="save-bar"><button class="save-button" type="submit">Save changes</button></div>
</form>
<h2 id="privacy">Privacy and security</h2>
<div class="metric-grid">
<div class="metric"><strong>__BOOKMARK_COUNT__</strong><span>Bookmarks saved</span></div>
<div class="metric"><strong>__HISTORY_COUNT__</strong><span>History entries retained</span></div>
<div class="metric"><strong>__ADDON_COUNT__</strong><span>UUIDv7 plugins tracked</span></div>
</div>
<h2 id="extensions">Plugins</h2>
<div class="card plugin-panel">__PLUGIN_HEALTH____PLUGIN_REGISTRY__</div>
<h2 id="downloads">Downloads</h2>
<div class="card"><div class="row"><div><div class="row-title">Download manager</div><div class="row-copy">Records are bounded and available from the toolbar downloads button.</div></div></div></div>
<h2 id="accessibility">Accessibility</h2>
<div class="card"><div class="row"><div><div class="row-title">Keyboard shortcuts</div><div class="row-copy">Ctrl+T opens a tab, Ctrl+L focuses the address bar, Ctrl+J opens downloads, and Ctrl+Shift+B toggles the bookmarks bar.</div></div></div></div>
</section>
</main>
</div>
</body>
</html>"##;
const PLUGINS_TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cephas Plugins</title>
<style>
:root {
color-scheme: light;
--bg: #f4f6fb;
--rail: #ffffff;
--surface: #ffffff;
--surface-2: #eef2f7;
--line: #d7dde7;
--line-soft: rgba(16,24,40,.08);
--text: #172033;
--muted: #596273;
--soft: #7a8494;
--accent: #2556d9;
--armour: #2556d9;
}
body.professional { --bg: #f3f5f9; --surface-2: #e7ebf2; --line: #d4dbe7; --text: #1f2937; --muted: #5f6b7a; --accent: #4f46e5; --armour: #4f46e5; }
body.nebula { --bg: #fafafa; --surface-2: #f1f1f1; --line: #dcdcdc; --text: #111827; --muted: #5f6673; --accent: #111827; --armour: #111827; }
body.ember { --bg: #f2f2f2; --surface-2: #e7e7e7; --line: #d1d5db; --text: #202124; --muted: #5f6368; --accent: #4b5563; --armour: #4b5563; }
body.glacier, body.ocean { --bg: #f3f8ff; --surface-2: #e6f0ff; --line: #cbdcf2; --accent: #2563eb; }
body.cupertino { --bg: #f5f5f7; --rail: rgba(255,255,255,.86); --surface: rgba(255,255,255,.9); --surface-2: #eef2f8; --line: #d8dee8; --text: #1d1d1f; --muted: #5f6673; --accent: #007aff; --armour: #007aff; }
body.glass { --bg: #eaf2ff; --rail: rgba(255,255,255,.58); --surface: rgba(255,255,255,.72); --surface-2: rgba(238,244,255,.78); --line: rgba(79,124,255,.24); --line-soft: rgba(79,124,255,.14); --text: #172033; --muted: #596273; --soft: #7a8494; --accent: #4f7cff; --armour: #4f7cff; }
body.agent-inspect { color-scheme: dark; --bg: #08111f; --rail: #0a1220; --surface: #0d1728; --surface-2: #111c30; --line: #facc15; --line-soft: rgba(250,204,21,.32); --text: #f8fafc; --muted: #cbd5e1; --soft: #94a3b8; --accent: #facc15; --armour: #38bdf8; }
body.forest { --bg: #f4fbf6; --surface-2: #e8f4ec; --line: #cfe6d6; --text: #173323; --muted: #586b5f; --accent: #16a34a; --armour: #2563eb; }
body.rose { --bg: #fff7f1; --surface-2: #f8ebe4; --line: #ead6cb; --text: #352018; --muted: #715f58; --accent: #e95420; --armour: #a855f7; }
body.dark-paper { color-scheme: dark; --bg: #141312; --rail: #1d1b18; --surface: #25221d; --surface-2: #2f2b25; --line: #3c382f; --line-soft: rgba(232,218,194,.1); --text: #f4efe6; --muted: #b5aa98; --soft: #8e8271; --accent: #d49a4a; --armour: #d49a4a; }
body.private-browsing { color-scheme: dark; --bg: #130b20; --rail: #1c1031; --surface: #261541; --surface-2: #321b55; --line: #5b3c82; --text: #fbf7ff; --muted: #cabee0; --accent: #c084fc; --armour: #f472b6; }
body.high-contrast-light { --bg: #fff; --rail: #fff; --surface: #fff; --surface-2: #f0f0f0; --line: #000; --text: #000; --muted: #111; --soft: #222; --accent: #003cff; --armour: #003cff; }
body.high-contrast-dark { color-scheme: dark; --bg: #000; --rail: #000; --surface: #000; --surface-2: #111; --line: #fff; --text: #fff; --muted: #f4f4f4; --soft: #d8d8d8; --accent: #00e5ff; --armour: #ffff00; }
__CUSTOM_THEME_PAGE_CSS__
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
font-family: Inter, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
a { color: inherit; text-decoration: none; }
.plugins-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 18px;
padding: 28px 18px 18px;
background: var(--rail);
border-right: 1px solid var(--line-soft);
}
.brand { display: flex; align-items: center; gap: 14px; padding: 0 8px 10px; font-size: 30px; font-weight: 750; letter-spacing: -.04em; }
.brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, var(--armour), #9f8cff); font-size: 15px; font-weight: 900; }
.nav { overflow: auto; padding-right: 6px; }
.nav-section { padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.nav-section:last-child { border-bottom: 0; }
.nav-item { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 8px; min-height: 44px; padding: 0 8px; border-radius: 12px; color: var(--text); font-size: 15px; font-weight: 650; }
.nav-item:hover, .nav-item.active { background: var(--surface-2); }
.nav-icon { color: var(--muted); font-size: 18px; font-weight: 800; }
.content { min-width: 0; padding: 18px 24px 54px; }
.hero { width: min(980px, 100%); margin: 0 auto 18px; padding: 26px 28px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(135deg, var(--surface), var(--surface-2)); box-shadow: 0 18px 44px rgba(0,0,0,.12); }
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 12px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(30px, 5vw, 48px); letter-spacing: -.06em; }
.lead { max-width: 760px; margin: 12px 0 0; color: var(--muted); font-size: 16px; line-height: 1.6; }
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 20px; }
.quick-card { min-height: 86px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
.quick-card strong { display: block; margin-bottom: 6px; font-size: 28px; letter-spacing: -.04em; }
.quick-card span { color: var(--muted); font-size: 13px; }
.main { width: min(980px, 100%); margin: 0 auto; }
.card { overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: 0 12px 30px rgba(0,0,0,.14); }
.row-title { font-size: 16px; font-weight: 750; }
.row-copy { margin-top: 5px; color: var(--muted); font-size: 13px; line-height: 1.45; }
.pill { min-height: 38px; display: inline-grid; place-items: center; padding: 0 16px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface-2); font-weight: 750; }
.pill.primary { color: #fff; background: var(--accent); border-color: var(--accent); }
.plugin-panel { padding: 0; }
.plugin-check-panel { display: grid; gap: 14px; margin-bottom: 14px; padding: 18px 20px; }
.plugin-check-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; }
.plugin-check-state { min-height: 34px; display: inline-grid; place-items: center; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
.plugin-check-state.ok { color: #fff; background: #15803d; border-color: #15803d; }
.plugin-check-state.warning { color: #111827; background: #facc15; border-color: #eab308; }
.plugin-check-state.error { color: #fff; background: #b42318; border-color: #b42318; }
.plugin-check-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.plugin-check-stats span { min-height: 42px; display: grid; align-content: center; padding: 0 12px; color: var(--muted); background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; font-size: 12px; }
.plugin-check-stats strong { color: var(--text); }
.plugin-issues { display: grid; gap: 8px; }
.plugin-check-empty, .plugin-issue { padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); color: var(--muted); font-size: 13px; }
.plugin-issue { display: grid; gap: 5px; }
.plugin-issue strong { color: var(--text); }
.plugin-issue div { display: flex; flex-wrap: wrap; gap: 8px; color: var(--soft); font-size: 12px; }
.plugin-issue code { color: var(--text); word-break: break-all; }
.plugin-issue.error { border-color: #b42318; }
.plugin-issue.warning { border-color: #eab308; }
.plugin-hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px 20px; border-bottom: 1px solid var(--line); background: linear-gradient(135deg, var(--surface), var(--surface-2)); }
.plugin-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--line); }
.plugin-stats span { min-height: 42px; display: grid; align-content: center; padding: 0 12px; color: var(--muted); background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; font-size: 12px; }
.plugin-stats strong { color: var(--text); }
.plugin-slots { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 20px 14px; color: var(--muted); font-size: 12px; }
.plugin-slots span { padding: 7px 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; }
.plugin-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 14px; }
.plugin-card { display: grid; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
.plugin-card-head { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; }
.plugin-card-head div > strong, .plugin-card-head div > span { display: block; min-width: 0; }
.plugin-card-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-card-head div > span { margin-top: 2px; color: var(--muted); font-size: 12px; }
.plugin-mark { display: grid; place-items: center; width: 34px; height: 34px; color: #fff; background: var(--accent); border-radius: 10px; font-weight: 900; }
.plugin-status { min-height: 28px; display: inline-grid; place-items: center; padding: 0 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface); font-size: 12px; font-weight: 800; }
.plugin-status.enabled { color: #fff; background: var(--accent); border-color: var(--accent); }
.plugin-status.blocked { color: #fff; background: #b42318; border-color: #b42318; }
.plugin-status.planned { color: var(--muted); }
.plugin-section-title { padding: 14px 20px 0; color: var(--text); font-size: 13px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.plugin-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.plugin-meta { display: grid; gap: 6px; color: var(--soft); font-size: 12px; line-height: 1.4; }
.plugin-meta code { color: var(--text); word-break: break-all; }
.plugin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.plugin-actions .pill { min-height: 32px; padding: 0 12px; font-size: 12px; }
.plugin-catalog-empty { margin: 14px; padding: 14px; color: var(--muted); background: var(--surface-2); border: 1px dashed var(--line); border-radius: 12px; }
@media (max-width: 980px) {
.plugins-shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }
.content { padding: 18px 14px 42px; }
.quick-grid, .plugin-check-head, .plugin-check-stats, .plugin-hero, .plugin-stats, .plugin-grid { grid-template-columns: 1fr; }
.plugin-card-head { grid-template-columns: 34px minmax(0, 1fr); }
.plugin-status { grid-column: 2; justify-self: start; }
}
__INTERNAL_PAGE_PERFORMANCE_CSS__
</style>
</head>
<body class="__THEME_ID__">
<div class="plugins-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">D</span><span>Plugins</span></div>
<nav class="nav" aria-label="Plugin sections">
<div class="nav-section">
<a class="nav-item active" href="cephas://plugins"><span class="nav-icon">P</span><span>Registry</span></a>
<a class="nav-item" href="cephas://settings#themes"><span class="nav-icon">T</span><span>Themes</span></a>
<a class="nav-item" href="cephas://agent"><span class="nav-icon">A</span><span>Agent</span></a>
<a class="nav-item" href="cephas://start"><span class="nav-icon">S</span><span>Start</span></a>
</div>
<div class="nav-section">
<a class="nav-item" href="cephas://settings"><span class="nav-icon">G</span><span>Settings</span></a>
</div>
</nav>
</aside>
<main class="content">
<section class="hero">
<p class="eyebrow">Local registry / __THEME_LABEL__</p>
<h1>UUIDv7 Plugin Registry</h1>
<p class="lead">Cephas keeps plugin metadata local, bounded, and explicit. Bundled project modules can be installed into this profile now; package download and execution stay separate until signatures and permission prompts exist.</p>
<div class="quick-grid">
<div class="quick-card"><strong>__PLUGIN_COUNT__</strong><span>records installed</span></div>
<div class="quick-card"><strong>__BUNDLED_COUNT__</strong><span>bundled modules</span></div>
<div class="quick-card"><strong>__PLANNED_COUNT__</strong><span>planned built-in APIs</span></div>
<div class="quick-card"><strong>__MAX_PLUGINS__</strong><span>maximum profile records</span></div>
</div>
</section>
<section class="main">
<div class="card plugin-check-panel">__PLUGIN_CHECK_SUMMARY__</div>
<div class="card plugin-panel">__PLUGIN_REGISTRY__</div>
</section>
</main>
</div>
</body>
</html>"##;
const GTK_CSS: &str = r#"
* {
font-family: "Inter", "Segoe UI", "Cantarell", "Ubuntu", sans-serif;
text-shadow: none;
-gtk-icon-shadow: none;
}
.bawn-root {
background: #0d1117;
}
.theme-paper {
background: #f5f7fb;
}
.theme-dark-paper {
background: #151413;
}
.theme-glass {
background: #dce7f6;
}
.theme-agent-inspect {
background: #08111f;
}
.theme-nebula,
.theme-ember {
background: #000;
}
.bawn-tabbar {
padding: 3px 6px 0 6px;
background: #202124;
border-bottom: 0;
}
.bawn-brand {
min-width: 74px;
min-height: 34px;
padding: 0 8px 0 2px;
color: #f8fafc;
background: transparent;
border: 0;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.04em;
}
.bawn-tabs {
padding: 0;
}
.bawn-tab {
min-height: 29px;
min-width: 156px;
color: #aab6c7;
background: transparent;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 9px 9px 0 0;
transition: background-color 90ms ease-out, border-color 90ms ease-out, color 90ms ease-out;
}
.bawn-tab-selected {
color: #f8fafc;
background: #34353a;
border-color: #34353a;
box-shadow: none;
}
.bawn-tab-pinned {
min-width: 116px;
border-color: #49617f;
}
.bawn-tab-private {
background: #171320;
border-color: #52456b;
}
button.bawn-tab-title,
button.bawn-tab-close,
button.bawn-tab-new {
min-height: 28px;
color: #c9d3e2;
background: transparent;
border: 0;
border-radius: 8px;
box-shadow: none;
font-size: 12px;
}
button.bawn-tab-title {
padding: 0 10px;
font-weight: 700;
}
button.bawn-tab-close {
min-width: 24px;
padding: 0;
color: #7d899a;
}
button.bawn-tab-new {
min-width: 30px;
padding: 0;
color: #dce5f4;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
.bawn-window-controls {
padding: 0 0 0 4px;
}
button.bawn-window-control {
min-width: 32px;
min-height: 25px;
padding: 0;
color: #d9dde6;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: none;
}
button.bawn-window-control:hover {
color: #fff;
background: rgba(255,255,255,.12);
}
button.bawn-window-close:hover {
color: #fff;
background: #d23f31;
border-color: #d23f31;
}
.bawn-topbar {
padding: 3px 7px 4px;
background: #34353a;
border-bottom: 0;
}
.bawn-bookmarkbar {
min-height: 30px;
padding: 0 8px 4px;
background: #34353a;
border-bottom: 1px solid #24262b;
}
.bawn-findbar {
padding: 6px 8px;
background: #34353a;
border-bottom: 1px solid #24262b;
}
.bawn-url {
min-height: 28px;
padding-left: 9px;
padding-right: 9px;
color: #eef3fb;
background: #202124;
border: 1px solid #202124;
border-radius: 7px;
box-shadow: none;
font-size: 13px;
}
.bawn-address-shell {
min-height: 32px;
padding: 2px 3px 2px 2px;
background: #202124;
border: 1px solid #46515e;
border-radius: 7px;
transition: background-color 90ms ease-out, border-color 90ms ease-out, box-shadow 120ms ease-out;
}
.bawn-address-shell.bawn-address-focused {
background: #181f2d;
border-color: #7aa2f7;
box-shadow: 0 0 0 2px rgba(122,162,247,0.18);
}
.bawn-address-shell .bawn-url {
min-height: 26px;
background: transparent;
border: 0;
border-radius: 6px;
}
.bawn-address-shell .bawn-url:focus {
border: 0;
box-shadow: none;
}
.bawn-page-actions {
padding-left: 6px;
border-left: 1px solid #454750;
}
.bawn-url:focus {
border-color: #7aa2f7;
box-shadow: 0 0 0 2px rgba(122,162,247,0.18);
}
button.bawn-nav,
button.bawn-armour {
min-height: 28px;
min-width: 30px;
padding: 0 5px;
color: #d5deeb;
background: rgba(16, 29, 39, .28);
border: 1px solid #46515e;
border-radius: 6px;
box-shadow: none;
font-size: 12px;
font-weight: 700;
transition: background-color 90ms ease-out, border-color 90ms ease-out, color 90ms ease-out, box-shadow 120ms ease-out;
}
.bawn-bookmark-chip {
min-height: 24px;
padding: 0 7px;
color: #eef1f6;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: none;
transition: background-color 90ms ease-out, border-color 90ms ease-out, color 90ms ease-out;
}
.bawn-bookmark-icon {
min-width: 17px;
min-height: 17px;
padding: 0 4px;
color: #101216;
background: #f1f5f9;
border-radius: 5px;
font-size: 10px;
font-weight: 900;
}
.bawn-bookmark-label {
color: #eef1f6;
font-size: 12px;
font-weight: 600;
}
.bawn-bookmark-separator {
margin: 4px 2px;
color: #6b7280;
}
.bawn-bookmark-overflow {
padding: 2px 9px 0;
color: #e5e7eb;
font-size: 16px;
font-weight: 800;
}
.bawn-theme-picker button {
min-height: 28px;
color: #d5deeb;
background: #1a2230;
border: 1px solid #2b3547;
border-radius: 6px;
box-shadow: none;
font-size: 12px;
}
button.bawn-nav:hover,
button.bawn-armour:hover,
button.bawn-tab-title:hover,
button.bawn-tab-close:hover,
button.bawn-tab-new:hover,
.bawn-bookmark-chip:hover,
.bawn-theme-picker button:hover {
color: #fff;
background: rgba(55, 79, 96, .46);
border-color: #5f7488;
}
button.bawn-armour {
color: #f2f5ff;
background: rgba(16, 29, 39, .28);
border-color: #46515e;
font-weight: 800;
}
.bawn-address-shell button.bawn-nav,
.bawn-address-shell button.bawn-armour {
min-width: 26px;
min-height: 24px;
padding: 0 5px;
border-radius: 6px;
}
button.bawn-download-active {
color: #fff;
background: #23385a;
border-color: #7aa2f7;
}
.bawn-zoom-label {
min-width: 42px;
color: #95a3b7;
font-size: 12px;
font-weight: 700;
}
.bawn-status {
padding: 6px 10px;
color: #d7dde8;
background: rgba(16, 23, 34, .88);
border: 1px solid rgba(255,255,255,.1);
border-radius: 8px;
box-shadow: 0 14px 36px rgba(0,0,0,.28);
font-size: 12px;
transition: opacity 140ms ease-out, background-color 90ms ease-out, border-color 90ms ease-out;
}
.bawn-progress trough {
min-height: 3px;
background: #101722;
border: 0;
}
.bawn-progress progress {
min-height: 3px;
background: #7aa2f7;
border: 0;
transition: background-color 90ms ease-out;
}
.bawn-progress.bawn-progress-complete progress {
background: #a7c7ff;
}
.bawn-dialog {
background: #111722;
}
.bawn-dialog-title {
color: #f8fafc;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
}
.bawn-popover-title {
color: #f8fafc;
font-size: 15px;
font-weight: 800;
letter-spacing: -0.01em;
}
.bawn-dialog-copy {
color: #aab6c7;
font-size: 13px;
}
.bawn-popover-copy,
.bawn-setting-check {
color: #aab6c7;
font-size: 13px;
}
.bawn-panel-content {
min-width: 420px;
background: #101722;
}
.bawn-popover-footer {
padding-top: 8px;
border-top: 1px solid #202938;
}
.bawn-addon-card {
padding: 12px;
background: #151c28;
border: 1px solid #283246;
border-radius: 14px;
}
.bawn-addon-title {
color: #f8fafc;
font-weight: 700;
}
.bawn-plugin-stats {
padding: 2px 0 4px;
}
.bawn-plugin-metric,
.bawn-plugin-card {
padding: 12px;
color: #f8fafc;
background: #151c28;
border: 1px solid #283246;
border-radius: 14px;
}
.bawn-plugin-card {
box-shadow: 0 12px 30px rgba(0,0,0,.16);
}
.bawn-plugin-metric-value {
color: #f8fafc;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.03em;
}
.bawn-plugin-title {
color: #f8fafc;
font-size: 15px;
font-weight: 800;
}
.bawn-plugin-kind,
.bawn-plugin-meta {
color: #8f9db2;
font-size: 12px;
}
.bawn-plugin-mark {
min-width: 34px;
min-height: 34px;
color: #07111f;
background: #7aa2f7;
border-radius: 10px;
font-weight: 900;
}
.bawn-plugin-status {
min-height: 26px;
padding: 0 10px;
color: #dbe6f7;
background: #202938;
border: 1px solid #334155;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.bawn-plugin-status.enabled {
color: #07111f;
background: #7aa2f7;
border-color: #7aa2f7;
}
.bawn-plugin-status.blocked {
color: #fff;
background: #b42318;
border-color: #b42318;
}
.bawn-history-row {
min-height: 36px;
padding: 0 12px;
color: #d5deeb;
background: #151c28;
border: 1px solid #283246;
border-radius: 12px;
box-shadow: none;
transition: background-color 90ms ease-out, border-color 90ms ease-out, color 90ms ease-out;
}
.bawn-history-row:hover {
color: #fff;
border-color: #43516a;
background: #1b2433;
}
.bawn-control-group {
padding: 2px;
background: transparent;
border: 0;
border-radius: 10px;
}
.bawn-menu-popover {
background: #2b2d33;
border: 1px solid #42454d;
border-radius: 14px;
box-shadow: 0 18px 44px rgba(0,0,0,.34);
}
.bawn-popover-scroll,
.bawn-popover-scroll viewport,
.bawn-menu-content {
background: #2b2d33;
border: 0;
}
.bawn-menu-content {
min-width: 400px;
padding: 4px 0;
}
.bawn-browser-menu-group {
padding: 3px 0;
}
.bawn-browser-menu-separator {
margin: 4px 0;
color: #474a53;
}
button.bawn-browser-menu-action,
.bawn-browser-menu-row {
min-height: 34px;
padding: 0 10px;
color: #eef1f6;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
transition: background-color 80ms ease-out, color 80ms ease-out;
}
button.bawn-browser-menu-action:hover {
background: #393c44;
}
.bawn-browser-menu-icon {
min-width: 20px;
color: #c2c8d2;
font-size: 14px;
}
.bawn-browser-menu-label {
color: #eef1f6;
font-size: 13px;
font-weight: 600;
}
.bawn-browser-menu-shortcut {
color: #a8afbc;
font-size: 12px;
}
button.bawn-browser-menu-mini {
min-width: 30px;
min-height: 25px;
padding: 0 8px;
color: #eef1f6;
background: transparent;
border: 0;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 700;
}
button.bawn-browser-menu-mini:hover {
background: #42454f;
}
.bawn-download-popover {
min-width: 400px;
background: #2b2d33;
}
.bawn-download-popover-header {
padding: 10px 14px 8px;
}
.bawn-download-popover-title {
color: #f1f4f8;
font-size: 15px;
font-weight: 800;
letter-spacing: -0.01em;
}
button.bawn-download-popover-close {
min-width: 26px;
min-height: 26px;
padding: 0;
color: #c8ced8;
background: transparent;
border: 0;
border-radius: 999px;
box-shadow: none;
}
button.bawn-download-popover-close:hover {
color: #fff;
background: #3d404a;
}
.bawn-download-empty {
padding: 12px 14px 16px;
color: #a8afbc;
font-size: 13px;
}
button.bawn-download-popover-row {
padding: 0;
color: #eef1f6;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
}
button.bawn-download-popover-row:hover {
background: #383b43;
}
.bawn-download-file-icon {
min-width: 30px;
color: #d9dee8;
font-size: 14px;
}
.bawn-download-file-name {
color: #eef1f6;
font-size: 14px;
font-weight: 700;
}
.bawn-download-file-meta {
color: #a9b0bd;
font-size: 12px;
}
button.bawn-download-footer {
min-height: 38px;
padding: 0 14px;
color: #eef1f6;
background: transparent;
border: 0;
border-top: 1px solid #474a53;
border-radius: 0;
box-shadow: none;
font-size: 13px;
font-weight: 700;
}
.bawn-download-footer-icon {
color: #aeb5c1;
}
button.bawn-download-footer:hover {
background: #383b43;
}
.bawn-menu-section {
padding: 5px 0;
background: transparent;
border: 0;
border-radius: 0;
}
.bawn-menu-section-title {
padding: 3px 10px;
color: #7aa2f7;
font-size: 12px;
font-weight: 800;
}
button.bawn-menu-action {
min-height: 32px;
padding: 0 10px;
color: #d5deeb;
background: transparent;
border: 0;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 600;
}
button.bawn-menu-action:hover {
color: #fff;
background: #1b2433;
}
button.bawn-primary-action {
min-height: 34px;
padding: 0 14px;
color: #07111f;
background: #d7e7ff;
border: 1px solid #d7e7ff;
border-radius: 10px;
box-shadow: none;
font-size: 12px;
font-weight: 800;
}
button.bawn-primary-action:hover {
color: #07111f;
background: #eef6ff;
border-color: #eef6ff;
}
.theme-nebula * {
font-family: "Courier New", monospace;
}
.theme-nebula.bawn-root,
.theme-ember.bawn-root {
background: #000;
}
.theme-nebula .bawn-tabbar,
.theme-nebula .bawn-topbar,
.theme-nebula .bawn-bookmarkbar,
.theme-nebula .bawn-findbar {
background: #000;
border-color: #1a1a1a;
}
.theme-nebula .bawn-status {
color: #cfcfcf;
background: rgba(0,0,0,.9);
border-color: #1a1a1a;
border-radius: 0;
}
.theme-nebula .bawn-brand,
.theme-nebula .bawn-tab,
.theme-nebula .bawn-url,
.theme-nebula .bawn-address-shell,
.theme-nebula button.bawn-nav,
.theme-nebula .bawn-bookmark-chip,
.theme-nebula button.bawn-tab-new,
.theme-nebula button.bawn-window-control,
.theme-nebula .bawn-theme-picker button,
.theme-nebula .bawn-control-group,
.theme-nebula .bawn-menu-section,
.theme-nebula .bawn-menu-popover,
.theme-nebula .bawn-menu-content,
.theme-nebula .bawn-panel-content,
.theme-nebula .bawn-popover-scroll,
.theme-nebula .bawn-popover-scroll viewport,
.theme-nebula button.bawn-menu-action,
.theme-nebula .bawn-addon-card,
.theme-nebula .bawn-history-row,
.theme-nebula.bawn-menu-popover,
.theme-nebula.bawn-menu-content,
.theme-nebula.bawn-panel-content,
.theme-nebula.bawn-popover-scroll,
.theme-nebula.bawn-popover-scroll viewport,
.theme-nebula.bawn-download-popover {
color: #cfcfcf;
background: #050505;
border-color: #1a1a1a;
border-radius: 0;
}
.theme-nebula .bawn-tab-selected {
color: #fff;
background: #0a0a0a;
border-color: #f4f4f4;
}
.theme-nebula button.bawn-armour {
color: #000;
background: #f4f4f4;
border-color: #f4f4f4;
}
.theme-nebula .bawn-address-shell button.bawn-armour {
color: #f4f4f4;
background: transparent;
border-color: transparent;
}
.theme-nebula .bawn-bookmark-icon {
color: #000;
background: #f4f4f4;
border-radius: 0;
}
.theme-nebula .bawn-bookmark-label {
color: #cfcfcf;
}
.theme-nebula button.bawn-primary-action {
color: #000;
background: #f4f4f4;
border-color: #f4f4f4;
}
.theme-ember .bawn-tab-selected,
.theme-ember .bawn-url,
.theme-ember button.bawn-nav,
.theme-ember .bawn-theme-picker button {
background: #161616;
border-color: #2d2d2d;
}
.theme-glacier .bawn-tab-selected,
.theme-glacier .bawn-url,
.theme-glacier button.bawn-nav,
.theme-glacier .bawn-theme-picker button {
background: #172030;
color: #edf4ff;
border-color: #334155;
}
.theme-paper .bawn-tabbar,
.theme-paper .bawn-topbar,
.theme-paper .bawn-bookmarkbar,
.theme-paper .bawn-findbar {
background: #eef2f7;
border-color: #d7dde7;
}
.theme-paper .bawn-status {
color: #172033;
background: rgba(255,255,255,.92);
border-color: #d7dde7;
}
.theme-paper .bawn-brand,
.theme-paper .bawn-tab-selected,
.theme-paper .bawn-url,
.theme-paper .bawn-address-shell,
.theme-paper button.bawn-nav,
.theme-paper .bawn-bookmark-chip,
.theme-paper button.bawn-window-control,
.theme-paper .bawn-theme-picker button,
.theme-paper .bawn-control-group,
.theme-paper .bawn-menu-section,
.theme-paper .bawn-menu-popover,
.theme-paper .bawn-menu-content,
.theme-paper .bawn-panel-content,
.theme-paper .bawn-popover-scroll,
.theme-paper .bawn-popover-scroll viewport,
.theme-paper button.bawn-menu-action,
.theme-paper .bawn-download-popover,
.theme-paper.bawn-menu-popover,
.theme-paper.bawn-menu-content,
.theme-paper.bawn-panel-content,
.theme-paper.bawn-popover-scroll,
.theme-paper.bawn-popover-scroll viewport,
.theme-paper.bawn-download-popover {
color: #172033;
background: #fff;
border-color: #d7dde7;
}
.theme-paper .bawn-browser-menu-label,
.theme-paper .bawn-download-popover-title,
.theme-paper .bawn-download-file-name,
.theme-paper button.bawn-download-footer {
color: #172033;
}
.theme-paper .bawn-browser-menu-icon,
.theme-paper .bawn-browser-menu-shortcut,
.theme-paper .bawn-download-file-meta,
.theme-paper .bawn-download-empty {
color: #566273;
}
.theme-paper button.bawn-browser-menu-action:hover,
.theme-paper button.bawn-browser-menu-mini:hover,
.theme-paper button.bawn-download-popover-row:hover,
.theme-paper button.bawn-download-footer:hover {
background: #eef2f7;
}
.theme-paper .bawn-browser-menu-separator {
color: #e2e7f0;
}
.theme-paper button.bawn-download-popover-close {
color: #566273;
}
.theme-paper button.bawn-download-popover-close:hover {
color: #172033;
background: #e8edf5;
}
.theme-paper button.bawn-download-footer {
border-top-color: #e2e7f0;
}
.theme-paper .bawn-download-footer-icon {
color: #566273;
}
.theme-paper button.bawn-browser-menu-mini {
color: #172033;
}
.theme-paper button.bawn-armour,
.theme-paper button.bawn-primary-action,
.theme-paper .bawn-progress progress {
background: #2556d9;
color: #fff;
border-color: #2556d9;
}
.theme-paper .bawn-address-shell button.bawn-armour {
color: #172033;
background: transparent;
border-color: transparent;
}
.theme-paper .bawn-bookmark-icon {
color: #fff;
background: #2556d9;
}
.theme-paper .bawn-bookmark-label {
color: #172033;
}
.theme-paper .bawn-status,
.theme-paper .bawn-zoom-label {
color: #566273;
}
.theme-paper.bawn-dialog,
.theme-paper .bawn-dialog {
background: #f5f7fb;
}
.theme-paper .bawn-dialog-title,
.theme-paper .bawn-plugin-title,
.theme-paper .bawn-plugin-metric-value {
color: #172033;
}
.theme-paper .bawn-dialog-copy,
.theme-paper .bawn-plugin-kind,
.theme-paper .bawn-plugin-meta {
color: #566273;
}
.theme-paper .bawn-plugin-card,
.theme-paper .bawn-plugin-metric,
.theme-paper .bawn-plugin-status {
color: #172033;
background: #fff;
border-color: #d7dde7;
}
.theme-paper .bawn-plugin-mark,
.theme-paper .bawn-plugin-status.enabled {
color: #fff;
background: #2556d9;
border-color: #2556d9;
}
.theme-dark-paper .bawn-tabbar,
.theme-dark-paper .bawn-topbar,
.theme-dark-paper .bawn-bookmarkbar,
.theme-dark-paper .bawn-findbar {
background: #1d1b18;
border-color: #3c382f;
}
.theme-dark-paper .bawn-status {
color: #b5aa98;
background: rgba(29,27,24,.94);
border-color: #3c382f;
}
.theme-dark-paper .bawn-brand,
.theme-dark-paper .bawn-tab-selected,
.theme-dark-paper .bawn-url,
.theme-dark-paper .bawn-address-shell,
.theme-dark-paper button.bawn-nav,
.theme-dark-paper .bawn-bookmark-chip,
.theme-dark-paper button.bawn-window-control,
.theme-dark-paper .bawn-theme-picker button,
.theme-dark-paper .bawn-control-group,
.theme-dark-paper .bawn-menu-section,
.theme-dark-paper .bawn-menu-popover,
.theme-dark-paper .bawn-menu-content,
.theme-dark-paper .bawn-panel-content,
.theme-dark-paper .bawn-popover-scroll,
.theme-dark-paper .bawn-popover-scroll viewport,
.theme-dark-paper button.bawn-menu-action,
.theme-dark-paper .bawn-download-popover,
.theme-dark-paper.bawn-menu-popover,
.theme-dark-paper.bawn-menu-content,
.theme-dark-paper.bawn-panel-content,
.theme-dark-paper.bawn-popover-scroll,
.theme-dark-paper.bawn-popover-scroll viewport,
.theme-dark-paper.bawn-download-popover {
color: #f4efe6;
background: #25221d;
border-color: #3c382f;
}
.theme-dark-paper .bawn-browser-menu-label,
.theme-dark-paper .bawn-download-popover-title,
.theme-dark-paper .bawn-download-file-name,
.theme-dark-paper button.bawn-download-footer {
color: #f4efe6;
}
.theme-dark-paper .bawn-browser-menu-icon,
.theme-dark-paper .bawn-browser-menu-shortcut,
.theme-dark-paper .bawn-download-file-meta,
.theme-dark-paper .bawn-download-empty,
.theme-dark-paper .bawn-status,
.theme-dark-paper .bawn-zoom-label {
color: #b5aa98;
}
.theme-dark-paper button.bawn-browser-menu-action:hover,
.theme-dark-paper button.bawn-browser-menu-mini:hover,
.theme-dark-paper button.bawn-download-popover-row:hover,
.theme-dark-paper button.bawn-download-footer:hover,
.theme-dark-paper button.bawn-nav:hover,
.theme-dark-paper .bawn-bookmark-chip:hover {
color: #f8f3ea;
background: #2f2b25;
}
.theme-dark-paper .bawn-browser-menu-separator {
color: #343029;
}
.theme-dark-paper button.bawn-download-popover-close {
color: #b5aa98;
}
.theme-dark-paper button.bawn-download-popover-close:hover {
color: #f4efe6;
background: #2f2b25;
}
.theme-dark-paper button.bawn-download-footer {
border-top-color: #343029;
}
.theme-dark-paper .bawn-download-footer-icon {
color: #b5aa98;
}
.theme-dark-paper button.bawn-browser-menu-mini {
color: #f4efe6;
}
.theme-dark-paper button.bawn-armour,
.theme-dark-paper button.bawn-primary-action,
.theme-dark-paper .bawn-progress progress {
color: #17130e;
background: #d49a4a;
border-color: #d49a4a;
}
.theme-dark-paper .bawn-address-shell button.bawn-armour {
color: #d49a4a;
background: transparent;
border-color: transparent;
}
.theme-dark-paper .bawn-bookmark-icon {
color: #17130e;
background: #d49a4a;
}
.theme-dark-paper .bawn-bookmark-label {
color: #f4efe6;
}
.theme-cupertino.bawn-root,
.theme-high-contrast-light.bawn-root {
background: #f5f5f7;
}
.theme-cupertino .bawn-tabbar,
.theme-cupertino .bawn-topbar,
.theme-cupertino .bawn-bookmarkbar,
.theme-cupertino .bawn-findbar {
background: #edf1f7;
border-color: #d8dee8;
}
.theme-cupertino .bawn-brand,
.theme-cupertino .bawn-tab-selected,
.theme-cupertino .bawn-url,
.theme-cupertino .bawn-address-shell,
.theme-cupertino button.bawn-nav,
.theme-cupertino .bawn-bookmark-chip,
.theme-cupertino button.bawn-window-control,
.theme-cupertino .bawn-theme-picker button,
.theme-cupertino .bawn-control-group,
.theme-cupertino .bawn-menu-section,
.theme-cupertino .bawn-menu-popover,
.theme-cupertino .bawn-menu-content,
.theme-cupertino .bawn-panel-content,
.theme-cupertino .bawn-popover-scroll,
.theme-cupertino .bawn-popover-scroll viewport,
.theme-cupertino button.bawn-menu-action,
.theme-cupertino .bawn-download-popover,
.theme-cupertino.bawn-menu-popover,
.theme-cupertino.bawn-menu-content,
.theme-cupertino.bawn-panel-content,
.theme-cupertino.bawn-popover-scroll,
.theme-cupertino.bawn-download-popover {
color: #1d1d1f;
background: #fff;
border-color: #d8dee8;
}
.theme-cupertino .bawn-browser-menu-label,
.theme-cupertino .bawn-download-popover-title,
.theme-cupertino .bawn-download-file-name,
.theme-cupertino button.bawn-download-footer {
color: #1d1d1f;
}
.theme-cupertino .bawn-browser-menu-icon,
.theme-cupertino .bawn-browser-menu-shortcut,
.theme-cupertino .bawn-download-file-meta,
.theme-cupertino .bawn-download-empty,
.theme-cupertino .bawn-status,
.theme-cupertino .bawn-zoom-label {
color: #626b7a;
}
.theme-cupertino button.bawn-browser-menu-action:hover,
.theme-cupertino button.bawn-browser-menu-mini:hover,
.theme-cupertino button.bawn-download-popover-row:hover,
.theme-cupertino button.bawn-download-footer:hover,
.theme-cupertino button.bawn-nav:hover,
.theme-cupertino .bawn-bookmark-chip:hover {
color: #111827;
background: #eef2f8;
}
.theme-cupertino button.bawn-armour,
.theme-cupertino button.bawn-primary-action,
.theme-cupertino .bawn-progress progress {
color: #fff;
background: #007aff;
border-color: #007aff;
}
.theme-cupertino .bawn-address-shell button.bawn-armour {
color: #007aff;
background: transparent;
border-color: transparent;
}
.theme-cupertino .bawn-bookmark-icon {
color: #fff;
background: #007aff;
}
.theme-cupertino.bawn-dialog,
.theme-high-contrast-light.bawn-dialog {
background: #f5f5f7;
}
.theme-cupertino .bawn-dialog-title,
.theme-cupertino .bawn-plugin-title,
.theme-cupertino .bawn-plugin-metric-value,
.theme-high-contrast-light .bawn-dialog-title,
.theme-high-contrast-light .bawn-plugin-title,
.theme-high-contrast-light .bawn-plugin-metric-value {
color: #1d1d1f;
}
.theme-cupertino .bawn-dialog-copy,
.theme-cupertino .bawn-plugin-kind,
.theme-cupertino .bawn-plugin-meta {
color: #626b7a;
}
.theme-cupertino .bawn-plugin-card,
.theme-cupertino .bawn-plugin-metric,
.theme-cupertino .bawn-plugin-status {
color: #1d1d1f;
background: #fff;
border-color: #d8dee8;
}
.theme-cupertino .bawn-plugin-mark,
.theme-cupertino .bawn-plugin-status.enabled {
color: #fff;
background: #007aff;
border-color: #007aff;
}
.theme-ocean.bawn-root,
.theme-forest.bawn-root,
.theme-rose.bawn-root,
.theme-private-browsing.bawn-root,
.theme-high-contrast-dark.bawn-root {
background: #050b12;
}
.theme-ocean .bawn-tabbar,
.theme-ocean .bawn-topbar,
.theme-ocean .bawn-bookmarkbar,
.theme-ocean .bawn-findbar {
background: #082130;
border-color: #12394d;
}
.theme-ocean .bawn-tab-selected,
.theme-ocean .bawn-url,
.theme-ocean .bawn-address-shell,
.theme-ocean button.bawn-nav,
.theme-ocean .bawn-theme-picker button {
color: #e0f7ff;
background: #0c3043;
border-color: #1e5f7a;
}
.theme-ocean button.bawn-armour,
.theme-ocean button.bawn-primary-action,
.theme-ocean .bawn-progress progress,
.theme-ocean .bawn-bookmark-icon {
color: #03212b;
background: #38bdf8;
border-color: #38bdf8;
}
.theme-ocean .bawn-address-shell button.bawn-armour {
color: #38bdf8;
background: transparent;
border-color: transparent;
}
.theme-forest .bawn-tabbar,
.theme-forest .bawn-topbar,
.theme-forest .bawn-bookmarkbar,
.theme-forest .bawn-findbar {
background: #0b2418;
border-color: #1e4b34;
}
.theme-forest .bawn-tab-selected,
.theme-forest .bawn-url,
.theme-forest .bawn-address-shell,
.theme-forest button.bawn-nav,
.theme-forest .bawn-theme-picker button {
color: #effff4;
background: #103322;
border-color: #2f6b4b;
}
.theme-forest button.bawn-armour,
.theme-forest button.bawn-primary-action,
.theme-forest .bawn-progress progress,
.theme-forest .bawn-bookmark-icon {
color: #052012;
background: #86efac;
border-color: #86efac;
}
.theme-forest .bawn-address-shell button.bawn-armour {
color: #86efac;
background: transparent;
border-color: transparent;
}
.theme-rose .bawn-tabbar,
.theme-rose .bawn-topbar,
.theme-rose .bawn-bookmarkbar,
.theme-rose .bawn-findbar {
background: #2a1422;
border-color: #5d2949;
}
.theme-rose .bawn-tab-selected,
.theme-rose .bawn-url,
.theme-rose .bawn-address-shell,
.theme-rose button.bawn-nav,
.theme-rose .bawn-theme-picker button {
color: #fff3f8;
background: #3a1a30;
border-color: #7b345d;
}
.theme-rose button.bawn-armour,
.theme-rose button.bawn-primary-action,
.theme-rose .bawn-progress progress,
.theme-rose .bawn-bookmark-icon {
color: #2d0a1c;
background: #fb7185;
border-color: #fb7185;
}
.theme-rose .bawn-address-shell button.bawn-armour {
color: #fb7185;
background: transparent;
border-color: transparent;
}
.theme-private-browsing .bawn-tabbar,
.theme-private-browsing .bawn-topbar,
.theme-private-browsing .bawn-bookmarkbar,
.theme-private-browsing .bawn-findbar {
background: #1c1031;
border-color: #3d2564;
}
.theme-private-browsing .bawn-tab-selected,
.theme-private-browsing .bawn-url,
.theme-private-browsing .bawn-address-shell,
.theme-private-browsing button.bawn-nav,
.theme-private-browsing .bawn-theme-picker button {
color: #fbf7ff;
background: #2a1848;
border-color: #5b3c82;
}
.theme-private-browsing button.bawn-armour,
.theme-private-browsing button.bawn-primary-action,
.theme-private-browsing .bawn-progress progress,
.theme-private-browsing .bawn-bookmark-icon {
color: #190729;
background: #c084fc;
border-color: #c084fc;
}
.theme-private-browsing .bawn-address-shell button.bawn-armour {
color: #c084fc;
background: transparent;
border-color: transparent;
}
.theme-ocean .bawn-menu-popover,
.theme-ocean .bawn-menu-content,
.theme-ocean .bawn-popover-scroll,
.theme-ocean .bawn-popover-scroll viewport,
.theme-ocean .bawn-download-popover,
.theme-ocean.bawn-menu-popover,
.theme-ocean.bawn-menu-content,
.theme-ocean.bawn-popover-scroll,
.theme-ocean.bawn-download-popover,
.theme-forest .bawn-menu-popover,
.theme-forest .bawn-menu-content,
.theme-forest .bawn-popover-scroll,
.theme-forest .bawn-popover-scroll viewport,
.theme-forest .bawn-download-popover,
.theme-forest.bawn-menu-popover,
.theme-forest.bawn-menu-content,
.theme-forest.bawn-popover-scroll,
.theme-forest.bawn-download-popover,
.theme-rose .bawn-menu-popover,
.theme-rose .bawn-menu-content,
.theme-rose .bawn-popover-scroll,
.theme-rose .bawn-popover-scroll viewport,
.theme-rose .bawn-download-popover,
.theme-rose.bawn-menu-popover,
.theme-rose.bawn-menu-content,
.theme-rose.bawn-popover-scroll,
.theme-rose.bawn-download-popover,
.theme-private-browsing .bawn-menu-popover,
.theme-private-browsing .bawn-menu-content,
.theme-private-browsing .bawn-popover-scroll,
.theme-private-browsing .bawn-popover-scroll viewport,
.theme-private-browsing .bawn-download-popover,
.theme-private-browsing.bawn-menu-popover,
.theme-private-browsing.bawn-menu-content,
.theme-private-browsing.bawn-popover-scroll,
.theme-private-browsing.bawn-download-popover {
color: #eef7ff;
background: #111827;
border-color: #334155;
}
.theme-ocean button.bawn-browser-menu-action:hover,
.theme-ocean button.bawn-download-popover-row:hover,
.theme-forest button.bawn-browser-menu-action:hover,
.theme-forest button.bawn-download-popover-row:hover,
.theme-rose button.bawn-browser-menu-action:hover,
.theme-rose button.bawn-download-popover-row:hover,
.theme-private-browsing button.bawn-browser-menu-action:hover,
.theme-private-browsing button.bawn-download-popover-row:hover {
background: #1f2937;
}
.theme-high-contrast-light .bawn-tabbar,
.theme-high-contrast-light .bawn-topbar,
.theme-high-contrast-light .bawn-bookmarkbar,
.theme-high-contrast-light .bawn-findbar,
.theme-high-contrast-light .bawn-brand,
.theme-high-contrast-light .bawn-tab-selected,
.theme-high-contrast-light .bawn-url,
.theme-high-contrast-light .bawn-address-shell,
.theme-high-contrast-light button.bawn-nav,
.theme-high-contrast-light .bawn-bookmark-chip,
.theme-high-contrast-light button.bawn-window-control,
.theme-high-contrast-light .bawn-theme-picker button,
.theme-high-contrast-light .bawn-menu-popover,
.theme-high-contrast-light .bawn-menu-content,
.theme-high-contrast-light .bawn-popover-scroll,
.theme-high-contrast-light .bawn-popover-scroll viewport,
.theme-high-contrast-light .bawn-download-popover,
.theme-high-contrast-light.bawn-menu-popover,
.theme-high-contrast-light.bawn-menu-content,
.theme-high-contrast-light.bawn-popover-scroll,
.theme-high-contrast-light.bawn-download-popover {
color: #000;
background: #fff;
border-color: #000;
}
.theme-high-contrast-light .bawn-browser-menu-label,
.theme-high-contrast-light .bawn-browser-menu-icon,
.theme-high-contrast-light .bawn-browser-menu-shortcut,
.theme-high-contrast-light .bawn-download-popover-title,
.theme-high-contrast-light .bawn-download-file-name,
.theme-high-contrast-light .bawn-download-file-meta,
.theme-high-contrast-light .bawn-download-empty,
.theme-high-contrast-light button.bawn-download-footer {
color: #000;
}
.theme-high-contrast-light button.bawn-browser-menu-action:hover,
.theme-high-contrast-light button.bawn-browser-menu-mini:hover,
.theme-high-contrast-light button.bawn-download-popover-row:hover,
.theme-high-contrast-light button.bawn-download-footer:hover {
color: #000;
background: #e6e6e6;
}
.theme-high-contrast-light .bawn-dialog-copy,
.theme-high-contrast-light .bawn-plugin-kind,
.theme-high-contrast-light .bawn-plugin-meta {
color: #111;
}
.theme-high-contrast-light .bawn-plugin-card,
.theme-high-contrast-light .bawn-plugin-metric,
.theme-high-contrast-light .bawn-plugin-status {
color: #000;
background: #fff;
border-color: #000;
}
.theme-high-contrast-light .bawn-plugin-mark,
.theme-high-contrast-light .bawn-plugin-status.enabled {
color: #fff;
background: #003cff;
border-color: #003cff;
}
.theme-high-contrast-light button.bawn-armour,
.theme-high-contrast-light button.bawn-primary-action,
.theme-high-contrast-light .bawn-progress progress,
.theme-high-contrast-light .bawn-bookmark-icon {
color: #fff;
background: #003cff;
border-color: #003cff;
}
.theme-high-contrast-dark .bawn-tabbar,
.theme-high-contrast-dark .bawn-topbar,
.theme-high-contrast-dark .bawn-bookmarkbar,
.theme-high-contrast-dark .bawn-findbar,
.theme-high-contrast-dark .bawn-brand,
.theme-high-contrast-dark .bawn-tab-selected,
.theme-high-contrast-dark .bawn-url,
.theme-high-contrast-dark .bawn-address-shell,
.theme-high-contrast-dark button.bawn-nav,
.theme-high-contrast-dark .bawn-bookmark-chip,
.theme-high-contrast-dark button.bawn-window-control,
.theme-high-contrast-dark .bawn-theme-picker button,
.theme-high-contrast-dark .bawn-menu-popover,
.theme-high-contrast-dark .bawn-menu-content,
.theme-high-contrast-dark .bawn-popover-scroll,
.theme-high-contrast-dark .bawn-popover-scroll viewport,
.theme-high-contrast-dark .bawn-download-popover,
.theme-high-contrast-dark.bawn-menu-popover,
.theme-high-contrast-dark.bawn-menu-content,
.theme-high-contrast-dark.bawn-popover-scroll,
.theme-high-contrast-dark.bawn-download-popover {
color: #fff;
background: #000;
border-color: #fff;
}
.theme-high-contrast-dark .bawn-browser-menu-label,
.theme-high-contrast-dark .bawn-browser-menu-icon,
.theme-high-contrast-dark .bawn-browser-menu-shortcut,
.theme-high-contrast-dark .bawn-download-popover-title,
.theme-high-contrast-dark .bawn-download-file-name,
.theme-high-contrast-dark .bawn-download-file-meta,
.theme-high-contrast-dark .bawn-download-empty,
.theme-high-contrast-dark button.bawn-download-footer {
color: #fff;
}
.theme-high-contrast-dark button.bawn-browser-menu-action:hover,
.theme-high-contrast-dark button.bawn-browser-menu-mini:hover,
.theme-high-contrast-dark button.bawn-download-popover-row:hover,
.theme-high-contrast-dark button.bawn-download-footer:hover {
color: #fff;
background: #1f1f1f;
}
.theme-high-contrast-dark button.bawn-armour,
.theme-high-contrast-dark button.bawn-primary-action,
.theme-high-contrast-dark .bawn-progress progress,
.theme-high-contrast-dark .bawn-bookmark-icon {
color: #000;
background: #00e5ff;
border-color: #00e5ff;
}
.theme-professional *,
.theme-nebula *,
.theme-ember *,
.theme-glacier *,
.theme-ocean *,
.theme-forest *,
.theme-rose * {
font-family: "Inter", "Segoe UI", "Cantarell", "Ubuntu", sans-serif;
}
.theme-professional.bawn-root,
.theme-professional.bawn-dialog,
.theme-nebula.bawn-root,
.theme-nebula.bawn-dialog,
.theme-ember.bawn-root,
.theme-ember.bawn-dialog,
.theme-glacier.bawn-root,
.theme-glacier.bawn-dialog,
.theme-ocean.bawn-root,
.theme-ocean.bawn-dialog,
.theme-forest.bawn-root,
.theme-forest.bawn-dialog,
.theme-rose.bawn-root,
.theme-rose.bawn-dialog {
background: #f5f7fb;
}
.theme-professional .bawn-tabbar,
.theme-professional .bawn-topbar,
.theme-professional .bawn-bookmarkbar,
.theme-professional .bawn-findbar,
.theme-nebula .bawn-tabbar,
.theme-nebula .bawn-topbar,
.theme-nebula .bawn-bookmarkbar,
.theme-nebula .bawn-findbar,
.theme-ember .bawn-tabbar,
.theme-ember .bawn-topbar,
.theme-ember .bawn-bookmarkbar,
.theme-ember .bawn-findbar,
.theme-glacier .bawn-tabbar,
.theme-glacier .bawn-topbar,
.theme-glacier .bawn-bookmarkbar,
.theme-glacier .bawn-findbar,
.theme-ocean .bawn-tabbar,
.theme-ocean .bawn-topbar,
.theme-ocean .bawn-bookmarkbar,
.theme-ocean .bawn-findbar,
.theme-forest .bawn-tabbar,
.theme-forest .bawn-topbar,
.theme-forest .bawn-bookmarkbar,
.theme-forest .bawn-findbar,
.theme-rose .bawn-tabbar,
.theme-rose .bawn-topbar,
.theme-rose .bawn-bookmarkbar,
.theme-rose .bawn-findbar {
background: #eef2f7;
border-color: #d7dde7;
}
.theme-professional .bawn-brand,
.theme-professional .bawn-tab-selected,
.theme-professional .bawn-url,
.theme-professional .bawn-address-shell,
.theme-professional button.bawn-nav,
.theme-professional .bawn-bookmark-chip,
.theme-professional button.bawn-window-control,
.theme-professional .bawn-theme-picker button,
.theme-professional .bawn-control-group,
.theme-professional .bawn-menu-section,
.theme-professional .bawn-menu-popover,
.theme-professional .bawn-menu-content,
.theme-professional .bawn-panel-content,
.theme-professional .bawn-popover-scroll,
.theme-professional .bawn-popover-scroll viewport,
.theme-professional button.bawn-menu-action,
.theme-professional .bawn-download-popover,
.theme-professional .bawn-plugin-card,
.theme-professional .bawn-plugin-metric,
.theme-professional .bawn-plugin-status,
.theme-professional .bawn-addon-card,
.theme-professional .bawn-history-row,
.theme-professional.bawn-menu-popover,
.theme-professional.bawn-menu-content,
.theme-professional.bawn-panel-content,
.theme-professional.bawn-popover-scroll,
.theme-professional.bawn-download-popover,
.theme-nebula .bawn-brand,
.theme-nebula .bawn-tab-selected,
.theme-nebula .bawn-url,
.theme-nebula .bawn-address-shell,
.theme-nebula button.bawn-nav,
.theme-nebula .bawn-bookmark-chip,
.theme-nebula button.bawn-window-control,
.theme-nebula .bawn-theme-picker button,
.theme-nebula .bawn-control-group,
.theme-nebula .bawn-menu-section,
.theme-nebula .bawn-menu-popover,
.theme-nebula .bawn-menu-content,
.theme-nebula .bawn-panel-content,
.theme-nebula .bawn-popover-scroll,
.theme-nebula .bawn-popover-scroll viewport,
.theme-nebula button.bawn-menu-action,
.theme-nebula .bawn-download-popover,
.theme-nebula .bawn-plugin-card,
.theme-nebula .bawn-plugin-metric,
.theme-nebula .bawn-plugin-status,
.theme-nebula .bawn-addon-card,
.theme-nebula .bawn-history-row,
.theme-nebula.bawn-menu-popover,
.theme-nebula.bawn-menu-content,
.theme-nebula.bawn-panel-content,
.theme-nebula.bawn-popover-scroll,
.theme-nebula.bawn-download-popover,
.theme-ember .bawn-brand,
.theme-ember .bawn-tab-selected,
.theme-ember .bawn-url,
.theme-ember .bawn-address-shell,
.theme-ember button.bawn-nav,
.theme-ember .bawn-bookmark-chip,
.theme-ember button.bawn-window-control,
.theme-ember .bawn-theme-picker button,
.theme-ember .bawn-control-group,
.theme-ember .bawn-menu-section,
.theme-ember .bawn-menu-popover,
.theme-ember .bawn-menu-content,
.theme-ember .bawn-panel-content,
.theme-ember .bawn-popover-scroll,
.theme-ember .bawn-popover-scroll viewport,
.theme-ember button.bawn-menu-action,
.theme-ember .bawn-download-popover,
.theme-ember .bawn-plugin-card,
.theme-ember .bawn-plugin-metric,
.theme-ember .bawn-plugin-status,
.theme-ember .bawn-addon-card,
.theme-ember .bawn-history-row,
.theme-ember.bawn-menu-popover,
.theme-ember.bawn-menu-content,
.theme-ember.bawn-panel-content,
.theme-ember.bawn-popover-scroll,
.theme-ember.bawn-download-popover,
.theme-glacier .bawn-brand,
.theme-glacier .bawn-tab-selected,
.theme-glacier .bawn-url,
.theme-glacier .bawn-address-shell,
.theme-glacier button.bawn-nav,
.theme-glacier .bawn-bookmark-chip,
.theme-glacier button.bawn-window-control,
.theme-glacier .bawn-theme-picker button,
.theme-glacier .bawn-control-group,
.theme-glacier .bawn-menu-section,
.theme-glacier .bawn-menu-popover,
.theme-glacier .bawn-menu-content,
.theme-glacier .bawn-panel-content,
.theme-glacier .bawn-popover-scroll,
.theme-glacier .bawn-popover-scroll viewport,
.theme-glacier button.bawn-menu-action,
.theme-glacier .bawn-download-popover,
.theme-glacier .bawn-plugin-card,
.theme-glacier .bawn-plugin-metric,
.theme-glacier .bawn-plugin-status,
.theme-glacier .bawn-addon-card,
.theme-glacier .bawn-history-row,
.theme-glacier.bawn-menu-popover,
.theme-glacier.bawn-menu-content,
.theme-glacier.bawn-panel-content,
.theme-glacier.bawn-popover-scroll,
.theme-glacier.bawn-download-popover,
.theme-ocean .bawn-brand,
.theme-ocean .bawn-tab-selected,
.theme-ocean .bawn-url,
.theme-ocean .bawn-address-shell,
.theme-ocean button.bawn-nav,
.theme-ocean .bawn-bookmark-chip,
.theme-ocean button.bawn-window-control,
.theme-ocean .bawn-theme-picker button,
.theme-ocean .bawn-control-group,
.theme-ocean .bawn-menu-section,
.theme-ocean .bawn-menu-popover,
.theme-ocean .bawn-menu-content,
.theme-ocean .bawn-panel-content,
.theme-ocean .bawn-popover-scroll,
.theme-ocean .bawn-popover-scroll viewport,
.theme-ocean button.bawn-menu-action,
.theme-ocean .bawn-download-popover,
.theme-ocean .bawn-plugin-card,
.theme-ocean .bawn-plugin-metric,
.theme-ocean .bawn-plugin-status,
.theme-ocean .bawn-addon-card,
.theme-ocean .bawn-history-row,
.theme-ocean.bawn-menu-popover,
.theme-ocean.bawn-menu-content,
.theme-ocean.bawn-panel-content,
.theme-ocean.bawn-popover-scroll,
.theme-ocean.bawn-download-popover,
.theme-forest .bawn-brand,
.theme-forest .bawn-tab-selected,
.theme-forest .bawn-url,
.theme-forest .bawn-address-shell,
.theme-forest button.bawn-nav,
.theme-forest .bawn-bookmark-chip,
.theme-forest button.bawn-window-control,
.theme-forest .bawn-theme-picker button,
.theme-forest .bawn-control-group,
.theme-forest .bawn-menu-section,
.theme-forest .bawn-menu-popover,
.theme-forest .bawn-menu-content,
.theme-forest .bawn-panel-content,
.theme-forest .bawn-popover-scroll,
.theme-forest .bawn-popover-scroll viewport,
.theme-forest button.bawn-menu-action,
.theme-forest .bawn-download-popover,
.theme-forest .bawn-plugin-card,
.theme-forest .bawn-plugin-metric,
.theme-forest .bawn-plugin-status,
.theme-forest .bawn-addon-card,
.theme-forest .bawn-history-row,
.theme-forest.bawn-menu-popover,
.theme-forest.bawn-menu-content,
.theme-forest.bawn-panel-content,
.theme-forest.bawn-popover-scroll,
.theme-forest.bawn-download-popover,
.theme-rose .bawn-brand,
.theme-rose .bawn-tab-selected,
.theme-rose .bawn-url,
.theme-rose .bawn-address-shell,
.theme-rose button.bawn-nav,
.theme-rose .bawn-bookmark-chip,
.theme-rose button.bawn-window-control,
.theme-rose .bawn-theme-picker button,
.theme-rose .bawn-control-group,
.theme-rose .bawn-menu-section,
.theme-rose .bawn-menu-popover,
.theme-rose .bawn-menu-content,
.theme-rose .bawn-panel-content,
.theme-rose .bawn-popover-scroll,
.theme-rose .bawn-popover-scroll viewport,
.theme-rose button.bawn-menu-action,
.theme-rose .bawn-download-popover,
.theme-rose .bawn-plugin-card,
.theme-rose .bawn-plugin-metric,
.theme-rose .bawn-plugin-status,
.theme-rose .bawn-addon-card,
.theme-rose .bawn-history-row,
.theme-rose.bawn-menu-popover,
.theme-rose.bawn-menu-content,
.theme-rose.bawn-panel-content,
.theme-rose.bawn-popover-scroll,
.theme-rose.bawn-download-popover {
color: #172033;
background: #fff;
border-color: #d7dde7;
border-radius: 10px;
}
.theme-professional .bawn-browser-menu-label,
.theme-professional .bawn-download-popover-title,
.theme-professional .bawn-download-file-name,
.theme-professional .bawn-dialog-title,
.theme-professional .bawn-plugin-title,
.theme-professional .bawn-plugin-metric-value,
.theme-nebula .bawn-browser-menu-label,
.theme-nebula .bawn-download-popover-title,
.theme-nebula .bawn-download-file-name,
.theme-nebula .bawn-dialog-title,
.theme-nebula .bawn-plugin-title,
.theme-nebula .bawn-plugin-metric-value,
.theme-ember .bawn-browser-menu-label,
.theme-ember .bawn-download-popover-title,
.theme-ember .bawn-download-file-name,
.theme-ember .bawn-dialog-title,
.theme-ember .bawn-plugin-title,
.theme-ember .bawn-plugin-metric-value,
.theme-glacier .bawn-browser-menu-label,
.theme-glacier .bawn-download-popover-title,
.theme-glacier .bawn-download-file-name,
.theme-glacier .bawn-dialog-title,
.theme-glacier .bawn-plugin-title,
.theme-glacier .bawn-plugin-metric-value,
.theme-ocean .bawn-browser-menu-label,
.theme-ocean .bawn-download-popover-title,
.theme-ocean .bawn-download-file-name,
.theme-ocean .bawn-dialog-title,
.theme-ocean .bawn-plugin-title,
.theme-ocean .bawn-plugin-metric-value,
.theme-forest .bawn-browser-menu-label,
.theme-forest .bawn-download-popover-title,
.theme-forest .bawn-download-file-name,
.theme-forest .bawn-dialog-title,
.theme-forest .bawn-plugin-title,
.theme-forest .bawn-plugin-metric-value,
.theme-rose .bawn-browser-menu-label,
.theme-rose .bawn-download-popover-title,
.theme-rose .bawn-download-file-name,
.theme-rose .bawn-dialog-title,
.theme-rose .bawn-plugin-title,
.theme-rose .bawn-plugin-metric-value {
color: #172033;
}
.theme-professional .bawn-browser-menu-icon,
.theme-professional .bawn-browser-menu-shortcut,
.theme-professional .bawn-download-file-meta,
.theme-professional .bawn-download-empty,
.theme-professional .bawn-dialog-copy,
.theme-professional .bawn-plugin-kind,
.theme-professional .bawn-plugin-meta,
.theme-nebula .bawn-browser-menu-icon,
.theme-nebula .bawn-browser-menu-shortcut,
.theme-nebula .bawn-download-file-meta,
.theme-nebula .bawn-download-empty,
.theme-nebula .bawn-dialog-copy,
.theme-nebula .bawn-plugin-kind,
.theme-nebula .bawn-plugin-meta,
.theme-ember .bawn-browser-menu-icon,
.theme-ember .bawn-browser-menu-shortcut,
.theme-ember .bawn-download-file-meta,
.theme-ember .bawn-download-empty,
.theme-ember .bawn-dialog-copy,
.theme-ember .bawn-plugin-kind,
.theme-ember .bawn-plugin-meta,
.theme-glacier .bawn-browser-menu-icon,
.theme-glacier .bawn-browser-menu-shortcut,
.theme-glacier .bawn-download-file-meta,
.theme-glacier .bawn-download-empty,
.theme-glacier .bawn-dialog-copy,
.theme-glacier .bawn-plugin-kind,
.theme-glacier .bawn-plugin-meta,
.theme-ocean .bawn-browser-menu-icon,
.theme-ocean .bawn-browser-menu-shortcut,
.theme-ocean .bawn-download-file-meta,
.theme-ocean .bawn-download-empty,
.theme-ocean .bawn-dialog-copy,
.theme-ocean .bawn-plugin-kind,
.theme-ocean .bawn-plugin-meta,
.theme-forest .bawn-browser-menu-icon,
.theme-forest .bawn-browser-menu-shortcut,
.theme-forest .bawn-download-file-meta,
.theme-forest .bawn-download-empty,
.theme-forest .bawn-dialog-copy,
.theme-forest .bawn-plugin-kind,
.theme-forest .bawn-plugin-meta,
.theme-rose .bawn-browser-menu-icon,
.theme-rose .bawn-browser-menu-shortcut,
.theme-rose .bawn-download-file-meta,
.theme-rose .bawn-download-empty,
.theme-rose .bawn-dialog-copy,
.theme-rose .bawn-plugin-kind,
.theme-rose .bawn-plugin-meta {
color: #566273;
}
.theme-professional button.bawn-browser-menu-action:hover,
.theme-professional button.bawn-browser-menu-mini:hover,
.theme-professional button.bawn-download-popover-row:hover,
.theme-professional button.bawn-download-footer:hover,
.theme-professional button.bawn-nav:hover,
.theme-professional .bawn-bookmark-chip:hover,
.theme-nebula button.bawn-browser-menu-action:hover,
.theme-nebula button.bawn-browser-menu-mini:hover,
.theme-nebula button.bawn-download-popover-row:hover,
.theme-nebula button.bawn-download-footer:hover,
.theme-nebula button.bawn-nav:hover,
.theme-nebula .bawn-bookmark-chip:hover,
.theme-ember button.bawn-browser-menu-action:hover,
.theme-ember button.bawn-browser-menu-mini:hover,
.theme-ember button.bawn-download-popover-row:hover,
.theme-ember button.bawn-download-footer:hover,
.theme-ember button.bawn-nav:hover,
.theme-ember .bawn-bookmark-chip:hover,
.theme-glacier button.bawn-browser-menu-action:hover,
.theme-glacier button.bawn-browser-menu-mini:hover,
.theme-glacier button.bawn-download-popover-row:hover,
.theme-glacier button.bawn-download-footer:hover,
.theme-glacier button.bawn-nav:hover,
.theme-glacier .bawn-bookmark-chip:hover,
.theme-ocean button.bawn-browser-menu-action:hover,
.theme-ocean button.bawn-browser-menu-mini:hover,
.theme-ocean button.bawn-download-popover-row:hover,
.theme-ocean button.bawn-download-footer:hover,
.theme-ocean button.bawn-nav:hover,
.theme-ocean .bawn-bookmark-chip:hover,
.theme-forest button.bawn-browser-menu-action:hover,
.theme-forest button.bawn-browser-menu-mini:hover,
.theme-forest button.bawn-download-popover-row:hover,
.theme-forest button.bawn-download-footer:hover,
.theme-forest button.bawn-nav:hover,
.theme-forest .bawn-bookmark-chip:hover,
.theme-rose button.bawn-browser-menu-action:hover,
.theme-rose button.bawn-browser-menu-mini:hover,
.theme-rose button.bawn-download-popover-row:hover,
.theme-rose button.bawn-download-footer:hover,
.theme-rose button.bawn-nav:hover,
.theme-rose .bawn-bookmark-chip:hover {
color: #172033;
background: #eef2f7;
}
.theme-professional button.bawn-armour,
.theme-professional button.bawn-primary-action,
.theme-professional .bawn-progress progress,
.theme-professional .bawn-bookmark-icon,
.theme-professional .bawn-plugin-mark,
.theme-professional .bawn-plugin-status.enabled {
color: #fff;
background: #4f46e5;
border-color: #4f46e5;
}
.theme-nebula button.bawn-armour,
.theme-nebula button.bawn-primary-action,
.theme-nebula .bawn-progress progress,
.theme-nebula .bawn-bookmark-icon,
.theme-nebula .bawn-plugin-mark,
.theme-nebula .bawn-plugin-status.enabled,
.theme-ember button.bawn-armour,
.theme-ember button.bawn-primary-action,
.theme-ember .bawn-progress progress,
.theme-ember .bawn-bookmark-icon,
.theme-ember .bawn-plugin-mark,
.theme-ember .bawn-plugin-status.enabled {
color: #fff;
background: #4b5563;
border-color: #4b5563;
}
.theme-glacier button.bawn-armour,
.theme-glacier button.bawn-primary-action,
.theme-glacier .bawn-progress progress,
.theme-glacier .bawn-bookmark-icon,
.theme-glacier .bawn-plugin-mark,
.theme-glacier .bawn-plugin-status.enabled,
.theme-ocean button.bawn-armour,
.theme-ocean button.bawn-primary-action,
.theme-ocean .bawn-progress progress,
.theme-ocean .bawn-bookmark-icon,
.theme-ocean .bawn-plugin-mark,
.theme-ocean .bawn-plugin-status.enabled {
color: #fff;
background: #2563eb;
border-color: #2563eb;
}
.theme-forest button.bawn-armour,
.theme-forest button.bawn-primary-action,
.theme-forest .bawn-progress progress,
.theme-forest .bawn-bookmark-icon,
.theme-forest .bawn-plugin-mark,
.theme-forest .bawn-plugin-status.enabled {
color: #fff;
background: #16a34a;
border-color: #16a34a;
}
.theme-rose button.bawn-armour,
.theme-rose button.bawn-primary-action,
.theme-rose .bawn-progress progress,
.theme-rose .bawn-bookmark-icon,
.theme-rose .bawn-plugin-mark,
.theme-rose .bawn-plugin-status.enabled {
color: #fff;
background: #e95420;
border-color: #e95420;
}
.theme-professional .bawn-address-shell button.bawn-armour,
.theme-nebula .bawn-address-shell button.bawn-armour,
.theme-ember .bawn-address-shell button.bawn-armour,
.theme-glacier .bawn-address-shell button.bawn-armour,
.theme-ocean .bawn-address-shell button.bawn-armour,
.theme-forest .bawn-address-shell button.bawn-armour,
.theme-rose .bawn-address-shell button.bawn-armour {
background: transparent;
border-color: transparent;
}
.theme-professional .bawn-address-shell button.bawn-armour { color: #4f46e5; }
.theme-nebula .bawn-address-shell button.bawn-armour,
.theme-ember .bawn-address-shell button.bawn-armour { color: #4b5563; }
.theme-glacier .bawn-address-shell button.bawn-armour,
.theme-ocean .bawn-address-shell button.bawn-armour { color: #2563eb; }
.theme-forest .bawn-address-shell button.bawn-armour { color: #16a34a; }
.theme-rose .bawn-address-shell button.bawn-armour { color: #e95420; }
.theme-glass.bawn-root,
.theme-glass.bawn-dialog {
background: linear-gradient(135deg, #f8fbff, #dce7f6);
}
.theme-glass .bawn-tabbar,
.theme-glass .bawn-topbar,
.theme-glass .bawn-bookmarkbar,
.theme-glass .bawn-findbar {
background: rgba(255,255,255,.56);
border-color: rgba(79,124,255,.18);
box-shadow: 0 10px 34px rgba(31,65,125,.12);
}
.theme-glass .bawn-brand,
.theme-glass .bawn-tab-selected,
.theme-glass .bawn-url,
.theme-glass .bawn-address-shell,
.theme-glass button.bawn-nav,
.theme-glass .bawn-bookmark-chip,
.theme-glass button.bawn-window-control,
.theme-glass .bawn-theme-picker button,
.theme-glass .bawn-control-group,
.theme-glass .bawn-menu-section,
.theme-glass .bawn-menu-popover,
.theme-glass .bawn-menu-content,
.theme-glass .bawn-panel-content,
.theme-glass .bawn-popover-scroll,
.theme-glass .bawn-popover-scroll viewport,
.theme-glass button.bawn-menu-action,
.theme-glass .bawn-download-popover,
.theme-glass .bawn-plugin-card,
.theme-glass .bawn-plugin-metric,
.theme-glass .bawn-plugin-status,
.theme-glass .bawn-addon-card,
.theme-glass .bawn-history-row,
.theme-glass.bawn-menu-popover,
.theme-glass.bawn-menu-content,
.theme-glass.bawn-panel-content,
.theme-glass.bawn-popover-scroll,
.theme-glass.bawn-download-popover {
color: #172033;
background: rgba(255,255,255,.72);
border-color: rgba(79,124,255,.22);
}
.theme-glass .bawn-address-shell {
box-shadow: 0 12px 34px rgba(31,65,125,.14);
}
.theme-glass .bawn-address-shell.bawn-address-focused {
border-color: #4f7cff;
box-shadow: 0 0 0 2px rgba(79,124,255,.2), 0 18px 46px rgba(31,65,125,.16);
}
.theme-glass .bawn-browser-menu-label,
.theme-glass .bawn-download-popover-title,
.theme-glass .bawn-download-file-name,
.theme-glass .bawn-dialog-title,
.theme-glass .bawn-plugin-title,
.theme-glass .bawn-plugin-metric-value,
.theme-glass .bawn-bookmark-label {
color: #172033;
}
.theme-glass .bawn-browser-menu-icon,
.theme-glass .bawn-browser-menu-shortcut,
.theme-glass .bawn-download-file-meta,
.theme-glass .bawn-download-empty,
.theme-glass .bawn-dialog-copy,
.theme-glass .bawn-plugin-kind,
.theme-glass .bawn-plugin-meta,
.theme-glass .bawn-status,
.theme-glass .bawn-zoom-label {
color: #596273;
}
.theme-glass button.bawn-armour,
.theme-glass button.bawn-primary-action,
.theme-glass .bawn-progress progress,
.theme-glass .bawn-bookmark-icon,
.theme-glass .bawn-plugin-mark,
.theme-glass .bawn-plugin-status.enabled {
color: #fff;
background: #4f7cff;
border-color: #4f7cff;
}
.theme-glass .bawn-address-shell button.bawn-armour {
color: #4f7cff;
background: transparent;
border-color: transparent;
}
.theme-agent-inspect.bawn-root,
.theme-agent-inspect.bawn-dialog {
background: #08111f;
}
.theme-agent-inspect .bawn-tabbar,
.theme-agent-inspect .bawn-topbar,
.theme-agent-inspect .bawn-bookmarkbar,
.theme-agent-inspect .bawn-findbar {
background: #0a1220;
border-color: #facc15;
}
.theme-agent-inspect .bawn-tabbar { border-bottom: 2px solid #38bdf8; }
.theme-agent-inspect .bawn-topbar { border-bottom: 2px solid #22c55e; }
.theme-agent-inspect .bawn-bookmarkbar { border-bottom: 2px solid #a78bfa; }
.theme-agent-inspect .bawn-findbar { border-bottom: 2px solid #fb7185; }
.theme-agent-inspect .bawn-brand,
.theme-agent-inspect .bawn-tab,
.theme-agent-inspect .bawn-tab-selected,
.theme-agent-inspect .bawn-url,
.theme-agent-inspect .bawn-address-shell,
.theme-agent-inspect button.bawn-nav,
.theme-agent-inspect .bawn-bookmark-chip,
.theme-agent-inspect button.bawn-window-control,
.theme-agent-inspect .bawn-theme-picker button,
.theme-agent-inspect .bawn-control-group,
.theme-agent-inspect .bawn-menu-section,
.theme-agent-inspect .bawn-menu-popover,
.theme-agent-inspect .bawn-menu-content,
.theme-agent-inspect .bawn-panel-content,
.theme-agent-inspect .bawn-popover-scroll,
.theme-agent-inspect .bawn-popover-scroll viewport,
.theme-agent-inspect button.bawn-menu-action,
.theme-agent-inspect .bawn-download-popover,
.theme-agent-inspect .bawn-plugin-card,
.theme-agent-inspect .bawn-plugin-metric,
.theme-agent-inspect .bawn-plugin-status,
.theme-agent-inspect .bawn-addon-card,
.theme-agent-inspect .bawn-history-row,
.theme-agent-inspect.bawn-menu-popover,
.theme-agent-inspect.bawn-menu-content,
.theme-agent-inspect.bawn-panel-content,
.theme-agent-inspect.bawn-popover-scroll,
.theme-agent-inspect.bawn-download-popover {
color: #f8fafc;
background: #0d1728;
border-color: #facc15;
}
.theme-agent-inspect .bawn-tab-selected { border-color: #38bdf8; }
.theme-agent-inspect .bawn-address-shell {
border-width: 2px;
border-color: #facc15;
box-shadow: 0 0 0 2px rgba(250,204,21,.12);
}
.theme-agent-inspect .bawn-page-actions { border-left: 2px solid #f472b6; }
.theme-agent-inspect button.bawn-nav { border-color: #38bdf8; }
.theme-agent-inspect button.bawn-armour,
.theme-agent-inspect button.bawn-primary-action,
.theme-agent-inspect .bawn-progress progress,
.theme-agent-inspect .bawn-bookmark-icon,
.theme-agent-inspect .bawn-plugin-mark,
.theme-agent-inspect .bawn-plugin-status.enabled {
color: #08111f;
background: #facc15;
border-color: #facc15;
}
.theme-agent-inspect .bawn-address-shell button.bawn-armour {
color: #38bdf8;
background: transparent;
border-color: #38bdf8;
}
.theme-agent-inspect .bawn-browser-menu-label,
.theme-agent-inspect .bawn-download-popover-title,
.theme-agent-inspect .bawn-download-file-name,
.theme-agent-inspect .bawn-dialog-title,
.theme-agent-inspect .bawn-plugin-title,
.theme-agent-inspect .bawn-plugin-metric-value,
.theme-agent-inspect .bawn-bookmark-label {
color: #f8fafc;
}
.theme-agent-inspect .bawn-browser-menu-icon,
.theme-agent-inspect .bawn-browser-menu-shortcut,
.theme-agent-inspect .bawn-download-file-meta,
.theme-agent-inspect .bawn-download-empty,
.theme-agent-inspect .bawn-dialog-copy,
.theme-agent-inspect .bawn-plugin-kind,
.theme-agent-inspect .bawn-plugin-meta,
.theme-agent-inspect .bawn-status,
.theme-agent-inspect .bawn-zoom-label {
color: #cbd5e1;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_control_action_paths_resolve() {
assert_eq!(
human_control_action_name("/human/move"),
Some("move pointer")
);
assert_eq!(human_control_action_name("/human/click"), Some("click"));
assert_eq!(human_control_action_name("/human/type"), Some("type text"));
assert_eq!(human_control_action_name("/human/key"), Some("press key"));
assert_eq!(human_control_action_name("/human/scroll"), Some("scroll"));
assert_eq!(human_control_action_name("/human/wait"), Some("wait"));
assert_eq!(human_control_action_name("/human/enable"), None);
assert_eq!(human_control_action_name("/human/unknown"), None);
}
#[test]
fn human_control_coordinates_parse_and_clamp() {
assert_eq!(
human_control_xy(|name| match name {
"x" => Some("320"),
"y" => Some("240"),
_ => None,
}),
Some((320, 240))
);
assert_eq!(
human_control_xy(|name| match name {
"x" => Some("-20"),
"y" => Some("12000"),
_ => None,
}),
Some((0, 10000))
);
assert_eq!(human_control_xy(|_| None), None);
assert_eq!(
human_control_xy(|name| match name {
"x" => Some("nope"),
"y" => Some("240"),
_ => None,
}),
None
);
}
#[test]
fn human_control_examples_cover_all_action_routes() {
let examples = agent_human_control_examples();
for route in [
"/human/move",
"/human/click",
"/human/type",
"/human/key",
"/human/scroll",
"/human/wait",
] {
assert!(examples.contains(route), "missing route {route}");
}
}
#[test]
fn javascript_strings_are_json_escaped() {
assert_eq!(js_string("a\"b\nc"), "\"a\\\"b\\nc\"");
}
#[test]
fn pdf_navigation_detects_pdf_paths() {
assert!(is_pdf_navigation(
&Url::parse("https://example.com/report.PDF?download=1").unwrap()
));
assert!(!is_pdf_navigation(
&Url::parse("https://example.com/pdf-viewer.html").unwrap()
));
}
#[test]
fn context_menu_pruning_keeps_fast_common_actions() {
assert!(!should_prune_context_menu_action(
ContextMenuAction::CopyLinkToClipboard,
false,
false
));
assert!(!should_prune_context_menu_action(
ContextMenuAction::Copy,
true,
false
));
assert!(!should_prune_context_menu_action(
ContextMenuAction::PasteAsPlainText,
true,
false
));
assert!(should_prune_context_menu_action(
ContextMenuAction::PasteAsPlainText,
false,
false
));
assert!(should_prune_context_menu_action(
ContextMenuAction::FontMenu,
true,
false
));
assert!(should_prune_context_menu_action(
ContextMenuAction::InspectElement,
false,
false
));
assert!(!should_prune_context_menu_action(
ContextMenuAction::InspectElement,
false,
true
));
}
#[test]
fn plugin_action_routes_parse_uuidv7_ids() {
let plugin_id = bundled_plugin_modules().first().unwrap().manifest.id;
let url = Url::parse(&format!("cephas://plugins/install?id={plugin_id}")).unwrap();
assert_eq!(plugin_selector_from_uri(&url), Some(plugin_id));
let module = Url::parse("cephas://plugins/install?module=reader-outline").unwrap();
assert_eq!(plugin_selector_from_uri(&module), Some(plugin_id));
let missing = Url::parse("cephas://plugins/install").unwrap();
assert_eq!(plugin_selector_from_uri(&missing), None);
}
#[test]
fn click_script_uses_nearest_clickable_activation() {
let script = human_click_script(12, 34);
assert!(script.contains("const x = 12;"));
assert!(script.contains("const y = 34;"));
assert!(script.contains("elementFromPoint(x, y)"));
assert!(script.contains("target.closest?.('a,button,input,select,textarea,label"));
assert!(script.contains("clickable.click()"));
}
#[test]
fn agent_emulation_cards_cover_every_manifest_command() {
let html = agent_emulation_cards_html();
for entry in agent_command_manifest() {
assert!(
html.contains(entry.id),
"missing emulation card for {}",
entry.id
);
assert!(
html.contains(&format!("command={}", entry.id)),
"missing emulation route for {}",
entry.id
);
}
assert!(!html.contains("data-request="));
assert!(html.contains("data-target-required="));
}
#[test]
fn agent_emulation_specs_are_non_empty_for_every_command() {
for entry in agent_command_manifest() {
let (category, coverage, example, result) =
agent_emulation_spec(entry.id, "https://example.com");
assert!(!category.is_empty(), "missing category for {}", entry.id);
assert!(!coverage.is_empty(), "missing coverage for {}", entry.id);
assert!(!example.is_empty(), "missing example for {}", entry.id);
assert!(!result.is_empty(), "missing result for {}", entry.id);
}
}
#[test]
fn agent_docs_html_exposes_integration_surfaces() {
let html = agent_docs_html(&Profile::default());
assert!(html.contains("Local Agentic Integration Guide"));
assert!(html.contains("cephas://agent/docs"));
assert!(html.contains("agent-commands --jsonl"));
assert!(html.contains("target/debug/cephas mcp"));
assert!(html.contains("cephas_agent_tooling"));
assert!(html.contains("agent-smoke --output-dir"));
assert!(html.contains("launch-safety JSON artifacts"));
assert!(html.contains("diagnostics --json"));
assert!(html.contains("diagnostics --json --strict"));
assert!(html.contains("cephas://diagnostics"));
assert!(html.contains("resource_checks"));
assert!(html.contains("unobserved_resource_caps"));
assert!(html.contains("settings.png"));
assert!(html.contains("diagnostics.png"));
assert!(html.contains("duration_ms"));
assert!(html.contains("plugins --jsonl"));
assert!(html.contains("plugins available"));
assert!(html.contains("Backend Contracts"));
assert!(html.contains("elixir_backend.MD"));
assert!(html.contains("plugins_backend.MD"));
assert!(html.contains("CAPTURE_OPTIMIZATION.md"));
assert!(html.contains("Agent Vision And Watchable Sessions"));
assert!(html.contains("cephas.agent-vision.v1"));
assert!(html.contains("agent-emulation:<command-id>"));
assert!(html.contains("human-control:<action>"));
assert!(html.contains("sync_recordings"));
assert!(html.contains("Resource Stability"));
assert!(html.contains("agent-tooling.resource_caps"));
assert!(html.contains("agent-tooling.diagnostics_surfaces"));
assert!(html.contains("live GTK runtime counters"));
assert!(html.contains("source_body_truncated"));
assert!(html.contains("Code-Tool Porting Checklist"));
}
#[test]
fn agent_screenshot_html_exposes_agent_vision_packet() {
let html = agent_screenshot_html(&Profile::default(), "https://example.com");
assert!(html.contains("Agent Vision"));
assert!(html.contains("cephas.agent-vision.v1"));
assert!(html.contains("agent-emulation:<command-id>"));
assert!(html.contains("human-control:<action>"));
assert!(html.contains("target/debug/cephas agent-screenshot"));
assert!(html.contains("sync_recordings"));
assert!(!html.contains("__AGENT_VISION_PACKET__"));
}
#[test]
fn settings_html_exposes_media_policy_and_plugins() {
let profile = Profile {
search_provider: SearchProvider::Brave,
..Profile::default()
};
let html = settings_html(&profile);
assert!(html.contains("name=\"media_playback\""));
assert!(html.contains("Balanced media"));
assert!(html.contains("name=\"search_provider\""));
assert!(html.contains("DuckDuckGo"));
assert!(html.contains("value=\"brave\" selected>Brave Search"));
assert!(html.contains("WebGL"));
assert!(html.contains("best-effort WebGPU"));
assert!(html.contains("name=\"startup\" value=\"continue-previous-session\""));
assert!(html.contains("Save startup behavior"));
assert!(html.contains("Browser Armour at a glance"));
assert!(html.contains("7 of 7 protections are enabled"));
assert!(html.contains(&format!("0 / {DEFAULT_MAX_SITE_ALLOWLIST}")));
assert!(html.contains(&format!(" / {DEFAULT_MAX_BLOCKED_HOSTS}")));
assert!(html.contains("Sites with Armour off"));
assert!(html.contains("Blocked-host rules"));
assert!(html.contains("UUIDv7 plugin registry"));
assert!(html.contains("UUIDv7 plugins tracked"));
assert!(html.contains("Registry health"));
assert!(html.contains("No plugin registry issues detected"));
assert!(html.contains("cephas.plugins.check.v1"));
assert!(html.contains("plugin-card"));
assert!(html.contains("Built-in API"));
assert!(html.contains("Bundled project modules"));
assert!(html.contains("plugins/reader-outline/plugin.json"));
assert!(html.contains("cephas://plugins/install?module="));
assert!(html.contains("macOS Paper"));
assert!(html.contains("Glass"));
assert!(html.contains("AI Inspect"));
assert!(html.contains("Brave Blue"));
assert!(html.contains("cephas://settings/theme?id=dark-paper"));
assert!(html.contains("cephas://settings/theme?id=agent-inspect"));
assert!(html.contains("Custom Theme Presets"));
assert!(html.contains("cephas://settings/theme-preset?id=midnight-focus"));
assert!(html.contains("data-preset=\"agent-grid\""));
assert!(html.contains("name=\"page\""));
assert!(html.contains("name=\"muted\""));
assert!(html.contains("name=\"radius\""));
assert!(html.contains("value=\"rounded\" selected>Rounded"));
assert!(html.contains("name=\"density\""));
assert!(html.contains("value=\"comfortable\" selected>Comfortable"));
assert!(html.contains("Custom theme contrast"));
assert!(html.contains("Text / surface"));
assert!(html.contains("PDF documents"));
assert!(html.contains("content-visibility: auto"));
assert!(html.contains("prefers-reduced-motion: reduce"));
assert!(!html.contains("__MEDIA_PLAYBACK_OPTIONS__"));
assert!(!html.contains("__SEARCH_PROVIDER_OPTIONS__"));
assert!(!html.contains("__INTERNAL_PAGE_PERFORMANCE_CSS__"));
assert!(!html.contains("__PLUGIN_HEALTH__"));
assert!(!html.contains("__PLUGIN_REGISTRY__"));
assert!(!html.contains("__THEME_GALLERY__"));
assert!(!html.contains("__CUSTOM_THEME_PRESETS__"));
assert!(!html.contains("__CUSTOM_THEME_CONTRAST__"));
assert!(!html.contains("__CUSTOM_RADIUS_OPTIONS__"));
assert!(!html.contains("__CUSTOM_DENSITY_OPTIONS__"));
assert!(!html.contains("__ARMOUR_"));
}
#[test]
fn webkit_armour_headers_skip_internal_and_preserve_resource_headers() {
let mut profile = Profile::default();
let headers = armour_webkit_headers_for_uri(&profile, "https://example.com/app.css");
assert!(headers.contains(&("Upgrade-Insecure-Requests", "1")));
assert!(headers.contains(&("Sec-GPC", "1")));
assert!(headers.contains(&("DNT", "1")));
assert!(!headers.iter().any(|(name, _)| *name == "Accept"));
assert!(!headers.iter().any(|(name, _)| *name == "Accept-Language"));
assert!(armour_webkit_headers_for_uri(&profile, "cephas://agent").is_empty());
assert!(armour_webkit_headers_for_uri(&profile, "https://cephas.local/start").is_empty());
assert!(armour_webkit_headers_for_uri(&profile, "not a url").is_empty());
profile
.shields
.allow_site(&Url::parse("https://example.com/").unwrap());
assert!(armour_webkit_headers_for_uri(&profile, "https://example.com/app.css").is_empty());
}
#[test]
fn webkit_armour_blocks_third_party_tracker_resources() {
let mut profile = Profile::default();
let page = Some("https://news.example/article");
let tracker = Url::parse("https://stats.google-analytics.com/analytics.js").unwrap();
let same_host = Url::parse("https://news.example/app.js").unwrap();
let internal = Url::parse("https://cephas.local/start").unwrap();
let data = Url::parse("data:,").unwrap();
assert!(should_block_webkit_request(&profile, page, &tracker));
assert!(!should_block_webkit_request(&profile, page, &same_host));
assert!(!should_block_webkit_request(&profile, page, &internal));
assert!(!should_block_webkit_request(&profile, page, &data));
assert!(!should_block_webkit_request(&profile, None, &tracker));
profile
.shields
.allow_site(&Url::parse("https://news.example/").unwrap());
assert!(!should_block_webkit_request(&profile, page, &tracker));
}
#[test]
fn diagnostics_template_exposes_resource_rows_and_json_guidance() {
let rows = diagnostics_rows_html(&[("Open tabs".to_string(), "1 / 100".to_string())]);
let html = render_template(
DIAGNOSTICS_TEMPLATE,
&[
("__THEME_ID__", "paper"),
("__CUSTOM_THEME_PAGE_CSS__", ""),
("__PROFILE_PATH__", "/tmp/profile.json"),
("__ROW_COUNT__", "1"),
("__DIAGNOSTICS_ROWS__", &rows),
],
);
assert!(html.contains("Cephas Resource Diagnostics"));
assert!(html.contains("cephas://diagnostics"));
assert!(html.contains("Open tabs"));
assert!(html.contains("cephas.diagnostics.v1"));
assert!(html.contains("resource_checks"));
assert!(html.contains("unobserved_resource_caps"));
assert!(html.contains("live GTK runtime counters"));
assert!(html.contains("persisted/offline subset"));
assert!(html.contains("agent-smoke --output-dir <dir> --case diagnostics"));
assert!(html.contains("diagnostics --json --strict"));
assert!(html.contains("content-visibility: auto"));
assert!(!html.contains("__INTERNAL_PAGE_PERFORMANCE_CSS__"));
}
#[test]
fn diagnostics_helpers_render_live_runtime_caps() {
assert_eq!(active_downloads_diagnostics_value(2, 0), "2 / 16");
assert_eq!(
active_downloads_diagnostics_value(2, 3),
"2 active + 3 pending / 16"
);
assert_eq!(
save_debounce_diagnostics_value(true, false, true),
"profile, downloads debounce pending"
);
assert_eq!(save_debounce_diagnostics_value(false, false, false), "idle");
assert_eq!(
human_waits_diagnostics_value(4, true),
"4 / 16 pending; control enabled"
);
assert!(recording_idle_diagnostics_value().contains("0 / 1200 frames"));
assert!(recording_idle_diagnostics_value().contains("0 / 10000 events"));
}
#[test]
fn settings_html_surfaces_plugin_check_warnings() {
let mut profile = Profile::default();
let module = bundled_plugin_modules().first().unwrap().clone();
profile.plugins.install_bundled(module.manifest.id).unwrap();
profile
.plugins
.installed
.iter_mut()
.find(|plugin| plugin.id == module.manifest.id)
.unwrap()
.name = "Drifted module name".to_string();
let html = settings_html(&profile);
assert!(html.contains("Plugin registry warnings are present"));
assert!(html.contains("0 errors, 1 warning"));
assert!(html.contains("plugin-health-status warning"));
}
#[test]
fn plugins_html_exposes_full_registry_page() {
let html = plugins_html(&Profile::default());
assert!(html.contains("UUIDv7 Plugin Registry"));
assert!(html.contains("cephas.plugins.v1"));
assert!(html.contains("Registry health"));
assert!(html.contains("No plugin registry issues detected"));
assert!(html.contains("cephas.plugins.check.v1"));
assert!(html.contains("plugin-card"));
assert!(html.contains("Built-in API"));
assert!(html.contains("bundled modules"));
assert!(html.contains("plugins/reader-outline/plugin.json"));
assert!(html.contains("cephas://plugins/install-all"));
assert!(html.contains("cephas://plugins/install?module="));
assert!(html.contains("cephas://plugins"));
assert!(html.contains("content-visibility: auto"));
assert!(html.contains("prefers-reduced-motion: no-preference"));
assert!(!html.contains("https://cephas.local/plugins"));
assert!(!html.contains("__PLUGIN_REGISTRY__"));
assert!(!html.contains("__PLUGIN_CHECK_SUMMARY__"));
assert!(!html.contains("__PLUGIN_COUNT__"));
assert!(!html.contains("__PLANNED_COUNT__"));
assert!(!html.contains("__BUNDLED_COUNT__"));
assert!(!html.contains("__MAX_PLUGINS__"));
assert!(!html.contains("__THEME_ID__"));
}
#[test]
fn plugins_html_surfaces_plugin_check_warnings() {
let mut profile = Profile::default();
let module = bundled_plugin_modules().first().unwrap().clone();
profile.plugins.install_bundled(module.manifest.id).unwrap();
profile
.plugins
.installed
.iter_mut()
.find(|plugin| plugin.id == module.manifest.id)
.unwrap()
.name = "Drifted module name".to_string();
let html = plugins_html(&profile);
assert!(html.contains("Registry is usable, but strict checks will fail"));
assert!(html.contains("bundled-metadata-drift"));
assert!(html.contains("installed bundled plugin metadata differs"));
assert!(html.contains("Module <code>reader-outline</code>"));
}
#[test]
fn plugins_html_moves_installed_bundled_modules_to_profile_actions() {
let mut profile = Profile::default();
let plugin_id = bundled_plugin_modules().first().unwrap().manifest.id;
profile.plugins.install_bundled(plugin_id).unwrap();
let html = plugins_html(&profile);
let install_href = "cephas://plugins/install?module=reader-outline";
let enable_href = "cephas://plugins/enable?module=reader-outline";
let sync_href = "cephas://plugins/sync?module=reader-outline";
assert!(!html.contains(install_href));
assert!(html.contains(enable_href));
assert!(html.contains(sync_href));
}
#[test]
fn plugins_html_hides_install_all_when_every_bundle_is_installed() {
let mut profile = Profile::default();
profile.plugins.install_all_available_bundled().unwrap();
let html = plugins_html(&profile);
assert!(!html.contains("cephas://plugins/install-all"));
assert!(html.contains("cephas://plugins/sync-all"));
assert!(html.contains("All bundled project plugins are installed"));
}
#[test]
fn landing_html_uses_fast_template_renderer() {
let profile = Profile {
search_provider: SearchProvider::Startpage,
..Profile::default()
};
let html = landing_html(&profile);
assert!(html.contains("Plugin slots"));
assert!(html.contains("cephas://agent"));
assert!(html.contains("cephas://start/theme"));
assert!(html.contains("action=\"https://www.startpage.com/sp/search\""));
assert!(html.contains("name=\"query\""));
assert!(html.contains("Search Startpage or type a URL"));
assert!(html.contains("https://www.startpage.com/sp/search?query=privacy+tools"));
assert!(html.contains("content-visibility: auto"));
assert!(html.contains("prefers-reduced-motion: reduce"));
assert!(!html.contains("__SHORTCUTS__"));
assert!(!html.contains("__ADDON_COUNT__"));
assert!(!html.contains("__NEXT_THEME_LABEL__"));
assert!(!html.contains("__SEARCH_ACTION__"));
assert!(!html.contains("__INTERNAL_PAGE_PERFORMANCE_CSS__"));
}
#[test]
fn landing_links_stop_at_limit() {
let mut profile = Profile::default();
for index in 0..20 {
profile.bookmarks.push(crate::storage::Bookmark {
title: format!("Bookmark {index}"),
url: Url::parse(&format!("https://example.com/{index}")).unwrap(),
created_at: Utc::now(),
});
}
assert!(landing_links(&profile, 0).is_empty());
assert_eq!(landing_links(&profile, 3).len(), 3);
}
#[test]
fn next_browser_theme_cycles_all_theme_variants() {
assert_eq!(
next_browser_theme(BrowserTheme::Paper),
BrowserTheme::DarkPaper
);
assert_eq!(
next_browser_theme(BrowserTheme::Custom),
BrowserTheme::Professional
);
}
#[test]
fn render_template_replaces_known_placeholders_once() {
let rendered = render_template(
"a __ONE__ b __TWO__ c __UNKNOWN__",
&[("__ONE__", "1"), ("__TWO__", "2")],
);
assert_eq!(rendered, "a 1 b 2 c __UNKNOWN__");
}
#[test]
fn gtk_css_keeps_fast_chrome_effect_states() {
assert!(GTK_CSS.contains("transition: background-color 90ms ease-out"));
assert!(GTK_CSS.contains("transition: opacity 140ms ease-out"));
assert!(GTK_CSS.contains("bawn-progress-complete"));
assert!(GTK_CSS.contains("background: #a7c7ff"));
assert!(GTK_CSS.contains("bawn-address-focused"));
assert!(GTK_CSS.contains("theme-glass"));
assert!(GTK_CSS.contains("theme-agent-inspect"));
assert!(GTK_CSS.contains("-gtk-icon-shadow: none"));
assert!(INTERNAL_PAGE_PERFORMANCE_CSS.contains("body.agent-inspect .card"));
assert!(INTERNAL_PAGE_PERFORMANCE_CSS.contains("backdrop-filter: blur"));
assert!(!INTERNAL_PAGE_PERFORMANCE_CSS.contains("scale(1.025)"));
assert!(LANDING_TEMPLATE.contains("text-shadow: none"));
}
#[test]
fn custom_theme_css_applies_extended_controls() {
let theme = CustomTheme {
name: "Studio".to_string(),
chrome: "#111827".to_string(),
page: "#f8fafc".to_string(),
surface: "#ffffff".to_string(),
text: "#0f172a".to_string(),
muted: "#475569".to_string(),
accent: "#2563eb".to_string(),
radius: CustomThemeRadius::Pill,
density: CustomThemeDensity::Spacious,
};
let page_css = custom_theme_page_css(&theme);
let gtk_css = custom_theme_gtk_css(&theme);
assert!(page_css.contains("--custom-page: #f8fafc"));
assert!(page_css.contains("--custom-muted: #475569"));
assert!(page_css.contains("--custom-control-radius: 999px"));
assert!(page_css.contains("--custom-control-height: 46px"));
assert!(page_css.contains("color-scheme: light"));
assert!(gtk_css.contains("background: #f8fafc"));
assert!(gtk_css.contains("min-height: 42px"));
assert!(gtk_css.contains("border-radius: 999px"));
}
#[test]
fn custom_theme_presets_are_bounded_unique_and_valid() {
assert!(CUSTOM_THEME_PRESETS.len() <= 8);
let mut ids = std::collections::BTreeSet::new();
for preset in CUSTOM_THEME_PRESETS {
assert!(ids.insert(preset.id));
assert_eq!(
custom_theme_preset_by_id(preset.id).unwrap().label,
preset.label
);
assert!(sanitize_hex_color(preset.chrome).is_some());
assert!(sanitize_hex_color(preset.page).is_some());
assert!(sanitize_hex_color(preset.surface).is_some());
assert!(sanitize_hex_color(preset.text).is_some());
assert!(sanitize_hex_color(preset.muted).is_some());
assert!(sanitize_hex_color(preset.accent).is_some());
assert_eq!(custom_theme_from_preset(preset).name, preset.label);
}
assert!(custom_theme_preset_by_id("missing").is_none());
}
#[test]
fn custom_theme_preset_renderer_marks_current_preset() {
let preset = custom_theme_preset_by_id("paper-ink").unwrap();
let theme = custom_theme_from_preset(preset);
let html = custom_theme_presets_html(&theme);
assert!(html.contains("theme-preset active"));
assert!(html.contains("Paper Ink"));
assert!(html.contains("--preset-page:#faf7ef"));
}
#[test]
fn custom_theme_color_scheme_uses_page_luminance() {
assert_eq!(custom_theme_color_scheme("#ffffff"), "light");
assert_eq!(custom_theme_color_scheme("#050505"), "dark");
assert_eq!(custom_theme_color_scheme("bad"), "dark");
}
#[test]
fn custom_theme_contrast_reports_pass_and_warning_states() {
assert!(contrast_ratio("#000000", "#ffffff").unwrap() > 20.0);
assert!(contrast_ratio("#777777", "#777777").unwrap() < 1.1);
let readable = custom_theme_from_preset(custom_theme_preset_by_id("paper-ink").unwrap());
let readable_html = custom_theme_contrast_html(&readable);
assert!(readable_html.contains("theme-contrast-status ok"));
assert!(readable_html.contains("Text / page"));
let low_contrast = CustomTheme {
name: "Low Contrast".to_string(),
chrome: "#222222".to_string(),
page: "#777777".to_string(),
surface: "#777777".to_string(),
text: "#777777".to_string(),
muted: "#777777".to_string(),
accent: "#777777".to_string(),
radius: CustomThemeRadius::Rounded,
density: CustomThemeDensity::Comfortable,
};
let warning_html = custom_theme_contrast_html(&low_contrast);
assert!(warning_html.contains("theme-contrast-status warning"));
assert!(warning_html.contains("Needs review"));
}
#[test]
fn address_focus_status_is_short() {
assert_eq!(ADDRESS_FOCUS_STATUS, "Address bar focused");
assert!(ADDRESS_FOCUS_STATUS.len() <= 32);
}
#[test]
fn internal_route_matching_rejects_lookalikes() {
assert!(route_matches(
"cephas://settings/theme?id=paper",
CEPHAS_SETTINGS_THEME_URI
));
assert!(is_internal_uri("CEPHAS://start"));
assert!(is_settings_uri("HTTPS://CEPHAS.LOCAL/settings?x=1"));
assert!(is_settings_uri("https://cephas.local:443/settings"));
assert!(is_trusted_internal_source_uri("https://cephas.local/agent"));
assert!(is_mutating_internal_action_uri(
"cephas://agent/human/click?x=1&y=2"
));
assert!(is_direct_internal_target("HTTPS://CEPHAS.LOCAL/agent/docs"));
assert!(!route_matches(
"cephas://settings/themeevil?id=paper",
CEPHAS_SETTINGS_THEME_URI
));
assert!(!is_internal_uri("https://example.com/start"));
assert!(!is_settings_uri("https://cephas.local:444/settings"));
assert!(!is_trusted_internal_source_uri("https://example.com/agent"));
assert!(!is_mutating_internal_action_uri(
"https://example.com/settings"
));
assert!(!is_direct_internal_target("https://example.com/agent"));
assert!(!is_settings_uri("https://example.com/settings"));
assert!(route_under(
"cephas://agent/human/click?x=1&y=2",
CEPHAS_AGENT_HUMAN_URI
));
assert!(!route_under(
"cephas://agent/humanity/click?x=1&y=2",
CEPHAS_AGENT_HUMAN_URI
));
assert!(!is_settings_uri("https://cephas.local/settings.evil"));
}
#[test]
fn settings_home_url_rejects_unsafe_schemes() {
assert_eq!(
parse_settings_home_url(" https://example.com/start ")
.unwrap()
.as_str(),
"https://example.com/start"
);
assert!(
parse_settings_home_url("cephas://agent")
.unwrap_err()
.contains("only http and https")
);
assert!(
parse_settings_home_url("file:///etc/passwd")
.unwrap_err()
.contains("only http and https")
);
assert!(parse_settings_home_url("not a url").is_err());
}
#[test]
fn unknown_internal_direct_launch_targets_are_blocked() {
assert!(should_block_unknown_internal_target(
"cephas://settings/themeevil?id=paper"
));
assert!(should_block_unknown_internal_target(
"https://cephas.local/settings.evil"
));
assert!(should_block_unknown_internal_target(
"cephas://agent/humanity/click"
));
assert!(!should_block_unknown_internal_target("cephas://settings"));
assert!(!should_block_unknown_internal_target(
"cephas://agent/human/wait?ms=50"
));
assert!(!should_block_unknown_internal_target(
"https://example.com/"
));
}
#[test]
fn mutating_internal_actions_require_trusted_source() {
assert!(is_trusted_internal_source_uri("https://cephas.local/agent"));
assert!(is_trusted_internal_source_uri("cephas://settings"));
assert!(!is_trusted_internal_source_uri("https://example.com/agent"));
assert!(!is_trusted_internal_source_uri(
"https://cephas.local/agent.evil"
));
assert!(is_mutating_internal_action_uri(
"cephas://plugins/install-all"
));
assert!(is_mutating_internal_action_uri(
"cephas://agent/human/wait?ms=1000"
));
assert!(!is_mutating_internal_action_uri(
"cephas://plugins/install-all-later"
));
}
#[test]
fn agent_reader_metadata_records_source_truncation() {
let url = Url::parse("https://example.com/").unwrap();
let article = extract_article("<h1>Example</h1><p>Hello</p>", &url);
let artifact = agent_reader_artifact(
&article,
"<h1>Example</h1><p>Hello</p>",
&url,
AgentReaderTool::JsonArtifact,
true,
);
let metadata = agent_reader_event_metadata(&artifact, 123);
assert_eq!(
metadata["artifact"]["source_body_truncated"].as_bool(),
Some(true)
);
}
#[test]
fn reader_redirect_targets_are_sanitized_and_web_only() {
let profile = Profile::default();
let base = Url::parse("https://example.com/articles/amp?utm_source=x").unwrap();
let target = sanitized_reader_redirect_target(
&profile,
&base,
"/articles/full?utm_source=newsletter&id=7",
)
.unwrap();
assert_eq!(target.as_str(), "https://example.com/articles/full?id=7");
assert!(sanitized_reader_redirect_target(&profile, &base, "cephas://agent").is_err());
assert!(sanitized_reader_redirect_target(&profile, &base, "data:text/html,hi").is_err());
}
#[test]
fn save_queue_full_falls_back_to_synchronous_write() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("profile.json");
let generation = Arc::new(AtomicU64::new(1));
let queue_depth = AtomicUsize::new(0);
let (sender, _receiver) = std::sync::mpsc::sync_channel::<SaveJob>(0);
let save_lock = Mutex::new(());
enqueue_save_job(
&sender,
&save_lock,
&queue_depth,
SaveJob::Profile {
path: path.clone(),
value: Box::new(Profile::default()),
generation,
expected_generation: 1,
},
);
assert!(path.exists());
assert_eq!(queue_depth.load(Ordering::Acquire), 0);
}
#[test]
fn unique_download_path_does_not_use_existing_copy_fallback() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("report.pdf"), b"").unwrap();
for index in 1..1000 {
fs::write(directory.path().join(format!("report ({index}).pdf")), b"").unwrap();
}
fs::write(directory.path().join("report (copy)"), b"").unwrap();
let candidate = unique_download_path_in(directory.path(), "report.pdf");
assert!(!candidate.exists());
assert!(
candidate
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("report-")
);
assert_eq!(
candidate.extension().and_then(|value| value.to_str()),
Some("pdf")
);
}
#[test]
fn download_path_guard_rejects_paths_outside_root() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let inside_file = root.path().join("file.txt");
let outside_file = outside.path().join("file.txt");
fs::write(&inside_file, b"ok").unwrap();
fs::write(&outside_file, b"no").unwrap();
assert!(path_inside_directory(&inside_file, root.path()));
assert!(path_inside_directory(
&root.path().join("missing.txt"),
root.path()
));
assert!(!path_inside_directory(&outside_file, root.path()));
assert!(!path_inside_directory(
&root.path().join("missing/child.txt"),
root.path()
));
}
#[cfg(unix)]
#[test]
fn download_path_guard_rejects_symlink_escapes() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("file.txt");
let outside_directory = outside.path().join("escape");
fs::write(&outside_file, b"outside").unwrap();
fs::create_dir(&outside_directory).unwrap();
let file_link = root.path().join("file-link.txt");
let directory_link = root.path().join("directory-link");
std::os::unix::fs::symlink(&outside_file, &file_link).unwrap();
std::os::unix::fs::symlink(&outside_directory, &directory_link).unwrap();
assert!(!path_inside_directory(&file_link, root.path()));
assert!(!path_inside_directory(
&directory_link.join("missing.txt"),
root.path()
));
}
#[test]
fn risky_download_open_detection_blocks_executables() {
assert!(is_risky_download_open_path(Path::new("installer.exe")));
assert!(is_risky_download_open_path(Path::new("launcher.desktop")));
assert!(is_risky_download_open_path(Path::new("script.sh")));
assert!(!is_risky_download_open_path(Path::new("document.pdf")));
}
#[test]
fn risky_downloads_inside_download_root_require_open_confirmation() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let risky_path = root.path().join("installer.sh");
let safe_path = root.path().join("document.pdf");
let outside_path = outside.path().join("installer.sh");
fs::write(&risky_path, b"#!/bin/sh\n").unwrap();
fs::write(&safe_path, b"pdf").unwrap();
fs::write(&outside_path, b"#!/bin/sh\n").unwrap();
let record = DownloadRecord {
id: DownloadId(1),
url: Url::parse("https://example.com/installer.sh").unwrap(),
file_path: risky_path,
mime_type: None,
total_bytes: None,
downloaded_bytes: 12,
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status: DownloadStatus::Completed,
};
let safe_record = DownloadRecord {
file_path: safe_path,
..record.clone()
};
let outside_record = DownloadRecord {
file_path: outside_path,
..record.clone()
};
assert!(download_command_allowed_in_directory(
&record,
DownloadCommand::Open,
root.path()
));
assert!(download_open_requires_confirmation(&record));
assert!(download_command_allowed_in_directory(
&safe_record,
DownloadCommand::Open,
root.path()
));
assert!(!download_open_requires_confirmation(&safe_record));
assert!(!download_command_allowed_in_directory(
&outside_record,
DownloadCommand::Open,
root.path()
));
}
#[test]
fn restored_session_tabs_preserve_safe_tab_state() {
let session = BrowserSession {
windows: vec![SessionWindow {
id: WindowId(1),
active_tab: Some(TabId(2)),
tabs: vec![
SessionTab {
id: TabId(1),
url: Some(Url::parse("https://example.com/one").unwrap()),
title: "One".to_string(),
pinned: true,
muted: false,
private: false,
zoom: 1.2,
},
SessionTab {
id: TabId(2),
url: Some(Url::parse("https://example.com/two").unwrap()),
title: "Two".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 0.9,
},
SessionTab {
id: TabId(3),
url: Some(Url::parse("https://private.example/").unwrap()),
title: "Private".to_string(),
pinned: false,
muted: false,
private: true,
zoom: 1.0,
},
],
}],
};
let (tabs, active_tab) = restored_session_tabs(session);
assert_eq!(active_tab, Some(TabId(2)));
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0].saved_id, TabId(1));
assert!(tabs[0].options.pinned);
assert!(!tabs[0].options.private);
assert_eq!(tabs[0].options.title.as_deref(), Some("One"));
assert_eq!(tabs[0].options.zoom, 1.2);
match &tabs[0].target {
StartTarget::Url(url) => assert_eq!(url.as_str(), "https://example.com/one"),
StartTarget::Landing => panic!("expected restored URL"),
}
}
}