1use std::path::PathBuf;
25
26use running_process_platform_internal::platform::window_icon as platform_icon;
27
28pub mod ico {
29 pub use running_process_platform_internal::platform::window_icon::ico::*;
30}
31mod osc;
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum IconSource {
36 Path(PathBuf),
38 Bytes(Vec<u8>),
42 Stock(StockIcon),
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum StockIcon {
60 Application,
62 Warning,
64 Error,
66 Information,
68 Shield,
70}
71
72impl StockIcon {
73 pub fn osc_name(self) -> &'static str {
80 match self {
81 Self::Application => "application-x-executable",
82 Self::Warning => "dialog-warning",
83 Self::Error => "dialog-error",
84 Self::Information => "dialog-information",
85 Self::Shield => "security-high",
86 }
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum IconSupport {
109 Available,
111 Degraded {
119 reason: &'static str,
121 },
122 Unsupported {
127 reason: &'static str,
129 },
130}
131
132impl IconSupport {
133 pub fn is_available(&self) -> bool {
139 matches!(self, Self::Available)
140 }
141
142 pub fn is_attemptable(&self) -> bool {
149 !matches!(self, Self::Unsupported { .. })
150 }
151
152 pub fn reason(&self) -> Option<&'static str> {
154 match self {
155 Self::Available => None,
156 Self::Degraded { reason } | Self::Unsupported { reason } => Some(reason),
157 }
158 }
159}
160
161#[derive(Debug, thiserror::Error)]
163pub enum IconError {
164 #[error("this host cannot accept a window icon: {reason}")]
169 Unsupported {
170 reason: &'static str,
172 },
173 #[error("cannot load icon from {path}: {source}")]
175 Load {
176 path: PathBuf,
178 #[source]
180 source: std::io::Error,
181 },
182 #[error("the system refused the icon data: {0}")]
189 Apply(#[source] std::io::Error),
190 #[error("this host accepts only a stock icon name, not an image file or bytes: {reason}")]
195 DegradedSourceUnsupported {
196 reason: &'static str,
198 },
199 #[error("supplied icon data is unusable: {0}")]
205 Decode(ico::IcoError),
206}
207
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub enum IconScope {
211 Host,
213 Child {
222 pid: u32,
224 },
225}
226
227fn platform_scope(scope: IconScope) -> platform_icon::IconScope {
228 match scope {
229 IconScope::Host => platform_icon::IconScope::Host,
230 IconScope::Child { pid } => platform_icon::IconScope::Child { pid },
231 }
232}
233
234fn platform_stock(stock: StockIcon) -> platform_icon::StockIcon {
235 match stock {
236 StockIcon::Application => platform_icon::StockIcon::Application,
237 StockIcon::Warning => platform_icon::StockIcon::Warning,
238 StockIcon::Error => platform_icon::StockIcon::Error,
239 StockIcon::Information => platform_icon::StockIcon::Information,
240 StockIcon::Shield => platform_icon::StockIcon::Shield,
241 }
242}
243
244fn platform_source(source: &IconSource) -> platform_icon::IconSource {
245 match source {
246 IconSource::Path(path) => platform_icon::IconSource::Path(path.clone()),
247 IconSource::Bytes(bytes) => platform_icon::IconSource::Bytes(bytes.clone()),
248 IconSource::Stock(stock) => platform_icon::IconSource::Stock(platform_stock(*stock)),
249 }
250}
251
252fn degraded_reason(reason: platform_icon::IconDegradedReason) -> &'static str {
253 match reason {
254 platform_icon::IconDegradedReason::WindowsTerminal => {
255 "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"
256 }
257 platform_icon::IconDegradedReason::NonClassicWindowsHost => {
258 "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"
259 }
260 platform_icon::IconDegradedReason::LinuxNameOnly => {
261 "WINDOWID is not set, so the terminal's X window cannot be identified; a stock name can still be sent via OSC 1"
262 }
263 }
264}
265
266fn unsupported_reason(reason: platform_icon::IconUnsupportedReason) -> &'static str {
267 match reason {
268 platform_icon::IconUnsupportedReason::ChildHasNoConsole => {
269 "that process has no console window of its own (it may share this one, have been created without a window, or have exited)"
270 }
271 platform_icon::IconUnsupportedReason::NoConsole => {
272 "this process has no console window (detached, or output is redirected from a windowless host)"
273 }
274 platform_icon::IconUnsupportedReason::MacTerminalOwnsWindow => {
275 "on macOS the window belongs to Terminal.app or iTerm2, not to this process; set the icon on the terminal application's own bundle"
276 }
277 platform_icon::IconUnsupportedReason::Wayland => {
278 "Wayland compositors do not let a client change another window's icon; set it in the terminal emulator's .desktop file"
279 }
280 platform_icon::IconUnsupportedReason::NoBackend => {
281 "no window-icon backend exists for this platform"
282 }
283 platform_icon::IconUnsupportedReason::LinuxChildScope => {
284 "X11 cannot identify another process's terminal window; WINDOWID names only this process's own host"
285 }
286 platform_icon::IconUnsupportedReason::LinuxNoDisplay => {
287 "no display server is attached (no DISPLAY or WAYLAND_DISPLAY), so there is no window to set an icon on"
288 }
289 platform_icon::IconUnsupportedReason::TargetDisappeared => {
290 "the target window disappeared between the support probe and the call"
291 }
292 platform_icon::IconUnsupportedReason::UnknownImageFormat => {
293 "the X11 backend accepts PNG data (or a .ico whose largest image is a PNG)"
294 }
295 platform_icon::IconUnsupportedReason::StockNeedsPixels => {
296 "stock icons are theme names, not images; X11 needs pixels. Pass a PNG, or let the OSC 1 fallback send the name"
297 }
298 platform_icon::IconUnsupportedReason::OversizedIcon => {
299 "icon is larger than 512x512; window managers scale down from far smaller"
300 }
301 platform_icon::IconUnsupportedReason::UnsupportedPngColorType => {
302 "the X11 backend needs an RGB or RGBA PNG; convert palette or grayscale images first"
303 }
304 platform_icon::IconUnsupportedReason::UnsupportedPngBitDepth => {
305 "the X11 backend needs an 8-bit PNG"
306 }
307 platform_icon::IconUnsupportedReason::UnsupportedX11VisualDepth => {
308 "the X11 visual depth cannot represent the requested icon"
309 }
310 }
311}
312
313fn map_platform_error(error: platform_icon::IconError) -> IconError {
314 match error {
315 platform_icon::IconError::Unsupported(reason) => IconError::Unsupported {
316 reason: unsupported_reason(reason),
317 },
318 platform_icon::IconError::Load { path, source } => IconError::Load { path, source },
319 platform_icon::IconError::Apply(source) => IconError::Apply(source),
320 platform_icon::IconError::Decode(source) => IconError::Decode(source),
321 }
322}
323
324pub fn icon_support(scope: IconScope) -> IconSupport {
328 match platform_icon::icon_support(platform_scope(scope)) {
329 platform_icon::IconSupport::Available => IconSupport::Available,
330 platform_icon::IconSupport::Degraded(reason) => IconSupport::Degraded {
331 reason: degraded_reason(reason),
332 },
333 platform_icon::IconSupport::Unsupported(reason) => IconSupport::Unsupported {
334 reason: unsupported_reason(reason),
335 },
336 }
337}
338
339pub fn host_icon_support() -> IconSupport {
341 icon_support(IconScope::Host)
342}
343
344pub fn set_host_icon(source: &IconSource) -> Result<(), IconError> {
349 set_icon(IconScope::Host, source)
350}
351
352pub fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
357 set_icon_given(icon_support(scope), scope, source)
358}
359
360#[cfg(test)]
367fn set_host_icon_given(support: IconSupport, source: &IconSource) -> Result<(), IconError> {
368 set_icon_given(support, IconScope::Host, source)
369}
370
371fn set_icon_given(
372 support: IconSupport,
373 scope: IconScope,
374 source: &IconSource,
375) -> Result<(), IconError> {
376 match support {
377 IconSupport::Available => {
378 platform_icon::set_icon(platform_scope(scope), &platform_source(source))
379 .map_err(map_platform_error)
380 }
381 IconSupport::Degraded { reason } => match source {
385 IconSource::Stock(icon) => osc::emit(icon.osc_name()).map_err(IconError::Apply),
386 _ => Err(IconError::DegradedSourceUnsupported { reason }),
387 },
388 IconSupport::Unsupported { reason } => Err(IconError::Unsupported { reason }),
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
399 fn support_is_reportable_everywhere() {
400 let support = host_icon_support();
401 match &support {
404 IconSupport::Available => assert_eq!(support.reason(), None),
405 IconSupport::Degraded { reason } | IconSupport::Unsupported { reason } => {
406 assert!(!reason.is_empty(), "a reduced verdict must explain itself");
407 assert_eq!(support.reason(), Some(*reason));
408 }
409 }
410 }
411
412 #[test]
413 fn a_degraded_host_is_attemptable_but_not_available() {
414 let degraded = IconSupport::Degraded {
418 reason: "name only",
419 };
420 assert!(!degraded.is_available());
421 assert!(degraded.is_attemptable());
422 assert_eq!(degraded.reason(), Some("name only"));
423
424 assert!(IconSupport::Available.is_attemptable());
425 assert!(!IconSupport::Unsupported { reason: "no" }.is_attemptable());
426 }
427
428 #[test]
429 fn a_degraded_host_accepts_a_stock_name_and_refuses_an_image() {
430 let degraded = IconSupport::Degraded {
433 reason: "name only",
434 };
435 let refused = set_host_icon_given(
436 degraded.clone(),
437 &IconSource::Path(PathBuf::from("some.ico")),
438 )
439 .expect_err("an image must be refused on a name-only host");
440 match refused {
441 IconError::DegradedSourceUnsupported { reason } => {
442 assert_eq!(reason, "name only");
443 }
444 other => panic!("expected DegradedSourceUnsupported, got {other:?}"),
445 }
446
447 let unsupported = set_host_icon_given(
450 IconSupport::Unsupported {
451 reason: "none at all",
452 },
453 &IconSource::Stock(StockIcon::Shield),
454 )
455 .expect_err("an unsupported host refuses everything");
456 assert!(matches!(unsupported, IconError::Unsupported { .. }));
457 }
458
459 #[test]
460 fn every_stock_icon_maps_to_a_freedesktop_name() {
461 for icon in [
464 StockIcon::Application,
465 StockIcon::Warning,
466 StockIcon::Error,
467 StockIcon::Information,
468 StockIcon::Shield,
469 ] {
470 let name = icon.osc_name();
471 assert!(!name.is_empty(), "{icon:?} has no OSC name");
472 assert!(
473 name.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
474 "{icon:?} -> {name:?} is not a freedesktop-style name"
475 );
476 }
477 }
478
479 #[test]
480 fn stock_names_are_distinct() {
481 let names: std::collections::BTreeSet<&str> = [
483 StockIcon::Application,
484 StockIcon::Warning,
485 StockIcon::Error,
486 StockIcon::Information,
487 StockIcon::Shield,
488 ]
489 .into_iter()
490 .map(StockIcon::osc_name)
491 .collect();
492 assert_eq!(names.len(), 5);
493 }
494 #[test]
495 fn availability_and_reason_are_consistent() {
496 assert!(IconSupport::Available.is_available());
497 assert!(IconSupport::Available.reason().is_none());
498
499 let no = IconSupport::Unsupported { reason: "because" };
500 assert!(!no.is_available());
501 assert_eq!(no.reason(), Some("because"));
502 }
503
504 #[test]
514 fn an_unsupported_host_refuses_instead_of_pretending() {
515 let error = set_host_icon_given(
516 IconSupport::Unsupported {
517 reason: "test verdict",
518 },
519 &IconSource::Path("anything.ico".into()),
520 )
521 .expect_err("an unsupported host must not report success");
522
523 match error {
524 IconError::Unsupported { reason } => assert_eq!(reason, "test verdict"),
525 other => panic!("expected Unsupported, got {other}"),
526 }
527 }
528
529 #[test]
532 fn refusal_precedes_loading_the_icon() {
533 let error = set_host_icon_given(
534 IconSupport::Unsupported { reason: "nope" },
535 &IconSource::Path("definitely-does-not-exist.ico".into()),
536 )
537 .expect_err("must refuse");
538 assert!(
539 matches!(error, IconError::Unsupported { .. }),
540 "a missing file must not mask the unsupported verdict; got {error}"
541 );
542 }
543
544 #[test]
552 fn a_process_with_no_console_window_is_unsupported() {
553 let support = icon_support(IconScope::Child { pid: 0 });
556 assert!(!support.is_available());
557 assert!(
558 !support.reason().expect("must explain itself").is_empty(),
559 "an unsupported result must carry a usable reason"
560 );
561 }
562
563 #[test]
574 fn own_pid_resolves_to_the_host_console_window() {
575 let host = icon_support(IconScope::Host);
576 let own = icon_support(IconScope::Child {
577 pid: std::process::id(),
578 });
579 assert_eq!(
580 host.is_available(),
581 own.is_available(),
582 "host scope says {host:?} but our own pid says {own:?}; the pid lookup disagrees with the direct console-window lookup"
583 );
584 }
585
586 #[test]
588 fn setting_a_childless_pid_is_an_error() {
589 let error = set_icon(
590 IconScope::Child { pid: 0 },
591 &IconSource::Stock(StockIcon::Warning),
592 )
593 .expect_err("a pid with no console cannot take an icon");
594 assert!(
595 matches!(error, IconError::Unsupported { .. }),
596 "expected Unsupported, got {error}"
597 );
598 }
599
600 #[test]
603 fn an_implausible_pid_is_unsupported() {
604 let support = icon_support(IconScope::Child { pid: u32::MAX });
605 assert!(!support.is_available());
606 }
607
608 #[test]
611 fn host_scope_agrees_with_the_host_specific_helper() {
612 assert_eq!(icon_support(IconScope::Host), host_icon_support());
613 }
614
615 #[test]
616 fn scopes_are_distinguishable() {
617 assert_ne!(IconScope::Host, IconScope::Child { pid: 1 });
618 assert_ne!(IconScope::Child { pid: 1 }, IconScope::Child { pid: 2 });
619 assert_eq!(IconScope::Child { pid: 7 }, IconScope::Child { pid: 7 });
620 }
621
622 #[test]
628 fn every_stock_icon_is_requestable() {
629 for stock in [
630 StockIcon::Application,
631 StockIcon::Warning,
632 StockIcon::Error,
633 StockIcon::Information,
634 StockIcon::Shield,
635 ] {
636 let result = set_host_icon_given(IconSupport::Available, &IconSource::Stock(stock));
637 match result {
638 Ok(()) => {}
640 Err(IconError::Unsupported { .. }) => {}
644 Err(other) => panic!("{stock:?} failed for a reason other than the host: {other}"),
645 }
646 }
647 }
648
649 #[test]
651 fn a_stock_icon_is_never_a_decode_error() {
652 let result = set_host_icon_given(
653 IconSupport::Available,
654 &IconSource::Stock(StockIcon::Warning),
655 );
656 if let Err(error) = result {
657 assert!(
658 !matches!(error, IconError::Decode(_)),
659 "a stock icon carries no data to decode, got {error}"
660 );
661 }
662 }
663
664 #[test]
666 fn stock_variants_are_distinguishable() {
667 assert_ne!(StockIcon::Warning, StockIcon::Error);
668 assert_ne!(StockIcon::Application, StockIcon::Shield);
669 assert_eq!(StockIcon::Information, StockIcon::Information);
670 }
671
672 #[test]
678 fn malformed_icon_bytes_are_refused() {
679 let result =
680 set_host_icon_given(IconSupport::Available, &IconSource::Bytes(vec![0xFF; 64]));
681 let error = result.expect_err("garbage is not an icon");
682 assert!(
683 matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
684 "expected a refusal before the OS was handed anything, got {error}"
685 );
686 }
687
688 #[test]
689 fn empty_icon_bytes_are_refused() {
690 let error = set_host_icon_given(IconSupport::Available, &IconSource::Bytes(Vec::new()))
691 .expect_err("empty data is not an icon");
692 assert!(
693 matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
694 "got {error}"
695 );
696 }
697
698 #[test]
706 fn a_missing_icon_file_never_reports_success() {
707 let result = set_host_icon_given(
708 IconSupport::Available,
709 &IconSource::Path("no-such-icon-file.ico".into()),
710 );
711 let error = result.expect_err("a missing file cannot produce a set icon");
712 assert!(
713 matches!(
714 error,
715 IconError::Load { .. } | IconError::Unsupported { .. }
716 ),
717 "expected a refusal, got {error}"
718 );
719 }
720}