can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-05-24 18:57:01 +08:00
parent f4996e495f
commit d823edc14e
12 changed files with 1971 additions and 573 deletions

14
breadbox-sync/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "breadbox-sync"
version = "0.1.0"
edition = "2021"
license = "MIT"
[[bin]]
name = "breadbox-sync"
path = "src/main.rs"
[dependencies]
breadbox-shared = { path = "../breadbox-shared" }
serde_json = "1"
ureq = "2"

281
breadbox-sync/src/main.rs Normal file
View file

@ -0,0 +1,281 @@
use std::{
collections::HashMap,
env,
fs,
io::Read,
path::{Path, PathBuf},
};
use breadbox_shared::{home_dir, IconCache};
// ---- Icon theme lookup ------------------------------------------------------
fn current_icon_theme() -> String {
let home = home_dir();
for cfg in [
home.join(".config/gtk-4.0/settings.ini"),
home.join(".config/gtk-3.0/settings.ini"),
] {
if let Ok(content) = fs::read_to_string(&cfg) {
for line in content.lines() {
if let Some(v) = line.strip_prefix("gtk-icon-theme-name=") {
let t = v.trim().trim_matches('"');
if !t.is_empty() {
return t.to_string();
}
}
}
}
}
"hicolor".to_string()
}
fn icon_search_dirs() -> Vec<PathBuf> {
let home = home_dir();
let xdg_data_home = env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"));
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
for d in [
xdg_data_home.join("icons"),
home.join(".local/share/icons"),
PathBuf::from("/usr/share/icons"),
] {
if seen.insert(d.clone()) {
dirs.push(d);
}
}
dirs
}
/// Search for `name` in system icon theme directories.
/// Prefers 64px > 48px > 128px > 32px > 256px PNG, then scalable SVG.
fn find_system_icon(name: &str, theme: &str) -> Option<PathBuf> {
let sizes = ["64x64", "48x48", "128x128", "32x32", "256x256"];
let dirs = icon_search_dirs();
let themes: Vec<&str> = if theme != "hicolor" {
vec![theme, "hicolor"]
} else {
vec!["hicolor"]
};
for dir in &dirs {
for t in &themes {
for size in &sizes {
let p = dir.join(t).join(size).join("apps").join(format!("{}.png", name));
if p.exists() {
return Some(p);
}
// Alternative path layout: <theme>/apps/<size>/
let p2 = dir.join(t).join("apps").join(size).join(format!("{}.png", name));
if p2.exists() {
return Some(p2);
}
}
// SVG (scalable)
for subdir in ["scalable/apps", "apps/scalable"] {
let p = dir.join(t).join(subdir).join(format!("{}.svg", name));
if p.exists() {
return Some(p);
}
}
}
}
// /usr/share/pixmaps
for ext in ["png", "svg", "xpm"] {
let p = PathBuf::from("/usr/share/pixmaps").join(format!("{}.{}", name, ext));
if p.exists() {
return Some(p);
}
}
None
}
// ---- Helpers ----------------------------------------------------------------
/// Strip file extension from an icon field value, returning the canonical name.
fn canonical_icon_name(icon: &str) -> String {
if icon.starts_with('/') {
return Path::new(icon)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(icon)
.to_string();
}
icon.strip_suffix(".png")
.or_else(|| icon.strip_suffix(".svg"))
.or_else(|| icon.strip_suffix(".xpm"))
.unwrap_or(icon)
.to_string()
}
/// A stem like `org.gnome.Gedit` or `com.github.App` — at least three segments,
/// all alphanumeric/hyphen/underscore.
fn looks_like_reverse_dns(stem: &str) -> bool {
let parts: Vec<&str> = stem.split('.').collect();
parts.len() >= 3
&& parts[0].len() >= 2
&& parts.iter().all(|p| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
})
}
/// Try to GET `url` and write the body to `dest`. Returns true on success.
fn try_download(agent: &ureq::Agent, url: &str, dest: &Path) -> bool {
let resp = match agent.get(url).call() {
Ok(r) if r.status() == 200 => r,
_ => return false,
};
let mut bytes = Vec::new();
if resp.into_reader().take(2_097_152).read_to_end(&mut bytes).is_err() || bytes.is_empty() {
return false;
}
// Validate the PNG signature so a 200 error page is never cached as an icon.
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
if !bytes.starts_with(&PNG_MAGIC) {
return false;
}
fs::write(dest, &bytes).is_ok()
}
/// Resolve an icon to a local path, downloading if necessary.
/// Returns None only if all strategies fail and no generic fallback is found.
fn resolve_icon(
icon_field: &str,
desktop_stem: &str,
theme: &str,
icon_cache: &IconCache,
agent: &ureq::Agent,
) -> Option<PathBuf> {
// Absolute path in Icon= field
if icon_field.starts_with('/') {
let p = PathBuf::from(icon_field);
if p.exists() {
return Some(p);
}
}
let name = canonical_icon_name(icon_field);
if name.is_empty() {
return find_system_icon("application-x-executable", theme);
}
// 1. System icon theme
if let Some(p) = find_system_icon(&name, theme) {
return Some(p);
}
// Already cached from a previous run?
let cached = icon_cache.path_for(&name);
if cached.exists() {
return Some(cached);
}
// 2. Flathub (appstream icon path, not the media CDN)
if looks_like_reverse_dns(desktop_stem) {
let url = format!(
"https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/{}.png",
desktop_stem
);
let dest = icon_cache.path_for(desktop_stem);
if try_download(agent, &url, &dest) {
eprintln!(" [flathub] {}", desktop_stem);
return Some(dest);
}
}
// 3. Generic fallback
find_system_icon("application-x-executable", theme)
}
// ---- Main -------------------------------------------------------------------
fn main() {
if let Err(e) = run() {
eprintln!("breadbox-sync: {}", e);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let icon_cache = IconCache::new();
icon_cache.ensure_dir()?;
let theme = current_icon_theme();
eprintln!("breadbox-sync: icon theme = {}", theme);
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let mut manifest: HashMap<String, String> = HashMap::new();
// Walk directories directly to get both the entry and its filename stem
// (needed for Flathub reverse-DNS resolution).
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for dir in breadbox_shared::app_dirs() {
let Ok(read_dir) = fs::read_dir(&dir) else { continue };
for file_entry in read_dir.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// User-local overrides system; process in dir order (system first, local last).
// Later entries for the same stem will overwrite earlier ones in the manifest.
let app = match breadbox_shared::parse_desktop(&path) {
Some(a) => a,
None => continue,
};
if app.icon_name.is_empty() {
continue;
}
// Deduplicate by the raw Icon= value, which is also the manifest key,
// so every distinct icon_name gets its own entry.
if !seen.insert(app.icon_name.clone()) {
continue;
}
eprint!("resolving icon for {} ({}) ... ", app.name, app.icon_name);
match resolve_icon(&app.icon_name, &stem, &theme, &icon_cache, &agent) {
Some(p) => {
eprintln!("{}", p.display());
manifest.insert(app.icon_name.clone(), p.to_string_lossy().into_owned());
}
None => {
eprintln!("not found");
}
}
}
}
let manifest_path = IconCache::manifest_path();
let json = serde_json::to_string_pretty(&manifest)?;
let tmp = manifest_path.with_extension("tmp");
fs::write(&tmp, &json)?;
fs::rename(&tmp, &manifest_path)?;
eprintln!(
"breadbox-sync: wrote manifest ({} entries) to {}",
manifest.len(),
manifest_path.display()
);
Ok(())
}