Skip to main content

running_process_platform_internal/platform_linux/
window_icon.rs

1//! The X11 `_NET_WM_ICON` backend (#577).
2//!
3//! # Why this is a real backend and OSC 1 is not
4//!
5//! `_NET_WM_ICON` carries actual pixels. The window manager scales them for
6//! the taskbar, the Alt-Tab switcher, and the title bar, so an icon set this
7//! way is the icon the user sees. OSC 1 carries a *name* that most emulators
8//! ignore — it is the fallback for hosts with no property to write.
9//!
10//! # Finding the window
11//!
12//! X11 has no "the window my process is drawing in" call: a terminal emulator
13//! owns its window, and this process is a child holding a pty, not an X
14//! client with a window of its own.
15//!
16//! The `WINDOWID` environment variable is the long-standing convention for
17//! bridging that gap — xterm, urxvt, and most emulators that inherit its
18//! behaviour export it to the shell they spawn. When it is absent there is no
19//! honest way to guess which window belongs to this process, and guessing
20//! wrong means writing an icon onto someone else's window. So an absent
21//! `WINDOWID` is reported as unsupported rather than searched for heuristically.
22//!
23//! # The property format
24//!
25//! `_NET_WM_ICON` is `CARDINAL[]`: width, height, then `width * height`
26//! pixels, row-major, each `0xAARRGGBB`. The spec permits several images
27//! concatenated so the WM can pick a size; one is written here, and the WM
28//! scales it.
29
30use std::path::Path;
31
32use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, PropMode, Window};
33// `change_property32` is a convenience wrapper, not part of the generated
34// xproto surface, so it needs its own trait in scope.
35use x11rb::wrapper::ConnectionExt as _;
36
37use crate::platform::window_icon::{
38    IconDegradedReason, IconError, IconScope, IconSource, IconSupport, IconUnsupportedReason,
39};
40
41#[cfg(test)]
42#[path = "window_icon/tests_support.rs"]
43mod tests_support;
44
45/// Decoded pixels, ready for the property.
46#[derive(Debug)]
47pub(super) struct Rgba {
48    pub width: u32,
49    pub height: u32,
50    /// Row-major RGBA, four bytes per pixel.
51    pub pixels: Vec<u8>,
52}
53
54/// Largest icon accepted, per side.
55///
56/// `_NET_WM_ICON` is sent over the X connection as one property, and a
57/// 4096×4096 icon would be 64 MiB on a socket shared with every other request
58/// this client makes. Window managers scale down from something far smaller.
59const MAX_SIDE: u32 = 512;
60
61/// The window this process should set an icon on, if it can be known.
62pub(super) fn window_id() -> Option<Window> {
63    parse_window_id(&std::env::var("WINDOWID").ok()?)
64}
65
66/// Parse a `WINDOWID` value.
67///
68/// Split from the env read so it can be tested without mutating
69/// process-global state — a test that sets `WINDOWID` is visible to every
70/// other test running in the same process, which is how a parallel harness
71/// turns one test's fixture into another test's flake.
72fn parse_window_id(raw: &str) -> Option<Window> {
73    // Emulators export it as decimal; accept hex too because some tools
74    // re-export it in the form `xwininfo` prints.
75    let trimmed = raw.trim();
76    let parsed = trimmed
77        .strip_prefix("0x")
78        .and_then(|hex| u32::from_str_radix(hex, 16).ok())
79        .or_else(|| trimmed.parse::<u32>().ok())?;
80    // Zero is the X "no window" sentinel, and a property write against it
81    // would be silently accepted by some servers.
82    (parsed != 0).then_some(parsed)
83}
84
85/// Whether this host can take a real icon.
86pub fn icon_support(scope: IconScope) -> IconSupport {
87    // A child's window cannot be identified. `WINDOWID` names *this*
88    // process's terminal, and mapping a pid to an X window would mean the
89    // same heuristic guessing that finding our own window deliberately
90    // avoids — with the added cost that guessing wrong writes an icon onto
91    // an unrelated application.
92    if matches!(scope, IconScope::Child { .. }) {
93        return IconSupport::Unsupported(IconUnsupportedReason::LinuxChildScope);
94    }
95    if std::env::var_os("WAYLAND_DISPLAY").is_some() {
96        return IconSupport::Unsupported(IconUnsupportedReason::Wayland);
97    }
98    if std::env::var_os("DISPLAY").is_none() {
99        return IconSupport::Unsupported(IconUnsupportedReason::LinuxNoDisplay);
100    }
101    if window_id().is_none() {
102        // Degraded rather than unsupported: OSC 1 still reaches the terminal
103        // even when we cannot identify its window.
104        return IconSupport::Degraded(IconDegradedReason::LinuxNameOnly);
105    }
106    IconSupport::Available
107}
108
109/// Write `source` onto this process's terminal window.
110pub fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
111    if let IconSupport::Unsupported(reason) = icon_support(scope) {
112        return Err(IconError::Unsupported(reason));
113    }
114    let window = window_id().ok_or(IconError::Unsupported(
115        IconUnsupportedReason::TargetDisappeared,
116    ))?;
117    let image = decode(source)?;
118    write_property(window, &image)
119}
120
121/// Decode an icon source to RGBA.
122fn decode(source: &IconSource) -> Result<Rgba, IconError> {
123    match source {
124        IconSource::Path(path) => {
125            let bytes = std::fs::read(path).map_err(|source| IconError::Load {
126                path: path.clone(),
127                source,
128            })?;
129            decode_bytes(&bytes, Some(path))
130        }
131        IconSource::Bytes(bytes) => decode_bytes(bytes, None),
132        // A stock name is a theme lookup, not an image. Resolving it would
133        // mean reading the user's icon theme, which is a different feature;
134        // the OSC 1 fallback already carries the name.
135        IconSource::Stock(_) => Err(IconError::Unsupported(
136            IconUnsupportedReason::StockNeedsPixels,
137        )),
138    }
139}
140
141/// PNG magic, per the spec's fixed 8-byte signature.
142const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
143
144fn decode_bytes(bytes: &[u8], path: Option<&Path>) -> Result<Rgba, IconError> {
145    if bytes.len() >= PNG_MAGIC.len() && bytes[..PNG_MAGIC.len()] == PNG_MAGIC {
146        return decode_png(bytes);
147    }
148    // An `.ico` embeds either a PNG or a bottom-up DIB. The PNG case is
149    // handled by unwrapping to the embedded image; a DIB would need a second
150    // decoder, and saying so is more useful than a generic parse failure that
151    // sends the caller looking for a corrupt file.
152    if let Ok(span) = crate::platform::window_icon::ico::best_image(bytes) {
153        let inner = &bytes[span.offset..span.offset + span.len];
154        if inner.len() >= PNG_MAGIC.len() && inner[..PNG_MAGIC.len()] == PNG_MAGIC {
155            return decode_png(inner);
156        }
157        return Err(IconError::Unsupported(
158            IconUnsupportedReason::UnknownImageFormat,
159        ));
160    }
161    let _ = path;
162    Err(IconError::Unsupported(
163        IconUnsupportedReason::UnknownImageFormat,
164    ))
165}
166
167fn decode_png(bytes: &[u8]) -> Result<Rgba, IconError> {
168    let decoder = png::Decoder::new(bytes);
169    let mut reader = decoder
170        .read_info()
171        .map_err(|e| IconError::Apply(std::io::Error::other(e.to_string())))?;
172
173    let mut buffer = vec![0; reader.output_buffer_size()];
174    let info = reader
175        .next_frame(&mut buffer)
176        .map_err(|e| IconError::Apply(std::io::Error::other(e.to_string())))?;
177
178    if info.width > MAX_SIDE || info.height > MAX_SIDE {
179        return Err(IconError::Unsupported(
180            IconUnsupportedReason::OversizedIcon,
181        ));
182    }
183
184    let pixels = match info.color_type {
185        png::ColorType::Rgba => buffer[..info.buffer_size()].to_vec(),
186        // Opaque source: synthesize full alpha rather than refusing, because a
187        // photo-style PNG with no alpha channel is a perfectly ordinary icon.
188        png::ColorType::Rgb => buffer[..info.buffer_size()]
189            .chunks_exact(3)
190            .flat_map(|p| [p[0], p[1], p[2], 0xff])
191            .collect(),
192        other => {
193            let _ = other;
194            return Err(IconError::Unsupported(
195                IconUnsupportedReason::UnsupportedPngColorType,
196            ));
197        }
198    };
199
200    Ok(Rgba {
201        width: info.width,
202        height: info.height,
203        pixels,
204    })
205}
206
207/// Pack RGBA bytes into the `0xAARRGGBB` cardinals the property wants.
208pub(super) fn to_cardinals(image: &Rgba) -> Vec<u32> {
209    let mut data = Vec::with_capacity(2 + (image.width * image.height) as usize);
210    data.push(image.width);
211    data.push(image.height);
212    for pixel in image.pixels.chunks_exact(4) {
213        data.push(
214            (u32::from(pixel[3]) << 24)
215                | (u32::from(pixel[0]) << 16)
216                | (u32::from(pixel[1]) << 8)
217                | u32::from(pixel[2]),
218        );
219    }
220    data
221}
222
223fn write_property(window: Window, image: &Rgba) -> Result<(), IconError> {
224    let (connection, _screen) =
225        x11rb::connect(None).map_err(|e| IconError::Apply(std::io::Error::other(e.to_string())))?;
226
227    let cookie = connection
228        .intern_atom(false, b"_NET_WM_ICON")
229        .map_err(|e| IconError::Apply(std::io::Error::other(e.to_string())))?;
230    let atom = cookie
231        .reply()
232        .map_err(|e| IconError::Apply(std::io::Error::other(e.to_string())))?
233        .atom;
234
235    let data = to_cardinals(image);
236    connection
237        .change_property32(PropMode::REPLACE, window, atom, AtomEnum::CARDINAL, &data)
238        .map_err(|e: x11rb::errors::ConnectionError| {
239            IconError::Apply(std::io::Error::other(e.to_string()))
240        })?
241        .check()
242        .map_err(|e: x11rb::errors::ReplyError| {
243            IconError::Apply(std::io::Error::other(e.to_string()))
244        })?;
245
246    // `check()` above round-trips, which both flushes the request and
247    // surfaces an X error the server would otherwise report asynchronously —
248    // without it a bad window id fails silently and the call reports success
249    // having done nothing.
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn cardinals_lead_with_dimensions_then_argb() {
259        let image = Rgba {
260            width: 2,
261            height: 1,
262            // Opaque red, then half-transparent blue.
263            pixels: vec![0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0x80],
264        };
265        assert_eq!(to_cardinals(&image), vec![2, 1, 0xffff_0000, 0x8000_00ff]);
266    }
267
268    #[test]
269    fn a_windowid_is_read_as_decimal_or_hex() {
270        // Emulators export decimal; tools that echo `xwininfo` export hex.
271        // Reading only one form would silently skip the backend.
272        assert_eq!(parse_window_id("12345"), Some(12345));
273        assert_eq!(parse_window_id("0x3039"), Some(0x3039));
274        assert_eq!(parse_window_id("  42  "), Some(42));
275        assert_eq!(parse_window_id("not a window"), None);
276    }
277
278    #[test]
279    fn a_zero_windowid_is_rejected() {
280        // Zero is X's "no window" sentinel, and some servers accept a
281        // property write against it without error — a silent no-op.
282        assert_eq!(parse_window_id("0"), None);
283        assert_eq!(parse_window_id("0x0"), None);
284    }
285
286    #[test]
287    fn a_child_scope_is_refused_rather_than_silently_hitting_our_own_window() {
288        // The bug this prevents: without the scope check, targeting a child
289        // writes the icon onto *this* process's terminal and reports success.
290        let support = icon_support(IconScope::Child { pid: 4242 });
291        match support {
292            IconSupport::Unsupported(IconUnsupportedReason::LinuxChildScope) => {}
293            other => panic!("a child's window is not identifiable on X11, got {other:?}"),
294        }
295    }
296
297    #[test]
298    fn an_rgb_png_gains_full_alpha_rather_than_being_refused() {
299        let png = super::tests_support::rgb_png(1, 1, [0x10, 0x20, 0x30]);
300        let image = decode_png(&png).expect("an RGB PNG is an ordinary icon");
301        assert_eq!(image.pixels, vec![0x10, 0x20, 0x30, 0xff]);
302    }
303
304    #[test]
305    fn a_non_image_is_refused_with_a_reason_naming_the_accepted_formats() {
306        let error = decode_bytes(b"not an image at all", None)
307            .expect_err("arbitrary bytes are not an icon");
308        match error {
309            IconError::Unsupported(IconUnsupportedReason::UnknownImageFormat) => {}
310            other => panic!("expected Unsupported, got {other:?}"),
311        }
312    }
313
314    #[test]
315    fn an_oversized_png_is_refused_before_it_reaches_the_socket() {
316        let png = super::tests_support::rgb_png(MAX_SIDE + 1, 1, [0, 0, 0]);
317        let error = decode_png(&png).expect_err("an oversized icon must be refused");
318        assert!(matches!(
319            error,
320            IconError::Unsupported(IconUnsupportedReason::OversizedIcon)
321        ));
322    }
323
324    #[test]
325    fn a_stock_icon_is_refused_because_x11_needs_pixels() {
326        let error = decode(&IconSource::Stock(crate::platform::window_icon::StockIcon::Shield))
327            .expect_err("a theme name is not an image");
328        match error {
329            IconError::Unsupported(IconUnsupportedReason::StockNeedsPixels) => {}
330            other => panic!("expected Unsupported, got {other:?}"),
331        }
332    }
333}