cnf_lib/provider/
flatpak.rs

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