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}