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 Firefox extension (via native messaging, length-prefixed JSON)
6
7use serde::{Deserialize, Serialize};
8
9/// Information about a running browser instance.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct BrowserInfo {
12    /// Human-readable browser name (e.g. "Firefox", "Chrome").
13    pub browser_name: String,
14    /// Browser vendor (e.g. "Mozilla").
15    ///
16    /// `None` when not reported by the browser (non-Firefox browsers or older versions).
17    #[serde(default)]
18    pub browser_vendor: Option<String>,
19    /// Browser version string (e.g. "120.0").
20    pub browser_version: String,
21    /// PID of the browser's main process.
22    pub pid: u32,
23    /// The browser profile identifier (directory basename, e.g. `abc123.default-release`).
24    ///
25    /// `None` when the profile cannot be determined (non-Linux platforms or if
26    /// the browser was not launched with an explicit `--profile` flag).
27    #[serde(default)]
28    pub profile_id: Option<String>,
29}
30
31/// The visual state of a browser window.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum WindowState {
35    /// Window is in its normal state.
36    Normal,
37    /// Window is minimized.
38    Minimized,
39    /// Window is maximized.
40    Maximized,
41    /// Window is in full-screen mode.
42    Fullscreen,
43}
44
45/// A brief summary of a tab, suitable for embedding in window listings.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct TabSummary {
48    /// Zero-based position of the tab within its window.
49    pub index: u32,
50    /// The tab's title.
51    pub title: String,
52    /// The URL currently loaded in the tab.
53    pub url: String,
54    /// Whether this is the currently active (focused) tab in its window.
55    pub is_active: bool,
56}
57
58/// A summary of a browser window including its tabs.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct WindowSummary {
61    /// The window's unique identifier within the browser.
62    pub id: u32,
63    /// The full window title as displayed in the title bar.
64    pub title: String,
65    /// An optional prefix prepended to the window title (Firefox-only, via `titlePreface`).
66    pub title_prefix: Option<String>,
67    /// Whether this window currently has input focus.
68    pub is_focused: bool,
69    /// The current visual state of the window.
70    pub state: WindowState,
71    /// Brief summaries of the tabs open in this window.
72    pub tabs: Vec<TabSummary>,
73}
74
75/// The loading status of a tab.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum TabStatus {
79    /// The tab is currently loading.
80    Loading,
81    /// The tab has finished loading.
82    Complete,
83}
84
85/// Full details about a browser tab.
86#[expect(
87    clippy::struct_excessive_bools,
88    reason = "TabDetails mirrors the Firefox tabs.Tab API, which exposes each state as a separate boolean property"
89)]
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct TabDetails {
92    /// The tab's unique identifier within the browser.
93    pub id: u32,
94    /// Zero-based position of the tab within its window.
95    pub index: u32,
96    /// The identifier of the window that contains this tab.
97    pub window_id: u32,
98    /// The tab's title.
99    pub title: String,
100    /// The URL currently loaded in the tab.
101    pub url: String,
102    /// Whether this is the currently active (focused) tab in its window.
103    pub is_active: bool,
104    /// Whether this tab is pinned.
105    pub is_pinned: bool,
106    /// Whether this tab has been discarded (unloaded from memory to save resources).
107    pub is_discarded: bool,
108    /// Whether this tab is currently producing audio.
109    pub is_audible: bool,
110    /// Whether this tab's audio is muted.
111    pub is_muted: bool,
112    /// The current loading status of the tab.
113    pub status: TabStatus,
114    /// Whether this tab is drawing user attention (e.g. a modal dialog is open, including basic auth prompts).
115    ///
116    /// Corresponds to the `attention` field in the Firefox `tabs.Tab` API.
117    #[serde(default)]
118    pub has_attention: bool,
119    /// Whether this tab is currently waiting for basic HTTP authentication credentials.
120    ///
121    /// Tracked by the extension via `browser.webRequest.onAuthRequired`.
122    #[serde(default)]
123    pub is_awaiting_auth: bool,
124    /// Whether this tab is currently displayed in Reader Mode.
125    ///
126    /// Firefox-specific; will be `false` on browsers that do not support Reader Mode.
127    #[serde(default)]
128    pub is_in_reader_mode: bool,
129    /// Whether this tab is open in a private/incognito window.
130    pub incognito: bool,
131    /// Number of entries in the tab's session history (back/forward stack).
132    ///
133    /// Populated via `window.history.length`; always available when the tab allows
134    /// content script injection. May be 0 for discarded tabs or privileged pages.
135    #[serde(default)]
136    pub history_length: u32,
137    /// Number of steps that can be navigated backward from the current history entry.
138    ///
139    /// `None` when the Navigation API (`window.navigation`) is unavailable (Firefox < 125
140    /// or privileged pages). When `Some`, equals the 0-based index of the current entry
141    /// in the history stack.
142    #[serde(default)]
143    pub history_steps_back: Option<u32>,
144    /// Number of steps that can be navigated forward from the current history entry.
145    ///
146    /// `None` under the same conditions as [`TabDetails::history_steps_back`].
147    #[serde(default)]
148    pub history_steps_forward: Option<u32>,
149    /// Number of history entries that exist but are inaccessible to the current document.
150    ///
151    /// These are cross-origin entries (or entries from a different document in the same
152    /// tab) that appear in the joint session history but are hidden from the Navigation
153    /// API for security reasons.  Computed as `window.history.length −
154    /// navigation.entries().length` when the Navigation API is available.
155    ///
156    /// `Some(0)` means the Navigation API is available and all entries are accessible.
157    /// `None` means the Navigation API is unavailable so the split cannot be determined;
158    /// in that case [`TabDetails::history_length`] already reflects the full total.
159    #[serde(default)]
160    pub history_hidden_count: Option<u32>,
161}
162
163/// An event emitted by the browser extension and broadcast to all event-stream subscribers.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(tag = "type")]
166pub enum BrowserEvent {
167    /// A new browser window was opened.
168    WindowOpened {
169        /// The new window's ID.
170        window_id: u32,
171        /// The window's title at the time it was created (may be empty).
172        title: String,
173    },
174    /// A browser window was closed.
175    WindowClosed {
176        /// The ID of the closed window.
177        window_id: u32,
178    },
179    /// The active tab in a window changed.
180    TabActivated {
181        /// The window containing the newly active tab.
182        window_id: u32,
183        /// The ID of the newly active tab.
184        tab_id: u32,
185        /// The ID of the previously active tab, if any.
186        #[serde(default)]
187        previous_tab_id: Option<u32>,
188    },
189    /// A new tab was opened.
190    TabOpened {
191        /// The new tab's ID.
192        tab_id: u32,
193        /// The window containing the new tab.
194        window_id: u32,
195        /// Zero-based position of the tab within its window.
196        index: u32,
197        /// The URL loaded in the tab at creation time (may be empty or `"about:blank"`).
198        url: String,
199        /// The tab's title at creation time (often empty).
200        title: String,
201    },
202    /// A tab was closed.
203    TabClosed {
204        /// The ID of the closed tab.
205        tab_id: u32,
206        /// The window that contained the tab.
207        window_id: u32,
208        /// Whether the tab was closed because its parent window was also closing.
209        is_window_closing: bool,
210    },
211    /// A tab started loading a new URL.
212    TabNavigated {
213        /// The ID of the navigating tab.
214        tab_id: u32,
215        /// The window containing the tab.
216        window_id: u32,
217        /// The new URL.
218        url: String,
219    },
220    /// A tab's title changed.
221    TabTitleChanged {
222        /// The ID of the tab.
223        tab_id: u32,
224        /// The window containing the tab.
225        window_id: u32,
226        /// The new title.
227        title: String,
228    },
229    /// A tab's loading status changed (e.g. from `loading` to `complete`).
230    TabStatusChanged {
231        /// The ID of the tab.
232        tab_id: u32,
233        /// The window containing the tab.
234        window_id: u32,
235        /// The new loading status.
236        status: TabStatus,
237    },
238}
239
240/// A command sent from the CLI to the mediator, and forwarded to the extension.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(tag = "type")]
243pub enum CliCommand {
244    /// Retrieve information about the connected browser instance.
245    GetBrowserInfo,
246    /// List all open windows with their tab summaries.
247    ListWindows,
248    /// Open a new browser window.
249    OpenWindow,
250    /// Close an existing browser window.
251    CloseWindow {
252        /// The ID of the window to close.
253        window_id: u32,
254    },
255    /// Set the title prefix (Firefox `titlePreface`) for a window.
256    SetWindowTitlePrefix {
257        /// The ID of the window whose prefix to set.
258        window_id: u32,
259        /// The prefix string to prepend to the window title.
260        prefix: String,
261    },
262    /// Remove the title prefix from a window, restoring the default title.
263    RemoveWindowTitlePrefix {
264        /// The ID of the window whose prefix to remove.
265        window_id: u32,
266    },
267    /// List all tabs in a window with full details.
268    ListTabs {
269        /// The ID of the window whose tabs to list.
270        window_id: u32,
271    },
272    /// Open a new tab in a window.
273    OpenTab {
274        /// The ID of the window in which to open the tab.
275        window_id: u32,
276        /// If set, the new tab will be inserted immediately before the tab with this ID.
277        insert_before_tab_id: Option<u32>,
278        /// If set, the new tab will be inserted immediately after the tab with this ID.
279        insert_after_tab_id: Option<u32>,
280        /// The URL to load in the new tab, or the browser's default new-tab page if absent.
281        url: Option<String>,
282        /// If `true`, after the tab finishes loading the extension strips any `user:password@`
283        /// credentials from the URL and navigates to the clean URL.
284        ///
285        /// This causes Firefox to cache the credentials (satisfying future auth challenges
286        /// automatically) while leaving the tab displaying the URL without embedded credentials.
287        /// Requires `url` to be set; ignored when `url` is absent.
288        #[serde(default)]
289        strip_credentials: bool,
290    },
291    /// Activate a tab, making it the focused tab in its window.
292    ActivateTab {
293        /// The ID of the tab to activate.
294        tab_id: u32,
295    },
296    /// Navigate an existing tab to a new URL.
297    NavigateTab {
298        /// The ID of the tab to navigate.
299        tab_id: u32,
300        /// The URL to load in the tab.
301        url: String,
302    },
303    /// Close a tab.
304    CloseTab {
305        /// The ID of the tab to close.
306        tab_id: u32,
307    },
308    /// Pin a tab.
309    PinTab {
310        /// The ID of the tab to pin.
311        tab_id: u32,
312    },
313    /// Unpin a tab.
314    UnpinTab {
315        /// The ID of the tab to unpin.
316        tab_id: u32,
317    },
318    /// Warm up a discarded tab, loading its content into memory without activating it.
319    WarmupTab {
320        /// The ID of the tab to warm up.
321        tab_id: u32,
322    },
323    /// Mute a tab, suppressing any audio it produces.
324    MuteTab {
325        /// The ID of the tab to mute.
326        tab_id: u32,
327    },
328    /// Unmute a tab, allowing it to produce audio again.
329    UnmuteTab {
330        /// The ID of the tab to unmute.
331        tab_id: u32,
332    },
333    /// Move a tab to a new position within its window.
334    MoveTab {
335        /// The ID of the tab to move.
336        tab_id: u32,
337        /// The new zero-based index for the tab within its window.
338        new_index: u32,
339    },
340    /// Navigate backward in a tab's session history.
341    ///
342    /// Returns a [`CliResult::Tab`] with the details of the page navigated to,
343    /// or the current tab state if the history boundary was already reached.
344    GoBack {
345        /// The ID of the tab to navigate.
346        tab_id: u32,
347        /// Number of steps to go back (default 1).
348        steps: u32,
349    },
350    /// Navigate forward in a tab's session history.
351    ///
352    /// Returns a [`CliResult::Tab`] with the details of the page navigated to,
353    /// or the current tab state if the history boundary was already reached.
354    GoForward {
355        /// The ID of the tab to navigate.
356        tab_id: u32,
357        /// Number of steps to go forward (default 1).
358        steps: u32,
359    },
360    /// Subscribe to a live stream of browser events.
361    ///
362    /// After sending this command the mediator streams [`BrowserEvent`] objects as
363    /// newline-delimited JSON on the same connection until the client disconnects.
364    /// No [`CliResponse`] is sent; events arrive directly as [`BrowserEvent`] JSON.
365    SubscribeEvents,
366}
367
368/// A request sent from the CLI to the mediator.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct CliRequest {
371    /// A unique identifier (UUID v4 string) used to correlate requests with responses.
372    pub request_id: String,
373    /// The command to execute.
374    ///
375    /// Flattened so the command's `type` tag and fields appear at the top level of the JSON
376    /// object alongside `request_id`, e.g. `{"request_id":"…","type":"ListWindows"}`.
377    #[serde(flatten)]
378    pub command: CliCommand,
379}
380
381/// The result payload of a successful command.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(tag = "type")]
384pub enum CliResult {
385    /// Browser information returned by `GetBrowserInfo`.
386    BrowserInfo(BrowserInfo),
387    /// Window list returned by `ListWindows`.
388    Windows {
389        /// The list of windows.
390        windows: Vec<WindowSummary>,
391    },
392    /// ID of a newly created window returned by `OpenWindow`.
393    WindowId {
394        /// The new window's ID.
395        window_id: u32,
396    },
397    /// Detailed tab list returned by `ListTabs`.
398    Tabs {
399        /// The list of tabs.
400        tabs: Vec<TabDetails>,
401    },
402    /// Details of a newly created or moved tab.
403    Tab(TabDetails),
404    /// Returned by commands that have no meaningful output.
405    Unit,
406}
407
408/// The outcome of a command: either a successful result or an error message.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(tag = "status", content = "data", rename_all = "lowercase")]
411pub enum CliOutcome {
412    /// The command succeeded.
413    Ok(CliResult),
414    /// The command failed with this error message.
415    Err(String),
416}
417
418/// A response sent from the mediator to the CLI.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct CliResponse {
421    /// The request ID from the corresponding [`CliRequest`].
422    pub request_id: String,
423    /// The outcome of the command.
424    pub outcome: CliOutcome,
425}
426
427/// Initial hello message sent from the Firefox extension to the mediator upon connection.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct ExtensionHello {
430    /// The name of the browser (e.g. "Firefox").
431    pub browser_name: String,
432    /// The browser vendor (e.g. "Mozilla").
433    ///
434    /// `None` on browsers that do not implement `browser.runtime.getBrowserInfo()`.
435    #[serde(default)]
436    pub browser_vendor: Option<String>,
437    /// The browser version string (e.g. "120.0").
438    pub browser_version: String,
439}
440
441/// A message received by the mediator from the Firefox extension via native messaging.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(tag = "message_type")]
444pub enum ExtensionMessage {
445    /// Sent once by the extension when it connects, providing browser identity information.
446    Hello(ExtensionHello),
447    /// A response to a previously forwarded [`CliRequest`].
448    Response(CliResponse),
449    /// An unsolicited browser event pushed by the extension.
450    Event {
451        /// The browser event payload.
452        event: BrowserEvent,
453    },
454}
455
456impl std::fmt::Display for WindowState {
457    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458        match self {
459            Self::Normal => write!(f, "normal"),
460            Self::Minimized => write!(f, "minimized"),
461            Self::Maximized => write!(f, "maximized"),
462            Self::Fullscreen => write!(f, "fullscreen"),
463        }
464    }
465}
466
467impl std::fmt::Display for TabStatus {
468    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        match self {
470            Self::Loading => write!(f, "loading"),
471            Self::Complete => write!(f, "complete"),
472        }
473    }
474}
475
476#[cfg(test)]
477mod test {
478    use super::{CliCommand, CliOutcome, CliRequest, CliResponse, CliResult, ExtensionMessage};
479
480    /// Verify that a `ListWindows` request round-trips through JSON correctly.
481    #[test]
482    #[expect(
483        clippy::expect_used,
484        reason = "panicking on unexpected failure is acceptable in tests"
485    )]
486    fn cli_request_list_windows_round_trip() {
487        let request = CliRequest {
488            request_id: "test-id-1".to_owned(),
489            command: CliCommand::ListWindows,
490        };
491        let json = serde_json::to_string(&request)
492            .expect("serialization should not fail for well-formed CliRequest");
493        let decoded: CliRequest = serde_json::from_str(&json)
494            .expect("deserialization should not fail for valid CliRequest JSON");
495        pretty_assertions::assert_eq!(request, decoded);
496    }
497
498    /// Verify that an `Ok(Unit)` response round-trips through JSON correctly.
499    #[test]
500    #[expect(
501        clippy::expect_used,
502        reason = "panicking on unexpected failure is acceptable in tests"
503    )]
504    fn cli_response_ok_unit_round_trip() {
505        let response = CliResponse {
506            request_id: "test-id-2".to_owned(),
507            outcome: CliOutcome::Ok(CliResult::Unit),
508        };
509        let json = serde_json::to_string(&response)
510            .expect("serialization should not fail for well-formed CliResponse");
511        let decoded: CliResponse = serde_json::from_str(&json)
512            .expect("deserialization should not fail for valid CliResponse JSON");
513        pretty_assertions::assert_eq!(response, decoded);
514    }
515
516    /// Verify that an `ExtensionMessage::Hello` round-trips through JSON correctly.
517    #[test]
518    #[expect(
519        clippy::expect_used,
520        reason = "panicking on unexpected failure is acceptable in tests"
521    )]
522    fn extension_hello_round_trip() {
523        let msg = ExtensionMessage::Hello(super::ExtensionHello {
524            browser_name: "Firefox".to_owned(),
525            browser_vendor: Some("Mozilla".to_owned()),
526            browser_version: "120.0".to_owned(),
527        });
528        let json = serde_json::to_string(&msg)
529            .expect("serialization should not fail for well-formed ExtensionMessage::Hello");
530        let decoded: ExtensionMessage = serde_json::from_str(&json)
531            .expect("deserialization should not fail for valid ExtensionMessage JSON");
532        pretty_assertions::assert_eq!(msg, decoded);
533    }
534}