use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserInfo {
pub browser_name: String,
#[serde(default)]
pub browser_vendor: Option<String>,
pub browser_version: String,
pub pid: u32,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WindowState {
Normal,
Minimized,
Maximized,
Fullscreen,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabSummary {
pub index: u32,
pub title: String,
pub url: String,
pub is_active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowSummary {
pub id: u32,
pub title: String,
pub title_prefix: Option<String>,
pub is_focused: bool,
pub state: WindowState,
pub tabs: Vec<TabSummary>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TabStatus {
Loading,
Complete,
}
#[expect(
clippy::struct_excessive_bools,
reason = "TabDetails mirrors the Firefox tabs.Tab API, which exposes each state as a separate boolean property"
)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabDetails {
pub id: u32,
pub index: u32,
pub window_id: u32,
pub title: String,
pub url: String,
pub is_active: bool,
pub is_pinned: bool,
pub is_discarded: bool,
pub is_audible: bool,
pub is_muted: bool,
pub status: TabStatus,
#[serde(default)]
pub has_attention: bool,
#[serde(default)]
pub is_awaiting_auth: bool,
#[serde(default)]
pub is_in_reader_mode: bool,
pub incognito: bool,
#[serde(default)]
pub history_length: u32,
#[serde(default)]
pub history_steps_back: Option<u32>,
#[serde(default)]
pub history_steps_forward: Option<u32>,
#[serde(default)]
pub history_hidden_count: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum BrowserEvent {
WindowOpened {
window_id: u32,
title: String,
},
WindowClosed {
window_id: u32,
},
TabActivated {
window_id: u32,
tab_id: u32,
#[serde(default)]
previous_tab_id: Option<u32>,
},
TabOpened {
tab_id: u32,
window_id: u32,
index: u32,
url: String,
title: String,
},
TabClosed {
tab_id: u32,
window_id: u32,
is_window_closing: bool,
},
TabNavigated {
tab_id: u32,
window_id: u32,
url: String,
},
TabTitleChanged {
tab_id: u32,
window_id: u32,
title: String,
},
TabStatusChanged {
tab_id: u32,
window_id: u32,
status: TabStatus,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliCommand {
GetBrowserInfo,
ListWindows,
OpenWindow,
CloseWindow {
window_id: u32,
},
SetWindowTitlePrefix {
window_id: u32,
prefix: String,
},
RemoveWindowTitlePrefix {
window_id: u32,
},
ListTabs {
window_id: u32,
},
OpenTab {
window_id: u32,
insert_before_tab_id: Option<u32>,
insert_after_tab_id: Option<u32>,
url: Option<String>,
#[serde(default)]
strip_credentials: bool,
},
ActivateTab {
tab_id: u32,
},
NavigateTab {
tab_id: u32,
url: String,
},
CloseTab {
tab_id: u32,
},
PinTab {
tab_id: u32,
},
UnpinTab {
tab_id: u32,
},
WarmupTab {
tab_id: u32,
},
MuteTab {
tab_id: u32,
},
UnmuteTab {
tab_id: u32,
},
MoveTab {
tab_id: u32,
new_index: u32,
},
GoBack {
tab_id: u32,
steps: u32,
},
GoForward {
tab_id: u32,
steps: u32,
},
SubscribeEvents,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliRequest {
pub request_id: String,
#[serde(flatten)]
pub command: CliCommand,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliResult {
BrowserInfo(BrowserInfo),
Windows {
windows: Vec<WindowSummary>,
},
WindowId {
window_id: u32,
},
Tabs {
tabs: Vec<TabDetails>,
},
Tab(TabDetails),
Unit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", content = "data", rename_all = "lowercase")]
pub enum CliOutcome {
Ok(CliResult),
Err(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliResponse {
pub request_id: String,
pub outcome: CliOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtensionHello {
pub browser_name: String,
#[serde(default)]
pub browser_vendor: Option<String>,
pub browser_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "message_type")]
pub enum ExtensionMessage {
Hello(ExtensionHello),
Response(CliResponse),
Event {
event: BrowserEvent,
},
}
impl std::fmt::Display for WindowState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Normal => write!(f, "normal"),
Self::Minimized => write!(f, "minimized"),
Self::Maximized => write!(f, "maximized"),
Self::Fullscreen => write!(f, "fullscreen"),
}
}
}
impl std::fmt::Display for TabStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Loading => write!(f, "loading"),
Self::Complete => write!(f, "complete"),
}
}
}
#[cfg(test)]
mod test {
use super::{CliCommand, CliOutcome, CliRequest, CliResponse, CliResult, ExtensionMessage};
#[test]
#[expect(
clippy::expect_used,
reason = "panicking on unexpected failure is acceptable in tests"
)]
fn cli_request_list_windows_round_trip() {
let request = CliRequest {
request_id: "test-id-1".to_owned(),
command: CliCommand::ListWindows,
};
let json = serde_json::to_string(&request)
.expect("serialization should not fail for well-formed CliRequest");
let decoded: CliRequest = serde_json::from_str(&json)
.expect("deserialization should not fail for valid CliRequest JSON");
pretty_assertions::assert_eq!(request, decoded);
}
#[test]
#[expect(
clippy::expect_used,
reason = "panicking on unexpected failure is acceptable in tests"
)]
fn cli_response_ok_unit_round_trip() {
let response = CliResponse {
request_id: "test-id-2".to_owned(),
outcome: CliOutcome::Ok(CliResult::Unit),
};
let json = serde_json::to_string(&response)
.expect("serialization should not fail for well-formed CliResponse");
let decoded: CliResponse = serde_json::from_str(&json)
.expect("deserialization should not fail for valid CliResponse JSON");
pretty_assertions::assert_eq!(response, decoded);
}
#[test]
#[expect(
clippy::expect_used,
reason = "panicking on unexpected failure is acceptable in tests"
)]
fn extension_hello_round_trip() {
let msg = ExtensionMessage::Hello(super::ExtensionHello {
browser_name: "Firefox".to_owned(),
browser_vendor: Some("Mozilla".to_owned()),
browser_version: "120.0".to_owned(),
});
let json = serde_json::to_string(&msg)
.expect("serialization should not fail for well-formed ExtensionMessage::Hello");
let decoded: ExtensionMessage = serde_json::from_str(&json)
.expect("deserialization should not fail for valid ExtensionMessage JSON");
pretty_assertions::assert_eq!(msg, decoded);
}
}