Skip to main content

cnf_lib/provider/
flatpak.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
3// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>
4
5//! # Search packages with Flatpak
6
7use crate::provider::prelude::*;
8
9/// Provider for Flatpak packages.
10#[derive(Debug, Default, PartialEq)]
11// In the future these may get (mutable) internal state.
12#[allow(missing_copy_implementations)]
13pub struct Flatpak {}
14
15impl Flatpak {
16    /// Create a new Flatpak provider.
17    pub fn new() -> Self {
18        Default::default()
19    }
20}
21
22impl fmt::Display for Flatpak {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "Flatpak")
25    }
26}
27
28#[async_trait]
29impl IsProvider for Flatpak {
30    async fn search_internal(
31        &self,
32        command: &str,
33        target_env: Arc<Environment>,
34    ) -> ProviderResult<Vec<Candidate>> {
35        let mut state = FlatpakState::default();
36        state
37            .discover_remotes(&target_env)
38            .await
39            .map_err(|e| match e {
40                RemoteError::Execution(ExecutionError::NotFound(val)) => {
41                    ProviderError::Requirements(val)
42                }
43                RemoteError::Execution(e) => ProviderError::Execution(e),
44                err => ProviderError::ApplicationError(anyhow::Error::new(err)),
45            })?;
46
47        let candidates = state
48            .get_remote_flatpaks(&target_env, command)
49            .await
50            .map_err(Error::RemoteFlatpaks)?;
51
52        state
53            .check_installed_flatpaks(&target_env, candidates)
54            .await
55            .map_err(Error::LocalFlatpaks)
56            .map_err(|e| e.into())
57    }
58}
59
60#[derive(Debug, Default)]
61struct FlatpakState {
62    remotes: Vec<Remote>,
63}
64
65impl FlatpakState {
66    /// Discover all remotes configured in an environment.
67    ///
68    /// Remotes are stored internally for later use. You must call this function before
69    /// [`FlatpakState::get_remote_flatpaks()`] or [`FlatpakState::check_installed_flatpaks()`].
70    async fn discover_remotes(&mut self, env: &Arc<Environment>) -> Result<(), RemoteError> {
71        let stdout = env
72            .output_of(cmd!("flatpak", "remotes", "--columns=name:f,options:f"))
73            .await?;
74
75        // When there is no result, flatpak will still at least print a single newline char.
76        if stdout.trim().is_empty() {
77            return Err(RemoteError::NoRemote);
78        }
79
80        for line in stdout.lines() {
81            match line.split_once('\t') {
82                Some((name, opts)) => {
83                    let remote_type = if opts.contains("user") {
84                        RemoteType::User
85                    } else if opts.contains("system") {
86                        RemoteType::System
87                    } else {
88                        return Err(RemoteError::UnknownType(opts.to_string()));
89                    };
90
91                    let remote = Remote {
92                        name: name.to_string(),
93                        r#type: remote_type,
94                    };
95
96                    self.remotes.push(remote);
97                }
98                None => return Err(RemoteError::Parse(stdout)),
99            }
100        }
101
102        Ok(())
103    }
104
105    /// Retrieves all flatpaks in all remotes and filters them.
106    ///
107    /// Filtering is performed by performing a case-insensitive match against the `search_for`
108    /// argument. No fuzzy matching or similar is currently used.
109    async fn get_remote_flatpaks(
110        &mut self,
111        env: &Arc<Environment>,
112        search_for: &str,
113    ) -> Result<Vec<Candidate>, RemoteFlatpakError> {
114        // Sanity checks. No reason to be friendly here, this is a simple usage error.
115        debug_assert!(
116            !self.remotes.is_empty(),
117            "cannot query remote flatpaks without a remote"
118        );
119        let mut candidates: Vec<Candidate> = vec![];
120
121        for remote in &self.remotes {
122            let output = env
123                .output_of(cmd!(
124                    "flatpak",
125                    "remote-ls",
126                    "--app",
127                    "--cached",
128                    "--columns=application:f,version:f,origin:f,description:f",
129                    &remote.r#type.to_cli(),
130                    &remote.name
131                ))
132                .await
133                .map_err(|e| RemoteFlatpakError::Execution {
134                    remote: remote.clone(),
135                    source: e,
136                })?;
137
138            'next_line: for line in output.lines() {
139                let mut candidate = Candidate::default();
140                for (index, split) in line.split('\t').enumerate() {
141                    match index {
142                        0 => {
143                            if split.to_lowercase().contains(&search_for.to_lowercase()) {
144                                candidate.package = split.to_string();
145                            } else {
146                                continue 'next_line;
147                            }
148                        }
149                        1 => candidate.version = split.to_string(),
150                        2 => candidate.origin = remote.to_origin(),
151                        3 => candidate.description = split.to_string(),
152                        _ => warn!("superfluous fragment '{}' in line '{}'", split, line),
153                    }
154                }
155
156                if !candidate.package.is_empty() {
157                    let mut install_action = cmd!(
158                        "flatpak",
159                        "install",
160                        "--app",
161                        &remote.r#type.to_cli(),
162                        &remote.name,
163                        &candidate.package
164                    );
165                    install_action.needs_privileges(matches!(remote.r#type, RemoteType::System));
166                    candidate.actions.install = Some(install_action);
167
168                    candidate.actions.execute = cmd!(
169                        "flatpak".to_string(),
170                        "run".to_string(),
171                        remote.r#type.to_cli(),
172                        candidate.package.clone()
173                    );
174
175                    candidates.push(candidate);
176                }
177            }
178        }
179
180        Ok(candidates)
181    }
182
183    /// Take a list of candidates and update their installation status.
184    ///
185    /// Queries the locally installed flatpaks and updates the `action.install` metadata to reflect
186    /// the installation status.
187    async fn check_installed_flatpaks(
188        &self,
189        env: &Arc<Environment>,
190        mut candidates: Vec<Candidate>,
191    ) -> Result<Vec<Candidate>, LocalFlatpaksError> {
192        let output = env
193            .output_of(cmd!(
194                "flatpak",
195                "list",
196                "--app",
197                "--columns=application:f,version:f,origin:f,installation:f,description:f"
198            ))
199            .await?;
200
201        for candidate in candidates.iter_mut() {
202            if output.lines().any(|line| {
203                let (origin, installation) = candidate
204                    .origin
205                    .trim_end_matches(')')
206                    .split_once(" (")
207                    .with_context(|| {
208                        format!(
209                            "failed to unparse package origin '{}' into origin and installation",
210                            candidate.origin
211                        )
212                    })
213                    .to_log()
214                    .unwrap_or(("", ""));
215                line.contains(&candidate.package)
216                    && line.contains(&candidate.version)
217                    && line.contains(&candidate.description)
218                    && line.contains(origin)
219                    && line.contains(installation)
220            }) {
221                // Already installed
222                candidate.actions.install = None;
223            }
224        }
225
226        Ok(candidates)
227    }
228}
229
230/// Rust-representation of a configured flatpak remote
231#[derive(Debug, Default, PartialEq, Clone)]
232pub struct Remote {
233    /// Pure name of the remote
234    name: String,
235    /// Type of remote
236    r#type: RemoteType,
237}
238
239impl Remote {
240    /// Convert a remote into a human-readable origin representation.
241    pub fn to_origin(&self) -> String {
242        self.to_string()
243    }
244}
245
246impl fmt::Display for Remote {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        write!(f, "{} ({})", self.name, self.r#type)
249    }
250}
251
252/// Types of remote
253#[derive(Debug, Default, PartialEq, Clone)]
254pub enum RemoteType {
255    /// User default remote.
256    #[default]
257    User,
258    /// System default remote.
259    System,
260    /// A different, specific remote.
261    Other(String),
262}
263
264impl RemoteType {
265    /// Convert a remote type into an appropriate flatpak CLI flag.
266    pub fn to_cli(&self) -> String {
267        match self {
268            Self::User => "--user".to_string(),
269            Self::System => "--system".to_string(),
270            Self::Other(val) => format!("--installation={}", val),
271        }
272    }
273}
274
275impl fmt::Display for RemoteType {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        match self {
278            Self::User => write!(f, "user"),
279            Self::System => write!(f, "system"),
280            Self::Other(val) => write!(f, "{}", val),
281        }
282    }
283}
284
285/// Overarching error for Flatpak interactions.
286#[derive(Debug, ThisError)]
287pub enum Error {
288    /// Error with remote handling.
289    #[error("failed to obtain remotes to search in")]
290    Remote(#[from] RemoteError),
291
292    /// Error with local flatpak interactions.
293    #[error(transparent)]
294    LocalFlatpaks(#[from] LocalFlatpaksError),
295
296    /// Error with remote flatpak interactions.
297    #[error(transparent)]
298    RemoteFlatpaks(#[from] RemoteFlatpakError),
299}
300
301impl From<Error> for ProviderError {
302    fn from(value: Error) -> Self {
303        match value {
304            Error::Remote(RemoteError::Execution(ExecutionError::NotFound(val))) => {
305                ProviderError::Requirements(val)
306            }
307            _ => ProviderError::ApplicationError(anyhow::Error::new(value)),
308        }
309    }
310}
311
312/// Errors from trying to discover configured flatpak remotes.
313#[derive(Debug, ThisError, Display)]
314pub enum RemoteError {
315    /// no configured remote found
316    NoRemote,
317
318    /// failed to parse remote info from output: {0}
319    Parse(String),
320
321    /// failed to determine remote type from options: '{0}'
322    UnknownType(String),
323
324    /// failed to query configured flatpak remotes
325    Execution(#[from] ExecutionError),
326}
327
328/// Error with remote interactions.
329#[derive(Debug, ThisError, Display)]
330pub enum RemoteFlatpakError {
331    /// failed to query available applications from remote '{remote}': {source}
332    Execution {
333        /// The remote that failed to respond.
334        remote: Remote,
335        /// The underlying error source.
336        source: ExecutionError,
337    },
338}
339
340/// Error with local flatpak interactions.
341#[derive(Debug, ThisError, Display)]
342pub enum LocalFlatpaksError {
343    /// failed to query locally installed flatpaks
344    Execution(#[from] ExecutionError),
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::test::prelude::*;
351
352    test::default_tests!(Flatpak::new());
353
354    #[test]
355    fn no_remotes() {
356        let query = quick_test!(Flatpak::new(), Ok("\n".to_string()));
357
358        assert::is_err!(query);
359        assert::err::application!(query);
360    }
361
362    #[test]
363    fn single_remote_nonexistent_app() {
364        let query = quick_test!(
365            Flatpak::new(),
366            // The remotes
367            Ok("foobar\tuser\n".to_string()),
368            // Flatpaks in that remote
369            Ok("app.cool.my\t0.12.56-beta\tfoobar\tSome descriptive text\n".to_string()),
370            // Locally installed flatpaks
371            Ok("\n".to_string())
372        );
373
374        assert::is_err!(query);
375        assert::err::not_found!(query);
376    }
377
378    #[test]
379    fn single_remote_matching_app() {
380        let query = quick_test!(
381            Flatpak::new(),
382            // The remotes
383            Ok("foobar\tuser\n".to_string()),
384            // Flatpaks in that remote
385            Ok("app.cool.my-test-app\t0.12.56-beta\tfoobar\tSome descriptive text\n".to_string()),
386            // Locally installed flatpaks
387            Ok("\n".to_string())
388        );
389
390        let result = query.results.unwrap();
391        assert_eq!(result.len(), 1);
392        assert_eq!(result[0].package, "app.cool.my-test-app".to_string());
393        assert_eq!(result[0].origin, "foobar (user)".to_string());
394    }
395}