From c39283352d2bd7a8d8eaa9351708a87d81d45d6a Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 25 Jul 2026 13:27:09 +0200 Subject: [PATCH 1/3] refactor(update): split the message dispatch into per-domain modules update.rs was the most-edited file in the project (24 commits in six months) and held all 70 match arms in one 1836-line function, so every feature landed in the same place and every diff read the same. mod.rs now keeps the dispatch table alone - one line per message, the whole protocol visible at a glance - and the bodies live next to their domain: store.rs install, update, uninstall, release notes, favorites github_auth.rs device-flow login and catalog fetching launcher.rs self-update: check, download, apply, relaunch preferences.rs settings panel and every user toggle keyboard.rs global key handling onboarding.rs first-launch welcome and tutorial Pure move: no behaviour change. Arm bodies became methods returning Task, so a `return` inside one still yields the same value from update(); the arms are distinct variants, so order was never significant. rustc's exhaustiveness check plus a clean clippy -D warnings prove the table still covers all 70 variants with no redundant arm. --- src/update.rs | 1836 ------------------------------------- src/update/github_auth.rs | 183 ++++ src/update/keyboard.rs | 151 +++ src/update/launcher.rs | 194 ++++ src/update/mod.rs | 820 +++++++++++++++++ src/update/onboarding.rs | 50 + src/update/preferences.rs | 140 +++ src/update/store.rs | 504 ++++++++++ 8 files changed, 2042 insertions(+), 1836 deletions(-) delete mode 100644 src/update.rs create mode 100644 src/update/github_auth.rs create mode 100644 src/update/keyboard.rs create mode 100644 src/update/launcher.rs create mode 100644 src/update/mod.rs create mode 100644 src/update/onboarding.rs create mode 100644 src/update/preferences.rs create mode 100644 src/update/store.rs diff --git a/src/update.rs b/src/update.rs deleted file mode 100644 index 7582b8c..0000000 --- a/src/update.rs +++ /dev/null @@ -1,1836 +0,0 @@ -use iced::Task; -use std::time::Duration; - -use crate::github; -use crate::i18n; -use crate::message::Message; -use crate::oauth; -use crate::scan; -use crate::state::{App, DetailTab, GitHubState, Notification, NotificationLevel}; -use crate::ui::markdown_blocks; -use crate::ui::theme::{ - accent_key_to_color, set_active_accent, set_active_theme, set_high_contrast, -}; - -impl App { - /// The OAuth token of the signed-in session, if any - the one-liner that - /// used to be copy-pasted at five call sites. - fn github_token(&self) -> Option { - if let GitHubState::Connected { session, .. } = &self.github_state { - Some(session.access_token.clone()) - } else { - None - } - } - - pub fn push_notification( - &mut self, - message: String, - level: NotificationLevel, - ) -> Task { - let id = self.next_notification_id; - self.next_notification_id += 1; - let timeout = level.timeout(); - self.notifications - .push(Notification::new(id, message, level)); - // When reduce_motion or animations off, don't auto-dismiss (user must click) - if self.reduce_motion || !self.animations { - Task::none() - } else { - Task::perform( - async move { - tokio::time::sleep(timeout).await; - }, - |_| Message::TickNotifications, - ) - } - } - - /// Decode any cached app icons that aren't yet in memory into image handles, - /// keyed by repo name. Runs when repos load; cheap and idempotent (skips - /// repos already decoded). Repos without a cached icon keep the hexagon. - pub fn reload_app_icons(&mut self) { - let names: Vec = self - .colony_repos() - .iter() - .map(|repo| repo.name.clone()) - .collect(); - for name in names { - if self.app_icons.contains_key(&name) { - continue; - } - if let Some(bytes) = crate::persistence::load_repo_icon(&name) { - if let Some(handle) = crate::icons::decode_icon(&bytes) { - self.app_icons.insert(name, handle); - } - } - } - } - - /// Rebuild the per-repo install-status cache (one filesystem pass). The - /// grid and detail views read ONLY this cache - never the disk - so it - /// must be called whenever an install can have changed: catalog load, - /// download completion, uninstall. - pub fn refresh_install_status(&mut self) { - self.install_status = self - .colony_repo_list - .iter() - .map(|repo| { - let installed = crate::persistence::installed_app_path(repo).is_some(); - let version = if installed { - crate::persistence::load_installed_version(&repo.name) - } else { - None - }; - (repo.name.clone(), (installed, version)) - }) - .collect(); - } - - /// Pop the next repo queued by "Update all" and start its download; no-op - /// when the queue is empty. Called from BOTH completion arms so one failed - /// install never strands the remaining queue. - fn dispatch_next_queued_update(&mut self) -> Task { - if self.update_queue.is_empty() { - return Task::none(); - } - let next = self.update_queue.remove(0); - let platform = github::current_platform_key().to_string(); - Task::done(Message::DownloadRelease(next, platform)) - } - - /// Rebuild `detail_blocks` for the currently-viewed (repo, tab) if that - /// key differs from the last parse. Cheap no-op when the cache is valid. - pub fn refresh_detail_markdown(&mut self) { - let Some(repo) = self.active_repo().cloned() else { - self.detail_blocks.clear(); - self.detail_md_source = None; - self.detail_is_placeholder = false; - return; - }; - let key = (repo.name.clone(), self.detail_tab); - if self.detail_md_source.as_ref() == Some(&key) { - return; - } - // Read the doc once here (cached) instead of twice per frame in the - // view. `is_placeholder` records tabs that have no document so the view - // does no disk I/O. - let (content, is_placeholder) = match self.detail_tab { - DetailTab::ReadMe => (repo.description.clone(), false), - DetailTab::License => match crate::persistence::read_repo_doc(&repo.name, "LICENSE.md") - { - Some(c) => (c, false), - None => (String::new(), true), - }, - DetailTab::Changelog => { - match crate::persistence::read_repo_doc(&repo.name, "CHANGELOG.md") { - Some(c) => (c, false), - None => (String::new(), true), - } - } - }; - self.detail_blocks = markdown_blocks::parse(&content); - self.detail_is_placeholder = is_placeholder; - self.detail_md_source = Some(key); - } - - pub fn save_preferences(&self) { - let prefs = crate::persistence::UserPreferences { - selected_section: Some(self.selected_section), - window_width: Some(self.window_size.0), - window_height: Some(self.window_size.1), - first_launch_done: Some(!self.show_first_launch), - selected_theme: Some(self.selected_theme.clone()), - selected_variant: Some(self.selected_variant.clone()), - selected_accent: Some(self.selected_accent.clone()), - // General - restore_session: Some(self.restore_session), - default_view: Some(self.default_view.clone()), - close_behavior: None, - language: Some(self.language.clone()), - auto_check_updates: Some(self.auto_check_updates), - update_channel: None, - auto_install_updates: None, - // Appearance - font_size: Some(self.font_size.clone()), - animations: Some(self.animations), - // Accessibility - high_contrast: Some(self.high_contrast), - text_size_a11y: Some(self.text_size_a11y.clone()), - reduce_motion: Some(self.reduce_motion), - keyboard_nav: Some(self.keyboard_nav), - dyslexia_font: Some(self.dyslexia_font), - // Storage - scan_on_startup: Some(self.scan_on_startup), - }; - if let Err(e) = crate::persistence::save_preferences(&prefs) { - tracing::warn!("Failed to save preferences: {e}"); - } - } - - pub fn update(&mut self, message: Message) -> Task { - match message { - Message::SearchChanged(query) => { - self.search_query = query; - // The visible rows changed: a stale highlight would point at - // a hidden item. - self.keyboard_cursor = None; - Task::none() - } - Message::SectionSelected(index) => { - if index < self.sections.len() { - // Start sidebar animation from current visual position - self.sidebar_indicator_from = self.sidebar_indicator_pos(); - self.sidebar_indicator_target = index as f32 * 44.0; - self.sidebar_indicator_start = Some(std::time::Instant::now()); - self.selected_section = index; - self.active_colony_repo = None; - self.keyboard_cursor = None; - // Dismiss any open overlay panel so the section change is - // actually visible — otherwise users stay stuck on the - // GitHub / Settings panel even though the underlying - // filter just changed. - self.show_github_menu = false; - self.show_settings = false; - self.save_preferences(); - } - Task::none() - } - Message::Rescan => { - if self.is_scanning { - return Task::none(); - } - self.is_scanning = true; - self.status_message = i18n::t("scanning"); - Task::perform( - async { - tokio::task::spawn_blocking(scan::scan_applications) - .await - .map_err(|e| anyhow::anyhow!("{e}"))? - .map_err(|e| anyhow::anyhow!(e.to_string())) - }, - |result| match result { - Ok(apps) => Message::RescanCompleted(Ok(apps)), - Err(e) => Message::RescanCompleted(Err(e.to_string())), - }, - ) - } - Message::RescanCompleted(result) => { - self.is_scanning = false; - match result { - Ok(apps) => { - self.status_message = - i18n::t_fmt("apps_found", &[("count", &apps.len().to_string())]); - // Refresh the offline scan cache (previously written on - // the boot path, now that the scan runs off-thread). - let cached: Vec = apps - .iter() - .map(|app| crate::persistence::CachedApp { - name: app.name.clone(), - exec: app.exec.clone(), - icon: app.icon.clone(), - category: format!("{:?}", app.category), - origin: format!("{:?}", app.origin), - }) - .collect(); - if let Err(e) = crate::persistence::save_scan_cache(&cached) { - tracing::warn!("Failed to save scan cache: {e}"); - } - self.applications = apps; - } - Err(e) => { - self.status_message = i18n::t_fmt("scan_error", &[("error", &e)]); - } - } - Task::none() - } - Message::LaunchApp(exec) => { - let launch_result = { - #[cfg(windows)] - { - // A scanned Windows entry is often a `.lnk`, which - // CreateProcess cannot run, so this one genuinely needs - // `start`. Build the command line with raw_arg and quote - // the target ourselves: Rust only quotes arguments that - // contain whitespace, which would leave cmd to re-parse - // `&`, `|` and friends in the path as separators. A quote - // in the path would escape our quoting, so reject it. - use std::os::windows::process::CommandExt; - if exec.contains('"') || exec.chars().any(|c| c.is_control()) { - Err(i18n::t_fmt( - "launch_error", - &[("error", "unsupported characters in application path")], - )) - } else { - std::process::Command::new("cmd") - .raw_arg(format!("/C start \"\" \"{exec}\"")) - .spawn() - .map(|_| ()) - .map_err(|error| { - i18n::t_fmt("launch_error", &[("error", &error.to_string())]) - }) - } - } - - #[cfg(not(windows))] - { - match shell_words::split(&exec) { - Ok(mut parts) => { - parts.retain(|part| !part.is_empty()); - if let Some((cmd, args)) = parts.split_first() { - std::process::Command::new(cmd) - .args(args) - .spawn() - .map(|_| ()) - .map_err(|error| { - i18n::t_fmt( - "launch_error", - &[("error", &error.to_string())], - ) - }) - } else { - Err(i18n::t("launch_error_empty")) - } - } - Err(error) => Err(i18n::t_fmt( - "launch_error", - &[("error", &error.to_string())], - )), - } - } - }; - - match launch_result { - Ok(()) => { - self.status_message = i18n::t("app_launched"); - Task::perform( - async { - tokio::time::sleep(Duration::from_secs(4)).await; - }, - |_| Message::ClearStatus, - ) - } - Err(msg) => { - self.status_message = msg.clone(); - self.push_notification(msg, NotificationLevel::Error) - } - } - } - Message::ColonyRepoSelected(name) => { - self.active_colony_repo = Some(name); - // A selection made from the GitHub panel must actually show - // the detail page, not stay hidden behind the overlay. - self.show_github_menu = false; - self.refresh_detail_markdown(); - Task::none() - } - Message::ColonyRepoBack => { - self.active_colony_repo = None; - self.confirm_uninstall = None; - self.detail_tab = crate::state::DetailTab::ReadMe; - Task::none() - } - Message::ClearStatus => { - self.status_message = i18n::t_fmt( - "apps_found", - &[("count", &self.applications.len().to_string())], - ); - Task::none() - } - Message::FontLoaded(_) => Task::none(), - - // --- GitHub / OAuth --- - Message::ToggleGitHubMenu => { - self.show_github_menu = !self.show_github_menu; - Task::none() - } - Message::GitHubLogin => { - self.github_state = GitHubState::Connecting { user_code: None }; - Task::perform( - async { - oauth::request_device_code() - .await - .map_err(|e| e.to_string()) - }, - Message::GitHubDeviceCodeReceived, - ) - } - Message::GitHubDeviceCodeReceived(result) => match result { - Ok(device) => { - self.github_state = GitHubState::Connecting { - user_code: Some(device.user_code.clone()), - }; - Task::perform( - async move { - oauth::poll_for_token(device) - .await - .map_err(|e| e.to_string()) - }, - Message::GitHubLoginCompleted, - ) - } - Err(e) => { - self.github_state = GitHubState::Error(e.clone()); - self.status_message = i18n::t_fmt("oauth_error", &[("error", &e)]); - self.push_notification( - i18n::t_fmt("oauth_error", &[("error", &e)]), - NotificationLevel::Error, - ) - } - }, - Message::GitHubLoginCompleted(result) => { - match result { - Ok(session) => { - self.github_state = GitHubState::Connected { - session: session.clone(), - }; - self.status_message = format!( - "{}{}", - i18n::t("github_connected"), - session - .username - .as_ref() - .map(|u| format!(" ({u})")) - .unwrap_or_default() - ); - let token = session.access_token.clone(); - // Guard the refetch like every other fetch path, so a - // concurrent GitHubRefreshRepos cannot double-fetch. - self.is_fetching_repos = true; - Task::perform( - async move { github::fetch_colony_repos(Some(&token)).await }, - |result| match result { - Ok(repos) => Message::GitHubReposFetched(repos), - Err(e) => Message::GitHubError(e.to_string()), - }, - ) - } - Err(e) => { - self.github_state = GitHubState::Error(e.clone()); - self.status_message = i18n::t_fmt("oauth_error", &[("error", &e)]); - self.push_notification( - i18n::t_fmt("oauth_error", &[("error", &e)]), - NotificationLevel::Error, - ) - } - } - } - Message::GitHubLogout => { - let _ = oauth::delete_saved_token(); - self.github_state = GitHubState::Disconnected; - self.status_message = i18n::t("github_disconnected"); - Task::none() - } - Message::GitHubReposFetched(repos) => { - self.is_fetching_repos = false; - let count = repos.len(); - if let Err(e) = crate::persistence::save_repos_cache(&repos) { - tracing::warn!("Failed to save repos cache: {e}"); - } - // The catalog is stored regardless of sign-in state: anonymous - // fetches land here too. - self.colony_repo_list = repos; - // A successful fetch is the one moment we KNOW which repos - // exist: drop doc/icon caches of repos that left the catalog. - let live: Vec = self - .colony_repo_list - .iter() - .map(|r| r.name.clone()) - .collect(); - crate::persistence::prune_orphaned_repo_caches(&live); - // Decode any freshly-cached app icons into image handles. - self.reload_app_icons(); - self.refresh_install_status(); - // New docs may have landed for the repo currently viewed. - self.detail_md_source = None; - self.refresh_detail_markdown(); - self.status_message = - i18n::t_fmt("github_repos_detected", &[("count", &count.to_string())]); - if self.auto_check_updates { - Task::done(Message::CheckUpdates) - } else { - Task::none() - } - } - Message::GitHubError(e) => { - self.is_fetching_repos = false; - tracing::error!(error = %e, "GitHub error"); - if self.colony_repo_list.is_empty() { - if let Some(cached) = crate::persistence::load_repos_cache() { - tracing::info!("Using {} cached repos as fallback", cached.len()); - self.colony_repo_list = cached; - } - } - // Offline fallback repos may have cached icons on disk. - self.reload_app_icons(); - self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); - if self.colony_repo_list.is_empty() { - self.push_notification( - i18n::t_fmt("github_api_error", &[("error", &e)]), - NotificationLevel::Error, - ) - } else { - // The catalog is showing (cached or previously fetched): a - // toast on every offline boot would be pure noise - the - // status line already carries the error. Only an EMPTY - // catalog warrants interrupting the user. - Task::none() - } - } - Message::DownloadRelease(repo_name, platform_key) => { - if self.is_downloading { - return Task::none(); - } - let repos = self.colony_repos(); - if let Some(repo) = repos.iter().find(|r| r.name == repo_name) { - if let Some(entry) = repo.manifest.release_files.get(&platform_key) { - let tag = entry.tag.clone(); - let file = entry.file.clone(); - let file_pattern = entry.file_pattern.clone(); - let binary = entry.binary.clone(); - let expected_sha256 = entry.sha256.clone(); - // Only what the manifest declares; the installer ORs in - // its own pin from any previously verified install, so - // that rule lives next to the check it feeds. - let require_signature = repo.manifest.signed; - let repo_name = repo.name.clone(); - // API calls (release resolution) use the token for - // rate limits; the asset download itself is a public - // endpoint and gets NO token - no reason to present - // credentials where none are needed. - let token = self.github_token(); - let display_name = file - .as_deref() - .or(file_pattern.as_deref()) - .unwrap_or(&repo.name) - .to_string(); - self.status_message = - i18n::t_fmt("downloading", &[("file", &display_name)]); - self.download_progress = Some((display_name.clone(), 0.0)); - self.is_downloading = true; - self.downloading_repo = Some(repo_name.clone()); - let dl_repo = repo_name.clone(); - let (progress_tx, progress_rx) = - futures::channel::mpsc::unbounded::<(u64, Option)>(); - let progress_name = display_name; - - let download_task = Task::perform( - async move { - // Fetch release info if we need tag resolution or asset matching - let needs_release_info = - tag.eq_ignore_ascii_case("latest") || file_pattern.is_some(); - - let (resolved_tag, resolved_file) = if needs_release_info { - let client = github::build_update_client(token.as_deref())?; - let release_info = - github::fetch_release_info(&client, &repo_name, &tag) - .await?; - let filename = if let Some(ref f) = file { - f.clone() - } else if let Some(ref pattern) = file_pattern { - github::find_asset_by_pattern( - &release_info.asset_names, - pattern, - )? - } else { - anyhow::bail!( - "colony.json: 'file' or 'filePattern' is required" - ); - }; - (release_info.tag, filename) - } else { - let f = file.ok_or_else(|| { - anyhow::anyhow!( - "colony.json: 'file' or 'filePattern' is required" - ) - })?; - (tag, f) - }; - - // The version/asset records are written by - // download_release_asset itself, inside the - // blocking install step: writing them here (or - // in DownloadCompleted) meant a cancel landing - // mid-install detached the blocking task and - // left an installed binary with no metadata. - let path = crate::download::download_release_asset( - None, - crate::download::AssetInstall { - repo_name: repo_name.clone(), - tag: resolved_tag.clone(), - filename: resolved_file.clone(), - binary_name: binary, - expected_sha256, - record_asset: file_pattern.is_some(), - require_signature, - }, - Some(progress_tx), - ) - .await?; - - Ok((path, dl_repo, resolved_tag)) - }, - |result: Result<_, anyhow::Error>| { - Message::DownloadCompleted(result.map_err(|e| e.to_string())) - }, - ); - - let progress_task = Task::run(progress_rx, move |(downloaded, total)| { - Message::DownloadProgress(progress_name.clone(), downloaded, total) - }); - - // Keep an abort handle so CancelDownload actually stops - // the download and its progress stream (dropping the - // progress sender), instead of only clearing the UI. - let (task, handle) = - Task::batch([download_task, progress_task]).abortable(); - self.download_abort = Some(handle); - return task; - } else { - self.status_message = - i18n::t_fmt("no_release_for", &[("platform", &platform_key)]); - } - } - Task::none() - } - Message::DownloadProgress(filename, downloaded, total) => { - // Ignore late progress events from a cancelled/finished download - // so the toast cannot resurrect after CancelDownload. - if self.is_downloading { - let fraction = total - .filter(|t| *t > 0) - .map(|t| downloaded as f32 / t as f32) - .unwrap_or(0.0); - self.download_progress = Some((filename, fraction)); - self.download_bytes = Some((downloaded, total)); - // Transfer speed: exponential moving average over samples. - let now = std::time::Instant::now(); - if let Some((t0, b0)) = self.last_progress_sample { - let dt = now.duration_since(t0).as_secs_f32(); - if dt > 0.05 && downloaded >= b0 { - let inst = (downloaded - b0) as f32 / dt; - self.download_speed = if self.download_speed > 0.0 { - 0.7 * self.download_speed + 0.3 * inst - } else { - inst - }; - self.last_progress_sample = Some((now, downloaded)); - } - } else { - self.last_progress_sample = Some((now, downloaded)); - } - } - Task::none() - } - Message::DownloadCompleted(result) => { - self.download_progress = None; - self.download_bytes = None; - self.download_speed = 0.0; - self.last_progress_sample = None; - self.is_downloading = false; - self.download_abort = None; - self.downloading_repo = None; - match result { - // Version/asset records were written atomically with the - // install (inside download_release_asset), so the tag is - // no longer needed here. - Ok((path, repo_name, _tag)) => { - // The just-installed version IS the one the badge was - // advertising: clear it, or the card keeps showing - // "Update vX -> vX" until the next global check. - self.available_updates.remove(&repo_name); - self.refresh_install_status(); - let display_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| path.display().to_string()); - // Use the short binary name (not the full install path) - // so the header status text can't squeeze the search box. - self.status_message = i18n::t_fmt("installed", &[("path", &display_name)]); - let notif = self.push_notification( - i18n::t_fmt("installed", &[("path", &display_name)]), - NotificationLevel::Info, - ); - Task::batch([notif, self.dispatch_next_queued_update()]) - } - Err(e) => { - self.status_message = i18n::t_fmt("download_error", &[("error", &e)]); - let notif = self.push_notification( - i18n::t_fmt("download_error", &[("error", &e)]), - NotificationLevel::Error, - ); - // A failed item does not strand the rest of the queue. - Task::batch([notif, self.dispatch_next_queued_update()]) - } - } - } - Message::CancelDownload => { - // Actually abort the running download + progress tasks so no - // phantom install completes and no second writer can race the - // same file on a retry. Cancel also empties the "Update all" - // queue: cancelling means stop, not "skip this one". - self.update_queue.clear(); - if let Some(handle) = self.download_abort.take() { - handle.abort(); - } - // The aborted task cannot clean up its staging file: sweep - // the cancelled repo's *.part leftovers here. - if let Some(repo) = self.downloading_repo.take() { - if let Ok(app_dir) = crate::persistence::colony_app_dir(&repo) { - if let Ok(entries) = std::fs::read_dir(&app_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.ends_with(".part") { - let _ = std::fs::remove_file(entry.path()); - } - } - } - } - } - self.download_progress = None; - self.download_bytes = None; - self.download_speed = 0.0; - self.last_progress_sample = None; - self.is_downloading = false; - self.status_message = i18n::t("download_cancelled"); - self.push_notification(i18n::t("download_cancelled"), NotificationLevel::Warning) - } - Message::LaunchColonyApp(path) => { - // Executed directly on every platform, never through `cmd /C`: - // Windows only quotes an argument containing a space or tab, so a - // manifest-derived path like `app&calc` reached cmd unquoted and - // its `&` was parsed as a command separator. A store install is - // always a real executable, so the shell buys nothing here. - let result = std::process::Command::new(&path).spawn().map(|_| ()); - - match result { - Ok(()) => { - self.status_message = i18n::t("app_launched"); - Task::perform( - async { - tokio::time::sleep(Duration::from_secs(4)).await; - }, - |_| Message::ClearStatus, - ) - } - Err(e) => { - let msg = i18n::t_fmt("launch_error_msg", &[("error", &e.to_string())]); - self.status_message = msg.clone(); - self.push_notification(msg, NotificationLevel::Error) - } - } - } - Message::ConfirmUninstall(repo_name) => { - self.confirm_uninstall = Some(repo_name); - Task::none() - } - Message::CancelUninstall => { - self.confirm_uninstall = None; - Task::none() - } - Message::UninstallColonyApp(repo_name) => { - self.confirm_uninstall = None; - // An uninstalled app has no meaningful "update available". - self.available_updates.remove(&repo_name); - // Stale notes describe the version that was just removed. - // (Doc/icon caches and the favorite deliberately survive: they - // 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 crate::persistence::colony_app_dir(&repo_name) { - Ok(app_dir) => { - if app_dir.exists() { - if let Err(e) = std::fs::remove_dir_all(&app_dir) { - self.status_message = - i18n::t_fmt("uninstall_error", &[("error", &e.to_string())]); - } else { - self.status_message = - i18n::t_fmt("uninstalled", &[("name", &repo_name)]); - // AFTER the directory removal, so the cache - // records the app as gone. - self.refresh_install_status(); - return Task::perform( - async { - tokio::time::sleep(Duration::from_secs(4)).await; - }, - |_| Message::ClearStatus, - ); - } - } - } - Err(e) => { - self.status_message = - i18n::t_fmt("scan_error", &[("error", &e.to_string())]); - } - } - Task::none() - } - Message::GitHubRefreshRepos => { - if self.is_fetching_repos { - return Task::none(); - } - self.is_fetching_repos = true; - // Anonymous refresh is supported: the token only raises the - // rate limit (60 req/h unauthenticated vs 5000 signed-in). - let token = self.github_token(); - Task::perform( - async move { github::fetch_colony_repos(token.as_deref()).await }, - |result| match result { - Ok(repos) => Message::GitHubReposFetched(repos), - Err(e) => Message::GitHubError(e.to_string()), - }, - ) - } - Message::ClearStoreCaches => { - let removed = crate::persistence::clear_store_caches(); - self.app_icons.clear(); - self.release_notes.clear(); - self.detail_md_source = None; - self.refresh_detail_markdown(); - let msg = i18n::t_fmt("caches_cleared", &[("count", &removed.to_string())]); - self.status_message = msg.clone(); - self.push_notification(msg, NotificationLevel::Info) - } - Message::CopyToClipboard(value) => iced::clipboard::write(value), - Message::OpenUrl(url) => { - // Single choke point for the three producers of this message - // (Markdown link clicks, README badge pills, "View on GitHub"), - // two of which carry remote attacker-influenced strings. Only - // http(s) may reach the desktop URI opener - see is_web_url. - let Some(safe) = crate::download::web_url(&url) else { - tracing::warn!("refusing to open non-http(s) url {url:?}"); - return Task::none(); - }; - if let Err(err) = open::that(&safe) { - tracing::warn!("failed to open url {safe:?}: {err}"); - } - Task::none() - } - Message::DismissNotification(id) => { - self.notifications.retain(|n| n.id != id); - Task::none() - } - Message::TickNotifications => { - if self.animations && !self.reduce_motion { - // Mark expired toasts for fade-out instead of dropping - // them: `removing` re-arms the animation subscription - // (has_active_animations), which previously stopped - // before the fade could ever play. - for n in &mut self.notifications { - if n.is_expired() { - n.removing = true; - } - } - } else { - self.notifications.retain(|n| !n.is_expired()); - } - Task::none() - } - Message::AnimationTick => { - const SPEED: f32 = 0.15; - const SNAP: f32 = 0.005; - let fade_lead = Duration::from_millis(800); - - // Notification fade-in / fade-out - for notif in &mut self.notifications { - // Fade in - if notif.fade_in < 1.0 && !notif.removing { - notif.fade_in = (notif.fade_in + SPEED).min(1.0); - if (1.0 - notif.fade_in) < SNAP { - notif.fade_in = 1.0; - } - } - // Start fade-out before expiration - let timeout = notif.level.timeout(); - if notif.created_at.elapsed() + fade_lead >= timeout && !notif.removing { - notif.removing = true; - } - // Fade out - if notif.removing { - notif.fade_out = (notif.fade_out - SPEED).max(0.0); - if notif.fade_out < SNAP { - notif.fade_out = 0.0; - } - } - } - self.notifications.retain(|n| n.fade_out > 0.0); - - // Smooth progress bar - if let Some((_, target)) = &self.download_progress { - let target = *target; - let diff = target - self.progress_display; - if diff.abs() > SNAP { - self.progress_display += diff * SPEED; - } else { - self.progress_display = target; - } - } else { - self.progress_display = 0.0; - } - - // Sidebar indicator: clear animation when duration elapsed - if let Some(start) = self.sidebar_indicator_start { - let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0; - if elapsed_ms >= App::SIDEBAR_ANIM_MS { - self.sidebar_indicator_start = None; - self.sidebar_indicator_from = self.sidebar_indicator_target; - } - } - - Task::none() - } - Message::KeyboardEvent(event) => { - if !self.keyboard_nav { - return Task::none(); - } - if let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event { - match key { - iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { - if self.show_settings { - self.show_settings = false; - } else if self.confirm_uninstall.is_some() { - self.confirm_uninstall = None; - } else if self.show_first_launch { - self.show_first_launch = false; - self.save_preferences(); - } else if self.active_colony_repo.is_some() { - self.active_colony_repo = None; - } else if self.show_github_menu { - self.show_github_menu = false; - } - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) - if !self.show_settings - && !self.show_github_menu - && !self.show_first_launch => - { - let len = self.sections.len(); - if len > 0 { - self.sidebar_indicator_from = self.sidebar_indicator_pos(); - if modifiers.shift() { - self.selected_section = if self.selected_section == 0 { - len - 1 - } else { - self.selected_section - 1 - }; - } else { - self.selected_section = (self.selected_section + 1) % len; - } - self.sidebar_indicator_target = self.selected_section as f32 * 44.0; - self.sidebar_indicator_start = Some(std::time::Instant::now()); - self.active_colony_repo = None; - self.save_preferences(); - } - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) - if self.show_settings => - { - self.settings_category = (self.settings_category + 1).min(5); - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) - if self.show_settings => - { - self.settings_category = self.settings_category.saturating_sub(1); - } - // Grid traversal: Down/Up move a highlight over the - // visible rows (store repos then local apps); Enter - // activates it. Keys are stable names, not indexes, - // so a catalog refresh cannot shift the highlight. - iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) - if !self.show_settings - && !self.show_github_menu - && !self.show_first_launch - && self.active_colony_repo.is_none() => - { - let keys = self.grid_keys(); - if !keys.is_empty() { - let next = match &self.keyboard_cursor { - Some(cur) => keys - .iter() - .position(|k| k == cur) - .map(|i| (i + 1).min(keys.len() - 1)) - .unwrap_or(0), - None => 0, - }; - self.keyboard_cursor = Some(keys[next].clone()); - } - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) - if !self.show_settings - && !self.show_github_menu - && !self.show_first_launch - && self.active_colony_repo.is_none() => - { - let keys = self.grid_keys(); - if !keys.is_empty() { - let next = match &self.keyboard_cursor { - Some(cur) => keys - .iter() - .position(|k| k == cur) - .map(|i| i.saturating_sub(1)) - .unwrap_or(0), - None => 0, - }; - self.keyboard_cursor = Some(keys[next].clone()); - } - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) - if self.show_settings => - { - self.settings_category = (self.settings_category + 3).min(5); - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) - if self.show_settings => - { - self.settings_category = self.settings_category.saturating_sub(3); - } - iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) - if !self.show_settings - && !self.show_github_menu - && !self.show_first_launch - && self.active_colony_repo.is_none() => - { - // Activate the keyboard highlight when there is - // one, else fall back to the first store row. - let target = self.keyboard_cursor.clone().or_else(|| { - self.filtered_colony_repos() - .first() - .map(|r| format!("repo:{}", r.name)) - }); - match target { - Some(key) if key.starts_with("repo:") => { - self.active_colony_repo = - Some(key["repo:".len()..].to_string()); - // Refresh the (repo, tab) markdown cache — - // the detail view reads cached blocks only. - self.refresh_detail_markdown(); - } - Some(key) if key.starts_with("app:") => { - let name = &key["app:".len()..]; - if let Some(app) = - self.applications.iter().find(|a| a.name == name) - { - let exec = app.exec.clone(); - return self.update(Message::LaunchApp(exec)); - } - } - _ => {} - } - } - _ => {} - } - } - Task::none() - } - Message::CheckUpdates => { - if self.is_checking_updates { - return Task::none(); - } - self.is_checking_updates = true; - self.status_message = i18n::t("checking_updates"); - // Collect (repo, pinned tag for this platform) for every - // installed Colony app so update detection compares against the - // tag that would actually be installed, not /releases/latest. - let platform = github::current_platform_key(); - let repos: Vec<(String, String)> = self - .colony_repos() - .iter() - .filter(|r| crate::persistence::installed_app_path(r).is_some()) - .filter_map(|r| { - r.manifest - .release_files - .get(platform) - .map(|entry| (r.name.clone(), entry.tag.clone())) - }) - .collect(); - - if repos.is_empty() { - // Nothing to check — reset the guard (otherwise it stays true - // forever, blocking all later checks) and still run the - // chained launcher self-update check. - self.is_checking_updates = false; - self.status_message = i18n::t_fmt( - "apps_found", - &[("count", &self.applications.len().to_string())], - ); - return Task::done(Message::CheckLauncherUpdate { manual: false }); - } - - let token = self.github_token(); - - Task::perform( - async move { - let client = match github::build_update_client(token.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - let futs: Vec<_> = repos - .iter() - .map(|(name, tag)| { - let c = client.clone(); - let n = name.clone(); - let t = tag.clone(); - async move { - github::check_update_available(&c, &n, &t) - .await - .map(|v| (n, v)) - } - }) - .collect(); - futures::future::join_all(futs) - .await - .into_iter() - .flatten() - .collect() - }, - Message::UpdatesChecked, - ) - } - Message::WindowResized(w, h) => { - self.window_size = (w, h); - self.window_save_gen += 1; - let gen = self.window_save_gen; - // Debounce: resize events flood during an interactive drag; - // only the delayed save matching the LAST generation writes. - Task::perform( - async move { - tokio::time::sleep(Duration::from_secs(1)).await; - gen - }, - Message::PersistWindowSize, - ) - } - Message::PersistWindowSize(gen) => { - if gen == self.window_save_gen { - self.save_preferences(); - } - Task::none() - } - Message::UpdateAll => { - if self.is_downloading { - return Task::none(); - } - let platform = github::current_platform_key(); - // Queue every updatable repo that actually ships an asset for - // this platform; order follows the catalog for predictability. - let mut queue: Vec = self - .colony_repos() - .iter() - .filter(|r| { - self.available_updates.contains_key(&r.name) - && r.manifest.release_files.contains_key(platform) - }) - .map(|r| r.name.clone()) - .collect(); - if queue.is_empty() { - return Task::none(); - } - let first = queue.remove(0); - self.update_queue = queue; - Task::done(Message::DownloadRelease(first, platform.to_string())) - } - Message::FetchReleaseNotes(repo_name) => { - if self.fetching_notes.contains(&repo_name) { - return Task::none(); - } - // Show the notes of the AVAILABLE update when there is one, - // otherwise of the manifest's pinned/latest release. - let platform = github::current_platform_key(); - let tag = self.available_updates.get(&repo_name).cloned().or_else(|| { - self.colony_repos() - .iter() - .find(|r| r.name == repo_name) - .and_then(|r| r.manifest.release_files.get(platform)) - .map(|e| e.tag.clone()) - }); - let Some(tag) = tag else { - return Task::none(); - }; - self.fetching_notes.insert(repo_name.clone()); - let token = self.github_token(); - let repo_for_result = repo_name.clone(); - Task::perform( - async move { - let client = github::build_update_client(token.as_deref()) - .map_err(|e| e.to_string())?; - let info = github::fetch_release_info(&client, &repo_name, &tag) - .await - .map_err(|e| e.to_string())?; - Ok((info.tag, info.body.unwrap_or_default())) - }, - move |result: Result<(String, String), String>| { - Message::ReleaseNotesFetched(repo_for_result, result) - }, - ) - } - Message::ReleaseNotesFetched(repo_name, result) => { - self.fetching_notes.remove(&repo_name); - match result { - Ok((tag, body)) => { - let blocks = markdown_blocks::parse(&body); - self.release_notes.insert(repo_name, (tag, blocks)); - } - Err(e) => { - // Non-blocking feature: a failed fetch surfaces in the - // status line, never as a modal interruption. - self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); - } - } - Task::none() - } - Message::UpdatesChecked(updates) => { - self.is_checking_updates = false; - // Record which apps have a pending update so the grid cards can - // show an update badge (not just a transient toast). - self.available_updates = updates.iter().cloned().collect(); - let notif_task = if updates.is_empty() { - self.status_message = i18n::t_fmt( - "apps_found", - &[("count", &self.applications.len().to_string())], - ); - Task::none() - } else { - let names: Vec<&str> = updates.iter().map(|(n, _)| n.as_str()).collect(); - let msg = i18n::t_fmt( - "updates_available", - &[ - ("count", &updates.len().to_string()), - ("names", &names.join(", ")), - ], - ); - self.push_notification(msg, NotificationLevel::Info) - }; - // Also check for launcher self-update - Task::batch([ - notif_task, - Task::done(Message::CheckLauncherUpdate { manual: false }), - ]) - } - Message::ToggleFavorite(name) => { - if let Some(pos) = self.favorites.iter().position(|f| f == &name) { - self.favorites.remove(pos); - } else { - self.favorites.push(name); - } - if let Err(e) = crate::persistence::save_favorites(&self.favorites) { - tracing::warn!("Failed to save favorites: {e}"); - } - Task::none() - } - Message::DismissFirstLaunch => { - self.show_first_launch = false; - self.welcome_step = 0; - self.save_preferences(); - Task::none() - } - Message::WelcomeNext => { - const LAST_STEP: u8 = crate::ui::TUTORIAL_LAST_STEP; - if self.welcome_step >= LAST_STEP { - self.show_first_launch = false; - self.welcome_step = 0; - self.save_preferences(); - Task::none() - } else { - self.welcome_step += 1; - crate::ui::fetch_bounds_task() - } - } - Message::WelcomeBack => { - self.welcome_step = self.welcome_step.saturating_sub(1); - crate::ui::fetch_bounds_task() - } - Message::TutorialBoundsUpdated(bounds) => { - self.tutorial_bounds = bounds; - Task::none() - } - Message::WelcomeConnectGithub => { - // Close the welcome overlay and jump straight to the GitHub - // panel so the user can start the device-flow login without - // an extra "dismiss then navigate" step. - self.show_first_launch = false; - self.welcome_step = 0; - self.show_github_menu = true; - self.save_preferences(); - Task::none() - } - Message::ToggleSettings => { - self.show_settings = !self.show_settings; - if !self.show_settings { - self.settings_category = 0; - } - Task::none() - } - Message::SettingsCategory(idx) => { - self.settings_category = idx; - Task::none() - } - Message::SettingsToggleSection(key) => { - if !self.settings_expanded_sections.remove(&key) { - self.settings_expanded_sections.insert(key); - } - Task::none() - } - Message::SelectThemeVariant(theme, variant) => { - self.selected_theme = theme; - self.selected_variant = variant; - set_active_theme(&self.selected_theme, &self.selected_variant); - self.save_preferences(); - self.push_notification(i18n::t("theme_applied"), NotificationLevel::Info) - } - Message::SelectAccentColor(color) => { - set_active_accent(accent_key_to_color(&color)); - self.selected_accent = color; - self.auto_accent = false; - self.save_preferences(); - Task::none() - } - Message::ToggleAutoAccent => { - self.auto_accent = !self.auto_accent; - if self.auto_accent { - set_active_accent(None); - } else { - set_active_accent(accent_key_to_color(&self.selected_accent)); - } - self.save_preferences(); - Task::none() - } - Message::ToggleRestoreSession => { - self.restore_session = !self.restore_session; - self.save_preferences(); - Task::none() - } - Message::PickDefaultView(v) => { - self.default_view = v; - self.save_preferences(); - Task::none() - } - Message::PickLanguage(v) => { - self.language = v; - self.save_preferences(); - // 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; - self.save_preferences(); - Task::none() - } - Message::PickFontSize(v) => { - self.font_size = v; - self.save_preferences(); - Task::none() - } - Message::ToggleAnimations => { - self.animations = !self.animations; - self.save_preferences(); - Task::none() - } - Message::ToggleHighContrast => { - self.high_contrast = !self.high_contrast; - set_high_contrast(self.high_contrast); - self.save_preferences(); - Task::none() - } - Message::PickTextSizeA11y(v) => { - self.text_size_a11y = v; - self.save_preferences(); - Task::none() - } - Message::ToggleReduceMotion => { - self.reduce_motion = !self.reduce_motion; - self.save_preferences(); - Task::none() - } - Message::ToggleKeyboardNav => { - self.keyboard_nav = !self.keyboard_nav; - self.save_preferences(); - Task::none() - } - Message::ToggleDyslexiaFont => { - self.dyslexia_font = !self.dyslexia_font; - self.save_preferences(); - Task::none() - } - Message::ToggleScanOnStartup => { - self.scan_on_startup = !self.scan_on_startup; - self.save_preferences(); - Task::none() - } - Message::DetailTabSelected(tab) => { - self.detail_tab = tab; - self.refresh_detail_markdown(); - Task::none() - } - // --- Launcher self-update --- - Message::CheckLauncherUpdate { manual } => { - if self.is_checking_launcher_update { - return Task::none(); - } - self.is_checking_launcher_update = true; - - let token = self.github_token(); - - Task::perform( - async move { - let client = github::build_update_client(token.as_deref()) - .map_err(|e| e.to_string())?; - github::check_launcher_update(&client) - .await - .map_err(|e| e.to_string()) - }, - move |result| Message::LauncherUpdateChecked(manual, result), - ) - } - Message::LauncherUpdateChecked(manual, result) => { - self.is_checking_launcher_update = false; - match result { - Ok(Some((tag, asset))) => { - let tag_display = tag.clone(); - self.launcher_update_available = Some((tag, asset)); - // On a package-managed install the in-app flow cannot - // apply: announce the update with the pacman guidance - // instead of pointing at a doomed download button. - let key = if self.launcher_system_managed { - "launcher_update_system_managed" - } else { - "launcher_update_available" - }; - self.push_notification( - i18n::t_fmt(key, &[("version", &tag_display)]), - NotificationLevel::Info, - ) - } - Ok(None) => { - self.launcher_update_available = None; - self.status_message = i18n::t("launcher_up_to_date"); - if manual { - // Explicit feedback for an explicit click; the - // automatic boot check stays quiet when current. - self.push_notification( - i18n::t("launcher_up_to_date"), - NotificationLevel::Info, - ) - } else { - Task::none() - } - } - Err(e) => { - // The check DID NOT run: never claim "up to date". - self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); - if manual { - self.push_notification( - i18n::t_fmt("github_api_error", &[("error", &e)]), - NotificationLevel::Error, - ) - } else { - Task::none() - } - } - } - } - Message::DownloadLauncherUpdate => { - if self.is_downloading { - return Task::none(); - } - // Defense in depth behind the UI gate: a package-managed exe - // dir is not writable, so the flow would download the whole - // asset and then die on the backup rename with EACCES. - if self.launcher_system_managed { - let msg = i18n::t_fmt( - "launcher_update_system_managed", - &[( - "version", - &self - .launcher_update_available - .as_ref() - .map(|(t, _)| t.clone()) - .unwrap_or_default(), - )], - ); - self.status_message = msg.clone(); - return self.push_notification(msg, NotificationLevel::Warning); - } - let (tag, asset) = match &self.launcher_update_available { - Some(t) => t.clone(), - None => return Task::none(), - }; - - let token = self.github_token(); - - self.is_downloading = true; - self.download_progress = Some((asset.clone(), 0.0)); - self.status_message = i18n::t_fmt("downloading", &[("file", &asset)]); - - let (progress_tx, progress_rx) = - futures::channel::mpsc::unbounded::<(u64, Option)>(); - - let download_task = Task::perform( - async move { - crate::download::download_launcher_asset( - token, - tag, - asset, - Some(progress_tx), - ) - .await - .map_err(|e| e.to_string()) - }, - Message::LauncherDownloadCompleted, - ); - - let progress_task = Task::run(progress_rx, |(downloaded, total)| { - Message::LauncherDownloadProgress( - total - .filter(|t| *t > 0) - .map(|t| downloaded as f32 / t as f32) - .unwrap_or(0.0), - ) - }); - - let (task, handle) = Task::batch([download_task, progress_task]).abortable(); - self.download_abort = Some(handle); - task - } - Message::LauncherDownloadProgress(progress) => { - if let Some((ref name, _)) = self.download_progress { - self.download_progress = Some((name.clone(), progress)); - } - Task::none() - } - Message::LauncherDownloadCompleted(result) => { - self.download_progress = None; - self.is_downloading = false; - self.download_abort = None; - match result { - Ok(path) => { - self.launcher_update_staged = Some(path); - self.status_message = i18n::t("launcher_update_ready"); - self.push_notification( - i18n::t("launcher_update_ready"), - NotificationLevel::Info, - ) - } - Err(e) => { - self.status_message = i18n::t_fmt("download_error", &[("error", &e)]); - self.push_notification( - i18n::t_fmt("download_error", &[("error", &e)]), - NotificationLevel::Error, - ) - } - } - } - Message::ApplyLauncherUpdate(new_binary) => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - crate::download::apply_launcher_update(&new_binary) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string()) - .and_then(|r| r) - }, - |result: Result| match result { - Ok(exe_path) => { - tracing::info!("Launching updated Colony: {}", exe_path.display()); - let _ = std::process::Command::new(&exe_path).spawn(); - std::process::exit(0); - } - Err(e) => Message::LauncherDownloadCompleted(Err(e)), - }, - ), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::github::{ColonyManifest, ColonyRepo, ReleaseFileEntry}; - - fn repo(name: &str, desc: &str) -> ColonyRepo { - let mut release_files = std::collections::HashMap::new(); - release_files.insert( - github::current_platform_key().to_string(), - ReleaseFileEntry { - tag: "latest".into(), - file: Some(format!("{name}-bin")), - file_pattern: None, - binary: None, - sha256: None, - }, - ); - ColonyRepo { - name: name.into(), - description: desc.into(), - language: "Rust".into(), - html_url: format!("https://github.com/Project-Colony/{name}"), - manifest: ColonyManifest { - name: name.into(), - category: "Development".into(), - platforms: vec!["linux".into()], - release_files, - icon: None, - signed: false, - }, - } - } - - /// Serialize tests that redirect XDG dirs (env vars are process-global) - /// and keep every disk write inside a throwaway directory. `dirs` only - /// honors XDG on Linux, so callers gate on cfg(target_os = "linux"). - #[cfg(target_os = "linux")] - fn with_temp_dirs(f: impl FnOnce()) { - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let tmp = tempfile::tempdir().expect("tempdir"); - let old_config = std::env::var_os("XDG_CONFIG_HOME"); - let old_data = std::env::var_os("XDG_DATA_HOME"); - std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("config")); - std::env::set_var("XDG_DATA_HOME", tmp.path().join("data")); - f(); - match old_config { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match old_data { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - - #[test] - fn open_detail_survives_catalog_replacement_and_reorder() { - let mut app = App::new_for_test(); - app.colony_repo_list = vec![repo("Alpha", ""), repo("Beta", ""), repo("Gamma", "")]; - let _ = app.update(Message::ColonyRepoSelected("Beta".into())); - assert_eq!(app.active_repo().map(|r| r.name.as_str()), Some("Beta")); - - // A refresh replaces AND reorders the vector (GitHub sorts by last - // push): the open detail page must still resolve to the same app. - app.colony_repo_list = vec![repo("Gamma", ""), repo("Beta", ""), repo("Alpha", "")]; - assert_eq!(app.active_repo().map(|r| r.name.as_str()), Some("Beta")); - - // A repo that vanished resolves to None (the view falls back to the - // grid) instead of showing someone else's page. - app.colony_repo_list = vec![repo("Alpha", "")]; - assert!(app.active_repo().is_none()); - } - - #[test] - fn download_completion_clears_the_update_badge() { - let mut app = App::new_for_test(); - app.available_updates - .insert("Grape".to_string(), "v2.0.0".to_string()); - let _ = app.update(Message::DownloadCompleted(Ok(( - std::path::PathBuf::from("/tmp/grape-bin"), - "Grape".to_string(), - "v2.0.0".to_string(), - )))); - assert!( - !app.available_updates.contains_key("Grape"), - "badge must not survive the update it advertised" - ); - assert!(!app.is_downloading); - } - - #[test] - fn update_all_queues_updatable_repos_and_chains_on_completion() { - let mut app = App::new_for_test(); - app.colony_repo_list = vec![repo("One", ""), repo("Two", ""), repo("Three", "")]; - app.available_updates - .insert("One".to_string(), "v2".to_string()); - app.available_updates - .insert("Three".to_string(), "v2".to_string()); - - let _ = app.update(Message::UpdateAll); - // The first updatable repo is dispatched immediately; the rest queue. - assert_eq!(app.update_queue, vec!["Three".to_string()]); - - // A completion - success or failure - pops the next entry. - let _ = app.update(Message::DownloadCompleted(Err("boom".into()))); - assert!( - app.update_queue.is_empty(), - "failure must not strand the queue" - ); - } - - #[test] - fn cancel_download_empties_the_update_queue() { - let mut app = App::new_for_test(); - app.update_queue = vec!["A".into(), "B".into()]; - app.is_downloading = true; - let _ = app.update(Message::CancelDownload); - assert!(app.update_queue.is_empty(), "cancel means stop, not skip"); - assert!(!app.is_downloading); - } - - #[test] - fn launcher_check_failure_never_claims_up_to_date() { - let mut app = App::new_for_test(); - app.is_checking_launcher_update = true; - let _ = app.update(Message::LauncherUpdateChecked( - false, - Err("network down".into()), - )); - assert!(!app.is_checking_launcher_update); - assert!(app.launcher_update_available.is_none()); - assert!( - app.status_message.contains("network down"), - "the failure must surface, got: {}", - app.status_message - ); - // Automatic check: no toast for the failure either (status line only). - assert!(app.notifications.is_empty()); - - // A clean Ok(None) on an AUTOMATIC check stays quiet (no toast)... - let _ = app.update(Message::LauncherUpdateChecked(false, Ok(None))); - assert!(app.notifications.is_empty()); - // ...but a MANUAL check gets explicit feedback. - let _ = app.update(Message::LauncherUpdateChecked(true, Ok(None))); - assert_eq!(app.notifications.len(), 1); - } - - #[test] - fn window_resize_bumps_generation_and_stale_saves_are_ignored() { - let mut app = App::new_for_test(); - let _ = app.update(Message::WindowResized(1280.0, 800.0)); - let _ = app.update(Message::WindowResized(1300.0, 820.0)); - assert_eq!(app.window_size, (1300.0, 820.0)); - assert_eq!(app.window_save_gen, 2); - // A stale generation must not trigger a save; the state check here is - // that the handler is a no-op (the fresh gen path writes prefs, which - // is covered by the linux-gated persistence test). - let _ = app.update(Message::PersistWindowSize(1)); - assert_eq!(app.window_save_gen, 2); - } - - #[test] - fn search_matches_description_and_display_name() { - let mut app = App::new_for_test(); - app.colony_repo_list = vec![ - repo("Grape", "Lecteur musique en Rust"), - repo("orCAL", "Calendar overlay"), - ]; - app.search_query = "musique".into(); - let hits: Vec<&str> = app - .filtered_colony_repos() - .iter() - .map(|r| r.name.as_str()) - .collect(); - assert_eq!(hits, vec!["Grape"]); - } - - #[test] - fn section_selection_out_of_bounds_is_ignored() { - let mut app = App::new_for_test(); - // No sections loaded: any index is out of bounds and must be ignored - // (and must not write preferences or panic). - let _ = app.update(Message::SectionSelected(3)); - assert_eq!(app.selected_section, 0); - } - - #[cfg(target_os = "linux")] - #[test] - fn repos_fetched_stores_catalog_while_disconnected_and_prunes_orphans() { - with_temp_dirs(|| { - let mut app = App::new_for_test(); - assert!(matches!(app.github_state, GitHubState::Disconnected)); - - // Seed an orphaned doc cache for a repo that no longer exists. - let orphan = crate::persistence::colony_data_dir() - .unwrap() - .join("repo-docs") - .join("Ghost"); - std::fs::create_dir_all(&orphan).unwrap(); - - let _ = app.update(Message::GitHubReposFetched(vec![repo("Alive", "")])); - - // The catalog is stored even though no session exists (anonymous - // mode), and the orphaned cache is pruned. - assert_eq!(app.colony_repos().len(), 1); - assert!(!orphan.exists(), "orphaned cache must be pruned"); - }); - } - - #[cfg(target_os = "linux")] - #[test] - fn github_error_only_toasts_when_the_catalog_is_empty() { - with_temp_dirs(|| { - // Empty catalog + no cache: the failure interrupts (error toast). - let mut app = App::new_for_test(); - let _ = app.update(Message::GitHubError("boom".into())); - assert_eq!(app.notifications.len(), 1); - - // Catalog showing: the same failure stays in the status line. - let mut app = App::new_for_test(); - app.colony_repo_list = vec![repo("Alive", "")]; - let _ = app.update(Message::GitHubError("boom".into())); - assert!(app.notifications.is_empty()); - assert!(app.status_message.contains("boom")); - }); - } - - #[cfg(target_os = "linux")] - /// The pin is what stops a compromised repo from flipping `signed` back to - /// false, so it must survive a manifest that no longer asks for signatures - - /// and a case-only rename of the repo, which creates a different directory. - #[cfg(target_os = "linux")] - #[test] - fn signature_pin_survives_and_is_case_insensitive() { - with_temp_dirs(|| { - use crate::persistence::{load_installed_signed, save_installed_signed}; - assert!( - !load_installed_signed("Spotter"), - "no pin before any install" - ); - - // The installer creates the app directory before recording anything; - // colony_app_dir deliberately does not, so mirror that order here. - std::fs::create_dir_all(crate::persistence::colony_app_dir("Spotter").unwrap()) - .unwrap(); - save_installed_signed("Spotter").unwrap(); - assert!(load_installed_signed("Spotter")); - assert!( - load_installed_signed("spotter"), - "a case-only rename must not drop the pin" - ); - assert!(load_installed_signed("SPOTTER")); - assert!( - !load_installed_signed("SpotterX"), - "the match must not be a prefix match" - ); - - // No API can clear it: only removing the app directory does, which is - // what uninstalling deliberately performs. - save_installed_signed("Spotter").unwrap(); - assert!(load_installed_signed("Spotter")); - let dir = crate::persistence::colony_app_dir("Spotter").unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - assert!( - !load_installed_signed("Spotter"), - "uninstall clears the pin" - ); - }); - } - - #[cfg(target_os = "linux")] - #[test] - fn toggle_favorite_persists_to_disk() { - with_temp_dirs(|| { - let mut app = App::new_for_test(); - let _ = app.update(Message::ToggleFavorite("Grape".into())); - assert!(app.is_favorite("Grape")); - assert_eq!( - crate::persistence::load_favorites(), - vec!["Grape".to_string()] - ); - let _ = app.update(Message::ToggleFavorite("Grape".into())); - assert!(!app.is_favorite("Grape")); - assert!(crate::persistence::load_favorites().is_empty()); - }); - } -} diff --git a/src/update/github_auth.rs b/src/update/github_auth.rs new file mode 100644 index 0000000..0d38307 --- /dev/null +++ b/src/update/github_auth.rs @@ -0,0 +1,183 @@ +//! GitHub device-flow authentication and catalog fetching. + +use iced::Task; + +use crate::github; +use crate::i18n; +use crate::message::Message; +use crate::oauth; +use crate::state::{App, GitHubState, NotificationLevel}; + +impl App { + pub(super) fn toggle_github_menu(&mut self) -> Task { + self.show_github_menu = !self.show_github_menu; + Task::none() + } + + pub(super) fn github_login(&mut self) -> Task { + self.github_state = GitHubState::Connecting { user_code: None }; + Task::perform( + async { + oauth::request_device_code() + .await + .map_err(|e| e.to_string()) + }, + Message::GitHubDeviceCodeReceived, + ) + } + + pub(super) fn github_device_code_received( + &mut self, + result: Result, + ) -> Task { + match result { + Ok(device) => { + self.github_state = GitHubState::Connecting { + user_code: Some(device.user_code.clone()), + }; + Task::perform( + async move { + oauth::poll_for_token(device) + .await + .map_err(|e| e.to_string()) + }, + Message::GitHubLoginCompleted, + ) + } + Err(e) => { + self.github_state = GitHubState::Error(e.clone()); + self.status_message = i18n::t_fmt("oauth_error", &[("error", &e)]); + self.push_notification( + i18n::t_fmt("oauth_error", &[("error", &e)]), + NotificationLevel::Error, + ) + } + } + } + + pub(super) fn github_login_completed( + &mut self, + result: Result, + ) -> Task { + match result { + Ok(session) => { + self.github_state = GitHubState::Connected { + session: session.clone(), + }; + self.status_message = format!( + "{}{}", + i18n::t("github_connected"), + session + .username + .as_ref() + .map(|u| format!(" ({u})")) + .unwrap_or_default() + ); + let token = session.access_token.clone(); + // Guard the refetch like every other fetch path, so a + // concurrent GitHubRefreshRepos cannot double-fetch. + self.is_fetching_repos = true; + Task::perform( + async move { github::fetch_colony_repos(Some(&token)).await }, + |result| match result { + Ok(repos) => Message::GitHubReposFetched(repos), + Err(e) => Message::GitHubError(e.to_string()), + }, + ) + } + Err(e) => { + self.github_state = GitHubState::Error(e.clone()); + self.status_message = i18n::t_fmt("oauth_error", &[("error", &e)]); + self.push_notification( + i18n::t_fmt("oauth_error", &[("error", &e)]), + NotificationLevel::Error, + ) + } + } + } + + pub(super) fn github_logout(&mut self) -> Task { + let _ = oauth::delete_saved_token(); + self.github_state = GitHubState::Disconnected; + self.status_message = i18n::t("github_disconnected"); + Task::none() + } + + pub(super) fn github_repos_fetched( + &mut self, + repos: Vec, + ) -> Task { + self.is_fetching_repos = false; + let count = repos.len(); + if let Err(e) = crate::persistence::save_repos_cache(&repos) { + tracing::warn!("Failed to save repos cache: {e}"); + } + // The catalog is stored regardless of sign-in state: anonymous + // fetches land here too. + self.colony_repo_list = repos; + // A successful fetch is the one moment we KNOW which repos + // exist: drop doc/icon caches of repos that left the catalog. + let live: Vec = self + .colony_repo_list + .iter() + .map(|r| r.name.clone()) + .collect(); + crate::persistence::prune_orphaned_repo_caches(&live); + // Decode any freshly-cached app icons into image handles. + self.reload_app_icons(); + self.refresh_install_status(); + // New docs may have landed for the repo currently viewed. + self.detail_md_source = None; + self.refresh_detail_markdown(); + self.status_message = + i18n::t_fmt("github_repos_detected", &[("count", &count.to_string())]); + if self.auto_check_updates { + Task::done(Message::CheckUpdates) + } else { + Task::none() + } + } + + pub(super) fn github_error(&mut self, e: String) -> Task { + self.is_fetching_repos = false; + tracing::error!(error = %e, "GitHub error"); + if self.colony_repo_list.is_empty() { + if let Some(cached) = crate::persistence::load_repos_cache() { + tracing::info!("Using {} cached repos as fallback", cached.len()); + self.colony_repo_list = cached; + } + } + // Offline fallback repos may have cached icons on disk. + self.reload_app_icons(); + self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); + if self.colony_repo_list.is_empty() { + self.push_notification( + i18n::t_fmt("github_api_error", &[("error", &e)]), + NotificationLevel::Error, + ) + } else { + // The catalog is showing (cached or previously fetched): a + // toast on every offline boot would be pure noise - the + // status line already carries the error. Only an EMPTY + // catalog warrants interrupting the user. + Task::none() + } + } + + pub(super) fn github_refresh_repos(&mut self) -> Task { + if self.is_fetching_repos { + return Task::none(); + } + self.is_fetching_repos = true; + // Anonymous refresh is supported: the token only raises the + // rate limit (60 req/h unauthenticated vs 5000 signed-in). + let token = self.github_token(); + Task::perform( + async move { github::fetch_colony_repos(token.as_deref()).await }, + |result| match result { + Ok(repos) => Message::GitHubReposFetched(repos), + Err(e) => Message::GitHubError(e.to_string()), + }, + ) + } +} diff --git a/src/update/keyboard.rs b/src/update/keyboard.rs new file mode 100644 index 0000000..3804fee --- /dev/null +++ b/src/update/keyboard.rs @@ -0,0 +1,151 @@ +//! Keyboard navigation: the global key handling for the whole shell. +//! +//! Bindings are matched by physical key so the same layout works on AZERTY and +//! QWERTY without a second table. + +use iced::keyboard; +use iced::Task; + +use crate::message::Message; +use crate::state::App; + +impl App { + pub(super) fn keyboard_event(&mut self, event: keyboard::Event) -> Task { + if !self.keyboard_nav { + return Task::none(); + } + if let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event { + match key { + iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { + if self.show_settings { + self.show_settings = false; + } else if self.confirm_uninstall.is_some() { + self.confirm_uninstall = None; + } else if self.show_first_launch { + self.show_first_launch = false; + self.save_preferences(); + } else if self.active_colony_repo.is_some() { + self.active_colony_repo = None; + } else if self.show_github_menu { + self.show_github_menu = false; + } + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) + if !self.show_settings && !self.show_github_menu && !self.show_first_launch => + { + let len = self.sections.len(); + if len > 0 { + self.sidebar_indicator_from = self.sidebar_indicator_pos(); + if modifiers.shift() { + self.selected_section = if self.selected_section == 0 { + len - 1 + } else { + self.selected_section - 1 + }; + } else { + self.selected_section = (self.selected_section + 1) % len; + } + self.sidebar_indicator_target = self.selected_section as f32 * 44.0; + self.sidebar_indicator_start = Some(std::time::Instant::now()); + self.active_colony_repo = None; + self.save_preferences(); + } + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) + if self.show_settings => + { + self.settings_category = (self.settings_category + 1).min(5); + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) + if self.show_settings => + { + self.settings_category = self.settings_category.saturating_sub(1); + } + // Grid traversal: Down/Up move a highlight over the + // visible rows (store repos then local apps); Enter + // activates it. Keys are stable names, not indexes, + // so a catalog refresh cannot shift the highlight. + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) + if !self.show_settings + && !self.show_github_menu + && !self.show_first_launch + && self.active_colony_repo.is_none() => + { + let keys = self.grid_keys(); + if !keys.is_empty() { + let next = match &self.keyboard_cursor { + Some(cur) => keys + .iter() + .position(|k| k == cur) + .map(|i| (i + 1).min(keys.len() - 1)) + .unwrap_or(0), + None => 0, + }; + self.keyboard_cursor = Some(keys[next].clone()); + } + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) + if !self.show_settings + && !self.show_github_menu + && !self.show_first_launch + && self.active_colony_repo.is_none() => + { + let keys = self.grid_keys(); + if !keys.is_empty() { + let next = match &self.keyboard_cursor { + Some(cur) => keys + .iter() + .position(|k| k == cur) + .map(|i| i.saturating_sub(1)) + .unwrap_or(0), + None => 0, + }; + self.keyboard_cursor = Some(keys[next].clone()); + } + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) + if self.show_settings => + { + self.settings_category = (self.settings_category + 3).min(5); + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) + if self.show_settings => + { + self.settings_category = self.settings_category.saturating_sub(3); + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) + if !self.show_settings + && !self.show_github_menu + && !self.show_first_launch + && self.active_colony_repo.is_none() => + { + // Activate the keyboard highlight when there is + // one, else fall back to the first store row. + let target = self.keyboard_cursor.clone().or_else(|| { + self.filtered_colony_repos() + .first() + .map(|r| format!("repo:{}", r.name)) + }); + match target { + Some(key) if key.starts_with("repo:") => { + self.active_colony_repo = Some(key["repo:".len()..].to_string()); + // Refresh the (repo, tab) markdown cache — + // the detail view reads cached blocks only. + self.refresh_detail_markdown(); + } + Some(key) if key.starts_with("app:") => { + let name = &key["app:".len()..]; + if let Some(app) = self.applications.iter().find(|a| a.name == name) { + let exec = app.exec.clone(); + return self.update(Message::LaunchApp(exec)); + } + } + _ => {} + } + } + _ => {} + } + } + Task::none() + } +} diff --git a/src/update/launcher.rs b/src/update/launcher.rs new file mode 100644 index 0000000..25e9da5 --- /dev/null +++ b/src/update/launcher.rs @@ -0,0 +1,194 @@ +//! Colony's own self-update: check, download, apply, relaunch. +//! +//! The trust rules live in [`crate::download`] and [`crate::signing`]; this +//! module only drives the UI state machine around them. + +use iced::Task; + +use crate::github; +use crate::i18n; +use crate::message::Message; +use crate::state::{App, NotificationLevel}; + +impl App { + pub(super) fn check_launcher_update(&mut self, manual: bool) -> Task { + if self.is_checking_launcher_update { + return Task::none(); + } + self.is_checking_launcher_update = true; + + let token = self.github_token(); + + Task::perform( + async move { + let client = + github::build_update_client(token.as_deref()).map_err(|e| e.to_string())?; + github::check_launcher_update(&client) + .await + .map_err(|e| e.to_string()) + }, + move |result| Message::LauncherUpdateChecked(manual, result), + ) + } + + pub(super) fn launcher_update_checked( + &mut self, + manual: bool, + result: Result, String>, + ) -> Task { + self.is_checking_launcher_update = false; + match result { + Ok(Some((tag, asset))) => { + let tag_display = tag.clone(); + self.launcher_update_available = Some((tag, asset)); + // On a package-managed install the in-app flow cannot apply: + // announce the update with the pacman guidance instead of + // pointing at a doomed download button. + let key = if self.launcher_system_managed { + "launcher_update_system_managed" + } else { + "launcher_update_available" + }; + self.push_notification( + i18n::t_fmt(key, &[("version", &tag_display)]), + NotificationLevel::Info, + ) + } + Ok(None) => { + self.launcher_update_available = None; + self.status_message = i18n::t("launcher_up_to_date"); + if manual { + // Explicit feedback for an explicit click; the automatic + // boot check stays quiet when current. + self.push_notification(i18n::t("launcher_up_to_date"), NotificationLevel::Info) + } else { + Task::none() + } + } + Err(e) => { + // The check DID NOT run: never claim "up to date". + self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); + if manual { + self.push_notification( + i18n::t_fmt("github_api_error", &[("error", &e)]), + NotificationLevel::Error, + ) + } else { + Task::none() + } + } + } + } + + pub(super) fn download_launcher_update(&mut self) -> Task { + if self.is_downloading { + return Task::none(); + } + // Defense in depth behind the UI gate: a package-managed exe dir is not + // writable, so the flow would download the whole asset and then die on + // the backup rename with EACCES. + if self.launcher_system_managed { + let msg = i18n::t_fmt( + "launcher_update_system_managed", + &[( + "version", + &self + .launcher_update_available + .as_ref() + .map(|(t, _)| t.clone()) + .unwrap_or_default(), + )], + ); + self.status_message = msg.clone(); + return self.push_notification(msg, NotificationLevel::Warning); + } + let (tag, asset) = match &self.launcher_update_available { + Some(t) => t.clone(), + None => return Task::none(), + }; + + let token = self.github_token(); + + self.is_downloading = true; + self.download_progress = Some((asset.clone(), 0.0)); + self.status_message = i18n::t_fmt("downloading", &[("file", &asset)]); + + let (progress_tx, progress_rx) = futures::channel::mpsc::unbounded::<(u64, Option)>(); + + let download_task = Task::perform( + async move { + crate::download::download_launcher_asset(token, tag, asset, Some(progress_tx)) + .await + .map_err(|e| e.to_string()) + }, + Message::LauncherDownloadCompleted, + ); + + let progress_task = Task::run(progress_rx, |(downloaded, total)| { + Message::LauncherDownloadProgress( + total + .filter(|t| *t > 0) + .map(|t| downloaded as f32 / t as f32) + .unwrap_or(0.0), + ) + }); + + let (task, handle) = Task::batch([download_task, progress_task]).abortable(); + self.download_abort = Some(handle); + task + } + + pub(super) fn launcher_download_progress(&mut self, progress: f32) -> Task { + if let Some((ref name, _)) = self.download_progress { + self.download_progress = Some((name.clone(), progress)); + } + Task::none() + } + + pub(super) fn launcher_download_completed( + &mut self, + result: Result, + ) -> Task { + self.download_progress = None; + self.is_downloading = false; + self.download_abort = None; + match result { + Ok(path) => { + self.launcher_update_staged = Some(path); + self.status_message = i18n::t("launcher_update_ready"); + self.push_notification(i18n::t("launcher_update_ready"), NotificationLevel::Info) + } + Err(e) => { + self.status_message = i18n::t_fmt("download_error", &[("error", &e)]); + self.push_notification( + i18n::t_fmt("download_error", &[("error", &e)]), + NotificationLevel::Error, + ) + } + } + } + + pub(super) fn apply_launcher_update( + &mut self, + new_binary: std::path::PathBuf, + ) -> Task { + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + crate::download::apply_launcher_update(&new_binary).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r) + }, + |result: Result| match result { + Ok(exe_path) => { + tracing::info!("Launching updated Colony: {}", exe_path.display()); + let _ = std::process::Command::new(&exe_path).spawn(); + std::process::exit(0); + } + Err(e) => Message::LauncherDownloadCompleted(Err(e)), + }, + ) + } +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000..d5bf0b0 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,820 @@ +mod github_auth; +mod keyboard; +mod launcher; +mod onboarding; +mod preferences; +mod store; + +use iced::Task; +use std::time::Duration; + +use crate::github; +use crate::i18n; +use crate::message::Message; +use crate::scan; +use crate::state::{App, DetailTab, GitHubState, Notification, NotificationLevel}; +use crate::ui::markdown_blocks; + +impl App { + /// The OAuth token of the signed-in session, if any - the one-liner that + /// used to be copy-pasted at five call sites. + pub(super) fn github_token(&self) -> Option { + if let GitHubState::Connected { session, .. } = &self.github_state { + Some(session.access_token.clone()) + } else { + None + } + } + + pub fn push_notification( + &mut self, + message: String, + level: NotificationLevel, + ) -> Task { + let id = self.next_notification_id; + self.next_notification_id += 1; + let timeout = level.timeout(); + self.notifications + .push(Notification::new(id, message, level)); + // When reduce_motion or animations off, don't auto-dismiss (user must click) + if self.reduce_motion || !self.animations { + Task::none() + } else { + Task::perform( + async move { + tokio::time::sleep(timeout).await; + }, + |_| Message::TickNotifications, + ) + } + } + + /// Decode any cached app icons that aren't yet in memory into image handles, + /// keyed by repo name. Runs when repos load; cheap and idempotent (skips + /// repos already decoded). Repos without a cached icon keep the hexagon. + pub fn reload_app_icons(&mut self) { + let names: Vec = self + .colony_repos() + .iter() + .map(|repo| repo.name.clone()) + .collect(); + for name in names { + if self.app_icons.contains_key(&name) { + continue; + } + if let Some(bytes) = crate::persistence::load_repo_icon(&name) { + if let Some(handle) = crate::icons::decode_icon(&bytes) { + self.app_icons.insert(name, handle); + } + } + } + } + + /// Rebuild the per-repo install-status cache (one filesystem pass). The + /// grid and detail views read ONLY this cache - never the disk - so it + /// must be called whenever an install can have changed: catalog load, + /// download completion, uninstall. + pub fn refresh_install_status(&mut self) { + self.install_status = self + .colony_repo_list + .iter() + .map(|repo| { + let installed = crate::persistence::installed_app_path(repo).is_some(); + let version = if installed { + crate::persistence::load_installed_version(&repo.name) + } else { + None + }; + (repo.name.clone(), (installed, version)) + }) + .collect(); + } + + /// Pop the next repo queued by "Update all" and start its download; no-op + /// when the queue is empty. Called from BOTH completion arms so one failed + /// install never strands the remaining queue. + pub(super) fn dispatch_next_queued_update(&mut self) -> Task { + if self.update_queue.is_empty() { + return Task::none(); + } + let next = self.update_queue.remove(0); + let platform = github::current_platform_key().to_string(); + Task::done(Message::DownloadRelease(next, platform)) + } + + /// Rebuild `detail_blocks` for the currently-viewed (repo, tab) if that + /// key differs from the last parse. Cheap no-op when the cache is valid. + pub fn refresh_detail_markdown(&mut self) { + let Some(repo) = self.active_repo().cloned() else { + self.detail_blocks.clear(); + self.detail_md_source = None; + self.detail_is_placeholder = false; + return; + }; + let key = (repo.name.clone(), self.detail_tab); + if self.detail_md_source.as_ref() == Some(&key) { + return; + } + // Read the doc once here (cached) instead of twice per frame in the + // view. `is_placeholder` records tabs that have no document so the view + // does no disk I/O. + let (content, is_placeholder) = match self.detail_tab { + DetailTab::ReadMe => (repo.description.clone(), false), + DetailTab::License => match crate::persistence::read_repo_doc(&repo.name, "LICENSE.md") + { + Some(c) => (c, false), + None => (String::new(), true), + }, + DetailTab::Changelog => { + match crate::persistence::read_repo_doc(&repo.name, "CHANGELOG.md") { + Some(c) => (c, false), + None => (String::new(), true), + } + } + }; + self.detail_blocks = markdown_blocks::parse(&content); + self.detail_is_placeholder = is_placeholder; + self.detail_md_source = Some(key); + } + + pub fn save_preferences(&self) { + let prefs = crate::persistence::UserPreferences { + selected_section: Some(self.selected_section), + window_width: Some(self.window_size.0), + window_height: Some(self.window_size.1), + first_launch_done: Some(!self.show_first_launch), + selected_theme: Some(self.selected_theme.clone()), + selected_variant: Some(self.selected_variant.clone()), + selected_accent: Some(self.selected_accent.clone()), + // General + restore_session: Some(self.restore_session), + default_view: Some(self.default_view.clone()), + close_behavior: None, + language: Some(self.language.clone()), + auto_check_updates: Some(self.auto_check_updates), + update_channel: None, + auto_install_updates: None, + // Appearance + font_size: Some(self.font_size.clone()), + animations: Some(self.animations), + // Accessibility + high_contrast: Some(self.high_contrast), + text_size_a11y: Some(self.text_size_a11y.clone()), + reduce_motion: Some(self.reduce_motion), + keyboard_nav: Some(self.keyboard_nav), + dyslexia_font: Some(self.dyslexia_font), + // Storage + scan_on_startup: Some(self.scan_on_startup), + }; + if let Err(e) = crate::persistence::save_preferences(&prefs) { + tracing::warn!("Failed to save preferences: {e}"); + } + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::SearchChanged(query) => { + self.search_query = query; + // The visible rows changed: a stale highlight would point at + // a hidden item. + self.keyboard_cursor = None; + Task::none() + } + Message::SectionSelected(index) => { + if index < self.sections.len() { + // Start sidebar animation from current visual position + self.sidebar_indicator_from = self.sidebar_indicator_pos(); + self.sidebar_indicator_target = index as f32 * 44.0; + self.sidebar_indicator_start = Some(std::time::Instant::now()); + self.selected_section = index; + self.active_colony_repo = None; + self.keyboard_cursor = None; + // Dismiss any open overlay panel so the section change is + // actually visible — otherwise users stay stuck on the + // GitHub / Settings panel even though the underlying + // filter just changed. + self.show_github_menu = false; + self.show_settings = false; + self.save_preferences(); + } + Task::none() + } + Message::Rescan => { + if self.is_scanning { + return Task::none(); + } + self.is_scanning = true; + self.status_message = i18n::t("scanning"); + Task::perform( + async { + tokio::task::spawn_blocking(scan::scan_applications) + .await + .map_err(|e| anyhow::anyhow!("{e}"))? + .map_err(|e| anyhow::anyhow!(e.to_string())) + }, + |result| match result { + Ok(apps) => Message::RescanCompleted(Ok(apps)), + Err(e) => Message::RescanCompleted(Err(e.to_string())), + }, + ) + } + Message::RescanCompleted(result) => { + self.is_scanning = false; + match result { + Ok(apps) => { + self.status_message = + i18n::t_fmt("apps_found", &[("count", &apps.len().to_string())]); + // Refresh the offline scan cache (previously written on + // the boot path, now that the scan runs off-thread). + let cached: Vec = apps + .iter() + .map(|app| crate::persistence::CachedApp { + name: app.name.clone(), + exec: app.exec.clone(), + icon: app.icon.clone(), + category: format!("{:?}", app.category), + origin: format!("{:?}", app.origin), + }) + .collect(); + if let Err(e) = crate::persistence::save_scan_cache(&cached) { + tracing::warn!("Failed to save scan cache: {e}"); + } + self.applications = apps; + } + Err(e) => { + self.status_message = i18n::t_fmt("scan_error", &[("error", &e)]); + } + } + Task::none() + } + Message::LaunchApp(exec) => { + let launch_result = { + #[cfg(windows)] + { + // A scanned Windows entry is often a `.lnk`, which + // CreateProcess cannot run, so this one genuinely needs + // `start`. Build the command line with raw_arg and quote + // the target ourselves: Rust only quotes arguments that + // contain whitespace, which would leave cmd to re-parse + // `&`, `|` and friends in the path as separators. A quote + // in the path would escape our quoting, so reject it. + use std::os::windows::process::CommandExt; + if exec.contains('"') || exec.chars().any(|c| c.is_control()) { + Err(i18n::t_fmt( + "launch_error", + &[("error", "unsupported characters in application path")], + )) + } else { + std::process::Command::new("cmd") + .raw_arg(format!("/C start \"\" \"{exec}\"")) + .spawn() + .map(|_| ()) + .map_err(|error| { + i18n::t_fmt("launch_error", &[("error", &error.to_string())]) + }) + } + } + + #[cfg(not(windows))] + { + match shell_words::split(&exec) { + Ok(mut parts) => { + parts.retain(|part| !part.is_empty()); + if let Some((cmd, args)) = parts.split_first() { + std::process::Command::new(cmd) + .args(args) + .spawn() + .map(|_| ()) + .map_err(|error| { + i18n::t_fmt( + "launch_error", + &[("error", &error.to_string())], + ) + }) + } else { + Err(i18n::t("launch_error_empty")) + } + } + Err(error) => Err(i18n::t_fmt( + "launch_error", + &[("error", &error.to_string())], + )), + } + } + }; + + match launch_result { + Ok(()) => { + self.status_message = i18n::t("app_launched"); + Task::perform( + async { + tokio::time::sleep(Duration::from_secs(4)).await; + }, + |_| Message::ClearStatus, + ) + } + Err(msg) => { + self.status_message = msg.clone(); + self.push_notification(msg, NotificationLevel::Error) + } + } + } + Message::ColonyRepoSelected(name) => { + self.active_colony_repo = Some(name); + // A selection made from the GitHub panel must actually show + // the detail page, not stay hidden behind the overlay. + self.show_github_menu = false; + self.refresh_detail_markdown(); + Task::none() + } + Message::ColonyRepoBack => { + self.active_colony_repo = None; + self.confirm_uninstall = None; + self.detail_tab = crate::state::DetailTab::ReadMe; + Task::none() + } + Message::ClearStatus => { + self.status_message = i18n::t_fmt( + "apps_found", + &[("count", &self.applications.len().to_string())], + ); + Task::none() + } + Message::FontLoaded(_) => Task::none(), + + // --- GitHub / OAuth (update/github_auth.rs) --- + Message::ToggleGitHubMenu => self.toggle_github_menu(), + Message::GitHubLogin => self.github_login(), + Message::GitHubDeviceCodeReceived(result) => self.github_device_code_received(result), + Message::GitHubLoginCompleted(result) => self.github_login_completed(result), + Message::GitHubLogout => self.github_logout(), + Message::GitHubReposFetched(repos) => self.github_repos_fetched(repos), + Message::GitHubError(e) => self.github_error(e), + + Message::DownloadRelease(repo_name, platform_key) => { + self.download_release(repo_name, platform_key) + } + Message::DownloadProgress(filename, downloaded, total) => { + self.download_progress(filename, downloaded, total) + } + Message::DownloadCompleted(result) => self.download_completed(result), + Message::CancelDownload => self.cancel_download(), + Message::LaunchColonyApp(path) => self.launch_colony_app(path), + Message::ConfirmUninstall(repo_name) => self.confirm_uninstall(repo_name), + Message::CancelUninstall => self.cancel_uninstall(), + Message::UninstallColonyApp(repo_name) => self.uninstall_colony_app(repo_name), + Message::GitHubRefreshRepos => self.github_refresh_repos(), + Message::ClearStoreCaches => self.clear_store_caches(), + Message::CopyToClipboard(value) => iced::clipboard::write(value), + Message::OpenUrl(url) => { + // Single choke point for the three producers of this message + // (Markdown link clicks, README badge pills, "View on GitHub"), + // two of which carry remote attacker-influenced strings. Only + // http(s) may reach the desktop URI opener - see is_web_url. + let Some(safe) = crate::download::web_url(&url) else { + tracing::warn!("refusing to open non-http(s) url {url:?}"); + return Task::none(); + }; + if let Err(err) = open::that(&safe) { + tracing::warn!("failed to open url {safe:?}: {err}"); + } + Task::none() + } + Message::DismissNotification(id) => { + self.notifications.retain(|n| n.id != id); + Task::none() + } + Message::TickNotifications => { + if self.animations && !self.reduce_motion { + // Mark expired toasts for fade-out instead of dropping + // them: `removing` re-arms the animation subscription + // (has_active_animations), which previously stopped + // before the fade could ever play. + for n in &mut self.notifications { + if n.is_expired() { + n.removing = true; + } + } + } else { + self.notifications.retain(|n| !n.is_expired()); + } + Task::none() + } + Message::AnimationTick => { + const SPEED: f32 = 0.15; + const SNAP: f32 = 0.005; + let fade_lead = Duration::from_millis(800); + + // Notification fade-in / fade-out + for notif in &mut self.notifications { + // Fade in + if notif.fade_in < 1.0 && !notif.removing { + notif.fade_in = (notif.fade_in + SPEED).min(1.0); + if (1.0 - notif.fade_in) < SNAP { + notif.fade_in = 1.0; + } + } + // Start fade-out before expiration + let timeout = notif.level.timeout(); + if notif.created_at.elapsed() + fade_lead >= timeout && !notif.removing { + notif.removing = true; + } + // Fade out + if notif.removing { + notif.fade_out = (notif.fade_out - SPEED).max(0.0); + if notif.fade_out < SNAP { + notif.fade_out = 0.0; + } + } + } + self.notifications.retain(|n| n.fade_out > 0.0); + + // Smooth progress bar + if let Some((_, target)) = &self.download_progress { + let target = *target; + let diff = target - self.progress_display; + if diff.abs() > SNAP { + self.progress_display += diff * SPEED; + } else { + self.progress_display = target; + } + } else { + self.progress_display = 0.0; + } + + // Sidebar indicator: clear animation when duration elapsed + if let Some(start) = self.sidebar_indicator_start { + let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0; + if elapsed_ms >= App::SIDEBAR_ANIM_MS { + self.sidebar_indicator_start = None; + self.sidebar_indicator_from = self.sidebar_indicator_target; + } + } + + Task::none() + } + Message::KeyboardEvent(event) => self.keyboard_event(event), + Message::CheckUpdates => self.check_updates(), + Message::WindowResized(w, h) => { + self.window_size = (w, h); + self.window_save_gen += 1; + let gen = self.window_save_gen; + // Debounce: resize events flood during an interactive drag; + // only the delayed save matching the LAST generation writes. + Task::perform( + async move { + tokio::time::sleep(Duration::from_secs(1)).await; + gen + }, + Message::PersistWindowSize, + ) + } + Message::PersistWindowSize(gen) => { + if gen == self.window_save_gen { + self.save_preferences(); + } + Task::none() + } + Message::UpdateAll => self.update_all(), + Message::FetchReleaseNotes(repo_name) => self.fetch_release_notes(repo_name), + Message::ReleaseNotesFetched(repo_name, result) => { + self.release_notes_fetched(repo_name, result) + } + Message::UpdatesChecked(updates) => self.updates_checked(updates), + Message::ToggleFavorite(name) => self.toggle_favorite(name), + // --- Onboarding (update/onboarding.rs) --- + Message::DismissFirstLaunch => self.dismiss_first_launch(), + Message::WelcomeNext => self.welcome_next(), + Message::WelcomeBack => self.welcome_back(), + Message::TutorialBoundsUpdated(bounds) => self.tutorial_bounds_updated(bounds), + Message::WelcomeConnectGithub => self.welcome_connect_github(), + + // --- Settings and preferences (update/preferences.rs) --- + Message::ToggleSettings => self.toggle_settings(), + Message::SettingsCategory(idx) => self.select_settings_category(idx), + Message::SettingsToggleSection(key) => self.toggle_settings_section(key), + Message::SelectThemeVariant(theme, variant) => { + self.select_theme_variant(theme, variant) + } + Message::SelectAccentColor(color) => self.select_accent_color(color), + Message::ToggleAutoAccent => self.toggle_auto_accent(), + Message::ToggleRestoreSession => self.toggle_restore_session(), + Message::PickDefaultView(v) => self.pick_default_view(v), + Message::PickLanguage(v) => self.pick_language(v), + Message::ToggleAutoCheckUpdates => self.toggle_auto_check_updates(), + Message::PickFontSize(v) => self.pick_font_size(v), + Message::ToggleAnimations => self.toggle_animations(), + Message::ToggleHighContrast => self.toggle_high_contrast(), + Message::PickTextSizeA11y(v) => self.pick_text_size_a11y(v), + Message::ToggleReduceMotion => self.toggle_reduce_motion(), + Message::ToggleKeyboardNav => self.toggle_keyboard_nav(), + Message::ToggleDyslexiaFont => self.toggle_dyslexia_font(), + Message::ToggleScanOnStartup => self.toggle_scan_on_startup(), + Message::DetailTabSelected(tab) => { + self.detail_tab = tab; + self.refresh_detail_markdown(); + Task::none() + } + // --- Launcher self-update (update/launcher.rs) --- + Message::CheckLauncherUpdate { manual } => self.check_launcher_update(manual), + Message::LauncherUpdateChecked(manual, result) => { + self.launcher_update_checked(manual, result) + } + Message::DownloadLauncherUpdate => self.download_launcher_update(), + Message::LauncherDownloadProgress(progress) => { + self.launcher_download_progress(progress) + } + Message::LauncherDownloadCompleted(result) => self.launcher_download_completed(result), + Message::ApplyLauncherUpdate(new_binary) => self.apply_launcher_update(new_binary), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::github::{ColonyManifest, ColonyRepo, ReleaseFileEntry}; + + fn repo(name: &str, desc: &str) -> ColonyRepo { + let mut release_files = std::collections::HashMap::new(); + release_files.insert( + github::current_platform_key().to_string(), + ReleaseFileEntry { + tag: "latest".into(), + file: Some(format!("{name}-bin")), + file_pattern: None, + binary: None, + sha256: None, + }, + ); + ColonyRepo { + name: name.into(), + description: desc.into(), + language: "Rust".into(), + html_url: format!("https://github.com/Project-Colony/{name}"), + manifest: ColonyManifest { + name: name.into(), + category: "Development".into(), + platforms: vec!["linux".into()], + release_files, + icon: None, + signed: false, + }, + } + } + + /// Serialize tests that redirect XDG dirs (env vars are process-global) + /// and keep every disk write inside a throwaway directory. `dirs` only + /// honors XDG on Linux, so callers gate on cfg(target_os = "linux"). + #[cfg(target_os = "linux")] + fn with_temp_dirs(f: impl FnOnce()) { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let old_config = std::env::var_os("XDG_CONFIG_HOME"); + let old_data = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("config")); + std::env::set_var("XDG_DATA_HOME", tmp.path().join("data")); + f(); + match old_config { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match old_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + + #[test] + fn open_detail_survives_catalog_replacement_and_reorder() { + let mut app = App::new_for_test(); + app.colony_repo_list = vec![repo("Alpha", ""), repo("Beta", ""), repo("Gamma", "")]; + let _ = app.update(Message::ColonyRepoSelected("Beta".into())); + assert_eq!(app.active_repo().map(|r| r.name.as_str()), Some("Beta")); + + // A refresh replaces AND reorders the vector (GitHub sorts by last + // push): the open detail page must still resolve to the same app. + app.colony_repo_list = vec![repo("Gamma", ""), repo("Beta", ""), repo("Alpha", "")]; + assert_eq!(app.active_repo().map(|r| r.name.as_str()), Some("Beta")); + + // A repo that vanished resolves to None (the view falls back to the + // grid) instead of showing someone else's page. + app.colony_repo_list = vec![repo("Alpha", "")]; + assert!(app.active_repo().is_none()); + } + + #[test] + fn download_completion_clears_the_update_badge() { + let mut app = App::new_for_test(); + app.available_updates + .insert("Grape".to_string(), "v2.0.0".to_string()); + let _ = app.update(Message::DownloadCompleted(Ok(( + std::path::PathBuf::from("/tmp/grape-bin"), + "Grape".to_string(), + "v2.0.0".to_string(), + )))); + assert!( + !app.available_updates.contains_key("Grape"), + "badge must not survive the update it advertised" + ); + assert!(!app.is_downloading); + } + + #[test] + fn update_all_queues_updatable_repos_and_chains_on_completion() { + let mut app = App::new_for_test(); + app.colony_repo_list = vec![repo("One", ""), repo("Two", ""), repo("Three", "")]; + app.available_updates + .insert("One".to_string(), "v2".to_string()); + app.available_updates + .insert("Three".to_string(), "v2".to_string()); + + let _ = app.update(Message::UpdateAll); + // The first updatable repo is dispatched immediately; the rest queue. + assert_eq!(app.update_queue, vec!["Three".to_string()]); + + // A completion - success or failure - pops the next entry. + let _ = app.update(Message::DownloadCompleted(Err("boom".into()))); + assert!( + app.update_queue.is_empty(), + "failure must not strand the queue" + ); + } + + #[test] + fn cancel_download_empties_the_update_queue() { + let mut app = App::new_for_test(); + app.update_queue = vec!["A".into(), "B".into()]; + app.is_downloading = true; + let _ = app.update(Message::CancelDownload); + assert!(app.update_queue.is_empty(), "cancel means stop, not skip"); + assert!(!app.is_downloading); + } + + #[test] + fn launcher_check_failure_never_claims_up_to_date() { + let mut app = App::new_for_test(); + app.is_checking_launcher_update = true; + let _ = app.update(Message::LauncherUpdateChecked( + false, + Err("network down".into()), + )); + assert!(!app.is_checking_launcher_update); + assert!(app.launcher_update_available.is_none()); + assert!( + app.status_message.contains("network down"), + "the failure must surface, got: {}", + app.status_message + ); + // Automatic check: no toast for the failure either (status line only). + assert!(app.notifications.is_empty()); + + // A clean Ok(None) on an AUTOMATIC check stays quiet (no toast)... + let _ = app.update(Message::LauncherUpdateChecked(false, Ok(None))); + assert!(app.notifications.is_empty()); + // ...but a MANUAL check gets explicit feedback. + let _ = app.update(Message::LauncherUpdateChecked(true, Ok(None))); + assert_eq!(app.notifications.len(), 1); + } + + #[test] + fn window_resize_bumps_generation_and_stale_saves_are_ignored() { + let mut app = App::new_for_test(); + let _ = app.update(Message::WindowResized(1280.0, 800.0)); + let _ = app.update(Message::WindowResized(1300.0, 820.0)); + assert_eq!(app.window_size, (1300.0, 820.0)); + assert_eq!(app.window_save_gen, 2); + // A stale generation must not trigger a save; the state check here is + // that the handler is a no-op (the fresh gen path writes prefs, which + // is covered by the linux-gated persistence test). + let _ = app.update(Message::PersistWindowSize(1)); + assert_eq!(app.window_save_gen, 2); + } + + #[test] + fn search_matches_description_and_display_name() { + let mut app = App::new_for_test(); + app.colony_repo_list = vec![ + repo("Grape", "Lecteur musique en Rust"), + repo("orCAL", "Calendar overlay"), + ]; + app.search_query = "musique".into(); + let hits: Vec<&str> = app + .filtered_colony_repos() + .iter() + .map(|r| r.name.as_str()) + .collect(); + assert_eq!(hits, vec!["Grape"]); + } + + #[test] + fn section_selection_out_of_bounds_is_ignored() { + let mut app = App::new_for_test(); + // No sections loaded: any index is out of bounds and must be ignored + // (and must not write preferences or panic). + let _ = app.update(Message::SectionSelected(3)); + assert_eq!(app.selected_section, 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn repos_fetched_stores_catalog_while_disconnected_and_prunes_orphans() { + with_temp_dirs(|| { + let mut app = App::new_for_test(); + assert!(matches!(app.github_state, GitHubState::Disconnected)); + + // Seed an orphaned doc cache for a repo that no longer exists. + let orphan = crate::persistence::colony_data_dir() + .unwrap() + .join("repo-docs") + .join("Ghost"); + std::fs::create_dir_all(&orphan).unwrap(); + + let _ = app.update(Message::GitHubReposFetched(vec![repo("Alive", "")])); + + // The catalog is stored even though no session exists (anonymous + // mode), and the orphaned cache is pruned. + assert_eq!(app.colony_repos().len(), 1); + assert!(!orphan.exists(), "orphaned cache must be pruned"); + }); + } + + #[cfg(target_os = "linux")] + #[test] + fn github_error_only_toasts_when_the_catalog_is_empty() { + with_temp_dirs(|| { + // Empty catalog + no cache: the failure interrupts (error toast). + let mut app = App::new_for_test(); + let _ = app.update(Message::GitHubError("boom".into())); + assert_eq!(app.notifications.len(), 1); + + // Catalog showing: the same failure stays in the status line. + let mut app = App::new_for_test(); + app.colony_repo_list = vec![repo("Alive", "")]; + let _ = app.update(Message::GitHubError("boom".into())); + assert!(app.notifications.is_empty()); + assert!(app.status_message.contains("boom")); + }); + } + + #[cfg(target_os = "linux")] + /// The pin is what stops a compromised repo from flipping `signed` back to + /// false, so it must survive a manifest that no longer asks for signatures - + /// and a case-only rename of the repo, which creates a different directory. + #[cfg(target_os = "linux")] + #[test] + fn signature_pin_survives_and_is_case_insensitive() { + with_temp_dirs(|| { + use crate::persistence::{load_installed_signed, save_installed_signed}; + assert!( + !load_installed_signed("Spotter"), + "no pin before any install" + ); + + // The installer creates the app directory before recording anything; + // colony_app_dir deliberately does not, so mirror that order here. + std::fs::create_dir_all(crate::persistence::colony_app_dir("Spotter").unwrap()) + .unwrap(); + save_installed_signed("Spotter").unwrap(); + assert!(load_installed_signed("Spotter")); + assert!( + load_installed_signed("spotter"), + "a case-only rename must not drop the pin" + ); + assert!(load_installed_signed("SPOTTER")); + assert!( + !load_installed_signed("SpotterX"), + "the match must not be a prefix match" + ); + + // No API can clear it: only removing the app directory does, which is + // what uninstalling deliberately performs. + save_installed_signed("Spotter").unwrap(); + assert!(load_installed_signed("Spotter")); + let dir = crate::persistence::colony_app_dir("Spotter").unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + assert!( + !load_installed_signed("Spotter"), + "uninstall clears the pin" + ); + }); + } + + #[cfg(target_os = "linux")] + #[test] + fn toggle_favorite_persists_to_disk() { + with_temp_dirs(|| { + let mut app = App::new_for_test(); + let _ = app.update(Message::ToggleFavorite("Grape".into())); + assert!(app.is_favorite("Grape")); + assert_eq!( + crate::persistence::load_favorites(), + vec!["Grape".to_string()] + ); + let _ = app.update(Message::ToggleFavorite("Grape".into())); + assert!(!app.is_favorite("Grape")); + assert!(crate::persistence::load_favorites().is_empty()); + }); + } +} diff --git a/src/update/onboarding.rs b/src/update/onboarding.rs new file mode 100644 index 0000000..4eb0a8b --- /dev/null +++ b/src/update/onboarding.rs @@ -0,0 +1,50 @@ +//! First-launch welcome flow and the coachmark tutorial. + +use iced::Task; + +use crate::message::Message; +use crate::state::App; +use crate::ui::TutorialBounds; + +impl App { + pub(super) fn dismiss_first_launch(&mut self) -> Task { + self.show_first_launch = false; + self.welcome_step = 0; + self.save_preferences(); + Task::none() + } + + pub(super) fn welcome_next(&mut self) -> Task { + const LAST_STEP: u8 = crate::ui::TUTORIAL_LAST_STEP; + if self.welcome_step >= LAST_STEP { + self.show_first_launch = false; + self.welcome_step = 0; + self.save_preferences(); + Task::none() + } else { + self.welcome_step += 1; + crate::ui::fetch_bounds_task() + } + } + + pub(super) fn welcome_back(&mut self) -> Task { + self.welcome_step = self.welcome_step.saturating_sub(1); + crate::ui::fetch_bounds_task() + } + + pub(super) fn tutorial_bounds_updated(&mut self, bounds: TutorialBounds) -> Task { + self.tutorial_bounds = bounds; + Task::none() + } + + pub(super) fn welcome_connect_github(&mut self) -> Task { + // Close the welcome overlay and jump straight to the GitHub panel so the + // user can start the device-flow login without an extra "dismiss then + // navigate" step. + self.show_first_launch = false; + self.welcome_step = 0; + self.show_github_menu = true; + self.save_preferences(); + Task::none() + } +} diff --git a/src/update/preferences.rs b/src/update/preferences.rs new file mode 100644 index 0000000..f996f1c --- /dev/null +++ b/src/update/preferences.rs @@ -0,0 +1,140 @@ +//! Settings-panel state and every user preference toggle. +//! +//! These arms share one shape: mutate a field, persist, and occasionally push a +//! side effect into the theme engine or i18n. They are kept as explicit methods +//! rather than a data-driven table so each preference stays greppable by name. + +use iced::Task; + +use crate::i18n; +use crate::message::Message; +use crate::state::{App, NotificationLevel}; +use crate::ui::theme::{ + accent_key_to_color, set_active_accent, set_active_theme, set_high_contrast, +}; + +impl App { + pub(super) fn toggle_settings(&mut self) -> Task { + self.show_settings = !self.show_settings; + if !self.show_settings { + self.settings_category = 0; + } + Task::none() + } + + pub(super) fn select_settings_category(&mut self, idx: usize) -> Task { + self.settings_category = idx; + Task::none() + } + + pub(super) fn toggle_settings_section(&mut self, key: String) -> Task { + if !self.settings_expanded_sections.remove(&key) { + self.settings_expanded_sections.insert(key); + } + Task::none() + } + + pub(super) fn select_theme_variant(&mut self, theme: String, variant: String) -> Task { + self.selected_theme = theme; + self.selected_variant = variant; + set_active_theme(&self.selected_theme, &self.selected_variant); + self.save_preferences(); + self.push_notification(i18n::t("theme_applied"), NotificationLevel::Info) + } + + pub(super) fn select_accent_color(&mut self, color: String) -> Task { + set_active_accent(accent_key_to_color(&color)); + self.selected_accent = color; + self.auto_accent = false; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_auto_accent(&mut self) -> Task { + self.auto_accent = !self.auto_accent; + if self.auto_accent { + set_active_accent(None); + } else { + set_active_accent(accent_key_to_color(&self.selected_accent)); + } + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_restore_session(&mut self) -> Task { + self.restore_session = !self.restore_session; + self.save_preferences(); + Task::none() + } + + pub(super) fn pick_default_view(&mut self, view: String) -> Task { + self.default_view = view; + self.save_preferences(); + Task::none() + } + + pub(super) fn pick_language(&mut self, language: String) -> Task { + self.language = language; + self.save_preferences(); + // 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() + } + + pub(super) fn toggle_auto_check_updates(&mut self) -> Task { + self.auto_check_updates = !self.auto_check_updates; + self.save_preferences(); + Task::none() + } + + pub(super) fn pick_font_size(&mut self, size: String) -> Task { + self.font_size = size; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_animations(&mut self) -> Task { + self.animations = !self.animations; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_high_contrast(&mut self) -> Task { + self.high_contrast = !self.high_contrast; + set_high_contrast(self.high_contrast); + self.save_preferences(); + Task::none() + } + + pub(super) fn pick_text_size_a11y(&mut self, size: String) -> Task { + self.text_size_a11y = size; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_reduce_motion(&mut self) -> Task { + self.reduce_motion = !self.reduce_motion; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_keyboard_nav(&mut self) -> Task { + self.keyboard_nav = !self.keyboard_nav; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_dyslexia_font(&mut self) -> Task { + self.dyslexia_font = !self.dyslexia_font; + self.save_preferences(); + Task::none() + } + + pub(super) fn toggle_scan_on_startup(&mut self) -> Task { + self.scan_on_startup = !self.scan_on_startup; + self.save_preferences(); + Task::none() + } +} diff --git a/src/update/store.rs b/src/update/store.rs new file mode 100644 index 0000000..9ef0ddd --- /dev/null +++ b/src/update/store.rs @@ -0,0 +1,504 @@ +//! The store side of the launcher: install, update, uninstall, release notes. + +use iced::Task; +use std::time::Duration; + +use crate::github; +use crate::i18n; +use crate::message::Message; +use crate::state::{App, NotificationLevel}; +use crate::ui::markdown_blocks; + +impl App { + pub(super) fn download_release( + &mut self, + repo_name: String, + platform_key: String, + ) -> Task { + if self.is_downloading { + return Task::none(); + } + let repos = self.colony_repos(); + if let Some(repo) = repos.iter().find(|r| r.name == repo_name) { + if let Some(entry) = repo.manifest.release_files.get(&platform_key) { + let tag = entry.tag.clone(); + let file = entry.file.clone(); + let file_pattern = entry.file_pattern.clone(); + let binary = entry.binary.clone(); + let expected_sha256 = entry.sha256.clone(); + // Only what the manifest declares; the installer ORs in + // its own pin from any previously verified install, so + // that rule lives next to the check it feeds. + let require_signature = repo.manifest.signed; + let repo_name = repo.name.clone(); + // API calls (release resolution) use the token for + // rate limits; the asset download itself is a public + // endpoint and gets NO token - no reason to present + // credentials where none are needed. + let token = self.github_token(); + let display_name = file + .as_deref() + .or(file_pattern.as_deref()) + .unwrap_or(&repo.name) + .to_string(); + self.status_message = i18n::t_fmt("downloading", &[("file", &display_name)]); + self.download_progress = Some((display_name.clone(), 0.0)); + self.is_downloading = true; + self.downloading_repo = Some(repo_name.clone()); + let dl_repo = repo_name.clone(); + let (progress_tx, progress_rx) = + futures::channel::mpsc::unbounded::<(u64, Option)>(); + let progress_name = display_name; + + let download_task = Task::perform( + async move { + // Fetch release info if we need tag resolution or asset matching + let needs_release_info = + tag.eq_ignore_ascii_case("latest") || file_pattern.is_some(); + + let (resolved_tag, resolved_file) = if needs_release_info { + let client = github::build_update_client(token.as_deref())?; + let release_info = + github::fetch_release_info(&client, &repo_name, &tag).await?; + let filename = if let Some(ref f) = file { + f.clone() + } else if let Some(ref pattern) = file_pattern { + github::find_asset_by_pattern(&release_info.asset_names, pattern)? + } else { + anyhow::bail!("colony.json: 'file' or 'filePattern' is required"); + }; + (release_info.tag, filename) + } else { + let f = file.ok_or_else(|| { + anyhow::anyhow!("colony.json: 'file' or 'filePattern' is required") + })?; + (tag, f) + }; + + // The version/asset records are written by + // download_release_asset itself, inside the + // blocking install step: writing them here (or + // in DownloadCompleted) meant a cancel landing + // mid-install detached the blocking task and + // left an installed binary with no metadata. + let path = crate::download::download_release_asset( + None, + crate::download::AssetInstall { + repo_name: repo_name.clone(), + tag: resolved_tag.clone(), + filename: resolved_file.clone(), + binary_name: binary, + expected_sha256, + record_asset: file_pattern.is_some(), + require_signature, + }, + Some(progress_tx), + ) + .await?; + + Ok((path, dl_repo, resolved_tag)) + }, + |result: Result<_, anyhow::Error>| { + Message::DownloadCompleted(result.map_err(|e| e.to_string())) + }, + ); + + let progress_task = Task::run(progress_rx, move |(downloaded, total)| { + Message::DownloadProgress(progress_name.clone(), downloaded, total) + }); + + // Keep an abort handle so CancelDownload actually stops + // the download and its progress stream (dropping the + // progress sender), instead of only clearing the UI. + let (task, handle) = Task::batch([download_task, progress_task]).abortable(); + self.download_abort = Some(handle); + return task; + } else { + self.status_message = i18n::t_fmt("no_release_for", &[("platform", &platform_key)]); + } + } + Task::none() + } + + pub(super) fn download_progress( + &mut self, + filename: String, + downloaded: u64, + total: Option, + ) -> Task { + // Ignore late progress events from a cancelled/finished download + // so the toast cannot resurrect after CancelDownload. + if self.is_downloading { + let fraction = total + .filter(|t| *t > 0) + .map(|t| downloaded as f32 / t as f32) + .unwrap_or(0.0); + self.download_progress = Some((filename, fraction)); + self.download_bytes = Some((downloaded, total)); + // Transfer speed: exponential moving average over samples. + let now = std::time::Instant::now(); + if let Some((t0, b0)) = self.last_progress_sample { + let dt = now.duration_since(t0).as_secs_f32(); + if dt > 0.05 && downloaded >= b0 { + let inst = (downloaded - b0) as f32 / dt; + self.download_speed = if self.download_speed > 0.0 { + 0.7 * self.download_speed + 0.3 * inst + } else { + inst + }; + self.last_progress_sample = Some((now, downloaded)); + } + } else { + self.last_progress_sample = Some((now, downloaded)); + } + } + Task::none() + } + + pub(super) fn download_completed( + &mut self, + result: Result<(std::path::PathBuf, String, String), String>, + ) -> Task { + self.download_progress = None; + self.download_bytes = None; + self.download_speed = 0.0; + self.last_progress_sample = None; + self.is_downloading = false; + self.download_abort = None; + self.downloading_repo = None; + match result { + // Version/asset records were written atomically with the + // install (inside download_release_asset), so the tag is + // no longer needed here. + Ok((path, repo_name, _tag)) => { + // The just-installed version IS the one the badge was + // advertising: clear it, or the card keeps showing + // "Update vX -> vX" until the next global check. + self.available_updates.remove(&repo_name); + self.refresh_install_status(); + let display_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + // Use the short binary name (not the full install path) + // so the header status text can't squeeze the search box. + self.status_message = i18n::t_fmt("installed", &[("path", &display_name)]); + let notif = self.push_notification( + i18n::t_fmt("installed", &[("path", &display_name)]), + NotificationLevel::Info, + ); + Task::batch([notif, self.dispatch_next_queued_update()]) + } + Err(e) => { + self.status_message = i18n::t_fmt("download_error", &[("error", &e)]); + let notif = self.push_notification( + i18n::t_fmt("download_error", &[("error", &e)]), + NotificationLevel::Error, + ); + // A failed item does not strand the rest of the queue. + Task::batch([notif, self.dispatch_next_queued_update()]) + } + } + } + + pub(super) fn cancel_download(&mut self) -> Task { + // Actually abort the running download + progress tasks so no + // phantom install completes and no second writer can race the + // same file on a retry. Cancel also empties the "Update all" + // queue: cancelling means stop, not "skip this one". + self.update_queue.clear(); + if let Some(handle) = self.download_abort.take() { + handle.abort(); + } + // The aborted task cannot clean up its staging file: sweep + // the cancelled repo's *.part leftovers here. + if let Some(repo) = self.downloading_repo.take() { + if let Ok(app_dir) = crate::persistence::colony_app_dir(&repo) { + if let Ok(entries) = std::fs::read_dir(&app_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".part") { + let _ = std::fs::remove_file(entry.path()); + } + } + } + } + } + self.download_progress = None; + self.download_bytes = None; + self.download_speed = 0.0; + self.last_progress_sample = None; + self.is_downloading = false; + self.status_message = i18n::t("download_cancelled"); + self.push_notification(i18n::t("download_cancelled"), NotificationLevel::Warning) + } + + pub(super) fn launch_colony_app(&mut self, path: std::path::PathBuf) -> Task { + // Executed directly on every platform, never through `cmd /C`: + // Windows only quotes an argument containing a space or tab, so a + // manifest-derived path like `app&calc` reached cmd unquoted and + // its `&` was parsed as a command separator. A store install is + // always a real executable, so the shell buys nothing here. + let result = std::process::Command::new(&path).spawn().map(|_| ()); + + match result { + Ok(()) => { + self.status_message = i18n::t("app_launched"); + Task::perform( + async { + tokio::time::sleep(Duration::from_secs(4)).await; + }, + |_| Message::ClearStatus, + ) + } + Err(e) => { + let msg = i18n::t_fmt("launch_error_msg", &[("error", &e.to_string())]); + self.status_message = msg.clone(); + self.push_notification(msg, NotificationLevel::Error) + } + } + } + + pub(super) fn confirm_uninstall(&mut self, repo_name: String) -> Task { + self.confirm_uninstall = Some(repo_name); + Task::none() + } + + pub(super) fn cancel_uninstall(&mut self) -> Task { + self.confirm_uninstall = None; + Task::none() + } + + pub(super) fn uninstall_colony_app(&mut self, repo_name: String) -> Task { + self.confirm_uninstall = None; + // An uninstalled app has no meaningful "update available". + self.available_updates.remove(&repo_name); + // Stale notes describe the version that was just removed. + // (Doc/icon caches and the favorite deliberately survive: they + // 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 crate::persistence::colony_app_dir(&repo_name) { + Ok(app_dir) => { + if app_dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&app_dir) { + self.status_message = + i18n::t_fmt("uninstall_error", &[("error", &e.to_string())]); + } else { + self.status_message = i18n::t_fmt("uninstalled", &[("name", &repo_name)]); + // AFTER the directory removal, so the cache + // records the app as gone. + self.refresh_install_status(); + return Task::perform( + async { + tokio::time::sleep(Duration::from_secs(4)).await; + }, + |_| Message::ClearStatus, + ); + } + } + } + Err(e) => { + self.status_message = i18n::t_fmt("scan_error", &[("error", &e.to_string())]); + } + } + Task::none() + } + + pub(super) fn clear_store_caches(&mut self) -> Task { + let removed = crate::persistence::clear_store_caches(); + self.app_icons.clear(); + self.release_notes.clear(); + self.detail_md_source = None; + self.refresh_detail_markdown(); + let msg = i18n::t_fmt("caches_cleared", &[("count", &removed.to_string())]); + self.status_message = msg.clone(); + self.push_notification(msg, NotificationLevel::Info) + } + + pub(super) fn check_updates(&mut self) -> Task { + if self.is_checking_updates { + return Task::none(); + } + self.is_checking_updates = true; + self.status_message = i18n::t("checking_updates"); + // Collect (repo, pinned tag for this platform) for every + // installed Colony app so update detection compares against the + // tag that would actually be installed, not /releases/latest. + let platform = github::current_platform_key(); + let repos: Vec<(String, String)> = self + .colony_repos() + .iter() + .filter(|r| crate::persistence::installed_app_path(r).is_some()) + .filter_map(|r| { + r.manifest + .release_files + .get(platform) + .map(|entry| (r.name.clone(), entry.tag.clone())) + }) + .collect(); + + if repos.is_empty() { + // Nothing to check — reset the guard (otherwise it stays true + // forever, blocking all later checks) and still run the + // chained launcher self-update check. + self.is_checking_updates = false; + self.status_message = i18n::t_fmt( + "apps_found", + &[("count", &self.applications.len().to_string())], + ); + return Task::done(Message::CheckLauncherUpdate { manual: false }); + } + + let token = self.github_token(); + + Task::perform( + async move { + let client = match github::build_update_client(token.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let futs: Vec<_> = repos + .iter() + .map(|(name, tag)| { + let c = client.clone(); + let n = name.clone(); + let t = tag.clone(); + async move { + github::check_update_available(&c, &n, &t) + .await + .map(|v| (n, v)) + } + }) + .collect(); + futures::future::join_all(futs) + .await + .into_iter() + .flatten() + .collect() + }, + Message::UpdatesChecked, + ) + } + + pub(super) fn update_all(&mut self) -> Task { + if self.is_downloading { + return Task::none(); + } + let platform = github::current_platform_key(); + // Queue every updatable repo that actually ships an asset for + // this platform; order follows the catalog for predictability. + let mut queue: Vec = self + .colony_repos() + .iter() + .filter(|r| { + self.available_updates.contains_key(&r.name) + && r.manifest.release_files.contains_key(platform) + }) + .map(|r| r.name.clone()) + .collect(); + if queue.is_empty() { + return Task::none(); + } + let first = queue.remove(0); + self.update_queue = queue; + Task::done(Message::DownloadRelease(first, platform.to_string())) + } + + pub(super) fn fetch_release_notes(&mut self, repo_name: String) -> Task { + if self.fetching_notes.contains(&repo_name) { + return Task::none(); + } + // Show the notes of the AVAILABLE update when there is one, + // otherwise of the manifest's pinned/latest release. + let platform = github::current_platform_key(); + let tag = self.available_updates.get(&repo_name).cloned().or_else(|| { + self.colony_repos() + .iter() + .find(|r| r.name == repo_name) + .and_then(|r| r.manifest.release_files.get(platform)) + .map(|e| e.tag.clone()) + }); + let Some(tag) = tag else { + return Task::none(); + }; + self.fetching_notes.insert(repo_name.clone()); + let token = self.github_token(); + let repo_for_result = repo_name.clone(); + Task::perform( + async move { + let client = + github::build_update_client(token.as_deref()).map_err(|e| e.to_string())?; + let info = github::fetch_release_info(&client, &repo_name, &tag) + .await + .map_err(|e| e.to_string())?; + Ok((info.tag, info.body.unwrap_or_default())) + }, + move |result: Result<(String, String), String>| { + Message::ReleaseNotesFetched(repo_for_result, result) + }, + ) + } + + pub(super) fn release_notes_fetched( + &mut self, + repo_name: String, + result: Result<(String, String), String>, + ) -> Task { + self.fetching_notes.remove(&repo_name); + match result { + Ok((tag, body)) => { + let blocks = markdown_blocks::parse(&body); + self.release_notes.insert(repo_name, (tag, blocks)); + } + Err(e) => { + // Non-blocking feature: a failed fetch surfaces in the + // status line, never as a modal interruption. + self.status_message = i18n::t_fmt("github_api_error", &[("error", &e)]); + } + } + Task::none() + } + + pub(super) fn updates_checked(&mut self, updates: Vec<(String, String)>) -> Task { + self.is_checking_updates = false; + // Record which apps have a pending update so the grid cards can + // show an update badge (not just a transient toast). + self.available_updates = updates.iter().cloned().collect(); + let notif_task = if updates.is_empty() { + self.status_message = i18n::t_fmt( + "apps_found", + &[("count", &self.applications.len().to_string())], + ); + Task::none() + } else { + let names: Vec<&str> = updates.iter().map(|(n, _)| n.as_str()).collect(); + let msg = i18n::t_fmt( + "updates_available", + &[ + ("count", &updates.len().to_string()), + ("names", &names.join(", ")), + ], + ); + self.push_notification(msg, NotificationLevel::Info) + }; + // Also check for launcher self-update + Task::batch([ + notif_task, + Task::done(Message::CheckLauncherUpdate { manual: false }), + ]) + } + + pub(super) fn toggle_favorite(&mut self, name: String) -> Task { + if let Some(pos) = self.favorites.iter().position(|f| f == &name) { + self.favorites.remove(pos); + } else { + self.favorites.push(name); + } + if let Err(e) = crate::persistence::save_favorites(&self.favorites) { + tracing::warn!("Failed to save favorites: {e}"); + } + Task::none() + } +} From 6a81034bf5ce0d40bc747d1c4013b39ab6f961a6 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 25 Jul 2026 13:28:55 +0200 Subject: [PATCH 2/3] refactor: extract the App shell from main.rs into app.rs main.rs was the second most-edited file (20 commits in six months) because it doubled as the crate root and the App shell: boot, view, subscription and theme all lived there, so every feature touching startup or the root layout landed in the entry point. main.rs is now 47 lines - the crate attribute, the module list and main(). The shell moved to app.rs unchanged; boot/title/view/subscription/theme became pub(crate) since main() now calls them across a module boundary, and ui/github_panel.rs now names crate::state::GitHubState directly instead of relying on a private import that happened to sit at the crate root. --- src/app.rs | 572 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 570 +--------------------------------------- src/ui/github_panel.rs | 5 +- 3 files changed, 581 insertions(+), 566 deletions(-) create mode 100644 src/app.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..ea796b8 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,572 @@ +//! The `App` shell: boot, root view, subscriptions and theme. +//! +//! Kept out of `main.rs` so the crate root stays a list of modules and an entry +//! point. The message handling lives in [`crate::update`], the widgets in +//! [`crate::ui`]. + +use iced::font; +use iced::keyboard; +use iced::widget::{button, column, container, mouse_area, opaque, row, stack, text, Column}; +use iced::{Element, Fill, Subscription, Task, Theme}; +use std::collections::HashSet; + +use crate::message::Message; +use crate::state::{ + default_font, App, DetailTab, GitHubState, APP_FONT_BYTES, DYSLEXIA_FONT_BYTES, FA_FONT_BYTES, +}; +use crate::ui::theme::{ + accent_key_to_color, set_active_accent, set_active_theme, set_high_contrast, Palette, +}; +use crate::{download, github, i18n, oauth, scan, sections, state, ui}; + +fn load_fonts() -> Task { + let main_fonts = APP_FONT_BYTES + .iter() + .map(|data| font::load(data.to_vec()).map(Message::FontLoaded)); + let dyslexia_font = + std::iter::once(font::load(DYSLEXIA_FONT_BYTES.to_vec()).map(Message::FontLoaded)); + let fa_fonts = FA_FONT_BYTES + .iter() + .map(|data| font::load(data.to_vec()).map(Message::FontLoaded)); + Task::batch(main_fonts.chain(dyslexia_font).chain(fa_fonts)) +} + +impl App { + pub(crate) fn boot() -> (Self, Task) { + let prefs = crate::persistence::load_preferences(); + + // The filesystem application scan and its cache write are deferred off + // the boot path (dispatched as a Rescan task below) so the window + // appears immediately instead of after a recursive directory walk. + let should_scan = prefs.scan_on_startup.unwrap_or(true); + let applications: Vec = if should_scan { + Vec::new() + } else { + // The startup scan is disabled: restore the last scan from cache + // (which was written on every scan but never read back) instead of + // greeting the user with a permanently empty local-apps grid. + crate::persistence::load_scan_cache() + .unwrap_or_default() + .into_iter() + .map(|c| scan::Application { + name: c.name, + exec: c.exec, + icon: c.icon, + category: scan::AppCategory::from_name(&c.category), + origin: match c.origin.as_str() { + "Windows" => scan::AppOrigin::Windows, + "Colony" => scan::AppOrigin::Colony, + "Linux" => scan::AppOrigin::Linux, + _ => scan::AppOrigin::External, + }, + }) + .collect() + }; + let status_message = if should_scan { + i18n::t("scanning") + } else { + i18n::t_fmt("apps_found", &[("count", &applications.len().to_string())]) + }; + + let sections = sections::load_sections(); + + let font = default_font(); + + let favorites = crate::persistence::load_favorites(); + + // Determine initial section: if restore_session is on use last section, + // otherwise use default_view to pick "favorites" section if configured. + let default_view = prefs.default_view.clone().unwrap_or_else(|| "all".into()); + let restore = prefs.restore_session.unwrap_or(true); + let selected_section = if restore { + // Clamp against the LOADED sections: a categories.json override + // that shrank the list must not leave a dangling index. + prefs + .selected_section + .unwrap_or(0) + .min(sections.len().saturating_sub(1)) + } else { + match default_view.as_str() { + "favorites" => sections.iter().position(|s| s.is_favorites).unwrap_or(0), + _ => 0, + } + }; + let show_first_launch = prefs.first_launch_done != Some(true); + + // Try to restore a saved OAuth session (load the token exactly once). + let saved_token = oauth::load_saved_token(); + let github_state = match saved_token { + Some(session) => { + tracing::info!("Restored GitHub session for {:?}", session.username); + GitHubState::Connected { session } + } + None => GitHubState::Disconnected, + }; + + // The catalog is public data: show the on-disk cache instantly, then + // refresh over the network - anonymously when no token is saved (the + // unauthenticated GitHub API allows 60 req/h, plenty for one boot + // fetch). Signing in is optional, exactly as the welcome flow promises. + let colony_repo_list = crate::persistence::load_repos_cache().unwrap_or_default(); + let startup_task = { + let token = match &github_state { + GitHubState::Connected { session } => Some(session.access_token.clone()), + _ => None, + }; + Task::perform( + async move { github::fetch_colony_repos(token.as_deref()).await }, + |result| match result { + Ok(repos) => Message::GitHubReposFetched(repos), + Err(e) => Message::GitHubError(e.to_string()), + }, + ) + }; + + let mut app = Self { + applications, + search_query: String::new(), + sections, + selected_section, + status_message, + active_colony_repo: None, + font, + github_state, + show_github_menu: false, + colony_repo_list, + notifications: Vec::new(), + next_notification_id: 0, + app_icons: std::collections::HashMap::new(), + download_progress: None, + download_bytes: None, + download_speed: 0.0, + last_progress_sample: None, + download_abort: None, + downloading_repo: None, + favorites, + confirm_uninstall: None, + show_first_launch, + welcome_step: 0, + tutorial_bounds: Default::default(), + show_settings: false, + settings_category: 0, + selected_theme: prefs + .selected_theme + .clone() + .unwrap_or_else(|| "gruvbox".into()), + selected_variant: prefs + .selected_variant + .clone() + .unwrap_or_else(|| "dark".into()), + selected_accent: prefs + .selected_accent + .clone() + .unwrap_or_else(|| "blue".into()), + auto_accent: false, + // 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), + // 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). + auto_check_updates: prefs.auto_check_updates.unwrap_or(true), + // Appearance extras + font_size: prefs.font_size.clone().unwrap_or_else(|| "default".into()), + animations: prefs.animations.unwrap_or(true), + // Accessibility + high_contrast: prefs.high_contrast.unwrap_or(false), + text_size_a11y: prefs + .text_size_a11y + .clone() + .unwrap_or_else(|| "default".into()), + reduce_motion: prefs.reduce_motion.unwrap_or(false), + keyboard_nav: prefs.keyboard_nav.unwrap_or(true), + dyslexia_font: prefs.dyslexia_font.unwrap_or(false), + // Storage + scan_on_startup: prefs.scan_on_startup.unwrap_or(true), + // Async operation tracking + is_scanning: false, + is_downloading: false, + is_checking_updates: false, + // A catalog fetch (token'd or anonymous) always starts at boot. + is_fetching_repos: true, + // Settings section state persistence + settings_expanded_sections: HashSet::new(), + // Detail tabs + detail_tab: DetailTab::ReadMe, + detail_blocks: Vec::new(), + detail_md_source: None, + detail_is_placeholder: false, + // Animation state + progress_display: 0.0, + sidebar_indicator_from: selected_section as f32 * 44.0, + sidebar_indicator_target: selected_section as f32 * 44.0, + sidebar_indicator_start: None, + available_updates: std::collections::HashMap::new(), + update_queue: Vec::new(), + release_notes: std::collections::HashMap::new(), + fetching_notes: std::collections::HashSet::new(), + install_status: std::collections::HashMap::new(), + keyboard_cursor: None, + window_size: ( + prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0), + prefs.window_height.unwrap_or(700.0).clamp(480.0, 4320.0), + ), + window_save_gen: 0, + // Launcher self-update + launcher_update_available: None, + is_checking_launcher_update: false, + launcher_update_staged: None, + launcher_system_managed: download::launcher_is_system_managed(), + }; + + // Cached catalog repos may have icons already on disk: decode them now + // so the offline/pre-fetch grid is not a wall of fallback hexagons. + app.reload_app_icons(); + app.refresh_install_status(); + + set_active_theme(&app.selected_theme, &app.selected_variant); + set_high_contrast(app.high_contrast); + if !app.auto_accent { + set_active_accent(accent_key_to_color(&app.selected_accent)); + } + + // No direct launcher-update check here: with auto-check on, the boot + // catalog fetch chains GitHubReposFetched -> CheckUpdates -> + // CheckLauncherUpdate already - a second dispatch meant every boot ran + // the check twice. + let launcher_check_task = Task::none(); + + let tutorial_task = if app.show_first_launch { + ui::fetch_bounds_task() + } else { + Task::none() + }; + + // Run the initial application scan off the boot thread. + let scan_task = if should_scan { + Task::done(Message::Rescan) + } else { + Task::none() + }; + + ( + app, + Task::batch([ + load_fonts(), + startup_task, + launcher_check_task, + tutorial_task, + scan_task, + ]), + ) + } + + pub(crate) fn title(&self) -> String { + String::from("Colony Launcher") + } + + pub(crate) fn view(&self) -> Element<'_, Message> { + let sidebar = self.view_sidebar(); + + let content = if self.show_settings { + self.view_settings_page() + } else if self.show_github_menu { + self.view_github_panel() + } else { + self.view_content() + }; + + let main_layout = row![sidebar, content].spacing(0); + + let page = container(main_layout).width(Fill).height(Fill); + + // Build overlay toasts (download progress + notifications) anchored to bottom-left + let mut overlay_items: Vec> = Vec::new(); + + // Download progress bar with graphical bar and cancel button + if let Some((ref filename, progress)) = self.download_progress { + let pct = (progress * 100.0) as u32; + // Size and speed when we have byte counters, plain % otherwise. + let bar_label = match self.download_bytes { + Some((done, Some(total))) if total > 0 => { + let speed = if self.download_speed > 1.0 { + format!(" · {}/s", state::human_bytes(self.download_speed as u64)) + } else { + String::new() + }; + format!( + "\u{f019} {} — {} / {} ({}%){}", + filename, + state::human_bytes(done), + state::human_bytes(total), + pct, + speed + ) + } + Some((done, _)) => { + let speed = if self.download_speed > 1.0 { + format!(" · {}/s", state::human_bytes(self.download_speed as u64)) + } else { + String::new() + }; + format!( + "\u{f019} {} — {}{}", + filename, + state::human_bytes(done), + speed + ) + } + None => format!("\u{f019} {} — {}%", filename, pct), + }; + let cancel_btn = button( + text("\u{f00d}") + .size(self.sz(12)) + .font(self.app_font()) + .color(Palette::TEXT_DIMMER()), + ) + .on_press(Message::CancelDownload) + .padding([4, 8]) + .style(|_theme, _status| button::Style { + background: Some(iced::Color::TRANSPARENT.into()), + ..Default::default() + }); + + // Graphical progress bar (smooth interpolation when animations enabled) + let bar_width: f32 = 200.0; + let display_progress = if self.animations && !self.reduce_motion { + self.progress_display + } else { + progress + }; + let filled_width = (bar_width * display_progress).max(2.0); + let bar_filled = container(text("")) + .width(iced::Length::Fixed(filled_width)) + .height(6) + .style(|_theme| container::Style { + background: Some(Palette::ACCENT_PROGRESS().into()), + border: iced::Border::default().rounded(3), + ..Default::default() + }); + let bar_track = container(bar_filled) + .width(iced::Length::Fixed(bar_width)) + .height(6) + .style(|_theme| container::Style { + background: Some(Palette::BG_CARD().into()), + border: iced::Border::default().rounded(3), + ..Default::default() + }); + + let progress_widget = container( + column![ + row![ + text(bar_label) + .size(self.sz(13)) + .font(self.app_font()) + .color(Palette::ACCENT_PROGRESS()), + cancel_btn, + ] + .spacing(8) + .align_y(iced::Alignment::Center), + bar_track, + ] + .spacing(6), + ) + .padding([8, 16]) + .style(|_theme| container::Style { + background: Some(Palette::BG_PROGRESS().into()), + border: iced::Border::default().rounded(8), + ..Default::default() + }); + overlay_items.push(progress_widget.into()); + } + + // Notification toasts (with fade-in / fade-out animation) + for notif in &self.notifications { + let dismiss_id = notif.id; + let alpha = if self.animations && !self.reduce_motion { + notif.opacity() + } else { + 1.0 + }; + let text_color = state::with_alpha(notif.color(), alpha); + let bg_color = state::with_alpha(notif.bg_color(), alpha); + let dismiss_color = state::with_alpha(Palette::TEXT_DIMMER(), alpha); + let primary_color = state::with_alpha(Palette::TEXT_PRIMARY(), alpha); + + let toast = button( + row![ + text(¬if.message) + .size(self.sz(13)) + .font(self.app_font()) + .color(text_color), + text("\u{f00d}") + .size(self.sz(12)) + .font(self.app_font()) + .color(dismiss_color), + ] + .spacing(12) + .align_y(iced::Alignment::Center), + ) + .on_press(Message::DismissNotification(dismiss_id)) + .padding([8, 16]) + .style(move |_theme, _status| button::Style { + background: Some(bg_color.into()), + text_color: primary_color, + border: iced::Border::default().rounded(8), + ..Default::default() + }); + overlay_items.push(toast.into()); + } + + // Build the base page with overlays + let base: Element<'_, Message> = if overlay_items.is_empty() { + page.into() + } else { + let overlay = container(Column::with_children(overlay_items).spacing(6)) + .padding(iced::Padding { + top: 0.0, + right: 0.0, + bottom: 12.0, + left: 216.0, + }) + .width(Fill) + .height(Fill) + .align_y(iced::alignment::Vertical::Bottom) + .align_x(iced::alignment::Horizontal::Left); + + stack![page, overlay].width(Fill).height(Fill).into() + }; + + // Uninstall confirmation overlay + if let Some(ref repo_name) = self.confirm_uninstall { + let confirm_msg = i18n::t_fmt("confirm_uninstall", &[("name", repo_name)]); + let repo_clone = repo_name.clone(); + let dialog = container( + column![ + text(confirm_msg) + .size(self.sz(16)) + .font(self.app_font()) + .color(Palette::TEXT_PRIMARY()), + container(text("")).height(16), + row![ + button( + text(i18n::t("cancel")) + .size(self.sz(13)) + .font(self.app_font()) + ) + .on_press(Message::CancelUninstall) + .padding([10, 20]) + .style(|_theme, status| { + let bg = match status { + button::Status::Hovered => Palette::BTN_HOVER(), + _ => Palette::BTN_DEFAULT(), + }; + button::Style { + background: Some(bg.into()), + text_color: Palette::TEXT_PRIMARY(), + border: iced::Border::default().rounded(8), + ..Default::default() + } + }), + button( + text(i18n::t("confirm_delete")) + .size(self.sz(13)) + .font(self.app_font()) + ) + .on_press(Message::UninstallColonyApp(repo_clone)) + .padding([10, 20]) + .style(|_theme, status| { + let bg = match status { + button::Status::Hovered => Palette::BTN_DANGER_HOVER(), + _ => Palette::BTN_DANGER_BG(), + }; + button::Style { + background: Some(bg.into()), + text_color: Palette::ERROR_LIGHT(), + border: iced::Border::default().rounded(8), + ..Default::default() + } + }), + ] + .spacing(12) + ] + .padding(24), + ) + .style(|_theme| container::Style { + background: Some(Palette::BG_SIDEBAR().into()), + border: iced::Border::default().rounded(12), + ..Default::default() + }); + + let backdrop = container( + container(dialog) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Fill), + ) + .width(Fill) + .height(Fill) + .style(|_theme| container::Style { + background: Some( + iced::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.5, + } + .into(), + ), + ..Default::default() + }); + + // Make the overlay modal: `opaque` stops clicks from reaching the + // page underneath, and a click on the dimmed backdrop dismisses the + // dialog (standard click-outside-to-cancel). + let modal = opaque(mouse_area(backdrop).on_press(Message::CancelUninstall)); + return stack![base, modal].width(Fill).height(Fill).into(); + } + + // First-launch guided tutorial: real UI visible, spotlight zooms in on + // each zone one by one (sidebar → search → grid → GitHub → finish). + if self.show_first_launch { + // `opaque` keeps clicks on the dimmed tutorial bands from falling + // through to (and operating) the real UI underneath. + let tutorial = opaque(self.view_tutorial()); + return stack![base, tutorial].width(Fill).height(Fill).into(); + } + + base + } + + pub(crate) fn subscription(&self) -> Subscription { + let keyboard = keyboard::listen().map(Message::KeyboardEvent); + // Track live window size so it can be persisted (debounced) and the + // next boot reopens at the same dimensions. + let resizes = iced::event::listen_with(|event, _status, _id| match event { + iced::Event::Window(iced::window::Event::Resized(size)) => { + Some(Message::WindowResized(size.width, size.height)) + } + _ => None, + }); + if self.has_active_animations() { + let tick = iced::time::every(std::time::Duration::from_millis(16)) + .map(|_| Message::AnimationTick); + Subscription::batch([keyboard, resizes, tick]) + } else { + Subscription::batch([keyboard, resizes]) + } + } + + pub(crate) fn theme(&self) -> Theme { + use ui::theme::active_palette; + let bg = active_palette().bg_primary; + let luma = bg.r * 0.299 + bg.g * 0.587 + bg.b * 0.114; + if luma > 0.5 { + Theme::Light + } else { + Theme::Dark + } + } +} diff --git a/src/main.rs b/src/main.rs index 894f6d5..ab03270 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,9 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +//! Crate root: module declarations and process entry point. The `App` itself +//! (boot, view, subscription, theme) lives in `app.rs`. + +mod app; mod config; mod download; mod github; @@ -15,19 +19,7 @@ mod state; mod ui; mod update; -use iced::font; -use iced::keyboard; -use iced::widget::{button, column, container, mouse_area, opaque, row, stack, text, Column}; -use iced::{Element, Fill, Subscription, Task, Theme}; -use std::collections::HashSet; -use ui::theme::{ - accent_key_to_color, set_active_accent, set_active_theme, set_high_contrast, Palette, -}; - -use message::Message; -use state::{ - default_font, App, DetailTab, GitHubState, APP_FONT_BYTES, DYSLEXIA_FONT_BYTES, FA_FONT_BYTES, -}; +use state::{default_font, App}; pub fn main() -> iced::Result { tracing_subscriber::fmt() @@ -52,555 +44,3 @@ pub fn main() -> iced::Result { .window_size((width, height)) .run() } - -fn load_fonts() -> Task { - let main_fonts = APP_FONT_BYTES - .iter() - .map(|data| font::load(data.to_vec()).map(Message::FontLoaded)); - let dyslexia_font = - std::iter::once(font::load(DYSLEXIA_FONT_BYTES.to_vec()).map(Message::FontLoaded)); - let fa_fonts = FA_FONT_BYTES - .iter() - .map(|data| font::load(data.to_vec()).map(Message::FontLoaded)); - Task::batch(main_fonts.chain(dyslexia_font).chain(fa_fonts)) -} - -impl App { - fn boot() -> (Self, Task) { - let prefs = crate::persistence::load_preferences(); - - // The filesystem application scan and its cache write are deferred off - // the boot path (dispatched as a Rescan task below) so the window - // appears immediately instead of after a recursive directory walk. - let should_scan = prefs.scan_on_startup.unwrap_or(true); - let applications: Vec = if should_scan { - Vec::new() - } else { - // The startup scan is disabled: restore the last scan from cache - // (which was written on every scan but never read back) instead of - // greeting the user with a permanently empty local-apps grid. - crate::persistence::load_scan_cache() - .unwrap_or_default() - .into_iter() - .map(|c| scan::Application { - name: c.name, - exec: c.exec, - icon: c.icon, - category: scan::AppCategory::from_name(&c.category), - origin: match c.origin.as_str() { - "Windows" => scan::AppOrigin::Windows, - "Colony" => scan::AppOrigin::Colony, - "Linux" => scan::AppOrigin::Linux, - _ => scan::AppOrigin::External, - }, - }) - .collect() - }; - let status_message = if should_scan { - i18n::t("scanning") - } else { - i18n::t_fmt("apps_found", &[("count", &applications.len().to_string())]) - }; - - let sections = sections::load_sections(); - - let font = default_font(); - - let favorites = crate::persistence::load_favorites(); - - // Determine initial section: if restore_session is on use last section, - // otherwise use default_view to pick "favorites" section if configured. - let default_view = prefs.default_view.clone().unwrap_or_else(|| "all".into()); - let restore = prefs.restore_session.unwrap_or(true); - let selected_section = if restore { - // Clamp against the LOADED sections: a categories.json override - // that shrank the list must not leave a dangling index. - prefs - .selected_section - .unwrap_or(0) - .min(sections.len().saturating_sub(1)) - } else { - match default_view.as_str() { - "favorites" => sections.iter().position(|s| s.is_favorites).unwrap_or(0), - _ => 0, - } - }; - let show_first_launch = prefs.first_launch_done != Some(true); - - // Try to restore a saved OAuth session (load the token exactly once). - let saved_token = oauth::load_saved_token(); - let github_state = match saved_token { - Some(session) => { - tracing::info!("Restored GitHub session for {:?}", session.username); - GitHubState::Connected { session } - } - None => GitHubState::Disconnected, - }; - - // The catalog is public data: show the on-disk cache instantly, then - // refresh over the network - anonymously when no token is saved (the - // unauthenticated GitHub API allows 60 req/h, plenty for one boot - // fetch). Signing in is optional, exactly as the welcome flow promises. - let colony_repo_list = crate::persistence::load_repos_cache().unwrap_or_default(); - let startup_task = { - let token = match &github_state { - GitHubState::Connected { session } => Some(session.access_token.clone()), - _ => None, - }; - Task::perform( - async move { github::fetch_colony_repos(token.as_deref()).await }, - |result| match result { - Ok(repos) => Message::GitHubReposFetched(repos), - Err(e) => Message::GitHubError(e.to_string()), - }, - ) - }; - - let mut app = Self { - applications, - search_query: String::new(), - sections, - selected_section, - status_message, - active_colony_repo: None, - font, - github_state, - show_github_menu: false, - colony_repo_list, - notifications: Vec::new(), - next_notification_id: 0, - app_icons: std::collections::HashMap::new(), - download_progress: None, - download_bytes: None, - download_speed: 0.0, - last_progress_sample: None, - download_abort: None, - downloading_repo: None, - favorites, - confirm_uninstall: None, - show_first_launch, - welcome_step: 0, - tutorial_bounds: Default::default(), - show_settings: false, - settings_category: 0, - selected_theme: prefs - .selected_theme - .clone() - .unwrap_or_else(|| "gruvbox".into()), - selected_variant: prefs - .selected_variant - .clone() - .unwrap_or_else(|| "dark".into()), - selected_accent: prefs - .selected_accent - .clone() - .unwrap_or_else(|| "blue".into()), - auto_accent: false, - // 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), - // 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). - auto_check_updates: prefs.auto_check_updates.unwrap_or(true), - // Appearance extras - font_size: prefs.font_size.clone().unwrap_or_else(|| "default".into()), - animations: prefs.animations.unwrap_or(true), - // Accessibility - high_contrast: prefs.high_contrast.unwrap_or(false), - text_size_a11y: prefs - .text_size_a11y - .clone() - .unwrap_or_else(|| "default".into()), - reduce_motion: prefs.reduce_motion.unwrap_or(false), - keyboard_nav: prefs.keyboard_nav.unwrap_or(true), - dyslexia_font: prefs.dyslexia_font.unwrap_or(false), - // Storage - scan_on_startup: prefs.scan_on_startup.unwrap_or(true), - // Async operation tracking - is_scanning: false, - is_downloading: false, - is_checking_updates: false, - // A catalog fetch (token'd or anonymous) always starts at boot. - is_fetching_repos: true, - // Settings section state persistence - settings_expanded_sections: HashSet::new(), - // Detail tabs - detail_tab: DetailTab::ReadMe, - detail_blocks: Vec::new(), - detail_md_source: None, - detail_is_placeholder: false, - // Animation state - progress_display: 0.0, - sidebar_indicator_from: selected_section as f32 * 44.0, - sidebar_indicator_target: selected_section as f32 * 44.0, - sidebar_indicator_start: None, - available_updates: std::collections::HashMap::new(), - update_queue: Vec::new(), - release_notes: std::collections::HashMap::new(), - fetching_notes: std::collections::HashSet::new(), - install_status: std::collections::HashMap::new(), - keyboard_cursor: None, - window_size: ( - prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0), - prefs.window_height.unwrap_or(700.0).clamp(480.0, 4320.0), - ), - window_save_gen: 0, - // Launcher self-update - launcher_update_available: None, - is_checking_launcher_update: false, - launcher_update_staged: None, - launcher_system_managed: download::launcher_is_system_managed(), - }; - - // Cached catalog repos may have icons already on disk: decode them now - // so the offline/pre-fetch grid is not a wall of fallback hexagons. - app.reload_app_icons(); - app.refresh_install_status(); - - set_active_theme(&app.selected_theme, &app.selected_variant); - set_high_contrast(app.high_contrast); - if !app.auto_accent { - set_active_accent(accent_key_to_color(&app.selected_accent)); - } - - // No direct launcher-update check here: with auto-check on, the boot - // catalog fetch chains GitHubReposFetched -> CheckUpdates -> - // CheckLauncherUpdate already - a second dispatch meant every boot ran - // the check twice. - let launcher_check_task = Task::none(); - - let tutorial_task = if app.show_first_launch { - ui::fetch_bounds_task() - } else { - Task::none() - }; - - // Run the initial application scan off the boot thread. - let scan_task = if should_scan { - Task::done(Message::Rescan) - } else { - Task::none() - }; - - ( - app, - Task::batch([ - load_fonts(), - startup_task, - launcher_check_task, - tutorial_task, - scan_task, - ]), - ) - } - - fn title(&self) -> String { - String::from("Colony Launcher") - } - - fn view(&self) -> Element<'_, Message> { - let sidebar = self.view_sidebar(); - - let content = if self.show_settings { - self.view_settings_page() - } else if self.show_github_menu { - self.view_github_panel() - } else { - self.view_content() - }; - - let main_layout = row![sidebar, content].spacing(0); - - let page = container(main_layout).width(Fill).height(Fill); - - // Build overlay toasts (download progress + notifications) anchored to bottom-left - let mut overlay_items: Vec> = Vec::new(); - - // Download progress bar with graphical bar and cancel button - if let Some((ref filename, progress)) = self.download_progress { - let pct = (progress * 100.0) as u32; - // Size and speed when we have byte counters, plain % otherwise. - let bar_label = match self.download_bytes { - Some((done, Some(total))) if total > 0 => { - let speed = if self.download_speed > 1.0 { - format!(" · {}/s", state::human_bytes(self.download_speed as u64)) - } else { - String::new() - }; - format!( - "\u{f019} {} — {} / {} ({}%){}", - filename, - state::human_bytes(done), - state::human_bytes(total), - pct, - speed - ) - } - Some((done, _)) => { - let speed = if self.download_speed > 1.0 { - format!(" · {}/s", state::human_bytes(self.download_speed as u64)) - } else { - String::new() - }; - format!( - "\u{f019} {} — {}{}", - filename, - state::human_bytes(done), - speed - ) - } - None => format!("\u{f019} {} — {}%", filename, pct), - }; - let cancel_btn = button( - text("\u{f00d}") - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_DIMMER()), - ) - .on_press(Message::CancelDownload) - .padding([4, 8]) - .style(|_theme, _status| button::Style { - background: Some(iced::Color::TRANSPARENT.into()), - ..Default::default() - }); - - // Graphical progress bar (smooth interpolation when animations enabled) - let bar_width: f32 = 200.0; - let display_progress = if self.animations && !self.reduce_motion { - self.progress_display - } else { - progress - }; - let filled_width = (bar_width * display_progress).max(2.0); - let bar_filled = container(text("")) - .width(iced::Length::Fixed(filled_width)) - .height(6) - .style(|_theme| container::Style { - background: Some(Palette::ACCENT_PROGRESS().into()), - border: iced::Border::default().rounded(3), - ..Default::default() - }); - let bar_track = container(bar_filled) - .width(iced::Length::Fixed(bar_width)) - .height(6) - .style(|_theme| container::Style { - background: Some(Palette::BG_CARD().into()), - border: iced::Border::default().rounded(3), - ..Default::default() - }); - - let progress_widget = container( - column![ - row![ - text(bar_label) - .size(self.sz(13)) - .font(self.app_font()) - .color(Palette::ACCENT_PROGRESS()), - cancel_btn, - ] - .spacing(8) - .align_y(iced::Alignment::Center), - bar_track, - ] - .spacing(6), - ) - .padding([8, 16]) - .style(|_theme| container::Style { - background: Some(Palette::BG_PROGRESS().into()), - border: iced::Border::default().rounded(8), - ..Default::default() - }); - overlay_items.push(progress_widget.into()); - } - - // Notification toasts (with fade-in / fade-out animation) - for notif in &self.notifications { - let dismiss_id = notif.id; - let alpha = if self.animations && !self.reduce_motion { - notif.opacity() - } else { - 1.0 - }; - let text_color = state::with_alpha(notif.color(), alpha); - let bg_color = state::with_alpha(notif.bg_color(), alpha); - let dismiss_color = state::with_alpha(Palette::TEXT_DIMMER(), alpha); - let primary_color = state::with_alpha(Palette::TEXT_PRIMARY(), alpha); - - let toast = button( - row![ - text(¬if.message) - .size(self.sz(13)) - .font(self.app_font()) - .color(text_color), - text("\u{f00d}") - .size(self.sz(12)) - .font(self.app_font()) - .color(dismiss_color), - ] - .spacing(12) - .align_y(iced::Alignment::Center), - ) - .on_press(Message::DismissNotification(dismiss_id)) - .padding([8, 16]) - .style(move |_theme, _status| button::Style { - background: Some(bg_color.into()), - text_color: primary_color, - border: iced::Border::default().rounded(8), - ..Default::default() - }); - overlay_items.push(toast.into()); - } - - // Build the base page with overlays - let base: Element<'_, Message> = if overlay_items.is_empty() { - page.into() - } else { - let overlay = container(Column::with_children(overlay_items).spacing(6)) - .padding(iced::Padding { - top: 0.0, - right: 0.0, - bottom: 12.0, - left: 216.0, - }) - .width(Fill) - .height(Fill) - .align_y(iced::alignment::Vertical::Bottom) - .align_x(iced::alignment::Horizontal::Left); - - stack![page, overlay].width(Fill).height(Fill).into() - }; - - // Uninstall confirmation overlay - if let Some(ref repo_name) = self.confirm_uninstall { - let confirm_msg = i18n::t_fmt("confirm_uninstall", &[("name", repo_name)]); - let repo_clone = repo_name.clone(); - let dialog = container( - column![ - text(confirm_msg) - .size(self.sz(16)) - .font(self.app_font()) - .color(Palette::TEXT_PRIMARY()), - container(text("")).height(16), - row![ - button( - text(i18n::t("cancel")) - .size(self.sz(13)) - .font(self.app_font()) - ) - .on_press(Message::CancelUninstall) - .padding([10, 20]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BTN_HOVER(), - _ => Palette::BTN_DEFAULT(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border::default().rounded(8), - ..Default::default() - } - }), - button( - text(i18n::t("confirm_delete")) - .size(self.sz(13)) - .font(self.app_font()) - ) - .on_press(Message::UninstallColonyApp(repo_clone)) - .padding([10, 20]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BTN_DANGER_HOVER(), - _ => Palette::BTN_DANGER_BG(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::ERROR_LIGHT(), - border: iced::Border::default().rounded(8), - ..Default::default() - } - }), - ] - .spacing(12) - ] - .padding(24), - ) - .style(|_theme| container::Style { - background: Some(Palette::BG_SIDEBAR().into()), - border: iced::Border::default().rounded(12), - ..Default::default() - }); - - let backdrop = container( - container(dialog) - .center_x(Fill) - .center_y(Fill) - .width(Fill) - .height(Fill), - ) - .width(Fill) - .height(Fill) - .style(|_theme| container::Style { - background: Some( - iced::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5, - } - .into(), - ), - ..Default::default() - }); - - // Make the overlay modal: `opaque` stops clicks from reaching the - // page underneath, and a click on the dimmed backdrop dismisses the - // dialog (standard click-outside-to-cancel). - let modal = opaque(mouse_area(backdrop).on_press(Message::CancelUninstall)); - return stack![base, modal].width(Fill).height(Fill).into(); - } - - // First-launch guided tutorial: real UI visible, spotlight zooms in on - // each zone one by one (sidebar → search → grid → GitHub → finish). - if self.show_first_launch { - // `opaque` keeps clicks on the dimmed tutorial bands from falling - // through to (and operating) the real UI underneath. - let tutorial = opaque(self.view_tutorial()); - return stack![base, tutorial].width(Fill).height(Fill).into(); - } - - base - } - - fn subscription(&self) -> Subscription { - let keyboard = keyboard::listen().map(Message::KeyboardEvent); - // Track live window size so it can be persisted (debounced) and the - // next boot reopens at the same dimensions. - let resizes = iced::event::listen_with(|event, _status, _id| match event { - iced::Event::Window(iced::window::Event::Resized(size)) => { - Some(Message::WindowResized(size.width, size.height)) - } - _ => None, - }); - if self.has_active_animations() { - let tick = iced::time::every(std::time::Duration::from_millis(16)) - .map(|_| Message::AnimationTick); - Subscription::batch([keyboard, resizes, tick]) - } else { - Subscription::batch([keyboard, resizes]) - } - } - - fn theme(&self) -> Theme { - use ui::theme::active_palette; - let bg = active_palette().bg_primary; - let luma = bg.r * 0.299 + bg.g * 0.587 + bg.b * 0.114; - if luma > 0.5 { - Theme::Light - } else { - Theme::Dark - } - } -} diff --git a/src/ui/github_panel.rs b/src/ui/github_panel.rs index 6b09767..6e25d5a 100644 --- a/src/ui/github_panel.rs +++ b/src/ui/github_panel.rs @@ -32,7 +32,10 @@ impl App { let login_btn = button(login_btn_content) .on_press_maybe( - if matches!(self.github_state, crate::GitHubState::Connecting { .. }) { + if matches!( + self.github_state, + crate::state::GitHubState::Connecting { .. } + ) { None } else { Some(Message::GitHubLogin) From 73383cd0434e07e579333685f792e9f60a5f471e Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 25 Jul 2026 13:34:05 +0200 Subject: [PATCH 3/3] refactor(i18n,github): one file per locale, and github split by layer i18n.rs held both locale tables in one 1523-line file, so adding a single string produced a diff straddling two languages ~350 lines apart. One file per locale now: fr.rs, en.rs, and a mod.rs that keeps the Locale type and the lookup. The key-set parity test still guards the pair - verified by breaking parity on purpose and watching it fail. github.rs mixed three concerns at 1432 lines: the HTTP client and its conditional-request cache, the wire types, and the catalog and release logic built on top. Split by layer into http/types/catalog/releases, with mod.rs re-exporting everything so no call site outside the module changed - the rest of the crate still names crate::github::X whatever file X now lives in. A handful of helpers that were private to the old single file (cached_get, GITHUB_API, build_client, the GithubRepo/Content/Readme wire structs) are now pub(crate), which is the visibility the split requires and nothing wider. --- src/github.rs | 1432 ------------------------------------- src/github/catalog.rs | 328 +++++++++ src/github/http.rs | 240 +++++++ src/github/mod.rs | 486 +++++++++++++ src/github/releases.rs | 349 +++++++++ src/github/types.rs | 75 ++ src/i18n.rs | 1523 ---------------------------------------- src/i18n/en.rs | 649 +++++++++++++++++ src/i18n/fr.rs | 708 +++++++++++++++++++ src/i18n/mod.rs | 187 +++++ 10 files changed, 3022 insertions(+), 2955 deletions(-) delete mode 100644 src/github.rs create mode 100644 src/github/catalog.rs create mode 100644 src/github/http.rs create mode 100644 src/github/mod.rs create mode 100644 src/github/releases.rs create mode 100644 src/github/types.rs delete mode 100644 src/i18n.rs create mode 100644 src/i18n/en.rs create mode 100644 src/i18n/fr.rs create mode 100644 src/i18n/mod.rs diff --git a/src/github.rs b/src/github.rs deleted file mode 100644 index 8b60870..0000000 --- a/src/github.rs +++ /dev/null @@ -1,1432 +0,0 @@ -use anyhow::Result; -use base64::Engine; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Mutex; -use std::time::Duration; -use tokio::sync::Mutex as TokioMutex; - -const GITHUB_API: &str = "https://api.github.com"; -pub(crate) const GITHUB_ACCOUNT: &str = "Project-Colony"; -pub(crate) const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Owner/repo for the Colony launcher itself. -pub(crate) const LAUNCHER_OWNER: &str = "Project-Colony"; -pub(crate) const LAUNCHER_REPO: &str = "Colony"; - -/// Default HTTP timeout for all GitHub API requests. -const HTTP_TIMEOUT: Duration = Duration::from_secs(30); -/// Default connect timeout. -pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Cap on concurrent per-repo fetches during a store refresh so a large org -/// cannot fire an unbounded burst of requests at the GitHub API at once. -const MAX_CONCURRENT_REPO_FETCHES: usize = 8; - -use crate::persistence::save_repo_doc; -use crate::persistence::save_repo_icon; -use crate::persistence::{load_installed_version, load_repos_cache}; - -// --- HTTP ETag Cache --- - -struct CacheEntry { - etag: String, - body: String, -} - -static HTTP_CACHE: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Per-URL lock to prevent concurrent requests to the same endpoint. -static URL_LOCKS: std::sync::LazyLock>>>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Acquire a per-URL lock to prevent race conditions on the same endpoint. -fn url_lock(url: &str) -> std::sync::Arc> { - let mut locks = URL_LOCKS.lock().expect("URL_LOCKS mutex poisoned"); - locks - .entry(url.to_string()) - .or_insert_with(|| std::sync::Arc::new(TokioMutex::new(()))) - .clone() -} - -/// Rate-limit information from GitHub API response headers. -#[derive(Debug, Clone)] -pub struct RateLimitInfo { - pub remaining: u64, - pub limit: u64, - pub reset: u64, -} - -/// Perform a GET request with ETag caching, per-URL locking, and rate-limit awareness. -/// Returns (body_string, optional_rate_limit_info). -async fn cached_get( - client: &reqwest::Client, - url: &str, -) -> Result<(String, Option)> { - let lock = url_lock(url); - let _guard = lock.lock().await; - - let mut request = client.get(url); - - // Add If-None-Match if we have a cached ETag - if let Ok(cache) = HTTP_CACHE.lock() { - if let Some(entry) = cache.get(url) { - request = request.header("If-None-Match", &entry.etag); - } - } - - let resp = request.send().await.map_err(|e| { - if e.is_timeout() { - anyhow::anyhow!("Request timed out for {url}") - } else if e.is_connect() { - anyhow::anyhow!("Connection failed for {url}: {e}") - } else { - anyhow::anyhow!("Network error for {url}: {e}") - } - })?; - - // Parse rate-limit headers - let rate_limit = parse_rate_limit(resp.headers()); - - if let Some(ref rl) = rate_limit { - if rl.remaining < 10 { - tracing::warn!( - "GitHub API rate limit low: {}/{} remaining (resets at {})", - rl.remaining, - rl.limit, - rl.reset - ); - } - } - - match resp.status().as_u16() { - 304 => { - // Not Modified — return cached body - if let Ok(cache) = HTTP_CACHE.lock() { - if let Some(entry) = cache.get(url) { - tracing::debug!("Cache hit (304) for {}", url); - return Ok((entry.body.clone(), rate_limit)); - } - } - anyhow::bail!("304 received but no cached body for {url}"); - } - 200 => { - let etag = resp - .headers() - .get("etag") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let body = resp.text().await?; - - // Store in cache if we got an ETag - if let Some(etag) = etag { - if let Ok(mut cache) = HTTP_CACHE.lock() { - cache.insert( - url.to_string(), - CacheEntry { - etag, - body: body.clone(), - }, - ); - } - } - Ok((body, rate_limit)) - } - status => { - // Only treat an exhausted quota as a rate-limit error on the - // statuses GitHub actually uses for it (403 / 429). A 200 or 304 - // that merely happened to consume the last quota unit is handled - // above and its body is preserved. - if matches!(status, 403 | 429) { - if let Some(ref rl) = rate_limit { - if rl.remaining == 0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - if rl.reset > now { - let wait = rl.reset - now; - anyhow::bail!( - "{}", - crate::i18n::t_fmt( - "github_rate_limit", - &[("wait", &wait.to_string())] - ) - ); - } - } - } - } - let body = resp.text().await.unwrap_or_default(); - Err(anyhow::Error::new(HttpStatus(status)) - .context(format!("GitHub API error {status}: {body}"))) - } - } -} - -/// Typed HTTP failure status carried inside the `anyhow` chain, so callers can -/// classify not-found precisely with [`is_not_found`] instead of substring- -/// matching "404" against the message - which misfired on any response body -/// that merely CONTAINED "404" and silently dropped legitimate repos from the -/// catalog (then clobbered the offline cache without them). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HttpStatus(pub u16); - -impl std::fmt::Display for HttpStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "HTTP {}", self.0) - } -} - -impl std::error::Error for HttpStatus {} - -/// True when the error chain carries an HTTP 404 from the GitHub API. -pub fn is_not_found(e: &anyhow::Error) -> bool { - e.downcast_ref::().is_some_and(|s| s.0 == 404) -} - -fn parse_rate_limit(headers: &reqwest::header::HeaderMap) -> Option { - let remaining = headers - .get("x-ratelimit-remaining")? - .to_str() - .ok()? - .parse() - .ok()?; - let limit = headers - .get("x-ratelimit-limit") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()) - .unwrap_or(60); - let reset = headers - .get("x-ratelimit-reset") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - Some(RateLimitInfo { - remaining, - limit, - reset, - }) -} - -/// Per-platform release info from colony.json. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReleaseFileEntry { - pub tag: String, - /// Exact asset filename to download. Required unless `file_pattern` is set. - pub file: Option, - /// Substring pattern to match against release asset names (case-insensitive). - /// Colony fetches the release assets list and picks the one matching this pattern. - /// Mutually exclusive with `file` — use one or the other. - pub file_pattern: Option, - /// Optional binary name inside an archive. When present, the downloaded file - /// is treated as an archive (.zip / .tar.gz) and Colony extracts this binary. - /// When absent, the downloaded file is the final binary (legacy behaviour). - pub binary: Option, - /// Optional SHA256 checksum for integrity verification. - pub sha256: Option, -} - -/// Parsed manifest from colony.json inside a repo. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ColonyManifest { - pub name: String, - pub category: String, - #[serde(default)] - pub platforms: Vec, - #[serde(default)] - pub release_files: HashMap, - /// Optional path (relative to the repo root) to a square PNG app icon shown - /// in the Colony grid. When absent, Colony probes a conventional `icon.png` - /// at the repo root, then falls back to the tinted category hexagon. - #[serde(default)] - pub icon: Option, - /// When true, every release asset MUST ship a valid `.sig` - /// (ed25519, Project-Colony org key): a missing signature aborts the - /// install instead of falling back to the legacy unsigned path. - #[serde(default)] - pub signed: bool, -} - -/// Metadata for a Colony-compatible repository (has colony.json). -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ColonyRepo { - pub name: String, - pub description: String, - pub language: String, - pub html_url: String, - pub manifest: ColonyManifest, -} - -#[derive(Debug, Deserialize)] -struct GithubRepo { - name: String, - description: Option, - language: Option, - html_url: String, -} - -#[derive(Debug, Deserialize)] -struct GithubContent { - name: String, - content: Option, -} - -#[derive(Debug, Deserialize)] -struct GithubReadme { - content: Option, -} - -/// Fetch all Colony repos (those containing colony.json) from Project-Colony. -/// Fetches manifests and READMEs concurrently for all repos. -pub async fn fetch_colony_repos(token: Option<&str>) -> Result> { - let client = build_client(token)?; - - // 1. List all repos for Project-Colony (with pagination) - let repos = list_repos_paginated(&client).await?; - - // Track whether any repo failed for a transient reason (timeout, 5xx, - // rate-limit) as opposed to genuinely lacking a colony.json (404). A - // transient failure must not silently drop an installed app from the store - // nor clobber the offline cache with a shortened list. - let transient_failures: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); - - // 2. Fetch manifest + README concurrently for all repos - let futures: Vec<_> = repos - .iter() - .map(|repo| { - let client = client.clone(); - let name = repo.name.clone(); - let fallback_desc = repo.description.clone().unwrap_or_default(); - let html_url = repo.html_url.clone(); - let language = repo.language.clone().unwrap_or_else(|| "Unknown".into()); - let transient_failures = transient_failures.clone(); - - async move { - let mut manifest = match fetch_colony_manifest(&client, &name).await { - Ok(Some(m)) => m, - Ok(None) => return None, - Err(e) => { - // fetch_colony_manifest already maps 404 to Ok(None), - // so an Err here is transient, not "no manifest". - tracing::warn!("Error checking colony.json for {}: {e}", name); - if let Ok(mut failed) = transient_failures.lock() { - failed.insert(name.clone()); - } - return None; - } - }; - - // Auto-detect platforms from release assets if manifest is minimal - if manifest.release_files.is_empty() { - if let Err(e) = auto_detect_release(&client, &name, &mut manifest).await { - tracing::debug!("Auto-detect skipped for {name}: {e}"); - } - } - - // Fetch README, LICENSE, CHANGELOG, icon concurrently - let readme_fut = fetch_readme(&client, &name); - let license_fut = fetch_license_with_fallback(&client, &name); - let changelog_fut = fetch_repo_file_candidates( - &client, - &name, - &["CHANGELOG.md", "CHANGES.md", "CHANGELOG"], - ); - let icon_fut = fetch_icon(&client, &name, manifest.icon.as_deref()); - - let (readme_result, license_result, changelog_result, icon_result) = - futures::future::join4(readme_fut, license_fut, changelog_fut, icon_fut).await; - - let description = readme_result.unwrap_or(fallback_desc); - - // Save docs + icon to disk cache - save_repo_doc(&name, "README.md", &description); - if let Ok(Some(ref content)) = license_result { - save_repo_doc(&name, "LICENSE.md", content); - } - if let Ok(Some(ref content)) = changelog_result { - save_repo_doc(&name, "CHANGELOG.md", content); - } - if let Ok(Some(ref bytes)) = icon_result { - save_repo_icon(&name, bytes); - } - - Some(ColonyRepo { - name, - description, - language, - html_url, - manifest, - }) - } - }) - .collect(); - - // Cap concurrency (order-preserving) instead of firing every repo's fetch - // chain at once. - use futures::StreamExt; - let results: Vec> = futures::stream::iter(futures) - .buffered(MAX_CONCURRENT_REPO_FETCHES) - .collect() - .await; - let mut repos_out: Vec = results.into_iter().flatten().collect(); - - // On a partially failed refresh, merge back ONLY the specific repos whose - // fetch failed transiently. The old any-failure flag resurrected EVERY - // cached repo, including ones genuinely deleted from the catalog. - let failed = transient_failures - .lock() - .map(|s| s.clone()) - .unwrap_or_default(); - if !failed.is_empty() { - if let Some(cached) = load_repos_cache() { - for repo in cached { - if failed.contains(&repo.name) && !repos_out.iter().any(|r| r.name == repo.name) { - repos_out.push(repo); - } - } - } - } - - Ok(repos_out) -} - -/// Build an HTTP client for API calls (public wrapper). -pub fn build_update_client(token: Option<&str>) -> Result { - build_client(token) -} - -fn build_client(token: Option<&str>) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::ACCEPT, - "application/vnd.github.v3+json".parse()?, - ); - headers.insert( - reqwest::header::USER_AGENT, - format!("Colony-Launcher/{APP_VERSION}").parse()?, - ); - if let Some(token) = token { - headers.insert( - reqwest::header::AUTHORIZATION, - format!("Bearer {token}").parse()?, - ); - } - Ok(reqwest::Client::builder() - .default_headers(headers) - .timeout(HTTP_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .build()?) -} - -/// List repos with pagination support (follows GitHub Link header). -async fn list_repos_paginated(client: &reqwest::Client) -> Result> { - let mut all_repos = Vec::new(); - let mut page = 1u32; - - loop { - let url = format!( - "{GITHUB_API}/orgs/{GITHUB_ACCOUNT}/repos?per_page=100&sort=updated&page={page}" - ); - let (body, _) = cached_get(client, &url).await?; - let repos: Vec = serde_json::from_str(&body)?; - - if repos.is_empty() { - break; - } - - let count = repos.len(); - all_repos.extend(repos); - - // If we got fewer than 100, we've reached the last page - if count < 100 { - break; - } - - page += 1; - - // Safety limit to prevent infinite loops - if page > 50 { - tracing::warn!("Pagination safety limit reached at page {page}"); - break; - } - } - - Ok(all_repos) -} - -/// Fetch and parse colony.json from a repo. Returns None if the file doesn't exist. -async fn fetch_colony_manifest( - client: &reqwest::Client, - repo_name: &str, -) -> Result> { - let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/colony.json"); - match cached_get(client, &url).await { - Ok((body, _)) => { - let content: GithubContent = serde_json::from_str(&body).map_err(|e| { - anyhow::anyhow!("Failed to parse GitHub content response for {repo_name}: {e}") - })?; - if content.name != "colony.json" { - return Ok(None); - } - // Decode Base64 content - let raw = content.content.unwrap_or_default(); - let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); - let bytes = base64::engine::general_purpose::STANDARD - .decode(&cleaned) - .map_err(|e| { - anyhow::anyhow!("Failed to decode base64 for {repo_name}/colony.json: {e}") - })?; - let manifest: ColonyManifest = serde_json::from_slice(&bytes) - .map_err(|e| anyhow::anyhow!("Invalid colony.json in {repo_name}: {e}"))?; - Ok(Some(manifest)) - } - Err(e) => { - if is_not_found(&e) { - Ok(None) - } else { - Err(e) - } - } - } -} - -/// Fetch the README content from a repo, returning the first ~500 chars as plain text. -async fn fetch_readme(client: &reqwest::Client, repo_name: &str) -> Result { - let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/readme"); - let (body, _) = cached_get(client, &url).await?; - let readme: GithubReadme = serde_json::from_str(&body)?; - let raw = readme.content.unwrap_or_default(); - let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); - let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; - let text = String::from_utf8_lossy(&bytes).trim().to_string(); - - if text.is_empty() { - anyhow::bail!("README is empty"); - } - - Ok(text) -} - -/// Fetch the repo's LICENSE via GitHub's dedicated license endpoint — one -/// request that returns the detected license file, instead of probing several -/// candidate filenames (which cost one request each, mostly 404s). -async fn fetch_license(client: &reqwest::Client, repo_name: &str) -> Result> { - let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/license"); - match cached_get(client, &url).await { - Ok((body, _)) => { - let content: GithubContent = serde_json::from_str(&body)?; - let raw = content.content.unwrap_or_default(); - let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); - let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; - Ok(Some(String::from_utf8_lossy(&bytes).to_string())) - } - Err(e) => { - if is_not_found(&e) { - Ok(None) - } else { - Err(e) - } - } - } -} - -/// LICENSE for display: try the fast dedicated endpoint first, then fall back -/// to probing common filenames only if GitHub could not auto-classify one — so -/// a repo carrying a nonstandard/undetectable license file is still surfaced -/// while the common case stays a single request. -async fn fetch_license_with_fallback( - client: &reqwest::Client, - repo_name: &str, -) -> Result> { - match fetch_license(client, repo_name).await { - Ok(Some(content)) => Ok(Some(content)), - Ok(None) => { - fetch_repo_file_candidates( - client, - repo_name, - &["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"], - ) - .await - } - Err(e) => Err(e), - } -} - -/// Fetch a file from a repo, trying multiple candidate paths. -/// Returns the decoded UTF-8 content of the first file found, or None if all return 404. -async fn fetch_repo_file_candidates( - client: &reqwest::Client, - repo_name: &str, - candidates: &[&str], -) -> Result> { - for path in candidates { - let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/{path}"); - match cached_get(client, &url).await { - Ok((body, _)) => { - let content: GithubContent = serde_json::from_str(&body)?; - let raw = content.content.unwrap_or_default(); - let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); - let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; - let text = String::from_utf8_lossy(&bytes).to_string(); - return Ok(Some(text)); - } - Err(e) => { - if is_not_found(&e) { - continue; - } - return Err(e); - } - } - } - Ok(None) -} - -/// Fetch the app icon bytes from a repo: the manifest-declared `icon` path -/// first, then a conventional `icon.png` at the repo root. Returns the raw PNG -/// bytes of the first that exists, or None if neither is present (404). -async fn fetch_icon( - client: &reqwest::Client, - repo_name: &str, - declared: Option<&str>, -) -> Result>> { - let mut candidates: Vec<&str> = Vec::new(); - if let Some(p) = declared { - candidates.push(p); - } - if !candidates.contains(&"icon.png") { - candidates.push("icon.png"); - } - for path in candidates { - let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/{path}"); - match cached_get(client, &url).await { - Ok((body, _)) => { - let content: GithubContent = serde_json::from_str(&body)?; - let raw = content.content.unwrap_or_default(); - let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); - let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; - if !bytes.is_empty() { - return Ok(Some(bytes)); - } - } - Err(e) => { - if is_not_found(&e) { - continue; - } - return Err(e); - } - } - } - Ok(None) -} - -/// Return the current platform key ("windows", "linux", "macos", or "macos-x86"). -/// On macOS, distinguishes Apple Silicon (aarch64 → "macos") from Intel (x86_64 → "macos-x86"). -pub fn current_platform_key() -> &'static str { - if cfg!(target_os = "windows") { - "windows" - } else if cfg!(target_os = "macos") { - if cfg!(target_arch = "aarch64") { - "macos" - } else { - "macos-x86" - } - } else { - "linux" - } -} - -/// Fetch the latest release tag for an arbitrary owner/repo combination. -pub async fn fetch_latest_release_tag_for( - client: &reqwest::Client, - owner: &str, - repo: &str, -) -> Result { - let url = format!("{GITHUB_API}/repos/{owner}/{repo}/releases/latest"); - let (body, _) = cached_get(client, &url).await?; - - #[derive(Deserialize)] - struct Release { - tag_name: String, - } - - let release: Release = serde_json::from_str(&body)?; - Ok(release.tag_name) -} - -/// Fetch the latest release tag for a Colony app repo. -pub async fn fetch_latest_release_tag(client: &reqwest::Client, repo_name: &str) -> Result { - fetch_latest_release_tag_for(client, GITHUB_ACCOUNT, repo_name).await -} - -/// Resolved release information from GitHub API. -#[derive(Debug)] -pub struct ResolvedRelease { - pub tag: String, - pub asset_names: Vec, - /// The release notes (GitHub release body, markdown). Previously never - /// fetched anywhere: the detail Changelog tab only showed the repo's - /// CHANGELOG.md file frozen at catalog-fetch time. - pub body: Option, -} - -/// Fetch release info (tag + asset list) for a repo. -/// If tag is "latest", resolves to the actual latest release. -/// Otherwise fetches the specific tagged release. -pub async fn fetch_release_info( - client: &reqwest::Client, - repo_name: &str, - tag: &str, -) -> Result { - let url = if tag.eq_ignore_ascii_case("latest") { - format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/releases/latest") - } else { - format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/releases/tags/{tag}") - }; - let (body, _) = cached_get(client, &url).await?; - - #[derive(Deserialize)] - struct Asset { - name: String, - } - #[derive(Deserialize)] - struct Release { - tag_name: String, - assets: Vec, - body: Option, - } - - let release: Release = serde_json::from_str(&body)?; - Ok(ResolvedRelease { - tag: release.tag_name, - asset_names: release.assets.into_iter().map(|a| a.name).collect(), - body: release.body, - }) -} - -/// Find an asset whose name contains the given pattern (case-insensitive). -/// Returns an error if zero or multiple assets match. -/// Metadata companions published alongside release binaries (signatures, -/// checksums, updater manifests). Never installable, so they are excluded from -/// pattern matching - otherwise `app-linux.sig` would make the pattern -/// "linux" ambiguous the day a repo starts signing its releases (Colony's own -/// releases already ship `.sig` siblings). -const NON_INSTALLABLE_SUFFIXES: &[&str] = &[ - ".sig", - ".asc", - ".sha256", - ".sha256sum", - ".txt", - ".yml", - ".yaml", - ".json", -]; - -/// Anchored glob match: `*` matches any run of characters, everything else is -/// literal (case-insensitive - both inputs must already be lowercase). The -/// pattern must cover the WHOLE name, unlike the legacy substring mode. -fn glob_matches(pattern: &str, name: &str) -> bool { - fn inner(p: &[u8], n: &[u8]) -> bool { - match (p.first(), n.first()) { - (None, None) => true, - (Some(b'*'), _) => { - // Star: match zero characters, or consume one and retry. - inner(&p[1..], n) || (!n.is_empty() && inner(p, &n[1..])) - } - (Some(pc), Some(nc)) if pc == nc => inner(&p[1..], &n[1..]), - _ => false, - } - } - inner(pattern.as_bytes(), name.as_bytes()) -} - -/// Resolve a `filePattern` against release asset names. -/// -/// Three matching modes, so real-world release layouts (e.g. electron-builder -/// publishing `App-1.2.3.AppImage` AND `App-1.2.3-arm64.AppImage`) stay -/// expressible: -/// - exact name match always wins (never ambiguous); -/// - a pattern containing `*` is an ANCHORED glob; comma-separated terms are -/// supported, where `!term` excludes: `"*.AppImage, !*-arm64*"`; -/// - otherwise the legacy case-insensitive substring match applies. -/// -/// Signature/checksum siblings (`.sig`, `.sha256`, ...) are never candidates. -pub fn find_asset_by_pattern(assets: &[String], pattern: &str) -> Result { - let pattern_lower = pattern.to_lowercase(); - if let Some(exact) = assets.iter().find(|n| n.to_lowercase() == pattern_lower) { - return Ok(exact.clone()); - } - - let terms: Vec<&str> = pattern_lower - .split(',') - .map(str::trim) - .filter(|t| !t.is_empty()) - .collect(); - let has_glob = terms.iter().any(|t| t.contains('*') || t.starts_with('!')); - let positives: Vec<&str> = terms - .iter() - .filter(|t| !t.starts_with('!')) - .copied() - .collect(); - let negatives: Vec<&str> = terms.iter().filter_map(|t| t.strip_prefix('!')).collect(); - - let matches: Vec<&String> = assets - .iter() - .filter(|name| { - let lower = name.to_lowercase(); - if NON_INSTALLABLE_SUFFIXES.iter().any(|s| lower.ends_with(s)) { - return false; - } - if has_glob { - positives.iter().any(|p| glob_matches(p, &lower)) - && !negatives.iter().any(|n| glob_matches(n, &lower)) - } else { - lower.contains(&pattern_lower) - } - }) - .collect(); - match matches.len() { - 0 => anyhow::bail!("No release asset matching pattern '{pattern}'"), - 1 => Ok(matches[0].clone()), - n => { - anyhow::bail!( - "Ambiguous pattern '{pattern}': {n} assets match ({}). Use a more specific pattern.", - matches.iter().map(|s| s.as_str()).collect::>().join(", ") - ) - } - } -} - -/// Platform detection entries: (expected asset suffix, platform key). -/// Order matters: "macos-x86" must come before "macos" to avoid false matches. -const PLATFORM_CONVENTIONS: &[(&str, &str)] = &[ - ("-linux", "linux"), - ("-windows.exe", "windows"), - ("-macos-x86", "macos-x86"), - ("-macos", "macos"), -]; - -/// Detect which platforms are available from release asset names using the -/// Colony naming convention: `{name}-linux`, `{name}-windows.exe`, -/// `{name}-macos`, `{name}-macos-x86`. -pub fn detect_platforms_from_assets(repo_name: &str, asset_names: &[String]) -> Vec { - let repo_lower = repo_name.to_lowercase(); - let mut platforms = Vec::new(); - - for &(suffix, platform) in PLATFORM_CONVENTIONS { - let expected = format!("{repo_lower}{suffix}"); - if asset_names.iter().any(|a| a.to_lowercase() == expected) { - platforms.push(platform.to_string()); - } - } - - platforms -} - -/// Build a `release_files` HashMap from detected assets, using the "latest" tag -/// and convention-based filenames. Uses the exact asset name found in the release. -pub fn build_release_files_from_assets( - repo_name: &str, - asset_names: &[String], -) -> HashMap { - let repo_lower = repo_name.to_lowercase(); - let mut map = HashMap::new(); - - for &(suffix, platform) in PLATFORM_CONVENTIONS { - let expected = format!("{repo_lower}{suffix}"); - if let Some(actual_name) = asset_names.iter().find(|a| a.to_lowercase() == expected) { - map.insert( - platform.to_string(), - ReleaseFileEntry { - tag: "latest".to_string(), - file: Some(actual_name.clone()), - file_pattern: None, - binary: None, - sha256: None, - }, - ); - } - } - - map -} - -/// For a repo with empty platforms/release_files (minimal colony.json), fetch the -/// latest release and auto-detect available platforms from its assets. -pub async fn auto_detect_release( - client: &reqwest::Client, - repo_name: &str, - manifest: &mut ColonyManifest, -) -> Result<()> { - let release = fetch_release_info(client, repo_name, "latest").await?; - let platforms = detect_platforms_from_assets(repo_name, &release.asset_names); - let release_files = build_release_files_from_assets(repo_name, &release.asset_names); - - if !platforms.is_empty() { - tracing::info!("Auto-detected platforms for {repo_name}: {:?}", platforms); - manifest.platforms = platforms; - manifest.release_files = release_files; - } - - Ok(()) -} - -/// Parse a version tag (e.g. "v1.2.3" or "1.2.3") into a semver::Version. -pub fn parse_version_tag(tag: &str) -> Option { - let cleaned = tag.strip_prefix('v').unwrap_or(tag); - semver::Version::parse(cleaned).ok() -} - -/// Check if an update is available for a repo whose manifest pins `pinned_tag` -/// for the current platform. Returns Some(target_tag) if the installed version -/// differs from what the manifest would install, None otherwise. -/// -/// `pinned_tag` is compared directly unless it is "latest", in which case the -/// repo's latest release is resolved. This avoids a perpetual "update -/// available" loop for apps pinned to a specific (older) release, and falls -/// back to string comparison when tags are not semver so detection is not -/// silently disabled. -pub async fn check_update_available( - client: &reqwest::Client, - repo_name: &str, - pinned_tag: &str, -) -> Option { - let installed = load_installed_version(repo_name)?; - - let target = if pinned_tag.eq_ignore_ascii_case("latest") { - fetch_latest_release_tag(client, repo_name).await.ok()? - } else { - pinned_tag.to_string() - }; - - // Case-insensitive: "Nightly" vs "nightly" must not read as an update - // (with non-semver tags the string fallback below would flag it forever). - if target.eq_ignore_ascii_case(&installed) { - return None; - } - - match (parse_version_tag(&installed), parse_version_tag(&target)) { - (Some(installed_ver), Some(target_ver)) => { - if target_ver > installed_ver { - Some(target) - } else { - None - } - } - _ => { - tracing::warn!( - "Non-semver tags for {repo_name} (installed '{installed}', target '{target}'); using string comparison" - ); - Some(target) - } - } -} - -// --- Launcher self-update --- - -/// Expected release asset name for the Colony launcher binary on the current platform. -pub fn launcher_asset_name() -> String { - let platform = current_platform_key(); - let ext = if cfg!(target_os = "windows") { - ".exe" - } else { - "" - }; - format!("colony-{platform}{ext}") -} - -/// Check if a newer version of the Colony launcher itself is available. -/// Returns Some((latest_tag, asset_filename)) if an update exists, None otherwise. -/// `Ok(None)` means the check RAN and Colony is current; failures propagate so -/// the UI never reports "up to date" when the check could not run at all -/// (offline, rate limited, or an unparseable release tag). -pub async fn check_launcher_update(client: &reqwest::Client) -> Result> { - let latest_tag = fetch_latest_release_tag_for(client, LAUNCHER_OWNER, LAUNCHER_REPO).await?; - - let current = parse_version_tag(APP_VERSION) - .ok_or_else(|| anyhow::anyhow!("unparseable app version '{APP_VERSION}'"))?; - let latest = parse_version_tag(&latest_tag) - .ok_or_else(|| anyhow::anyhow!("unrecognized release tag '{latest_tag}'"))?; - - Ok((latest > current).then(|| (latest_tag, launcher_asset_name()))) -} - -// --- Offline cache --- - -// --- Favorites persistence --- - -// --- User preferences persistence --- - -// --- Application scan cache --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_colony_manifest() { - let json = r#"{ - "name": "TestApp", - "category": "Utilities", - "platforms": ["windows", "linux"], - "releaseFiles": { - "windows": { "tag": "Windows", "file": "TestApp.exe" }, - "linux": { "tag": "Linux", "file": "TestApp" } - } - }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert_eq!(manifest.name, "TestApp"); - assert_eq!(manifest.category, "Utilities"); - assert_eq!(manifest.platforms, vec!["windows", "linux"]); - assert_eq!(manifest.release_files.len(), 2); - assert_eq!(manifest.release_files["windows"].tag, "Windows"); - assert_eq!( - manifest.release_files["windows"].file.as_deref(), - Some("TestApp.exe") - ); - assert_eq!(manifest.release_files["linux"].tag, "Linux"); - assert_eq!( - manifest.release_files["linux"].file.as_deref(), - Some("TestApp") - ); - } - - #[test] - fn parse_colony_manifest_with_sha256() { - let json = r#"{ - "name": "TestApp", - "category": "Utilities", - "platforms": ["linux"], - "releaseFiles": { - "linux": { "tag": "v1.0", "file": "app", "sha256": "abc123def456" } - } - }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert_eq!( - manifest.release_files["linux"].sha256.as_deref(), - Some("abc123def456") - ); - } - - #[test] - fn parse_colony_manifest_with_binary_and_latest() { - let json = r#"{ - "name": "Lilypad", - "category": "Security", - "platforms": ["windows", "linux", "macos"], - "releaseFiles": { - "windows": { - "tag": "latest", - "file": "lilypad-x86_64-pc-windows-msvc.zip", - "binary": "lilypad-cli.exe", - "sha256": "abc123" - }, - "linux": { - "tag": "latest", - "file": "lilypad-x86_64-unknown-linux-gnu.tar.gz", - "binary": "lilypad-cli" - }, - "macos": { - "tag": "v0.1.0", - "file": "lilypad-aarch64-apple-darwin.tar.gz", - "binary": "lilypad-cli" - } - } - }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert_eq!(manifest.name, "Lilypad"); - assert_eq!(manifest.platforms.len(), 3); - // Windows: archive + binary + latest - let win = &manifest.release_files["windows"]; - assert_eq!(win.tag, "latest"); - assert_eq!( - win.file.as_deref(), - Some("lilypad-x86_64-pc-windows-msvc.zip") - ); - assert_eq!(win.binary.as_deref(), Some("lilypad-cli.exe")); - assert_eq!(win.sha256.as_deref(), Some("abc123")); - // Linux: archive + binary + latest, no sha256 - let linux = &manifest.release_files["linux"]; - assert_eq!(linux.tag, "latest"); - assert_eq!(linux.binary.as_deref(), Some("lilypad-cli")); - assert!(linux.sha256.is_none()); - // macOS: pinned tag - let macos = &manifest.release_files["macos"]; - assert_eq!(macos.tag, "v0.1.0"); - assert_eq!(macos.binary.as_deref(), Some("lilypad-cli")); - } - - #[test] - fn parse_colony_manifest_binary_absent() { - // Legacy format without binary field still works - let json = r#"{ - "name": "TestApp", - "category": "Utilities", - "platforms": ["windows"], - "releaseFiles": { - "windows": { "tag": "Windows", "file": "TestApp.exe" } - } - }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert!(manifest.release_files["windows"].binary.is_none()); - } - - #[test] - fn parse_colony_manifest_with_file_pattern() { - let json = r#"{ - "name": "Lilypad", - "category": "Security", - "platforms": ["windows", "linux", "macos"], - "releaseFiles": { - "windows": { - "tag": "latest", - "filePattern": "windows", - "binary": "lilypad-cli.exe" - }, - "linux": { - "tag": "latest", - "filePattern": "linux", - "binary": "lilypad-cli" - }, - "macos": { - "tag": "latest", - "filePattern": "darwin", - "binary": "lilypad-cli" - } - } - }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert_eq!(manifest.name, "Lilypad"); - // Windows: filePattern instead of file - let win = &manifest.release_files["windows"]; - assert_eq!(win.tag, "latest"); - assert!(win.file.is_none()); - assert_eq!(win.file_pattern.as_deref(), Some("windows")); - assert_eq!(win.binary.as_deref(), Some("lilypad-cli.exe")); - // Linux - let linux = &manifest.release_files["linux"]; - assert_eq!(linux.file_pattern.as_deref(), Some("linux")); - // macOS - let macos = &manifest.release_files["macos"]; - assert_eq!(macos.file_pattern.as_deref(), Some("darwin")); - } - - #[test] - fn find_asset_by_pattern_single_match() { - let assets = vec![ - "lilypad-x86_64-pc-windows-msvc.zip".to_string(), - "lilypad-x86_64-unknown-linux-gnu.tar.gz".to_string(), - "lilypad-aarch64-apple-darwin.tar.gz".to_string(), - ]; - let result = find_asset_by_pattern(&assets, "windows"); - assert_eq!(result.unwrap(), "lilypad-x86_64-pc-windows-msvc.zip"); - - let result = find_asset_by_pattern(&assets, "linux"); - assert_eq!(result.unwrap(), "lilypad-x86_64-unknown-linux-gnu.tar.gz"); - - let result = find_asset_by_pattern(&assets, "darwin"); - assert_eq!(result.unwrap(), "lilypad-aarch64-apple-darwin.tar.gz"); - } - - #[test] - fn find_asset_by_pattern_case_insensitive() { - let assets = vec!["MyApp-Windows-x64.zip".to_string()]; - let result = find_asset_by_pattern(&assets, "windows"); - assert!(result.is_ok()); - } - - #[test] - fn find_asset_by_pattern_no_match() { - let assets = vec!["app-linux.tar.gz".to_string()]; - let result = find_asset_by_pattern(&assets, "windows"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("No release asset")); - } - - #[test] - fn find_asset_by_pattern_ambiguous() { - let assets = vec![ - "app-linux-x64.tar.gz".to_string(), - "app-linux-arm64.tar.gz".to_string(), - ]; - let result = find_asset_by_pattern(&assets, "linux"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Ambiguous")); - } - - #[test] - fn is_not_found_matches_typed_status_not_message_text() { - // A 404 is recognized through the typed status... - let e404 = anyhow::Error::new(HttpStatus(404)).context("GitHub API error 404: Not Found"); - assert!(is_not_found(&e404)); - // ...a different status is not, even if its BODY contains "404" (the - // old substring check misclassified this and dropped live repos). - let e500 = anyhow::Error::new(HttpStatus(500)) - .context("GitHub API error 500: upstream said 404 somewhere"); - assert!(!is_not_found(&e500)); - // ...and a plain network error without a status is not a 404 either. - assert!(!is_not_found(&anyhow::anyhow!("Network error: dns 404ish"))); - } - - #[test] - fn find_asset_by_pattern_exact_name_beats_substring_overlap() { - // "app-macos" is a substring of "app-macos-x86": an exact name match - // must win instead of erroring as ambiguous, so Apple Silicon - // manifests can pin the shorter asset name. - let assets = vec!["app-macos".to_string(), "app-macos-x86".to_string()]; - assert_eq!( - find_asset_by_pattern(&assets, "app-macos").unwrap(), - "app-macos" - ); - assert_eq!( - find_asset_by_pattern(&assets, "app-macos-x86").unwrap(), - "app-macos-x86" - ); - } - - #[test] - fn spec_conformant_manifest_parses_field_for_field() { - // Locks docs/colony-spec.md <-> code parity: this sample uses every - // documented manifest field with the spec's exact camelCase names. - // If a rename or removal breaks the spec, this test fails first. - let json = r#"{ - "name": "Lilypad", - "category": "Security", - "platforms": ["windows", "linux", "macos", "macos-x86"], - "icon": "assets/icons/icon.png", - "signed": true, - "releaseFiles": { - "linux": { - "tag": "latest", - "filePattern": "lilypad-*-linux.tar.gz, !*-arm64*", - "binary": "lilypad-cli", - "sha256": "abc123" - }, - "windows": { - "tag": "v1.0.0", - "file": "lilypad-windows.zip", - "binary": "lilypad-cli.exe" - } - } - }"#; - let m: ColonyManifest = serde_json::from_str(json).expect("spec sample must parse"); - assert_eq!(m.name, "Lilypad"); - assert_eq!(m.category, "Security"); - assert_eq!(m.platforms.len(), 4); - assert_eq!(m.icon.as_deref(), Some("assets/icons/icon.png")); - assert!(m.signed); - let linux = &m.release_files["linux"]; - assert_eq!(linux.tag, "latest"); - assert_eq!( - linux.file_pattern.as_deref(), - Some("lilypad-*-linux.tar.gz, !*-arm64*") - ); - assert_eq!(linux.binary.as_deref(), Some("lilypad-cli")); - assert_eq!(linux.sha256.as_deref(), Some("abc123")); - let windows = &m.release_files["windows"]; - assert_eq!(windows.tag, "v1.0.0"); - assert_eq!(windows.file.as_deref(), Some("lilypad-windows.zip")); - // Every spec category value (and its documented aliases) maps to a - // real category - never silently to Other (except Other itself). - for cat in [ - "Development", - "Graphics", - "Network", - "Office", - "Multimedia", - "System", - "Utility", - "Utilities", - "Security", - "Game", - "Games", - ] { - assert_ne!( - crate::scan::AppCategory::from_name(cat), - crate::scan::AppCategory::Other, - "spec category '{cat}' must not fall back to Other" - ); - } - } - - #[test] - fn manifest_signed_flag_parses_and_defaults_off() { - let json = r#"{ "name": "App", "category": "Utility", "signed": true }"#; - let m: ColonyManifest = serde_json::from_str(json).unwrap(); - assert!(m.signed); - let json = r#"{ "name": "App", "category": "Utility" }"#; - let m: ColonyManifest = serde_json::from_str(json).unwrap(); - assert!(!m.signed, "signed must default to false (legacy manifests)"); - } - - #[test] - fn find_asset_by_pattern_glob_with_exclusion_resolves_electron_builder_layout() { - // SphereCord's real release layout: electron-builder publishes both - // architectures plus updater metadata. Substring matching could never - // express this; an anchored glob with an exclusion can. - let assets = vec![ - "SphereCord-3.2.7.AppImage".to_string(), - "SphereCord-3.2.7-arm64.AppImage".to_string(), - "SphereCord-Setup-3.2.7.exe".to_string(), - "latest-linux.yml".to_string(), - "spherecord-3.2.7.tar.gz".to_string(), - ]; - assert_eq!( - find_asset_by_pattern(&assets, "spherecord-*.appimage, !*-arm64*").unwrap(), - "SphereCord-3.2.7.AppImage" - ); - assert_eq!( - find_asset_by_pattern(&assets, "*-arm64.appimage").unwrap(), - "SphereCord-3.2.7-arm64.AppImage" - ); - } - - #[test] - fn find_asset_by_pattern_glob_is_anchored() { - let assets = vec!["app-linux".to_string(), "app-linux-musl".to_string()]; - // Anchored: "*-linux" must NOT match "app-linux-musl". - assert_eq!( - find_asset_by_pattern(&assets, "*-linux").unwrap(), - "app-linux" - ); - } - - #[test] - fn find_asset_by_pattern_ignores_signature_and_checksum_siblings() { - // The day a repo signs its releases (like Colony itself), every binary - // grows a .sig sibling containing the same name: the pattern must - // keep resolving to the binary, not error as ambiguous. - let assets = vec![ - "app-linux".to_string(), - "app-linux.sig".to_string(), - "app-linux.sha256".to_string(), - "latest-linux.yml".to_string(), - ]; - assert_eq!( - find_asset_by_pattern(&assets, "linux").unwrap(), - "app-linux" - ); - } - - #[test] - fn parse_colony_manifest_missing_required_field() { - // category is required, so missing it should fail - let json = r#"{ "name": "TestApp" }"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn colony_manifest_minimal_deserialize() { - // platforms and release_files are optional (serde default) - let json = r#"{ "name": "orCAL", "category": "Utilities" }"#; - let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); - assert_eq!(manifest.name, "orCAL"); - assert_eq!(manifest.category, "Utilities"); - assert!(manifest.platforms.is_empty()); - assert!(manifest.release_files.is_empty()); - } - - #[test] - fn current_platform_key_is_valid() { - let key = current_platform_key(); - assert!( - key == "windows" || key == "linux" || key == "macos" || key == "macos-x86", - "unexpected platform key: {key}" - ); - } - - #[test] - fn base64_decode_manifest() { - let json = r#"{"name":"Test","category":"Games","platforms":["linux"],"releaseFiles":{"linux":{"tag":"v1","file":"test"}}}"#; - let encoded = base64::engine::general_purpose::STANDARD.encode(json); - let decoded = base64::engine::general_purpose::STANDARD - .decode(&encoded) - .unwrap(); - let manifest: ColonyManifest = serde_json::from_slice(&decoded).unwrap(); - assert_eq!(manifest.name, "Test"); - assert_eq!(manifest.category, "Games"); - } - - #[test] - fn parse_version_tag_with_v_prefix() { - let v = parse_version_tag("v1.2.3").unwrap(); - assert_eq!(v, semver::Version::new(1, 2, 3)); - } - - #[test] - fn parse_version_tag_without_prefix() { - let v = parse_version_tag("2.0.0").unwrap(); - assert_eq!(v, semver::Version::new(2, 0, 0)); - } - - #[test] - fn parse_version_tag_invalid() { - assert!(parse_version_tag("not-a-version").is_none()); - } - - #[test] - fn version_comparison() { - let old = parse_version_tag("v1.0.0").unwrap(); - let new = parse_version_tag("v1.1.0").unwrap(); - assert!(new > old); - } - - #[test] - fn detect_platforms_convention_naming() { - let assets = vec![ - "orcal-linux".to_string(), - "orcal-windows.exe".to_string(), - "orcal-macos".to_string(), - ]; - let platforms = detect_platforms_from_assets("orcal", &assets); - assert_eq!(platforms, vec!["linux", "windows", "macos"]); - } - - #[test] - fn detect_platforms_with_x86() { - let assets = vec![ - "myapp-linux".to_string(), - "myapp-macos".to_string(), - "myapp-macos-x86".to_string(), - ]; - let platforms = detect_platforms_from_assets("myapp", &assets); - assert!(platforms.contains(&"linux".to_string())); - assert!(platforms.contains(&"macos".to_string())); - assert!(platforms.contains(&"macos-x86".to_string())); - } - - #[test] - fn detect_platforms_empty_assets() { - let assets: Vec = vec![]; - let platforms = detect_platforms_from_assets("myapp", &assets); - assert!(platforms.is_empty()); - } - - #[test] - fn detect_platforms_case_insensitive() { - let assets = vec!["MyApp-Linux".to_string()]; - let platforms = detect_platforms_from_assets("MyApp", &assets); - assert_eq!(platforms, vec!["linux"]); - } - - #[test] - fn build_release_files_creates_entries() { - let assets = vec!["orcal-linux".to_string(), "orcal-windows.exe".to_string()]; - let files = build_release_files_from_assets("orcal", &assets); - assert_eq!(files.len(), 2); - - let linux = files.get("linux").unwrap(); - assert_eq!(linux.tag, "latest"); - assert_eq!(linux.file.as_deref(), Some("orcal-linux")); - assert!(linux.file_pattern.is_none()); - assert!(linux.binary.is_none()); - - let win = files.get("windows").unwrap(); - assert_eq!(win.tag, "latest"); - assert_eq!(win.file.as_deref(), Some("orcal-windows.exe")); - } -} diff --git a/src/github/catalog.rs b/src/github/catalog.rs new file mode 100644 index 0000000..a32b2c5 --- /dev/null +++ b/src/github/catalog.rs @@ -0,0 +1,328 @@ +//! Building the store catalog: list the org's repos, read each `colony.json`, +//! and gather the docs and icon that go with it. + +use anyhow::Result; +use base64::Engine; + +use crate::persistence::{load_repos_cache, save_repo_doc, save_repo_icon}; + +use super::releases::auto_detect_release; + +use super::http::*; +use super::types::*; + +/// Fetch all Colony repos (those containing colony.json) from Project-Colony. +/// Fetches manifests and READMEs concurrently for all repos. +pub async fn fetch_colony_repos(token: Option<&str>) -> Result> { + let client = build_client(token)?; + + // 1. List all repos for Project-Colony (with pagination) + let repos = list_repos_paginated(&client).await?; + + // Track whether any repo failed for a transient reason (timeout, 5xx, + // rate-limit) as opposed to genuinely lacking a colony.json (404). A + // transient failure must not silently drop an installed app from the store + // nor clobber the offline cache with a shortened list. + let transient_failures: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); + + // 2. Fetch manifest + README concurrently for all repos + let futures: Vec<_> = repos + .iter() + .map(|repo| { + let client = client.clone(); + let name = repo.name.clone(); + let fallback_desc = repo.description.clone().unwrap_or_default(); + let html_url = repo.html_url.clone(); + let language = repo.language.clone().unwrap_or_else(|| "Unknown".into()); + let transient_failures = transient_failures.clone(); + + async move { + let mut manifest = match fetch_colony_manifest(&client, &name).await { + Ok(Some(m)) => m, + Ok(None) => return None, + Err(e) => { + // fetch_colony_manifest already maps 404 to Ok(None), + // so an Err here is transient, not "no manifest". + tracing::warn!("Error checking colony.json for {}: {e}", name); + if let Ok(mut failed) = transient_failures.lock() { + failed.insert(name.clone()); + } + return None; + } + }; + + // Auto-detect platforms from release assets if manifest is minimal + if manifest.release_files.is_empty() { + if let Err(e) = auto_detect_release(&client, &name, &mut manifest).await { + tracing::debug!("Auto-detect skipped for {name}: {e}"); + } + } + + // Fetch README, LICENSE, CHANGELOG, icon concurrently + let readme_fut = fetch_readme(&client, &name); + let license_fut = fetch_license_with_fallback(&client, &name); + let changelog_fut = fetch_repo_file_candidates( + &client, + &name, + &["CHANGELOG.md", "CHANGES.md", "CHANGELOG"], + ); + let icon_fut = fetch_icon(&client, &name, manifest.icon.as_deref()); + + let (readme_result, license_result, changelog_result, icon_result) = + futures::future::join4(readme_fut, license_fut, changelog_fut, icon_fut).await; + + let description = readme_result.unwrap_or(fallback_desc); + + // Save docs + icon to disk cache + save_repo_doc(&name, "README.md", &description); + if let Ok(Some(ref content)) = license_result { + save_repo_doc(&name, "LICENSE.md", content); + } + if let Ok(Some(ref content)) = changelog_result { + save_repo_doc(&name, "CHANGELOG.md", content); + } + if let Ok(Some(ref bytes)) = icon_result { + save_repo_icon(&name, bytes); + } + + Some(ColonyRepo { + name, + description, + language, + html_url, + manifest, + }) + } + }) + .collect(); + + // Cap concurrency (order-preserving) instead of firing every repo's fetch + // chain at once. + use futures::StreamExt; + let results: Vec> = futures::stream::iter(futures) + .buffered(MAX_CONCURRENT_REPO_FETCHES) + .collect() + .await; + let mut repos_out: Vec = results.into_iter().flatten().collect(); + + // On a partially failed refresh, merge back ONLY the specific repos whose + // fetch failed transiently. The old any-failure flag resurrected EVERY + // cached repo, including ones genuinely deleted from the catalog. + let failed = transient_failures + .lock() + .map(|s| s.clone()) + .unwrap_or_default(); + if !failed.is_empty() { + if let Some(cached) = load_repos_cache() { + for repo in cached { + if failed.contains(&repo.name) && !repos_out.iter().any(|r| r.name == repo.name) { + repos_out.push(repo); + } + } + } + } + + Ok(repos_out) +} + +/// List repos with pagination support (follows GitHub Link header). +async fn list_repos_paginated(client: &reqwest::Client) -> Result> { + let mut all_repos = Vec::new(); + let mut page = 1u32; + + loop { + let url = format!( + "{GITHUB_API}/orgs/{GITHUB_ACCOUNT}/repos?per_page=100&sort=updated&page={page}" + ); + let (body, _) = cached_get(client, &url).await?; + let repos: Vec = serde_json::from_str(&body)?; + + if repos.is_empty() { + break; + } + + let count = repos.len(); + all_repos.extend(repos); + + // If we got fewer than 100, we've reached the last page + if count < 100 { + break; + } + + page += 1; + + // Safety limit to prevent infinite loops + if page > 50 { + tracing::warn!("Pagination safety limit reached at page {page}"); + break; + } + } + + Ok(all_repos) +} + +/// Fetch and parse colony.json from a repo. Returns None if the file doesn't exist. +async fn fetch_colony_manifest( + client: &reqwest::Client, + repo_name: &str, +) -> Result> { + let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/colony.json"); + match cached_get(client, &url).await { + Ok((body, _)) => { + let content: GithubContent = serde_json::from_str(&body).map_err(|e| { + anyhow::anyhow!("Failed to parse GitHub content response for {repo_name}: {e}") + })?; + if content.name != "colony.json" { + return Ok(None); + } + // Decode Base64 content + let raw = content.content.unwrap_or_default(); + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .map_err(|e| { + anyhow::anyhow!("Failed to decode base64 for {repo_name}/colony.json: {e}") + })?; + let manifest: ColonyManifest = serde_json::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("Invalid colony.json in {repo_name}: {e}"))?; + Ok(Some(manifest)) + } + Err(e) => { + if is_not_found(&e) { + Ok(None) + } else { + Err(e) + } + } + } +} + +/// Fetch the README content from a repo, returning the first ~500 chars as plain text. +async fn fetch_readme(client: &reqwest::Client, repo_name: &str) -> Result { + let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/readme"); + let (body, _) = cached_get(client, &url).await?; + let readme: GithubReadme = serde_json::from_str(&body)?; + let raw = readme.content.unwrap_or_default(); + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; + let text = String::from_utf8_lossy(&bytes).trim().to_string(); + + if text.is_empty() { + anyhow::bail!("README is empty"); + } + + Ok(text) +} + +/// Fetch the repo's LICENSE via GitHub's dedicated license endpoint — one +/// request that returns the detected license file, instead of probing several +/// candidate filenames (which cost one request each, mostly 404s). +async fn fetch_license(client: &reqwest::Client, repo_name: &str) -> Result> { + let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/license"); + match cached_get(client, &url).await { + Ok((body, _)) => { + let content: GithubContent = serde_json::from_str(&body)?; + let raw = content.content.unwrap_or_default(); + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; + Ok(Some(String::from_utf8_lossy(&bytes).to_string())) + } + Err(e) => { + if is_not_found(&e) { + Ok(None) + } else { + Err(e) + } + } + } +} + +/// LICENSE for display: try the fast dedicated endpoint first, then fall back +/// to probing common filenames only if GitHub could not auto-classify one — so +/// a repo carrying a nonstandard/undetectable license file is still surfaced +/// while the common case stays a single request. +async fn fetch_license_with_fallback( + client: &reqwest::Client, + repo_name: &str, +) -> Result> { + match fetch_license(client, repo_name).await { + Ok(Some(content)) => Ok(Some(content)), + Ok(None) => { + fetch_repo_file_candidates( + client, + repo_name, + &["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"], + ) + .await + } + Err(e) => Err(e), + } +} + +/// Fetch a file from a repo, trying multiple candidate paths. +/// Returns the decoded UTF-8 content of the first file found, or None if all return 404. +async fn fetch_repo_file_candidates( + client: &reqwest::Client, + repo_name: &str, + candidates: &[&str], +) -> Result> { + for path in candidates { + let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/{path}"); + match cached_get(client, &url).await { + Ok((body, _)) => { + let content: GithubContent = serde_json::from_str(&body)?; + let raw = content.content.unwrap_or_default(); + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; + let text = String::from_utf8_lossy(&bytes).to_string(); + return Ok(Some(text)); + } + Err(e) => { + if is_not_found(&e) { + continue; + } + return Err(e); + } + } + } + Ok(None) +} + +/// Fetch the app icon bytes from a repo: the manifest-declared `icon` path +/// first, then a conventional `icon.png` at the repo root. Returns the raw PNG +/// bytes of the first that exists, or None if neither is present (404). +async fn fetch_icon( + client: &reqwest::Client, + repo_name: &str, + declared: Option<&str>, +) -> Result>> { + let mut candidates: Vec<&str> = Vec::new(); + if let Some(p) = declared { + candidates.push(p); + } + if !candidates.contains(&"icon.png") { + candidates.push("icon.png"); + } + for path in candidates { + let url = format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/contents/{path}"); + match cached_get(client, &url).await { + Ok((body, _)) => { + let content: GithubContent = serde_json::from_str(&body)?; + let raw = content.content.unwrap_or_default(); + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = base64::engine::general_purpose::STANDARD.decode(&cleaned)?; + if !bytes.is_empty() { + return Ok(Some(bytes)); + } + } + Err(e) => { + if is_not_found(&e) { + continue; + } + return Err(e); + } + } + } + Ok(None) +} diff --git a/src/github/http.rs b/src/github/http.rs new file mode 100644 index 0000000..b54d71d --- /dev/null +++ b/src/github/http.rs @@ -0,0 +1,240 @@ +//! The HTTP layer: shared clients, the conditional-request cache with its +//! per-URL locks, and status plumbing. Nothing above this module touches headers. + +use anyhow::Result; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; +use tokio::sync::Mutex as TokioMutex; + +pub(crate) const GITHUB_API: &str = "https://api.github.com"; + +pub(crate) const GITHUB_ACCOUNT: &str = "Project-Colony"; + +pub(crate) const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Owner/repo for the Colony launcher itself. +pub(crate) const LAUNCHER_OWNER: &str = "Project-Colony"; + +pub(crate) const LAUNCHER_REPO: &str = "Colony"; + +/// Default HTTP timeout for all GitHub API requests. +pub(crate) const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Default connect timeout. +pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Cap on concurrent per-repo fetches during a store refresh so a large org +/// cannot fire an unbounded burst of requests at the GitHub API at once. +pub(crate) const MAX_CONCURRENT_REPO_FETCHES: usize = 8; + +// --- HTTP ETag Cache --- + +struct CacheEntry { + etag: String, + body: String, +} + +static HTTP_CACHE: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Per-URL lock to prevent concurrent requests to the same endpoint. +static URL_LOCKS: std::sync::LazyLock>>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Acquire a per-URL lock to prevent race conditions on the same endpoint. +fn url_lock(url: &str) -> std::sync::Arc> { + let mut locks = URL_LOCKS.lock().expect("URL_LOCKS mutex poisoned"); + locks + .entry(url.to_string()) + .or_insert_with(|| std::sync::Arc::new(TokioMutex::new(()))) + .clone() +} + +/// Rate-limit information from GitHub API response headers. +#[derive(Debug, Clone)] +pub struct RateLimitInfo { + pub remaining: u64, + pub limit: u64, + pub reset: u64, +} + +/// Perform a GET request with ETag caching, per-URL locking, and rate-limit awareness. +/// Returns (body_string, optional_rate_limit_info). +pub(crate) async fn cached_get( + client: &reqwest::Client, + url: &str, +) -> Result<(String, Option)> { + let lock = url_lock(url); + let _guard = lock.lock().await; + + let mut request = client.get(url); + + // Add If-None-Match if we have a cached ETag + if let Ok(cache) = HTTP_CACHE.lock() { + if let Some(entry) = cache.get(url) { + request = request.header("If-None-Match", &entry.etag); + } + } + + let resp = request.send().await.map_err(|e| { + if e.is_timeout() { + anyhow::anyhow!("Request timed out for {url}") + } else if e.is_connect() { + anyhow::anyhow!("Connection failed for {url}: {e}") + } else { + anyhow::anyhow!("Network error for {url}: {e}") + } + })?; + + // Parse rate-limit headers + let rate_limit = parse_rate_limit(resp.headers()); + + if let Some(ref rl) = rate_limit { + if rl.remaining < 10 { + tracing::warn!( + "GitHub API rate limit low: {}/{} remaining (resets at {})", + rl.remaining, + rl.limit, + rl.reset + ); + } + } + + match resp.status().as_u16() { + 304 => { + // Not Modified — return cached body + if let Ok(cache) = HTTP_CACHE.lock() { + if let Some(entry) = cache.get(url) { + tracing::debug!("Cache hit (304) for {}", url); + return Ok((entry.body.clone(), rate_limit)); + } + } + anyhow::bail!("304 received but no cached body for {url}"); + } + 200 => { + let etag = resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let body = resp.text().await?; + + // Store in cache if we got an ETag + if let Some(etag) = etag { + if let Ok(mut cache) = HTTP_CACHE.lock() { + cache.insert( + url.to_string(), + CacheEntry { + etag, + body: body.clone(), + }, + ); + } + } + Ok((body, rate_limit)) + } + status => { + // Only treat an exhausted quota as a rate-limit error on the + // statuses GitHub actually uses for it (403 / 429). A 200 or 304 + // that merely happened to consume the last quota unit is handled + // above and its body is preserved. + if matches!(status, 403 | 429) { + if let Some(ref rl) = rate_limit { + if rl.remaining == 0 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if rl.reset > now { + let wait = rl.reset - now; + anyhow::bail!( + "{}", + crate::i18n::t_fmt( + "github_rate_limit", + &[("wait", &wait.to_string())] + ) + ); + } + } + } + } + let body = resp.text().await.unwrap_or_default(); + Err(anyhow::Error::new(HttpStatus(status)) + .context(format!("GitHub API error {status}: {body}"))) + } + } +} + +/// Typed HTTP failure status carried inside the `anyhow` chain, so callers can +/// classify not-found precisely with [`is_not_found`] instead of substring- +/// matching "404" against the message - which misfired on any response body +/// that merely CONTAINED "404" and silently dropped legitimate repos from the +/// catalog (then clobbered the offline cache without them). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HttpStatus(pub u16); + +impl std::fmt::Display for HttpStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "HTTP {}", self.0) + } +} + +impl std::error::Error for HttpStatus {} + +/// True when the error chain carries an HTTP 404 from the GitHub API. +pub fn is_not_found(e: &anyhow::Error) -> bool { + e.downcast_ref::().is_some_and(|s| s.0 == 404) +} + +pub(crate) fn parse_rate_limit(headers: &reqwest::header::HeaderMap) -> Option { + let remaining = headers + .get("x-ratelimit-remaining")? + .to_str() + .ok()? + .parse() + .ok()?; + let limit = headers + .get("x-ratelimit-limit") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + let reset = headers + .get("x-ratelimit-reset") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + Some(RateLimitInfo { + remaining, + limit, + reset, + }) +} + +/// Build an HTTP client for API calls (public wrapper). +pub fn build_update_client(token: Option<&str>) -> Result { + build_client(token) +} + +pub(crate) fn build_client(token: Option<&str>) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::ACCEPT, + "application/vnd.github.v3+json".parse()?, + ); + headers.insert( + reqwest::header::USER_AGENT, + format!("Colony-Launcher/{APP_VERSION}").parse()?, + ); + if let Some(token) = token { + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Bearer {token}").parse()?, + ); + } + Ok(reqwest::Client::builder() + .default_headers(headers) + .timeout(HTTP_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build()?) +} diff --git a/src/github/mod.rs b/src/github/mod.rs new file mode 100644 index 0000000..916e42f --- /dev/null +++ b/src/github/mod.rs @@ -0,0 +1,486 @@ +//! GitHub integration: the store catalog and release resolution. +//! +//! Split by layer - [`http`] owns the client and cache, [`types`] the wire +//! shapes, [`catalog`] the store listing, [`releases`] tag and asset resolution. +//! Everything the rest of the crate uses is re-exported here, so call sites keep +//! naming `crate::github::X` whichever file X lives in. + +mod catalog; +mod http; +mod releases; +mod types; + +pub use catalog::*; +pub use http::*; +pub use releases::*; +pub use types::*; + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + + #[test] + fn parse_colony_manifest() { + let json = r#"{ + "name": "TestApp", + "category": "Utilities", + "platforms": ["windows", "linux"], + "releaseFiles": { + "windows": { "tag": "Windows", "file": "TestApp.exe" }, + "linux": { "tag": "Linux", "file": "TestApp" } + } + }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.name, "TestApp"); + assert_eq!(manifest.category, "Utilities"); + assert_eq!(manifest.platforms, vec!["windows", "linux"]); + assert_eq!(manifest.release_files.len(), 2); + assert_eq!(manifest.release_files["windows"].tag, "Windows"); + assert_eq!( + manifest.release_files["windows"].file.as_deref(), + Some("TestApp.exe") + ); + assert_eq!(manifest.release_files["linux"].tag, "Linux"); + assert_eq!( + manifest.release_files["linux"].file.as_deref(), + Some("TestApp") + ); + } + + #[test] + fn parse_colony_manifest_with_sha256() { + let json = r#"{ + "name": "TestApp", + "category": "Utilities", + "platforms": ["linux"], + "releaseFiles": { + "linux": { "tag": "v1.0", "file": "app", "sha256": "abc123def456" } + } + }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert_eq!( + manifest.release_files["linux"].sha256.as_deref(), + Some("abc123def456") + ); + } + + #[test] + fn parse_colony_manifest_with_binary_and_latest() { + let json = r#"{ + "name": "Lilypad", + "category": "Security", + "platforms": ["windows", "linux", "macos"], + "releaseFiles": { + "windows": { + "tag": "latest", + "file": "lilypad-x86_64-pc-windows-msvc.zip", + "binary": "lilypad-cli.exe", + "sha256": "abc123" + }, + "linux": { + "tag": "latest", + "file": "lilypad-x86_64-unknown-linux-gnu.tar.gz", + "binary": "lilypad-cli" + }, + "macos": { + "tag": "v0.1.0", + "file": "lilypad-aarch64-apple-darwin.tar.gz", + "binary": "lilypad-cli" + } + } + }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.name, "Lilypad"); + assert_eq!(manifest.platforms.len(), 3); + // Windows: archive + binary + latest + let win = &manifest.release_files["windows"]; + assert_eq!(win.tag, "latest"); + assert_eq!( + win.file.as_deref(), + Some("lilypad-x86_64-pc-windows-msvc.zip") + ); + assert_eq!(win.binary.as_deref(), Some("lilypad-cli.exe")); + assert_eq!(win.sha256.as_deref(), Some("abc123")); + // Linux: archive + binary + latest, no sha256 + let linux = &manifest.release_files["linux"]; + assert_eq!(linux.tag, "latest"); + assert_eq!(linux.binary.as_deref(), Some("lilypad-cli")); + assert!(linux.sha256.is_none()); + // macOS: pinned tag + let macos = &manifest.release_files["macos"]; + assert_eq!(macos.tag, "v0.1.0"); + assert_eq!(macos.binary.as_deref(), Some("lilypad-cli")); + } + + #[test] + fn parse_colony_manifest_binary_absent() { + // Legacy format without binary field still works + let json = r#"{ + "name": "TestApp", + "category": "Utilities", + "platforms": ["windows"], + "releaseFiles": { + "windows": { "tag": "Windows", "file": "TestApp.exe" } + } + }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert!(manifest.release_files["windows"].binary.is_none()); + } + + #[test] + fn parse_colony_manifest_with_file_pattern() { + let json = r#"{ + "name": "Lilypad", + "category": "Security", + "platforms": ["windows", "linux", "macos"], + "releaseFiles": { + "windows": { + "tag": "latest", + "filePattern": "windows", + "binary": "lilypad-cli.exe" + }, + "linux": { + "tag": "latest", + "filePattern": "linux", + "binary": "lilypad-cli" + }, + "macos": { + "tag": "latest", + "filePattern": "darwin", + "binary": "lilypad-cli" + } + } + }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.name, "Lilypad"); + // Windows: filePattern instead of file + let win = &manifest.release_files["windows"]; + assert_eq!(win.tag, "latest"); + assert!(win.file.is_none()); + assert_eq!(win.file_pattern.as_deref(), Some("windows")); + assert_eq!(win.binary.as_deref(), Some("lilypad-cli.exe")); + // Linux + let linux = &manifest.release_files["linux"]; + assert_eq!(linux.file_pattern.as_deref(), Some("linux")); + // macOS + let macos = &manifest.release_files["macos"]; + assert_eq!(macos.file_pattern.as_deref(), Some("darwin")); + } + + #[test] + fn find_asset_by_pattern_single_match() { + let assets = vec![ + "lilypad-x86_64-pc-windows-msvc.zip".to_string(), + "lilypad-x86_64-unknown-linux-gnu.tar.gz".to_string(), + "lilypad-aarch64-apple-darwin.tar.gz".to_string(), + ]; + let result = find_asset_by_pattern(&assets, "windows"); + assert_eq!(result.unwrap(), "lilypad-x86_64-pc-windows-msvc.zip"); + + let result = find_asset_by_pattern(&assets, "linux"); + assert_eq!(result.unwrap(), "lilypad-x86_64-unknown-linux-gnu.tar.gz"); + + let result = find_asset_by_pattern(&assets, "darwin"); + assert_eq!(result.unwrap(), "lilypad-aarch64-apple-darwin.tar.gz"); + } + + #[test] + fn find_asset_by_pattern_case_insensitive() { + let assets = vec!["MyApp-Windows-x64.zip".to_string()]; + let result = find_asset_by_pattern(&assets, "windows"); + assert!(result.is_ok()); + } + + #[test] + fn find_asset_by_pattern_no_match() { + let assets = vec!["app-linux.tar.gz".to_string()]; + let result = find_asset_by_pattern(&assets, "windows"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No release asset")); + } + + #[test] + fn find_asset_by_pattern_ambiguous() { + let assets = vec![ + "app-linux-x64.tar.gz".to_string(), + "app-linux-arm64.tar.gz".to_string(), + ]; + let result = find_asset_by_pattern(&assets, "linux"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Ambiguous")); + } + + #[test] + fn is_not_found_matches_typed_status_not_message_text() { + // A 404 is recognized through the typed status... + let e404 = anyhow::Error::new(HttpStatus(404)).context("GitHub API error 404: Not Found"); + assert!(is_not_found(&e404)); + // ...a different status is not, even if its BODY contains "404" (the + // old substring check misclassified this and dropped live repos). + let e500 = anyhow::Error::new(HttpStatus(500)) + .context("GitHub API error 500: upstream said 404 somewhere"); + assert!(!is_not_found(&e500)); + // ...and a plain network error without a status is not a 404 either. + assert!(!is_not_found(&anyhow::anyhow!("Network error: dns 404ish"))); + } + + #[test] + fn find_asset_by_pattern_exact_name_beats_substring_overlap() { + // "app-macos" is a substring of "app-macos-x86": an exact name match + // must win instead of erroring as ambiguous, so Apple Silicon + // manifests can pin the shorter asset name. + let assets = vec!["app-macos".to_string(), "app-macos-x86".to_string()]; + assert_eq!( + find_asset_by_pattern(&assets, "app-macos").unwrap(), + "app-macos" + ); + assert_eq!( + find_asset_by_pattern(&assets, "app-macos-x86").unwrap(), + "app-macos-x86" + ); + } + + #[test] + fn spec_conformant_manifest_parses_field_for_field() { + // Locks docs/colony-spec.md <-> code parity: this sample uses every + // documented manifest field with the spec's exact camelCase names. + // If a rename or removal breaks the spec, this test fails first. + let json = r#"{ + "name": "Lilypad", + "category": "Security", + "platforms": ["windows", "linux", "macos", "macos-x86"], + "icon": "assets/icons/icon.png", + "signed": true, + "releaseFiles": { + "linux": { + "tag": "latest", + "filePattern": "lilypad-*-linux.tar.gz, !*-arm64*", + "binary": "lilypad-cli", + "sha256": "abc123" + }, + "windows": { + "tag": "v1.0.0", + "file": "lilypad-windows.zip", + "binary": "lilypad-cli.exe" + } + } + }"#; + let m: ColonyManifest = serde_json::from_str(json).expect("spec sample must parse"); + assert_eq!(m.name, "Lilypad"); + assert_eq!(m.category, "Security"); + assert_eq!(m.platforms.len(), 4); + assert_eq!(m.icon.as_deref(), Some("assets/icons/icon.png")); + assert!(m.signed); + let linux = &m.release_files["linux"]; + assert_eq!(linux.tag, "latest"); + assert_eq!( + linux.file_pattern.as_deref(), + Some("lilypad-*-linux.tar.gz, !*-arm64*") + ); + assert_eq!(linux.binary.as_deref(), Some("lilypad-cli")); + assert_eq!(linux.sha256.as_deref(), Some("abc123")); + let windows = &m.release_files["windows"]; + assert_eq!(windows.tag, "v1.0.0"); + assert_eq!(windows.file.as_deref(), Some("lilypad-windows.zip")); + // Every spec category value (and its documented aliases) maps to a + // real category - never silently to Other (except Other itself). + for cat in [ + "Development", + "Graphics", + "Network", + "Office", + "Multimedia", + "System", + "Utility", + "Utilities", + "Security", + "Game", + "Games", + ] { + assert_ne!( + crate::scan::AppCategory::from_name(cat), + crate::scan::AppCategory::Other, + "spec category '{cat}' must not fall back to Other" + ); + } + } + + #[test] + fn manifest_signed_flag_parses_and_defaults_off() { + let json = r#"{ "name": "App", "category": "Utility", "signed": true }"#; + let m: ColonyManifest = serde_json::from_str(json).unwrap(); + assert!(m.signed); + let json = r#"{ "name": "App", "category": "Utility" }"#; + let m: ColonyManifest = serde_json::from_str(json).unwrap(); + assert!(!m.signed, "signed must default to false (legacy manifests)"); + } + + #[test] + fn find_asset_by_pattern_glob_with_exclusion_resolves_electron_builder_layout() { + // SphereCord's real release layout: electron-builder publishes both + // architectures plus updater metadata. Substring matching could never + // express this; an anchored glob with an exclusion can. + let assets = vec![ + "SphereCord-3.2.7.AppImage".to_string(), + "SphereCord-3.2.7-arm64.AppImage".to_string(), + "SphereCord-Setup-3.2.7.exe".to_string(), + "latest-linux.yml".to_string(), + "spherecord-3.2.7.tar.gz".to_string(), + ]; + assert_eq!( + find_asset_by_pattern(&assets, "spherecord-*.appimage, !*-arm64*").unwrap(), + "SphereCord-3.2.7.AppImage" + ); + assert_eq!( + find_asset_by_pattern(&assets, "*-arm64.appimage").unwrap(), + "SphereCord-3.2.7-arm64.AppImage" + ); + } + + #[test] + fn find_asset_by_pattern_glob_is_anchored() { + let assets = vec!["app-linux".to_string(), "app-linux-musl".to_string()]; + // Anchored: "*-linux" must NOT match "app-linux-musl". + assert_eq!( + find_asset_by_pattern(&assets, "*-linux").unwrap(), + "app-linux" + ); + } + + #[test] + fn find_asset_by_pattern_ignores_signature_and_checksum_siblings() { + // The day a repo signs its releases (like Colony itself), every binary + // grows a .sig sibling containing the same name: the pattern must + // keep resolving to the binary, not error as ambiguous. + let assets = vec![ + "app-linux".to_string(), + "app-linux.sig".to_string(), + "app-linux.sha256".to_string(), + "latest-linux.yml".to_string(), + ]; + assert_eq!( + find_asset_by_pattern(&assets, "linux").unwrap(), + "app-linux" + ); + } + + #[test] + fn parse_colony_manifest_missing_required_field() { + // category is required, so missing it should fail + let json = r#"{ "name": "TestApp" }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn colony_manifest_minimal_deserialize() { + // platforms and release_files are optional (serde default) + let json = r#"{ "name": "orCAL", "category": "Utilities" }"#; + let manifest: ColonyManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.name, "orCAL"); + assert_eq!(manifest.category, "Utilities"); + assert!(manifest.platforms.is_empty()); + assert!(manifest.release_files.is_empty()); + } + + #[test] + fn current_platform_key_is_valid() { + let key = current_platform_key(); + assert!( + key == "windows" || key == "linux" || key == "macos" || key == "macos-x86", + "unexpected platform key: {key}" + ); + } + + #[test] + fn base64_decode_manifest() { + let json = r#"{"name":"Test","category":"Games","platforms":["linux"],"releaseFiles":{"linux":{"tag":"v1","file":"test"}}}"#; + let encoded = base64::engine::general_purpose::STANDARD.encode(json); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&encoded) + .unwrap(); + let manifest: ColonyManifest = serde_json::from_slice(&decoded).unwrap(); + assert_eq!(manifest.name, "Test"); + assert_eq!(manifest.category, "Games"); + } + + #[test] + fn parse_version_tag_with_v_prefix() { + let v = parse_version_tag("v1.2.3").unwrap(); + assert_eq!(v, semver::Version::new(1, 2, 3)); + } + + #[test] + fn parse_version_tag_without_prefix() { + let v = parse_version_tag("2.0.0").unwrap(); + assert_eq!(v, semver::Version::new(2, 0, 0)); + } + + #[test] + fn parse_version_tag_invalid() { + assert!(parse_version_tag("not-a-version").is_none()); + } + + #[test] + fn version_comparison() { + let old = parse_version_tag("v1.0.0").unwrap(); + let new = parse_version_tag("v1.1.0").unwrap(); + assert!(new > old); + } + + #[test] + fn detect_platforms_convention_naming() { + let assets = vec![ + "orcal-linux".to_string(), + "orcal-windows.exe".to_string(), + "orcal-macos".to_string(), + ]; + let platforms = detect_platforms_from_assets("orcal", &assets); + assert_eq!(platforms, vec!["linux", "windows", "macos"]); + } + + #[test] + fn detect_platforms_with_x86() { + let assets = vec![ + "myapp-linux".to_string(), + "myapp-macos".to_string(), + "myapp-macos-x86".to_string(), + ]; + let platforms = detect_platforms_from_assets("myapp", &assets); + assert!(platforms.contains(&"linux".to_string())); + assert!(platforms.contains(&"macos".to_string())); + assert!(platforms.contains(&"macos-x86".to_string())); + } + + #[test] + fn detect_platforms_empty_assets() { + let assets: Vec = vec![]; + let platforms = detect_platforms_from_assets("myapp", &assets); + assert!(platforms.is_empty()); + } + + #[test] + fn detect_platforms_case_insensitive() { + let assets = vec!["MyApp-Linux".to_string()]; + let platforms = detect_platforms_from_assets("MyApp", &assets); + assert_eq!(platforms, vec!["linux"]); + } + + #[test] + fn build_release_files_creates_entries() { + let assets = vec!["orcal-linux".to_string(), "orcal-windows.exe".to_string()]; + let files = build_release_files_from_assets("orcal", &assets); + assert_eq!(files.len(), 2); + + let linux = files.get("linux").unwrap(); + assert_eq!(linux.tag, "latest"); + assert_eq!(linux.file.as_deref(), Some("orcal-linux")); + assert!(linux.file_pattern.is_none()); + assert!(linux.binary.is_none()); + + let win = files.get("windows").unwrap(); + assert_eq!(win.tag, "latest"); + assert_eq!(win.file.as_deref(), Some("orcal-windows.exe")); + } +} diff --git a/src/github/releases.rs b/src/github/releases.rs new file mode 100644 index 0000000..781c9f2 --- /dev/null +++ b/src/github/releases.rs @@ -0,0 +1,349 @@ +//! Release resolution: which tag, which asset, and whether an update exists. + +use anyhow::Result; +use serde::Deserialize; +use std::collections::HashMap; + +use crate::persistence::load_installed_version; + +use super::http::*; +use super::types::*; + +/// Return the current platform key ("windows", "linux", "macos", or "macos-x86"). +/// On macOS, distinguishes Apple Silicon (aarch64 → "macos") from Intel (x86_64 → "macos-x86"). +pub fn current_platform_key() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + "macos" + } else { + "macos-x86" + } + } else { + "linux" + } +} + +/// Fetch the latest release tag for an arbitrary owner/repo combination. +pub async fn fetch_latest_release_tag_for( + client: &reqwest::Client, + owner: &str, + repo: &str, +) -> Result { + let url = format!("{GITHUB_API}/repos/{owner}/{repo}/releases/latest"); + let (body, _) = cached_get(client, &url).await?; + + #[derive(Deserialize)] + struct Release { + tag_name: String, + } + + let release: Release = serde_json::from_str(&body)?; + Ok(release.tag_name) +} + +/// Fetch the latest release tag for a Colony app repo. +pub async fn fetch_latest_release_tag(client: &reqwest::Client, repo_name: &str) -> Result { + fetch_latest_release_tag_for(client, GITHUB_ACCOUNT, repo_name).await +} + +/// Resolved release information from GitHub API. +#[derive(Debug)] +pub struct ResolvedRelease { + pub tag: String, + pub asset_names: Vec, + /// The release notes (GitHub release body, markdown). Previously never + /// fetched anywhere: the detail Changelog tab only showed the repo's + /// CHANGELOG.md file frozen at catalog-fetch time. + pub body: Option, +} + +/// Fetch release info (tag + asset list) for a repo. +/// If tag is "latest", resolves to the actual latest release. +/// Otherwise fetches the specific tagged release. +pub async fn fetch_release_info( + client: &reqwest::Client, + repo_name: &str, + tag: &str, +) -> Result { + let url = if tag.eq_ignore_ascii_case("latest") { + format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/releases/latest") + } else { + format!("{GITHUB_API}/repos/{GITHUB_ACCOUNT}/{repo_name}/releases/tags/{tag}") + }; + let (body, _) = cached_get(client, &url).await?; + + #[derive(Deserialize)] + struct Asset { + name: String, + } + #[derive(Deserialize)] + struct Release { + tag_name: String, + assets: Vec, + body: Option, + } + + let release: Release = serde_json::from_str(&body)?; + Ok(ResolvedRelease { + tag: release.tag_name, + asset_names: release.assets.into_iter().map(|a| a.name).collect(), + body: release.body, + }) +} + +/// Find an asset whose name contains the given pattern (case-insensitive). +/// Returns an error if zero or multiple assets match. +/// Metadata companions published alongside release binaries (signatures, +/// checksums, updater manifests). Never installable, so they are excluded from +/// pattern matching - otherwise `app-linux.sig` would make the pattern +/// "linux" ambiguous the day a repo starts signing its releases (Colony's own +/// releases already ship `.sig` siblings). +const NON_INSTALLABLE_SUFFIXES: &[&str] = &[ + ".sig", + ".asc", + ".sha256", + ".sha256sum", + ".txt", + ".yml", + ".yaml", + ".json", +]; + +/// Anchored glob match: `*` matches any run of characters, everything else is +/// literal (case-insensitive - both inputs must already be lowercase). The +/// pattern must cover the WHOLE name, unlike the legacy substring mode. +pub(crate) fn glob_matches(pattern: &str, name: &str) -> bool { + fn inner(p: &[u8], n: &[u8]) -> bool { + match (p.first(), n.first()) { + (None, None) => true, + (Some(b'*'), _) => { + // Star: match zero characters, or consume one and retry. + inner(&p[1..], n) || (!n.is_empty() && inner(p, &n[1..])) + } + (Some(pc), Some(nc)) if pc == nc => inner(&p[1..], &n[1..]), + _ => false, + } + } + inner(pattern.as_bytes(), name.as_bytes()) +} + +/// Resolve a `filePattern` against release asset names. +/// +/// Three matching modes, so real-world release layouts (e.g. electron-builder +/// publishing `App-1.2.3.AppImage` AND `App-1.2.3-arm64.AppImage`) stay +/// expressible: +/// - exact name match always wins (never ambiguous); +/// - a pattern containing `*` is an ANCHORED glob; comma-separated terms are +/// supported, where `!term` excludes: `"*.AppImage, !*-arm64*"`; +/// - otherwise the legacy case-insensitive substring match applies. +/// +/// Signature/checksum siblings (`.sig`, `.sha256`, ...) are never candidates. +pub fn find_asset_by_pattern(assets: &[String], pattern: &str) -> Result { + let pattern_lower = pattern.to_lowercase(); + if let Some(exact) = assets.iter().find(|n| n.to_lowercase() == pattern_lower) { + return Ok(exact.clone()); + } + + let terms: Vec<&str> = pattern_lower + .split(',') + .map(str::trim) + .filter(|t| !t.is_empty()) + .collect(); + let has_glob = terms.iter().any(|t| t.contains('*') || t.starts_with('!')); + let positives: Vec<&str> = terms + .iter() + .filter(|t| !t.starts_with('!')) + .copied() + .collect(); + let negatives: Vec<&str> = terms.iter().filter_map(|t| t.strip_prefix('!')).collect(); + + let matches: Vec<&String> = assets + .iter() + .filter(|name| { + let lower = name.to_lowercase(); + if NON_INSTALLABLE_SUFFIXES.iter().any(|s| lower.ends_with(s)) { + return false; + } + if has_glob { + positives.iter().any(|p| glob_matches(p, &lower)) + && !negatives.iter().any(|n| glob_matches(n, &lower)) + } else { + lower.contains(&pattern_lower) + } + }) + .collect(); + match matches.len() { + 0 => anyhow::bail!("No release asset matching pattern '{pattern}'"), + 1 => Ok(matches[0].clone()), + n => { + anyhow::bail!( + "Ambiguous pattern '{pattern}': {n} assets match ({}). Use a more specific pattern.", + matches.iter().map(|s| s.as_str()).collect::>().join(", ") + ) + } + } +} + +/// Platform detection entries: (expected asset suffix, platform key). +/// Order matters: "macos-x86" must come before "macos" to avoid false matches. +const PLATFORM_CONVENTIONS: &[(&str, &str)] = &[ + ("-linux", "linux"), + ("-windows.exe", "windows"), + ("-macos-x86", "macos-x86"), + ("-macos", "macos"), +]; + +/// Detect which platforms are available from release asset names using the +/// Colony naming convention: `{name}-linux`, `{name}-windows.exe`, +/// `{name}-macos`, `{name}-macos-x86`. +pub fn detect_platforms_from_assets(repo_name: &str, asset_names: &[String]) -> Vec { + let repo_lower = repo_name.to_lowercase(); + let mut platforms = Vec::new(); + + for &(suffix, platform) in PLATFORM_CONVENTIONS { + let expected = format!("{repo_lower}{suffix}"); + if asset_names.iter().any(|a| a.to_lowercase() == expected) { + platforms.push(platform.to_string()); + } + } + + platforms +} + +/// Build a `release_files` HashMap from detected assets, using the "latest" tag +/// and convention-based filenames. Uses the exact asset name found in the release. +pub fn build_release_files_from_assets( + repo_name: &str, + asset_names: &[String], +) -> HashMap { + let repo_lower = repo_name.to_lowercase(); + let mut map = HashMap::new(); + + for &(suffix, platform) in PLATFORM_CONVENTIONS { + let expected = format!("{repo_lower}{suffix}"); + if let Some(actual_name) = asset_names.iter().find(|a| a.to_lowercase() == expected) { + map.insert( + platform.to_string(), + ReleaseFileEntry { + tag: "latest".to_string(), + file: Some(actual_name.clone()), + file_pattern: None, + binary: None, + sha256: None, + }, + ); + } + } + + map +} + +/// For a repo with empty platforms/release_files (minimal colony.json), fetch the +/// latest release and auto-detect available platforms from its assets. +pub async fn auto_detect_release( + client: &reqwest::Client, + repo_name: &str, + manifest: &mut ColonyManifest, +) -> Result<()> { + let release = fetch_release_info(client, repo_name, "latest").await?; + let platforms = detect_platforms_from_assets(repo_name, &release.asset_names); + let release_files = build_release_files_from_assets(repo_name, &release.asset_names); + + if !platforms.is_empty() { + tracing::info!("Auto-detected platforms for {repo_name}: {:?}", platforms); + manifest.platforms = platforms; + manifest.release_files = release_files; + } + + Ok(()) +} + +/// Parse a version tag (e.g. "v1.2.3" or "1.2.3") into a semver::Version. +pub fn parse_version_tag(tag: &str) -> Option { + let cleaned = tag.strip_prefix('v').unwrap_or(tag); + semver::Version::parse(cleaned).ok() +} + +/// Check if an update is available for a repo whose manifest pins `pinned_tag` +/// for the current platform. Returns Some(target_tag) if the installed version +/// differs from what the manifest would install, None otherwise. +/// +/// `pinned_tag` is compared directly unless it is "latest", in which case the +/// repo's latest release is resolved. This avoids a perpetual "update +/// available" loop for apps pinned to a specific (older) release, and falls +/// back to string comparison when tags are not semver so detection is not +/// silently disabled. +pub async fn check_update_available( + client: &reqwest::Client, + repo_name: &str, + pinned_tag: &str, +) -> Option { + let installed = load_installed_version(repo_name)?; + + let target = if pinned_tag.eq_ignore_ascii_case("latest") { + fetch_latest_release_tag(client, repo_name).await.ok()? + } else { + pinned_tag.to_string() + }; + + // Case-insensitive: "Nightly" vs "nightly" must not read as an update + // (with non-semver tags the string fallback below would flag it forever). + if target.eq_ignore_ascii_case(&installed) { + return None; + } + + match (parse_version_tag(&installed), parse_version_tag(&target)) { + (Some(installed_ver), Some(target_ver)) => { + if target_ver > installed_ver { + Some(target) + } else { + None + } + } + _ => { + tracing::warn!( + "Non-semver tags for {repo_name} (installed '{installed}', target '{target}'); using string comparison" + ); + Some(target) + } + } +} + +// --- Launcher self-update --- + +/// Expected release asset name for the Colony launcher binary on the current platform. +pub fn launcher_asset_name() -> String { + let platform = current_platform_key(); + let ext = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; + format!("colony-{platform}{ext}") +} + +/// Check if a newer version of the Colony launcher itself is available. +/// Returns Some((latest_tag, asset_filename)) if an update exists, None otherwise. +/// `Ok(None)` means the check RAN and Colony is current; failures propagate so +/// the UI never reports "up to date" when the check could not run at all +/// (offline, rate limited, or an unparseable release tag). +pub async fn check_launcher_update(client: &reqwest::Client) -> Result> { + let latest_tag = fetch_latest_release_tag_for(client, LAUNCHER_OWNER, LAUNCHER_REPO).await?; + + let current = parse_version_tag(APP_VERSION) + .ok_or_else(|| anyhow::anyhow!("unparseable app version '{APP_VERSION}'"))?; + let latest = parse_version_tag(&latest_tag) + .ok_or_else(|| anyhow::anyhow!("unrecognized release tag '{latest_tag}'"))?; + + Ok((latest > current).then(|| (latest_tag, launcher_asset_name()))) +} + +// --- Offline cache --- + +// --- Favorites persistence --- + +// --- User preferences persistence --- + +// --- Application scan cache --- diff --git a/src/github/types.rs b/src/github/types.rs new file mode 100644 index 0000000..c63852b --- /dev/null +++ b/src/github/types.rs @@ -0,0 +1,75 @@ +//! Wire types: the shape of `colony.json` and of the GitHub API responses this +//! module deserializes. No behaviour lives here. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Per-platform release info from colony.json. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseFileEntry { + pub tag: String, + /// Exact asset filename to download. Required unless `file_pattern` is set. + pub file: Option, + /// Substring pattern to match against release asset names (case-insensitive). + /// Colony fetches the release assets list and picks the one matching this pattern. + /// Mutually exclusive with `file` — use one or the other. + pub file_pattern: Option, + /// Optional binary name inside an archive. When present, the downloaded file + /// is treated as an archive (.zip / .tar.gz) and Colony extracts this binary. + /// When absent, the downloaded file is the final binary (legacy behaviour). + pub binary: Option, + /// Optional SHA256 checksum for integrity verification. + pub sha256: Option, +} + +/// Parsed manifest from colony.json inside a repo. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ColonyManifest { + pub name: String, + pub category: String, + #[serde(default)] + pub platforms: Vec, + #[serde(default)] + pub release_files: HashMap, + /// Optional path (relative to the repo root) to a square PNG app icon shown + /// in the Colony grid. When absent, Colony probes a conventional `icon.png` + /// at the repo root, then falls back to the tinted category hexagon. + #[serde(default)] + pub icon: Option, + /// When true, every release asset MUST ship a valid `.sig` + /// (ed25519, Project-Colony org key): a missing signature aborts the + /// install instead of falling back to the legacy unsigned path. + #[serde(default)] + pub signed: bool, +} + +/// Metadata for a Colony-compatible repository (has colony.json). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ColonyRepo { + pub name: String, + pub description: String, + pub language: String, + pub html_url: String, + pub manifest: ColonyManifest, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct GithubRepo { + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) language: Option, + pub(crate) html_url: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct GithubContent { + pub(crate) name: String, + pub(crate) content: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct GithubReadme { + pub(crate) content: Option, +} diff --git a/src/i18n.rs b/src/i18n.rs deleted file mode 100644 index 3646f51..0000000 --- a/src/i18n.rs +++ /dev/null @@ -1,1523 +0,0 @@ -use std::collections::HashMap; -use std::sync::RwLock; - -static LOCALE: RwLock> = RwLock::new(None); - -pub struct Locale { - strings: HashMap, - lang: String, -} - -impl Locale { - fn new(lang: &str) -> Self { - let mut strings = HashMap::new(); - - match lang { - "fr" => { - // Sidebar - strings.insert("categories".into(), "Catégories".into()); - strings.insert("rescan".into(), "Rescan".into()); - - // GitHub panel - strings.insert("github_connect_desc".into(), "Connectez-vous à GitHub pour détecter les dépôts Colony (colony.json) de l'organisation Project-Colony.".into()); - strings.insert("github_login".into(), "Se connecter avec GitHub".into()); - strings.insert( - "github_public_api".into(), - "Mode non connecté : API publique GitHub (60 req/h)".into(), - ); - strings.insert( - "github_rate_limit".into(), - "Quota GitHub atteint. Réessayez dans {wait} secondes.".into(), - ); - strings.insert( - "github_enter_code".into(), - "Entrez ce code sur GitHub :".into(), - ); - strings.insert( - "github_copy_hint".into(), - "Cliquez pour copier — En attente d'autorisation...".into(), - ); - strings.insert("github_connecting".into(), "Connexion en cours...".into()); - strings.insert("github_connected".into(), "Connecté".into()); - strings.insert( - "github_repos_detected".into(), - "{count} dépôts Colony détectés".into(), - ); - strings.insert( - "github_no_repos".into(), - "Aucun dépôt avec colony.json trouvé.".into(), - ); - strings.insert("github_refresh".into(), "Rafraîchir les dépôts".into()); - strings.insert("github_logout".into(), "Se déconnecter".into()); - strings.insert("github_error".into(), "Erreur : {error}".into()); - strings.insert("github_retry".into(), "Réessayer".into()); - strings.insert("github_disconnected".into(), "Déconnecté de GitHub".into()); - - // App grid - strings.insert("no_apps_found".into(), "Aucune application trouvée".into()); - strings.insert( - "search_placeholder".into(), - "Rechercher des applications...".into(), - ); - strings.insert("status_installed".into(), "Installé".into()); - strings.insert("status_get".into(), "À installer".into()); - strings.insert("status_update".into(), "Mise à jour".into()); - - // Detail view - strings.insert("back".into(), "Retour".into()); - strings.insert("language_label".into(), "Langage: {lang}".into()); - strings.insert("launch".into(), "Lancer {name}".into()); - strings.insert("update".into(), "Mettre à jour".into()); - strings.insert("download".into(), "Télécharger".into()); - strings.insert("no_release".into(), "Aucune release disponible".into()); - strings.insert( - "no_release_platform".into(), - "Non disponible pour votre plateforme".into(), - ); - - // Status messages - strings.insert("apps_found".into(), "{count} applications trouvées".into()); - strings.insert("app_launched".into(), "Application lancée.".into()); - strings.insert("installed".into(), "Installé : {path}".into()); - strings.insert( - "download_error".into(), - "Erreur téléchargement : {error}".into(), - ); - strings.insert("downloading".into(), "Téléchargement de {file}…".into()); - strings.insert( - "no_release_for".into(), - "Pas de release pour {platform}".into(), - ); - strings.insert("uninstalled".into(), "{name} désinstallé.".into()); - strings.insert( - "launch_error".into(), - "Impossible de lancer: {error}".into(), - ); - strings.insert( - "launch_error_empty".into(), - "Impossible de lancer: commande vide".into(), - ); - strings.insert( - "uninstall_error".into(), - "Erreur désinstallation : {error}".into(), - ); - - // OAuth errors - strings.insert("oauth_error".into(), "Erreur OAuth: {error}".into()); - strings.insert( - "oauth_device_expired".into(), - "Délai dépassé : l'autorisation GitHub n'a pas été confirmée à temps.".into(), - ); - strings.insert( - "oauth_device_failed".into(), - "Échec de la connexion GitHub : {error} — {desc}".into(), - ); - strings.insert("github_api_error".into(), "Erreur GitHub: {error}".into()); - strings.insert("scan_error".into(), "Erreur: {error}".into()); - strings.insert( - "launch_error_msg".into(), - "Erreur lancement : {error}".into(), - ); - strings.insert( - "updates_available".into(), - "{count} mise(s) à jour disponible(s) : {names}".into(), - ); - - // Sidebar section names (localized) - strings.insert("section_all".into(), "Tout".into()); - strings.insert("section_favorites".into(), "Favoris".into()); - strings.insert("section_windows".into(), "Windows".into()); - strings.insert("section_linux".into(), "Linux".into()); - strings.insert("section_development".into(), "Développement".into()); - strings.insert("section_graphics".into(), "Graphisme".into()); - strings.insert("section_network".into(), "Réseau".into()); - strings.insert("section_office".into(), "Bureautique".into()); - strings.insert("section_multimedia".into(), "Multimédia".into()); - strings.insert("section_system".into(), "Système".into()); - strings.insert("section_utilities".into(), "Utilitaires".into()); - strings.insert("section_games".into(), "Jeux".into()); - strings.insert("section_other".into(), "Autre".into()); - - // Thread errors - strings.insert( - "error_thread_panic".into(), - "Erreur interne : le thread a paniqué".into(), - ); - - // Download cancellation - strings.insert("download_cancelled".into(), "Téléchargement annulé".into()); - - // Uninstall confirmation - strings.insert( - "confirm_uninstall".into(), - "Voulez-vous vraiment désinstaller « {name} » ? Cette action est irréversible." - .into(), - ); - strings.insert("cancel".into(), "Annuler".into()); - strings.insert("confirm_delete".into(), "Désinstaller".into()); - - // Favorites - strings.insert("add_favorite".into(), "Ajouter aux favoris".into()); - strings.insert("remove_favorite".into(), "Retirer des favoris".into()); - - // First launch — carousel (3 steps) - strings.insert("welcome_title".into(), "Bienvenue dans Colony".into()); - strings.insert("welcome_desc".into(), "Le lanceur centralisé de l'écosystème Project-Colony. Découvrez, installez et lancez vos apps en un clic.".into()); - // Step 1 — interface tour - strings.insert( - "welcome_step1_title".into(), - "L'interface en 3 zones".into(), - ); - strings.insert("welcome_step1_tip1_title".into(), "Sidebar".into()); - strings.insert( - "welcome_step1_tip1_desc".into(), - "Filtrez par catégorie ou origine (Colony / système).".into(), - ); - strings.insert("welcome_step1_tip2_title".into(), "Recherche".into()); - strings.insert( - "welcome_step1_tip2_desc".into(), - "Tapez le nom d'une app dans la barre en haut pour filtrer instantanément." - .into(), - ); - strings.insert("welcome_step1_tip3_title".into(), "Détail".into()); - strings.insert( - "welcome_step1_tip3_desc".into(), - "Cliquez une app pour lire le README, le changelog et l'installer.".into(), - ); - // Step 2 — GitHub + ready - strings.insert( - "welcome_step2_title".into(), - "Connectez GitHub (optionnel)".into(), - ); - strings.insert("welcome_step2_desc".into(), "Sans compte : 60 requêtes GitHub par heure. Avec compte : 5000/h + accès aux repos privés. Recommandé si vous comptez explorer beaucoup.".into()); - strings.insert( - "welcome_step2_hint1".into(), - "\u{f005} Favoris (⭐) pour un accès rapide".into(), - ); - strings.insert( - "welcome_step2_hint2".into(), - "\u{f53f} 24 familles de thèmes dans les préférences".into(), - ); - strings.insert( - "welcome_step2_hint3".into(), - "\u{f059} Consultez la FAQ et le tutoriel complet sur GitHub".into(), - ); - // Navigation - strings.insert("welcome_start".into(), "C'est parti !".into()); - strings.insert("welcome_next".into(), "Suivant".into()); - strings.insert("welcome_back".into(), "Retour".into()); - strings.insert("welcome_skip".into(), "Passer".into()); - strings.insert("welcome_connect_now".into(), "Connecter maintenant".into()); - strings.insert("welcome_later".into(), "Plus tard".into()); - - // Tutoriel guidé (spotlight sur l'UI réelle) - strings.insert("tut_sidebar_title".into(), "Les catégories".into()); - strings.insert("tut_sidebar_desc".into(), "Filtrez vos apps par type : jeux, outils, favoris, ou par origine (écosystème Colony vs. système). La barre latérale reste toujours visible.".into()); - strings.insert("tut_search_title".into(), "La recherche".into()); - strings.insert("tut_search_desc".into(), "Tapez le nom d'une app pour la retrouver instantanément, peu importe la catégorie sélectionnée.".into()); - strings.insert("tut_grid_title".into(), "Vos applications".into()); - strings.insert("tut_grid_desc".into(), "Voici toutes vos apps installées et les apps Colony disponibles. Cliquez une carte pour voir son README, son changelog et l'installer en un clic.".into()); - strings.insert( - "tut_github_title".into(), - "Connexion GitHub (optionnel)".into(), - ); - strings.insert("tut_github_desc".into(), "Sans compte : 60 requêtes/h. Avec compte : 5000/h + accès aux repos privés. Recommandé si vous explorez beaucoup. Le bouton Rescan juste en dessous relance l'analyse système.".into()); - strings.insert("tut_finish_title".into(), "Vous êtes prêt !".into()); - strings.insert("tut_finish_desc".into(), "L'icône d'engrenage à côté du titre ouvre les préférences : 24 familles de thèmes, raccourcis clavier, accessibilité. Bon voyage dans Colony !".into()); - - // Loading / async feedback - strings.insert("loading".into(), "Chargement...".into()); - strings.insert("scanning".into(), "Analyse en cours...".into()); - strings.insert( - "checking_updates".into(), - "Vérification des mises à jour...".into(), - ); - strings.insert( - "syncing_repos".into(), - "Synchronisation des dépôts...".into(), - ); - strings.insert( - "no_results_for".into(), - "Aucun résultat pour « {query} »".into(), - ); - strings.insert( - "n_results_found".into(), - "{count} résultat(s) pour « {query} »".into(), - ); - strings.insert("theme_applied".into(), "Thème appliqué.".into()); - - // Keyboard shortcuts - strings.insert("shortcuts_title".into(), "Raccourcis clavier".into()); - strings.insert( - "shortcut_esc".into(), - "Échap — Fermer le panneau actif".into(), - ); - strings.insert( - "shortcut_tab".into(), - "Tab / Maj+Tab — Naviguer entre les catégories".into(), - ); - strings.insert( - "shortcut_arrows".into(), - "↑ ↓ — Naviguer dans les paramètres".into(), - ); - strings.insert( - "shortcut_enter".into(), - "Entrée — Ouvrir le premier élément visible".into(), - ); - strings.insert( - "shortcut_pageupdown".into(), - "Page ↑/↓ — Naviguer plus vite dans les paramètres".into(), - ); - - // Tooltips / hints - strings.insert("hint_settings".into(), "Ouvrir les préférences".into()); - strings.insert( - "hint_search".into(), - "Tapez pour filtrer les applications".into(), - ); - strings.insert( - "hint_favorites".into(), - "Cliquez sur l'étoile pour ajouter aux favoris".into(), - ); - strings.insert( - "hint_keyboard".into(), - "Utilisez Tab et les flèches pour naviguer".into(), - ); - - // Settings - strings.insert("settings_title".into(), "Préférences".into()); - strings.insert("settings_close".into(), "Fermer".into()); - strings.insert("settings_cat_general".into(), "Général".into()); - strings.insert("settings_cat_appearance".into(), "Apparences".into()); - strings.insert("settings_cat_accessibility".into(), "Accessibilité".into()); - strings.insert("settings_cat_storage".into(), "Stockage".into()); - strings.insert("settings_cat_about".into(), "À propos".into()); - strings.insert("settings_cat_shortcuts".into(), "Raccourcis".into()); - // General - strings.insert( - "settings_general_title".into(), - "Paramètres généraux".into(), - ); - strings.insert( - "settings_general_desc".into(), - "Les préférences sont enregistrées automatiquement.".into(), - ); - // Startup - strings.insert("settings_section_startup".into(), "Démarrage".into()); - strings.insert( - "settings_startup_section_desc".into(), - "Gérez l'ouverture de Colony et la restauration des sessions.".into(), - ); - strings.insert( - "settings_restore_session".into(), - "Restaurer la dernière session".into(), - ); - strings.insert( - "settings_restore_session_desc".into(), - "Catégorie et écran affichés au dernier usage.".into(), - ); - strings.insert("settings_default_view".into(), "Ouvrir sur".into()); - strings.insert( - "settings_default_view_desc".into(), - "Choisissez l'écran par défaut.".into(), - ); - strings.insert("settings_default_view_all".into(), "Toutes".into()); - strings.insert("settings_default_view_favorites".into(), "Favoris".into()); - strings.insert("settings_default_view_recent".into(), "Récents".into()); - strings.insert( - "settings_close_behavior".into(), - "Comportement à la fermeture".into(), - ); - strings.insert( - "settings_close_behavior_desc".into(), - "Choisissez l'action à la fermeture.".into(), - ); - strings.insert("settings_close_quit".into(), "Quitter".into()); - strings.insert("settings_close_tray".into(), "Réduire dans la barre".into()); - // Language - strings.insert("settings_section_language".into(), "Langue".into()); - strings.insert( - "settings_language_desc".into(), - "Personnalisez l'interface et le format horaire.".into(), - ); - strings.insert( - "settings_current_language".into(), - "Langue de l'interface".into(), - ); - strings.insert( - "settings_current_language_desc".into(), - "Synchronisée avec le système.".into(), - ); - strings.insert("settings_time_format".into(), "Format horaire".into()); - strings.insert( - "settings_time_format_desc".into(), - "Format utilisé dans l'application.".into(), - ); - // Updates - strings.insert("settings_section_updates".into(), "Mises à jour".into()); - strings.insert( - "settings_updates_desc".into(), - "Gérez la vérification et le canal des mises à jour.".into(), - ); - strings.insert( - "settings_auto_check_updates".into(), - "Vérifier automatiquement".into(), - ); - strings.insert( - "settings_auto_check_updates_desc".into(), - "Vérifie les nouvelles versions au lancement.".into(), - ); - strings.insert("settings_update_channel".into(), "Canal".into()); - strings.insert( - "settings_update_channel_desc".into(), - "Choisissez la stabilité des versions.".into(), - ); - strings.insert( - "settings_auto_install_updates".into(), - "Installer automatiquement".into(), - ); - strings.insert( - "settings_auto_install_updates_desc".into(), - "Installe les mises à jour en arrière-plan.".into(), - ); - strings.insert( - "settings_check_updates".into(), - "Vérifier les mises à jour".into(), - ); - // Privacy - strings.insert("settings_section_privacy".into(), "Confidentialité".into()); - strings.insert( - "settings_privacy_desc".into(), - "Choisissez les données partagées avec Colony.".into(), - ); - strings.insert( - "settings_error_reports".into(), - "Envoyer des rapports d'erreurs".into(), - ); - strings.insert( - "settings_error_reports_desc".into(), - "Permet d'améliorer la stabilité.".into(), - ); - strings.insert( - "settings_usage_stats".into(), - "Statistiques anonymes d'utilisation".into(), - ); - strings.insert( - "settings_usage_stats_desc".into(), - "Aide à comprendre l'usage de Colony.".into(), - ); - // Appearance - strings.insert( - "settings_appearance_title".into(), - "Paramètres d'apparence".into(), - ); - strings.insert( - "settings_appearance_desc".into(), - "Ajustez le thème, les accents et les effets visuels.".into(), - ); - strings.insert("settings_section_theme".into(), "Thème".into()); - strings.insert( - "settings_theme_desc".into(), - "Choisissez le thème de l'interface.".into(), - ); - strings.insert("settings_theme_current".into(), "Thème actuel".into()); - strings.insert( - "settings_theme_current_desc".into(), - "Apparence globale de l'application.".into(), - ); - strings.insert("settings_theme_dark".into(), "Sombre".into()); - // Theme families - strings.insert("settings_theme_catppuccin".into(), "Catppuccin".into()); - strings.insert("settings_theme_catppuccin_latte".into(), "Latte".into()); - strings.insert("settings_theme_catppuccin_frappe".into(), "Frappé".into()); - strings.insert( - "settings_theme_catppuccin_macchiato".into(), - "Macchiato".into(), - ); - strings.insert("settings_theme_catppuccin_mocha".into(), "Mocha".into()); - strings.insert("settings_theme_gruvbox".into(), "Gruvbox".into()); - strings.insert("settings_theme_light".into(), "Mode clair".into()); - strings.insert("settings_theme_dark_mode".into(), "Mode sombre".into()); - strings.insert("settings_theme_everblush".into(), "Everblush".into()); - strings.insert("settings_theme_kanagawa".into(), "Kanagawa".into()); - strings.insert( - "settings_theme_kanagawa_journal".into(), - "Mode journal".into(), - ); - // New theme families - strings.insert("settings_theme_nord".into(), "Nord".into()); - strings.insert("settings_theme_dracula".into(), "Dracula".into()); - strings.insert("settings_theme_solarized".into(), "Solarized".into()); - strings.insert("settings_theme_tokyonight".into(), "Tokyo Night".into()); - strings.insert("settings_theme_tokyonight_night".into(), "Nuit".into()); - strings.insert("settings_theme_tokyonight_day".into(), "Jour".into()); - strings.insert("settings_theme_rosepine".into(), "Rosé Pine".into()); - strings.insert("settings_theme_rosepine_main".into(), "Principal".into()); - strings.insert("settings_theme_rosepine_moon".into(), "Lune".into()); - strings.insert("settings_theme_rosepine_dawn".into(), "Aurore".into()); - strings.insert("settings_theme_onedark".into(), "One Dark".into()); - strings.insert("settings_theme_monokai".into(), "Monokai Pro".into()); - strings.insert("settings_theme_monokai_pro".into(), "Pro".into()); - strings.insert("settings_theme_monokai_classic".into(), "Classic".into()); - strings.insert("settings_theme_monokai_spectrum".into(), "Spectrum".into()); - strings.insert("settings_theme_ayu".into(), "Ayu".into()); - strings.insert("settings_theme_ayu_mirage".into(), "Mirage".into()); - strings.insert("settings_theme_everforest".into(), "Everforest".into()); - strings.insert("settings_theme_material".into(), "Material".into()); - strings.insert("settings_theme_material_oceanic".into(), "Oceanic".into()); - strings.insert( - "settings_theme_material_palenight".into(), - "Palenight".into(), - ); - strings.insert( - "settings_theme_material_deepocean".into(), - "Deep Ocean".into(), - ); - strings.insert("settings_theme_flexoki".into(), "Flexoki".into()); - strings.insert("settings_theme_nightfox".into(), "Nightfox".into()); - strings.insert("settings_theme_nightfox_nightfox".into(), "Nightfox".into()); - strings.insert("settings_theme_nightfox_dawnfox".into(), "Dawnfox".into()); - strings.insert("settings_theme_sonokai".into(), "Sonokai".into()); - strings.insert("settings_theme_sonokai_default".into(), "Défaut".into()); - strings.insert("settings_theme_oxocarbon".into(), "Oxocarbon".into()); - strings.insert("settings_theme_nightowl".into(), "Night Owl".into()); - strings.insert("settings_theme_iceberg".into(), "Iceberg".into()); - strings.insert("settings_theme_horizon".into(), "Horizon".into()); - strings.insert("settings_theme_melange".into(), "Mélange".into()); - strings.insert("settings_theme_synthwave".into(), "Synthwave '84".into()); - strings.insert("settings_theme_modus".into(), "Modus".into()); - strings.insert("settings_theme_modus_operandi".into(), "Operandi".into()); - strings.insert("settings_theme_modus_vivendi".into(), "Vivendi".into()); - strings.insert( - "settings_theme_stellar_blade".into(), - "Stellar Blade".into(), - ); - strings.insert("settings_theme_stellar_blade_eve".into(), "EVE".into()); - strings.insert("settings_theme_stellar_blade_tachy".into(), "Tachy".into()); - strings.insert("settings_theme_stellar_blade_lily".into(), "Lily".into()); - strings.insert("settings_theme_stellar_blade_enya".into(), "Enya".into()); - strings.insert("settings_theme_stellar_blade_kaya".into(), "Kaya".into()); - // Colors & accents - strings.insert( - "settings_section_colors".into(), - "Couleurs & accents".into(), - ); - strings.insert( - "settings_colors_desc".into(), - "Personnalisez la couleur d'accent de l'interface.".into(), - ); - strings.insert("settings_accent_color".into(), "Couleur d'accent".into()); - strings.insert( - "settings_accent_color_desc".into(), - "Couleur utilisée pour les éléments interactifs.".into(), - ); - strings.insert("settings_accent_red".into(), "Rouge".into()); - strings.insert("settings_accent_orange".into(), "Orange".into()); - strings.insert("settings_accent_yellow".into(), "Jaune".into()); - strings.insert("settings_accent_green".into(), "Vert".into()); - strings.insert("settings_accent_blue".into(), "Bleu".into()); - strings.insert("settings_accent_indigo".into(), "Indigo".into()); - strings.insert("settings_accent_violet".into(), "Violet".into()); - strings.insert("settings_accent_amber".into(), "Ambre".into()); - strings.insert( - "settings_auto_accent".into(), - "Accent automatique selon le fond".into(), - ); - strings.insert( - "settings_auto_accent_desc".into(), - "Adapte automatiquement l'accent aux arrière-plans.".into(), - ); - strings.insert("settings_enabled_label".into(), "Activé".into()); - strings.insert("settings_disabled_label".into(), "Désactivé".into()); - strings.insert("settings_section_typography".into(), "Typographie".into()); - strings.insert( - "settings_typography_desc".into(), - "Configurez la police et la taille du texte.".into(), - ); - strings.insert("settings_font".into(), "Police".into()); - strings.insert( - "settings_font_desc".into(), - "Police utilisée dans l'interface.".into(), - ); - strings.insert("settings_font_size".into(), "Taille du texte".into()); - strings.insert( - "settings_font_size_desc".into(), - "Taille de base du texte.".into(), - ); - strings.insert("settings_font_size_default".into(), "Par défaut".into()); - strings.insert("settings_font_size_small".into(), "Petit".into()); - strings.insert("settings_font_size_large".into(), "Grand".into()); - strings.insert("settings_font_size_xlarge".into(), "Très grand".into()); - strings.insert( - "settings_section_effects".into(), - "Arrière-plans & effets".into(), - ); - strings.insert( - "settings_effects_desc".into(), - "Gérez les animations et effets visuels.".into(), - ); - strings.insert("settings_animations".into(), "Animations".into()); - strings.insert( - "settings_animations_desc".into(), - "Activer les transitions animées.".into(), - ); - strings.insert("settings_section_preview".into(), "Aperçu".into()); - strings.insert( - "settings_preview_card".into(), - "Carte de prévisualisation".into(), - ); - strings.insert( - "settings_preview_summary".into(), - "Thème: Sombre · Accent: Bleu · Texte: Par défaut · Effets: Activés".into(), - ); - // Accessibility - strings.insert( - "settings_accessibility_title".into(), - "Paramètres d'accessibilité".into(), - ); - strings.insert( - "settings_accessibility_desc".into(), - "Facilitez la lecture, la navigation et la lecture média.".into(), - ); - strings.insert("settings_section_vision".into(), "Vision".into()); - strings.insert( - "settings_vision_desc".into(), - "Options pour améliorer la lisibilité.".into(), - ); - strings.insert("settings_high_contrast".into(), "Contraste élevé".into()); - strings.insert( - "settings_high_contrast_desc".into(), - "Augmente le contraste des éléments.".into(), - ); - strings.insert("settings_disabled".into(), "Désactivé".into()); - strings.insert("settings_text_size_a11y".into(), "Taille du texte".into()); - strings.insert( - "settings_text_size_a11y_desc".into(), - "Ajustez la taille du texte pour le confort.".into(), - ); - strings.insert("settings_section_motion".into(), "Mouvement".into()); - strings.insert( - "settings_motion_desc".into(), - "Réduisez les animations pour le confort.".into(), - ); - strings.insert( - "settings_reduce_motion".into(), - "Réduire les animations".into(), - ); - strings.insert( - "settings_reduce_motion_desc".into(), - "Limite les transitions et mouvements.".into(), - ); - strings.insert( - "settings_section_navigation".into(), - "Navigation & interaction".into(), - ); - strings.insert( - "settings_navigation_desc".into(), - "Options de navigation au clavier et interaction.".into(), - ); - strings.insert("settings_keyboard_nav".into(), "Navigation clavier".into()); - strings.insert( - "settings_keyboard_nav_desc".into(), - "Naviguer avec Tab et les flèches.".into(), - ); - strings.insert("settings_section_reading".into(), "Lecture".into()); - strings.insert( - "settings_reading_desc".into(), - "Options de confort de lecture.".into(), - ); - strings.insert("settings_dyslexia_font".into(), "Police dyslexie".into()); - strings.insert( - "settings_dyslexia_font_desc".into(), - "Utiliser une police adaptée à la dyslexie.".into(), - ); - // Storage - strings.insert("settings_storage_title".into(), "Stockage".into()); - strings.insert( - "settings_storage_desc".into(), - "Gérez l'emplacement des applications et du cache.".into(), - ); - strings.insert("settings_section_scan".into(), "Scan".into()); - strings.insert( - "settings_scan_desc".into(), - "Configurez les dossiers analysés au démarrage.".into(), - ); - strings.insert("settings_scan_dirs".into(), "Dossiers de scan".into()); - strings.insert( - "settings_scan_dirs_desc".into(), - "Répertoires analysés pour les applications.".into(), - ); - strings.insert("settings_scan_dirs_value".into(), "Par défaut".into()); - strings.insert("settings_startup".into(), "Scanner au démarrage".into()); - strings.insert( - "settings_startup_desc".into(), - "Met à jour la bibliothèque au démarrage.".into(), - ); - strings.insert("settings_enabled".into(), "Activé".into()); - strings.insert("settings_section_install".into(), "Installation".into()); - strings.insert("settings_local_apps".into(), "Applications locales".into()); - strings.insert("settings_colony_repos".into(), "Dépôts Colony".into()); - strings.insert("settings_favorites".into(), "Favoris".into()); - // Placeholders - strings.insert("settings_coming_soon".into(), "Bientôt".into()); - // About - strings.insert("settings_about_title".into(), "À propos de Colony".into()); - strings.insert("settings_about".into(), "À propos".into()); - strings.insert("settings_version".into(), "Colony v0.1.0".into()); - // Launcher self-update - strings.insert( - "launcher_update_available".into(), - "Colony {version} est disponible !".into(), - ); - strings.insert( - "launcher_update_available_short".into(), - "\u{f0aa} Mise à jour {version}".into(), - ); - strings.insert( - "launcher_update_ready".into(), - "Mise à jour prête. Cliquez pour relancer Colony.".into(), - ); - strings.insert( - "launcher_restart_to_update".into(), - "\u{f021} Relancer pour mettre à jour".into(), - ); - strings.insert( - "launcher_download_update".into(), - "Télécharger la mise à jour {version}".into(), - ); - strings.insert( - "launcher_update_failed".into(), - "Échec de la mise à jour : {error}".into(), - ); - strings.insert( - "check_launcher_updates".into(), - "Vérifier les mises à jour".into(), - ); - strings.insert("launcher_up_to_date".into(), "Colony est à jour".into()); - strings.insert("update_all".into(), "Tout mettre à jour ({count})".into()); - strings.insert("whats_new".into(), "Nouveautés de {version}".into()); - 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("clear_caches".into(), "Vider les caches du store".into()); - strings.insert("clear_caches_desc".into(), "Supprime les descriptions et icônes mises en cache (elles se re-téléchargent au prochain rafraîchissement). Les applications installées ne sont pas touchées.".into()); - strings.insert( - "caches_cleared".into(), - "{count} cache(s) supprimé(s)".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()); - strings.insert("tab_license".into(), "License".into()); - strings.insert("tab_changelog".into(), "Changelog".into()); - strings.insert("tab_loading".into(), "Chargement...".into()); - strings.insert("tab_not_available".into(), "Non disponible".into()); - } - _ => { - // English (default) - // Sidebar - strings.insert("categories".into(), "Categories".into()); - strings.insert("rescan".into(), "Rescan".into()); - - // GitHub panel - strings.insert("github_connect_desc".into(), "Connect to GitHub to detect Colony repos (colony.json) from the Project-Colony organization.".into()); - strings.insert("github_login".into(), "Sign in with GitHub".into()); - strings.insert( - "github_public_api".into(), - "Not connected: Public GitHub API (60 req/h)".into(), - ); - strings.insert( - "github_rate_limit".into(), - "GitHub rate limit reached. Retry in {wait} seconds.".into(), - ); - strings.insert( - "github_enter_code".into(), - "Enter this code on GitHub:".into(), - ); - strings.insert( - "github_copy_hint".into(), - "Click to copy — Waiting for authorization...".into(), - ); - strings.insert("github_connecting".into(), "Connecting...".into()); - strings.insert("github_connected".into(), "Connected".into()); - strings.insert( - "github_repos_detected".into(), - "{count} Colony repos detected".into(), - ); - strings.insert( - "github_no_repos".into(), - "No repos with colony.json found.".into(), - ); - strings.insert("github_refresh".into(), "Refresh repos".into()); - strings.insert("github_logout".into(), "Sign out".into()); - strings.insert("github_error".into(), "Error: {error}".into()); - strings.insert("github_retry".into(), "Retry".into()); - strings.insert( - "github_disconnected".into(), - "Disconnected from GitHub".into(), - ); - - // App grid - strings.insert("no_apps_found".into(), "No applications found".into()); - strings.insert("search_placeholder".into(), "Search applications...".into()); - strings.insert("status_installed".into(), "Installed".into()); - strings.insert("status_get".into(), "Get".into()); - strings.insert("status_update".into(), "Update".into()); - - // Detail view - strings.insert("back".into(), "Back".into()); - strings.insert("language_label".into(), "Language: {lang}".into()); - strings.insert("launch".into(), "Launch {name}".into()); - strings.insert("update".into(), "Update".into()); - strings.insert("download".into(), "Download".into()); - strings.insert("no_release".into(), "No release available".into()); - strings.insert( - "no_release_platform".into(), - "Not available for your platform".into(), - ); - - // Status messages - strings.insert("apps_found".into(), "{count} applications found".into()); - strings.insert("app_launched".into(), "Application launched.".into()); - strings.insert("installed".into(), "Installed: {path}".into()); - strings.insert("download_error".into(), "Download error: {error}".into()); - strings.insert("downloading".into(), "Downloading {file}…".into()); - strings.insert("no_release_for".into(), "No release for {platform}".into()); - strings.insert("uninstalled".into(), "{name} uninstalled.".into()); - strings.insert("launch_error".into(), "Cannot launch: {error}".into()); - strings.insert( - "launch_error_empty".into(), - "Cannot launch: empty command".into(), - ); - strings.insert("uninstall_error".into(), "Uninstall error: {error}".into()); - - // OAuth errors - strings.insert("oauth_error".into(), "OAuth error: {error}".into()); - strings.insert( - "oauth_device_expired".into(), - "Timed out: GitHub authorization was not confirmed in time.".into(), - ); - strings.insert( - "oauth_device_failed".into(), - "GitHub sign-in failed: {error} — {desc}".into(), - ); - strings.insert("github_api_error".into(), "GitHub error: {error}".into()); - strings.insert("scan_error".into(), "Error: {error}".into()); - strings.insert("launch_error_msg".into(), "Launch error: {error}".into()); - strings.insert( - "updates_available".into(), - "{count} update(s) available: {names}".into(), - ); - - // Sidebar section names (localized) - strings.insert("section_all".into(), "All".into()); - strings.insert("section_favorites".into(), "Favorites".into()); - strings.insert("section_windows".into(), "Windows".into()); - strings.insert("section_linux".into(), "Linux".into()); - strings.insert("section_development".into(), "Development".into()); - strings.insert("section_graphics".into(), "Graphics".into()); - strings.insert("section_network".into(), "Network".into()); - strings.insert("section_office".into(), "Office".into()); - strings.insert("section_multimedia".into(), "Multimedia".into()); - strings.insert("section_system".into(), "System".into()); - strings.insert("section_utilities".into(), "Utilities".into()); - strings.insert("section_games".into(), "Games".into()); - strings.insert("section_other".into(), "Other".into()); - - // Thread errors - strings.insert( - "error_thread_panic".into(), - "Internal error: background thread panicked".into(), - ); - - // Download cancellation - strings.insert("download_cancelled".into(), "Download cancelled".into()); - - // Uninstall confirmation - strings.insert( - "confirm_uninstall".into(), - "Are you sure you want to uninstall \"{name}\"? This action cannot be undone." - .into(), - ); - strings.insert("cancel".into(), "Cancel".into()); - strings.insert("confirm_delete".into(), "Uninstall".into()); - - // Favorites - strings.insert("add_favorite".into(), "Add to favorites".into()); - strings.insert("remove_favorite".into(), "Remove from favorites".into()); - - // First launch — carousel (3 steps) - strings.insert("welcome_title".into(), "Welcome to Colony".into()); - strings.insert("welcome_desc".into(), "The centralized launcher for the Project-Colony ecosystem. Discover, install and launch apps in one click.".into()); - // Step 1 — interface tour - strings.insert( - "welcome_step1_title".into(), - "The interface, in 3 zones".into(), - ); - strings.insert("welcome_step1_tip1_title".into(), "Sidebar".into()); - strings.insert( - "welcome_step1_tip1_desc".into(), - "Filter by category or origin (Colony / system apps).".into(), - ); - strings.insert("welcome_step1_tip2_title".into(), "Search".into()); - strings.insert( - "welcome_step1_tip2_desc".into(), - "Type an app name in the top bar to filter instantly.".into(), - ); - strings.insert("welcome_step1_tip3_title".into(), "Detail".into()); - strings.insert( - "welcome_step1_tip3_desc".into(), - "Click any app to read its README, changelog and install it.".into(), - ); - // Step 2 — GitHub + ready - strings.insert( - "welcome_step2_title".into(), - "Connect GitHub (optional)".into(), - ); - strings.insert("welcome_step2_desc".into(), "Without an account: 60 GitHub requests per hour. With an account: 5000/h + access to your private repos. Recommended if you plan to browse a lot.".into()); - strings.insert( - "welcome_step2_hint1".into(), - "\u{f005} Favorites (⭐) for quick access".into(), - ); - strings.insert( - "welcome_step2_hint2".into(), - "\u{f53f} 24 theme families in the preferences".into(), - ); - strings.insert( - "welcome_step2_hint3".into(), - "\u{f059} Full tutorial + FAQ on GitHub".into(), - ); - // Navigation - strings.insert("welcome_start".into(), "Let's go!".into()); - strings.insert("welcome_next".into(), "Next".into()); - strings.insert("welcome_back".into(), "Back".into()); - strings.insert("welcome_skip".into(), "Skip".into()); - strings.insert("welcome_connect_now".into(), "Connect now".into()); - strings.insert("welcome_later".into(), "Later".into()); - - // Guided tutorial (spotlight over real UI) - strings.insert("tut_sidebar_title".into(), "Categories".into()); - strings.insert("tut_sidebar_desc".into(), "Filter your apps by type — games, tools, favorites — or by origin (Colony ecosystem vs. system). The sidebar stays visible at all times.".into()); - strings.insert("tut_search_title".into(), "Search".into()); - strings.insert("tut_search_desc".into(), "Type an app name here to find it instantly, regardless of the selected category.".into()); - strings.insert("tut_grid_title".into(), "Your applications".into()); - strings.insert("tut_grid_desc".into(), "These are your installed apps plus the Colony apps available to install. Click a card to read its README, changelog and install in one click.".into()); - strings.insert( - "tut_github_title".into(), - "GitHub connection (optional)".into(), - ); - strings.insert("tut_github_desc".into(), "Without an account: 60 requests/h. With one: 5000/h + private repo access. Recommended if you plan to browse a lot. The Rescan button below refreshes the system scan.".into()); - strings.insert("tut_finish_title".into(), "You're all set!".into()); - strings.insert("tut_finish_desc".into(), "The gear icon next to the title opens Preferences: 24 theme families, keyboard shortcuts, accessibility. Enjoy Colony!".into()); - - // Loading / async feedback - strings.insert("loading".into(), "Loading...".into()); - strings.insert("scanning".into(), "Scanning...".into()); - strings.insert("checking_updates".into(), "Checking for updates...".into()); - strings.insert("syncing_repos".into(), "Syncing repositories...".into()); - strings.insert("no_results_for".into(), "No results for \"{query}\"".into()); - strings.insert( - "n_results_found".into(), - "{count} result(s) for \"{query}\"".into(), - ); - strings.insert("theme_applied".into(), "Theme applied.".into()); - - // Keyboard shortcuts - strings.insert("shortcuts_title".into(), "Keyboard shortcuts".into()); - strings.insert("shortcut_esc".into(), "Esc — Close active panel".into()); - strings.insert( - "shortcut_tab".into(), - "Tab / Shift+Tab — Navigate categories".into(), - ); - strings.insert("shortcut_arrows".into(), "↑ ↓ — Navigate settings".into()); - strings.insert( - "shortcut_enter".into(), - "Enter — Open first visible item".into(), - ); - strings.insert( - "shortcut_pageupdown".into(), - "Page Up/Down — Fast navigation in settings".into(), - ); - - // Tooltips / hints - strings.insert("hint_settings".into(), "Open preferences".into()); - strings.insert("hint_search".into(), "Type to filter applications".into()); - strings.insert( - "hint_favorites".into(), - "Click the star to add to favorites".into(), - ); - strings.insert( - "hint_keyboard".into(), - "Use Tab and arrow keys to navigate".into(), - ); - - // Settings - strings.insert("settings_title".into(), "Preferences".into()); - strings.insert("settings_close".into(), "Close".into()); - strings.insert("settings_cat_general".into(), "General".into()); - strings.insert("settings_cat_appearance".into(), "Appearance".into()); - strings.insert("settings_cat_accessibility".into(), "Accessibility".into()); - strings.insert("settings_cat_storage".into(), "Storage".into()); - strings.insert("settings_cat_about".into(), "About".into()); - strings.insert("settings_cat_shortcuts".into(), "Shortcuts".into()); - // General - strings.insert("settings_general_title".into(), "General settings".into()); - strings.insert( - "settings_general_desc".into(), - "Preferences are saved automatically.".into(), - ); - // Startup - strings.insert("settings_section_startup".into(), "Startup".into()); - strings.insert( - "settings_startup_section_desc".into(), - "Manage Colony startup and session restoration.".into(), - ); - strings.insert( - "settings_restore_session".into(), - "Restore last session".into(), - ); - strings.insert( - "settings_restore_session_desc".into(), - "Category and screen from last usage.".into(), - ); - strings.insert("settings_default_view".into(), "Open on".into()); - strings.insert( - "settings_default_view_desc".into(), - "Choose the default screen.".into(), - ); - strings.insert("settings_default_view_all".into(), "All".into()); - strings.insert("settings_default_view_favorites".into(), "Favorites".into()); - strings.insert("settings_default_view_recent".into(), "Recent".into()); - strings.insert("settings_close_behavior".into(), "Close behavior".into()); - strings.insert( - "settings_close_behavior_desc".into(), - "Choose action on close.".into(), - ); - strings.insert("settings_close_quit".into(), "Quit".into()); - strings.insert("settings_close_tray".into(), "Minimize to tray".into()); - // Language - strings.insert("settings_section_language".into(), "Language".into()); - strings.insert( - "settings_language_desc".into(), - "Customize the interface and time format.".into(), - ); - strings.insert( - "settings_current_language".into(), - "Interface language".into(), - ); - strings.insert( - "settings_current_language_desc".into(), - "Synced with system.".into(), - ); - strings.insert("settings_time_format".into(), "Time format".into()); - strings.insert( - "settings_time_format_desc".into(), - "Format used in the application.".into(), - ); - // Updates - strings.insert("settings_section_updates".into(), "Updates".into()); - strings.insert( - "settings_updates_desc".into(), - "Manage update checking and channel.".into(), - ); - strings.insert( - "settings_auto_check_updates".into(), - "Check automatically".into(), - ); - strings.insert( - "settings_auto_check_updates_desc".into(), - "Check for new versions on launch.".into(), - ); - strings.insert("settings_update_channel".into(), "Channel".into()); - strings.insert( - "settings_update_channel_desc".into(), - "Choose version stability.".into(), - ); - strings.insert( - "settings_auto_install_updates".into(), - "Install automatically".into(), - ); - strings.insert( - "settings_auto_install_updates_desc".into(), - "Install updates in background.".into(), - ); - strings.insert("settings_check_updates".into(), "Check for updates".into()); - // Privacy - strings.insert("settings_section_privacy".into(), "Privacy".into()); - strings.insert( - "settings_privacy_desc".into(), - "Choose data shared with Colony.".into(), - ); - strings.insert("settings_error_reports".into(), "Send error reports".into()); - strings.insert( - "settings_error_reports_desc".into(), - "Helps improve stability.".into(), - ); - strings.insert( - "settings_usage_stats".into(), - "Anonymous usage statistics".into(), - ); - strings.insert( - "settings_usage_stats_desc".into(), - "Helps understand Colony usage.".into(), - ); - // Appearance - strings.insert( - "settings_appearance_title".into(), - "Appearance settings".into(), - ); - strings.insert( - "settings_appearance_desc".into(), - "Adjust theme, accents and visual effects.".into(), - ); - strings.insert("settings_section_theme".into(), "Theme".into()); - strings.insert( - "settings_theme_desc".into(), - "Choose the interface theme.".into(), - ); - strings.insert("settings_theme_current".into(), "Current theme".into()); - strings.insert( - "settings_theme_current_desc".into(), - "Overall application appearance.".into(), - ); - strings.insert("settings_theme_dark".into(), "Dark".into()); - // Theme families - strings.insert("settings_theme_catppuccin".into(), "Catppuccin".into()); - strings.insert("settings_theme_catppuccin_latte".into(), "Latte".into()); - strings.insert("settings_theme_catppuccin_frappe".into(), "Frappé".into()); - strings.insert( - "settings_theme_catppuccin_macchiato".into(), - "Macchiato".into(), - ); - strings.insert("settings_theme_catppuccin_mocha".into(), "Mocha".into()); - strings.insert("settings_theme_gruvbox".into(), "Gruvbox".into()); - strings.insert("settings_theme_light".into(), "Light mode".into()); - strings.insert("settings_theme_dark_mode".into(), "Dark mode".into()); - strings.insert("settings_theme_everblush".into(), "Everblush".into()); - strings.insert("settings_theme_kanagawa".into(), "Kanagawa".into()); - strings.insert( - "settings_theme_kanagawa_journal".into(), - "Journal mode".into(), - ); - // New theme families - strings.insert("settings_theme_nord".into(), "Nord".into()); - strings.insert("settings_theme_dracula".into(), "Dracula".into()); - strings.insert("settings_theme_solarized".into(), "Solarized".into()); - strings.insert("settings_theme_tokyonight".into(), "Tokyo Night".into()); - strings.insert("settings_theme_tokyonight_night".into(), "Night".into()); - strings.insert("settings_theme_tokyonight_day".into(), "Day".into()); - strings.insert("settings_theme_rosepine".into(), "Rosé Pine".into()); - strings.insert("settings_theme_rosepine_main".into(), "Main".into()); - strings.insert("settings_theme_rosepine_moon".into(), "Moon".into()); - strings.insert("settings_theme_rosepine_dawn".into(), "Dawn".into()); - strings.insert("settings_theme_onedark".into(), "One Dark".into()); - strings.insert("settings_theme_monokai".into(), "Monokai Pro".into()); - strings.insert("settings_theme_monokai_pro".into(), "Pro".into()); - strings.insert("settings_theme_monokai_classic".into(), "Classic".into()); - strings.insert("settings_theme_monokai_spectrum".into(), "Spectrum".into()); - strings.insert("settings_theme_ayu".into(), "Ayu".into()); - strings.insert("settings_theme_ayu_mirage".into(), "Mirage".into()); - strings.insert("settings_theme_everforest".into(), "Everforest".into()); - strings.insert("settings_theme_material".into(), "Material".into()); - strings.insert("settings_theme_material_oceanic".into(), "Oceanic".into()); - strings.insert( - "settings_theme_material_palenight".into(), - "Palenight".into(), - ); - strings.insert( - "settings_theme_material_deepocean".into(), - "Deep Ocean".into(), - ); - strings.insert("settings_theme_flexoki".into(), "Flexoki".into()); - strings.insert("settings_theme_nightfox".into(), "Nightfox".into()); - strings.insert("settings_theme_nightfox_nightfox".into(), "Nightfox".into()); - strings.insert("settings_theme_nightfox_dawnfox".into(), "Dawnfox".into()); - strings.insert("settings_theme_sonokai".into(), "Sonokai".into()); - strings.insert("settings_theme_sonokai_default".into(), "Default".into()); - strings.insert("settings_theme_oxocarbon".into(), "Oxocarbon".into()); - strings.insert("settings_theme_nightowl".into(), "Night Owl".into()); - strings.insert("settings_theme_iceberg".into(), "Iceberg".into()); - strings.insert("settings_theme_horizon".into(), "Horizon".into()); - strings.insert("settings_theme_melange".into(), "Melange".into()); - strings.insert("settings_theme_synthwave".into(), "Synthwave '84".into()); - strings.insert("settings_theme_modus".into(), "Modus".into()); - strings.insert("settings_theme_modus_operandi".into(), "Operandi".into()); - strings.insert("settings_theme_modus_vivendi".into(), "Vivendi".into()); - strings.insert( - "settings_theme_stellar_blade".into(), - "Stellar Blade".into(), - ); - strings.insert("settings_theme_stellar_blade_eve".into(), "EVE".into()); - strings.insert("settings_theme_stellar_blade_tachy".into(), "Tachy".into()); - strings.insert("settings_theme_stellar_blade_lily".into(), "Lily".into()); - strings.insert("settings_theme_stellar_blade_enya".into(), "Enya".into()); - strings.insert("settings_theme_stellar_blade_kaya".into(), "Kaya".into()); - // Colors & accents - strings.insert("settings_section_colors".into(), "Colors & accents".into()); - strings.insert( - "settings_colors_desc".into(), - "Customize the interface accent color.".into(), - ); - strings.insert("settings_accent_color".into(), "Accent color".into()); - strings.insert( - "settings_accent_color_desc".into(), - "Color used for interactive elements.".into(), - ); - strings.insert("settings_accent_red".into(), "Red".into()); - strings.insert("settings_accent_orange".into(), "Orange".into()); - strings.insert("settings_accent_yellow".into(), "Yellow".into()); - strings.insert("settings_accent_green".into(), "Green".into()); - strings.insert("settings_accent_blue".into(), "Blue".into()); - strings.insert("settings_accent_indigo".into(), "Indigo".into()); - strings.insert("settings_accent_violet".into(), "Violet".into()); - strings.insert("settings_accent_amber".into(), "Amber".into()); - strings.insert( - "settings_auto_accent".into(), - "Auto accent from background".into(), - ); - strings.insert( - "settings_auto_accent_desc".into(), - "Automatically adapts accent to backgrounds.".into(), - ); - strings.insert("settings_enabled_label".into(), "Enabled".into()); - strings.insert("settings_disabled_label".into(), "Disabled".into()); - strings.insert("settings_section_typography".into(), "Typography".into()); - strings.insert( - "settings_typography_desc".into(), - "Configure font and text size.".into(), - ); - strings.insert("settings_font".into(), "Font".into()); - strings.insert( - "settings_font_desc".into(), - "Font used in the interface.".into(), - ); - strings.insert("settings_font_size".into(), "Text size".into()); - strings.insert("settings_font_size_desc".into(), "Base text size.".into()); - strings.insert("settings_font_size_default".into(), "Default".into()); - strings.insert("settings_font_size_small".into(), "Small".into()); - strings.insert("settings_font_size_large".into(), "Large".into()); - strings.insert("settings_font_size_xlarge".into(), "Extra large".into()); - strings.insert( - "settings_section_effects".into(), - "Backgrounds & effects".into(), - ); - strings.insert( - "settings_effects_desc".into(), - "Manage animations and visual effects.".into(), - ); - strings.insert("settings_animations".into(), "Animations".into()); - strings.insert( - "settings_animations_desc".into(), - "Enable animated transitions.".into(), - ); - strings.insert("settings_section_preview".into(), "Preview".into()); - strings.insert("settings_preview_card".into(), "Preview card".into()); - strings.insert( - "settings_preview_summary".into(), - "Theme: Dark · Accent: Blue · Text: Default · Effects: Enabled".into(), - ); - // Accessibility - strings.insert( - "settings_accessibility_title".into(), - "Accessibility settings".into(), - ); - strings.insert( - "settings_accessibility_desc".into(), - "Improve reading, navigation and media playback.".into(), - ); - strings.insert("settings_section_vision".into(), "Vision".into()); - strings.insert( - "settings_vision_desc".into(), - "Options to improve readability.".into(), - ); - strings.insert("settings_high_contrast".into(), "High contrast".into()); - strings.insert( - "settings_high_contrast_desc".into(), - "Increase contrast of elements.".into(), - ); - strings.insert("settings_disabled".into(), "Disabled".into()); - strings.insert("settings_text_size_a11y".into(), "Text size".into()); - strings.insert( - "settings_text_size_a11y_desc".into(), - "Adjust text size for comfort.".into(), - ); - strings.insert("settings_section_motion".into(), "Motion".into()); - strings.insert( - "settings_motion_desc".into(), - "Reduce animations for comfort.".into(), - ); - strings.insert("settings_reduce_motion".into(), "Reduce motion".into()); - strings.insert( - "settings_reduce_motion_desc".into(), - "Limit transitions and movements.".into(), - ); - strings.insert( - "settings_section_navigation".into(), - "Navigation & interaction".into(), - ); - strings.insert( - "settings_navigation_desc".into(), - "Keyboard navigation and interaction options.".into(), - ); - strings.insert("settings_keyboard_nav".into(), "Keyboard navigation".into()); - strings.insert( - "settings_keyboard_nav_desc".into(), - "Navigate with Tab and arrow keys.".into(), - ); - strings.insert("settings_section_reading".into(), "Reading".into()); - strings.insert( - "settings_reading_desc".into(), - "Reading comfort options.".into(), - ); - strings.insert("settings_dyslexia_font".into(), "Dyslexia font".into()); - strings.insert( - "settings_dyslexia_font_desc".into(), - "Use a font adapted for dyslexia.".into(), - ); - // Storage - strings.insert("settings_storage_title".into(), "Storage".into()); - strings.insert( - "settings_storage_desc".into(), - "Manage application locations and cache.".into(), - ); - strings.insert("settings_section_scan".into(), "Scan".into()); - strings.insert( - "settings_scan_desc".into(), - "Configure directories scanned at startup.".into(), - ); - strings.insert("settings_scan_dirs".into(), "Scan directories".into()); - strings.insert( - "settings_scan_dirs_desc".into(), - "Directories scanned for applications.".into(), - ); - strings.insert("settings_scan_dirs_value".into(), "Default".into()); - strings.insert("settings_startup".into(), "Scan on startup".into()); - strings.insert( - "settings_startup_desc".into(), - "Updates the library at startup.".into(), - ); - strings.insert("settings_enabled".into(), "Enabled".into()); - strings.insert("settings_section_install".into(), "Installation".into()); - strings.insert("settings_local_apps".into(), "Local applications".into()); - strings.insert("settings_colony_repos".into(), "Colony repos".into()); - strings.insert("settings_favorites".into(), "Favorites".into()); - // Placeholders - strings.insert("settings_coming_soon".into(), "Coming soon".into()); - // About - strings.insert("settings_about_title".into(), "About Colony".into()); - strings.insert("settings_about".into(), "About".into()); - strings.insert("settings_version".into(), "Colony v0.1.0".into()); - // Launcher self-update - strings.insert( - "launcher_update_available".into(), - "Colony {version} is available!".into(), - ); - strings.insert( - "launcher_update_available_short".into(), - "\u{f0aa} Update {version}".into(), - ); - strings.insert( - "launcher_update_ready".into(), - "Update ready. Click to restart Colony.".into(), - ); - strings.insert( - "launcher_restart_to_update".into(), - "\u{f021} Restart to update".into(), - ); - strings.insert( - "launcher_download_update".into(), - "Download update {version}".into(), - ); - strings.insert( - "launcher_update_failed".into(), - "Update failed: {error}".into(), - ); - strings.insert("check_launcher_updates".into(), "Check for updates".into()); - strings.insert("launcher_up_to_date".into(), "Colony is up to date".into()); - strings.insert("update_all".into(), "Update all ({count})".into()); - strings.insert("whats_new".into(), "What's new in {version}".into()); - 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("clear_caches".into(), "Clear store caches".into()); - strings.insert("clear_caches_desc".into(), "Removes cached descriptions and icons (they re-download on the next refresh). Installed applications are not touched.".into()); - strings.insert("caches_cleared".into(), "{count} cache(s) removed".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()); - strings.insert("tab_license".into(), "License".into()); - strings.insert("tab_changelog".into(), "Changelog".into()); - strings.insert("tab_loading".into(), "Loading...".into()); - strings.insert("tab_not_available".into(), "Not available".into()); - } - } - - Self { - strings, - lang: lang.to_string(), - } - } -} - -/// Initialize the locale system. Call once at startup. -/// -/// `preferred` is the user's saved language preference ("fr"/"en"); when it is -/// a recognized language it wins over environment detection, so the in-app -/// language picker actually takes effect on the next launch. Falls back to -/// `detect_language()` (LC_ALL / LC_MESSAGES / LANG) when unset or unknown. -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}"); - if let Ok(mut locale) = LOCALE.write() { - *locale = Some(Locale::new(lang)); - } -} - -/// Localized display name for a built-in sidebar section, keyed by its -/// canonical English name. Custom/user sections (no matching key) fall back to -/// their raw name so nothing is lost. -pub fn section_display_name(name: &str) -> String { - let key = match name.to_lowercase().as_str() { - "all" => "section_all", - "favorites" | "favoris" => "section_favorites", - "windows" => "section_windows", - "linux" => "section_linux", - "development" => "section_development", - "graphics" => "section_graphics", - "network" => "section_network", - "office" => "section_office", - "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 - .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 - .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() - }) -} - -/// Get a translated string with variable substitution. -/// Variables use `{name}` syntax. -pub fn t_fmt(key: &str, vars: &[(&str, &str)]) -> String { - let mut result = t(key); - for (name, value) in vars { - result = result.replace(&format!("{{{name}}}"), value); - } - result -} - -/// Get the current language code. -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. -fn detect_language() -> String { - // Check LANG, LC_ALL, LC_MESSAGES - for var in ["LC_ALL", "LC_MESSAGES", "LANG"] { - if let Ok(val) = std::env::var(var) { - let lang = val.split('.').next().unwrap_or(&val); - let lang = lang.split('_').next().unwrap_or(lang); - if lang == "fr" { - return "fr".to_string(); - } - } - } - - "en".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn english_locale_has_keys() { - let locale = Locale::new("en"); - assert!(locale.strings.contains_key("categories")); - assert!(locale.strings.contains_key("github_login")); - assert!(locale.strings.contains_key("back")); - assert!(locale.strings.contains_key("error_thread_panic")); - assert!(locale.strings.contains_key("confirm_uninstall")); - assert!(locale.strings.contains_key("welcome_title")); - assert!(locale.strings.contains_key("download_cancelled")); - assert!(locale.strings.contains_key("add_favorite")); - } - - #[test] - fn french_locale_has_keys() { - let locale = Locale::new("fr"); - assert_eq!(locale.strings.get("categories").unwrap(), "Catégories"); - assert_eq!(locale.strings.get("back").unwrap(), "Retour"); - assert!(locale.strings.contains_key("error_thread_panic")); - assert!(locale.strings.contains_key("confirm_uninstall")); - assert!(locale.strings.contains_key("welcome_title")); - } - - #[test] - fn unknown_lang_defaults_to_english() { - let locale = Locale::new("xx"); - assert_eq!(locale.strings.get("categories").unwrap(), "Categories"); - } - - #[test] - fn fr_and_en_have_identical_key_sets() { - let fr = Locale::new("fr"); - let en = Locale::new("en"); - let fr_keys: std::collections::BTreeSet<&String> = fr.strings.keys().collect(); - let en_keys: std::collections::BTreeSet<&String> = en.strings.keys().collect(); - let only_fr: Vec<&&String> = fr_keys.difference(&en_keys).collect(); - let only_en: Vec<&&String> = en_keys.difference(&fr_keys).collect(); - assert!( - only_fr.is_empty() && only_en.is_empty(), - "Locale key mismatch — only in fr: {only_fr:?}; only in en: {only_en:?}" - ); - } - - #[test] - fn t_fmt_substitution() { - // Initialize with English for test - set_language("en"); - let result = t_fmt("apps_found", &[("count", "42")]); - assert_eq!(result, "42 applications found"); - } -} diff --git a/src/i18n/en.rs b/src/i18n/en.rs new file mode 100644 index 0000000..b6efead --- /dev/null +++ b/src/i18n/en.rs @@ -0,0 +1,649 @@ +//! English strings. +//! +//! One file per locale so a new string is a one-locale diff. The key sets of the +//! two locales must match exactly - `super::tests::fr_and_en_have_identical_key_sets` +//! fails otherwise. + +use std::collections::HashMap; + +pub(super) fn insert_all(strings: &mut HashMap) { + // English (default) + // Sidebar + strings.insert("categories".into(), "Categories".into()); + strings.insert("rescan".into(), "Rescan".into()); + + // GitHub panel + strings.insert("github_connect_desc".into(), "Connect to GitHub to detect Colony repos (colony.json) from the Project-Colony organization.".into()); + strings.insert("github_login".into(), "Sign in with GitHub".into()); + strings.insert( + "github_public_api".into(), + "Not connected: Public GitHub API (60 req/h)".into(), + ); + strings.insert( + "github_rate_limit".into(), + "GitHub rate limit reached. Retry in {wait} seconds.".into(), + ); + strings.insert( + "github_enter_code".into(), + "Enter this code on GitHub:".into(), + ); + strings.insert( + "github_copy_hint".into(), + "Click to copy — Waiting for authorization...".into(), + ); + strings.insert("github_connecting".into(), "Connecting...".into()); + strings.insert("github_connected".into(), "Connected".into()); + strings.insert( + "github_repos_detected".into(), + "{count} Colony repos detected".into(), + ); + strings.insert( + "github_no_repos".into(), + "No repos with colony.json found.".into(), + ); + strings.insert("github_refresh".into(), "Refresh repos".into()); + strings.insert("github_logout".into(), "Sign out".into()); + strings.insert("github_error".into(), "Error: {error}".into()); + strings.insert("github_retry".into(), "Retry".into()); + strings.insert( + "github_disconnected".into(), + "Disconnected from GitHub".into(), + ); + + // App grid + strings.insert("no_apps_found".into(), "No applications found".into()); + strings.insert("search_placeholder".into(), "Search applications...".into()); + strings.insert("status_installed".into(), "Installed".into()); + strings.insert("status_get".into(), "Get".into()); + strings.insert("status_update".into(), "Update".into()); + + // Detail view + strings.insert("back".into(), "Back".into()); + strings.insert("language_label".into(), "Language: {lang}".into()); + strings.insert("launch".into(), "Launch {name}".into()); + strings.insert("update".into(), "Update".into()); + strings.insert("download".into(), "Download".into()); + strings.insert("no_release".into(), "No release available".into()); + strings.insert( + "no_release_platform".into(), + "Not available for your platform".into(), + ); + + // Status messages + strings.insert("apps_found".into(), "{count} applications found".into()); + strings.insert("app_launched".into(), "Application launched.".into()); + strings.insert("installed".into(), "Installed: {path}".into()); + strings.insert("download_error".into(), "Download error: {error}".into()); + strings.insert("downloading".into(), "Downloading {file}…".into()); + strings.insert("no_release_for".into(), "No release for {platform}".into()); + strings.insert("uninstalled".into(), "{name} uninstalled.".into()); + strings.insert("launch_error".into(), "Cannot launch: {error}".into()); + strings.insert( + "launch_error_empty".into(), + "Cannot launch: empty command".into(), + ); + strings.insert("uninstall_error".into(), "Uninstall error: {error}".into()); + + // OAuth errors + strings.insert("oauth_error".into(), "OAuth error: {error}".into()); + strings.insert( + "oauth_device_expired".into(), + "Timed out: GitHub authorization was not confirmed in time.".into(), + ); + strings.insert( + "oauth_device_failed".into(), + "GitHub sign-in failed: {error} — {desc}".into(), + ); + strings.insert("github_api_error".into(), "GitHub error: {error}".into()); + strings.insert("scan_error".into(), "Error: {error}".into()); + strings.insert("launch_error_msg".into(), "Launch error: {error}".into()); + strings.insert( + "updates_available".into(), + "{count} update(s) available: {names}".into(), + ); + + // Sidebar section names (localized) + strings.insert("section_all".into(), "All".into()); + strings.insert("section_favorites".into(), "Favorites".into()); + strings.insert("section_windows".into(), "Windows".into()); + strings.insert("section_linux".into(), "Linux".into()); + strings.insert("section_development".into(), "Development".into()); + strings.insert("section_graphics".into(), "Graphics".into()); + strings.insert("section_network".into(), "Network".into()); + strings.insert("section_office".into(), "Office".into()); + strings.insert("section_multimedia".into(), "Multimedia".into()); + strings.insert("section_system".into(), "System".into()); + strings.insert("section_utilities".into(), "Utilities".into()); + strings.insert("section_games".into(), "Games".into()); + strings.insert("section_other".into(), "Other".into()); + + // Thread errors + strings.insert( + "error_thread_panic".into(), + "Internal error: background thread panicked".into(), + ); + + // Download cancellation + strings.insert("download_cancelled".into(), "Download cancelled".into()); + + // Uninstall confirmation + strings.insert( + "confirm_uninstall".into(), + "Are you sure you want to uninstall \"{name}\"? This action cannot be undone.".into(), + ); + strings.insert("cancel".into(), "Cancel".into()); + strings.insert("confirm_delete".into(), "Uninstall".into()); + + // Favorites + strings.insert("add_favorite".into(), "Add to favorites".into()); + strings.insert("remove_favorite".into(), "Remove from favorites".into()); + + // First launch — carousel (3 steps) + strings.insert("welcome_title".into(), "Welcome to Colony".into()); + strings.insert("welcome_desc".into(), "The centralized launcher for the Project-Colony ecosystem. Discover, install and launch apps in one click.".into()); + // Step 1 — interface tour + strings.insert( + "welcome_step1_title".into(), + "The interface, in 3 zones".into(), + ); + strings.insert("welcome_step1_tip1_title".into(), "Sidebar".into()); + strings.insert( + "welcome_step1_tip1_desc".into(), + "Filter by category or origin (Colony / system apps).".into(), + ); + strings.insert("welcome_step1_tip2_title".into(), "Search".into()); + strings.insert( + "welcome_step1_tip2_desc".into(), + "Type an app name in the top bar to filter instantly.".into(), + ); + strings.insert("welcome_step1_tip3_title".into(), "Detail".into()); + strings.insert( + "welcome_step1_tip3_desc".into(), + "Click any app to read its README, changelog and install it.".into(), + ); + // Step 2 — GitHub + ready + strings.insert( + "welcome_step2_title".into(), + "Connect GitHub (optional)".into(), + ); + strings.insert("welcome_step2_desc".into(), "Without an account: 60 GitHub requests per hour. With an account: 5000/h + access to your private repos. Recommended if you plan to browse a lot.".into()); + strings.insert( + "welcome_step2_hint1".into(), + "\u{f005} Favorites (⭐) for quick access".into(), + ); + strings.insert( + "welcome_step2_hint2".into(), + "\u{f53f} 24 theme families in the preferences".into(), + ); + strings.insert( + "welcome_step2_hint3".into(), + "\u{f059} Full tutorial + FAQ on GitHub".into(), + ); + // Navigation + strings.insert("welcome_start".into(), "Let's go!".into()); + strings.insert("welcome_next".into(), "Next".into()); + strings.insert("welcome_back".into(), "Back".into()); + strings.insert("welcome_skip".into(), "Skip".into()); + strings.insert("welcome_connect_now".into(), "Connect now".into()); + strings.insert("welcome_later".into(), "Later".into()); + + // Guided tutorial (spotlight over real UI) + strings.insert("tut_sidebar_title".into(), "Categories".into()); + strings.insert("tut_sidebar_desc".into(), "Filter your apps by type — games, tools, favorites — or by origin (Colony ecosystem vs. system). The sidebar stays visible at all times.".into()); + strings.insert("tut_search_title".into(), "Search".into()); + strings.insert( + "tut_search_desc".into(), + "Type an app name here to find it instantly, regardless of the selected category.".into(), + ); + strings.insert("tut_grid_title".into(), "Your applications".into()); + strings.insert("tut_grid_desc".into(), "These are your installed apps plus the Colony apps available to install. Click a card to read its README, changelog and install in one click.".into()); + strings.insert( + "tut_github_title".into(), + "GitHub connection (optional)".into(), + ); + strings.insert("tut_github_desc".into(), "Without an account: 60 requests/h. With one: 5000/h + private repo access. Recommended if you plan to browse a lot. The Rescan button below refreshes the system scan.".into()); + strings.insert("tut_finish_title".into(), "You're all set!".into()); + strings.insert("tut_finish_desc".into(), "The gear icon next to the title opens Preferences: 24 theme families, keyboard shortcuts, accessibility. Enjoy Colony!".into()); + + // Loading / async feedback + strings.insert("loading".into(), "Loading...".into()); + strings.insert("scanning".into(), "Scanning...".into()); + strings.insert("checking_updates".into(), "Checking for updates...".into()); + strings.insert("syncing_repos".into(), "Syncing repositories...".into()); + strings.insert("no_results_for".into(), "No results for \"{query}\"".into()); + strings.insert( + "n_results_found".into(), + "{count} result(s) for \"{query}\"".into(), + ); + strings.insert("theme_applied".into(), "Theme applied.".into()); + + // Keyboard shortcuts + strings.insert("shortcuts_title".into(), "Keyboard shortcuts".into()); + strings.insert("shortcut_esc".into(), "Esc — Close active panel".into()); + strings.insert( + "shortcut_tab".into(), + "Tab / Shift+Tab — Navigate categories".into(), + ); + strings.insert("shortcut_arrows".into(), "↑ ↓ — Navigate settings".into()); + strings.insert( + "shortcut_enter".into(), + "Enter — Open first visible item".into(), + ); + strings.insert( + "shortcut_pageupdown".into(), + "Page Up/Down — Fast navigation in settings".into(), + ); + + // Tooltips / hints + strings.insert("hint_settings".into(), "Open preferences".into()); + strings.insert("hint_search".into(), "Type to filter applications".into()); + strings.insert( + "hint_favorites".into(), + "Click the star to add to favorites".into(), + ); + strings.insert( + "hint_keyboard".into(), + "Use Tab and arrow keys to navigate".into(), + ); + + // Settings + strings.insert("settings_title".into(), "Preferences".into()); + strings.insert("settings_close".into(), "Close".into()); + strings.insert("settings_cat_general".into(), "General".into()); + strings.insert("settings_cat_appearance".into(), "Appearance".into()); + strings.insert("settings_cat_accessibility".into(), "Accessibility".into()); + strings.insert("settings_cat_storage".into(), "Storage".into()); + strings.insert("settings_cat_about".into(), "About".into()); + strings.insert("settings_cat_shortcuts".into(), "Shortcuts".into()); + // General + strings.insert("settings_general_title".into(), "General settings".into()); + strings.insert( + "settings_general_desc".into(), + "Preferences are saved automatically.".into(), + ); + // Startup + strings.insert("settings_section_startup".into(), "Startup".into()); + strings.insert( + "settings_startup_section_desc".into(), + "Manage Colony startup and session restoration.".into(), + ); + strings.insert( + "settings_restore_session".into(), + "Restore last session".into(), + ); + strings.insert( + "settings_restore_session_desc".into(), + "Category and screen from last usage.".into(), + ); + strings.insert("settings_default_view".into(), "Open on".into()); + strings.insert( + "settings_default_view_desc".into(), + "Choose the default screen.".into(), + ); + strings.insert("settings_default_view_all".into(), "All".into()); + strings.insert("settings_default_view_favorites".into(), "Favorites".into()); + strings.insert("settings_default_view_recent".into(), "Recent".into()); + strings.insert("settings_close_behavior".into(), "Close behavior".into()); + strings.insert( + "settings_close_behavior_desc".into(), + "Choose action on close.".into(), + ); + strings.insert("settings_close_quit".into(), "Quit".into()); + strings.insert("settings_close_tray".into(), "Minimize to tray".into()); + // Language + strings.insert("settings_section_language".into(), "Language".into()); + strings.insert( + "settings_language_desc".into(), + "Customize the interface and time format.".into(), + ); + strings.insert( + "settings_current_language".into(), + "Interface language".into(), + ); + strings.insert( + "settings_current_language_desc".into(), + "Synced with system.".into(), + ); + strings.insert("settings_time_format".into(), "Time format".into()); + strings.insert( + "settings_time_format_desc".into(), + "Format used in the application.".into(), + ); + // Updates + strings.insert("settings_section_updates".into(), "Updates".into()); + strings.insert( + "settings_updates_desc".into(), + "Manage update checking and channel.".into(), + ); + strings.insert( + "settings_auto_check_updates".into(), + "Check automatically".into(), + ); + strings.insert( + "settings_auto_check_updates_desc".into(), + "Check for new versions on launch.".into(), + ); + strings.insert("settings_update_channel".into(), "Channel".into()); + strings.insert( + "settings_update_channel_desc".into(), + "Choose version stability.".into(), + ); + strings.insert( + "settings_auto_install_updates".into(), + "Install automatically".into(), + ); + strings.insert( + "settings_auto_install_updates_desc".into(), + "Install updates in background.".into(), + ); + strings.insert("settings_check_updates".into(), "Check for updates".into()); + // Privacy + strings.insert("settings_section_privacy".into(), "Privacy".into()); + strings.insert( + "settings_privacy_desc".into(), + "Choose data shared with Colony.".into(), + ); + strings.insert("settings_error_reports".into(), "Send error reports".into()); + strings.insert( + "settings_error_reports_desc".into(), + "Helps improve stability.".into(), + ); + strings.insert( + "settings_usage_stats".into(), + "Anonymous usage statistics".into(), + ); + strings.insert( + "settings_usage_stats_desc".into(), + "Helps understand Colony usage.".into(), + ); + // Appearance + strings.insert( + "settings_appearance_title".into(), + "Appearance settings".into(), + ); + strings.insert( + "settings_appearance_desc".into(), + "Adjust theme, accents and visual effects.".into(), + ); + strings.insert("settings_section_theme".into(), "Theme".into()); + strings.insert( + "settings_theme_desc".into(), + "Choose the interface theme.".into(), + ); + strings.insert("settings_theme_current".into(), "Current theme".into()); + strings.insert( + "settings_theme_current_desc".into(), + "Overall application appearance.".into(), + ); + strings.insert("settings_theme_dark".into(), "Dark".into()); + // Theme families + strings.insert("settings_theme_catppuccin".into(), "Catppuccin".into()); + strings.insert("settings_theme_catppuccin_latte".into(), "Latte".into()); + strings.insert("settings_theme_catppuccin_frappe".into(), "Frappé".into()); + strings.insert( + "settings_theme_catppuccin_macchiato".into(), + "Macchiato".into(), + ); + strings.insert("settings_theme_catppuccin_mocha".into(), "Mocha".into()); + strings.insert("settings_theme_gruvbox".into(), "Gruvbox".into()); + strings.insert("settings_theme_light".into(), "Light mode".into()); + strings.insert("settings_theme_dark_mode".into(), "Dark mode".into()); + strings.insert("settings_theme_everblush".into(), "Everblush".into()); + strings.insert("settings_theme_kanagawa".into(), "Kanagawa".into()); + strings.insert( + "settings_theme_kanagawa_journal".into(), + "Journal mode".into(), + ); + // New theme families + strings.insert("settings_theme_nord".into(), "Nord".into()); + strings.insert("settings_theme_dracula".into(), "Dracula".into()); + strings.insert("settings_theme_solarized".into(), "Solarized".into()); + strings.insert("settings_theme_tokyonight".into(), "Tokyo Night".into()); + strings.insert("settings_theme_tokyonight_night".into(), "Night".into()); + strings.insert("settings_theme_tokyonight_day".into(), "Day".into()); + strings.insert("settings_theme_rosepine".into(), "Rosé Pine".into()); + strings.insert("settings_theme_rosepine_main".into(), "Main".into()); + strings.insert("settings_theme_rosepine_moon".into(), "Moon".into()); + strings.insert("settings_theme_rosepine_dawn".into(), "Dawn".into()); + strings.insert("settings_theme_onedark".into(), "One Dark".into()); + strings.insert("settings_theme_monokai".into(), "Monokai Pro".into()); + strings.insert("settings_theme_monokai_pro".into(), "Pro".into()); + strings.insert("settings_theme_monokai_classic".into(), "Classic".into()); + strings.insert("settings_theme_monokai_spectrum".into(), "Spectrum".into()); + strings.insert("settings_theme_ayu".into(), "Ayu".into()); + strings.insert("settings_theme_ayu_mirage".into(), "Mirage".into()); + strings.insert("settings_theme_everforest".into(), "Everforest".into()); + strings.insert("settings_theme_material".into(), "Material".into()); + strings.insert("settings_theme_material_oceanic".into(), "Oceanic".into()); + strings.insert( + "settings_theme_material_palenight".into(), + "Palenight".into(), + ); + strings.insert( + "settings_theme_material_deepocean".into(), + "Deep Ocean".into(), + ); + strings.insert("settings_theme_flexoki".into(), "Flexoki".into()); + strings.insert("settings_theme_nightfox".into(), "Nightfox".into()); + strings.insert("settings_theme_nightfox_nightfox".into(), "Nightfox".into()); + strings.insert("settings_theme_nightfox_dawnfox".into(), "Dawnfox".into()); + strings.insert("settings_theme_sonokai".into(), "Sonokai".into()); + strings.insert("settings_theme_sonokai_default".into(), "Default".into()); + strings.insert("settings_theme_oxocarbon".into(), "Oxocarbon".into()); + strings.insert("settings_theme_nightowl".into(), "Night Owl".into()); + strings.insert("settings_theme_iceberg".into(), "Iceberg".into()); + strings.insert("settings_theme_horizon".into(), "Horizon".into()); + strings.insert("settings_theme_melange".into(), "Melange".into()); + strings.insert("settings_theme_synthwave".into(), "Synthwave '84".into()); + strings.insert("settings_theme_modus".into(), "Modus".into()); + strings.insert("settings_theme_modus_operandi".into(), "Operandi".into()); + strings.insert("settings_theme_modus_vivendi".into(), "Vivendi".into()); + strings.insert( + "settings_theme_stellar_blade".into(), + "Stellar Blade".into(), + ); + strings.insert("settings_theme_stellar_blade_eve".into(), "EVE".into()); + strings.insert("settings_theme_stellar_blade_tachy".into(), "Tachy".into()); + strings.insert("settings_theme_stellar_blade_lily".into(), "Lily".into()); + strings.insert("settings_theme_stellar_blade_enya".into(), "Enya".into()); + strings.insert("settings_theme_stellar_blade_kaya".into(), "Kaya".into()); + // Colors & accents + strings.insert("settings_section_colors".into(), "Colors & accents".into()); + strings.insert( + "settings_colors_desc".into(), + "Customize the interface accent color.".into(), + ); + strings.insert("settings_accent_color".into(), "Accent color".into()); + strings.insert( + "settings_accent_color_desc".into(), + "Color used for interactive elements.".into(), + ); + strings.insert("settings_accent_red".into(), "Red".into()); + strings.insert("settings_accent_orange".into(), "Orange".into()); + strings.insert("settings_accent_yellow".into(), "Yellow".into()); + strings.insert("settings_accent_green".into(), "Green".into()); + strings.insert("settings_accent_blue".into(), "Blue".into()); + strings.insert("settings_accent_indigo".into(), "Indigo".into()); + strings.insert("settings_accent_violet".into(), "Violet".into()); + strings.insert("settings_accent_amber".into(), "Amber".into()); + strings.insert( + "settings_auto_accent".into(), + "Auto accent from background".into(), + ); + strings.insert( + "settings_auto_accent_desc".into(), + "Automatically adapts accent to backgrounds.".into(), + ); + strings.insert("settings_enabled_label".into(), "Enabled".into()); + strings.insert("settings_disabled_label".into(), "Disabled".into()); + strings.insert("settings_section_typography".into(), "Typography".into()); + strings.insert( + "settings_typography_desc".into(), + "Configure font and text size.".into(), + ); + strings.insert("settings_font".into(), "Font".into()); + strings.insert( + "settings_font_desc".into(), + "Font used in the interface.".into(), + ); + strings.insert("settings_font_size".into(), "Text size".into()); + strings.insert("settings_font_size_desc".into(), "Base text size.".into()); + strings.insert("settings_font_size_default".into(), "Default".into()); + strings.insert("settings_font_size_small".into(), "Small".into()); + strings.insert("settings_font_size_large".into(), "Large".into()); + strings.insert("settings_font_size_xlarge".into(), "Extra large".into()); + strings.insert( + "settings_section_effects".into(), + "Backgrounds & effects".into(), + ); + strings.insert( + "settings_effects_desc".into(), + "Manage animations and visual effects.".into(), + ); + strings.insert("settings_animations".into(), "Animations".into()); + strings.insert( + "settings_animations_desc".into(), + "Enable animated transitions.".into(), + ); + strings.insert("settings_section_preview".into(), "Preview".into()); + strings.insert("settings_preview_card".into(), "Preview card".into()); + strings.insert( + "settings_preview_summary".into(), + "Theme: Dark · Accent: Blue · Text: Default · Effects: Enabled".into(), + ); + // Accessibility + strings.insert( + "settings_accessibility_title".into(), + "Accessibility settings".into(), + ); + strings.insert( + "settings_accessibility_desc".into(), + "Improve reading, navigation and media playback.".into(), + ); + strings.insert("settings_section_vision".into(), "Vision".into()); + strings.insert( + "settings_vision_desc".into(), + "Options to improve readability.".into(), + ); + strings.insert("settings_high_contrast".into(), "High contrast".into()); + strings.insert( + "settings_high_contrast_desc".into(), + "Increase contrast of elements.".into(), + ); + strings.insert("settings_disabled".into(), "Disabled".into()); + strings.insert("settings_text_size_a11y".into(), "Text size".into()); + strings.insert( + "settings_text_size_a11y_desc".into(), + "Adjust text size for comfort.".into(), + ); + strings.insert("settings_section_motion".into(), "Motion".into()); + strings.insert( + "settings_motion_desc".into(), + "Reduce animations for comfort.".into(), + ); + strings.insert("settings_reduce_motion".into(), "Reduce motion".into()); + strings.insert( + "settings_reduce_motion_desc".into(), + "Limit transitions and movements.".into(), + ); + strings.insert( + "settings_section_navigation".into(), + "Navigation & interaction".into(), + ); + strings.insert( + "settings_navigation_desc".into(), + "Keyboard navigation and interaction options.".into(), + ); + strings.insert("settings_keyboard_nav".into(), "Keyboard navigation".into()); + strings.insert( + "settings_keyboard_nav_desc".into(), + "Navigate with Tab and arrow keys.".into(), + ); + strings.insert("settings_section_reading".into(), "Reading".into()); + strings.insert( + "settings_reading_desc".into(), + "Reading comfort options.".into(), + ); + strings.insert("settings_dyslexia_font".into(), "Dyslexia font".into()); + strings.insert( + "settings_dyslexia_font_desc".into(), + "Use a font adapted for dyslexia.".into(), + ); + // Storage + strings.insert("settings_storage_title".into(), "Storage".into()); + strings.insert( + "settings_storage_desc".into(), + "Manage application locations and cache.".into(), + ); + strings.insert("settings_section_scan".into(), "Scan".into()); + strings.insert( + "settings_scan_desc".into(), + "Configure directories scanned at startup.".into(), + ); + strings.insert("settings_scan_dirs".into(), "Scan directories".into()); + strings.insert( + "settings_scan_dirs_desc".into(), + "Directories scanned for applications.".into(), + ); + strings.insert("settings_scan_dirs_value".into(), "Default".into()); + strings.insert("settings_startup".into(), "Scan on startup".into()); + strings.insert( + "settings_startup_desc".into(), + "Updates the library at startup.".into(), + ); + strings.insert("settings_enabled".into(), "Enabled".into()); + strings.insert("settings_section_install".into(), "Installation".into()); + strings.insert("settings_local_apps".into(), "Local applications".into()); + strings.insert("settings_colony_repos".into(), "Colony repos".into()); + strings.insert("settings_favorites".into(), "Favorites".into()); + // Placeholders + strings.insert("settings_coming_soon".into(), "Coming soon".into()); + // About + strings.insert("settings_about_title".into(), "About Colony".into()); + strings.insert("settings_about".into(), "About".into()); + strings.insert("settings_version".into(), "Colony v0.1.0".into()); + // Launcher self-update + strings.insert( + "launcher_update_available".into(), + "Colony {version} is available!".into(), + ); + strings.insert( + "launcher_update_available_short".into(), + "\u{f0aa} Update {version}".into(), + ); + strings.insert( + "launcher_update_ready".into(), + "Update ready. Click to restart Colony.".into(), + ); + strings.insert( + "launcher_restart_to_update".into(), + "\u{f021} Restart to update".into(), + ); + strings.insert( + "launcher_download_update".into(), + "Download update {version}".into(), + ); + strings.insert( + "launcher_update_failed".into(), + "Update failed: {error}".into(), + ); + strings.insert("check_launcher_updates".into(), "Check for updates".into()); + strings.insert("launcher_up_to_date".into(), "Colony is up to date".into()); + strings.insert("update_all".into(), "Update all ({count})".into()); + strings.insert("whats_new".into(), "What's new in {version}".into()); + 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("clear_caches".into(), "Clear store caches".into()); + strings.insert("clear_caches_desc".into(), "Removes cached descriptions and icons (they re-download on the next refresh). Installed applications are not touched.".into()); + strings.insert("caches_cleared".into(), "{count} cache(s) removed".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()); + strings.insert("tab_license".into(), "License".into()); + strings.insert("tab_changelog".into(), "Changelog".into()); + strings.insert("tab_loading".into(), "Loading...".into()); + strings.insert("tab_not_available".into(), "Not available".into()); +} diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs new file mode 100644 index 0000000..85263fe --- /dev/null +++ b/src/i18n/fr.rs @@ -0,0 +1,708 @@ +//! French strings. +//! +//! One file per locale so a new string is a one-locale diff. The key sets of the +//! two locales must match exactly - `super::tests::fr_and_en_have_identical_key_sets` +//! fails otherwise. + +use std::collections::HashMap; + +pub(super) fn insert_all(strings: &mut HashMap) { + // Sidebar + strings.insert("categories".into(), "Catégories".into()); + strings.insert("rescan".into(), "Rescan".into()); + + // GitHub panel + strings.insert("github_connect_desc".into(), "Connectez-vous à GitHub pour détecter les dépôts Colony (colony.json) de l'organisation Project-Colony.".into()); + strings.insert("github_login".into(), "Se connecter avec GitHub".into()); + strings.insert( + "github_public_api".into(), + "Mode non connecté : API publique GitHub (60 req/h)".into(), + ); + strings.insert( + "github_rate_limit".into(), + "Quota GitHub atteint. Réessayez dans {wait} secondes.".into(), + ); + strings.insert( + "github_enter_code".into(), + "Entrez ce code sur GitHub :".into(), + ); + strings.insert( + "github_copy_hint".into(), + "Cliquez pour copier — En attente d'autorisation...".into(), + ); + strings.insert("github_connecting".into(), "Connexion en cours...".into()); + strings.insert("github_connected".into(), "Connecté".into()); + strings.insert( + "github_repos_detected".into(), + "{count} dépôts Colony détectés".into(), + ); + strings.insert( + "github_no_repos".into(), + "Aucun dépôt avec colony.json trouvé.".into(), + ); + strings.insert("github_refresh".into(), "Rafraîchir les dépôts".into()); + strings.insert("github_logout".into(), "Se déconnecter".into()); + strings.insert("github_error".into(), "Erreur : {error}".into()); + strings.insert("github_retry".into(), "Réessayer".into()); + strings.insert("github_disconnected".into(), "Déconnecté de GitHub".into()); + + // App grid + strings.insert("no_apps_found".into(), "Aucune application trouvée".into()); + strings.insert( + "search_placeholder".into(), + "Rechercher des applications...".into(), + ); + strings.insert("status_installed".into(), "Installé".into()); + strings.insert("status_get".into(), "À installer".into()); + strings.insert("status_update".into(), "Mise à jour".into()); + + // Detail view + strings.insert("back".into(), "Retour".into()); + strings.insert("language_label".into(), "Langage: {lang}".into()); + strings.insert("launch".into(), "Lancer {name}".into()); + strings.insert("update".into(), "Mettre à jour".into()); + strings.insert("download".into(), "Télécharger".into()); + strings.insert("no_release".into(), "Aucune release disponible".into()); + strings.insert( + "no_release_platform".into(), + "Non disponible pour votre plateforme".into(), + ); + + // Status messages + strings.insert("apps_found".into(), "{count} applications trouvées".into()); + strings.insert("app_launched".into(), "Application lancée.".into()); + strings.insert("installed".into(), "Installé : {path}".into()); + strings.insert( + "download_error".into(), + "Erreur téléchargement : {error}".into(), + ); + strings.insert("downloading".into(), "Téléchargement de {file}…".into()); + strings.insert( + "no_release_for".into(), + "Pas de release pour {platform}".into(), + ); + strings.insert("uninstalled".into(), "{name} désinstallé.".into()); + strings.insert( + "launch_error".into(), + "Impossible de lancer: {error}".into(), + ); + strings.insert( + "launch_error_empty".into(), + "Impossible de lancer: commande vide".into(), + ); + strings.insert( + "uninstall_error".into(), + "Erreur désinstallation : {error}".into(), + ); + + // OAuth errors + strings.insert("oauth_error".into(), "Erreur OAuth: {error}".into()); + strings.insert( + "oauth_device_expired".into(), + "Délai dépassé : l'autorisation GitHub n'a pas été confirmée à temps.".into(), + ); + strings.insert( + "oauth_device_failed".into(), + "Échec de la connexion GitHub : {error} — {desc}".into(), + ); + strings.insert("github_api_error".into(), "Erreur GitHub: {error}".into()); + strings.insert("scan_error".into(), "Erreur: {error}".into()); + strings.insert( + "launch_error_msg".into(), + "Erreur lancement : {error}".into(), + ); + strings.insert( + "updates_available".into(), + "{count} mise(s) à jour disponible(s) : {names}".into(), + ); + + // Sidebar section names (localized) + strings.insert("section_all".into(), "Tout".into()); + strings.insert("section_favorites".into(), "Favoris".into()); + strings.insert("section_windows".into(), "Windows".into()); + strings.insert("section_linux".into(), "Linux".into()); + strings.insert("section_development".into(), "Développement".into()); + strings.insert("section_graphics".into(), "Graphisme".into()); + strings.insert("section_network".into(), "Réseau".into()); + strings.insert("section_office".into(), "Bureautique".into()); + strings.insert("section_multimedia".into(), "Multimédia".into()); + strings.insert("section_system".into(), "Système".into()); + strings.insert("section_utilities".into(), "Utilitaires".into()); + strings.insert("section_games".into(), "Jeux".into()); + strings.insert("section_other".into(), "Autre".into()); + + // Thread errors + strings.insert( + "error_thread_panic".into(), + "Erreur interne : le thread a paniqué".into(), + ); + + // Download cancellation + strings.insert("download_cancelled".into(), "Téléchargement annulé".into()); + + // Uninstall confirmation + strings.insert( + "confirm_uninstall".into(), + "Voulez-vous vraiment désinstaller « {name} » ? Cette action est irréversible.".into(), + ); + strings.insert("cancel".into(), "Annuler".into()); + strings.insert("confirm_delete".into(), "Désinstaller".into()); + + // Favorites + strings.insert("add_favorite".into(), "Ajouter aux favoris".into()); + strings.insert("remove_favorite".into(), "Retirer des favoris".into()); + + // First launch — carousel (3 steps) + strings.insert("welcome_title".into(), "Bienvenue dans Colony".into()); + strings.insert("welcome_desc".into(), "Le lanceur centralisé de l'écosystème Project-Colony. Découvrez, installez et lancez vos apps en un clic.".into()); + // Step 1 — interface tour + strings.insert( + "welcome_step1_title".into(), + "L'interface en 3 zones".into(), + ); + strings.insert("welcome_step1_tip1_title".into(), "Sidebar".into()); + strings.insert( + "welcome_step1_tip1_desc".into(), + "Filtrez par catégorie ou origine (Colony / système).".into(), + ); + strings.insert("welcome_step1_tip2_title".into(), "Recherche".into()); + strings.insert( + "welcome_step1_tip2_desc".into(), + "Tapez le nom d'une app dans la barre en haut pour filtrer instantanément.".into(), + ); + strings.insert("welcome_step1_tip3_title".into(), "Détail".into()); + strings.insert( + "welcome_step1_tip3_desc".into(), + "Cliquez une app pour lire le README, le changelog et l'installer.".into(), + ); + // Step 2 — GitHub + ready + strings.insert( + "welcome_step2_title".into(), + "Connectez GitHub (optionnel)".into(), + ); + strings.insert("welcome_step2_desc".into(), "Sans compte : 60 requêtes GitHub par heure. Avec compte : 5000/h + accès aux repos privés. Recommandé si vous comptez explorer beaucoup.".into()); + strings.insert( + "welcome_step2_hint1".into(), + "\u{f005} Favoris (⭐) pour un accès rapide".into(), + ); + strings.insert( + "welcome_step2_hint2".into(), + "\u{f53f} 24 familles de thèmes dans les préférences".into(), + ); + strings.insert( + "welcome_step2_hint3".into(), + "\u{f059} Consultez la FAQ et le tutoriel complet sur GitHub".into(), + ); + // Navigation + strings.insert("welcome_start".into(), "C'est parti !".into()); + strings.insert("welcome_next".into(), "Suivant".into()); + strings.insert("welcome_back".into(), "Retour".into()); + strings.insert("welcome_skip".into(), "Passer".into()); + strings.insert("welcome_connect_now".into(), "Connecter maintenant".into()); + strings.insert("welcome_later".into(), "Plus tard".into()); + + // Tutoriel guidé (spotlight sur l'UI réelle) + strings.insert("tut_sidebar_title".into(), "Les catégories".into()); + strings.insert("tut_sidebar_desc".into(), "Filtrez vos apps par type : jeux, outils, favoris, ou par origine (écosystème Colony vs. système). La barre latérale reste toujours visible.".into()); + strings.insert("tut_search_title".into(), "La recherche".into()); + strings.insert("tut_search_desc".into(), "Tapez le nom d'une app pour la retrouver instantanément, peu importe la catégorie sélectionnée.".into()); + strings.insert("tut_grid_title".into(), "Vos applications".into()); + strings.insert("tut_grid_desc".into(), "Voici toutes vos apps installées et les apps Colony disponibles. Cliquez une carte pour voir son README, son changelog et l'installer en un clic.".into()); + strings.insert( + "tut_github_title".into(), + "Connexion GitHub (optionnel)".into(), + ); + strings.insert("tut_github_desc".into(), "Sans compte : 60 requêtes/h. Avec compte : 5000/h + accès aux repos privés. Recommandé si vous explorez beaucoup. Le bouton Rescan juste en dessous relance l'analyse système.".into()); + strings.insert("tut_finish_title".into(), "Vous êtes prêt !".into()); + strings.insert("tut_finish_desc".into(), "L'icône d'engrenage à côté du titre ouvre les préférences : 24 familles de thèmes, raccourcis clavier, accessibilité. Bon voyage dans Colony !".into()); + + // Loading / async feedback + strings.insert("loading".into(), "Chargement...".into()); + strings.insert("scanning".into(), "Analyse en cours...".into()); + strings.insert( + "checking_updates".into(), + "Vérification des mises à jour...".into(), + ); + strings.insert( + "syncing_repos".into(), + "Synchronisation des dépôts...".into(), + ); + strings.insert( + "no_results_for".into(), + "Aucun résultat pour « {query} »".into(), + ); + strings.insert( + "n_results_found".into(), + "{count} résultat(s) pour « {query} »".into(), + ); + strings.insert("theme_applied".into(), "Thème appliqué.".into()); + + // Keyboard shortcuts + strings.insert("shortcuts_title".into(), "Raccourcis clavier".into()); + strings.insert( + "shortcut_esc".into(), + "Échap — Fermer le panneau actif".into(), + ); + strings.insert( + "shortcut_tab".into(), + "Tab / Maj+Tab — Naviguer entre les catégories".into(), + ); + strings.insert( + "shortcut_arrows".into(), + "↑ ↓ — Naviguer dans les paramètres".into(), + ); + strings.insert( + "shortcut_enter".into(), + "Entrée — Ouvrir le premier élément visible".into(), + ); + strings.insert( + "shortcut_pageupdown".into(), + "Page ↑/↓ — Naviguer plus vite dans les paramètres".into(), + ); + + // Tooltips / hints + strings.insert("hint_settings".into(), "Ouvrir les préférences".into()); + strings.insert( + "hint_search".into(), + "Tapez pour filtrer les applications".into(), + ); + strings.insert( + "hint_favorites".into(), + "Cliquez sur l'étoile pour ajouter aux favoris".into(), + ); + strings.insert( + "hint_keyboard".into(), + "Utilisez Tab et les flèches pour naviguer".into(), + ); + + // Settings + strings.insert("settings_title".into(), "Préférences".into()); + strings.insert("settings_close".into(), "Fermer".into()); + strings.insert("settings_cat_general".into(), "Général".into()); + strings.insert("settings_cat_appearance".into(), "Apparences".into()); + strings.insert("settings_cat_accessibility".into(), "Accessibilité".into()); + strings.insert("settings_cat_storage".into(), "Stockage".into()); + strings.insert("settings_cat_about".into(), "À propos".into()); + strings.insert("settings_cat_shortcuts".into(), "Raccourcis".into()); + // General + strings.insert( + "settings_general_title".into(), + "Paramètres généraux".into(), + ); + strings.insert( + "settings_general_desc".into(), + "Les préférences sont enregistrées automatiquement.".into(), + ); + // Startup + strings.insert("settings_section_startup".into(), "Démarrage".into()); + strings.insert( + "settings_startup_section_desc".into(), + "Gérez l'ouverture de Colony et la restauration des sessions.".into(), + ); + strings.insert( + "settings_restore_session".into(), + "Restaurer la dernière session".into(), + ); + strings.insert( + "settings_restore_session_desc".into(), + "Catégorie et écran affichés au dernier usage.".into(), + ); + strings.insert("settings_default_view".into(), "Ouvrir sur".into()); + strings.insert( + "settings_default_view_desc".into(), + "Choisissez l'écran par défaut.".into(), + ); + strings.insert("settings_default_view_all".into(), "Toutes".into()); + strings.insert("settings_default_view_favorites".into(), "Favoris".into()); + strings.insert("settings_default_view_recent".into(), "Récents".into()); + strings.insert( + "settings_close_behavior".into(), + "Comportement à la fermeture".into(), + ); + strings.insert( + "settings_close_behavior_desc".into(), + "Choisissez l'action à la fermeture.".into(), + ); + strings.insert("settings_close_quit".into(), "Quitter".into()); + strings.insert("settings_close_tray".into(), "Réduire dans la barre".into()); + // Language + strings.insert("settings_section_language".into(), "Langue".into()); + strings.insert( + "settings_language_desc".into(), + "Personnalisez l'interface et le format horaire.".into(), + ); + strings.insert( + "settings_current_language".into(), + "Langue de l'interface".into(), + ); + strings.insert( + "settings_current_language_desc".into(), + "Synchronisée avec le système.".into(), + ); + strings.insert("settings_time_format".into(), "Format horaire".into()); + strings.insert( + "settings_time_format_desc".into(), + "Format utilisé dans l'application.".into(), + ); + // Updates + strings.insert("settings_section_updates".into(), "Mises à jour".into()); + strings.insert( + "settings_updates_desc".into(), + "Gérez la vérification et le canal des mises à jour.".into(), + ); + strings.insert( + "settings_auto_check_updates".into(), + "Vérifier automatiquement".into(), + ); + strings.insert( + "settings_auto_check_updates_desc".into(), + "Vérifie les nouvelles versions au lancement.".into(), + ); + strings.insert("settings_update_channel".into(), "Canal".into()); + strings.insert( + "settings_update_channel_desc".into(), + "Choisissez la stabilité des versions.".into(), + ); + strings.insert( + "settings_auto_install_updates".into(), + "Installer automatiquement".into(), + ); + strings.insert( + "settings_auto_install_updates_desc".into(), + "Installe les mises à jour en arrière-plan.".into(), + ); + strings.insert( + "settings_check_updates".into(), + "Vérifier les mises à jour".into(), + ); + // Privacy + strings.insert("settings_section_privacy".into(), "Confidentialité".into()); + strings.insert( + "settings_privacy_desc".into(), + "Choisissez les données partagées avec Colony.".into(), + ); + strings.insert( + "settings_error_reports".into(), + "Envoyer des rapports d'erreurs".into(), + ); + strings.insert( + "settings_error_reports_desc".into(), + "Permet d'améliorer la stabilité.".into(), + ); + strings.insert( + "settings_usage_stats".into(), + "Statistiques anonymes d'utilisation".into(), + ); + strings.insert( + "settings_usage_stats_desc".into(), + "Aide à comprendre l'usage de Colony.".into(), + ); + // Appearance + strings.insert( + "settings_appearance_title".into(), + "Paramètres d'apparence".into(), + ); + strings.insert( + "settings_appearance_desc".into(), + "Ajustez le thème, les accents et les effets visuels.".into(), + ); + strings.insert("settings_section_theme".into(), "Thème".into()); + strings.insert( + "settings_theme_desc".into(), + "Choisissez le thème de l'interface.".into(), + ); + strings.insert("settings_theme_current".into(), "Thème actuel".into()); + strings.insert( + "settings_theme_current_desc".into(), + "Apparence globale de l'application.".into(), + ); + strings.insert("settings_theme_dark".into(), "Sombre".into()); + // Theme families + strings.insert("settings_theme_catppuccin".into(), "Catppuccin".into()); + strings.insert("settings_theme_catppuccin_latte".into(), "Latte".into()); + strings.insert("settings_theme_catppuccin_frappe".into(), "Frappé".into()); + strings.insert( + "settings_theme_catppuccin_macchiato".into(), + "Macchiato".into(), + ); + strings.insert("settings_theme_catppuccin_mocha".into(), "Mocha".into()); + strings.insert("settings_theme_gruvbox".into(), "Gruvbox".into()); + strings.insert("settings_theme_light".into(), "Mode clair".into()); + strings.insert("settings_theme_dark_mode".into(), "Mode sombre".into()); + strings.insert("settings_theme_everblush".into(), "Everblush".into()); + strings.insert("settings_theme_kanagawa".into(), "Kanagawa".into()); + strings.insert( + "settings_theme_kanagawa_journal".into(), + "Mode journal".into(), + ); + // New theme families + strings.insert("settings_theme_nord".into(), "Nord".into()); + strings.insert("settings_theme_dracula".into(), "Dracula".into()); + strings.insert("settings_theme_solarized".into(), "Solarized".into()); + strings.insert("settings_theme_tokyonight".into(), "Tokyo Night".into()); + strings.insert("settings_theme_tokyonight_night".into(), "Nuit".into()); + strings.insert("settings_theme_tokyonight_day".into(), "Jour".into()); + strings.insert("settings_theme_rosepine".into(), "Rosé Pine".into()); + strings.insert("settings_theme_rosepine_main".into(), "Principal".into()); + strings.insert("settings_theme_rosepine_moon".into(), "Lune".into()); + strings.insert("settings_theme_rosepine_dawn".into(), "Aurore".into()); + strings.insert("settings_theme_onedark".into(), "One Dark".into()); + strings.insert("settings_theme_monokai".into(), "Monokai Pro".into()); + strings.insert("settings_theme_monokai_pro".into(), "Pro".into()); + strings.insert("settings_theme_monokai_classic".into(), "Classic".into()); + strings.insert("settings_theme_monokai_spectrum".into(), "Spectrum".into()); + strings.insert("settings_theme_ayu".into(), "Ayu".into()); + strings.insert("settings_theme_ayu_mirage".into(), "Mirage".into()); + strings.insert("settings_theme_everforest".into(), "Everforest".into()); + strings.insert("settings_theme_material".into(), "Material".into()); + strings.insert("settings_theme_material_oceanic".into(), "Oceanic".into()); + strings.insert( + "settings_theme_material_palenight".into(), + "Palenight".into(), + ); + strings.insert( + "settings_theme_material_deepocean".into(), + "Deep Ocean".into(), + ); + strings.insert("settings_theme_flexoki".into(), "Flexoki".into()); + strings.insert("settings_theme_nightfox".into(), "Nightfox".into()); + strings.insert("settings_theme_nightfox_nightfox".into(), "Nightfox".into()); + strings.insert("settings_theme_nightfox_dawnfox".into(), "Dawnfox".into()); + strings.insert("settings_theme_sonokai".into(), "Sonokai".into()); + strings.insert("settings_theme_sonokai_default".into(), "Défaut".into()); + strings.insert("settings_theme_oxocarbon".into(), "Oxocarbon".into()); + strings.insert("settings_theme_nightowl".into(), "Night Owl".into()); + strings.insert("settings_theme_iceberg".into(), "Iceberg".into()); + strings.insert("settings_theme_horizon".into(), "Horizon".into()); + strings.insert("settings_theme_melange".into(), "Mélange".into()); + strings.insert("settings_theme_synthwave".into(), "Synthwave '84".into()); + strings.insert("settings_theme_modus".into(), "Modus".into()); + strings.insert("settings_theme_modus_operandi".into(), "Operandi".into()); + strings.insert("settings_theme_modus_vivendi".into(), "Vivendi".into()); + strings.insert( + "settings_theme_stellar_blade".into(), + "Stellar Blade".into(), + ); + strings.insert("settings_theme_stellar_blade_eve".into(), "EVE".into()); + strings.insert("settings_theme_stellar_blade_tachy".into(), "Tachy".into()); + strings.insert("settings_theme_stellar_blade_lily".into(), "Lily".into()); + strings.insert("settings_theme_stellar_blade_enya".into(), "Enya".into()); + strings.insert("settings_theme_stellar_blade_kaya".into(), "Kaya".into()); + // Colors & accents + strings.insert( + "settings_section_colors".into(), + "Couleurs & accents".into(), + ); + strings.insert( + "settings_colors_desc".into(), + "Personnalisez la couleur d'accent de l'interface.".into(), + ); + strings.insert("settings_accent_color".into(), "Couleur d'accent".into()); + strings.insert( + "settings_accent_color_desc".into(), + "Couleur utilisée pour les éléments interactifs.".into(), + ); + strings.insert("settings_accent_red".into(), "Rouge".into()); + strings.insert("settings_accent_orange".into(), "Orange".into()); + strings.insert("settings_accent_yellow".into(), "Jaune".into()); + strings.insert("settings_accent_green".into(), "Vert".into()); + strings.insert("settings_accent_blue".into(), "Bleu".into()); + strings.insert("settings_accent_indigo".into(), "Indigo".into()); + strings.insert("settings_accent_violet".into(), "Violet".into()); + strings.insert("settings_accent_amber".into(), "Ambre".into()); + strings.insert( + "settings_auto_accent".into(), + "Accent automatique selon le fond".into(), + ); + strings.insert( + "settings_auto_accent_desc".into(), + "Adapte automatiquement l'accent aux arrière-plans.".into(), + ); + strings.insert("settings_enabled_label".into(), "Activé".into()); + strings.insert("settings_disabled_label".into(), "Désactivé".into()); + strings.insert("settings_section_typography".into(), "Typographie".into()); + strings.insert( + "settings_typography_desc".into(), + "Configurez la police et la taille du texte.".into(), + ); + strings.insert("settings_font".into(), "Police".into()); + strings.insert( + "settings_font_desc".into(), + "Police utilisée dans l'interface.".into(), + ); + strings.insert("settings_font_size".into(), "Taille du texte".into()); + strings.insert( + "settings_font_size_desc".into(), + "Taille de base du texte.".into(), + ); + strings.insert("settings_font_size_default".into(), "Par défaut".into()); + strings.insert("settings_font_size_small".into(), "Petit".into()); + strings.insert("settings_font_size_large".into(), "Grand".into()); + strings.insert("settings_font_size_xlarge".into(), "Très grand".into()); + strings.insert( + "settings_section_effects".into(), + "Arrière-plans & effets".into(), + ); + strings.insert( + "settings_effects_desc".into(), + "Gérez les animations et effets visuels.".into(), + ); + strings.insert("settings_animations".into(), "Animations".into()); + strings.insert( + "settings_animations_desc".into(), + "Activer les transitions animées.".into(), + ); + strings.insert("settings_section_preview".into(), "Aperçu".into()); + strings.insert( + "settings_preview_card".into(), + "Carte de prévisualisation".into(), + ); + strings.insert( + "settings_preview_summary".into(), + "Thème: Sombre · Accent: Bleu · Texte: Par défaut · Effets: Activés".into(), + ); + // Accessibility + strings.insert( + "settings_accessibility_title".into(), + "Paramètres d'accessibilité".into(), + ); + strings.insert( + "settings_accessibility_desc".into(), + "Facilitez la lecture, la navigation et la lecture média.".into(), + ); + strings.insert("settings_section_vision".into(), "Vision".into()); + strings.insert( + "settings_vision_desc".into(), + "Options pour améliorer la lisibilité.".into(), + ); + strings.insert("settings_high_contrast".into(), "Contraste élevé".into()); + strings.insert( + "settings_high_contrast_desc".into(), + "Augmente le contraste des éléments.".into(), + ); + strings.insert("settings_disabled".into(), "Désactivé".into()); + strings.insert("settings_text_size_a11y".into(), "Taille du texte".into()); + strings.insert( + "settings_text_size_a11y_desc".into(), + "Ajustez la taille du texte pour le confort.".into(), + ); + strings.insert("settings_section_motion".into(), "Mouvement".into()); + strings.insert( + "settings_motion_desc".into(), + "Réduisez les animations pour le confort.".into(), + ); + strings.insert( + "settings_reduce_motion".into(), + "Réduire les animations".into(), + ); + strings.insert( + "settings_reduce_motion_desc".into(), + "Limite les transitions et mouvements.".into(), + ); + strings.insert( + "settings_section_navigation".into(), + "Navigation & interaction".into(), + ); + strings.insert( + "settings_navigation_desc".into(), + "Options de navigation au clavier et interaction.".into(), + ); + strings.insert("settings_keyboard_nav".into(), "Navigation clavier".into()); + strings.insert( + "settings_keyboard_nav_desc".into(), + "Naviguer avec Tab et les flèches.".into(), + ); + strings.insert("settings_section_reading".into(), "Lecture".into()); + strings.insert( + "settings_reading_desc".into(), + "Options de confort de lecture.".into(), + ); + strings.insert("settings_dyslexia_font".into(), "Police dyslexie".into()); + strings.insert( + "settings_dyslexia_font_desc".into(), + "Utiliser une police adaptée à la dyslexie.".into(), + ); + // Storage + strings.insert("settings_storage_title".into(), "Stockage".into()); + strings.insert( + "settings_storage_desc".into(), + "Gérez l'emplacement des applications et du cache.".into(), + ); + strings.insert("settings_section_scan".into(), "Scan".into()); + strings.insert( + "settings_scan_desc".into(), + "Configurez les dossiers analysés au démarrage.".into(), + ); + strings.insert("settings_scan_dirs".into(), "Dossiers de scan".into()); + strings.insert( + "settings_scan_dirs_desc".into(), + "Répertoires analysés pour les applications.".into(), + ); + strings.insert("settings_scan_dirs_value".into(), "Par défaut".into()); + strings.insert("settings_startup".into(), "Scanner au démarrage".into()); + strings.insert( + "settings_startup_desc".into(), + "Met à jour la bibliothèque au démarrage.".into(), + ); + strings.insert("settings_enabled".into(), "Activé".into()); + strings.insert("settings_section_install".into(), "Installation".into()); + strings.insert("settings_local_apps".into(), "Applications locales".into()); + strings.insert("settings_colony_repos".into(), "Dépôts Colony".into()); + strings.insert("settings_favorites".into(), "Favoris".into()); + // Placeholders + strings.insert("settings_coming_soon".into(), "Bientôt".into()); + // About + strings.insert("settings_about_title".into(), "À propos de Colony".into()); + strings.insert("settings_about".into(), "À propos".into()); + strings.insert("settings_version".into(), "Colony v0.1.0".into()); + // Launcher self-update + strings.insert( + "launcher_update_available".into(), + "Colony {version} est disponible !".into(), + ); + strings.insert( + "launcher_update_available_short".into(), + "\u{f0aa} Mise à jour {version}".into(), + ); + strings.insert( + "launcher_update_ready".into(), + "Mise à jour prête. Cliquez pour relancer Colony.".into(), + ); + strings.insert( + "launcher_restart_to_update".into(), + "\u{f021} Relancer pour mettre à jour".into(), + ); + strings.insert( + "launcher_download_update".into(), + "Télécharger la mise à jour {version}".into(), + ); + strings.insert( + "launcher_update_failed".into(), + "Échec de la mise à jour : {error}".into(), + ); + strings.insert( + "check_launcher_updates".into(), + "Vérifier les mises à jour".into(), + ); + strings.insert("launcher_up_to_date".into(), "Colony est à jour".into()); + strings.insert("update_all".into(), "Tout mettre à jour ({count})".into()); + strings.insert("whats_new".into(), "Nouveautés de {version}".into()); + 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("clear_caches".into(), "Vider les caches du store".into()); + strings.insert("clear_caches_desc".into(), "Supprime les descriptions et icônes mises en cache (elles se re-téléchargent au prochain rafraîchissement). Les applications installées ne sont pas touchées.".into()); + strings.insert( + "caches_cleared".into(), + "{count} cache(s) supprimé(s)".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()); + strings.insert("tab_license".into(), "License".into()); + strings.insert("tab_changelog".into(), "Changelog".into()); + strings.insert("tab_loading".into(), "Chargement...".into()); + strings.insert("tab_not_available".into(), "Non disponible".into()); +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..815f7c9 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,187 @@ +mod en; +mod fr; + +use std::collections::HashMap; +use std::sync::RwLock; + +static LOCALE: RwLock> = RwLock::new(None); + +pub struct Locale { + strings: HashMap, + lang: String, +} + +impl Locale { + fn new(lang: &str) -> Self { + let mut strings = HashMap::new(); + + match lang { + "fr" => fr::insert_all(&mut strings), + _ => en::insert_all(&mut strings), + } + + Self { + strings, + lang: lang.to_string(), + } + } +} + +/// Initialize the locale system. Call once at startup. +/// +/// `preferred` is the user's saved language preference ("fr"/"en"); when it is +/// a recognized language it wins over environment detection, so the in-app +/// language picker actually takes effect on the next launch. Falls back to +/// `detect_language()` (LC_ALL / LC_MESSAGES / LANG) when unset or unknown. +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}"); + if let Ok(mut locale) = LOCALE.write() { + *locale = Some(Locale::new(lang)); + } +} + +/// Localized display name for a built-in sidebar section, keyed by its +/// canonical English name. Custom/user sections (no matching key) fall back to +/// their raw name so nothing is lost. +pub fn section_display_name(name: &str) -> String { + let key = match name.to_lowercase().as_str() { + "all" => "section_all", + "favorites" | "favoris" => "section_favorites", + "windows" => "section_windows", + "linux" => "section_linux", + "development" => "section_development", + "graphics" => "section_graphics", + "network" => "section_network", + "office" => "section_office", + "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 + .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 + .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() + }) +} + +/// Get a translated string with variable substitution. +/// Variables use `{name}` syntax. +pub fn t_fmt(key: &str, vars: &[(&str, &str)]) -> String { + let mut result = t(key); + for (name, value) in vars { + result = result.replace(&format!("{{{name}}}"), value); + } + result +} + +/// Get the current language code. +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. +fn detect_language() -> String { + // Check LANG, LC_ALL, LC_MESSAGES + for var in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(val) = std::env::var(var) { + let lang = val.split('.').next().unwrap_or(&val); + let lang = lang.split('_').next().unwrap_or(lang); + if lang == "fr" { + return "fr".to_string(); + } + } + } + + "en".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn english_locale_has_keys() { + let locale = Locale::new("en"); + assert!(locale.strings.contains_key("categories")); + assert!(locale.strings.contains_key("github_login")); + assert!(locale.strings.contains_key("back")); + assert!(locale.strings.contains_key("error_thread_panic")); + assert!(locale.strings.contains_key("confirm_uninstall")); + assert!(locale.strings.contains_key("welcome_title")); + assert!(locale.strings.contains_key("download_cancelled")); + assert!(locale.strings.contains_key("add_favorite")); + } + + #[test] + fn french_locale_has_keys() { + let locale = Locale::new("fr"); + assert_eq!(locale.strings.get("categories").unwrap(), "Catégories"); + assert_eq!(locale.strings.get("back").unwrap(), "Retour"); + assert!(locale.strings.contains_key("error_thread_panic")); + assert!(locale.strings.contains_key("confirm_uninstall")); + assert!(locale.strings.contains_key("welcome_title")); + } + + #[test] + fn unknown_lang_defaults_to_english() { + let locale = Locale::new("xx"); + assert_eq!(locale.strings.get("categories").unwrap(), "Categories"); + } + + #[test] + fn fr_and_en_have_identical_key_sets() { + let fr = Locale::new("fr"); + let en = Locale::new("en"); + let fr_keys: std::collections::BTreeSet<&String> = fr.strings.keys().collect(); + let en_keys: std::collections::BTreeSet<&String> = en.strings.keys().collect(); + let only_fr: Vec<&&String> = fr_keys.difference(&en_keys).collect(); + let only_en: Vec<&&String> = en_keys.difference(&fr_keys).collect(); + assert!( + only_fr.is_empty() && only_en.is_empty(), + "Locale key mismatch — only in fr: {only_fr:?}; only in en: {only_en:?}" + ); + } + + #[test] + fn t_fmt_substitution() { + // Initialize with English for test + set_language("en"); + let result = t_fmt("apps_found", &[("count", "42")]); + assert_eq!(result, "42 applications found"); + } +}