diff --git a/src/download.rs b/src/download.rs index d09ca1b..c913010 100644 --- a/src/download.rs +++ b/src/download.rs @@ -417,6 +417,12 @@ pub async fn download_release_asset( if record_asset { crate::persistence::save_installed_asset(&repo_name, &filename)?; } + // Desktop integration (Linux): index the installed app in the + // desktop environment. Best-effort - a failure here must not fail + // an otherwise complete install. + if let Err(e) = crate::persistence::write_desktop_entry(&repo_name, &final_path) { + tracing::warn!("could not write desktop entry for {repo_name}: {e}"); + } Ok(final_path) }) diff --git a/src/i18n.rs b/src/i18n.rs index 9db9b79..201dc46 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::RwLock; -static LOCALE: OnceLock = OnceLock::new(); +static LOCALE: RwLock> = RwLock::new(None); pub struct Locale { strings: HashMap, @@ -244,10 +244,6 @@ impl Locale { "n_results_found".into(), "{count} résultat(s) pour « {query} »".into(), ); - strings.insert( - "language_restart_notice".into(), - "Le changement de langue prendra effet au prochain lancement.".into(), - ); strings.insert("theme_applied".into(), "Thème appliqué.".into()); // Keyboard shortcuts @@ -703,6 +699,8 @@ impl Locale { strings.insert("view_on_github".into(), "Voir sur GitHub".into()); strings.insert("installed_version".into(), "Installé : {version}".into()); strings.insert("launch_action".into(), "Lancer".into()); + strings.insert("section_security".into(), "Sécurité".into()); + strings.insert("language_changed".into(), "Langue changée".into()); strings.insert("launcher_update_system_managed".into(), "Mise à jour {version} disponible - cette installation est gérée par le gestionnaire de paquets, mettez à jour via « pacman -Syu » (colony-bin)".into()); // Detail tabs strings.insert("tab_readme".into(), "ReadMe".into()); @@ -918,10 +916,6 @@ impl Locale { "n_results_found".into(), "{count} result(s) for \"{query}\"".into(), ); - strings.insert( - "language_restart_notice".into(), - "Language change will take effect on next launch.".into(), - ); strings.insert("theme_applied".into(), "Theme applied.".into()); // Keyboard shortcuts @@ -1341,6 +1335,8 @@ impl Locale { strings.insert("view_on_github".into(), "View on GitHub".into()); strings.insert("installed_version".into(), "Installed: {version}".into()); strings.insert("launch_action".into(), "Launch".into()); + strings.insert("section_security".into(), "Security".into()); + strings.insert("language_changed".into(), "Language changed".into()); strings.insert("launcher_update_system_managed".into(), "Update {version} is available - this install is managed by the package manager, update via 'pacman -Syu' (colony-bin)".into()); // Detail tabs strings.insert("tab_readme".into(), "ReadMe".into()); @@ -1368,8 +1364,22 @@ pub fn init(preferred: Option) { let lang = preferred .filter(|l| l == "fr" || l == "en") .unwrap_or_else(detect_language); + set_language(&lang); +} + +/// Swap the active locale at runtime. Views call `t()` on every render, so +/// the whole UI re-labels on the next frame - no restart required (the locale +/// used to live in a OnceLock, forcing one). +pub fn set_language(lang: &str) { + let lang = if lang == "fr" || lang == "en" { + lang + } else { + "en" + }; tracing::info!("Locale: {lang}"); - LOCALE.get_or_init(|| Locale::new(&lang)); + if let Ok(mut locale) = LOCALE.write() { + *locale = Some(Locale::new(lang)); + } } /// Localized display name for a built-in sidebar section, keyed by its @@ -1388,23 +1398,24 @@ pub fn section_display_name(name: &str) -> String { "multimedia" => "section_multimedia", "system" => "section_system", "utilities" | "utility" => "section_utilities", + "security" => "section_security", "games" | "game" => "section_games", "other" => "section_other", _ => return name.to_string(), }; LOCALE - .get() - .and_then(|locale| locale.strings.get(key)) - .cloned() + .read() + .ok() + .and_then(|l| l.as_ref().and_then(|l| l.strings.get(key).cloned())) .unwrap_or_else(|| name.to_string()) } /// Get a translated string by key. pub fn t(key: &str) -> String { LOCALE - .get() - .and_then(|locale| locale.strings.get(key)) - .cloned() + .read() + .ok() + .and_then(|l| l.as_ref().and_then(|l| l.strings.get(key).cloned())) .unwrap_or_else(|| { tracing::warn!("Missing translation key: {key}"); key.to_string() @@ -1422,8 +1433,12 @@ pub fn t_fmt(key: &str, vars: &[(&str, &str)]) -> String { } /// Get the current language code. -pub fn current_lang() -> &'static str { - LOCALE.get().map(|l| l.lang.as_str()).unwrap_or("en") +pub fn current_lang() -> String { + LOCALE + .read() + .ok() + .and_then(|l| l.as_ref().map(|l| l.lang.clone())) + .unwrap_or_else(|| "en".to_string()) } /// Detect the user's language from environment. @@ -1492,7 +1507,7 @@ mod tests { #[test] fn t_fmt_substitution() { // Initialize with English for test - let _ = LOCALE.set(Locale::new("en")); + set_language("en"); let result = t_fmt("apps_found", &[("count", "42")]); assert_eq!(result, "42 applications found"); } diff --git a/src/main.rs b/src/main.rs index 5094756..3a2ce90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -196,10 +196,7 @@ impl App { // General restore_session: prefs.restore_session.unwrap_or(true), default_view: prefs.default_view.clone().unwrap_or_else(|| "all".into()), - language: prefs - .language - .clone() - .unwrap_or_else(|| i18n::current_lang().to_string()), + language: prefs.language.clone().unwrap_or_else(i18n::current_lang), // ON by default: a store that never checks for updates leaves its // badges permanently invisible. The check is cheap (batched; zero // API calls for apps pinned to a fixed tag). diff --git a/src/persistence.rs b/src/persistence.rs index 1ffeff1..3493dc2 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -259,6 +259,59 @@ pub struct CachedApp { pub origin: String, } +/// Write a `.desktop` launcher entry for an installed store app, so desktop +/// environments (rofi/wofi/GNOME/KDE) index it like any other application. +/// The entry is tagged `X-Colony-Managed=true`, which Colony's own scan skips +/// (the app is already represented by its store card). Linux only; no-op +/// elsewhere. +#[cfg(target_os = "linux")] +pub fn write_desktop_entry(repo_name: &str, exec_path: &std::path::Path) -> Result<()> { + let dir = dirs::data_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot determine data directory"))? + .join("applications"); + std::fs::create_dir_all(&dir)?; + let icon_line = repo_icon_dir(repo_name) + .ok() + .map(|d| d.join("icon.png")) + .filter(|p| p.exists()) + .map(|p| format!("Icon={}\n", p.display())) + .unwrap_or_default(); + let entry = format!( + "[Desktop Entry]\nType=Application\nName={repo_name}\nExec=\"{}\"\nTerminal=false\nCategories=Utility;\nComment=Installed by Colony\nX-Colony-Managed=true\n{icon_line}", + exec_path.display() + ); + std::fs::write(dir.join(desktop_entry_filename(repo_name)), entry)?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn write_desktop_entry(_repo_name: &str, _exec_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +/// Remove the `.desktop` entry written by [`write_desktop_entry`] (no-op when +/// absent or on non-Linux platforms). +pub fn remove_desktop_entry(repo_name: &str) { + #[cfg(target_os = "linux")] + if let Some(data) = dirs::data_dir() { + let path = data + .join("applications") + .join(desktop_entry_filename(repo_name)); + if path.exists() { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("failed to remove desktop entry {}: {e}", path.display()); + } + } + } + #[cfg(not(target_os = "linux"))] + let _ = repo_name; +} + +#[cfg(target_os = "linux")] +fn desktop_entry_filename(repo_name: &str) -> String { + format!("colony-{}.desktop", repo_name.to_lowercase()) +} + /// Remove doc/icon caches for repos that are NO LONGER in the catalog, so a /// deleted or renamed repo does not leave its caches behind forever. Runs /// after each successful catalog fetch (never on a cache fallback, where a diff --git a/src/scan.rs b/src/scan.rs index 9b78534..0d940bb 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -602,6 +602,15 @@ fn parse_desktop_file(path: &Path) -> Result { "Categories" => categories = value.to_string(), "NoDisplay" => no_display = value.eq_ignore_ascii_case("true"), "Hidden" => hidden = value.eq_ignore_ascii_case("true"), + // Entries Colony itself wrote for installed store apps: real + // desktop launchers should show them, but Colony's own scan + // must skip them - the app is already represented by its + // store card, a local duplicate would appear twice. + "X-Colony-Managed" => { + if value.eq_ignore_ascii_case("true") { + anyhow::bail!("Colony-managed entry (represented by its store card)"); + } + } _ => {} } } diff --git a/src/update.rs b/src/update.rs index 9b48a6a..addb7fe 100644 --- a/src/update.rs +++ b/src/update.rs @@ -654,6 +654,7 @@ impl App { // belong to the CATALOG entry, which is still listed - orphan // cleanup happens on catalog refresh instead.) self.release_notes.remove(&repo_name); + crate::persistence::remove_desktop_entry(&repo_name); match github::colony_apps_dir() { Ok(apps_dir) => { let app_dir = apps_dir.join(&repo_name); @@ -1145,7 +1146,11 @@ impl App { Message::PickLanguage(v) => { self.language = v; self.save_preferences(); - self.push_notification(i18n::t("language_restart_notice"), NotificationLevel::Info) + // Live swap: every view calls t() per render, so the whole UI + // re-labels on the next frame - the restart notice is history. + i18n::set_language(&self.language); + self.status_message = i18n::t("language_changed"); + Task::none() } Message::ToggleAutoCheckUpdates => { self.auto_check_updates = !self.auto_check_updates;