running_process_platform_internal/platform_linux/
window_icon.rs1use std::path::Path;
31
32use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, PropMode, Window};
33use 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#[derive(Debug)]
47pub(super) struct Rgba {
48 pub width: u32,
49 pub height: u32,
50 pub pixels: Vec<u8>,
52}
53
54const MAX_SIDE: u32 = 512;
60
61pub(super) fn window_id() -> Option<Window> {
63 parse_window_id(&std::env::var("WINDOWID").ok()?)
64}
65
66fn parse_window_id(raw: &str) -> Option<Window> {
73 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 (parsed != 0).then_some(parsed)
83}
84
85pub fn icon_support(scope: IconScope) -> IconSupport {
87 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 return IconSupport::Degraded(IconDegradedReason::LinuxNameOnly);
105 }
106 IconSupport::Available
107}
108
109pub 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
121fn 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 IconSource::Stock(_) => Err(IconError::Unsupported(
136 IconUnsupportedReason::StockNeedsPixels,
137 )),
138 }
139}
140
141const 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 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 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
207pub(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 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 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 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 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 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}