agentmux_srv/backend/
user_widgets.rs1use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, OnceLock};
17use std::time::Duration;
18
19use tokio::sync::broadcast;
20
21use super::config_watcher_fs::{broadcast_full_config, resolve_settings_dir};
22use super::eventbus::EventBus;
23use super::fs_watch::{FsWatchEvent, FsWatchEventKind, FsWatchPool};
24use super::mps::Broker;
25use super::wconfig::{self, ConfigState, WidgetConfigType};
26
27pub const USER_WIDGETS_FILE: &str = "widgets.json";
28
29pub fn user_widgets_path() -> PathBuf {
30 resolve_settings_dir().join(USER_WIDGETS_FILE)
31}
32
33pub fn read_user_widgets(path: &Path) -> Result<HashMap<String, WidgetConfigType>, String> {
35 if !path.exists() {
36 return Ok(HashMap::new());
37 }
38 let content = std::fs::read_to_string(path).map_err(|e| format!("read widgets.json: {e}"))?;
39 if content.trim().is_empty() {
40 return Ok(HashMap::new());
41 }
42 serde_json::from_str(&content).map_err(|e| format!("parse widgets.json: {e}"))
43}
44
45fn builtin_widgets() -> &'static HashMap<String, WidgetConfigType> {
47 static BUILTIN: OnceLock<HashMap<String, WidgetConfigType>> = OnceLock::new();
48 BUILTIN.get_or_init(|| wconfig::build_default_config().widgets)
49}
50
51pub fn merge_widgets(
53 builtin: &HashMap<String, WidgetConfigType>,
54 user: HashMap<String, WidgetConfigType>,
55) -> HashMap<String, WidgetConfigType> {
56 let mut merged = builtin.clone();
57 merged.extend(user);
58 merged
59}
60
61static LAST_USER: std::sync::Mutex<Option<HashMap<String, WidgetConfigType>>> = std::sync::Mutex::new(None);
65
66pub fn last_v1_entries() -> HashMap<String, WidgetConfigType> {
68 let guard = LAST_USER.lock().unwrap_or_else(|p| p.into_inner());
69 guard
70 .as_ref()
71 .map(|u| u.iter().filter(|(_, e)| !e.module.is_empty()).map(|(k, e)| (k.clone(), e.clone())).collect())
72 .unwrap_or_default()
73}
74
75fn apply(config_watcher: &ConfigState, user: HashMap<String, WidgetConfigType>) {
76 wconfig::validate_widget_configs(&user);
77 *LAST_USER.lock().unwrap_or_else(|p| p.into_inner()) = Some(user);
78 if let Some(svc) = super::widget_packages::service() {
79 svc.rescan(&last_v1_entries());
80 }
81 recompute(config_watcher);
82}
83
84pub fn recompute(config_watcher: &ConfigState) {
88 let user = LAST_USER.lock().unwrap_or_else(|p| p.into_inner()).clone().unwrap_or_default();
89 let mut base = builtin_widgets().clone();
90 if let Some(svc) = super::widget_packages::service() {
91 base.extend(super::widget_packages::widget_entries(&svc.list()));
92 }
93 config_watcher.update_widgets(merge_widgets(&base, user));
94}
95
96pub fn load_user_widgets_from_disk(config_watcher: &ConfigState) {
97 let path = user_widgets_path();
98 match read_user_widgets(&path) {
99 Ok(user) => {
100 tracing::info!(path = %path.display(), count = user.len(), "user widgets.json loaded");
101 apply(config_watcher, user);
102 }
103 Err(e) => {
104 tracing::warn!(path = %path.display(), error = %e, "user widgets.json failed to load (built-ins only)");
105 }
106 }
107}
108
109fn is_user_widgets_event(event: &FsWatchEvent) -> bool {
110 let is_file = event.path.file_name().map(|n| n == USER_WIDGETS_FILE).unwrap_or(false);
111 is_file && matches!(event.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified)
114}
115
116fn on_changed(config_watcher: &Arc<ConfigState>, event_bus: &Arc<EventBus>, broker: &Broker, user: HashMap<String, WidgetConfigType>) {
119 apply(config_watcher, user);
120 broadcast_full_config(config_watcher, event_bus);
121 let packages = super::widget_packages::service().map(|s| s.list()).unwrap_or_default();
122 super::widget_packages::publish(broker, &packages);
123}
124
125pub fn spawn_user_widgets_watcher(
126 pool: Arc<FsWatchPool>,
127 config_watcher: Arc<ConfigState>,
128 event_bus: Arc<EventBus>,
129 broker: Arc<Broker>,
130) {
131 let path = user_widgets_path();
132 let Some(dir) = path.parent() else {
133 return;
134 };
135 if !dir.exists() {
136 tracing::warn!(dir = %dir.display(), "settings directory does not exist, widgets.json watcher not started");
137 return;
138 }
139
140 let mut events = pool.events();
143 let _ = pool.subscribe_file(&path);
144 tracing::info!(path = %path.display(), "fs_watch subscription active for user widgets.json");
145
146 let watched_path = path.clone();
147 tokio::spawn(async move {
148 loop {
149 match events.recv().await {
150 Ok(ev) if is_user_widgets_event(&ev) => {}
151 Ok(_) => continue,
152 Err(broadcast::error::RecvError::Closed) => {
153 tracing::info!("user widgets watcher event stream closed, stopping");
154 break;
155 }
156 Err(broadcast::error::RecvError::Lagged(skipped)) => {
157 tracing::warn!(skipped, "user widgets watcher lagged; reloading defensively");
158 }
159 }
160 tokio::time::sleep(Duration::from_millis(300)).await;
161 while events.try_recv().is_ok() {}
162
163 match read_user_widgets(&watched_path) {
164 Ok(user) => {
165 tracing::info!(path = %watched_path.display(), "user widgets.json changed, reloading");
166 let (c, e, b) = (config_watcher.clone(), event_bus.clone(), broker.clone());
168 let _ = tokio::task::spawn_blocking(move || on_changed(&c, &e, &b, user)).await;
169 }
170 Err(e) => {
171 tracing::warn!(path = %watched_path.display(), error = %e, "user widgets.json reload parse error (keeping previous widgets)");
172 }
173 }
174 }
175 });
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn widget(label: &str) -> WidgetConfigType {
183 WidgetConfigType { label: label.to_string(), ..Default::default() }
184 }
185
186 #[test]
187 fn a_change_to_widgets_json_tells_the_ui_the_package_list_too() {
188 let seen = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
189 let broker = Broker::new();
190 let log = seen.clone();
191 broker.add_observer(Arc::new(move |ev| log.lock().unwrap().push(ev.event.clone())));
192 let config = Arc::new(ConfigState::new());
193 on_changed(&config, &Arc::new(EventBus::new()), &broker, HashMap::from([("mine".to_string(), widget("Mine"))]));
194 assert!(seen.lock().unwrap().iter().any(|e| e == super::super::mps::EVENT_WIDGET_PACKAGES));
195 }
196
197 #[test]
198 fn a_missing_or_empty_file_is_no_widgets() {
199 let dir = tempfile::tempdir().unwrap();
200 let path = dir.path().join(USER_WIDGETS_FILE);
201 assert!(read_user_widgets(&path).unwrap().is_empty());
202 std::fs::write(&path, " \n").unwrap();
203 assert!(read_user_widgets(&path).unwrap().is_empty());
204 }
205
206 #[test]
207 fn reads_an_ext_widget_with_its_module() {
208 let dir = tempfile::tempdir().unwrap();
209 let path = dir.path().join(USER_WIDGETS_FILE);
210 std::fs::write(
211 &path,
212 r#"{"ext@hello":{"label":"Hello","module":"hello/index.js","blockdef":{"meta":{"view":"ext:hello"}}}}"#,
213 )
214 .unwrap();
215 let user = read_user_widgets(&path).unwrap();
216 assert_eq!(user["ext@hello"].module, "hello/index.js");
217 }
218
219 #[test]
220 fn a_malformed_file_is_an_error_not_an_empty_set() {
221 let dir = tempfile::tempdir().unwrap();
222 let path = dir.path().join(USER_WIDGETS_FILE);
223 std::fs::write(&path, "{ not json").unwrap();
224 assert!(read_user_widgets(&path).is_err());
225 }
226
227 #[test]
228 fn user_entries_add_widgets_and_replace_builtins_by_key() {
229 let builtin = HashMap::from([("defwidget@a".to_string(), widget("A")), ("defwidget@b".to_string(), widget("B"))]);
230 let user = HashMap::from([("defwidget@b".to_string(), widget("B2")), ("ext@c".to_string(), widget("C"))]);
231 let merged = merge_widgets(&builtin, user);
232 assert_eq!(merged.len(), 3);
233 assert_eq!(merged["defwidget@a"].label, "A");
234 assert_eq!(merged["defwidget@b"].label, "B2");
235 assert_eq!(merged["ext@c"].label, "C");
236 }
237
238 #[test]
239 fn the_merge_always_starts_from_the_builtins() {
240 let builtin = HashMap::from([("defwidget@a".to_string(), widget("A"))]);
242 let first = merge_widgets(&builtin, HashMap::from([("ext@c".to_string(), widget("C"))]));
243 assert!(first.contains_key("ext@c"));
244 let second = merge_widgets(&builtin, HashMap::new());
245 assert!(!second.contains_key("ext@c"));
246 }
247}