Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
55 changes: 35 additions & 20 deletions src/i18n.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::RwLock;

static LOCALE: OnceLock<Locale> = OnceLock::new();
static LOCALE: RwLock<Option<Locale>> = RwLock::new(None);

pub struct Locale {
strings: HashMap<String, String>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1368,8 +1364,22 @@ pub fn init(preferred: Option<String>) {
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
Expand All @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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");
}
Expand Down
5 changes: 1 addition & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
53 changes: 53 additions & 0 deletions src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,15 @@ fn parse_desktop_file(path: &Path) -> Result<Application> {
"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)");
}
}
_ => {}
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading