Skip to main content

argui_platform/
identity.rs

1use std::sync::Arc;
2
3use image::{ImageEncoder, codecs::png::PngEncoder};
4
5#[derive(Clone, Debug, Eq, Hash, PartialEq)]
6pub struct ApplicationId(String);
7
8impl ApplicationId {
9    pub fn new(value: impl Into<String>) -> Result<Self, ApplicationIdError> {
10        let value = value.into();
11        if valid_application_id(&value) {
12            Ok(Self(value))
13        } else {
14            Err(ApplicationIdError(value))
15        }
16    }
17
18    #[must_use]
19    pub fn as_str(&self) -> &str {
20        &self.0
21    }
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ApplicationIdError(String);
26
27impl std::fmt::Display for ApplicationIdError {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(formatter, "invalid reverse-DNS application id: {}", self.0)
30    }
31}
32
33impl std::error::Error for ApplicationIdError {}
34
35fn valid_application_id(value: &str) -> bool {
36    let mut segments = value.split('.');
37    let Some(first) = segments.next() else {
38        return false;
39    };
40    let Some(second) = segments.next() else {
41        return false;
42    };
43    valid_segment(first) && valid_segment(second) && segments.all(valid_segment)
44}
45
46fn valid_segment(segment: &str) -> bool {
47    !segment.is_empty()
48        && segment
49            .bytes()
50            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
51        && segment.as_bytes()[0].is_ascii_lowercase()
52}
53
54#[derive(Clone, Debug, PartialEq)]
55pub struct ApplicationIdentity {
56    pub id: ApplicationId,
57    pub display_name: String,
58    pub icons: IconSet,
59    linux_application_id: Option<String>,
60}
61
62impl ApplicationIdentity {
63    #[must_use]
64    pub fn new(id: ApplicationId, display_name: impl Into<String>, icons: IconSet) -> Self {
65        Self {
66            id,
67            display_name: display_name.into(),
68            icons,
69            linux_application_id: None,
70        }
71    }
72
73    /// Overrides the desktop-file id exposed to Wayland and X11.
74    ///
75    /// This should match the installed `<id>.desktop` file name without its
76    /// `.desktop` suffix. It is useful when a packager derives that name from
77    /// an executable instead of the reverse-DNS bundle identifier.
78    #[must_use]
79    pub fn with_linux_application_id(mut self, id: impl Into<String>) -> Self {
80        self.linux_application_id = Some(id.into());
81        self
82    }
83
84    #[must_use]
85    pub fn linux_application_id(&self) -> &str {
86        self.linux_application_id
87            .as_deref()
88            .unwrap_or_else(|| self.id.as_str())
89    }
90
91    #[must_use]
92    pub fn development(display_name: impl Into<String>) -> Self {
93        Self {
94            id: ApplicationId("dev.argui.application".into()),
95            display_name: display_name.into(),
96            icons: IconSet::new(),
97            linux_application_id: None,
98        }
99    }
100}
101
102#[derive(Clone, Debug, Default, PartialEq)]
103pub struct IconSet {
104    icons: Vec<AppIcon>,
105}
106
107impl IconSet {
108    #[must_use]
109    pub const fn new() -> Self {
110        Self { icons: Vec::new() }
111    }
112
113    #[must_use]
114    pub fn single(icon: AppIcon) -> Self {
115        Self { icons: vec![icon] }
116    }
117
118    #[must_use]
119    pub fn with(mut self, icon: AppIcon) -> Self {
120        self.icons
121            .retain(|candidate| candidate.width != icon.width || candidate.height != icon.height);
122        self.icons.push(icon);
123        self.icons.sort_by_key(AppIcon::pixel_count);
124        self
125    }
126
127    #[must_use]
128    pub fn icons(&self) -> &[AppIcon] {
129        &self.icons
130    }
131
132    #[must_use]
133    pub fn best_square(&self, target: u32) -> Option<&AppIcon> {
134        self.icons.iter().min_by_key(|icon| {
135            let size = icon.width.max(icon.height);
136            size.abs_diff(target)
137        })
138    }
139
140    #[must_use]
141    pub fn is_empty(&self) -> bool {
142        self.icons.is_empty()
143    }
144}
145
146#[derive(Clone, Debug, PartialEq)]
147pub struct AppIcon {
148    pub width: u32,
149    pub height: u32,
150    pub rgba8: Arc<[u8]>,
151    pub png: Arc<[u8]>,
152}
153
154impl AppIcon {
155    pub fn from_png(encoded: impl AsRef<[u8]>) -> Result<Self, AppIconError> {
156        let encoded = encoded.as_ref();
157        let decoded =
158            image::load_from_memory_with_format(encoded, image::ImageFormat::Png)?.into_rgba8();
159        Self::from_parts(
160            decoded.width(),
161            decoded.height(),
162            decoded.into_raw(),
163            encoded.to_vec(),
164        )
165    }
166
167    pub fn from_rgba8(
168        width: u32,
169        height: u32,
170        rgba8: impl Into<Vec<u8>>,
171    ) -> Result<Self, AppIconError> {
172        let rgba8 = rgba8.into();
173        validate_rgba(width, height, rgba8.len())?;
174        let mut png = Vec::new();
175        PngEncoder::new(&mut png).write_image(
176            &rgba8,
177            width,
178            height,
179            image::ExtendedColorType::Rgba8,
180        )?;
181        Self::from_parts(width, height, rgba8, png)
182    }
183
184    fn from_parts(
185        width: u32,
186        height: u32,
187        rgba8: Vec<u8>,
188        png: Vec<u8>,
189    ) -> Result<Self, AppIconError> {
190        validate_rgba(width, height, rgba8.len())?;
191        Ok(Self {
192            width,
193            height,
194            rgba8: rgba8.into(),
195            png: png.into(),
196        })
197    }
198
199    fn pixel_count(&self) -> u64 {
200        u64::from(self.width) * u64::from(self.height)
201    }
202}
203
204fn validate_rgba(width: u32, height: u32, actual: usize) -> Result<(), AppIconError> {
205    let expected = usize::try_from(width)
206        .ok()
207        .and_then(|width| usize::try_from(height).ok().map(|height| width * height))
208        .and_then(|pixels| pixels.checked_mul(4))
209        .filter(|_| width != 0 && height != 0)
210        .ok_or(AppIconError::InvalidDimensions)?;
211    if expected == actual {
212        Ok(())
213    } else {
214        Err(AppIconError::InvalidByteLength { expected, actual })
215    }
216}
217
218#[derive(Debug)]
219pub enum AppIconError {
220    Decode(image::ImageError),
221    InvalidDimensions,
222    InvalidByteLength { expected: usize, actual: usize },
223}
224
225impl std::fmt::Display for AppIconError {
226    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match self {
228            Self::Decode(error) => write!(formatter, "icon decoding failed: {error}"),
229            Self::InvalidDimensions => formatter.write_str("invalid icon dimensions"),
230            Self::InvalidByteLength { expected, actual } => {
231                write!(
232                    formatter,
233                    "invalid icon byte length: expected {expected}, got {actual}"
234                )
235            }
236        }
237    }
238}
239
240impl std::error::Error for AppIconError {}
241
242impl From<image::ImageError> for AppIconError {
243    fn from(error: image::ImageError) -> Self {
244        Self::Decode(error)
245    }
246}