Files
EasyCLIProxyAPI/src-tauri/src/configuration_watcher.rs
T
lzt404 d7e39e8fdc Add Oh My Pi support through the OpenAI Responses API
Discover CPA models dynamically and route the managed provider to /v1/responses.
2026-10-08 18:36:58 +08:00

658 lines
25 KiB
Rust

use super::{
agent_managed_paths, apply_gui_managed_settings, consume_software_write,
core_config_settings_from_value, core_install_dir, gui_config_path, is_loopback_host,
lock_core_config_file, normalized_config_path, path_to_string,
refresh_agent_config_status_cache, request_codex_model_catalog_refresh, validate_gui_config,
write_core_config_if_changed, AgentClient, AgentConfigStatusCache, ConfigFilesChangedPayload,
CoreConfigSettings, GuiConfigFile, GuiConfigState, CONFIG_FILES_CHANGED_EVENT,
CORE_CONFIG_FILE,
};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
use tauri::{Emitter, Manager};
fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool {
left == right || normalized_config_path(left) == normalized_config_path(right)
}
fn wait_for_config_file_stability(path: &Path) {
if !path.is_file() {
return;
}
let mut previous = None;
let mut stable_samples = 0;
for _ in 0..10 {
let current = fs::metadata(path)
.ok()
.map(|metadata| (metadata.len(), metadata.modified().ok()));
if current == previous {
stable_samples += 1;
if stable_samples >= 2 {
return;
}
} else {
stable_samples = 0;
previous = current;
}
thread::sleep(Duration::from_millis(100));
}
}
fn patch_core_from_gui_config_if_valid(config: &GuiConfigFile) -> Result<(), String> {
let _guard = lock_core_config_file()?;
let path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read kernel configuration {}: {error}", path_to_string(&path)))?;
let updated = apply_gui_managed_settings(&content, config)?;
write_core_config_if_changed(&path, &updated).map(|_| ())
}
fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("Failed to get user directory: {error}"))?;
let mut paths = vec![
gui_config_path()?,
core_install_dir()?.join(CORE_CONFIG_FILE),
];
for client in [
AgentClient::ClaudeCode,
AgentClient::ClaudeDesktop,
AgentClient::Codex,
AgentClient::OpenCode,
AgentClient::OpenClaw,
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::WorkBuddy,
AgentClient::AntigravityCli,
AgentClient::KimiCode,
AgentClient::GrokBuild,
AgentClient::Omp,
] {
paths.extend(agent_managed_paths(client, &home));
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn handle_configuration_file_changes(
app: &tauri::AppHandle,
changed_paths: Vec<PathBuf>,
tracked_paths: &[PathBuf],
) {
let gui_path = gui_config_path().ok();
let core_path = core_install_dir()
.ok()
.map(|path| path.join(CORE_CONFIG_FILE));
let gui_state = app.state::<GuiConfigState>();
let cache = app.state::<AgentConfigStatusCache>();
let mut payload_paths = Vec::new();
let mut errors = Vec::new();
let mut refresh_agents = false;
let mut tracked_changes: Vec<PathBuf> = Vec::new();
for path in changed_paths {
let Some(tracked_path) = tracked_paths
.iter()
.find(|tracked| paths_refer_to_same_file(&path, tracked))
.cloned()
else {
continue;
};
if consume_software_write(&tracked_path) {
continue;
}
wait_for_config_file_stability(&tracked_path);
tracked_changes.retain(|existing| !paths_refer_to_same_file(existing, &tracked_path));
tracked_changes.push(tracked_path);
}
payload_paths.extend(tracked_changes.iter().map(|path| path_to_string(path)));
let is_gui_path = |path: &Path| {
gui_path
.as_deref()
.is_some_and(|gui_path| paths_refer_to_same_file(path, gui_path))
};
let is_core_path = |path: &Path| {
core_path
.as_deref()
.is_some_and(|core_path| paths_refer_to_same_file(path, core_path))
};
if tracked_changes
.iter()
.any(|path| is_gui_path(path) || is_core_path(path))
{
refresh_agents = true;
let mut preserve_invalid_gui_file = false;
let mut preserve_invalid_core_file = false;
for tracked_path in tracked_changes.iter().rev() {
if is_gui_path(tracked_path) {
let parsed = (|| -> Result<GuiConfigFile, String> {
let content = fs::read_to_string(tracked_path).map_err(|error| {
format!(
"Failed to read GUI configuration {}: {error}",
path_to_string(tracked_path)
)
})?;
let mut config = toml::from_str::<GuiConfigFile>(&content)
.map_err(|error| format!("Failed to parse GUI configuration: {error}"))?;
config.allow_lan = !is_loopback_host(&config.host);
config.proxy_url = super::network_proxy::resolve(&config);
validate_gui_config(&config)?;
Ok(config)
})();
let config = match parsed {
Ok(config) => config,
Err(error) => {
errors.push(error);
preserve_invalid_gui_file = true;
continue;
}
};
let result = (|| -> Result<(), String> {
gui_state.replace_external(config.clone())?;
if !preserve_invalid_core_file {
patch_core_from_gui_config_if_valid(&config)?;
}
cache.clear()?;
Ok(())
})();
if let Err(error) = result {
errors.push(error);
}
break;
}
if is_core_path(tracked_path) {
let parsed = (|| -> Result<CoreConfigSettings, String> {
let content = fs::read_to_string(tracked_path).map_err(|error| {
format!("Failed to read kernel configuration {}: {error}", path_to_string(tracked_path))
})?;
let document = serde_norway::from_str::<serde_norway::Value>(&content)
.map_err(|error| format!("Failed to parse kernel configuration: {error}"))?;
core_config_settings_from_value(&document)
})();
let settings = match parsed {
Ok(settings) => settings,
Err(error) => {
errors.push(error);
preserve_invalid_core_file = true;
continue;
}
};
let result = (|| -> Result<(), String> {
if preserve_invalid_gui_file {
gui_state.replace_core_settings_external(&settings)?;
} else {
gui_state.sync_core_settings_external(&settings)?;
}
cache.clear()?;
Ok(())
})();
if let Err(error) = result {
errors.push(error);
}
break;
}
}
request_codex_model_catalog_refresh();
}
refresh_agents |= tracked_changes
.iter()
.any(|path| !is_gui_path(path) && !is_core_path(path));
if refresh_agents {
if let Err(error) = refresh_agent_config_status_cache(app, gui_state.inner(), cache.inner())
{
errors.push(error);
}
}
if !payload_paths.is_empty() || !errors.is_empty() {
let _ = app.emit(
CONFIG_FILES_CHANGED_EVENT,
ConfigFilesChangedPayload {
paths: payload_paths,
errors,
},
);
}
}
pub(crate) fn nearest_existing_watch_directory(path: &Path) -> Option<PathBuf> {
let mut directory = path.parent();
while let Some(candidate) = directory {
if candidate.is_dir() {
return Some(candidate.to_path_buf());
}
directory = candidate.parent();
}
None
}
fn ensure_configuration_watch_directories(
watcher: &mut RecommendedWatcher,
tracked_paths: &[PathBuf],
watched_directories: &mut Vec<PathBuf>,
) -> Result<Vec<PathBuf>, String> {
let mut newly_watched = Vec::new();
for tracked_path in tracked_paths {
let Some(directory) = nearest_existing_watch_directory(tracked_path) else {
continue;
};
let directory = normalized_config_path(&directory);
if watched_directories
.iter()
.any(|watched| watched == &directory)
{
continue;
}
watcher
.watch(&directory, RecursiveMode::NonRecursive)
.map_err(|error| format!("Failed to monitor configuration directory {}: {error}", path_to_string(&directory)))?;
watched_directories.push(directory.clone());
newly_watched.push(directory);
}
Ok(newly_watched)
}
#[derive(Default)]
struct PendingConfigurationChanges {
paths: Vec<PathBuf>,
error: Option<String>,
}
fn configuration_watch_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| {
nearest_existing_watch_directory(path)
.and_then(|parent| {
path.strip_prefix(&parent)
.ok()
.map(|suffix| normalized_config_path(&parent).join(suffix))
})
.unwrap_or_else(|| path.to_path_buf())
})
}
impl PendingConfigurationChanges {
fn insert(&mut self, path: PathBuf) {
self.paths.retain(|existing| existing != &path);
self.paths.push(path);
}
fn record(&mut self, event: notify::Event, tracked_paths: &[(PathBuf, PathBuf)]) -> bool {
if event.kind.is_access() {
return false;
}
if event.need_rescan() {
for (tracked, _) in tracked_paths {
self.insert(tracked.clone());
}
return true;
}
let allow_ancestor_match = match event.kind {
notify::EventKind::Modify(notify::event::ModifyKind::Metadata(_)) => false,
#[cfg(target_os = "windows")]
notify::EventKind::Modify(notify::event::ModifyKind::Any) => false,
_ => true,
};
let mut relevant = false;
for path in &event.paths {
let normalized = configuration_watch_path(path);
for (tracked, canonical) in tracked_paths {
let exact_match = path == tracked || normalized == *canonical;
if exact_match
|| (allow_ancestor_match
&& (tracked.starts_with(path) || canonical.starts_with(&normalized)))
{
self.insert(tracked.clone());
relevant = true;
}
}
}
relevant
}
}
pub(crate) fn start_configuration_file_watcher(app: tauri::AppHandle) -> Result<(), String> {
let tracked_paths = tracked_configuration_paths(&app)?;
let callback_paths: Vec<_> = tracked_paths
.iter()
.map(|path| (path.clone(), configuration_watch_path(path)))
.collect();
let pending = Arc::new(Mutex::new(PendingConfigurationChanges::default()));
let callback_pending = pending.clone();
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
let mut watcher: RecommendedWatcher =
notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
let Ok(mut pending) = callback_pending.lock() else {
return;
};
let relevant = match event {
Ok(event) => pending.record(event, &callback_paths),
Err(error) => {
pending.error = Some(error.to_string());
for (path, _) in &callback_paths {
pending.insert(path.clone());
}
true
}
};
if relevant {
let _ = sender.try_send(());
}
})
.map_err(|error| format!("Failed to create configuration file watcher: {error}"))?;
let mut watched_directories = Vec::new();
ensure_configuration_watch_directories(&mut watcher, &tracked_paths, &mut watched_directories)?;
thread::spawn(move || {
let mut watcher = watcher;
loop {
if receiver.recv().is_err() {
return;
}
let deadline = Instant::now() + Duration::from_secs(2);
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
if receiver
.recv_timeout(remaining.min(Duration::from_millis(500)))
.is_err()
{
break;
}
}
let mut changes = match pending.lock() {
Ok(mut pending) => std::mem::take(&mut *pending),
Err(_) => return,
};
if let Some(error) = changes.error.take() {
eprintln!("Configuration file watcher error: {error}");
}
match ensure_configuration_watch_directories(
&mut watcher,
&tracked_paths,
&mut watched_directories,
) {
Ok(new_directories) => {
for tracked_path in &tracked_paths {
let parent = tracked_path.parent().map(normalized_config_path);
if tracked_path.is_file()
&& parent.as_ref().is_some_and(|parent| {
new_directories.iter().any(|directory| directory == parent)
})
{
changes.insert(tracked_path.clone());
}
}
}
Err(error) => eprintln!("Failed to update configuration directory watcher: {error}"),
}
handle_configuration_file_changes(&app, changes.paths, &tracked_paths);
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use notify::{
event::{CreateKind, DataChange, Flag, MetadataKind, ModifyKind, RemoveKind, RenameMode},
Event, EventKind,
};
#[test]
fn ancestor_metadata_events_do_not_mark_tracked_files() {
let directory = std::env::temp_dir().join("cpa-watch-directory-metadata");
let canonical = configuration_watch_path(&directory).join("agent/config.yaml");
for tracked in [
directory.join("agent/config.yaml"),
std::env::temp_dir().join("cpa-watch-directory-alias/agent/config.yaml"),
] {
for kind in [
MetadataKind::Ownership,
MetadataKind::Any,
MetadataKind::Permissions,
MetadataKind::Extended,
] {
let mut pending = PendingConfigurationChanges::default();
let event = Event::new(EventKind::Modify(ModifyKind::Metadata(kind)))
.add_path(directory.clone());
assert!(!pending.record(event, &[(tracked.clone(), canonical.clone())]));
assert!(pending.paths.is_empty());
}
}
}
#[test]
fn tracked_file_events_still_mark_exact_and_canonical_paths() {
let tracked = std::env::temp_dir().join("cpa-watch-file-events/config.yaml");
let canonical = configuration_watch_path(&tracked);
for path in [tracked.clone(), canonical.clone()] {
for kind in [
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Ownership)),
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
EventKind::Modify(ModifyKind::Any),
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
EventKind::Create(CreateKind::File),
EventKind::Remove(RemoveKind::File),
EventKind::Modify(ModifyKind::Name(RenameMode::To)),
] {
let mut pending = PendingConfigurationChanges::default();
assert!(pending.record(
Event::new(kind).add_path(path.clone()),
&[(tracked.clone(), canonical.clone())],
));
assert_eq!(pending.paths, vec![tracked.clone()]);
}
}
}
#[test]
fn ancestor_structural_and_unknown_events_still_mark_tracked_files() {
let directory = std::env::temp_dir().join("cpa-watch-structural-events");
let tracked = directory.join("agent/config.yaml");
for kind in [
EventKind::Create(CreateKind::Folder),
EventKind::Remove(RemoveKind::Folder),
EventKind::Modify(ModifyKind::Name(RenameMode::From)),
EventKind::Modify(ModifyKind::Name(RenameMode::To)),
EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
EventKind::Any,
EventKind::Other,
] {
let mut pending = PendingConfigurationChanges::default();
assert!(pending.record(
Event::new(kind).add_path(directory.clone()),
&[(tracked.clone(), configuration_watch_path(&tracked))],
));
assert_eq!(pending.paths, vec![tracked.clone()]);
}
}
#[test]
fn ancestor_generic_modifications_follow_the_platform_backend() {
let directory = std::env::temp_dir().join("cpa-watch-generic-modification");
let canonical = configuration_watch_path(&directory).join("config.yaml");
for tracked in [
directory.join("config.yaml"),
std::env::temp_dir().join("cpa-watch-generic-alias/config.yaml"),
] {
let mut pending = PendingConfigurationChanges::default();
let event = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(directory.clone());
let relevant = pending.record(event, &[(tracked.clone(), canonical.clone())]);
assert_eq!(relevant, !cfg!(target_os = "windows"));
if cfg!(target_os = "windows") {
assert!(pending.paths.is_empty());
} else {
assert_eq!(pending.paths, vec![tracked]);
}
}
}
#[test]
fn rescan_takes_priority_over_directory_event_filters() {
let directory = std::env::temp_dir().join("cpa-watch-metadata-rescan");
let tracked = directory.join("agent/config.yaml");
let unrelated = std::env::temp_dir().join("cpa-watch-other-rescan/config.toml");
let paths = [
(tracked.clone(), configuration_watch_path(&tracked)),
(unrelated.clone(), configuration_watch_path(&unrelated)),
];
for kind in [
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
EventKind::Modify(ModifyKind::Any),
] {
let mut pending = PendingConfigurationChanges::default();
let event = Event::new(kind)
.add_path(directory.clone())
.set_flag(Flag::Rescan);
assert!(pending.record(event, &paths));
assert_eq!(pending.paths, vec![tracked.clone(), unrelated.clone()]);
}
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[test]
fn native_directory_metadata_events_are_ignored_but_file_edits_are_detected() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root =
std::env::temp_dir().join(format!("cpa-watch-native-{}-{stamp}", std::process::id()));
let directory = root.join("agent");
let tracked = directory.join("config.yaml");
fs::create_dir(&root).unwrap();
fs::create_dir(&directory).unwrap();
fs::write(&tracked, "model: original\n").unwrap();
let before = fs::metadata(&tracked).unwrap();
let tracked_paths = [(tracked.clone(), configuration_watch_path(&tracked))];
let (sender, receiver) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(move |event| {
let _ = sender.send(event);
})
.unwrap();
watcher.watch(&root, RecursiveMode::NonRecursive).unwrap();
watcher
.watch(&directory, RecursiveMode::NonRecursive)
.unwrap();
let receive_modification = |path: &Path| {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let event = receiver
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.expect("expected a native filesystem event")
.unwrap();
if event.kind.is_modify()
&& event
.paths
.iter()
.any(|item| paths_refer_to_same_file(item, path))
{
return event;
}
}
};
let original_permissions = fs::metadata(&directory).unwrap().permissions();
let mut permissions = original_permissions.clone();
#[cfg(target_os = "windows")]
permissions.set_readonly(!permissions.readonly());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(permissions.mode() ^ 0o020);
}
fs::set_permissions(&directory, permissions).unwrap();
let directory_event = receive_modification(&directory);
fs::set_permissions(&directory, original_permissions).unwrap();
let after = fs::metadata(&tracked).unwrap();
let content_after_attributes = fs::read_to_string(&tracked).unwrap();
let mut pending = PendingConfigurationChanges::default();
let directory_relevant = pending.record(directory_event, &tracked_paths);
let directory_changes = std::mem::take(&mut pending.paths);
fs::write(&tracked, "model: updated\n").unwrap();
let file_event = receive_modification(&tracked);
let file_relevant = pending.record(file_event, &tracked_paths);
drop(watcher);
fs::remove_file(&tracked).unwrap();
fs::remove_dir(&directory).unwrap();
fs::remove_dir(&root).unwrap();
assert_eq!(content_after_attributes, "model: original\n");
assert_eq!(before.len(), after.len());
assert_eq!(before.modified().unwrap(), after.modified().unwrap());
assert!(!directory_relevant);
assert!(directory_changes.is_empty());
assert!(file_relevant);
assert_eq!(pending.paths, vec![tracked]);
}
#[test]
fn missing_paths_match_canonical_events_after_the_directory_is_created() {
let root = std::env::temp_dir();
let relative = Path::new("cpa-nonexistent-watch-unit-test/agent/config.toml");
let tracked = root.join(relative);
let canonical = fs::canonicalize(&root).unwrap().join(relative);
let mut pending = PendingConfigurationChanges::default();
let event = notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File))
.add_path(canonical);
assert!(pending.record(
event,
&[(tracked.clone(), configuration_watch_path(&tracked))]
));
assert_eq!(pending.paths, vec![tracked]);
}
#[test]
fn pending_changes_ignore_noise_and_remain_bounded_in_last_write_order() {
let root = std::env::temp_dir().join("cpa-watcher-unit-test");
let gui = root.join("gui.toml");
let core = root.join("core.yaml");
let tracked = vec![(gui.clone(), gui.clone()), (core.clone(), core.clone())];
let mut pending = PendingConfigurationChanges::default();
let event = |path: PathBuf| {
notify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(path)
};
for index in 0..1000 {
assert!(!pending.record(event(root.join(format!("noise-{index}"))), &tracked));
}
assert!(pending.paths.is_empty());
for _ in 0..1000 {
pending.record(event(gui.clone()), &tracked);
pending.record(event(core.clone()), &tracked);
}
pending.record(event(gui.clone()), &tracked);
assert_eq!(pending.paths, vec![core, gui]);
}
#[test]
fn parent_directory_events_keep_new_configuration_directories_discoverable() {
let directory = std::env::temp_dir().join("cpa-watch-missing-parent");
let path = directory.join("agent/config.toml");
let mut pending = PendingConfigurationChanges::default();
let event =
notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::Folder))
.add_path(directory);
assert!(pending.record(event, &[(path.clone(), path.clone())]));
assert_eq!(pending.paths, vec![path]);
}
}