Skip to main content

browser_controller_types/
lib.rs

1//! Shared protocol types for the browser-controller system.
2//!
3//! This crate defines the data types used in communication between:
4//! - The CLI and the mediator (over Unix Domain Socket, newline-delimited JSON)
5//! - The mediator and the browser extension (via native messaging, length-prefixed JSON)
6
7use serde::{Deserialize, Serialize};
8use zeroize::Zeroizing;
9
10/// Error type for invalid [`WindowId`] values.
11///
12/// Currently empty — all `u32` values are accepted. The `#[non_exhaustive]`
13/// attribute allows adding validation variants in the future without a
14/// semver-breaking change.
15#[non_exhaustive]
16#[derive(Debug, Clone, thiserror::Error)]
17pub enum InvalidWindowId {}
18
19/// Browser-assigned window identifier.
20///
21/// A lightweight newtype around `u32` that prevents accidental misuse of
22/// tab or download IDs where a window ID is expected.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(transparent)]
25pub struct WindowId(u32);
26
27impl WindowId {
28    /// Returns the underlying `u32` value.
29    #[must_use]
30    pub const fn as_u32(self) -> u32 {
31        self.0
32    }
33}
34
35#[expect(
36    clippy::infallible_try_from,
37    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
38)]
39impl TryFrom<u32> for WindowId {
40    type Error = InvalidWindowId;
41    fn try_from(v: u32) -> Result<Self, Self::Error> {
42        Ok(Self(v))
43    }
44}
45
46impl std::fmt::Display for WindowId {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        self.0.fmt(f)
49    }
50}
51
52impl std::str::FromStr for WindowId {
53    type Err = std::num::ParseIntError;
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        s.parse::<u32>().map(Self)
56    }
57}
58
59/// Error type for invalid [`TabId`] values.
60///
61/// Currently empty — all `u32` values are accepted. The `#[non_exhaustive]`
62/// attribute allows adding validation variants in the future without a
63/// semver-breaking change.
64#[non_exhaustive]
65#[derive(Debug, Clone, thiserror::Error)]
66pub enum InvalidTabId {}
67
68/// Browser-assigned tab identifier.
69///
70/// A lightweight newtype around `u32` that prevents accidental misuse of
71/// window or download IDs where a tab ID is expected.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
73#[serde(transparent)]
74pub struct TabId(u32);
75
76impl TabId {
77    /// Returns the underlying `u32` value.
78    #[must_use]
79    pub const fn as_u32(self) -> u32 {
80        self.0
81    }
82}
83
84#[expect(
85    clippy::infallible_try_from,
86    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
87)]
88impl TryFrom<u32> for TabId {
89    type Error = InvalidTabId;
90    fn try_from(v: u32) -> Result<Self, Self::Error> {
91        Ok(Self(v))
92    }
93}
94
95impl std::fmt::Display for TabId {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        self.0.fmt(f)
98    }
99}
100
101impl std::str::FromStr for TabId {
102    type Err = std::num::ParseIntError;
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        s.parse::<u32>().map(Self)
105    }
106}
107
108/// Error type for invalid [`DownloadId`] values.
109///
110/// Currently empty — all `u32` values are accepted. The `#[non_exhaustive]`
111/// attribute allows adding validation variants in the future without a
112/// semver-breaking change.
113#[non_exhaustive]
114#[derive(Debug, Clone, thiserror::Error)]
115pub enum InvalidDownloadId {}
116
117/// Browser-assigned download identifier.
118///
119/// A lightweight newtype around `u32` that prevents accidental misuse of
120/// window or tab IDs where a download ID is expected.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct DownloadId(u32);
124
125impl DownloadId {
126    /// Returns the underlying `u32` value.
127    #[must_use]
128    pub const fn as_u32(self) -> u32 {
129        self.0
130    }
131}
132
133#[expect(
134    clippy::infallible_try_from,
135    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
136)]
137impl TryFrom<u32> for DownloadId {
138    type Error = InvalidDownloadId;
139    fn try_from(v: u32) -> Result<Self, Self::Error> {
140        Ok(Self(v))
141    }
142}
143
144impl std::fmt::Display for DownloadId {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        self.0.fmt(f)
147    }
148}
149
150impl std::str::FromStr for DownloadId {
151    type Err = std::num::ParseIntError;
152    fn from_str(s: &str) -> Result<Self, Self::Err> {
153        s.parse::<u32>().map(Self)
154    }
155}
156
157/// Error type for invalid [`CookieStoreId`] values.
158///
159/// Currently empty — all string values are accepted. The `#[non_exhaustive]`
160/// attribute allows adding validation variants in the future without a
161/// semver-breaking change.
162#[non_exhaustive]
163#[derive(Debug, Clone, thiserror::Error)]
164pub enum InvalidCookieStoreId {}
165
166/// Firefox container (cookie store) identifier.
167///
168/// A lightweight newtype around `String` that prevents accidental misuse of
169/// other string fields where a cookie store ID is expected.
170/// Values are typically of the form `"firefox-container-1"`.
171#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
172#[serde(transparent)]
173pub struct CookieStoreId(String);
174
175impl CookieStoreId {
176    /// Returns the underlying string slice.
177    #[must_use]
178    pub fn as_str(&self) -> &str {
179        &self.0
180    }
181
182    /// Consumes `self` and returns the inner `String`.
183    #[must_use]
184    pub fn into_inner(self) -> String {
185        self.0
186    }
187}
188
189#[expect(
190    clippy::infallible_try_from,
191    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
192)]
193impl TryFrom<String> for CookieStoreId {
194    type Error = InvalidCookieStoreId;
195    fn try_from(v: String) -> Result<Self, Self::Error> {
196        Ok(Self(v))
197    }
198}
199
200#[expect(
201    clippy::infallible_try_from,
202    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
203)]
204impl TryFrom<&str> for CookieStoreId {
205    type Error = InvalidCookieStoreId;
206    fn try_from(v: &str) -> Result<Self, Self::Error> {
207        Ok(Self(v.to_owned()))
208    }
209}
210
211impl std::fmt::Display for CookieStoreId {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        self.0.fmt(f)
214    }
215}
216
217impl std::str::FromStr for CookieStoreId {
218    type Err = std::convert::Infallible;
219    fn from_str(s: &str) -> Result<Self, Self::Err> {
220        Ok(Self(s.to_owned()))
221    }
222}
223
224impl AsRef<str> for CookieStoreId {
225    fn as_ref(&self) -> &str {
226        &self.0
227    }
228}
229
230/// Error type for invalid [`TabGroupId`] values.
231///
232/// Currently empty — all `u32` values are accepted. The `#[non_exhaustive]`
233/// attribute allows adding validation variants in the future without a
234/// semver-breaking change.
235#[non_exhaustive]
236#[derive(Debug, Clone, thiserror::Error)]
237pub enum InvalidTabGroupId {}
238
239/// Chrome-assigned tab group identifier.
240///
241/// A lightweight newtype around `u32` that prevents accidental misuse of
242/// other IDs where a tab group ID is expected. Chrome-only; Firefox does
243/// not have a tab groups API.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
245#[serde(transparent)]
246pub struct TabGroupId(u32);
247
248impl TabGroupId {
249    /// Returns the underlying `u32` value.
250    #[must_use]
251    pub const fn as_u32(self) -> u32 {
252        self.0
253    }
254}
255
256#[expect(
257    clippy::infallible_try_from,
258    reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
259)]
260impl TryFrom<u32> for TabGroupId {
261    type Error = InvalidTabGroupId;
262    fn try_from(v: u32) -> Result<Self, Self::Error> {
263        Ok(Self(v))
264    }
265}
266
267impl std::fmt::Display for TabGroupId {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        self.0.fmt(f)
270    }
271}
272
273impl std::str::FromStr for TabGroupId {
274    type Err = std::num::ParseIntError;
275    fn from_str(s: &str) -> Result<Self, Self::Err> {
276        s.parse::<u32>().map(Self)
277    }
278}
279
280/// A password that is zeroed from memory on drop.
281///
282/// The inner string is wrapped in [`Zeroizing`] so it is securely erased
283/// when this value is dropped. [`Debug`] output redacts the value.
284#[derive(Clone, Serialize, Deserialize)]
285#[serde(transparent)]
286pub struct Password(Zeroizing<String>);
287
288impl std::fmt::Debug for Password {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.write_str("Password([REDACTED])")
291    }
292}
293
294impl PartialEq for Password {
295    fn eq(&self, other: &Self) -> bool {
296        *self.0 == *other.0
297    }
298}
299
300impl Eq for Password {}
301
302impl From<String> for Password {
303    fn from(s: String) -> Self {
304        Self(Zeroizing::new(s))
305    }
306}
307
308impl From<&str> for Password {
309    fn from(s: &str) -> Self {
310        Self(Zeroizing::new(s.to_owned()))
311    }
312}
313
314impl std::ops::Deref for Password {
315    type Target = str;
316    fn deref(&self) -> &str {
317        &self.0
318    }
319}
320
321/// Serde helper: deserializes `-1` as `None` and non-negative values as
322/// `Some(u64)`; serializes `None` back to `-1`.
323mod neg1_as_none {
324    use serde::{Deserialize as _, Deserializer, Serializer};
325
326    /// Serialize `None` as `-1` and `Some(v)` as the integer `v`.
327    #[expect(clippy::ref_option, reason = "signature required by serde(with)")]
328    pub(crate) fn serialize<S: Serializer>(value: &Option<u64>, ser: S) -> Result<S::Ok, S::Error> {
329        match *value {
330            Some(v) => ser.serialize_i64(i64::try_from(v).unwrap_or(i64::MAX)),
331            None => ser.serialize_i64(-1),
332        }
333    }
334
335    /// Deserialize a signed integer, mapping negative values to `None`.
336    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<u64>, D::Error> {
337        let v = i64::deserialize(de)?;
338        if v < 0 {
339            Ok(None)
340        } else {
341            Ok(Some(u64::try_from(v).unwrap_or(u64::MAX)))
342        }
343    }
344}
345
346/// Information about a running browser instance.
347#[non_exhaustive]
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct BrowserInfo {
350    /// Human-readable browser name (e.g. "Firefox", "Chrome").
351    pub browser_name: String,
352    /// Browser vendor (e.g. "Mozilla").
353    ///
354    /// `None` when not reported by the browser (non-Firefox browsers or older versions).
355    #[serde(default)]
356    pub browser_vendor: Option<String>,
357    /// Browser version string (e.g. "120.0").
358    pub browser_version: String,
359    /// PID of the browser's main process.
360    pub pid: u32,
361    /// The browser profile identifier (directory basename, e.g. `abc123.default-release`).
362    ///
363    /// `None` when the profile cannot be determined from the browser's command line.
364    #[serde(default)]
365    pub profile_id: Option<String>,
366}
367
368impl BrowserInfo {
369    /// Create a new `BrowserInfo`.
370    #[must_use]
371    pub const fn new(
372        browser_name: String,
373        browser_vendor: Option<String>,
374        browser_version: String,
375        pid: u32,
376        profile_id: Option<String>,
377    ) -> Self {
378        Self {
379            browser_name,
380            browser_vendor,
381            browser_version,
382            pid,
383            profile_id,
384        }
385    }
386}
387
388/// The visual state of a browser window.
389#[non_exhaustive]
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "snake_case")]
392pub enum WindowState {
393    /// Window is in its normal state.
394    Normal,
395    /// Window is minimized.
396    Minimized,
397    /// Window is maximized.
398    Maximized,
399    /// Window is in full-screen mode.
400    Fullscreen,
401}
402
403/// The type of a browser window.
404#[non_exhaustive]
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case")]
407pub enum WindowType {
408    /// A regular browser window.
409    Normal,
410    /// A popup window (e.g. opened via `window.open()`).
411    Popup,
412    /// A panel window (Chrome-only, deprecated).
413    Panel,
414    /// A developer tools window.
415    Devtools,
416}
417
418impl std::fmt::Display for WindowType {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            Self::Normal => write!(f, "normal"),
422            Self::Popup => write!(f, "popup"),
423            Self::Panel => write!(f, "panel"),
424            Self::Devtools => write!(f, "devtools"),
425        }
426    }
427}
428
429/// The color of a Chrome tab group.
430#[non_exhaustive]
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum TabGroupColor {
434    /// Grey color.
435    Grey,
436    /// Blue color.
437    Blue,
438    /// Red color.
439    Red,
440    /// Yellow color.
441    Yellow,
442    /// Green color.
443    Green,
444    /// Pink color.
445    Pink,
446    /// Purple color.
447    Purple,
448    /// Cyan color.
449    Cyan,
450    /// Orange color.
451    Orange,
452}
453
454impl std::fmt::Display for TabGroupColor {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        match self {
457            Self::Grey => write!(f, "grey"),
458            Self::Blue => write!(f, "blue"),
459            Self::Red => write!(f, "red"),
460            Self::Yellow => write!(f, "yellow"),
461            Self::Green => write!(f, "green"),
462            Self::Pink => write!(f, "pink"),
463            Self::Purple => write!(f, "purple"),
464            Self::Cyan => write!(f, "cyan"),
465            Self::Orange => write!(f, "orange"),
466        }
467    }
468}
469
470/// Information about a Chrome tab group.
471///
472/// Chrome-only; Firefox does not support tab groups.
473#[non_exhaustive]
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct TabGroupInfo {
476    /// The tab group's unique identifier.
477    pub id: TabGroupId,
478    /// The display title of the group (may be empty).
479    pub title: String,
480    /// The color of the group.
481    pub color: TabGroupColor,
482    /// Whether the group is visually collapsed.
483    pub collapsed: bool,
484    /// The window this group belongs to.
485    pub window_id: WindowId,
486}
487
488impl TabGroupInfo {
489    /// Create a new `TabGroupInfo`.
490    #[must_use]
491    pub const fn new(
492        id: TabGroupId,
493        title: String,
494        color: TabGroupColor,
495        collapsed: bool,
496        window_id: WindowId,
497    ) -> Self {
498        Self {
499            id,
500            title,
501            color,
502            collapsed,
503            window_id,
504        }
505    }
506}
507
508/// A brief summary of a tab, suitable for embedding in window listings.
509#[non_exhaustive]
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct TabSummary {
512    /// The browser-assigned tab ID.
513    pub id: TabId,
514    /// Zero-based position of the tab within its window.
515    pub index: u32,
516    /// The tab's title.
517    pub title: String,
518    /// The URL currently loaded in the tab.
519    pub url: String,
520    /// Whether this is the currently active (focused) tab in its window.
521    pub is_active: bool,
522    /// The cookie store (container) ID this tab belongs to.
523    ///
524    /// Firefox-specific; `None` on browsers that don't support containers.
525    #[serde(default)]
526    pub cookie_store_id: Option<CookieStoreId>,
527    /// The human-readable container name (e.g. "Work", "Personal").
528    ///
529    /// Firefox-specific; `None` on browsers that don't support containers.
530    #[serde(default)]
531    pub container_name: Option<String>,
532    /// Whether this tab is open in a private/incognito window.
533    #[serde(default)]
534    pub incognito: bool,
535}
536
537impl TabSummary {
538    /// Create a new `TabSummary`.
539    #[expect(
540        clippy::too_many_arguments,
541        reason = "mirrors the browser's tabs.Tab API fields"
542    )]
543    #[must_use]
544    pub const fn new(
545        id: TabId,
546        index: u32,
547        title: String,
548        url: String,
549        is_active: bool,
550        cookie_store_id: Option<CookieStoreId>,
551        container_name: Option<String>,
552        incognito: bool,
553    ) -> Self {
554        Self {
555            id,
556            index,
557            title,
558            url,
559            is_active,
560            cookie_store_id,
561            container_name,
562            incognito,
563        }
564    }
565}
566
567/// A summary of a browser window including its tabs.
568#[non_exhaustive]
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570pub struct WindowSummary {
571    /// The window's unique identifier within the browser.
572    pub id: WindowId,
573    /// The full window title as displayed in the title bar.
574    pub title: String,
575    /// An optional prefix prepended to the window title (Firefox-only, via `titlePreface`).
576    pub title_prefix: Option<String>,
577    /// Whether this window currently has input focus.
578    pub is_focused: bool,
579    /// Whether this is the most recently focused window.
580    ///
581    /// This differs from `is_focused` when no window currently has OS-level focus
582    /// (e.g. all browser windows are on an inactive Wayland workspace). Firefox tracks
583    /// last-focused state internally and uses it as the fallback target when creating
584    /// a tab without a specific window.
585    pub is_last_focused: bool,
586    /// The current visual state of the window.
587    pub state: WindowState,
588    /// The type of this window (normal, popup, panel, devtools).
589    #[serde(default)]
590    pub window_type: Option<WindowType>,
591    /// Whether this window is in private/incognito mode.
592    #[serde(default)]
593    pub incognito: bool,
594    /// Window width in pixels.
595    #[serde(default)]
596    pub width: Option<u32>,
597    /// Window height in pixels.
598    #[serde(default)]
599    pub height: Option<u32>,
600    /// Left edge of the window in pixels from the screen left.
601    ///
602    /// May be negative on multi-monitor setups where a monitor is to the left
603    /// of the primary monitor's origin.
604    #[serde(default)]
605    pub left: Option<i32>,
606    /// Top edge of the window in pixels from the screen top.
607    ///
608    /// May be negative on multi-monitor setups where a monitor is above the
609    /// primary monitor's origin.
610    #[serde(default)]
611    pub top: Option<i32>,
612    /// Brief summaries of the tabs open in this window.
613    pub tabs: Vec<TabSummary>,
614}
615
616impl WindowSummary {
617    /// Create a new `WindowSummary`.
618    #[expect(
619        clippy::too_many_arguments,
620        reason = "mirrors the browser's windows.Window API fields"
621    )]
622    #[must_use]
623    pub const fn new(
624        id: WindowId,
625        title: String,
626        title_prefix: Option<String>,
627        is_focused: bool,
628        is_last_focused: bool,
629        state: WindowState,
630        window_type: Option<WindowType>,
631        incognito: bool,
632        width: Option<u32>,
633        height: Option<u32>,
634        left: Option<i32>,
635        top: Option<i32>,
636        tabs: Vec<TabSummary>,
637    ) -> Self {
638        Self {
639            id,
640            title,
641            title_prefix,
642            is_focused,
643            is_last_focused,
644            state,
645            window_type,
646            incognito,
647            width,
648            height,
649            left,
650            top,
651            tabs,
652        }
653    }
654}
655
656/// The loading status of a tab.
657#[non_exhaustive]
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(rename_all = "snake_case")]
660pub enum TabStatus {
661    /// The tab is currently loading.
662    Loading,
663    /// The tab has finished loading.
664    Complete,
665    /// The tab has been discarded (unloaded from memory). Chrome-only.
666    Unloaded,
667}
668
669/// The state of a download.
670#[non_exhaustive]
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(rename_all = "snake_case")]
673pub enum DownloadState {
674    /// The download is actively receiving data.
675    InProgress,
676    /// The download completed successfully.
677    Complete,
678    /// The download was interrupted (check `error` for the reason).
679    Interrupted,
680}
681
682impl std::fmt::Display for DownloadState {
683    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684        match self {
685            Self::InProgress => write!(f, "in_progress"),
686            Self::Complete => write!(f, "complete"),
687            Self::Interrupted => write!(f, "interrupted"),
688        }
689    }
690}
691
692/// How to handle filename conflicts when downloading.
693#[non_exhaustive]
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
695#[serde(rename_all = "snake_case")]
696pub enum FilenameConflictAction {
697    /// Add a number to the filename to make it unique.
698    Uniquify,
699    /// Overwrite the existing file.
700    Overwrite,
701    /// Prompt the user.
702    Prompt,
703}
704
705impl std::fmt::Display for FilenameConflictAction {
706    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
707        match self {
708            Self::Uniquify => write!(f, "uniquify"),
709            Self::Overwrite => write!(f, "overwrite"),
710            Self::Prompt => write!(f, "prompt"),
711        }
712    }
713}
714
715/// Details about a download.
716#[non_exhaustive]
717#[expect(
718    clippy::struct_excessive_bools,
719    reason = "DownloadItem mirrors the browser's DownloadItem API, which exposes each state as a separate boolean property"
720)]
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct DownloadItem {
723    /// Browser-assigned download ID.
724    pub id: DownloadId,
725    /// The URL that was downloaded.
726    pub url: String,
727    /// Absolute filesystem path where the file was saved.
728    pub filename: String,
729    /// Current state of the download.
730    pub state: DownloadState,
731    /// Bytes received so far.
732    pub bytes_received: u64,
733    /// Total file size in bytes, or `None` if unknown.
734    #[serde(with = "neg1_as_none")]
735    pub total_bytes: Option<u64>,
736    /// Final file size in bytes, or `None` if unknown.
737    #[serde(with = "neg1_as_none")]
738    pub file_size: Option<u64>,
739    /// Error reason if the download was interrupted.
740    #[serde(default)]
741    pub error: Option<String>,
742    /// ISO 8601 timestamp when the download started.
743    pub start_time: String,
744    /// ISO 8601 timestamp when the download ended.
745    #[serde(default)]
746    pub end_time: Option<String>,
747    /// Whether the download is paused.
748    pub paused: bool,
749    /// Whether an interrupted download can be resumed.
750    pub can_resume: bool,
751    /// Whether the downloaded file still exists on disk.
752    pub exists: bool,
753    /// MIME type of the downloaded file.
754    #[serde(default)]
755    pub mime: Option<String>,
756    /// Whether the download is associated with a private/incognito session.
757    pub incognito: bool,
758    /// Predicted completion time as an ISO 8601 timestamp string.
759    #[serde(default)]
760    pub estimated_end_time: Option<String>,
761    /// Danger classification of the download (e.g. "safe", "file", "url", "uncommon", "malware").
762    #[serde(default)]
763    pub danger: Option<String>,
764}
765
766impl DownloadItem {
767    /// Create a new `DownloadItem`.
768    #[expect(
769        clippy::too_many_arguments,
770        reason = "mirrors the browser's DownloadItem API fields"
771    )]
772    #[expect(
773        clippy::fn_params_excessive_bools,
774        reason = "mirrors the browser's DownloadItem API booleans"
775    )]
776    #[must_use]
777    pub const fn new(
778        id: DownloadId,
779        url: String,
780        filename: String,
781        state: DownloadState,
782        bytes_received: u64,
783        total_bytes: Option<u64>,
784        file_size: Option<u64>,
785        error: Option<String>,
786        start_time: String,
787        end_time: Option<String>,
788        paused: bool,
789        can_resume: bool,
790        exists: bool,
791        mime: Option<String>,
792        incognito: bool,
793        estimated_end_time: Option<String>,
794        danger: Option<String>,
795    ) -> Self {
796        Self {
797            id,
798            url,
799            filename,
800            state,
801            bytes_received,
802            total_bytes,
803            file_size,
804            error,
805            start_time,
806            end_time,
807            paused,
808            can_resume,
809            exists,
810            mime,
811            incognito,
812            estimated_end_time,
813            danger,
814        }
815    }
816}
817
818/// Full details about a browser tab.
819#[non_exhaustive]
820#[expect(
821    clippy::struct_excessive_bools,
822    reason = "TabDetails mirrors the Firefox tabs.Tab API, which exposes each state as a separate boolean property"
823)]
824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
825pub struct TabDetails {
826    /// The tab's unique identifier within the browser.
827    pub id: TabId,
828    /// Zero-based position of the tab within its window.
829    pub index: u32,
830    /// The identifier of the window that contains this tab.
831    pub window_id: WindowId,
832    /// The tab's title.
833    pub title: String,
834    /// The URL currently loaded in the tab.
835    pub url: String,
836    /// Whether this is the currently active (focused) tab in its window.
837    pub is_active: bool,
838    /// Whether this tab is pinned.
839    pub is_pinned: bool,
840    /// Whether this tab has been discarded (unloaded from memory to save resources).
841    pub is_discarded: bool,
842    /// Whether this tab is currently producing audio.
843    pub is_audible: bool,
844    /// Whether this tab's audio is muted.
845    pub is_muted: bool,
846    /// The current loading status of the tab.
847    pub status: TabStatus,
848    /// Whether this tab is drawing user attention (e.g. a modal dialog is open, including basic auth prompts).
849    ///
850    /// Corresponds to the `attention` field in the Firefox `tabs.Tab` API.
851    #[serde(default)]
852    pub has_attention: bool,
853    /// Whether this tab is currently waiting for basic HTTP authentication credentials.
854    ///
855    /// Tracked by the extension via `browser.webRequest.onAuthRequired`.
856    #[serde(default)]
857    pub is_awaiting_auth: bool,
858    /// Whether this tab is currently displayed in Reader Mode.
859    ///
860    /// Firefox-specific; will be `false` on browsers that do not support Reader Mode.
861    #[serde(default)]
862    pub is_in_reader_mode: bool,
863    /// Whether this tab is open in a private/incognito window.
864    pub incognito: bool,
865    /// Number of entries in the tab's session history (back/forward stack).
866    ///
867    /// Populated via `window.history.length`; always available when the tab allows
868    /// content script injection. May be 0 for discarded tabs or privileged pages.
869    #[serde(default)]
870    pub history_length: u32,
871    /// Number of steps that can be navigated backward from the current history entry.
872    ///
873    /// `None` when the Navigation API (`window.navigation`) is unavailable (Firefox < 125
874    /// or privileged pages). When `Some`, equals the 0-based index of the current entry
875    /// in the history stack.
876    #[serde(default)]
877    pub history_steps_back: Option<u32>,
878    /// Number of steps that can be navigated forward from the current history entry.
879    ///
880    /// `None` under the same conditions as [`TabDetails::history_steps_back`].
881    #[serde(default)]
882    pub history_steps_forward: Option<u32>,
883    /// Number of history entries that exist but are inaccessible to the current document.
884    ///
885    /// These are cross-origin entries (or entries from a different document in the same
886    /// tab) that appear in the joint session history but are hidden from the Navigation
887    /// API for security reasons.  Computed as `window.history.length −
888    /// navigation.entries().length` when the Navigation API is available.
889    ///
890    /// `Some(0)` means the Navigation API is available and all entries are accessible.
891    /// `None` means the Navigation API is unavailable so the split cannot be determined;
892    /// in that case [`TabDetails::history_length`] already reflects the full total.
893    #[serde(default)]
894    pub history_hidden_count: Option<u32>,
895    /// The cookie store (container) ID this tab belongs to.
896    ///
897    /// Firefox-specific; `None` on browsers that don't support containers.
898    #[serde(default)]
899    pub cookie_store_id: Option<CookieStoreId>,
900    /// The human-readable container name (e.g. "Work", "Personal").
901    ///
902    /// Firefox-specific; `None` on browsers that don't support containers.
903    #[serde(default)]
904    pub container_name: Option<String>,
905    /// The ID of the tab that opened this one, if any.
906    ///
907    /// `None` when the tab was not opened by another tab (e.g. opened via the
908    /// address bar, bookmarks, or the `tabs open` command).
909    #[serde(default)]
910    pub opener_tab_id: Option<TabId>,
911    /// Timestamp (milliseconds since epoch) of the last user interaction.
912    ///
913    /// Firefox-specific; `None` on browsers that don't track this.
914    #[serde(default)]
915    pub last_accessed: Option<u64>,
916    /// Whether the browser can auto-discard this tab to save memory.
917    ///
918    /// Chrome-specific; `None` on browsers that don't support this.
919    #[serde(default)]
920    pub auto_discardable: Option<bool>,
921    /// Tab group ID, or `None` if this tab is not in a group.
922    ///
923    /// Chrome-specific; `None` on browsers that don't support tab groups.
924    #[serde(default)]
925    pub group_id: Option<TabGroupId>,
926}
927
928impl TabDetails {
929    /// Create a new `TabDetails`.
930    #[expect(
931        clippy::too_many_arguments,
932        reason = "mirrors the browser's tabs.Tab API fields"
933    )]
934    #[expect(
935        clippy::fn_params_excessive_bools,
936        reason = "mirrors the browser's tabs.Tab API booleans"
937    )]
938    #[must_use]
939    pub const fn new(
940        id: TabId,
941        index: u32,
942        window_id: WindowId,
943        title: String,
944        url: String,
945        is_active: bool,
946        is_pinned: bool,
947        is_discarded: bool,
948        is_audible: bool,
949        is_muted: bool,
950        status: TabStatus,
951        has_attention: bool,
952        is_awaiting_auth: bool,
953        is_in_reader_mode: bool,
954        incognito: bool,
955        history_length: u32,
956        history_steps_back: Option<u32>,
957        history_steps_forward: Option<u32>,
958        history_hidden_count: Option<u32>,
959        cookie_store_id: Option<CookieStoreId>,
960        container_name: Option<String>,
961        opener_tab_id: Option<TabId>,
962        last_accessed: Option<u64>,
963        auto_discardable: Option<bool>,
964        group_id: Option<TabGroupId>,
965    ) -> Self {
966        Self {
967            id,
968            index,
969            window_id,
970            title,
971            url,
972            is_active,
973            is_pinned,
974            is_discarded,
975            is_audible,
976            is_muted,
977            status,
978            has_attention,
979            is_awaiting_auth,
980            is_in_reader_mode,
981            incognito,
982            history_length,
983            history_steps_back,
984            history_steps_forward,
985            history_hidden_count,
986            cookie_store_id,
987            container_name,
988            opener_tab_id,
989            last_accessed,
990            auto_discardable,
991            group_id,
992        }
993    }
994}
995
996/// Information about a Firefox container (contextual identity).
997#[non_exhaustive]
998#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
999pub struct ContainerInfo {
1000    /// The cookie store ID (e.g. `"firefox-container-1"`).
1001    pub cookie_store_id: CookieStoreId,
1002    /// Human-readable name (e.g. `"Work"`).
1003    pub name: String,
1004    /// Color identifier (e.g. `"blue"`).
1005    pub color: String,
1006    /// Hex color code (e.g. `"#37adff"`).
1007    pub color_code: String,
1008    /// Icon identifier (e.g. `"briefcase"`).
1009    pub icon: String,
1010}
1011
1012impl ContainerInfo {
1013    /// Create a new `ContainerInfo`.
1014    #[must_use]
1015    pub const fn new(
1016        cookie_store_id: CookieStoreId,
1017        name: String,
1018        color: String,
1019        color_code: String,
1020        icon: String,
1021    ) -> Self {
1022        Self {
1023            cookie_store_id,
1024            name,
1025            color,
1026            color_code,
1027            icon,
1028        }
1029    }
1030}
1031
1032/// An event emitted by the browser extension and broadcast to all event-stream subscribers.
1033#[non_exhaustive]
1034#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035#[serde(tag = "type")]
1036pub enum BrowserEvent {
1037    /// A new browser window was opened.
1038    WindowOpened {
1039        /// The new window's ID.
1040        window_id: WindowId,
1041        /// The window's title at the time it was created (may be empty).
1042        title: String,
1043    },
1044    /// A browser window was closed.
1045    WindowClosed {
1046        /// The ID of the closed window.
1047        window_id: WindowId,
1048    },
1049    /// The active tab in a window changed.
1050    TabActivated {
1051        /// The window containing the newly active tab.
1052        window_id: WindowId,
1053        /// The ID of the newly active tab.
1054        tab_id: TabId,
1055        /// The ID of the previously active tab, if any.
1056        #[serde(default)]
1057        previous_tab_id: Option<TabId>,
1058    },
1059    /// A new tab was opened.
1060    TabOpened {
1061        /// The new tab's ID.
1062        tab_id: TabId,
1063        /// The window containing the new tab.
1064        window_id: WindowId,
1065        /// Zero-based position of the tab within its window.
1066        index: u32,
1067        /// The URL loaded in the tab at creation time (may be empty or `"about:blank"`).
1068        url: String,
1069        /// The tab's title at creation time (often empty).
1070        title: String,
1071    },
1072    /// A tab was closed.
1073    TabClosed {
1074        /// The ID of the closed tab.
1075        tab_id: TabId,
1076        /// The window that contained the tab.
1077        window_id: WindowId,
1078        /// Whether the tab was closed because its parent window was also closing.
1079        is_window_closing: bool,
1080    },
1081    /// A tab started loading a new URL.
1082    TabNavigated {
1083        /// The ID of the navigating tab.
1084        tab_id: TabId,
1085        /// The window containing the tab.
1086        window_id: WindowId,
1087        /// The new URL.
1088        url: String,
1089    },
1090    /// A tab's title changed.
1091    TabTitleChanged {
1092        /// The ID of the tab.
1093        tab_id: TabId,
1094        /// The window containing the tab.
1095        window_id: WindowId,
1096        /// The new title.
1097        title: String,
1098    },
1099    /// A tab's loading status changed (e.g. from `loading` to `complete`).
1100    TabStatusChanged {
1101        /// The ID of the tab.
1102        tab_id: TabId,
1103        /// The window containing the tab.
1104        window_id: WindowId,
1105        /// The new loading status.
1106        status: TabStatus,
1107    },
1108    /// A new download was started.
1109    DownloadCreated {
1110        /// The download's ID.
1111        download_id: DownloadId,
1112        /// The URL being downloaded.
1113        url: String,
1114        /// The filename (may be empty until determined).
1115        filename: String,
1116        /// The MIME type, if known.
1117        #[serde(default)]
1118        mime: Option<String>,
1119    },
1120    /// A download's state or properties changed.
1121    DownloadChanged {
1122        /// The download's ID.
1123        download_id: DownloadId,
1124        /// The new state, if it changed.
1125        #[serde(default)]
1126        state: Option<DownloadState>,
1127        /// The new filename, if it changed.
1128        #[serde(default)]
1129        filename: Option<String>,
1130        /// The error reason, if the download was interrupted.
1131        #[serde(default)]
1132        error: Option<String>,
1133    },
1134    /// A download was removed from the browser's history.
1135    DownloadErased {
1136        /// The download's ID.
1137        download_id: DownloadId,
1138    },
1139    /// A tab was moved to a new position within its window.
1140    TabMoved {
1141        /// The ID of the moved tab.
1142        tab_id: TabId,
1143        /// The window containing the tab.
1144        window_id: WindowId,
1145        /// The previous zero-based index.
1146        from_index: u32,
1147        /// The new zero-based index.
1148        to_index: u32,
1149    },
1150    /// A tab was attached to a window (moved from another window).
1151    TabAttached {
1152        /// The ID of the attached tab.
1153        tab_id: TabId,
1154        /// The window the tab was attached to.
1155        new_window_id: WindowId,
1156        /// The tab's new zero-based index in the window.
1157        new_index: u32,
1158    },
1159    /// A tab was detached from a window (being moved to another window).
1160    TabDetached {
1161        /// The ID of the detached tab.
1162        tab_id: TabId,
1163        /// The window the tab was detached from.
1164        old_window_id: WindowId,
1165        /// The tab's old zero-based index in the window.
1166        old_index: u32,
1167    },
1168    /// The focused window changed.
1169    WindowFocusChanged {
1170        /// The newly focused window ID, or `None` if all windows lost focus
1171        /// (e.g. the user switched to another application).
1172        #[serde(default)]
1173        window_id: Option<WindowId>,
1174    },
1175    /// A tab group was created. Chrome-only.
1176    TabGroupCreated {
1177        /// The new group's ID.
1178        group_id: TabGroupId,
1179        /// The window containing the group.
1180        window_id: WindowId,
1181        /// The group's display title.
1182        title: String,
1183        /// The group's color.
1184        color: String,
1185        /// Whether the group is collapsed.
1186        collapsed: bool,
1187    },
1188    /// A tab group's properties changed. Chrome-only.
1189    TabGroupUpdated {
1190        /// The updated group's ID.
1191        group_id: TabGroupId,
1192        /// The window containing the group.
1193        window_id: WindowId,
1194        /// The group's display title.
1195        title: String,
1196        /// The group's color.
1197        color: String,
1198        /// Whether the group is collapsed.
1199        collapsed: bool,
1200    },
1201    /// A tab group was removed. Chrome-only.
1202    TabGroupRemoved {
1203        /// The removed group's ID.
1204        group_id: TabGroupId,
1205        /// The window that contained the group.
1206        window_id: WindowId,
1207    },
1208    /// An uncaught error or unhandled promise rejection occurred in the
1209    /// extension's service worker.
1210    ///
1211    /// These are forwarded from the extension's global error handlers so they
1212    /// are visible in the mediator log and event stream.
1213    ExtensionError {
1214        /// Error category (e.g. `"uncaught_error"`, `"unhandled_rejection"`).
1215        kind: String,
1216        /// Human-readable error message.
1217        message: String,
1218        /// Stack trace or additional context.
1219        #[serde(default)]
1220        detail: String,
1221    },
1222    /// Some events were lost because the consumer could not keep up.
1223    ///
1224    /// This is a synthetic event generated by the mediator, not the browser.
1225    /// The consumer should assume that any cached state may be stale and
1226    /// re-query if needed.
1227    EventsLost {
1228        /// The number of events that were dropped.
1229        count: u64,
1230    },
1231}
1232
1233impl BrowserEvent {
1234    /// Returns `true` if this is a download-related event.
1235    #[must_use]
1236    pub const fn is_download_event(&self) -> bool {
1237        matches!(
1238            self,
1239            Self::DownloadCreated { .. }
1240                | Self::DownloadChanged { .. }
1241                | Self::DownloadErased { .. }
1242        )
1243    }
1244
1245    /// Returns `true` if this event passes the given subscription filter.
1246    ///
1247    /// When both `include_windows_tabs` and `include_downloads` are `false`,
1248    /// all events pass (backward compatible "no filter" mode).
1249    #[must_use]
1250    pub const fn matches_filter(
1251        &self,
1252        include_windows_tabs: bool,
1253        include_downloads: bool,
1254    ) -> bool {
1255        // No filter flags → deliver everything.
1256        if !include_windows_tabs && !include_downloads {
1257            return true;
1258        }
1259        if self.is_download_event() {
1260            include_downloads
1261        } else {
1262            include_windows_tabs
1263        }
1264    }
1265}
1266
1267/// A command sent from the CLI to the mediator, and forwarded to the extension.
1268#[non_exhaustive]
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270#[serde(tag = "type")]
1271pub enum CliCommand {
1272    /// Retrieve information about the connected browser instance.
1273    GetBrowserInfo,
1274    /// List all open windows with their tab summaries.
1275    ListWindows,
1276    /// Open a new browser window.
1277    OpenWindow {
1278        /// Optional title prefix (Firefox `titlePreface`) to set on the new window.
1279        /// Firefox-only; returns an error on other browsers when set.
1280        ///
1281        /// When set, the extension calls `browser.windows.update` immediately after
1282        /// creation with `{ titlePreface: title_prefix }`.
1283        #[serde(default)]
1284        title_prefix: Option<String>,
1285        /// If `true`, open the window in private/incognito browsing mode.
1286        ///
1287        /// The extension must be allowed to run in private windows for this to work.
1288        #[serde(default)]
1289        incognito: bool,
1290    },
1291    /// Close an existing browser window.
1292    CloseWindow {
1293        /// The ID of the window to close.
1294        window_id: WindowId,
1295    },
1296    /// Set the title prefix (Firefox `titlePreface`) for a window.
1297    ///
1298    /// Firefox-only. Returns an error on browsers that do not support
1299    /// `titlePreface`.
1300    SetWindowTitlePrefix {
1301        /// The ID of the window whose prefix to set.
1302        window_id: WindowId,
1303        /// The prefix string to prepend to the window title.
1304        prefix: String,
1305    },
1306    /// Remove the title prefix from a window, restoring the default title.
1307    ///
1308    /// Firefox-only. Returns an error on browsers that do not support
1309    /// `titlePreface`.
1310    RemoveWindowTitlePrefix {
1311        /// The ID of the window whose prefix to remove.
1312        window_id: WindowId,
1313    },
1314    /// List all tabs in a window with full details.
1315    ListTabs {
1316        /// The ID of the window whose tabs to list.
1317        window_id: WindowId,
1318    },
1319    /// Open a new tab in a window.
1320    OpenTab {
1321        /// The ID of the window in which to open the tab.
1322        window_id: WindowId,
1323        /// If set, the new tab will be inserted immediately before the tab with this ID.
1324        insert_before_tab_id: Option<TabId>,
1325        /// If set, the new tab will be inserted immediately after the tab with this ID.
1326        insert_after_tab_id: Option<TabId>,
1327        /// The URL to load in the new tab, or the browser's default new-tab page if absent.
1328        url: Option<String>,
1329        /// Optional username for HTTP authentication.
1330        ///
1331        /// When set (together with `password`), the extension opens the URL without
1332        /// embedded credentials and responds to the server's 401 challenge via the
1333        /// `webRequest.onAuthRequired` API, injecting the credentials into the browser's
1334        /// built-in authentication cache. Subsequent requests to the same realm reuse the
1335        /// cached credentials automatically. Requires `url` to be set.
1336        #[serde(default)]
1337        username: Option<String>,
1338        /// Optional password for HTTP authentication.
1339        ///
1340        /// Used together with `username`. Requires `url` to be set.
1341        #[serde(default)]
1342        password: Option<Password>,
1343        /// If `true`, the new tab is created in the background and the currently active tab
1344        /// in the window remains active.
1345        #[serde(default)]
1346        background: bool,
1347        /// Firefox container (cookie store) ID to open the tab in.
1348        /// Firefox-only; returns an error on browsers without container support.
1349        ///
1350        /// E.g. `"firefox-container-1"`.
1351        #[serde(default)]
1352        cookie_store_id: Option<CookieStoreId>,
1353        /// Optional timeout in milliseconds to wait for the tab to finish loading
1354        /// before returning.
1355        ///
1356        /// When set, the extension waits for `tabs.onUpdated` to report
1357        /// `status: "complete"` for this tab, up to the given timeout. If the
1358        /// timeout elapses, the tab details are returned in whatever state they
1359        /// are in. When `None`, the tab details are returned immediately after
1360        /// creation without waiting.
1361        #[serde(default)]
1362        wait_for_load_timeout_ms: Option<u32>,
1363    },
1364    /// Activate a tab, making it the focused tab in its window.
1365    ActivateTab {
1366        /// The ID of the tab to activate.
1367        tab_id: TabId,
1368    },
1369    /// Navigate an existing tab to a new URL.
1370    NavigateTab {
1371        /// The ID of the tab to navigate.
1372        tab_id: TabId,
1373        /// The URL to load in the tab.
1374        url: String,
1375    },
1376    /// Reload a tab.
1377    ReloadTab {
1378        /// The ID of the tab to reload.
1379        tab_id: TabId,
1380        /// If `true`, bypass the browser cache (hard refresh).
1381        #[serde(default)]
1382        bypass_cache: bool,
1383    },
1384    /// Close a tab.
1385    CloseTab {
1386        /// The ID of the tab to close.
1387        tab_id: TabId,
1388    },
1389    /// Pin a tab.
1390    PinTab {
1391        /// The ID of the tab to pin.
1392        tab_id: TabId,
1393    },
1394    /// Unpin a tab.
1395    UnpinTab {
1396        /// The ID of the tab to unpin.
1397        tab_id: TabId,
1398    },
1399    /// Toggle Reader Mode for a tab.
1400    ///
1401    /// Firefox-only. Switches the tab into or out of Reader Mode. The tab
1402    /// must be displaying a page that Firefox considers reader-mode compatible.
1403    ToggleReaderMode {
1404        /// The ID of the tab whose Reader Mode to toggle.
1405        tab_id: TabId,
1406    },
1407    /// Discard a tab, unloading its content from memory without closing it.
1408    ///
1409    /// The tab remains in the tab strip but its content is freed. It will be
1410    /// reloaded when activated. Cannot discard the active tab.
1411    DiscardTab {
1412        /// The ID of the tab to discard.
1413        tab_id: TabId,
1414    },
1415    /// Warm up a discarded tab, loading its content into memory without activating it.
1416    ///
1417    /// Firefox-only. Uses `tabs.warmup()` which is not available on Chrome.
1418    WarmupTab {
1419        /// The ID of the tab to warm up.
1420        tab_id: TabId,
1421    },
1422    /// Mute a tab, suppressing any audio it produces.
1423    MuteTab {
1424        /// The ID of the tab to mute.
1425        tab_id: TabId,
1426    },
1427    /// Unmute a tab, allowing it to produce audio again.
1428    UnmuteTab {
1429        /// The ID of the tab to unmute.
1430        tab_id: TabId,
1431    },
1432    /// Move a tab to a new position within its window.
1433    MoveTab {
1434        /// The ID of the tab to move.
1435        tab_id: TabId,
1436        /// The new zero-based index for the tab within its window.
1437        new_index: u32,
1438    },
1439    /// Navigate backward in a tab's session history.
1440    ///
1441    /// Returns a [`CliResult::Tab`] with the details of the page navigated to,
1442    /// or the current tab state if the history boundary was already reached.
1443    GoBack {
1444        /// The ID of the tab to navigate.
1445        tab_id: TabId,
1446        /// Number of steps to go back (default 1).
1447        steps: u32,
1448    },
1449    /// Navigate forward in a tab's session history.
1450    ///
1451    /// Returns a [`CliResult::Tab`] with the details of the page navigated to,
1452    /// or the current tab state if the history boundary was already reached.
1453    GoForward {
1454        /// The ID of the tab to navigate.
1455        tab_id: TabId,
1456        /// Number of steps to go forward (default 1).
1457        steps: u32,
1458    },
1459    /// Subscribe to a live stream of browser events.
1460    ///
1461    /// After sending this command the mediator streams [`BrowserEvent`] objects as
1462    /// newline-delimited JSON on the same connection until the client disconnects.
1463    /// No [`CliResponse`] is sent; events arrive directly as [`BrowserEvent`] JSON.
1464    ///
1465    /// When both `include_windows_tabs` and `include_downloads` are `false`
1466    /// (the default), all event categories are delivered (backward compatible).
1467    SubscribeEvents {
1468        /// Include window and tab events (`WindowOpened`, `WindowClosed`,
1469        /// `TabOpened`, `TabClosed`, `TabActivated`, `TabNavigated`,
1470        /// `TabTitleChanged`, `TabStatusChanged`).
1471        #[serde(default)]
1472        include_windows_tabs: bool,
1473        /// Include download events (`DownloadCreated`, `DownloadChanged`,
1474        /// `DownloadErased`).
1475        #[serde(default)]
1476        include_downloads: bool,
1477    },
1478    /// List all Firefox containers (contextual identities).
1479    ///
1480    /// Firefox-only. Returns an error on browsers that do not support
1481    /// contextual identities.
1482    ListContainers,
1483    /// Close a tab and reopen its URL in a different container.
1484    ///
1485    /// Firefox-only. The tab is closed and a new tab is created in the target
1486    /// container with the same URL.
1487    ReopenTabInContainer {
1488        /// The ID of the tab to reopen.
1489        tab_id: TabId,
1490        /// The target container's cookie store ID.
1491        cookie_store_id: CookieStoreId,
1492    },
1493    /// List downloads, optionally filtered by state.
1494    ListDownloads {
1495        /// Filter by download state.
1496        #[serde(default)]
1497        state: Option<DownloadState>,
1498        /// Maximum number of results to return.
1499        #[serde(default)]
1500        limit: Option<u32>,
1501        /// Free-text search query matching URL and filename.
1502        #[serde(default)]
1503        query: Option<String>,
1504    },
1505    /// Start a new download.
1506    StartDownload {
1507        /// The URL to download.
1508        url: String,
1509        /// Filename relative to the downloads folder.
1510        #[serde(default)]
1511        filename: Option<String>,
1512        /// If `true`, show the Save As dialog.
1513        #[serde(default)]
1514        save_as: bool,
1515        /// How to handle filename conflicts.
1516        #[serde(default)]
1517        conflict_action: Option<FilenameConflictAction>,
1518    },
1519    /// Cancel an active download.
1520    CancelDownload {
1521        /// The download ID to cancel.
1522        download_id: DownloadId,
1523    },
1524    /// Pause an active download.
1525    PauseDownload {
1526        /// The download ID to pause.
1527        download_id: DownloadId,
1528    },
1529    /// Resume a paused download.
1530    ResumeDownload {
1531        /// The download ID to resume.
1532        download_id: DownloadId,
1533    },
1534    /// Retry an interrupted download by re-downloading from the same URL.
1535    RetryDownload {
1536        /// The download ID to retry.
1537        download_id: DownloadId,
1538    },
1539    /// Remove a download from the browser's download history (the file stays on disk).
1540    EraseDownload {
1541        /// The download ID to erase.
1542        download_id: DownloadId,
1543    },
1544    /// Clear all downloads from the browser's history, optionally filtered by state.
1545    EraseAllDownloads {
1546        /// Only erase downloads in this state.
1547        #[serde(default)]
1548        state: Option<DownloadState>,
1549    },
1550    /// List all tab groups, optionally filtered by window.
1551    ///
1552    /// Chrome-only. Returns an error on browsers that do not support tab groups.
1553    ListTabGroups {
1554        /// Only list groups in this window.
1555        #[serde(default)]
1556        window_id: Option<WindowId>,
1557    },
1558    /// Get a single tab group by ID.
1559    ///
1560    /// Chrome-only.
1561    GetTabGroup {
1562        /// The ID of the group to retrieve.
1563        group_id: TabGroupId,
1564    },
1565    /// Update a tab group's properties.
1566    ///
1567    /// Chrome-only.
1568    UpdateTabGroup {
1569        /// The ID of the group to update.
1570        group_id: TabGroupId,
1571        /// New title for the group.
1572        #[serde(default)]
1573        title: Option<String>,
1574        /// New color for the group.
1575        #[serde(default)]
1576        color: Option<TabGroupColor>,
1577        /// New collapsed state for the group.
1578        #[serde(default)]
1579        collapsed: Option<bool>,
1580    },
1581    /// Move a tab group to a new position.
1582    ///
1583    /// Chrome-only.
1584    MoveTabGroup {
1585        /// The ID of the group to move.
1586        group_id: TabGroupId,
1587        /// The new zero-based index for the group.
1588        index: u32,
1589        /// Move the group to a different window.
1590        #[serde(default)]
1591        window_id: Option<WindowId>,
1592    },
1593    /// Add tabs to a tab group, optionally creating a new group.
1594    ///
1595    /// Chrome-only.
1596    GroupTabs {
1597        /// The tab IDs to group.
1598        tab_ids: Vec<TabId>,
1599        /// The group to add the tabs to. If `None`, a new group is created.
1600        #[serde(default)]
1601        group_id: Option<TabGroupId>,
1602    },
1603    /// Remove tabs from their tab groups.
1604    ///
1605    /// Chrome-only.
1606    UngroupTabs {
1607        /// The tab IDs to ungroup.
1608        tab_ids: Vec<TabId>,
1609    },
1610}
1611
1612/// A request sent from the CLI to the mediator.
1613#[non_exhaustive]
1614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1615pub struct CliRequest {
1616    /// A unique identifier (UUID v4 string) used to correlate requests with responses.
1617    pub request_id: String,
1618    /// The command to execute.
1619    ///
1620    /// Flattened so the command's `type` tag and fields appear at the top level of the JSON
1621    /// object alongside `request_id`, e.g. `{"request_id":"…","type":"ListWindows"}`.
1622    #[serde(flatten)]
1623    pub command: CliCommand,
1624}
1625
1626impl CliRequest {
1627    /// Create a new `CliRequest`.
1628    #[must_use]
1629    pub const fn new(request_id: String, command: CliCommand) -> Self {
1630        Self {
1631            request_id,
1632            command,
1633        }
1634    }
1635}
1636
1637/// The result payload of a successful command.
1638#[non_exhaustive]
1639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1640#[serde(tag = "type")]
1641pub enum CliResult {
1642    /// Browser information returned by `GetBrowserInfo`.
1643    BrowserInfo(BrowserInfo),
1644    /// Window list returned by `ListWindows`.
1645    Windows {
1646        /// The list of windows.
1647        windows: Vec<WindowSummary>,
1648    },
1649    /// ID of a newly created window returned by `OpenWindow`.
1650    WindowId {
1651        /// The new window's ID.
1652        window_id: WindowId,
1653    },
1654    /// Detailed tab list returned by `ListTabs`.
1655    Tabs {
1656        /// The list of tabs.
1657        tabs: Vec<TabDetails>,
1658    },
1659    /// Details of a newly created or moved tab.
1660    Tab(TabDetails),
1661    /// Container list returned by `ListContainers`.
1662    Containers {
1663        /// The list of containers.
1664        containers: Vec<ContainerInfo>,
1665    },
1666    /// Download list returned by `ListDownloads`.
1667    Downloads {
1668        /// The list of downloads.
1669        downloads: Vec<DownloadItem>,
1670    },
1671    /// ID of a newly started download returned by `StartDownload`.
1672    DownloadId {
1673        /// The new download's ID.
1674        download_id: DownloadId,
1675    },
1676    /// Tab group list returned by `ListTabGroups`.
1677    TabGroups {
1678        /// The list of tab groups.
1679        tab_groups: Vec<TabGroupInfo>,
1680    },
1681    /// Details of a single tab group.
1682    TabGroup(TabGroupInfo),
1683    /// Returned by commands that have no meaningful output.
1684    Unit,
1685}
1686
1687/// The outcome of a command: either a successful result or an error message.
1688#[non_exhaustive]
1689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1690#[serde(tag = "status", content = "data", rename_all = "lowercase")]
1691pub enum CliOutcome {
1692    /// The command succeeded.
1693    Ok(CliResult),
1694    /// The command failed with this error message.
1695    Err(String),
1696}
1697
1698/// A response sent from the mediator to the CLI.
1699#[non_exhaustive]
1700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1701pub struct CliResponse {
1702    /// The request ID from the corresponding [`CliRequest`].
1703    pub request_id: String,
1704    /// The outcome of the command.
1705    pub outcome: CliOutcome,
1706}
1707
1708impl CliResponse {
1709    /// Create a new `CliResponse`.
1710    #[must_use]
1711    pub const fn new(request_id: String, outcome: CliOutcome) -> Self {
1712        Self {
1713            request_id,
1714            outcome,
1715        }
1716    }
1717}
1718
1719/// Initial hello message sent from the browser extension to the mediator upon connection.
1720#[non_exhaustive]
1721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1722pub struct ExtensionHello {
1723    /// The name of the browser (e.g. "Firefox").
1724    pub browser_name: String,
1725    /// The browser vendor (e.g. "Mozilla").
1726    ///
1727    /// `None` on browsers that do not implement `browser.runtime.getBrowserInfo()`.
1728    #[serde(default)]
1729    pub browser_vendor: Option<String>,
1730    /// The browser version string (e.g. "120.0").
1731    pub browser_version: String,
1732}
1733
1734impl ExtensionHello {
1735    /// Create a new `ExtensionHello`.
1736    #[must_use]
1737    pub const fn new(
1738        browser_name: String,
1739        browser_vendor: Option<String>,
1740        browser_version: String,
1741    ) -> Self {
1742        Self {
1743            browser_name,
1744            browser_vendor,
1745            browser_version,
1746        }
1747    }
1748}
1749
1750/// A message received by the mediator from the browser extension via native messaging.
1751#[non_exhaustive]
1752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1753#[serde(tag = "message_type")]
1754pub enum ExtensionMessage {
1755    /// Sent once by the extension when it connects, providing browser identity information.
1756    Hello(ExtensionHello),
1757    /// A response to a previously forwarded [`CliRequest`].
1758    Response(CliResponse),
1759    /// An unsolicited browser event pushed by the extension.
1760    Event {
1761        /// The browser event payload.
1762        event: BrowserEvent,
1763    },
1764}
1765
1766impl std::fmt::Display for WindowState {
1767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1768        match self {
1769            Self::Normal => write!(f, "normal"),
1770            Self::Minimized => write!(f, "minimized"),
1771            Self::Maximized => write!(f, "maximized"),
1772            Self::Fullscreen => write!(f, "fullscreen"),
1773        }
1774    }
1775}
1776
1777impl std::fmt::Display for TabStatus {
1778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779        match self {
1780            Self::Loading => write!(f, "loading"),
1781            Self::Complete => write!(f, "complete"),
1782            Self::Unloaded => write!(f, "unloaded"),
1783        }
1784    }
1785}
1786
1787#[cfg(test)]
1788mod test {
1789    use super::{CliCommand, CliOutcome, CliRequest, CliResponse, CliResult, ExtensionMessage};
1790
1791    /// Verify that a `ListWindows` request round-trips through JSON correctly.
1792    #[test]
1793    #[expect(
1794        clippy::expect_used,
1795        reason = "panicking on unexpected failure is acceptable in tests"
1796    )]
1797    fn cli_request_list_windows_round_trip() {
1798        let request = CliRequest {
1799            request_id: "test-id-1".to_owned(),
1800            command: CliCommand::ListWindows,
1801        };
1802        let json = serde_json::to_string(&request)
1803            .expect("serialization should not fail for well-formed CliRequest");
1804        let decoded: CliRequest = serde_json::from_str(&json)
1805            .expect("deserialization should not fail for valid CliRequest JSON");
1806        pretty_assertions::assert_eq!(request, decoded);
1807    }
1808
1809    /// Verify that an `Ok(Unit)` response round-trips through JSON correctly.
1810    #[test]
1811    #[expect(
1812        clippy::expect_used,
1813        reason = "panicking on unexpected failure is acceptable in tests"
1814    )]
1815    fn cli_response_ok_unit_round_trip() {
1816        let response = CliResponse {
1817            request_id: "test-id-2".to_owned(),
1818            outcome: CliOutcome::Ok(CliResult::Unit),
1819        };
1820        let json = serde_json::to_string(&response)
1821            .expect("serialization should not fail for well-formed CliResponse");
1822        let decoded: CliResponse = serde_json::from_str(&json)
1823            .expect("deserialization should not fail for valid CliResponse JSON");
1824        pretty_assertions::assert_eq!(response, decoded);
1825    }
1826
1827    /// Verify that an `ExtensionMessage::Hello` round-trips through JSON correctly.
1828    #[test]
1829    #[expect(
1830        clippy::expect_used,
1831        reason = "panicking on unexpected failure is acceptable in tests"
1832    )]
1833    fn extension_hello_round_trip() {
1834        let msg = ExtensionMessage::Hello(super::ExtensionHello {
1835            browser_name: "Firefox".to_owned(),
1836            browser_vendor: Some("Mozilla".to_owned()),
1837            browser_version: "120.0".to_owned(),
1838        });
1839        let json = serde_json::to_string(&msg)
1840            .expect("serialization should not fail for well-formed ExtensionMessage::Hello");
1841        let decoded: ExtensionMessage = serde_json::from_str(&json)
1842            .expect("deserialization should not fail for valid ExtensionMessage JSON");
1843        pretty_assertions::assert_eq!(msg, decoded);
1844    }
1845}