Skip to main content

victauri_browser/
tab_state.rs

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
13/// Per-tab bridge state tracked by the native host.
14pub 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
41/// Manages the state of all browser tabs connected via the extension.
42pub 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    /// Register a pending command and return a receiver for the response.
57    #[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    /// Resolve a pending command with a response value.
71    #[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    /// Get the target tab ID, resolving `None` to the active tab.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if no active tab is set or the specified tab doesn't exist.
90    #[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    /// List all tracked tabs with their metadata.
146    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    // --- Adversarial stress tests ---
400
401    #[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        // Create 500 tabs
427        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        // Simultaneously close even-numbered tabs
436        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        // Some creates may happen after close, some before
448        // Final count should be at least 250 (the odd ones that were never closed)
449        // and at most 500 (if all closes ran before creates)
450        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        // First rx should be orphaned (sender dropped when overwritten)
510        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        // active_tab is 0, but 0 is treated as "no active tab"
573        let result = mgr.resolve_tab(None).await;
574        assert!(matches!(result, Err(TabError::NoActiveTab)));
575    }
576
577    // --- Deep challenger: resolve_tab + pending command lifecycle ---
578
579    #[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        // active_tab still points to 5, but it's gone
611        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        // Close the tab — pending commands are dropped
622        mgr.on_tab_closed(1).await;
623
624        // The sender was dropped, so rx should get RecvError
625        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        // Re-create same tab ID (e.g., service worker restart)
660        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); // Reset by re-creation
666    }
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        // Should not panic on unknown tab
687        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}