running_process/window_icon/mod.rs
1//! Setting the host console/terminal window icon (#577).
2//!
3//! # Capability-reported, never assumed
4//!
5//! Most terminals do not let a running program change their window icon, and
6//! they do not say so — the API call succeeds and nothing happens. Windows
7//! Terminal is the case that matters most: `GetConsoleWindow` returns a real
8//! handle to a hidden pseudo-console window, so `WM_SETICON` succeeds against
9//! a window nobody can see.
10//!
11//! A function that returns `Ok(())` there would be worse than one that fails:
12//! the caller would ship a feature that silently does nothing on the default
13//! terminal of every recent Windows install. So support is *probed* and
14//! reported, and [`set_host_icon`] refuses rather than pretending.
15//!
16//! # What is supported
17//!
18//! Classic Windows console (`conhost.exe`) only, for now. Everything else
19//! reports [`IconSupport::Unsupported`] with a reason. Linux/X11 is a
20//! plausible later addition; macOS Terminal.app and iTerm2, Windows Terminal,
21//! Wayland compositors, and most modern emulators deliberately reserve the
22//! window decoration to themselves, and no in-process API changes that.
23
24use std::path::PathBuf;
25
26pub mod ico;
27mod osc;
28#[cfg(target_os = "linux")]
29mod x11;
30// Gated to match `x11`, its only consumer: the PNG encoder it wraps is a
31// Linux-only dependency, so compiling this elsewhere fails to find `png`.
32#[cfg(all(test, target_os = "linux"))]
33mod tests_support;
34
35/// Where an icon comes from.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum IconSource {
38 /// Icon file on disk: `.ico` on Windows.
39 Path(PathBuf),
40 /// Raw `.ico` bytes, typically embedded in the binary with
41 /// `include_bytes!` so an application ships its own icon without needing
42 /// a file to exist at runtime.
43 Bytes(Vec<u8>),
44 /// A stock icon the OS already ships, named symbolically.
45 ///
46 /// Nothing to bundle and nothing to decode, which suits the cases these
47 /// exist for — marking a console as a warning or an error surface.
48 /// See [`StockIcon`] for the names.
49 Stock(StockIcon),
50}
51
52/// A stock icon provided by the operating system.
53///
54/// A closed set rather than a free-form string. A name the OS does not know
55/// can only fail at runtime, and a caller has no way to discover which names
56/// are valid; an enum makes the answer a compile error instead. The variants
57/// are the ones with a direct equivalent on every platform this could grow
58/// to, so the set stays meaningful rather than becoming Windows constants
59/// wearing generic names.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum StockIcon {
62 /// The application's own default icon.
63 Application,
64 /// Warning: a hazard the user should notice.
65 Warning,
66 /// Error: something has already gone wrong.
67 Error,
68 /// Information: neutral notice.
69 Information,
70 /// Shield: an elevation or security prompt.
71 Shield,
72}
73
74impl StockIcon {
75 /// The symbolic name sent by the OSC 1 fallback.
76 ///
77 /// freedesktop icon-naming-spec names rather than this crate's variant
78 /// spelling: a terminal or window manager that does anything at all with
79 /// OSC 1 looks the name up in the desktop icon theme, so a bespoke name
80 /// would resolve to nothing on every host.
81 pub fn osc_name(self) -> &'static str {
82 match self {
83 Self::Application => "application-x-executable",
84 Self::Warning => "dialog-warning",
85 Self::Error => "dialog-error",
86 Self::Information => "dialog-information",
87 Self::Shield => "security-high",
88 }
89 }
90}
91
92/// Whether this process can set its host window's icon.
93///
94/// # Capability matrix
95///
96/// | Host | Verdict | Backend |
97/// |---|---|---|
98/// | Windows conhost | `Available` | `WM_SETICON` |
99/// | Windows Terminal | `Degraded` | OSC 1 name only; set the profile's `icon` field for a real image |
100/// | Other Windows emulators | `Degraded` | OSC 1 name only |
101/// | Linux X11 | `Degraded` | OSC 1 name only (`_NET_WM_ICON` not yet implemented) |
102/// | Linux Wayland | `Unsupported` | compositors do not let a client set another window's icon |
103/// | macOS | `Unsupported` | the window belongs to Terminal.app / iTerm2, not to this process |
104/// | No terminal | `Unsupported` | nothing to set an icon on |
105///
106/// An out-of-date row here is a documentation regression: callers decide
107/// whether to ship an icon at all based on this table.
108#[derive(Clone, Debug, PartialEq, Eq)]
109#[non_exhaustive]
110pub enum IconSupport {
111 /// The host window accepts an icon.
112 Available,
113 /// The host accepts only a symbolic *name*, not an image.
114 ///
115 /// Reported rather than folded into `Available` because the difference is
116 /// visible to the user: an OSC 1 name may be shown, ignored, or applied
117 /// to something other than the window icon, and a caller told "yes" that
118 /// then sees nothing change cannot tell a failure from a terminal that
119 /// simply does not do icons.
120 Degraded {
121 /// What will actually happen, and why it is less than asked for.
122 reason: &'static str,
123 },
124 /// It does not, and this is why.
125 ///
126 /// The reason is carried so a caller can log something an operator can
127 /// act on, rather than a bare boolean that invites retrying forever.
128 Unsupported {
129 /// Human-readable explanation.
130 reason: &'static str,
131 },
132}
133
134impl IconSupport {
135 /// Whether a real image icon can be set.
136 ///
137 /// False for [`IconSupport::Degraded`]: a caller choosing whether to embed
138 /// and ship an icon file wants to know whether the file will be used, and
139 /// on a degraded host it will not be.
140 pub fn is_available(&self) -> bool {
141 matches!(self, Self::Available)
142 }
143
144 /// Whether an attempt will do *something*, image or not.
145 ///
146 /// True for both [`IconSupport::Available`] and
147 /// [`IconSupport::Degraded`] — the distinction a caller wants when
148 /// deciding whether to bother calling at all, as opposed to whether to
149 /// ship an image.
150 pub fn is_attemptable(&self) -> bool {
151 !matches!(self, Self::Unsupported { .. })
152 }
153
154 /// The reason support is absent or reduced, if it is.
155 pub fn reason(&self) -> Option<&'static str> {
156 match self {
157 Self::Available => None,
158 Self::Degraded { reason } | Self::Unsupported { reason } => Some(reason),
159 }
160 }
161}
162
163/// Why setting an icon failed.
164#[derive(Debug, thiserror::Error)]
165pub enum IconError {
166 /// The host cannot accept an icon at all.
167 ///
168 /// Distinct from an I/O failure: retrying or supplying a different file
169 /// will not help, and the caller should stop asking.
170 #[error("this host cannot accept a window icon: {reason}")]
171 Unsupported {
172 /// Why the host is unsupported.
173 reason: &'static str,
174 },
175 /// The icon source could not be loaded.
176 #[error("cannot load icon from {path}: {source}")]
177 Load {
178 /// Path that failed to load.
179 path: PathBuf,
180 /// Underlying OS error.
181 #[source]
182 source: std::io::Error,
183 },
184 /// The OS refused to build an icon from otherwise well-formed data.
185 ///
186 /// Removed in #720 because nothing constructed it; reinstated here
187 /// because `CreateIconFromResourceEx` can fail on data this crate has
188 /// already validated the shape of — the image itself may still be
189 /// something the OS will not decode.
190 #[error("the system refused the icon data: {0}")]
191 Apply(#[source] std::io::Error),
192 /// The host accepts only a symbolic name, and this source is not one.
193 ///
194 /// Distinct from [`IconError::Unsupported`]: the host *would* accept a
195 /// stock icon, so the remedy is to pass one rather than to give up.
196 #[error("this host accepts only a stock icon name, not an image file or bytes: {reason}")]
197 DegradedSourceUnsupported {
198 /// What the host will and will not accept.
199 reason: &'static str,
200 },
201 /// The supplied bytes are not a usable icon.
202 ///
203 /// Separate from [`IconError::Load`] because the remedy differs: a bad
204 /// path is fixed by pointing somewhere else, malformed bytes by fixing
205 /// what was embedded.
206 #[error("supplied icon data is unusable: {0}")]
207 Decode(ico::IcoError),
208}
209
210/// Which window an icon operation targets.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum IconScope {
213 /// This process's own host console window.
214 Host,
215 /// A child process's console window.
216 ///
217 /// Only meaningful when the child was given its own console
218 /// (`CREATE_NEW_CONSOLE` on Windows). A child that inherited ours shares
219 /// the same window, so targeting it changes this process's icon too —
220 /// that is inherent to sharing a console, not a failure, and
221 /// [`icon_support`] reports it as available because the icon really does
222 /// change.
223 Child {
224 /// Process id of the child.
225 pid: u32,
226 },
227}
228
229/// Whether a window can accept an icon.
230///
231/// Cheap, and safe to call before deciding whether to ship an icon at all.
232pub fn icon_support(scope: IconScope) -> IconSupport {
233 imp::icon_support(scope)
234}
235
236/// Whether this process's host window can accept an icon.
237pub fn host_icon_support() -> IconSupport {
238 icon_support(IconScope::Host)
239}
240
241/// Set the icon on this process's host console window.
242///
243/// Returns [`IconError::Unsupported`] when the host does not accept icons,
244/// rather than succeeding without effect.
245pub fn set_host_icon(source: &IconSource) -> Result<(), IconError> {
246 set_icon(IconScope::Host, source)
247}
248
249/// Set the icon on the window named by `scope`.
250///
251/// Returns [`IconError::Unsupported`] when that window does not accept icons,
252/// rather than succeeding without effect.
253pub fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
254 set_icon_given(icon_support(scope), scope, source)
255}
256
257/// [`set_host_icon`] with the support verdict supplied.
258///
259/// Split out so the refusal path is testable on every platform without
260/// depending on whether the machine running the tests happens to have a
261/// console window. A test that only exercises the refusal when the ambient
262/// host is unsupported silently checks nothing everywhere else.
263#[cfg(test)]
264fn set_host_icon_given(support: IconSupport, source: &IconSource) -> Result<(), IconError> {
265 set_icon_given(support, IconScope::Host, source)
266}
267
268fn set_icon_given(
269 support: IconSupport,
270 scope: IconScope,
271 source: &IconSource,
272) -> Result<(), IconError> {
273 match support {
274 IconSupport::Available => imp::set_icon(scope, source),
275 // Only a stock name has anything to send. A file or a byte blob would
276 // mean inventing a name the caller never chose, and OSC 1 carries a
277 // name rather than an image.
278 IconSupport::Degraded { reason } => match source {
279 IconSource::Stock(icon) => osc::emit(icon.osc_name()).map_err(IconError::Apply),
280 _ => Err(IconError::DegradedSourceUnsupported { reason }),
281 },
282 IconSupport::Unsupported { reason } => Err(IconError::Unsupported { reason }),
283 }
284}
285
286#[cfg(windows)]
287mod imp {
288 use super::{IconError, IconScope, IconSource, IconSupport, StockIcon};
289 use std::os::windows::ffi::OsStrExt as _;
290
291 use winapi::shared::minwindef::{BOOL, DWORD, FALSE, LPARAM, TRUE};
292 use winapi::shared::windef::{HICON, HWND};
293 use winapi::um::wincon::GetConsoleWindow;
294 use winapi::um::winuser::{
295 CreateIconFromResourceEx, EnumWindows, GetClassNameW, GetWindowThreadProcessId, LoadIconW,
296 LoadImageW, SendMessageW, IDI_APPLICATION, IDI_ERROR, IDI_INFORMATION, IDI_SHIELD,
297 IDI_WARNING, IMAGE_ICON, LR_DEFAULTSIZE, LR_LOADFROMFILE, WM_SETICON,
298 };
299
300 /// `wParam` values for `WM_SETICON`.
301 const ICON_SMALL: usize = 0;
302 const ICON_BIG: usize = 1;
303
304 /// Window class of the classic console host.
305 ///
306 /// This is the discriminator that matters. Windows Terminal hosts the
307 /// session in a pseudo-console whose `GetConsoleWindow` handle belongs to
308 /// a hidden window of a different class — `WM_SETICON` against it
309 /// succeeds and changes nothing visible.
310 const CONHOST_CLASS: &str = "ConsoleWindowClass";
311
312 fn console_window() -> Option<HWND> {
313 let hwnd = unsafe { GetConsoleWindow() };
314 (!hwnd.is_null()).then_some(hwnd)
315 }
316
317 fn class_name(hwnd: HWND) -> String {
318 let mut buffer = [0u16; 256];
319 let len = unsafe { GetClassNameW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) };
320 if len <= 0 {
321 return String::new();
322 }
323 String::from_utf16_lossy(&buffer[..len as usize])
324 }
325
326 /// The console window a scope names, if there is one.
327 fn window_for(scope: IconScope) -> Option<HWND> {
328 match scope {
329 IconScope::Host => console_window(),
330 IconScope::Child { pid } => console_window_of_pid(pid),
331 }
332 }
333
334 /// Find the console window owned by `pid`.
335 ///
336 /// A process has at most one console window, so the first match is the
337 /// answer. The class is checked here as well as in the support probe
338 /// because a process can own windows that are not its console.
339 fn console_window_of_pid(pid: u32) -> Option<HWND> {
340 struct Search {
341 pid: u32,
342 found: HWND,
343 }
344
345 unsafe extern "system" fn visit(hwnd: HWND, lparam: LPARAM) -> BOOL {
346 let search = &mut *(lparam as *mut Search);
347 let mut owner: DWORD = 0;
348 GetWindowThreadProcessId(hwnd, &mut owner);
349 if owner == search.pid && class_name(hwnd) == CONHOST_CLASS {
350 search.found = hwnd;
351 return FALSE; // stop: a process has one console window
352 }
353 TRUE
354 }
355
356 let mut search = Search {
357 pid,
358 found: std::ptr::null_mut(),
359 };
360 unsafe { EnumWindows(Some(visit), &mut search as *mut Search as LPARAM) };
361 (!search.found.is_null()).then_some(search.found)
362 }
363
364 pub(super) fn icon_support(scope: IconScope) -> IconSupport {
365 if let IconScope::Child { pid } = scope {
366 return match console_window_of_pid(pid) {
367 Some(_) => IconSupport::Available,
368 // Either the child has no console of its own (it inherited
369 // ours, or was created with CREATE_NO_WINDOW), or it has
370 // already exited. Both mean there is no window to target.
371 None => IconSupport::Unsupported {
372 reason: "that process has no console window of its own (it may share this one, have been created without a window, or have exited)",
373 },
374 };
375 }
376 // Checked before the window class because it yields a remedy the
377 // class check cannot: Windows Terminal *does* support a per-profile
378 // icon, just not one set at runtime. "Set the profile's icon field"
379 // is actionable; "your host owns its decoration" is not.
380 if std::env::var_os("WT_SESSION").is_some() {
381 return IconSupport::Degraded {
382 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",
383 };
384 }
385 let Some(hwnd) = console_window() else {
386 return IconSupport::Unsupported {
387 reason: "this process has no console window (detached, or output is redirected \
388 from a windowless host)",
389 };
390 };
391 if class_name(hwnd) == CONHOST_CLASS {
392 return IconSupport::Available;
393 }
394 IconSupport::Degraded {
395 reason: "the host is not the classic console (conhost). Modern emulators own \
396 their window decoration and ignore WM_SETICON; a stock name can still \
397 be sent via OSC 1",
398 }
399 }
400
401 /// Load an icon from a file, letting the OS pick the best size.
402 fn load_from_path(path: &std::path::Path) -> Result<HICON, IconError> {
403 let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
404 wide.push(0);
405
406 // LR_DEFAULTSIZE picks the system's preferred size from a multi-image
407 // .ico rather than whichever image happens to be first.
408 let icon = unsafe {
409 LoadImageW(
410 std::ptr::null_mut(),
411 wide.as_ptr(),
412 IMAGE_ICON,
413 0,
414 0,
415 LR_LOADFROMFILE | LR_DEFAULTSIZE,
416 )
417 } as HICON;
418 if icon.is_null() {
419 return Err(IconError::Load {
420 path: path.to_path_buf(),
421 source: std::io::Error::last_os_error(),
422 });
423 }
424 Ok(icon)
425 }
426
427 /// Load an icon from `.ico` bytes held in memory.
428 ///
429 /// There is no `LoadImage` equivalent that takes a whole `.ico` from
430 /// memory, so the directory is walked here to find one image and
431 /// `CreateIconFromResourceEx` is given exactly that span. The bytes are
432 /// treated as untrusted: `super::ico::best_image` bounds-checks every
433 /// offset before we hand a length to the OS, which would otherwise read
434 /// whatever follows in our address space.
435 fn load_from_bytes(bytes: &[u8]) -> Result<HICON, IconError> {
436 let span = super::ico::best_image(bytes).map_err(IconError::Decode)?;
437 let image = &bytes[span.offset..span.offset + span.len];
438
439 // 0x00030000 is the icon resource version the API expects.
440 const ICON_RESOURCE_VERSION: DWORD = 0x0003_0000;
441 let icon = unsafe {
442 CreateIconFromResourceEx(
443 image.as_ptr() as *mut u8,
444 image.len() as DWORD,
445 TRUE,
446 ICON_RESOURCE_VERSION,
447 0,
448 0,
449 LR_DEFAULTSIZE,
450 )
451 };
452 if icon.is_null() {
453 return Err(IconError::Apply(std::io::Error::last_os_error()));
454 }
455 Ok(icon)
456 }
457
458 /// Load an icon the OS already provides.
459 ///
460 /// These are shared resources owned by the system, so unlike the file and
461 /// byte paths there is nothing to free and no data to validate — the only
462 /// failure is the OS declining to hand one over.
463 pub(super) fn load_stock(stock: StockIcon) -> Result<HICON, IconError> {
464 let name = match stock {
465 StockIcon::Application => IDI_APPLICATION,
466 StockIcon::Warning => IDI_WARNING,
467 StockIcon::Error => IDI_ERROR,
468 StockIcon::Information => IDI_INFORMATION,
469 StockIcon::Shield => IDI_SHIELD,
470 };
471 // A null hInstance asks for a system icon rather than one from this
472 // module's resources.
473 let icon = unsafe { LoadIconW(std::ptr::null_mut(), name) };
474 if icon.is_null() {
475 return Err(IconError::Apply(std::io::Error::last_os_error()));
476 }
477 Ok(icon)
478 }
479
480 pub(super) fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
481 let hwnd = window_for(scope).ok_or(IconError::Unsupported {
482 reason: "the console window disappeared between the support probe and the call",
483 })?;
484
485 let icon = match source {
486 IconSource::Path(path) => load_from_path(path)?,
487 IconSource::Bytes(bytes) => load_from_bytes(bytes)?,
488 IconSource::Stock(stock) => load_stock(*stock)?,
489 };
490
491 // Both slots: the small icon is the title bar and Alt+Tab, the big one
492 // is the taskbar. Setting only one leaves the other stale, which looks
493 // like a partial failure to a user.
494 unsafe {
495 SendMessageW(hwnd, WM_SETICON, ICON_SMALL, icon as isize);
496 SendMessageW(hwnd, WM_SETICON, ICON_BIG, icon as isize);
497 }
498 Ok(())
499 }
500}
501
502#[cfg(not(windows))]
503mod imp {
504 use super::{IconError, IconScope, IconSource, IconSupport};
505
506 pub(super) fn icon_support(_scope: IconScope) -> IconSupport {
507 // Per-platform verdicts rather than one blanket string. A caller
508 // logging "unsupported" on macOS and on headless Linux is logging two
509 // different problems, and only one of them has a remedy.
510 if cfg!(target_os = "macos") {
511 return IconSupport::Unsupported {
512 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",
513 };
514 }
515 #[cfg(target_os = "linux")]
516 {
517 super::x11::support(_scope)
518 }
519 #[cfg(not(target_os = "linux"))]
520 {
521 if std::env::var_os("WAYLAND_DISPLAY").is_some() {
522 return IconSupport::Unsupported {
523 reason: "Wayland compositors do not let a client change another window's icon; set it in the terminal emulator's .desktop file",
524 };
525 }
526 IconSupport::Unsupported {
527 reason: "no window-icon backend exists for this platform",
528 }
529 }
530 }
531
532 pub(super) fn set_icon(_scope: IconScope, _source: &IconSource) -> Result<(), IconError> {
533 #[cfg(target_os = "linux")]
534 {
535 super::x11::set_icon(_scope, _source)
536 }
537 #[cfg(not(target_os = "linux"))]
538 {
539 Err(IconError::Unsupported {
540 reason: "no window-icon backend exists for this platform",
541 })
542 }
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 /// The probe must answer without panicking wherever it runs — including
551 /// CI, which has no console window at all.
552 #[test]
553 fn support_is_reportable_everywhere() {
554 let support = host_icon_support();
555 // Whichever answer, it must be self-describing: an unsupported result
556 // without a reason would leave a caller with nothing to log.
557 match &support {
558 IconSupport::Available => assert_eq!(support.reason(), None),
559 IconSupport::Degraded { reason } | IconSupport::Unsupported { reason } => {
560 assert!(!reason.is_empty(), "a reduced verdict must explain itself");
561 assert_eq!(support.reason(), Some(*reason));
562 }
563 }
564 }
565
566 /// Windows Terminal must be detected by env, not only by window class.
567 ///
568 /// The class check cannot distinguish WT from any other non-conhost
569 /// host, and only WT has the specific remedy of a per-profile `icon`
570 /// field. This runs the real detection with the env var set, so the
571 /// branch is exercised rather than assumed.
572 #[test]
573 #[cfg(windows)]
574 fn windows_terminal_is_detected_by_env_and_names_its_remedy() {
575 // SAFETY: single-threaded test process; the var is restored below.
576 let previous = std::env::var_os("WT_SESSION");
577 unsafe { std::env::set_var("WT_SESSION", "test-session") };
578 let support = host_icon_support();
579 match previous {
580 Some(value) => unsafe { std::env::set_var("WT_SESSION", value) },
581 None => unsafe { std::env::remove_var("WT_SESSION") },
582 }
583
584 match support {
585 IconSupport::Degraded { reason } => {
586 assert!(
587 reason.contains("profile"),
588 "WT's verdict must point at the profile icon field; got {reason:?}"
589 );
590 }
591 other => panic!("WT_SESSION must yield Degraded, got {other:?}"),
592 }
593 }
594 #[test]
595 fn a_degraded_host_is_attemptable_but_not_available() {
596 // The distinction a caller acts on: `is_available` decides whether
597 // to embed and ship an icon file, `is_attemptable` decides whether
598 // to bother calling at all.
599 let degraded = IconSupport::Degraded {
600 reason: "name only",
601 };
602 assert!(!degraded.is_available());
603 assert!(degraded.is_attemptable());
604 assert_eq!(degraded.reason(), Some("name only"));
605
606 assert!(IconSupport::Available.is_attemptable());
607 assert!(!IconSupport::Unsupported { reason: "no" }.is_attemptable());
608 }
609
610 #[test]
611 fn a_degraded_host_accepts_a_stock_name_and_refuses_an_image() {
612 // OSC 1 carries a name, not an image. Accepting a file here would
613 // mean inventing a name the caller never chose.
614 let degraded = IconSupport::Degraded {
615 reason: "name only",
616 };
617 let refused = set_host_icon_given(
618 degraded.clone(),
619 &IconSource::Path(PathBuf::from("some.ico")),
620 )
621 .expect_err("an image must be refused on a name-only host");
622 match refused {
623 IconError::DegradedSourceUnsupported { reason } => {
624 assert_eq!(reason, "name only");
625 }
626 other => panic!("expected DegradedSourceUnsupported, got {other:?}"),
627 }
628
629 // And it is distinct from Unsupported, because the remedy differs:
630 // pass a stock icon rather than give up.
631 let unsupported = set_host_icon_given(
632 IconSupport::Unsupported {
633 reason: "none at all",
634 },
635 &IconSource::Stock(StockIcon::Shield),
636 )
637 .expect_err("an unsupported host refuses everything");
638 assert!(matches!(unsupported, IconError::Unsupported { .. }));
639 }
640
641 #[test]
642 fn every_stock_icon_maps_to_a_freedesktop_name() {
643 // A bespoke name would resolve to nothing in any desktop icon
644 // theme, which is the only place an OSC 1 name gets looked up.
645 for icon in [
646 StockIcon::Application,
647 StockIcon::Warning,
648 StockIcon::Error,
649 StockIcon::Information,
650 StockIcon::Shield,
651 ] {
652 let name = icon.osc_name();
653 assert!(!name.is_empty(), "{icon:?} has no OSC name");
654 assert!(
655 name.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
656 "{icon:?} -> {name:?} is not a freedesktop-style name"
657 );
658 }
659 }
660
661 #[test]
662 fn stock_names_are_distinct() {
663 // Two icons sharing a name would silently show the wrong one.
664 let names: std::collections::BTreeSet<&str> = [
665 StockIcon::Application,
666 StockIcon::Warning,
667 StockIcon::Error,
668 StockIcon::Information,
669 StockIcon::Shield,
670 ]
671 .into_iter()
672 .map(StockIcon::osc_name)
673 .collect();
674 assert_eq!(names.len(), 5);
675 }
676 #[test]
677 fn availability_and_reason_are_consistent() {
678 assert!(IconSupport::Available.is_available());
679 assert!(IconSupport::Available.reason().is_none());
680
681 let no = IconSupport::Unsupported { reason: "because" };
682 assert!(!no.is_available());
683 assert_eq!(no.reason(), Some("because"));
684 }
685
686 /// An unsupported host must refuse rather than report success.
687 ///
688 /// This is the whole point of the module: a caller that gets `Ok(())`
689 /// would ship a feature that silently does nothing on the default
690 /// terminal of every recent Windows install.
691 ///
692 /// The verdict is injected rather than probed, so this runs the refusal
693 /// on every platform. Probing would make the test a no-op wherever the
694 /// ambient host happens to be supported.
695 #[test]
696 fn an_unsupported_host_refuses_instead_of_pretending() {
697 let error = set_host_icon_given(
698 IconSupport::Unsupported {
699 reason: "test verdict",
700 },
701 &IconSource::Path("anything.ico".into()),
702 )
703 .expect_err("an unsupported host must not report success");
704
705 match error {
706 IconError::Unsupported { reason } => assert_eq!(reason, "test verdict"),
707 other => panic!("expected Unsupported, got {other}"),
708 }
709 }
710
711 /// The refusal must not depend on the icon existing: an unsupported host
712 /// is unsupported whatever it is handed.
713 #[test]
714 fn refusal_precedes_loading_the_icon() {
715 let error = set_host_icon_given(
716 IconSupport::Unsupported { reason: "nope" },
717 &IconSource::Path("definitely-does-not-exist.ico".into()),
718 )
719 .expect_err("must refuse");
720 assert!(
721 matches!(error, IconError::Unsupported { .. }),
722 "a missing file must not mask the unsupported verdict; got {error}"
723 );
724 }
725
726 /// The OS must hand back a real icon for every variant.
727 ///
728 /// Calls the loader directly rather than going through `set_host_icon`,
729 /// which refuses at the window lookup on a machine with no console — so
730 /// the enum-to-OS mapping would otherwise never run here. That is the
731 /// only place the mapping itself is exercised.
732 #[cfg(windows)]
733 #[test]
734 fn the_os_supplies_every_stock_icon() {
735 for stock in [
736 StockIcon::Application,
737 StockIcon::Warning,
738 StockIcon::Error,
739 StockIcon::Information,
740 StockIcon::Shield,
741 ] {
742 let icon =
743 imp::load_stock(stock).unwrap_or_else(|e| panic!("the OS declined {stock:?}: {e}"));
744 assert!(!icon.is_null(), "{stock:?} produced a null icon");
745 }
746 }
747
748 /// A pid that owns no console window must be refused, with a reason.
749 ///
750 /// Platform-neutral: off Windows the whole feature is unavailable and
751 /// says so, which is a different sentence but the same contract. An
752 /// earlier version asserted the Windows wording here and failed on the
753 /// musl and coverage lanes — the assertion was Windows-specific while the
754 /// test was not.
755 #[test]
756 fn a_process_with_no_console_window_is_unsupported() {
757 // pid 0 is the system idle process and never owns a console window,
758 // so this is stable across machines and needs no fixture.
759 let support = icon_support(IconScope::Child { pid: 0 });
760 assert!(!support.is_available());
761 assert!(
762 !support.reason().expect("must explain itself").is_empty(),
763 "an unsupported result must carry a usable reason"
764 );
765 }
766
767 /// On Windows the reason must name what is actually missing, so a caller
768 /// knows to spawn with CREATE_NEW_CONSOLE rather than retrying.
769 #[cfg(windows)]
770 #[test]
771 fn a_childless_pid_reason_names_the_console_window() {
772 let support = icon_support(IconScope::Child { pid: 0 });
773 let reason = support.reason().expect("must explain itself");
774 assert!(
775 reason.contains("console window"),
776 "the reason should name what is missing: {reason}"
777 );
778 }
779
780 /// Looking up our OWN pid must find the same window the host scope does.
781 ///
782 /// This is the deterministic test of the pid lookup: no spawning, no
783 /// waiting, no session-wide state. Whenever this process has a console
784 /// window, `Child { pid: self }` names that very window, so the two
785 /// scopes must agree — and a broken `console_window_of_pid` makes them
786 /// disagree immediately.
787 ///
788 /// Where there is no console window both are unsupported, which is also
789 /// agreement, so the assertion holds on every machine.
790 #[test]
791 fn own_pid_resolves_to_the_host_console_window() {
792 let host = icon_support(IconScope::Host);
793 let own = icon_support(IconScope::Child {
794 pid: std::process::id(),
795 });
796 assert_eq!(
797 host.is_available(),
798 own.is_available(),
799 "host scope says {host:?} but our own pid says {own:?}; the pid lookup disagrees with the direct console-window lookup"
800 );
801 }
802
803 /// And the setter refuses rather than silently doing nothing.
804 #[test]
805 fn setting_a_childless_pid_is_an_error() {
806 let error = set_icon(
807 IconScope::Child { pid: 0 },
808 &IconSource::Stock(StockIcon::Warning),
809 )
810 .expect_err("a pid with no console cannot take an icon");
811 assert!(
812 matches!(error, IconError::Unsupported { .. }),
813 "expected Unsupported, got {error}"
814 );
815 }
816
817 /// An exited process cannot be targeted either — same answer, so a caller
818 /// does not have to distinguish "never had one" from "gone".
819 #[test]
820 fn an_implausible_pid_is_unsupported() {
821 let support = icon_support(IconScope::Child { pid: u32::MAX });
822 assert!(!support.is_available());
823 }
824
825 /// Host scope must keep answering exactly as before: the scope-aware
826 /// entry point is a generalisation, not a behaviour change.
827 #[test]
828 fn host_scope_agrees_with_the_host_specific_helper() {
829 assert_eq!(icon_support(IconScope::Host), host_icon_support());
830 }
831
832 #[test]
833 fn scopes_are_distinguishable() {
834 assert_ne!(IconScope::Host, IconScope::Child { pid: 1 });
835 assert_ne!(IconScope::Child { pid: 1 }, IconScope::Child { pid: 2 });
836 assert_eq!(IconScope::Child { pid: 7 }, IconScope::Child { pid: 7 });
837 }
838
839 /// A stock icon needs no data, so the only thing that can go wrong is
840 /// the host — never a decode.
841 ///
842 /// Runs everywhere by forcing the verdict, so the enum-to-OS mapping is
843 /// exercised on platforms with no console window at all.
844 #[test]
845 fn every_stock_icon_is_requestable() {
846 for stock in [
847 StockIcon::Application,
848 StockIcon::Warning,
849 StockIcon::Error,
850 StockIcon::Information,
851 StockIcon::Shield,
852 ] {
853 let result = set_host_icon_given(IconSupport::Available, &IconSource::Stock(stock));
854 match result {
855 // On a host with a real console window the icon is set.
856 Ok(()) => {}
857 // Without one, the refusal comes from the window lookup — not
858 // from the icon, which is the point: a stock icon is never a
859 // decode failure.
860 Err(IconError::Unsupported { .. }) => {}
861 Err(other) => panic!("{stock:?} failed for a reason other than the host: {other}"),
862 }
863 }
864 }
865
866 /// A stock request must never be reported as bad data.
867 #[test]
868 fn a_stock_icon_is_never_a_decode_error() {
869 let result = set_host_icon_given(
870 IconSupport::Available,
871 &IconSource::Stock(StockIcon::Warning),
872 );
873 if let Err(error) = result {
874 assert!(
875 !matches!(error, IconError::Decode(_)),
876 "a stock icon carries no data to decode, got {error}"
877 );
878 }
879 }
880
881 /// Distinct variants must not collapse onto one another.
882 #[test]
883 fn stock_variants_are_distinguishable() {
884 assert_ne!(StockIcon::Warning, StockIcon::Error);
885 assert_ne!(StockIcon::Application, StockIcon::Shield);
886 assert_eq!(StockIcon::Information, StockIcon::Information);
887 }
888
889 /// Malformed bytes must be refused before the OS sees them.
890 ///
891 /// Runs everywhere by forcing the verdict, because the decode happens
892 /// before any window is touched — so this covers the validation on
893 /// platforms that have no console window at all.
894 #[test]
895 fn malformed_icon_bytes_are_refused() {
896 let result =
897 set_host_icon_given(IconSupport::Available, &IconSource::Bytes(vec![0xFF; 64]));
898 let error = result.expect_err("garbage is not an icon");
899 assert!(
900 matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
901 "expected a refusal before the OS was handed anything, got {error}"
902 );
903 }
904
905 #[test]
906 fn empty_icon_bytes_are_refused() {
907 let error = set_host_icon_given(IconSupport::Available, &IconSource::Bytes(Vec::new()))
908 .expect_err("empty data is not an icon");
909 assert!(
910 matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
911 "got {error}"
912 );
913 }
914
915 /// A missing file must be a load error, not a silent success.
916 ///
917 /// Reaching the load path needs a real conhost window, which a CI runner
918 /// does not have. Rather than skip invisibly, the verdict is forced to
919 /// `Available` so the load path runs everywhere: with no console window
920 /// `imp::set_host_icon` returns `Unsupported`, and with one it returns
921 /// `Load`. Both are refusals — what must never happen is `Ok`.
922 #[test]
923 fn a_missing_icon_file_never_reports_success() {
924 let result = set_host_icon_given(
925 IconSupport::Available,
926 &IconSource::Path("no-such-icon-file.ico".into()),
927 );
928 let error = result.expect_err("a missing file cannot produce a set icon");
929 assert!(
930 matches!(
931 error,
932 IconError::Load { .. } | IconError::Unsupported { .. }
933 ),
934 "expected a refusal, got {error}"
935 );
936 }
937}