1use std::collections::HashMap;
2use std::sync::atomic::{AtomicU32, Ordering};
3
4use serde_json::Value;
5use tokio::sync::{RwLock, oneshot};
6
7use victauri_core::EventLog;
8use victauri_core::recording::EventRecorder;
9
10const DEFAULT_EVENT_CAPACITY: usize = 10_000;
11const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
12
13pub struct TabState {
15 pub tab_id: u32,
16 pub url: String,
17 pub title: String,
18 pub bridge_ready: bool,
19 #[allow(dead_code)]
20 pub recorder: EventRecorder,
21 #[allow(dead_code)]
22 pub event_log: EventLog,
23 #[allow(dead_code)]
24 pub pending_commands: HashMap<String, oneshot::Sender<Value>>,
25}
26
27impl TabState {
28 fn new(tab_id: u32, url: String, title: String) -> Self {
29 Self {
30 tab_id,
31 url,
32 title,
33 bridge_ready: false,
34 recorder: EventRecorder::new(DEFAULT_RECORDER_CAPACITY),
35 event_log: EventLog::new(DEFAULT_EVENT_CAPACITY),
36 pending_commands: HashMap::new(),
37 }
38 }
39}
40
41pub struct TabManager {
43 tabs: RwLock<HashMap<u32, TabState>>,
44 active_tab: AtomicU32,
45}
46
47impl TabManager {
48 #[must_use]
49 pub fn new() -> Self {
50 Self {
51 tabs: RwLock::new(HashMap::new()),
52 active_tab: AtomicU32::new(0),
53 }
54 }
55
56 #[allow(dead_code)]
58 pub async fn register_pending(
59 &self,
60 tab_id: u32,
61 command_id: &str,
62 ) -> Option<oneshot::Receiver<Value>> {
63 let mut tabs = self.tabs.write().await;
64 let tab = tabs.get_mut(&tab_id)?;
65 let (tx, rx) = oneshot::channel();
66 tab.pending_commands.insert(command_id.to_string(), tx);
67 Some(rx)
68 }
69
70 #[allow(dead_code)]
72 pub async fn resolve_pending(&self, tab_id: u32, command_id: &str, value: Value) -> bool {
73 let mut tabs = self.tabs.write().await;
74 let Some(tab) = tabs.get_mut(&tab_id) else {
75 return false;
76 };
77 if let Some(tx) = tab.pending_commands.remove(command_id) {
78 let _ = tx.send(value);
79 true
80 } else {
81 false
82 }
83 }
84
85 #[allow(dead_code)]
91 pub async fn resolve_tab(&self, tab_id: Option<u32>) -> Result<u32, TabError> {
92 let id = tab_id.unwrap_or_else(|| self.active_tab.load(Ordering::Relaxed));
93 if id == 0 {
94 return Err(TabError::NoActiveTab);
95 }
96 let tabs = self.tabs.read().await;
97 if tabs.contains_key(&id) {
98 Ok(id)
99 } else {
100 Err(TabError::TabNotFound(id))
101 }
102 }
103
104 pub async fn on_tab_created(&self, tab_id: u32, url: &str, title: &str) {
105 let mut tabs = self.tabs.write().await;
106 tabs.insert(
107 tab_id,
108 TabState::new(tab_id, url.to_string(), title.to_string()),
109 );
110 }
111
112 pub async fn on_tab_closed(&self, tab_id: u32) {
113 let mut tabs = self.tabs.write().await;
114 tabs.remove(&tab_id);
115 }
116
117 pub async fn on_tab_activated(&self, tab_id: u32) {
118 self.active_tab.store(tab_id, Ordering::Relaxed);
119 }
120
121 pub async fn on_tab_updated(&self, tab_id: u32, url: Option<&str>, title: Option<&str>) {
122 let mut tabs = self.tabs.write().await;
123 if let Some(tab) = tabs.get_mut(&tab_id) {
124 if let Some(u) = url {
125 tab.url = u.to_string();
126 }
127 if let Some(t) = title {
128 tab.title = t.to_string();
129 }
130 }
131 }
132
133 pub async fn on_bridge_ready(&self, tab_id: u32) {
134 let mut tabs = self.tabs.write().await;
135 if let Some(tab) = tabs.get_mut(&tab_id) {
136 tab.bridge_ready = true;
137 }
138 }
139
140 #[allow(dead_code)]
141 pub async fn get_active_tab_id(&self) -> u32 {
142 self.active_tab.load(Ordering::Relaxed)
143 }
144
145 pub async fn list_tabs(&self) -> Vec<TabInfo> {
147 let tabs = self.tabs.read().await;
148 let active = self.active_tab.load(Ordering::Relaxed);
149 tabs.values()
150 .map(|t| TabInfo {
151 tab_id: t.tab_id,
152 url: t.url.clone(),
153 title: t.title.clone(),
154 bridge_ready: t.bridge_ready,
155 active: t.tab_id == active,
156 })
157 .collect()
158 }
159
160 #[must_use]
161 pub async fn tab_count(&self) -> usize {
162 self.tabs.read().await.len()
163 }
164
165 #[allow(dead_code)]
166 pub async fn is_bridge_ready(&self, tab_id: u32) -> bool {
167 let tabs = self.tabs.read().await;
168 tabs.get(&tab_id).is_some_and(|t| t.bridge_ready)
169 }
170}
171
172impl Default for TabManager {
173 fn default() -> Self {
174 Self::new()
175 }
176}
177
178#[derive(Debug, Clone, serde::Serialize)]
179pub struct TabInfo {
180 pub tab_id: u32,
181 pub url: String,
182 pub title: String,
183 pub bridge_ready: bool,
184 pub active: bool,
185}
186
187#[allow(dead_code)]
188#[derive(Debug, thiserror::Error)]
189pub enum TabError {
190 #[error("no active tab — open a tab in the browser first")]
191 NoActiveTab,
192
193 #[error("tab {0} not found — it may have been closed")]
194 TabNotFound(u32),
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[tokio::test]
202 async fn tab_lifecycle() {
203 let mgr = TabManager::new();
204
205 mgr.on_tab_created(1, "https://example.com", "Example")
206 .await;
207 mgr.on_tab_activated(1).await;
208
209 assert_eq!(mgr.tab_count().await, 1);
210 assert_eq!(mgr.get_active_tab_id().await, 1);
211
212 let resolved = mgr.resolve_tab(None).await.unwrap();
213 assert_eq!(resolved, 1);
214
215 mgr.on_bridge_ready(1).await;
216 assert!(mgr.is_bridge_ready(1).await);
217
218 mgr.on_tab_closed(1).await;
219 assert_eq!(mgr.tab_count().await, 0);
220 }
221
222 #[tokio::test]
223 async fn resolve_tab_errors() {
224 let mgr = TabManager::new();
225
226 assert!(matches!(
227 mgr.resolve_tab(None).await,
228 Err(TabError::NoActiveTab)
229 ));
230
231 assert!(matches!(
232 mgr.resolve_tab(Some(999)).await,
233 Err(TabError::TabNotFound(999))
234 ));
235 }
236
237 #[tokio::test]
238 async fn pending_command_lifecycle() {
239 let mgr = TabManager::new();
240 mgr.on_tab_created(1, "https://example.com", "Test").await;
241
242 let rx = mgr.register_pending(1, "cmd-1").await.unwrap();
243 mgr.resolve_pending(1, "cmd-1", serde_json::json!({"ok": true}))
244 .await;
245
246 let result = rx.await.unwrap();
247 assert_eq!(result, serde_json::json!({"ok": true}));
248 }
249
250 #[tokio::test]
251 async fn list_tabs_with_active() {
252 let mgr = TabManager::new();
253 mgr.on_tab_created(1, "https://one.com", "One").await;
254 mgr.on_tab_created(2, "https://two.com", "Two").await;
255 mgr.on_tab_activated(2).await;
256
257 let tabs = mgr.list_tabs().await;
258 assert_eq!(tabs.len(), 2);
259
260 let active: Vec<_> = tabs.iter().filter(|t| t.active).collect();
261 assert_eq!(active.len(), 1);
262 assert_eq!(active[0].tab_id, 2);
263 }
264
265 #[tokio::test]
266 async fn tab_update() {
267 let mgr = TabManager::new();
268 mgr.on_tab_created(1, "https://old.com", "Old Title").await;
269 mgr.on_tab_updated(1, Some("https://new.com"), Some("New Title"))
270 .await;
271
272 let tabs = mgr.list_tabs().await;
273 assert_eq!(tabs[0].url, "https://new.com");
274 assert_eq!(tabs[0].title, "New Title");
275 }
276
277 #[tokio::test]
278 async fn bridge_ready_unknown_tab_noop() {
279 let mgr = TabManager::new();
280 mgr.on_bridge_ready(999).await;
281 assert!(!mgr.is_bridge_ready(999).await);
282 }
283
284 #[tokio::test]
285 async fn bridge_not_ready_by_default() {
286 let mgr = TabManager::new();
287 mgr.on_tab_created(1, "https://x.com", "X").await;
288 assert!(!mgr.is_bridge_ready(1).await);
289 }
290
291 #[tokio::test]
292 async fn resolve_pending_unknown_tab_returns_false() {
293 let mgr = TabManager::new();
294 let resolved = mgr
295 .resolve_pending(999, "cmd-1", serde_json::json!({}))
296 .await;
297 assert!(!resolved);
298 }
299
300 #[tokio::test]
301 async fn resolve_pending_unknown_command_returns_false() {
302 let mgr = TabManager::new();
303 mgr.on_tab_created(1, "https://x.com", "X").await;
304 let resolved = mgr
305 .resolve_pending(1, "nonexistent", serde_json::json!({}))
306 .await;
307 assert!(!resolved);
308 }
309
310 #[tokio::test]
311 async fn register_pending_unknown_tab_returns_none() {
312 let mgr = TabManager::new();
313 assert!(mgr.register_pending(999, "cmd-1").await.is_none());
314 }
315
316 #[tokio::test]
317 async fn tab_update_unknown_tab_noop() {
318 let mgr = TabManager::new();
319 mgr.on_tab_updated(999, Some("https://x.com"), Some("X"))
320 .await;
321 assert_eq!(mgr.tab_count().await, 0);
322 }
323
324 #[tokio::test]
325 async fn tab_update_partial_url_only() {
326 let mgr = TabManager::new();
327 mgr.on_tab_created(1, "https://old.com", "Title").await;
328 mgr.on_tab_updated(1, Some("https://new.com"), None).await;
329
330 let tabs = mgr.list_tabs().await;
331 assert_eq!(tabs[0].url, "https://new.com");
332 assert_eq!(tabs[0].title, "Title");
333 }
334
335 #[tokio::test]
336 async fn tab_update_partial_title_only() {
337 let mgr = TabManager::new();
338 mgr.on_tab_created(1, "https://x.com", "Old").await;
339 mgr.on_tab_updated(1, None, Some("New")).await;
340
341 let tabs = mgr.list_tabs().await;
342 assert_eq!(tabs[0].url, "https://x.com");
343 assert_eq!(tabs[0].title, "New");
344 }
345
346 #[tokio::test]
347 async fn multiple_tabs_create_close() {
348 let mgr = TabManager::new();
349 mgr.on_tab_created(1, "https://one.com", "One").await;
350 mgr.on_tab_created(2, "https://two.com", "Two").await;
351 mgr.on_tab_created(3, "https://three.com", "Three").await;
352 assert_eq!(mgr.tab_count().await, 3);
353
354 mgr.on_tab_closed(2).await;
355 assert_eq!(mgr.tab_count().await, 2);
356
357 let tabs = mgr.list_tabs().await;
358 let ids: Vec<u32> = tabs.iter().map(|t| t.tab_id).collect();
359 assert!(ids.contains(&1));
360 assert!(!ids.contains(&2));
361 assert!(ids.contains(&3));
362 }
363
364 #[tokio::test]
365 async fn close_nonexistent_tab_noop() {
366 let mgr = TabManager::new();
367 mgr.on_tab_closed(999).await;
368 assert_eq!(mgr.tab_count().await, 0);
369 }
370
371 #[tokio::test]
372 async fn default_trait_works() {
373 let mgr = TabManager::default();
374 assert_eq!(mgr.tab_count().await, 0);
375 assert_eq!(mgr.get_active_tab_id().await, 0);
376 }
377
378 #[tokio::test]
379 async fn active_tab_switches() {
380 let mgr = TabManager::new();
381 mgr.on_tab_created(1, "https://one.com", "One").await;
382 mgr.on_tab_created(2, "https://two.com", "Two").await;
383
384 mgr.on_tab_activated(1).await;
385 assert_eq!(mgr.get_active_tab_id().await, 1);
386
387 mgr.on_tab_activated(2).await;
388 assert_eq!(mgr.get_active_tab_id().await, 2);
389 }
390
391 #[tokio::test]
392 async fn resolve_tab_with_explicit_id() {
393 let mgr = TabManager::new();
394 mgr.on_tab_created(5, "https://five.com", "Five").await;
395 let resolved = mgr.resolve_tab(Some(5)).await.unwrap();
396 assert_eq!(resolved, 5);
397 }
398
399 #[tokio::test]
402 async fn concurrent_tab_creation_1000() {
403 let mgr = Arc::new(TabManager::new());
404 let mut handles = vec![];
405
406 for i in 0..1000u32 {
407 let m = Arc::clone(&mgr);
408 handles.push(tokio::spawn(async move {
409 m.on_tab_created(i, &format!("https://{i}.com"), &format!("Tab {i}"))
410 .await;
411 }));
412 }
413
414 for h in handles {
415 h.await.unwrap();
416 }
417
418 assert_eq!(mgr.tab_count().await, 1000);
419 }
420
421 #[tokio::test]
422 async fn concurrent_create_close_race() {
423 let mgr = Arc::new(TabManager::new());
424 let mut handles = vec![];
425
426 for i in 0..500u32 {
428 let m = Arc::clone(&mgr);
429 handles.push(tokio::spawn(async move {
430 m.on_tab_created(i, &format!("https://{i}.com"), &format!("Tab {i}"))
431 .await;
432 }));
433 }
434
435 for i in (0..500u32).step_by(2) {
437 let m = Arc::clone(&mgr);
438 handles.push(tokio::spawn(async move {
439 m.on_tab_closed(i).await;
440 }));
441 }
442
443 for h in handles {
444 h.await.unwrap();
445 }
446
447 let count = mgr.tab_count().await;
451 assert!((200..=500).contains(&count), "unexpected count: {count}");
452 }
453
454 #[tokio::test]
455 async fn rapid_activate_deactivate() {
456 let mgr = Arc::new(TabManager::new());
457
458 for i in 1..=10u32 {
459 mgr.on_tab_created(i, &format!("https://{i}.com"), &format!("Tab {i}"))
460 .await;
461 }
462
463 let mut handles = vec![];
464 for i in 1..=10u32 {
465 let m = Arc::clone(&mgr);
466 handles.push(tokio::spawn(async move {
467 for _ in 0..100 {
468 m.on_tab_activated(i).await;
469 }
470 }));
471 }
472
473 for h in handles {
474 h.await.unwrap();
475 }
476
477 let active = mgr.get_active_tab_id().await;
478 assert!((1..=10).contains(&active));
479 }
480
481 #[tokio::test]
482 async fn tab_with_very_long_url() {
483 let mgr = TabManager::new();
484 let long_url = format!("https://example.com/{}", "a".repeat(100_000));
485 mgr.on_tab_created(1, &long_url, "Test").await;
486
487 let tabs = mgr.list_tabs().await;
488 assert_eq!(tabs[0].url.len(), long_url.len());
489 }
490
491 #[tokio::test]
492 async fn tab_with_unicode_title() {
493 let mgr = TabManager::new();
494 mgr.on_tab_created(1, "https://example.com", "日本語タイトル 🚀 émojis")
495 .await;
496
497 let tabs = mgr.list_tabs().await;
498 assert!(tabs[0].title.contains("🚀"));
499 }
500
501 #[tokio::test]
502 async fn pending_command_overwrite() {
503 let mgr = TabManager::new();
504 mgr.on_tab_created(1, "https://x.com", "X").await;
505
506 let rx1 = mgr.register_pending(1, "cmd-dup").await.unwrap();
507 let rx2 = mgr.register_pending(1, "cmd-dup").await.unwrap();
508
509 assert!(rx1.await.is_err());
511
512 mgr.resolve_pending(1, "cmd-dup", serde_json::json!({"v": 2}))
513 .await;
514 let result = rx2.await.unwrap();
515 assert_eq!(result, serde_json::json!({"v": 2}));
516 }
517
518 #[tokio::test]
519 async fn resolve_tab_after_active_closed() {
520 let mgr = TabManager::new();
521 mgr.on_tab_created(1, "https://one.com", "One").await;
522 mgr.on_tab_activated(1).await;
523 mgr.on_tab_closed(1).await;
524
525 let result = mgr.resolve_tab(None).await;
526 assert!(matches!(result, Err(TabError::TabNotFound(1))));
527 }
528
529 #[tokio::test]
530 async fn concurrent_pending_commands() {
531 let mgr = Arc::new(TabManager::new());
532 mgr.on_tab_created(1, "https://x.com", "X").await;
533
534 let mut receivers = vec![];
535 for i in 0..100 {
536 let rx = mgr.register_pending(1, &format!("cmd-{i}")).await.unwrap();
537 receivers.push((i, rx));
538 }
539
540 let mut handles = vec![];
541 for i in 0..100 {
542 let m = Arc::clone(&mgr);
543 handles.push(tokio::spawn(async move {
544 m.resolve_pending(1, &format!("cmd-{i}"), serde_json::json!({"i": i}))
545 .await
546 }));
547 }
548
549 for h in handles {
550 assert!(h.await.unwrap());
551 }
552
553 for (i, rx) in receivers {
554 let val = rx.await.unwrap();
555 assert_eq!(val["i"], i);
556 }
557 }
558
559 #[tokio::test]
560 async fn list_tabs_empty_is_empty_vec() {
561 let mgr = TabManager::new();
562 let tabs = mgr.list_tabs().await;
563 assert!(tabs.is_empty());
564 }
565
566 #[tokio::test]
567 async fn tab_id_zero_not_confused_with_no_active() {
568 let mgr = TabManager::new();
569 mgr.on_tab_created(0, "https://zero.com", "Zero").await;
570 mgr.on_tab_activated(0).await;
571
572 let result = mgr.resolve_tab(None).await;
574 assert!(matches!(result, Err(TabError::NoActiveTab)));
575 }
576
577 #[tokio::test]
580 async fn resolve_tab_with_explicit_id_works() {
581 let mgr = TabManager::new();
582 mgr.on_tab_created(42, "https://x.com", "X").await;
583 let result = mgr.resolve_tab(Some(42)).await;
584 assert_eq!(result.unwrap(), 42);
585 }
586
587 #[tokio::test]
588 async fn resolve_tab_with_explicit_nonexistent_errors() {
589 let mgr = TabManager::new();
590 mgr.on_tab_created(1, "https://x.com", "X").await;
591 let result = mgr.resolve_tab(Some(999)).await;
592 assert!(matches!(result, Err(TabError::TabNotFound(999))));
593 }
594
595 #[tokio::test]
596 async fn resolve_tab_with_none_uses_active() {
597 let mgr = TabManager::new();
598 mgr.on_tab_created(5, "https://x.com", "X").await;
599 mgr.on_tab_activated(5).await;
600 let result = mgr.resolve_tab(None).await;
601 assert_eq!(result.unwrap(), 5);
602 }
603
604 #[tokio::test]
605 async fn resolve_tab_active_but_closed_errors() {
606 let mgr = TabManager::new();
607 mgr.on_tab_created(5, "https://x.com", "X").await;
608 mgr.on_tab_activated(5).await;
609 mgr.on_tab_closed(5).await;
610 let result = mgr.resolve_tab(None).await;
612 assert!(matches!(result, Err(TabError::TabNotFound(5))));
613 }
614
615 #[tokio::test]
616 async fn pending_command_lost_on_tab_close() {
617 let mgr = TabManager::new();
618 mgr.on_tab_created(1, "https://x.com", "X").await;
619 let rx = mgr.register_pending(1, "cmd-1").await.unwrap();
620
621 mgr.on_tab_closed(1).await;
623
624 assert!(rx.await.is_err());
626 }
627
628 #[tokio::test]
629 async fn register_pending_on_nonexistent_tab_returns_none() {
630 let mgr = TabManager::new();
631 let result = mgr.register_pending(999, "cmd-1").await;
632 assert!(result.is_none());
633 }
634
635 #[tokio::test]
636 async fn resolve_pending_on_nonexistent_tab_returns_false() {
637 let mgr = TabManager::new();
638 let result = mgr
639 .resolve_pending(999, "cmd-1", serde_json::json!({}))
640 .await;
641 assert!(!result);
642 }
643
644 #[tokio::test]
645 async fn resolve_pending_with_wrong_command_id_returns_false() {
646 let mgr = TabManager::new();
647 mgr.on_tab_created(1, "https://x.com", "X").await;
648 let _rx = mgr.register_pending(1, "cmd-1").await.unwrap();
649 let result = mgr.resolve_pending(1, "cmd-2", serde_json::json!({})).await;
650 assert!(!result);
651 }
652
653 #[tokio::test]
654 async fn tab_create_overwrites_existing() {
655 let mgr = TabManager::new();
656 mgr.on_tab_created(1, "https://first.com", "First").await;
657 mgr.on_bridge_ready(1).await;
658
659 mgr.on_tab_created(1, "https://second.com", "Second").await;
661
662 let tabs = mgr.list_tabs().await;
663 assert_eq!(tabs.len(), 1);
664 assert_eq!(tabs[0].url, "https://second.com");
665 assert!(!tabs[0].bridge_ready); }
667
668 #[tokio::test]
669 async fn list_tabs_shows_correct_active_flag() {
670 let mgr = TabManager::new();
671 mgr.on_tab_created(1, "https://a.com", "A").await;
672 mgr.on_tab_created(2, "https://b.com", "B").await;
673 mgr.on_tab_created(3, "https://c.com", "C").await;
674 mgr.on_tab_activated(2).await;
675
676 let tabs = mgr.list_tabs().await;
677 let active_count = tabs.iter().filter(|t| t.active).count();
678 assert_eq!(active_count, 1);
679 let active_tab = tabs.iter().find(|t| t.active).unwrap();
680 assert_eq!(active_tab.tab_id, 2);
681 }
682
683 #[tokio::test]
684 async fn on_tab_updated_unknown_tab_is_silent() {
685 let mgr = TabManager::new();
686 mgr.on_tab_updated(999, Some("https://new.com"), Some("New"))
688 .await;
689 assert_eq!(mgr.tab_count().await, 0);
690 }
691
692 #[tokio::test]
693 async fn on_bridge_ready_unknown_tab_is_silent() {
694 let mgr = TabManager::new();
695 mgr.on_bridge_ready(999).await;
696 assert_eq!(mgr.tab_count().await, 0);
697 }
698
699 use std::sync::Arc;
700}