Skip to main content

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}