use super::capture;
use crate::model::{Origin, Package, Source, World};
use std::time::UNIX_EPOCH;
pub fn available() -> bool {
super::have("flatpak")
}
pub fn augment(world: &mut World) {
let apps_raw = match capture(
"flatpak",
&[
"list",
"--app",
"--columns=application,name,description,version,size,origin,runtime",
],
) {
Ok(r) => r,
Err(_) => return,
};
let (mut apps, edges) = parse_apps(&apps_raw);
for pkg in &mut apps {
if let Some((epoch, date)) = deploy_date(&pkg.name) {
pkg.install_epoch = Some(epoch);
pkg.install_date = Some(date);
}
}
let runtimes = capture(
"flatpak",
&[
"list",
"--runtime",
"--columns=ref,name,version,size,origin",
],
)
.map(|r| parse_runtimes(&r))
.unwrap_or_default();
for pkg in apps.into_iter().chain(runtimes) {
if pkg.manual {
world.manual.insert(pkg.name.clone());
}
world.packages.insert(pkg.name.clone(), pkg);
}
for (app, runtime) in edges {
if world.packages.contains_key(&runtime) {
world
.deps
.entry(app.clone())
.or_default()
.push(runtime.clone());
world.rdeps.entry(runtime).or_default().push(app);
}
}
}
pub fn parse_apps(text: &str) -> (Vec<Package>, Vec<(String, String)>) {
let mut pkgs = Vec::new();
let mut edges = Vec::new();
for line in text.lines() {
let f: Vec<&str> = line.split('\t').collect();
let app_id = match f.first() {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
_ => continue,
};
let friendly = f.get(1).map(|s| s.trim()).unwrap_or("");
let blurb = f.get(2).map(|s| s.trim()).unwrap_or("");
let version = f.get(3).map(|s| s.trim()).unwrap_or("");
let size = f.get(4).map(|s| parse_size_kb(s.trim())).unwrap_or(0);
let remote = f.get(5).map(|s| s.trim()).filter(|s| !s.is_empty());
let runtime = f.get(6).map(|s| s.trim()).unwrap_or("");
if !runtime.is_empty() {
edges.push((app_id.clone(), runtime_key(runtime)));
}
pkgs.push(Package {
name: app_id,
version: version.to_string(),
candidate: None,
installed_size: size,
description: describe(friendly, blurb),
details: None,
manual: true, source: Source::Flatpak,
remote: remote.map(String::from),
origin: Origin::Repo,
install_epoch: None,
install_date: None,
});
}
(pkgs, edges)
}
pub fn parse_runtimes(text: &str) -> Vec<Package> {
text.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split('\t').collect();
let key = match f.first() {
Some(r) if !r.trim().is_empty() => runtime_key(r.trim()),
_ => return None,
};
let friendly = f.get(1).map(|s| s.trim()).unwrap_or("");
let version = f.get(2).map(|s| s.trim()).unwrap_or("");
let size = f.get(3).map(|s| parse_size_kb(s.trim())).unwrap_or(0);
let remote = f.get(4).map(|s| s.trim()).filter(|s| !s.is_empty());
Some(Package {
name: key,
version: version.to_string(),
candidate: None,
installed_size: size,
description: describe(friendly, "shared flatpak runtime"),
details: None,
manual: false, source: Source::Flatpak,
remote: remote.map(String::from),
origin: Origin::Repo,
install_epoch: None,
install_date: None,
})
})
.collect()
}
fn describe(friendly: &str, blurb: &str) -> String {
match (friendly.is_empty(), blurb.is_empty()) {
(false, false) => format!("{friendly} - {blurb}"),
(false, true) => friendly.to_string(),
(true, false) => blurb.to_string(),
(true, true) => String::new(),
}
}
fn runtime_key(s: &str) -> String {
let parts: Vec<&str> = s.trim_start_matches("runtime/").split('/').collect();
if parts.len() >= 3 {
format!("{}/{}", parts[0], parts[2]) } else {
s.to_string()
}
}
fn deploy_date(app_id: &str) -> Option<(i64, String)> {
let home = std::env::var("HOME").unwrap_or_default();
let candidates = [
format!("/var/lib/flatpak/app/{app_id}"),
format!("{home}/.local/share/flatpak/app/{app_id}"),
];
for path in candidates {
if let Ok(meta) = std::fs::metadata(&path)
&& let Ok(mtime) = meta.modified()
&& let Ok(since) = mtime.duration_since(UNIX_EPOCH)
{
let epoch = since.as_secs() as i64;
let date = chrono::DateTime::from_timestamp(epoch, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default();
return Some((epoch, date));
}
}
None
}
fn parse_size_kb(value: &str) -> u64 {
let mut it = value.split_whitespace();
let amount: f64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
let mult = match it.next().unwrap_or("") {
"B" | "bytes" => 1.0 / 1024.0,
"kB" | "KB" | "KiB" => 1.0,
"MB" | "MiB" => 1024.0,
"GB" | "GiB" => 1024.0 * 1024.0,
_ => 1.0,
};
(amount * mult) as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_apps_with_friendly_names_and_runtime_edge() {
let text = "im.riot.Riot\tElement\tSecure messenger\t1.12.24\t544.4 MB\tflathub\torg.freedesktop.Platform/x86_64/25.08\n\
org.gimp.GIMP\tGNU Image Manipulation Program\t\t3.2.4\t268.3 MB\tflathub\torg.gnome.Platform/x86_64/50\n";
let (apps, edges) = parse_apps(text);
assert_eq!(apps.len(), 2);
let riot = &apps[0];
assert_eq!(riot.name, "im.riot.Riot"); assert!(riot.description.starts_with("Element")); assert!(riot.manual);
assert_eq!(riot.source, Source::Flatpak);
assert_eq!(riot.remote.as_deref(), Some("flathub"));
assert!(riot.installed_size > 0);
assert!(edges.contains(&(
"im.riot.Riot".to_string(),
"org.freedesktop.Platform/25.08".to_string()
)));
}
#[test]
fn runtime_ref_normalises_to_id_branch() {
assert_eq!(
runtime_key("org.freedesktop.Platform/x86_64/25.08"),
"org.freedesktop.Platform/25.08"
);
assert_eq!(
runtime_key("runtime/org.gnome.Platform/x86_64/50"),
"org.gnome.Platform/50"
);
}
#[test]
fn runtimes_parse_as_non_manual() {
let text = "org.freedesktop.Platform/x86_64/25.08\tFreedesktop Platform\t25.08\t900.0 MB\tflathub\n";
let rts = parse_runtimes(text);
assert_eq!(rts.len(), 1);
assert_eq!(rts[0].name, "org.freedesktop.Platform/25.08");
assert!(!rts[0].manual);
assert_eq!(rts[0].source, Source::Flatpak);
}
#[test]
fn size_units() {
assert_eq!(parse_size_kb("2.0 MB"), 2048);
assert_eq!(parse_size_kb("1.0 GB"), 1024 * 1024);
assert_eq!(parse_size_kb("512 KiB"), 512);
}
}