Skip to main content

libplasmoid_updater/
types.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// KDE Store category IDs for supported component types.
8const CATEGORY_PLASMA_WIDGET: u16 = 705;
9const CATEGORY_WALLPAPER_PLUGIN: u16 = 715;
10const CATEGORY_KWIN_EFFECT: u16 = 719;
11const CATEGORY_KWIN_SCRIPT: u16 = 720;
12const CATEGORY_KWIN_SWITCHER: u16 = 721;
13const CATEGORY_GLOBAL_THEME: u16 = 722;
14const CATEGORY_PLASMA_STYLE: u16 = 709;
15const CATEGORY_AURORAE_DECORATION: u16 = 114;
16const CATEGORY_COLOR_SCHEME: u16 = 112;
17const CATEGORY_SPLASH_SCREEN: u16 = 708;
18const CATEGORY_SDDM_THEME: u16 = 101;
19const CATEGORY_ICON_THEME: u16 = 132;
20const CATEGORY_WALLPAPER: u16 = 299;
21
22/// Type of KDE Plasma component.
23///
24/// Maps to KDE Store category IDs and determines installation paths,
25/// registry files, and update strategies.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ComponentType {
29    PlasmaWidget,
30    WallpaperPlugin,
31    KWinEffect,
32    KWinScript,
33    KWinSwitcher,
34    GlobalTheme,
35    PlasmaStyle,
36    AuroraeDecoration,
37    ColorScheme,
38    SplashScreen,
39    SddmTheme,
40    IconTheme,
41    Wallpaper,
42}
43
44impl ComponentType {
45    pub(crate) const fn category_id(self) -> u16 {
46        match self {
47            Self::PlasmaWidget => CATEGORY_PLASMA_WIDGET,
48            Self::WallpaperPlugin => CATEGORY_WALLPAPER_PLUGIN,
49            Self::KWinEffect => CATEGORY_KWIN_EFFECT,
50            Self::KWinScript => CATEGORY_KWIN_SCRIPT,
51            Self::KWinSwitcher => CATEGORY_KWIN_SWITCHER,
52            Self::GlobalTheme => CATEGORY_GLOBAL_THEME,
53            Self::PlasmaStyle => CATEGORY_PLASMA_STYLE,
54            Self::AuroraeDecoration => CATEGORY_AURORAE_DECORATION,
55            Self::ColorScheme => CATEGORY_COLOR_SCHEME,
56            Self::SplashScreen => CATEGORY_SPLASH_SCREEN,
57            Self::SddmTheme => CATEGORY_SDDM_THEME,
58            Self::IconTheme => CATEGORY_ICON_THEME,
59            Self::Wallpaper => CATEGORY_WALLPAPER,
60        }
61    }
62
63    /// Returns true if the given store `type_id` belongs to this component type.
64    ///
65    /// The OCS API returns subcategory IDs in the `typeid` field. For example,
66    /// PlasmaWidget queries with parent category 705 but store entries have
67    /// specific subcategory IDs (706 "Applets", 708 "Clocks", 710 "Monitoring",
68    /// etc.). This method accounts for those parent-child relationships.
69    pub(crate) const fn matches_type_id(self, type_id: u16) -> bool {
70        if self.category_id() == type_id {
71            return true;
72        }
73        // PlasmaWidget (705) is the parent of all subcategories in the 700-range.
74        // Using the full range is safe because other 700-range types (e.g. 708
75        // SplashScreen, 709 PlasmaStyle) have their own `category_id()` which
76        // fires first via the direct match above.
77        matches!((self, type_id), (Self::PlasmaWidget, 700..=799))
78    }
79
80    pub(crate) const fn kpackage_type(self) -> Option<&'static str> {
81        match self {
82            Self::PlasmaWidget => Some("Plasma/Applet"),
83            Self::WallpaperPlugin => Some("Plasma/Wallpaper"),
84            Self::KWinEffect => Some("KWin/Effect"),
85            Self::KWinScript => Some("KWin/Script"),
86            Self::KWinSwitcher => Some("KWin/WindowSwitcher"),
87            Self::GlobalTheme => Some("Plasma/LookAndFeel"),
88            Self::PlasmaStyle => Some("Plasma/Theme"),
89            Self::SplashScreen => Some("Plasma/LookAndFeel"),
90            _ => None,
91        }
92    }
93
94    /// Returns `true` if this type can fall back to direct file installation
95    /// when `kpackagetool6` fails.
96    ///
97    /// Only the newly-added kpackage types (GlobalTheme, PlasmaStyle, SplashScreen)
98    /// have this fallback, since they previously worked with direct install.
99    /// The original 5 types (PlasmaWidget, WallpaperPlugin, KWinEffect, KWinScript,
100    /// KWinSwitcher) have always required kpackagetool6 and have no fallback.
101    pub(crate) const fn has_direct_fallback(self) -> bool {
102        matches!(
103            self,
104            Self::GlobalTheme | Self::PlasmaStyle | Self::SplashScreen
105        )
106    }
107
108    /// Returns true if this type uses registry-based discovery only
109    /// (no metadata files on disk).
110    pub(crate) const fn registry_only(self) -> bool {
111        matches!(self, Self::IconTheme | Self::Wallpaper | Self::ColorScheme)
112    }
113
114    /// Returns all component types that share the same filesystem path.
115    ///
116    /// `GlobalTheme` and `SplashScreen` both use `plasma/look-and-feel`.
117    /// During discovery, components in shared directories need to be
118    /// checked against all possible types to assign the correct `ComponentType`.
119    pub(crate) fn shared_path_types(self) -> &'static [ComponentType] {
120        match self {
121            Self::GlobalTheme | Self::SplashScreen => &[Self::GlobalTheme, Self::SplashScreen],
122            Self::PlasmaWidget => &[Self::PlasmaWidget],
123            Self::WallpaperPlugin => &[Self::WallpaperPlugin],
124            Self::KWinEffect => &[Self::KWinEffect],
125            Self::KWinScript => &[Self::KWinScript],
126            Self::KWinSwitcher => &[Self::KWinSwitcher],
127            Self::PlasmaStyle => &[Self::PlasmaStyle],
128            Self::AuroraeDecoration => &[Self::AuroraeDecoration],
129            Self::ColorScheme => &[Self::ColorScheme],
130            Self::SddmTheme => &[Self::SddmTheme],
131            Self::IconTheme => &[Self::IconTheme],
132            Self::Wallpaper => &[Self::Wallpaper],
133        }
134    }
135
136    // -- Filesystem paths --
137
138    /// Returns the user-local data directory suffix, or `None` for system-only types (e.g., SDDM).
139    pub(crate) const fn user_suffix(self) -> Option<&'static str> {
140        match self {
141            Self::PlasmaWidget => Some("plasma/plasmoids"),
142            Self::WallpaperPlugin => Some("plasma/wallpapers"),
143            Self::KWinEffect => Some("kwin/effects"),
144            Self::KWinScript => Some("kwin/scripts"),
145            Self::KWinSwitcher => Some("kwin/tabbox"),
146            Self::GlobalTheme | Self::SplashScreen => Some("plasma/look-and-feel"),
147            Self::PlasmaStyle => Some("plasma/desktoptheme"),
148            Self::AuroraeDecoration => Some("aurorae/themes"),
149            Self::ColorScheme => Some("color-schemes"),
150            Self::SddmTheme => None,
151            Self::IconTheme => Some("icons"),
152            Self::Wallpaper => Some("wallpapers"),
153        }
154    }
155
156    /// Returns the full user-local installation path for this component type.
157    pub fn user_path(self) -> PathBuf {
158        match self.user_suffix() {
159            Some(suffix) => crate::paths::data_home().join(suffix),
160            None => PathBuf::new(),
161        }
162    }
163
164    /// Returns the system-wide installation path for this component type.
165    pub fn system_path(self) -> PathBuf {
166        PathBuf::from(match self {
167            Self::PlasmaWidget => "/usr/share/plasma/plasmoids",
168            Self::WallpaperPlugin => "/usr/share/plasma/wallpapers",
169            Self::KWinEffect => "/usr/share/kwin/effects",
170            Self::KWinScript => "/usr/share/kwin/scripts",
171            Self::KWinSwitcher => "/usr/share/kwin/tabbox",
172            Self::GlobalTheme | Self::SplashScreen => "/usr/share/plasma/look-and-feel",
173            Self::PlasmaStyle => "/usr/share/plasma/desktoptheme",
174            Self::AuroraeDecoration => "/usr/share/aurorae/themes",
175            Self::ColorScheme => "/usr/share/color-schemes",
176            Self::SddmTheme => "/usr/share/sddm/themes",
177            Self::IconTheme => "/usr/share/icons",
178            Self::Wallpaper => "/usr/share/wallpapers",
179        })
180    }
181
182    /// Returns the backup subdirectory name for this component type.
183    pub(crate) const fn backup_subdir(self) -> &'static str {
184        match self {
185            Self::PlasmaWidget => "plasma-plasmoids",
186            Self::WallpaperPlugin => "plasma-wallpapers",
187            Self::KWinEffect => "kwin-effects",
188            Self::KWinScript => "kwin-scripts",
189            Self::KWinSwitcher => "kwin-tabbox",
190            Self::GlobalTheme => "plasma-look-and-feel",
191            Self::PlasmaStyle => "plasma-desktoptheme",
192            Self::AuroraeDecoration => "aurorae-themes",
193            Self::ColorScheme => "color-schemes",
194            Self::SplashScreen => "plasma-splash",
195            Self::SddmTheme => "sddm-themes",
196            Self::IconTheme => "icons",
197            Self::Wallpaper => "wallpapers",
198        }
199    }
200
201    // -- Registry --
202
203    pub(crate) const fn registry_file(self) -> Option<&'static str> {
204        match self {
205            Self::PlasmaWidget => Some("plasmoids.knsregistry"),
206            Self::KWinEffect => Some("kwineffect.knsregistry"),
207            Self::KWinScript => Some("kwinscripts.knsregistry"),
208            Self::KWinSwitcher => Some("kwinswitcher.knsregistry"),
209            Self::WallpaperPlugin => Some("wallpaperplugin.knsregistry"),
210            Self::GlobalTheme => Some("lookandfeel.knsregistry"),
211            Self::PlasmaStyle => Some("plasma-themes.knsregistry"),
212            Self::AuroraeDecoration => Some("aurorae.knsregistry"),
213            Self::ColorScheme => Some("colorschemes.knsregistry"),
214            Self::SplashScreen => Some("ksplash.knsregistry"),
215            Self::SddmTheme => Some("sddmtheme.knsregistry"),
216            Self::IconTheme => Some("icons.knsregistry"),
217            Self::Wallpaper => Some("wallpaper.knsregistry"),
218        }
219    }
220
221    // -- Enumeration --
222
223    pub const fn all() -> &'static [ComponentType] {
224        &[
225            Self::PlasmaWidget,
226            Self::WallpaperPlugin,
227            Self::KWinEffect,
228            Self::KWinScript,
229            Self::KWinSwitcher,
230            Self::GlobalTheme,
231            Self::PlasmaStyle,
232            Self::AuroraeDecoration,
233            Self::ColorScheme,
234            Self::SplashScreen,
235            Self::SddmTheme,
236            Self::IconTheme,
237            Self::Wallpaper,
238        ]
239    }
240
241    pub const fn all_user() -> &'static [ComponentType] {
242        &[
243            Self::PlasmaWidget,
244            Self::WallpaperPlugin,
245            Self::KWinEffect,
246            Self::KWinScript,
247            Self::KWinSwitcher,
248            Self::GlobalTheme,
249            Self::PlasmaStyle,
250            Self::AuroraeDecoration,
251            Self::ColorScheme,
252            Self::SplashScreen,
253            Self::IconTheme,
254            Self::Wallpaper,
255        ]
256    }
257}
258
259impl std::fmt::Display for ComponentType {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::PlasmaWidget => write!(f, "Plasma Widget"),
263            Self::WallpaperPlugin => write!(f, "Wallpaper Plugin"),
264            Self::KWinEffect => write!(f, "KWin Effect"),
265            Self::KWinScript => write!(f, "KWin Script"),
266            Self::KWinSwitcher => write!(f, "KWin Switcher"),
267            Self::GlobalTheme => write!(f, "Global Theme"),
268            Self::PlasmaStyle => write!(f, "Plasma Style"),
269            Self::AuroraeDecoration => write!(f, "Aurorae Decoration"),
270            Self::ColorScheme => write!(f, "Color Scheme"),
271            Self::SplashScreen => write!(f, "Splash Screen"),
272            Self::SddmTheme => write!(f, "SDDM Theme"),
273            Self::IconTheme => write!(f, "Icon Theme"),
274            Self::Wallpaper => write!(f, "Wallpaper"),
275        }
276    }
277}
278
279// -- Internal types --
280
281/// A KDE component installed on the local system.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct InstalledComponent {
284    pub name: String,
285    pub directory_name: String,
286    pub version: String,
287    pub component_type: ComponentType,
288    #[serde(with = "pathbuf_serde")]
289    pub path: PathBuf,
290    pub is_system: bool,
291    pub release_date: String,
292}
293
294/// An available update for an installed component, with download metadata.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AvailableUpdate {
297    pub installed: InstalledComponent,
298    pub content_id: u64,
299    pub latest_version: String,
300    pub download_url: String,
301    pub store_url: String,
302    pub release_date: String,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub checksum: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub download_size: Option<u64>,
307}
308
309/// Builder for constructing [`AvailableUpdate`] instances with optional fields.
310pub(crate) struct AvailableUpdateBuilder {
311    installed: InstalledComponent,
312    content_id: u64,
313    latest_version: String,
314    download_url: String,
315    release_date: String,
316    checksum: Option<String>,
317    download_size: Option<u64>,
318}
319
320impl AvailableUpdateBuilder {
321    pub(crate) fn checksum(mut self, checksum: Option<String>) -> Self {
322        self.checksum = checksum;
323        self
324    }
325
326    pub(crate) fn download_size(mut self, size: Option<u64>) -> Self {
327        self.download_size = size;
328        self
329    }
330
331    pub(crate) fn build(self) -> AvailableUpdate {
332        let store_url = format!("https://store.kde.org/p/{}", self.content_id);
333        AvailableUpdate {
334            installed: self.installed,
335            content_id: self.content_id,
336            latest_version: self.latest_version,
337            download_url: self.download_url,
338            store_url,
339            release_date: self.release_date,
340            checksum: self.checksum,
341            download_size: self.download_size,
342        }
343    }
344}
345
346impl AvailableUpdate {
347    pub(crate) fn builder(
348        installed: InstalledComponent,
349        content_id: u64,
350        latest_version: String,
351        download_url: String,
352        release_date: String,
353    ) -> AvailableUpdateBuilder {
354        AvailableUpdateBuilder {
355            installed,
356            content_id,
357            latest_version,
358            download_url,
359            release_date,
360            checksum: None,
361            download_size: None,
362        }
363    }
364}
365
366/// An entry from the KDE Store API representing a published component.
367#[derive(Debug, Clone)]
368pub(crate) struct StoreEntry {
369    pub id: u64,
370    pub name: String,
371    pub version: String,
372    pub type_id: u16,
373    pub download_links: Vec<DownloadLink>,
374    pub changed_date: String,
375}
376
377/// A download link for a store entry, with optional checksum and size.
378#[derive(Debug, Clone)]
379pub(crate) struct DownloadLink {
380    pub url: String,
381    pub version: String,
382    pub checksum: Option<String>,
383    pub size_kb: Option<u64>,
384}
385
386/// Metadata parsed from a component's `metadata.json` or `metadata.desktop` file.
387#[derive(Debug, Clone, Default, Deserialize)]
388pub(crate) struct PackageMetadata {
389    #[serde(rename = "KPlugin")]
390    pub kplugin: Option<KPluginInfo>,
391}
392
393/// Plugin metadata from the `KPlugin` section of `metadata.json`.
394#[derive(Debug, Clone, Default, Deserialize, Serialize)]
395pub(crate) struct KPluginInfo {
396    #[serde(rename = "Name")]
397    pub name: Option<String>,
398    #[serde(rename = "Version")]
399    pub version: Option<String>,
400    #[serde(rename = "Description")]
401    pub description: Option<String>,
402    #[serde(rename = "Icon")]
403    pub icon: Option<String>,
404}
405
406impl PackageMetadata {
407    pub(crate) fn name(&self) -> Option<&str> {
408        self.kplugin.as_ref()?.name.as_deref()
409    }
410
411    pub(crate) fn version(&self) -> Option<&str> {
412        self.kplugin.as_ref()?.version.as_deref()
413    }
414}
415
416/// Diagnostic information about a component that could not be checked or updated.
417///
418/// Returned as part of [`CheckResult::diagnostics`](crate::CheckResult::diagnostics).
419/// Contains the component name, the reason it was skipped, and optional version/ID metadata.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct Diagnostic {
422    pub name: String,
423    pub reason: String,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub installed_version: Option<String>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub available_version: Option<String>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub content_id: Option<u64>,
430}
431
432impl Diagnostic {
433    pub(crate) fn new(name: String, reason: String) -> Self {
434        Self {
435            name,
436            reason,
437            installed_version: None,
438            available_version: None,
439            content_id: None,
440        }
441    }
442
443    pub(crate) fn with_versions(
444        mut self,
445        installed: Option<String>,
446        available: Option<String>,
447    ) -> Self {
448        self.installed_version = installed;
449        self.available_version = available;
450        self
451    }
452
453    pub(crate) fn with_content_id(mut self, id: u64) -> Self {
454        self.content_id = Some(id);
455        self
456    }
457}
458
459/// Internal result of checking for available updates, including diagnostics.
460#[derive(Debug, Clone, Default, Serialize, Deserialize)]
461pub(crate) struct UpdateCheckResult {
462    pub updates: Vec<AvailableUpdate>,
463    pub unresolved: Vec<Diagnostic>,
464    pub check_failures: Vec<Diagnostic>,
465}
466
467impl UpdateCheckResult {
468    pub fn add_update(&mut self, update: AvailableUpdate) {
469        self.updates.push(update);
470    }
471
472    pub fn add_unresolved(&mut self, diagnostic: Diagnostic) {
473        self.unresolved.push(diagnostic);
474    }
475
476    pub fn add_check_failure(&mut self, diagnostic: Diagnostic) {
477        self.check_failures.push(diagnostic);
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn shared_path_types_returns_both_for_global_theme() {
487        let types = ComponentType::GlobalTheme.shared_path_types();
488        assert!(types.contains(&ComponentType::GlobalTheme));
489        assert!(types.contains(&ComponentType::SplashScreen));
490        assert_eq!(types.len(), 2);
491    }
492
493    #[test]
494    fn shared_path_types_returns_both_for_splash_screen() {
495        let types = ComponentType::SplashScreen.shared_path_types();
496        assert!(types.contains(&ComponentType::GlobalTheme));
497        assert!(types.contains(&ComponentType::SplashScreen));
498        assert_eq!(types.len(), 2);
499    }
500
501    #[test]
502    fn plasma_widget_matches_extended_subcategories() {
503        assert!(ComponentType::PlasmaWidget.matches_type_id(705)); // parent
504        assert!(ComponentType::PlasmaWidget.matches_type_id(706)); // existing
505        assert!(ComponentType::PlasmaWidget.matches_type_id(714)); // previously missing
506        assert!(ComponentType::PlasmaWidget.matches_type_id(718)); // previously missing
507        assert!(ComponentType::PlasmaWidget.matches_type_id(723)); // existing
508        assert!(!ComponentType::PlasmaWidget.matches_type_id(100)); // unrelated
509        assert!(!ComponentType::PlasmaWidget.matches_type_id(800)); // out of range
510    }
511
512    #[test]
513    fn shared_path_types_returns_single_for_unique_path() {
514        assert_eq!(
515            ComponentType::PlasmaWidget.shared_path_types(),
516            &[ComponentType::PlasmaWidget]
517        );
518        assert_eq!(
519            ComponentType::KWinEffect.shared_path_types(),
520            &[ComponentType::KWinEffect]
521        );
522        assert_eq!(
523            ComponentType::IconTheme.shared_path_types(),
524            &[ComponentType::IconTheme]
525        );
526    }
527}
528
529mod pathbuf_serde {
530    use std::path::{Path, PathBuf};
531
532    use serde::{self, Deserialize, Deserializer, Serializer};
533
534    pub fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
535    where
536        S: Serializer,
537    {
538        serializer.serialize_str(&path.to_string_lossy())
539    }
540
541    pub fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
542    where
543        D: Deserializer<'de>,
544    {
545        let s = String::deserialize(deserializer)?;
546        Ok(PathBuf::from(s))
547    }
548}