use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum InvalidWindowId {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WindowId(u32);
impl WindowId {
#[must_use]
pub const fn as_u32(self) -> u32 {
self.0
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<u32> for WindowId {
type Error = InvalidWindowId;
fn try_from(v: u32) -> Result<Self, Self::Error> {
Ok(Self(v))
}
}
impl std::fmt::Display for WindowId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for WindowId {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u32>().map(Self)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum InvalidTabId {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TabId(u32);
impl TabId {
#[must_use]
pub const fn as_u32(self) -> u32 {
self.0
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<u32> for TabId {
type Error = InvalidTabId;
fn try_from(v: u32) -> Result<Self, Self::Error> {
Ok(Self(v))
}
}
impl std::fmt::Display for TabId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for TabId {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u32>().map(Self)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum InvalidDownloadId {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DownloadId(u32);
impl DownloadId {
#[must_use]
pub const fn as_u32(self) -> u32 {
self.0
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<u32> for DownloadId {
type Error = InvalidDownloadId;
fn try_from(v: u32) -> Result<Self, Self::Error> {
Ok(Self(v))
}
}
impl std::fmt::Display for DownloadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for DownloadId {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u32>().map(Self)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum InvalidCookieStoreId {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CookieStoreId(String);
impl CookieStoreId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<String> for CookieStoreId {
type Error = InvalidCookieStoreId;
fn try_from(v: String) -> Result<Self, Self::Error> {
Ok(Self(v))
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<&str> for CookieStoreId {
type Error = InvalidCookieStoreId;
fn try_from(v: &str) -> Result<Self, Self::Error> {
Ok(Self(v.to_owned()))
}
}
impl std::fmt::Display for CookieStoreId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for CookieStoreId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.to_owned()))
}
}
impl AsRef<str> for CookieStoreId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum InvalidTabGroupId {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TabGroupId(u32);
impl TabGroupId {
#[must_use]
pub const fn as_u32(self) -> u32 {
self.0
}
}
#[expect(
clippy::infallible_try_from,
reason = "error type is intentionally empty now but #[non_exhaustive] to allow adding validation later without a semver break"
)]
impl TryFrom<u32> for TabGroupId {
type Error = InvalidTabGroupId;
fn try_from(v: u32) -> Result<Self, Self::Error> {
Ok(Self(v))
}
}
impl std::fmt::Display for TabGroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for TabGroupId {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u32>().map(Self)
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Password(Zeroizing<String>);
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Password([REDACTED])")
}
}
impl PartialEq for Password {
fn eq(&self, other: &Self) -> bool {
*self.0 == *other.0
}
}
impl Eq for Password {}
impl From<String> for Password {
fn from(s: String) -> Self {
Self(Zeroizing::new(s))
}
}
impl From<&str> for Password {
fn from(s: &str) -> Self {
Self(Zeroizing::new(s.to_owned()))
}
}
impl std::ops::Deref for Password {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
mod neg1_as_none {
use serde::{Deserialize as _, Deserializer, Serializer};
#[expect(clippy::ref_option, reason = "signature required by serde(with)")]
pub(crate) fn serialize<S: Serializer>(value: &Option<u64>, ser: S) -> Result<S::Ok, S::Error> {
match *value {
Some(v) => ser.serialize_i64(i64::try_from(v).unwrap_or(i64::MAX)),
None => ser.serialize_i64(-1),
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<u64>, D::Error> {
let v = i64::deserialize(de)?;
if v < 0 {
Ok(None)
} else {
Ok(Some(u64::try_from(v).unwrap_or(u64::MAX)))
}
}
}
#[non_exhaustive]
#[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>,
}
impl BrowserInfo {
#[must_use]
pub const fn new(
browser_name: String,
browser_vendor: Option<String>,
browser_version: String,
pid: u32,
profile_id: Option<String>,
) -> Self {
Self {
browser_name,
browser_vendor,
browser_version,
pid,
profile_id,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WindowState {
Normal,
Minimized,
Maximized,
Fullscreen,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WindowType {
Normal,
Popup,
Panel,
Devtools,
}
impl std::fmt::Display for WindowType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Normal => write!(f, "normal"),
Self::Popup => write!(f, "popup"),
Self::Panel => write!(f, "panel"),
Self::Devtools => write!(f, "devtools"),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TabGroupColor {
Grey,
Blue,
Red,
Yellow,
Green,
Pink,
Purple,
Cyan,
Orange,
}
impl std::fmt::Display for TabGroupColor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Grey => write!(f, "grey"),
Self::Blue => write!(f, "blue"),
Self::Red => write!(f, "red"),
Self::Yellow => write!(f, "yellow"),
Self::Green => write!(f, "green"),
Self::Pink => write!(f, "pink"),
Self::Purple => write!(f, "purple"),
Self::Cyan => write!(f, "cyan"),
Self::Orange => write!(f, "orange"),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabGroupInfo {
pub id: TabGroupId,
pub title: String,
pub color: TabGroupColor,
pub collapsed: bool,
pub window_id: WindowId,
}
impl TabGroupInfo {
#[must_use]
pub const fn new(
id: TabGroupId,
title: String,
color: TabGroupColor,
collapsed: bool,
window_id: WindowId,
) -> Self {
Self {
id,
title,
color,
collapsed,
window_id,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabSummary {
pub id: TabId,
pub index: u32,
pub title: String,
pub url: String,
pub is_active: bool,
#[serde(default)]
pub cookie_store_id: Option<CookieStoreId>,
#[serde(default)]
pub container_name: Option<String>,
#[serde(default)]
pub incognito: bool,
}
impl TabSummary {
#[expect(
clippy::too_many_arguments,
reason = "mirrors the browser's tabs.Tab API fields"
)]
#[must_use]
pub const fn new(
id: TabId,
index: u32,
title: String,
url: String,
is_active: bool,
cookie_store_id: Option<CookieStoreId>,
container_name: Option<String>,
incognito: bool,
) -> Self {
Self {
id,
index,
title,
url,
is_active,
cookie_store_id,
container_name,
incognito,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowSummary {
pub id: WindowId,
pub title: String,
pub title_prefix: Option<String>,
pub is_focused: bool,
pub is_last_focused: bool,
pub state: WindowState,
#[serde(default)]
pub window_type: Option<WindowType>,
#[serde(default)]
pub incognito: bool,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
#[serde(default)]
pub left: Option<i32>,
#[serde(default)]
pub top: Option<i32>,
pub tabs: Vec<TabSummary>,
}
impl WindowSummary {
#[expect(
clippy::too_many_arguments,
reason = "mirrors the browser's windows.Window API fields"
)]
#[must_use]
pub const fn new(
id: WindowId,
title: String,
title_prefix: Option<String>,
is_focused: bool,
is_last_focused: bool,
state: WindowState,
window_type: Option<WindowType>,
incognito: bool,
width: Option<u32>,
height: Option<u32>,
left: Option<i32>,
top: Option<i32>,
tabs: Vec<TabSummary>,
) -> Self {
Self {
id,
title,
title_prefix,
is_focused,
is_last_focused,
state,
window_type,
incognito,
width,
height,
left,
top,
tabs,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TabStatus {
Loading,
Complete,
Unloaded,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DownloadState {
InProgress,
Complete,
Interrupted,
}
impl std::fmt::Display for DownloadState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InProgress => write!(f, "in_progress"),
Self::Complete => write!(f, "complete"),
Self::Interrupted => write!(f, "interrupted"),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilenameConflictAction {
Uniquify,
Overwrite,
Prompt,
}
impl std::fmt::Display for FilenameConflictAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Uniquify => write!(f, "uniquify"),
Self::Overwrite => write!(f, "overwrite"),
Self::Prompt => write!(f, "prompt"),
}
}
}
#[non_exhaustive]
#[expect(
clippy::struct_excessive_bools,
reason = "DownloadItem mirrors the browser's DownloadItem API, which exposes each state as a separate boolean property"
)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DownloadItem {
pub id: DownloadId,
pub url: String,
pub filename: String,
pub state: DownloadState,
pub bytes_received: u64,
#[serde(with = "neg1_as_none")]
pub total_bytes: Option<u64>,
#[serde(with = "neg1_as_none")]
pub file_size: Option<u64>,
#[serde(default)]
pub error: Option<String>,
pub start_time: String,
#[serde(default)]
pub end_time: Option<String>,
pub paused: bool,
pub can_resume: bool,
pub exists: bool,
#[serde(default)]
pub mime: Option<String>,
pub incognito: bool,
#[serde(default)]
pub estimated_end_time: Option<String>,
#[serde(default)]
pub danger: Option<String>,
}
impl DownloadItem {
#[expect(
clippy::too_many_arguments,
reason = "mirrors the browser's DownloadItem API fields"
)]
#[expect(
clippy::fn_params_excessive_bools,
reason = "mirrors the browser's DownloadItem API booleans"
)]
#[must_use]
pub const fn new(
id: DownloadId,
url: String,
filename: String,
state: DownloadState,
bytes_received: u64,
total_bytes: Option<u64>,
file_size: Option<u64>,
error: Option<String>,
start_time: String,
end_time: Option<String>,
paused: bool,
can_resume: bool,
exists: bool,
mime: Option<String>,
incognito: bool,
estimated_end_time: Option<String>,
danger: Option<String>,
) -> Self {
Self {
id,
url,
filename,
state,
bytes_received,
total_bytes,
file_size,
error,
start_time,
end_time,
paused,
can_resume,
exists,
mime,
incognito,
estimated_end_time,
danger,
}
}
}
#[non_exhaustive]
#[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: TabId,
pub index: u32,
pub window_id: WindowId,
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>,
#[serde(default)]
pub cookie_store_id: Option<CookieStoreId>,
#[serde(default)]
pub container_name: Option<String>,
#[serde(default)]
pub opener_tab_id: Option<TabId>,
#[serde(default)]
pub last_accessed: Option<u64>,
#[serde(default)]
pub auto_discardable: Option<bool>,
#[serde(default)]
pub group_id: Option<TabGroupId>,
}
impl TabDetails {
#[expect(
clippy::too_many_arguments,
reason = "mirrors the browser's tabs.Tab API fields"
)]
#[expect(
clippy::fn_params_excessive_bools,
reason = "mirrors the browser's tabs.Tab API booleans"
)]
#[must_use]
pub const fn new(
id: TabId,
index: u32,
window_id: WindowId,
title: String,
url: String,
is_active: bool,
is_pinned: bool,
is_discarded: bool,
is_audible: bool,
is_muted: bool,
status: TabStatus,
has_attention: bool,
is_awaiting_auth: bool,
is_in_reader_mode: bool,
incognito: bool,
history_length: u32,
history_steps_back: Option<u32>,
history_steps_forward: Option<u32>,
history_hidden_count: Option<u32>,
cookie_store_id: Option<CookieStoreId>,
container_name: Option<String>,
opener_tab_id: Option<TabId>,
last_accessed: Option<u64>,
auto_discardable: Option<bool>,
group_id: Option<TabGroupId>,
) -> Self {
Self {
id,
index,
window_id,
title,
url,
is_active,
is_pinned,
is_discarded,
is_audible,
is_muted,
status,
has_attention,
is_awaiting_auth,
is_in_reader_mode,
incognito,
history_length,
history_steps_back,
history_steps_forward,
history_hidden_count,
cookie_store_id,
container_name,
opener_tab_id,
last_accessed,
auto_discardable,
group_id,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContainerInfo {
pub cookie_store_id: CookieStoreId,
pub name: String,
pub color: String,
pub color_code: String,
pub icon: String,
}
impl ContainerInfo {
#[must_use]
pub const fn new(
cookie_store_id: CookieStoreId,
name: String,
color: String,
color_code: String,
icon: String,
) -> Self {
Self {
cookie_store_id,
name,
color,
color_code,
icon,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum BrowserEvent {
WindowOpened {
window_id: WindowId,
title: String,
},
WindowClosed {
window_id: WindowId,
},
TabActivated {
window_id: WindowId,
tab_id: TabId,
#[serde(default)]
previous_tab_id: Option<TabId>,
},
TabOpened {
tab_id: TabId,
window_id: WindowId,
index: u32,
url: String,
title: String,
},
TabClosed {
tab_id: TabId,
window_id: WindowId,
is_window_closing: bool,
},
TabNavigated {
tab_id: TabId,
window_id: WindowId,
url: String,
},
TabTitleChanged {
tab_id: TabId,
window_id: WindowId,
title: String,
},
TabStatusChanged {
tab_id: TabId,
window_id: WindowId,
status: TabStatus,
},
DownloadCreated {
download_id: DownloadId,
url: String,
filename: String,
#[serde(default)]
mime: Option<String>,
},
DownloadChanged {
download_id: DownloadId,
#[serde(default)]
state: Option<DownloadState>,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
error: Option<String>,
},
DownloadErased {
download_id: DownloadId,
},
TabMoved {
tab_id: TabId,
window_id: WindowId,
from_index: u32,
to_index: u32,
},
TabAttached {
tab_id: TabId,
new_window_id: WindowId,
new_index: u32,
},
TabDetached {
tab_id: TabId,
old_window_id: WindowId,
old_index: u32,
},
WindowFocusChanged {
#[serde(default)]
window_id: Option<WindowId>,
},
TabGroupCreated {
group_id: TabGroupId,
window_id: WindowId,
title: String,
color: String,
collapsed: bool,
},
TabGroupUpdated {
group_id: TabGroupId,
window_id: WindowId,
title: String,
color: String,
collapsed: bool,
},
TabGroupRemoved {
group_id: TabGroupId,
window_id: WindowId,
},
ExtensionError {
kind: String,
message: String,
#[serde(default)]
detail: String,
},
EventsLost {
count: u64,
},
}
impl BrowserEvent {
#[must_use]
pub const fn is_download_event(&self) -> bool {
matches!(
self,
Self::DownloadCreated { .. }
| Self::DownloadChanged { .. }
| Self::DownloadErased { .. }
)
}
#[must_use]
pub const fn matches_filter(
&self,
include_windows_tabs: bool,
include_downloads: bool,
) -> bool {
if !include_windows_tabs && !include_downloads {
return true;
}
if self.is_download_event() {
include_downloads
} else {
include_windows_tabs
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliCommand {
GetBrowserInfo,
ListWindows,
OpenWindow {
#[serde(default)]
title_prefix: Option<String>,
#[serde(default)]
incognito: bool,
},
CloseWindow {
window_id: WindowId,
},
SetWindowTitlePrefix {
window_id: WindowId,
prefix: String,
},
RemoveWindowTitlePrefix {
window_id: WindowId,
},
ListTabs {
window_id: WindowId,
},
OpenTab {
window_id: WindowId,
insert_before_tab_id: Option<TabId>,
insert_after_tab_id: Option<TabId>,
url: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
password: Option<Password>,
#[serde(default)]
background: bool,
#[serde(default)]
cookie_store_id: Option<CookieStoreId>,
#[serde(default)]
wait_for_load_timeout_ms: Option<u32>,
},
ActivateTab {
tab_id: TabId,
},
NavigateTab {
tab_id: TabId,
url: String,
},
ReloadTab {
tab_id: TabId,
#[serde(default)]
bypass_cache: bool,
},
CloseTab {
tab_id: TabId,
},
PinTab {
tab_id: TabId,
},
UnpinTab {
tab_id: TabId,
},
ToggleReaderMode {
tab_id: TabId,
},
DiscardTab {
tab_id: TabId,
},
WarmupTab {
tab_id: TabId,
},
MuteTab {
tab_id: TabId,
},
UnmuteTab {
tab_id: TabId,
},
MoveTab {
tab_id: TabId,
new_index: u32,
},
GoBack {
tab_id: TabId,
steps: u32,
},
GoForward {
tab_id: TabId,
steps: u32,
},
SubscribeEvents {
#[serde(default)]
include_windows_tabs: bool,
#[serde(default)]
include_downloads: bool,
},
ListContainers,
ReopenTabInContainer {
tab_id: TabId,
cookie_store_id: CookieStoreId,
},
ListDownloads {
#[serde(default)]
state: Option<DownloadState>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
query: Option<String>,
},
StartDownload {
url: String,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
save_as: bool,
#[serde(default)]
conflict_action: Option<FilenameConflictAction>,
},
CancelDownload {
download_id: DownloadId,
},
PauseDownload {
download_id: DownloadId,
},
ResumeDownload {
download_id: DownloadId,
},
RetryDownload {
download_id: DownloadId,
},
EraseDownload {
download_id: DownloadId,
},
EraseAllDownloads {
#[serde(default)]
state: Option<DownloadState>,
},
ListTabGroups {
#[serde(default)]
window_id: Option<WindowId>,
},
GetTabGroup {
group_id: TabGroupId,
},
UpdateTabGroup {
group_id: TabGroupId,
#[serde(default)]
title: Option<String>,
#[serde(default)]
color: Option<TabGroupColor>,
#[serde(default)]
collapsed: Option<bool>,
},
MoveTabGroup {
group_id: TabGroupId,
index: u32,
#[serde(default)]
window_id: Option<WindowId>,
},
GroupTabs {
tab_ids: Vec<TabId>,
#[serde(default)]
group_id: Option<TabGroupId>,
},
UngroupTabs {
tab_ids: Vec<TabId>,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliRequest {
pub request_id: String,
#[serde(flatten)]
pub command: CliCommand,
}
impl CliRequest {
#[must_use]
pub const fn new(request_id: String, command: CliCommand) -> Self {
Self {
request_id,
command,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliResult {
BrowserInfo(BrowserInfo),
Windows {
windows: Vec<WindowSummary>,
},
WindowId {
window_id: WindowId,
},
Tabs {
tabs: Vec<TabDetails>,
},
Tab(TabDetails),
Containers {
containers: Vec<ContainerInfo>,
},
Downloads {
downloads: Vec<DownloadItem>,
},
DownloadId {
download_id: DownloadId,
},
TabGroups {
tab_groups: Vec<TabGroupInfo>,
},
TabGroup(TabGroupInfo),
Unit,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", content = "data", rename_all = "lowercase")]
pub enum CliOutcome {
Ok(CliResult),
Err(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliResponse {
pub request_id: String,
pub outcome: CliOutcome,
}
impl CliResponse {
#[must_use]
pub const fn new(request_id: String, outcome: CliOutcome) -> Self {
Self {
request_id,
outcome,
}
}
}
#[non_exhaustive]
#[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,
}
impl ExtensionHello {
#[must_use]
pub const fn new(
browser_name: String,
browser_vendor: Option<String>,
browser_version: String,
) -> Self {
Self {
browser_name,
browser_vendor,
browser_version,
}
}
}
#[non_exhaustive]
#[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"),
Self::Unloaded => write!(f, "unloaded"),
}
}
}
#[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);
}
}