use std::path::PathBuf;
use running_process_platform_internal::platform::window_icon as platform_icon;
pub mod ico {
pub use running_process_platform_internal::platform::window_icon::ico::*;
}
mod osc;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IconSource {
Path(PathBuf),
Bytes(Vec<u8>),
Stock(StockIcon),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StockIcon {
Application,
Warning,
Error,
Information,
Shield,
}
impl StockIcon {
pub fn osc_name(self) -> &'static str {
match self {
Self::Application => "application-x-executable",
Self::Warning => "dialog-warning",
Self::Error => "dialog-error",
Self::Information => "dialog-information",
Self::Shield => "security-high",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum IconSupport {
Available,
Degraded {
reason: &'static str,
},
Unsupported {
reason: &'static str,
},
}
impl IconSupport {
pub fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
pub fn is_attemptable(&self) -> bool {
!matches!(self, Self::Unsupported { .. })
}
pub fn reason(&self) -> Option<&'static str> {
match self {
Self::Available => None,
Self::Degraded { reason } | Self::Unsupported { reason } => Some(reason),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum IconError {
#[error("this host cannot accept a window icon: {reason}")]
Unsupported {
reason: &'static str,
},
#[error("cannot load icon from {path}: {source}")]
Load {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the system refused the icon data: {0}")]
Apply(#[source] std::io::Error),
#[error("this host accepts only a stock icon name, not an image file or bytes: {reason}")]
DegradedSourceUnsupported {
reason: &'static str,
},
#[error("supplied icon data is unusable: {0}")]
Decode(ico::IcoError),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IconScope {
Host,
Child {
pid: u32,
},
}
fn platform_scope(scope: IconScope) -> platform_icon::IconScope {
match scope {
IconScope::Host => platform_icon::IconScope::Host,
IconScope::Child { pid } => platform_icon::IconScope::Child { pid },
}
}
fn platform_stock(stock: StockIcon) -> platform_icon::StockIcon {
match stock {
StockIcon::Application => platform_icon::StockIcon::Application,
StockIcon::Warning => platform_icon::StockIcon::Warning,
StockIcon::Error => platform_icon::StockIcon::Error,
StockIcon::Information => platform_icon::StockIcon::Information,
StockIcon::Shield => platform_icon::StockIcon::Shield,
}
}
fn platform_source(source: &IconSource) -> platform_icon::IconSource {
match source {
IconSource::Path(path) => platform_icon::IconSource::Path(path.clone()),
IconSource::Bytes(bytes) => platform_icon::IconSource::Bytes(bytes.clone()),
IconSource::Stock(stock) => platform_icon::IconSource::Stock(platform_stock(*stock)),
}
}
fn degraded_reason(reason: platform_icon::IconDegradedReason) -> &'static str {
match reason {
platform_icon::IconDegradedReason::WindowsTerminal => {
"Windows Terminal owns its window decoration and ignores WM_SETICON. Set the `icon` field on the WT profile for a real image; a stock name can still be sent via OSC 1"
}
platform_icon::IconDegradedReason::NonClassicWindowsHost => {
"the host is not the classic console (conhost). Modern emulators own their window decoration and ignore WM_SETICON; a stock name can still be sent via OSC 1"
}
platform_icon::IconDegradedReason::LinuxNameOnly => {
"WINDOWID is not set, so the terminal's X window cannot be identified; a stock name can still be sent via OSC 1"
}
}
}
fn unsupported_reason(reason: platform_icon::IconUnsupportedReason) -> &'static str {
match reason {
platform_icon::IconUnsupportedReason::ChildHasNoConsole => {
"that process has no console window of its own (it may share this one, have been created without a window, or have exited)"
}
platform_icon::IconUnsupportedReason::NoConsole => {
"this process has no console window (detached, or output is redirected from a windowless host)"
}
platform_icon::IconUnsupportedReason::MacTerminalOwnsWindow => {
"on macOS the window belongs to Terminal.app or iTerm2, not to this process; set the icon on the terminal application's own bundle"
}
platform_icon::IconUnsupportedReason::Wayland => {
"Wayland compositors do not let a client change another window's icon; set it in the terminal emulator's .desktop file"
}
platform_icon::IconUnsupportedReason::NoBackend => {
"no window-icon backend exists for this platform"
}
platform_icon::IconUnsupportedReason::LinuxChildScope => {
"X11 cannot identify another process's terminal window; WINDOWID names only this process's own host"
}
platform_icon::IconUnsupportedReason::LinuxNoDisplay => {
"no display server is attached (no DISPLAY or WAYLAND_DISPLAY), so there is no window to set an icon on"
}
platform_icon::IconUnsupportedReason::TargetDisappeared => {
"the target window disappeared between the support probe and the call"
}
platform_icon::IconUnsupportedReason::UnknownImageFormat => {
"the X11 backend accepts PNG data (or a .ico whose largest image is a PNG)"
}
platform_icon::IconUnsupportedReason::StockNeedsPixels => {
"stock icons are theme names, not images; X11 needs pixels. Pass a PNG, or let the OSC 1 fallback send the name"
}
platform_icon::IconUnsupportedReason::OversizedIcon => {
"icon is larger than 512x512; window managers scale down from far smaller"
}
platform_icon::IconUnsupportedReason::UnsupportedPngColorType => {
"the X11 backend needs an RGB or RGBA PNG; convert palette or grayscale images first"
}
platform_icon::IconUnsupportedReason::UnsupportedPngBitDepth => {
"the X11 backend needs an 8-bit PNG"
}
platform_icon::IconUnsupportedReason::UnsupportedX11VisualDepth => {
"the X11 visual depth cannot represent the requested icon"
}
}
}
fn map_platform_error(error: platform_icon::IconError) -> IconError {
match error {
platform_icon::IconError::Unsupported(reason) => IconError::Unsupported {
reason: unsupported_reason(reason),
},
platform_icon::IconError::Load { path, source } => IconError::Load { path, source },
platform_icon::IconError::Apply(source) => IconError::Apply(source),
platform_icon::IconError::Decode(source) => IconError::Decode(source),
}
}
pub fn icon_support(scope: IconScope) -> IconSupport {
match platform_icon::icon_support(platform_scope(scope)) {
platform_icon::IconSupport::Available => IconSupport::Available,
platform_icon::IconSupport::Degraded(reason) => IconSupport::Degraded {
reason: degraded_reason(reason),
},
platform_icon::IconSupport::Unsupported(reason) => IconSupport::Unsupported {
reason: unsupported_reason(reason),
},
}
}
pub fn host_icon_support() -> IconSupport {
icon_support(IconScope::Host)
}
pub fn set_host_icon(source: &IconSource) -> Result<(), IconError> {
set_icon(IconScope::Host, source)
}
pub fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
set_icon_given(icon_support(scope), scope, source)
}
#[cfg(test)]
fn set_host_icon_given(support: IconSupport, source: &IconSource) -> Result<(), IconError> {
set_icon_given(support, IconScope::Host, source)
}
fn set_icon_given(
support: IconSupport,
scope: IconScope,
source: &IconSource,
) -> Result<(), IconError> {
match support {
IconSupport::Available => {
platform_icon::set_icon(platform_scope(scope), &platform_source(source))
.map_err(map_platform_error)
}
IconSupport::Degraded { reason } => match source {
IconSource::Stock(icon) => osc::emit(icon.osc_name()).map_err(IconError::Apply),
_ => Err(IconError::DegradedSourceUnsupported { reason }),
},
IconSupport::Unsupported { reason } => Err(IconError::Unsupported { reason }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn support_is_reportable_everywhere() {
let support = host_icon_support();
match &support {
IconSupport::Available => assert_eq!(support.reason(), None),
IconSupport::Degraded { reason } | IconSupport::Unsupported { reason } => {
assert!(!reason.is_empty(), "a reduced verdict must explain itself");
assert_eq!(support.reason(), Some(*reason));
}
}
}
#[test]
fn a_degraded_host_is_attemptable_but_not_available() {
let degraded = IconSupport::Degraded {
reason: "name only",
};
assert!(!degraded.is_available());
assert!(degraded.is_attemptable());
assert_eq!(degraded.reason(), Some("name only"));
assert!(IconSupport::Available.is_attemptable());
assert!(!IconSupport::Unsupported { reason: "no" }.is_attemptable());
}
#[test]
fn a_degraded_host_accepts_a_stock_name_and_refuses_an_image() {
let degraded = IconSupport::Degraded {
reason: "name only",
};
let refused = set_host_icon_given(
degraded.clone(),
&IconSource::Path(PathBuf::from("some.ico")),
)
.expect_err("an image must be refused on a name-only host");
match refused {
IconError::DegradedSourceUnsupported { reason } => {
assert_eq!(reason, "name only");
}
other => panic!("expected DegradedSourceUnsupported, got {other:?}"),
}
let unsupported = set_host_icon_given(
IconSupport::Unsupported {
reason: "none at all",
},
&IconSource::Stock(StockIcon::Shield),
)
.expect_err("an unsupported host refuses everything");
assert!(matches!(unsupported, IconError::Unsupported { .. }));
}
#[test]
fn every_stock_icon_maps_to_a_freedesktop_name() {
for icon in [
StockIcon::Application,
StockIcon::Warning,
StockIcon::Error,
StockIcon::Information,
StockIcon::Shield,
] {
let name = icon.osc_name();
assert!(!name.is_empty(), "{icon:?} has no OSC name");
assert!(
name.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"{icon:?} -> {name:?} is not a freedesktop-style name"
);
}
}
#[test]
fn stock_names_are_distinct() {
let names: std::collections::BTreeSet<&str> = [
StockIcon::Application,
StockIcon::Warning,
StockIcon::Error,
StockIcon::Information,
StockIcon::Shield,
]
.into_iter()
.map(StockIcon::osc_name)
.collect();
assert_eq!(names.len(), 5);
}
#[test]
fn availability_and_reason_are_consistent() {
assert!(IconSupport::Available.is_available());
assert!(IconSupport::Available.reason().is_none());
let no = IconSupport::Unsupported { reason: "because" };
assert!(!no.is_available());
assert_eq!(no.reason(), Some("because"));
}
#[test]
fn an_unsupported_host_refuses_instead_of_pretending() {
let error = set_host_icon_given(
IconSupport::Unsupported {
reason: "test verdict",
},
&IconSource::Path("anything.ico".into()),
)
.expect_err("an unsupported host must not report success");
match error {
IconError::Unsupported { reason } => assert_eq!(reason, "test verdict"),
other => panic!("expected Unsupported, got {other}"),
}
}
#[test]
fn refusal_precedes_loading_the_icon() {
let error = set_host_icon_given(
IconSupport::Unsupported { reason: "nope" },
&IconSource::Path("definitely-does-not-exist.ico".into()),
)
.expect_err("must refuse");
assert!(
matches!(error, IconError::Unsupported { .. }),
"a missing file must not mask the unsupported verdict; got {error}"
);
}
#[test]
fn a_process_with_no_console_window_is_unsupported() {
let support = icon_support(IconScope::Child { pid: 0 });
assert!(!support.is_available());
assert!(
!support.reason().expect("must explain itself").is_empty(),
"an unsupported result must carry a usable reason"
);
}
#[test]
fn own_pid_resolves_to_the_host_console_window() {
let host = icon_support(IconScope::Host);
let own = icon_support(IconScope::Child {
pid: std::process::id(),
});
assert_eq!(
host.is_available(),
own.is_available(),
"host scope says {host:?} but our own pid says {own:?}; the pid lookup disagrees with the direct console-window lookup"
);
}
#[test]
fn setting_a_childless_pid_is_an_error() {
let error = set_icon(
IconScope::Child { pid: 0 },
&IconSource::Stock(StockIcon::Warning),
)
.expect_err("a pid with no console cannot take an icon");
assert!(
matches!(error, IconError::Unsupported { .. }),
"expected Unsupported, got {error}"
);
}
#[test]
fn an_implausible_pid_is_unsupported() {
let support = icon_support(IconScope::Child { pid: u32::MAX });
assert!(!support.is_available());
}
#[test]
fn host_scope_agrees_with_the_host_specific_helper() {
assert_eq!(icon_support(IconScope::Host), host_icon_support());
}
#[test]
fn scopes_are_distinguishable() {
assert_ne!(IconScope::Host, IconScope::Child { pid: 1 });
assert_ne!(IconScope::Child { pid: 1 }, IconScope::Child { pid: 2 });
assert_eq!(IconScope::Child { pid: 7 }, IconScope::Child { pid: 7 });
}
#[test]
fn every_stock_icon_is_requestable() {
for stock in [
StockIcon::Application,
StockIcon::Warning,
StockIcon::Error,
StockIcon::Information,
StockIcon::Shield,
] {
let result = set_host_icon_given(IconSupport::Available, &IconSource::Stock(stock));
match result {
Ok(()) => {}
Err(IconError::Unsupported { .. }) => {}
Err(other) => panic!("{stock:?} failed for a reason other than the host: {other}"),
}
}
}
#[test]
fn a_stock_icon_is_never_a_decode_error() {
let result = set_host_icon_given(
IconSupport::Available,
&IconSource::Stock(StockIcon::Warning),
);
if let Err(error) = result {
assert!(
!matches!(error, IconError::Decode(_)),
"a stock icon carries no data to decode, got {error}"
);
}
}
#[test]
fn stock_variants_are_distinguishable() {
assert_ne!(StockIcon::Warning, StockIcon::Error);
assert_ne!(StockIcon::Application, StockIcon::Shield);
assert_eq!(StockIcon::Information, StockIcon::Information);
}
#[test]
fn malformed_icon_bytes_are_refused() {
let result =
set_host_icon_given(IconSupport::Available, &IconSource::Bytes(vec![0xFF; 64]));
let error = result.expect_err("garbage is not an icon");
assert!(
matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
"expected a refusal before the OS was handed anything, got {error}"
);
}
#[test]
fn empty_icon_bytes_are_refused() {
let error = set_host_icon_given(IconSupport::Available, &IconSource::Bytes(Vec::new()))
.expect_err("empty data is not an icon");
assert!(
matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
"got {error}"
);
}
#[test]
fn a_missing_icon_file_never_reports_success() {
let result = set_host_icon_given(
IconSupport::Available,
&IconSource::Path("no-such-icon-file.ico".into()),
);
let error = result.expect_err("a missing file cannot produce a set icon");
assert!(
matches!(
error,
IconError::Load { .. } | IconError::Unsupported { .. }
),
"expected a refusal, got {error}"
);
}
}