use super::launch;
use crate::core::{config_cache::ConfigCache, settings_manager::agent_dir};
use crate::utils::net;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::RwLock,
time::Duration,
};
static CONFIG_LOCK: RwLock<()> = RwLock::new(());
static CONFIG_CACHE: ConfigCache = ConfigCache::new();
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 9222;
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_MAX_SNAPSHOT_CHARS: usize = 20_000;
pub const DEFAULT_MAX_CONTENT_CHARS: usize = 50_000;
pub const DEFAULT_MAX_ELEMENTS: usize = 200;
const TIMEOUT_RANGE: (u64, u64) = (1, 600);
const SNAPSHOT_CHARS_RANGE: (usize, usize) = (500, 1_000_000);
const CONTENT_CHARS_RANGE: (usize, usize) = (500, 2_000_000);
const MAX_ELEMENTS_RANGE: (usize, usize) = (1, 2000);
pub fn config_path() -> PathBuf {
agent_dir().join("extensions").join("browser.json")
}
fn default_template() -> String {
let browsers = launch::default_browsers()
.iter()
.map(|program| format!(" {program:?}"))
.collect::<Vec<_>>()
.join(",\n");
format!(
r#"{{
"_comment": "browser extension config (all keys optional). The CDP endpoint has no authentication, so host must be a loopback address.",
"host": "127.0.0.1",
"port": 9222,
"timeout": 30,
"browsers": [
{browsers}
],
"maxSnapshotChars": 20000,
"maxContentChars": 50000,
"maxElements": 200
}}
"#
)
}
pub fn ensure_config_file() {
let path = config_path();
if path.exists() {
return;
}
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
_ = std::fs::write(&path, default_template());
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub host: String,
pub port: u16,
pub timeout: Duration,
pub browsers: Vec<String>,
pub max_snapshot_chars: usize,
pub max_content_chars: usize,
pub max_elements: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
host: DEFAULT_HOST.to_string(),
port: DEFAULT_PORT,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
browsers: launch::default_browsers(),
max_snapshot_chars: DEFAULT_MAX_SNAPSHOT_CHARS,
max_content_chars: DEFAULT_MAX_CONTENT_CHARS,
max_elements: DEFAULT_MAX_ELEMENTS,
}
}
}
impl Config {
pub fn validate(&self) -> Result<(), String> {
if !net::is_loopback_host(&self.host) {
return Err(format!(
"browser: config `host` must be a loopback address (localhost / 127.0.0.1 / ::1), got `{}`; the CDP endpoint has no authentication",
self.host
));
}
if self.port == 0 {
return Err("browser: config `port` must be 1-65535".to_string());
}
let secs = self.timeout.as_secs();
if !(TIMEOUT_RANGE.0..=TIMEOUT_RANGE.1).contains(&secs) {
return Err(format!(
"browser: config `timeout` must be {}..={} seconds, got {secs}",
TIMEOUT_RANGE.0, TIMEOUT_RANGE.1
));
}
if let Some(index) = self.browsers.iter().position(|p| p.trim().is_empty()) {
return Err(format!(
"browser: config `browsers[{index}]` is empty; remove the entry to stop auto-starting that candidate"
));
}
for (key, value, range) in [
(
"maxSnapshotChars",
self.max_snapshot_chars,
SNAPSHOT_CHARS_RANGE,
),
(
"maxContentChars",
self.max_content_chars,
CONTENT_CHARS_RANGE,
),
("maxElements", self.max_elements, MAX_ELEMENTS_RANGE),
] {
if !(range.0..=range.1).contains(&value) {
return Err(format!(
"browser: config `{key}` must be {}..={}, got {value}",
range.0, range.1
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
struct FileConfig {
host: Option<String>,
port: Option<u16>,
timeout: Option<u64>,
browsers: Option<Vec<String>>,
max_snapshot_chars: Option<usize>,
max_content_chars: Option<usize>,
max_elements: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Loaded {
pub config: Config,
pub error: Option<String>,
}
pub fn load() -> Loaded {
let path = config_path();
let _guard = CONFIG_LOCK.read().unwrap_or_else(|e| e.into_inner());
let value = CONFIG_CACHE.read(&path, || load_uncached(&path));
loaded_config(&value)
}
pub fn invalidate() {
let _guard = CONFIG_LOCK.write().unwrap_or_else(|e| e.into_inner());
CONFIG_CACHE.invalidate();
}
fn load_uncached(path: &Path) -> Value {
if !path.exists() {
return json!({ "config": Value::Null });
}
match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<FileConfig>(&text) {
Ok(file) => json!({ "config": file }),
Err(err) => json!({ "error": format!("Failed to parse {}: {err}", path.display()) }),
},
Err(err) => json!({ "error": format!("Failed to read {}: {err}", path.display()) }),
}
}
fn loaded_config(value: &Value) -> Loaded {
if let Some(error) = value.get("error").and_then(Value::as_str) {
return Loaded {
config: Config::default(),
error: Some(format!("browser: {error}")),
};
}
let Some(config) = value.get("config").filter(|v| !v.is_null()) else {
return Loaded {
config: Config::default(),
error: None,
};
};
let Ok(file) = serde_json::from_value::<FileConfig>(config.clone()) else {
return Loaded {
config: Config::default(),
error: Some("browser: config could not be decoded".to_string()),
};
};
let defaults = Config::default();
let config = Config {
host: file
.host
.filter(|h| !h.trim().is_empty())
.unwrap_or(defaults.host),
port: file.port.unwrap_or(defaults.port),
timeout: Duration::from_secs(file.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS)),
browsers: match file.browsers {
Some(browsers) => browsers
.into_iter()
.map(|program| program.trim().to_string())
.collect(),
None => defaults.browsers,
},
max_snapshot_chars: file
.max_snapshot_chars
.unwrap_or(defaults.max_snapshot_chars),
max_content_chars: file.max_content_chars.unwrap_or(defaults.max_content_chars),
max_elements: file.max_elements.unwrap_or(defaults.max_elements),
};
match config.validate() {
Ok(()) => Loaded {
config,
error: None,
},
Err(error) => Loaded {
config: Config::default(),
error: Some(error),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::AgentDirGuard;
#[test]
fn missing_file_falls_back_to_defaults() {
let _dir = AgentDirGuard::temp();
let loaded = load();
assert_eq!(loaded.error, None);
assert_eq!(loaded.config, Config::default());
}
#[test]
fn partial_file_overrides_only_given_keys() {
let _dir = AgentDirGuard::temp();
ensure_config_file();
std::fs::write(config_path(), r#"{ "port": 9333, "maxElements": 7 }"#).unwrap();
invalidate();
let loaded = load();
assert_eq!(loaded.error, None);
assert_eq!(loaded.config.port, 9333);
assert_eq!(loaded.config.max_elements, 7);
assert_eq!(loaded.config.host, DEFAULT_HOST);
assert_eq!(loaded.config.timeout, Duration::from_secs(30));
assert_eq!(loaded.config.browsers, launch::default_browsers());
}
#[test]
fn browsers_key_replaces_the_builtin_list() {
let _dir = AgentDirGuard::temp();
ensure_config_file();
std::fs::write(
config_path(),
r#"{ "browsers": [" my-chrome ", "/opt/brave/brave"] }"#,
)
.unwrap();
invalidate();
assert_eq!(
load().config.browsers,
vec!["my-chrome".to_string(), "/opt/brave/brave".to_string()]
);
std::fs::write(config_path(), r#"{ "browsers": [] }"#).unwrap();
invalidate();
assert!(load().config.browsers.is_empty());
}
#[test]
fn empty_browser_entries_are_rejected() {
let config = Config {
browsers: vec![" ".to_string()],
..Config::default()
};
assert!(config.validate().unwrap_err().contains("browsers[0]"));
}
#[test]
fn default_template_is_valid() {
let _dir = AgentDirGuard::temp();
ensure_config_file();
let text = std::fs::read_to_string(config_path()).unwrap();
let file: FileConfig = serde_json::from_str(&text).unwrap();
let browsers = file.browsers.clone().expect("模板应列出默认候选浏览器");
assert_eq!(browsers, launch::default_browsers());
let config = Config {
host: file.host.unwrap(),
port: file.port.unwrap(),
timeout: Duration::from_secs(file.timeout.unwrap()),
browsers,
max_snapshot_chars: file.max_snapshot_chars.unwrap(),
max_content_chars: file.max_content_chars.unwrap(),
max_elements: file.max_elements.unwrap(),
};
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn non_loopback_host_is_rejected() {
let config = Config {
host: "10.0.0.5".to_string(),
..Config::default()
};
assert!(config.validate().unwrap_err().contains("loopback"));
}
#[test]
fn out_of_range_values_are_rejected() {
let port = Config {
port: 0,
..Config::default()
};
assert!(port.validate().unwrap_err().contains("port"));
let timeout = Config {
timeout: Duration::from_secs(0),
..Config::default()
};
assert!(timeout.validate().unwrap_err().contains("timeout"));
let elements = Config {
max_elements: 0,
..Config::default()
};
assert!(elements.validate().unwrap_err().contains("maxElements"));
}
#[test]
fn malformed_file_reports_an_error() {
let _dir = AgentDirGuard::temp();
ensure_config_file();
std::fs::write(config_path(), "{ not json").unwrap();
invalidate();
let loaded = load();
assert!(loaded.error.unwrap().contains("Failed to parse"));
assert_eq!(loaded.config, Config::default());
}
}