use std::path::PathBuf;
pub mod ico;
mod osc;
#[cfg(target_os = "linux")]
mod x11;
#[cfg(all(test, target_os = "linux"))]
mod tests_support;
#[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,
},
}
pub fn icon_support(scope: IconScope) -> IconSupport {
imp::icon_support(scope)
}
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 => imp::set_icon(scope, source),
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(windows)]
mod imp {
use super::{IconError, IconScope, IconSource, IconSupport, StockIcon};
use std::os::windows::ffi::OsStrExt as _;
use winapi::shared::minwindef::{BOOL, DWORD, FALSE, LPARAM, TRUE};
use winapi::shared::windef::{HICON, HWND};
use winapi::um::wincon::GetConsoleWindow;
use winapi::um::winuser::{
CreateIconFromResourceEx, EnumWindows, GetClassNameW, GetWindowThreadProcessId, LoadIconW,
LoadImageW, SendMessageW, IDI_APPLICATION, IDI_ERROR, IDI_INFORMATION, IDI_SHIELD,
IDI_WARNING, IMAGE_ICON, LR_DEFAULTSIZE, LR_LOADFROMFILE, WM_SETICON,
};
const ICON_SMALL: usize = 0;
const ICON_BIG: usize = 1;
const CONHOST_CLASS: &str = "ConsoleWindowClass";
fn console_window() -> Option<HWND> {
let hwnd = unsafe { GetConsoleWindow() };
(!hwnd.is_null()).then_some(hwnd)
}
fn class_name(hwnd: HWND) -> String {
let mut buffer = [0u16; 256];
let len = unsafe { GetClassNameW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) };
if len <= 0 {
return String::new();
}
String::from_utf16_lossy(&buffer[..len as usize])
}
fn window_for(scope: IconScope) -> Option<HWND> {
match scope {
IconScope::Host => console_window(),
IconScope::Child { pid } => console_window_of_pid(pid),
}
}
fn console_window_of_pid(pid: u32) -> Option<HWND> {
struct Search {
pid: u32,
found: HWND,
}
unsafe extern "system" fn visit(hwnd: HWND, lparam: LPARAM) -> BOOL {
let search = &mut *(lparam as *mut Search);
let mut owner: DWORD = 0;
GetWindowThreadProcessId(hwnd, &mut owner);
if owner == search.pid && class_name(hwnd) == CONHOST_CLASS {
search.found = hwnd;
return FALSE; }
TRUE
}
let mut search = Search {
pid,
found: std::ptr::null_mut(),
};
unsafe { EnumWindows(Some(visit), &mut search as *mut Search as LPARAM) };
(!search.found.is_null()).then_some(search.found)
}
pub(super) fn icon_support(scope: IconScope) -> IconSupport {
if let IconScope::Child { pid } = scope {
return match console_window_of_pid(pid) {
Some(_) => IconSupport::Available,
None => IconSupport::Unsupported {
reason: "that process has no console window of its own (it may share this one, have been created without a window, or have exited)",
},
};
}
if std::env::var_os("WT_SESSION").is_some() {
return IconSupport::Degraded {
reason: "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",
};
}
let Some(hwnd) = console_window() else {
return IconSupport::Unsupported {
reason: "this process has no console window (detached, or output is redirected \
from a windowless host)",
};
};
if class_name(hwnd) == CONHOST_CLASS {
return IconSupport::Available;
}
IconSupport::Degraded {
reason: "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",
}
}
fn load_from_path(path: &std::path::Path) -> Result<HICON, IconError> {
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
wide.push(0);
let icon = unsafe {
LoadImageW(
std::ptr::null_mut(),
wide.as_ptr(),
IMAGE_ICON,
0,
0,
LR_LOADFROMFILE | LR_DEFAULTSIZE,
)
} as HICON;
if icon.is_null() {
return Err(IconError::Load {
path: path.to_path_buf(),
source: std::io::Error::last_os_error(),
});
}
Ok(icon)
}
fn load_from_bytes(bytes: &[u8]) -> Result<HICON, IconError> {
let span = super::ico::best_image(bytes).map_err(IconError::Decode)?;
let image = &bytes[span.offset..span.offset + span.len];
const ICON_RESOURCE_VERSION: DWORD = 0x0003_0000;
let icon = unsafe {
CreateIconFromResourceEx(
image.as_ptr() as *mut u8,
image.len() as DWORD,
TRUE,
ICON_RESOURCE_VERSION,
0,
0,
LR_DEFAULTSIZE,
)
};
if icon.is_null() {
return Err(IconError::Apply(std::io::Error::last_os_error()));
}
Ok(icon)
}
pub(super) fn load_stock(stock: StockIcon) -> Result<HICON, IconError> {
let name = match stock {
StockIcon::Application => IDI_APPLICATION,
StockIcon::Warning => IDI_WARNING,
StockIcon::Error => IDI_ERROR,
StockIcon::Information => IDI_INFORMATION,
StockIcon::Shield => IDI_SHIELD,
};
let icon = unsafe { LoadIconW(std::ptr::null_mut(), name) };
if icon.is_null() {
return Err(IconError::Apply(std::io::Error::last_os_error()));
}
Ok(icon)
}
pub(super) fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
let hwnd = window_for(scope).ok_or(IconError::Unsupported {
reason: "the console window disappeared between the support probe and the call",
})?;
let icon = match source {
IconSource::Path(path) => load_from_path(path)?,
IconSource::Bytes(bytes) => load_from_bytes(bytes)?,
IconSource::Stock(stock) => load_stock(*stock)?,
};
unsafe {
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, icon as isize);
SendMessageW(hwnd, WM_SETICON, ICON_BIG, icon as isize);
}
Ok(())
}
}
#[cfg(not(windows))]
mod imp {
use super::{IconError, IconScope, IconSource, IconSupport};
pub(super) fn icon_support(_scope: IconScope) -> IconSupport {
if cfg!(target_os = "macos") {
return IconSupport::Unsupported {
reason: "on macOS the window belongs to Terminal.app or iTerm2, not to this process; set the icon on the terminal application's own bundle",
};
}
#[cfg(target_os = "linux")]
{
super::x11::support(_scope)
}
#[cfg(not(target_os = "linux"))]
{
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
return IconSupport::Unsupported {
reason: "Wayland compositors do not let a client change another window's icon; set it in the terminal emulator's .desktop file",
};
}
IconSupport::Unsupported {
reason: "no window-icon backend exists for this platform",
}
}
}
pub(super) fn set_icon(_scope: IconScope, _source: &IconSource) -> Result<(), IconError> {
#[cfg(target_os = "linux")]
{
super::x11::set_icon(_scope, _source)
}
#[cfg(not(target_os = "linux"))]
{
Err(IconError::Unsupported {
reason: "no window-icon backend exists for this platform",
})
}
}
}
#[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]
#[cfg(windows)]
fn windows_terminal_is_detected_by_env_and_names_its_remedy() {
let previous = std::env::var_os("WT_SESSION");
unsafe { std::env::set_var("WT_SESSION", "test-session") };
let support = host_icon_support();
match previous {
Some(value) => unsafe { std::env::set_var("WT_SESSION", value) },
None => unsafe { std::env::remove_var("WT_SESSION") },
}
match support {
IconSupport::Degraded { reason } => {
assert!(
reason.contains("profile"),
"WT's verdict must point at the profile icon field; got {reason:?}"
);
}
other => panic!("WT_SESSION must yield Degraded, got {other:?}"),
}
}
#[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}"
);
}
#[cfg(windows)]
#[test]
fn the_os_supplies_every_stock_icon() {
for stock in [
StockIcon::Application,
StockIcon::Warning,
StockIcon::Error,
StockIcon::Information,
StockIcon::Shield,
] {
let icon =
imp::load_stock(stock).unwrap_or_else(|e| panic!("the OS declined {stock:?}: {e}"));
assert!(!icon.is_null(), "{stock:?} produced a null icon");
}
}
#[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"
);
}
#[cfg(windows)]
#[test]
fn a_childless_pid_reason_names_the_console_window() {
let support = icon_support(IconScope::Child { pid: 0 });
let reason = support.reason().expect("must explain itself");
assert!(
reason.contains("console window"),
"the reason should name what is missing: {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}"
);
}
}