1mod audit;
15pub mod responses;
16
17mod engine_ops;
18mod lifecycle;
19mod page_ops;
20mod store_ops;
21
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use uuid::Uuid;
27use vs_engine_webkit::EngineRuntime;
28use vs_protocol::{Node, StateToken};
29use vs_store::{ActionInsert, Store};
30
31use crate::error::{DaemonError, Result};
32use crate::page_state::PageState;
33
34pub(crate) use audit::AuditCtx;
35pub use responses::{
36 ActCall, ActResponse, AnnotateResponse, AuthClearResponse, AuthListResponse, AuthLoadResponse,
37 AuthSaveResponse, CaptureResponse, CloseResponse, ExtractResponse, FindHit, FindResponse,
38 LayoutResponse, LogResponse, MarkResponse, OpenResponse, ReadResponse, SessionCloseResponse,
39 SessionOpenResponse, SkillListResponse, SkillShowResponse, StatusResponse, ViewResponse,
40 ViewportResponse, WaitResponse,
41};
42
43#[derive(Debug)]
45pub(crate) struct SessionState {
46 pub(crate) pages: HashMap<String, PageState>,
47}
48
49impl SessionState {
50 pub(crate) fn new() -> Self {
51 Self {
52 pages: HashMap::new(),
53 }
54 }
55}
56
57#[derive(Clone)]
59pub struct Daemon {
60 pub(crate) inner: Arc<Inner>,
61}
62
63pub(crate) struct Inner {
64 pub(crate) store: Mutex<Store>,
65 pub(crate) engine: Arc<EngineRuntime>,
66 pub(crate) sessions: Mutex<HashMap<String, SessionState>>,
67 pub(crate) captures_dir: std::path::PathBuf,
68 pub(crate) skills_dir: std::path::PathBuf,
69 pub(crate) master_key: Option<vs_store::MasterKey>,
70}
71
72impl Daemon {
73 #[must_use]
76 pub fn new(store: Store, engine: Arc<EngineRuntime>) -> Self {
77 Self {
78 inner: Arc::new(Inner {
79 store: Mutex::new(store),
80 engine,
81 sessions: Mutex::new(HashMap::new()),
82 captures_dir: std::env::temp_dir().join("vibesurfer-captures"),
83 skills_dir: std::path::PathBuf::from("./skills"),
84 master_key: None,
85 }),
86 }
87 }
88
89 #[must_use]
92 pub fn with_captures_dir(self, dir: impl Into<std::path::PathBuf>) -> Self {
93 let mut inner = Arc::try_unwrap(self.inner)
94 .map_err(|_| ())
95 .expect("Daemon::with_captures_dir must run before any clone of the daemon handle");
96 inner.captures_dir = dir.into();
97 Self {
98 inner: Arc::new(inner),
99 }
100 }
101
102 #[must_use]
104 pub fn with_skills_dir(self, dir: impl Into<std::path::PathBuf>) -> Self {
105 let mut inner = Arc::try_unwrap(self.inner)
106 .map_err(|_| ())
107 .expect("Daemon::with_skills_dir must run before any clone of the daemon handle");
108 inner.skills_dir = dir.into();
109 Self {
110 inner: Arc::new(inner),
111 }
112 }
113
114 #[must_use]
117 pub fn with_master_key(self, key: vs_store::MasterKey) -> Self {
118 let mut inner = Arc::try_unwrap(self.inner)
119 .map_err(|_| ())
120 .expect("Daemon::with_master_key must run before any clone of the daemon handle");
121 inner.master_key = Some(key);
122 Self {
123 inner: Arc::new(inner),
124 }
125 }
126
127 pub(crate) fn audit_call<R, F>(&self, mut ctx: AuditCtx, f: F) -> Result<R>
131 where
132 F: FnOnce(&mut AuditCtx) -> Result<R>,
133 {
134 let started = Instant::now();
135 let result = f(&mut ctx);
136 let error_code = result.as_ref().err().map(|e| e.wire().0.to_string());
137 self.audit_from_ctx(&ctx, started.elapsed(), error_code)?;
138 result
139 }
140
141 fn audit_from_ctx(
143 &self,
144 ctx: &AuditCtx,
145 latency: Duration,
146 error_code: Option<String>,
147 ) -> Result<()> {
148 let now = vs_store::epoch_secs();
149 let row = ActionInsert {
150 session_id: ctx.session_id.clone(),
151 page_id: ctx.page_id.clone(),
152 primitive: ctx.primitive.to_string(),
153 args_redacted: ctx.args_redacted.clone(),
154 args_hash: ctx.args_hash.clone(),
155 before_token: ctx.before_token.map(|t| t.to_string()),
156 after_token: ctx.after_token.map(|t| t.to_string()),
157 idempotency_hit: ctx.idempotency_hit,
158 result_summary: ctx.result_summary.clone(),
159 latency_ms: i64::try_from(latency.as_millis()).unwrap_or(i64::MAX),
160 group_label: ctx.group_label.clone(),
161 started_at: now,
162 finished_at: now,
163 error_code,
164 };
165 self.inner
166 .store
167 .lock()
168 .expect("poisoned")
169 .record_action(&row)?;
170 Ok(())
171 }
172
173 pub(crate) fn require_session(&self, session_id: &str) -> Result<()> {
174 if !self
175 .inner
176 .sessions
177 .lock()
178 .expect("poisoned")
179 .contains_key(session_id)
180 {
181 return Err(DaemonError::UnknownSession(session_id.to_string()));
182 }
183 Ok(())
184 }
185
186 pub(crate) fn require_master_key(&self) -> Result<&vs_store::MasterKey> {
187 self.inner
188 .master_key
189 .as_ref()
190 .ok_or(DaemonError::BadRequest(
191 "no master key configured; daemon was not started with one".into(),
192 ))
193 }
194
195 pub(crate) fn engine_handle_for(
196 &self,
197 session_id: &str,
198 page_id: &str,
199 ) -> Result<vs_engine_webkit::PageHandle> {
200 let sessions = self.inner.sessions.lock().expect("poisoned");
201 let session = sessions
202 .get(session_id)
203 .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?;
204 let page = session
205 .pages
206 .get(page_id)
207 .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
208 Ok(page.engine_handle)
209 }
210
211 pub(crate) fn current_token(&self, session_id: &str, page_id: &str) -> Result<StateToken> {
212 let sessions = self.inner.sessions.lock().expect("poisoned");
213 let page = sessions
214 .get(session_id)
215 .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
216 .pages
217 .get(page_id)
218 .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
219 Ok(page.last_token.unwrap_or(StateToken::ZERO))
220 }
221
222 #[doc(hidden)]
224 pub fn audit_log(&self, filter: &vs_store::ActionFilter) -> Result<Vec<vs_store::Action>> {
225 Ok(self
226 .inner
227 .store
228 .lock()
229 .expect("poisoned")
230 .list_actions(filter)?)
231 }
232
233 pub fn inspect_console(
235 &self,
236 session_id: &str,
237 page_id: &str,
238 ) -> Result<Vec<vs_engine_webkit::inspector::ConsoleEntry>> {
239 let ctx = AuditCtx::new("vs_inspect", session_id)
240 .with_page(page_id)
241 .with_args(
242 "console".into(),
243 crate::tokens::args_hash("vs_inspect", &["console".into()]),
244 );
245 self.audit_call(ctx, |ctx| {
246 self.require_session(session_id)?;
247 require_capability(self, |c| c.inspector_console, "vs_inspect console")?;
248 let handle = self.engine_handle_for(session_id, page_id)?;
249 let entries = self.inner.engine.console_entries(handle)?;
250 ctx.after_token = Some(self.current_token(session_id, page_id)?);
251 Ok(entries)
252 })
253 }
254
255 pub fn inspect_network(
257 &self,
258 session_id: &str,
259 page_id: &str,
260 ) -> Result<Vec<vs_engine_webkit::inspector::NetworkEntry>> {
261 let ctx = AuditCtx::new("vs_inspect", session_id)
262 .with_page(page_id)
263 .with_args(
264 "network".into(),
265 crate::tokens::args_hash("vs_inspect", &["network".into()]),
266 );
267 self.audit_call(ctx, |ctx| {
268 self.require_session(session_id)?;
269 require_capability(self, |c| c.inspector_network, "vs_inspect network")?;
270 let handle = self.engine_handle_for(session_id, page_id)?;
271 let entries = self.inner.engine.network_entries(handle)?;
272 ctx.after_token = Some(self.current_token(session_id, page_id)?);
273 Ok(entries)
274 })
275 }
276 pub fn inspect_request(
279 &self,
280 session_id: &str,
281 page_id: &str,
282 seq: u64,
283 ) -> Result<Option<vs_engine_webkit::inspector::RequestDetail>> {
284 let ctx = AuditCtx::new("vs_inspect", session_id)
285 .with_page(page_id)
286 .with_args(
287 format!("request {seq}"),
288 crate::tokens::args_hash("vs_inspect", &["request".into(), seq.to_string()]),
289 );
290 self.audit_call(ctx, |ctx| {
291 self.require_session(session_id)?;
292 require_capability(self, |c| c.inspector_network, "vs_inspect request")?;
293 let handle = self.engine_handle_for(session_id, page_id)?;
294 let detail = self.inner.engine.request_detail(handle, seq)?;
295 ctx.after_token = Some(self.current_token(session_id, page_id)?);
296 Ok(detail)
297 })
298 }
299
300 pub fn inspect_eval(
301 &self,
302 session_id: &str,
303 page_id: &str,
304 expr: &str,
305 ) -> Result<vs_engine_webkit::inspector::EvalResult> {
306 let redacted_expr = crate::redact::redact_string(expr);
307 let ctx = AuditCtx::new("vs_inspect", session_id)
308 .with_page(page_id)
309 .with_args(
310 format!("eval {redacted_expr}"),
311 crate::tokens::args_hash("vs_inspect", &["eval".into(), redacted_expr.clone()]),
312 );
313 self.audit_call(ctx, |ctx| {
314 self.require_session(session_id)?;
315 let handle = self.engine_handle_for(session_id, page_id)?;
316 let r = self.inner.engine.eval_js(handle, expr)?;
317 ctx.after_token = Some(self.current_token(session_id, page_id)?);
318 Ok(r)
319 })
320 }
321
322 pub fn inspect_storage(
323 &self,
324 session_id: &str,
325 page_id: &str,
326 scope: vs_engine_webkit::inspector::StorageScope,
327 ) -> Result<Vec<vs_engine_webkit::inspector::StorageEntry>> {
328 let ctx = AuditCtx::new("vs_inspect", session_id)
329 .with_page(page_id)
330 .with_args(
331 format!("storage {}", scope.as_str()),
332 crate::tokens::args_hash("vs_inspect", &["storage".into(), scope.as_str().into()]),
333 );
334 self.audit_call(ctx, |ctx| {
335 self.require_session(session_id)?;
336 let handle = self.engine_handle_for(session_id, page_id)?;
337 let entries = self.inner.engine.storage(handle, scope)?;
338 ctx.after_token = Some(self.current_token(session_id, page_id)?);
339 Ok(entries)
340 })
341 }
342
343 pub fn inspect_cookie_events(
344 &self,
345 session_id: &str,
346 page_id: &str,
347 ) -> Result<Vec<vs_engine_webkit::inspector::CookieEvent>> {
348 let ctx = AuditCtx::new("vs_inspect", session_id)
349 .with_page(page_id)
350 .with_args(
351 "cookie-events".to_string(),
352 crate::tokens::args_hash("vs_inspect", &["cookie-events".into()]),
353 );
354 self.audit_call(ctx, |ctx| {
355 self.require_session(session_id)?;
356 let handle = self.engine_handle_for(session_id, page_id)?;
357 let events = self.inner.engine.cookie_events(handle)?;
358 ctx.after_token = Some(self.current_token(session_id, page_id)?);
359 Ok(events)
360 })
361 }
362
363 pub fn cursor_op(
364 &self,
365 session_id: &str,
366 page_id: &str,
367 op: vs_engine_webkit::engine::CursorOp,
368 mode: vs_engine_webkit::engine::InputMode,
369 ) -> Result<vs_protocol::StateToken> {
370 let ctx = AuditCtx::new("vs_cursor_op", session_id)
371 .with_page(page_id)
372 .with_args(
373 format!("{op:?} mode={}", mode.as_str()),
374 crate::tokens::args_hash(
375 "vs_cursor_op",
376 &[format!("{op:?}"), mode.as_str().into()],
377 ),
378 );
379 self.audit_call(ctx, |ctx| {
380 self.require_session(session_id)?;
381 let handle = self.engine_handle_for(session_id, page_id)?;
382 self.inner.engine.cursor_op(handle, op, mode)?;
383 let token = self.current_token(session_id, page_id)?;
384 ctx.after_token = Some(token);
385 Ok(token)
386 })
387 }
388
389 pub fn inspect_scripts(
390 &self,
391 session_id: &str,
392 page_id: &str,
393 ) -> Result<Vec<vs_engine_webkit::inspector::ScriptEntry>> {
394 let ctx = AuditCtx::new("vs_inspect", session_id)
395 .with_page(page_id)
396 .with_args(
397 "scripts".into(),
398 crate::tokens::args_hash("vs_inspect", &["scripts".into()]),
399 );
400 self.audit_call(ctx, |ctx| {
401 self.require_session(session_id)?;
402 let handle = self.engine_handle_for(session_id, page_id)?;
403 let entries = self.inner.engine.scripts(handle)?;
404 ctx.after_token = Some(self.current_token(session_id, page_id)?);
405 Ok(entries)
406 })
407 }
408
409 pub fn inspect_script_source(
410 &self,
411 session_id: &str,
412 page_id: &str,
413 seq: u64,
414 ) -> Result<Option<vs_engine_webkit::inspector::ScriptSource>> {
415 let ctx = AuditCtx::new("vs_inspect", session_id)
416 .with_page(page_id)
417 .with_args(
418 format!("script {seq}"),
419 crate::tokens::args_hash("vs_inspect", &["script".into(), seq.to_string()]),
420 );
421 self.audit_call(ctx, |ctx| {
422 self.require_session(session_id)?;
423 let handle = self.engine_handle_for(session_id, page_id)?;
424 let src = self.inner.engine.script_source(handle, seq)?;
425 ctx.after_token = Some(self.current_token(session_id, page_id)?);
426 Ok(src)
427 })
428 }
429
430 pub fn inspect_dom(
431 &self,
432 session_id: &str,
433 page_id: &str,
434 r: vs_protocol::Ref,
435 extra_props: Vec<String>,
436 ) -> Result<Option<vs_engine_webkit::inspector::DomDetail>> {
437 let ctx = AuditCtx::new("vs_inspect", session_id)
438 .with_page(page_id)
439 .with_args(
440 format!("dom {}", r.0),
441 crate::tokens::args_hash("vs_inspect", &["dom".into(), r.0.to_string()]),
442 );
443 self.audit_call(ctx, |ctx| {
444 self.require_session(session_id)?;
445 let handle = self.engine_handle_for(session_id, page_id)?;
446 let d = self.inner.engine.dom(handle, r, extra_props)?;
447 ctx.after_token = Some(self.current_token(session_id, page_id)?);
448 Ok(d)
449 })
450 }
451
452 pub fn inspect_performance(
453 &self,
454 session_id: &str,
455 page_id: &str,
456 ) -> Result<vs_engine_webkit::inspector::PerformanceMetrics> {
457 let ctx = AuditCtx::new("vs_inspect", session_id)
458 .with_page(page_id)
459 .with_args(
460 "performance".into(),
461 crate::tokens::args_hash("vs_inspect", &["performance".into()]),
462 );
463 self.audit_call(ctx, |ctx| {
464 self.require_session(session_id)?;
465 let handle = self.engine_handle_for(session_id, page_id)?;
466 let m = self.inner.engine.performance(handle)?;
467 ctx.after_token = Some(self.current_token(session_id, page_id)?);
468 Ok(m)
469 })
470 }
471 #[must_use]
483 pub fn dispatch(
484 &self,
485 primitives: &[crate::dispatch::Primitive],
486 ) -> Vec<crate::dispatch::DispatchOutcome> {
487 primitives
488 .iter()
489 .map(|p| crate::dispatch::DispatchOutcome::from_wire(crate::server::dispatch(self, p)))
490 .collect()
491 }
492}
493
494pub(crate) fn short_id() -> String {
495 Uuid::now_v7().simple().to_string()[..24].to_string()
508}
509
510pub(crate) fn render_subtree_text(node: &Node) -> String {
511 let mut out = String::new();
512 render_node_text(node, 0, &mut out);
513 out
514}
515
516fn render_node_text(node: &Node, depth: usize, out: &mut String) {
517 use std::fmt::Write as _;
518 for _ in 0..depth {
519 out.push_str(" ");
520 }
521 let _ = write!(out, "[{}] {}: {}", node.r, node.role, node.label);
522 out.push('\n');
523 for child in &node.children {
524 render_node_text(child, depth + 1, out);
525 }
526}
527
528fn require_capability<F>(daemon: &Daemon, pick: F, op: &'static str) -> Result<()>
535where
536 F: FnOnce(&vs_engine_webkit::EngineCapabilities) -> bool,
537{
538 let caps = daemon.inner.engine.capabilities()?;
539 if pick(&caps) {
540 Ok(())
541 } else {
542 Err(crate::error::DaemonError::Engine(
543 vs_engine_webkit::EngineError::Unsupported {
544 engine: caps.name,
545 primitive: op,
546 },
547 ))
548 }
549}