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