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}