From 4253bcacbeac8d8b465f9fb81b710f3551eeac38 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 20 Jul 2026 17:37:35 +0200 Subject: [PATCH] fix: browse and install from the catalog without GitHub sign-in The welcome flow and the GitHub panel both promise that signing in is optional ('Public GitHub API (60 req/h)'), but the catalog lived inside GitHubState::Connected: every fetch path required a token and repos were only stored while connected, so a first-run user saw an empty Hive until they completed OAuth - and a signed-out user lost the catalog entirely. - The catalog moves out of GitHubState into its own App field: public data, independent of the session. Signing in only raises the rate limit and unlocks account features. - Boot now loads the on-disk repos cache immediately (instant, offline- friendly grid) and always starts a fetch - authenticated when a token is saved, anonymous otherwise. Cached repo icons are decoded at boot too, instead of waiting for the first network round-trip. - Refresh works while signed out (anonymous fetch), and login completion now sets is_fetching_repos like every other fetch path, closing the duplicate-fetch window. - A failed fetch only raises an error toast when the catalog is EMPTY; with a cached catalog showing, the failure lands in the status line - otherwise every offline boot would toast an error. Verified end-to-end: a boot with clean XDG dirs and no reachable keyring (no token) populates the catalog anonymously. --- src/main.rs | 194 +++++++++++++--------- src/state.rs | 60 ++++--- src/ui/github_panel.rs | 187 +++++++++++---------- src/update.rs | 366 +++++++++++++++++++++++++---------------- 4 files changed, 477 insertions(+), 330 deletions(-) diff --git a/src/main.rs b/src/main.rs index a322bcc..b393d37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,8 +3,8 @@ mod config; mod download; mod github; -mod icons; mod i18n; +mod icons; mod message; mod oauth; mod persistence; @@ -16,19 +16,17 @@ mod ui; mod update; use iced::font; -use iced::widget::{ - button, column, container, mouse_area, opaque, row, stack, text, Column, -}; use iced::keyboard; +use iced::widget::{button, column, container, mouse_area, opaque, row, stack, text, Column}; use iced::{Element, Fill, Subscription, Task, Theme}; -use ui::theme::{Palette, set_active_theme, set_active_accent, set_high_contrast, accent_key_to_color}; 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::{ - App, DetailTab, GitHubState, - APP_FONT_BYTES, DYSLEXIA_FONT_BYTES, FA_FONT_BYTES, - default_font, + default_font, App, DetailTab, GitHubState, APP_FONT_BYTES, DYSLEXIA_FONT_BYTES, FA_FONT_BYTES, }; pub fn main() -> iced::Result { @@ -42,26 +40,21 @@ pub fn main() -> iced::Result { // Honor the saved language preference over environment locale detection. i18n::init(github::load_preferences().language); - iced::application( - App::boot, - App::update, - App::view, - ) - .title(App::title) - .theme(App::theme) - .subscription(App::subscription) - .default_font(default_font()) - .window_size((1000.0, 700.0)) - .run() + iced::application(App::boot, App::update, App::view) + .title(App::title) + .theme(App::theme) + .subscription(App::subscription) + .default_font(default_font()) + .window_size((1000.0, 700.0)) + .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 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)); @@ -97,9 +90,7 @@ impl App { prefs.selected_section.unwrap_or(0) } else { match default_view.as_str() { - "favorites" => { - sections.iter().position(|s| s.is_favorites).unwrap_or(0) - } + "favorites" => sections.iter().position(|s| s.is_favorites).unwrap_or(0), _ => 0, } }; @@ -107,36 +98,34 @@ impl App { // Try to restore a saved OAuth session (load the token exactly once). let saved_token = oauth::load_saved_token(); - let has_token = saved_token.is_some(); let github_state = match saved_token { Some(session) => { tracing::info!("Restored GitHub session for {:?}", session.username); - GitHubState::Connected { - session, - repos: Vec::new(), - } + GitHubState::Connected { session } } None => GitHubState::Disconnected, }; - // If we have a saved token, kick off a repo fetch - let startup_task = match &github_state { - GitHubState::Connected { session, .. } => { - let token = session.access_token.clone(); - 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()), - }, - ) - } - _ => Task::none(), + // 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 = github::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 app = Self { + let mut app = Self { applications, search_query: String::new(), sections, @@ -146,6 +135,7 @@ impl App { font, github_state, show_github_menu: false, + colony_repo_list, notifications: Vec::new(), next_notification_id: 0, app_icons: std::collections::HashMap::new(), @@ -158,21 +148,36 @@ impl App { 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()), + 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().to_string()), + language: prefs + .language + .clone() + .unwrap_or_else(|| i18n::current_lang().to_string()), auto_check_updates: prefs.auto_check_updates.unwrap_or(false), // 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()), + 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), @@ -182,7 +187,8 @@ impl App { is_scanning: false, is_downloading: false, is_checking_updates: false, - is_fetching_repos: has_token, + // 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 @@ -202,6 +208,10 @@ impl App { launcher_update_staged: None, }; + // 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(); + set_active_theme(&app.selected_theme, &app.selected_variant); set_high_contrast(app.high_contrast); if !app.auto_accent { @@ -227,7 +237,16 @@ impl App { Task::none() }; - (app, Task::batch([load_fonts(), startup_task, launcher_check_task, tutorial_task, scan_task])) + ( + app, + Task::batch([ + load_fonts(), + startup_task, + launcher_check_task, + tutorial_task, + scan_task, + ]), + ) } fn title(&self) -> String { @@ -247,9 +266,7 @@ impl App { let main_layout = row![sidebar, content].spacing(0); - let page = container(main_layout) - .width(Fill) - .height(Fill); + 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(); @@ -259,7 +276,10 @@ impl App { let pct = (progress * 100.0) as u32; let bar_label = format!("\u{f019} {} — {}%", filename, pct); let cancel_btn = button( - text("\u{f00d}").size(self.sz(12)).font(self.app_font()).color(Palette::TEXT_DIMMER()) + text("\u{f00d}") + .size(self.sz(12)) + .font(self.app_font()) + .color(Palette::TEXT_DIMMER()), ) .on_press(Message::CancelDownload) .padding([4, 8]) @@ -346,13 +366,11 @@ impl App { ) .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() - } + .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()); } @@ -361,15 +379,17 @@ impl App { 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); + 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() }; @@ -387,7 +407,9 @@ impl App { container(text("")).height(16), row![ button( - text(i18n::t("cancel")).size(self.sz(13)).font(self.app_font()) + text(i18n::t("cancel")) + .size(self.sz(13)) + .font(self.app_font()) ) .on_press(Message::CancelUninstall) .padding([10, 20]) @@ -404,7 +426,9 @@ impl App { } }), button( - text(i18n::t("confirm_delete")).size(self.sz(13)).font(self.app_font()) + text(i18n::t("confirm_delete")) + .size(self.sz(13)) + .font(self.app_font()) ) .on_press(Message::UninstallColonyApp(repo_clone)) .padding([10, 20]) @@ -441,16 +465,22 @@ impl App { .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()), + 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), - ); + let modal = opaque(mouse_area(backdrop).on_press(Message::CancelUninstall)); return stack![base, modal].width(Fill).height(Fill).into(); } @@ -481,6 +511,10 @@ impl App { 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 } + if luma > 0.5 { + Theme::Light + } else { + Theme::Dark + } } } diff --git a/src/state.rs b/src/state.rs index c229cc2..286464e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -19,7 +19,10 @@ pub fn ease_out_cubic(t: f32) -> f32 { /// Apply alpha to a Color by multiplying its existing alpha channel. pub fn with_alpha(color: iced::Color, alpha: f32) -> iced::Color { - iced::Color { a: color.a * alpha, ..color } + iced::Color { + a: color.a * alpha, + ..color + } } // --- Font constants --- @@ -116,10 +119,7 @@ impl Notification { pub enum GitHubState { Disconnected, Connecting { user_code: Option }, - Connected { - session: OAuthSession, - repos: Vec, - }, + Connected { session: OAuthSession }, Error(String), } @@ -145,6 +145,13 @@ pub struct App { // GitHub / OAuth pub github_state: GitHubState, pub show_github_menu: bool, + /// The store catalog. Lives OUTSIDE GitHubState on purpose: the catalog is + /// public data, browsable anonymously (60 req/h unauthenticated GitHub API) + /// and restored from the on-disk cache at boot - signing in only raises the + /// rate limit and unlocks account features. Keyed UI state (e.g. the open + /// detail page) must reference repos by NAME, not index: refreshes replace + /// and reorder this vector. + pub colony_repo_list: Vec, // Notifications pub notifications: Vec, pub next_notification_id: u64, @@ -172,21 +179,21 @@ pub struct App { pub show_settings: bool, pub settings_category: usize, // Appearance - pub selected_theme: String, // e.g. "gruvbox" - pub selected_variant: String, // e.g. "dark" - pub selected_accent: String, // e.g. "blue" + pub selected_theme: String, // e.g. "gruvbox" + pub selected_variant: String, // e.g. "dark" + pub selected_accent: String, // e.g. "blue" pub auto_accent: bool, // General preferences pub restore_session: bool, - pub default_view: String, // "all", "favorites" - pub language: String, // "fr", "en" + pub default_view: String, // "all", "favorites" + pub language: String, // "fr", "en" pub auto_check_updates: bool, // Appearance extras - pub font_size: String, // "small", "default", "large" + pub font_size: String, // "small", "default", "large" pub animations: bool, // Accessibility pub high_contrast: bool, - pub text_size_a11y: String, // "small", "default", "large", "xlarge" + pub text_size_a11y: String, // "small", "default", "large", "xlarge" pub reduce_motion: bool, pub keyboard_nav: bool, pub dyslexia_font: bool, @@ -213,24 +220,20 @@ pub struct App { /// Message::UpdatesChecked; read by the grid cards to show an update badge. pub available_updates: std::collections::HashMap, // Launcher self-update - pub launcher_update_available: Option<(String, String)>, // (tag, asset_filename) + pub launcher_update_available: Option<(String, String)>, // (tag, asset_filename) pub is_checking_launcher_update: bool, pub launcher_update_staged: Option, // Animation state - pub progress_display: f32, // smoothly interpolated progress bar value - pub sidebar_indicator_from: f32, // start Y position of current animation - pub sidebar_indicator_target: f32, // target Y position + pub progress_display: f32, // smoothly interpolated progress bar value + pub sidebar_indicator_from: f32, // start Y position of current animation + pub sidebar_indicator_target: f32, // target Y position pub sidebar_indicator_start: Option, // when the animation started (None = idle) } impl App { - /// Get the list of Colony repos from GitHub state. + /// Get the store catalog (available signed-in or anonymous). pub fn colony_repos(&self) -> &[ColonyRepo] { - if let GitHubState::Connected { repos, .. } = &self.github_state { - repos - } else { - &[] - } + &self.colony_repo_list } /// Filter local applications by the currently selected section. @@ -303,9 +306,15 @@ impl App { pub fn app_font_with_weight(&self, weight: Weight) -> Font { if self.dyslexia_font { - Font { weight, ..Font::with_name(DYSLEXIA_FONT_NAME) } + Font { + weight, + ..Font::with_name(DYSLEXIA_FONT_NAME) + } } else { - Font { weight, ..self.font } + Font { + weight, + ..self.font + } } } @@ -340,7 +349,8 @@ impl App { let elapsed = start.elapsed().as_secs_f32() * 1000.0; let t = (elapsed / Self::SIDEBAR_ANIM_MS).min(1.0); let eased = ease_out_cubic(t); - self.sidebar_indicator_from + (self.sidebar_indicator_target - self.sidebar_indicator_from) * eased + self.sidebar_indicator_from + + (self.sidebar_indicator_target - self.sidebar_indicator_from) * eased } None => self.sidebar_indicator_target, } diff --git a/src/ui/github_panel.rs b/src/ui/github_panel.rs index 51af004..e1d66f4 100644 --- a/src/ui/github_panel.rs +++ b/src/ui/github_panel.rs @@ -3,9 +3,9 @@ use iced::widget::{button, column, container, row, scrollable, text, Column}; use iced::{Element, Fill, Length}; use crate::github::ColonyRepo; -use crate::ui::theme::Palette; -use crate::state::{App, GitHubState}; use crate::message::Message; +use crate::state::{App, GitHubState}; +use crate::ui::theme::Palette; impl App { pub(crate) fn view_github_panel(&self) -> Element<'_, Message> { @@ -23,17 +23,21 @@ impl App { let login_btn_content = row![ text("\u{f09b}").size(self.sz(18)).font(self.app_font()), - text(crate::i18n::t("github_login")).size(self.sz(14)).font(self.app_font()), + text(crate::i18n::t("github_login")) + .size(self.sz(14)) + .font(self.app_font()), ] .spacing(10) .align_y(iced::Alignment::Center); let login_btn = button(login_btn_content) - .on_press_maybe(if matches!(self.github_state, crate::GitHubState::Connecting { .. }) { - None - } else { - Some(Message::GitHubLogin) - }) + .on_press_maybe( + if matches!(self.github_state, crate::GitHubState::Connecting { .. }) { + None + } else { + Some(Message::GitHubLogin) + }, + ) .padding([12, 24]) .style(|_theme, status| { let bg = match status { @@ -54,76 +58,93 @@ impl App { .font(self.app_font()) .color(Palette::TEXT_MUTED()); - column![desc, container(text("")).height(16), login_btn, container(text("")).height(12), info] - .spacing(8) - .into() + column![ + desc, + container(text("")).height(16), + login_btn, + container(text("")).height(12), + info + ] + .spacing(8) + .into() } - GitHubState::Connecting { user_code } => { - match user_code { - Some(code) => { - let spinner_label = text("\u{f110} ") - .size(self.sz(14)) - .font(self.app_font()) - .color(Palette::ACCENT()); - let label = text(crate::i18n::t("github_enter_code")) - .size(self.sz(14)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); - let code_btn = button( - text(code.as_str()) - .size(self.sz(28)) - .font(self.app_font()) - .color(Palette::TEXT_SECONDARY()) - ) - .on_press(Message::CopyToClipboard(code.clone())) - .padding([8, 16]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_SELECTED(), - _ => Palette::BG_INPUT(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_SECONDARY(), - border: iced::Border::default().rounded(8), - ..Default::default() - } - }); - let hint = text(crate::i18n::t("github_copy_hint")) - .size(self.sz(12)) + GitHubState::Connecting { user_code } => match user_code { + Some(code) => { + let spinner_label = text("\u{f110} ") + .size(self.sz(14)) + .font(self.app_font()) + .color(Palette::ACCENT()); + let label = text(crate::i18n::t("github_enter_code")) + .size(self.sz(14)) + .font(self.app_font()) + .color(Palette::TEXT_MUTED()); + let code_btn = button( + text(code.as_str()) + .size(self.sz(28)) .font(self.app_font()) - .color(Palette::TEXT_DIMMEST()); - column![spinner_label, label, code_btn, hint] - .spacing(8) - .into() - } - None => { - row![ - text("\u{f110}").size(self.sz(16)).font(self.app_font()).color(Palette::ACCENT()), - text(crate::i18n::t("github_connecting")) - .size(self.sz(16)) - .font(self.app_font()) - .color(Palette::TEXT_SECONDARY()), - ] + .color(Palette::TEXT_SECONDARY()), + ) + .on_press(Message::CopyToClipboard(code.clone())) + .padding([8, 16]) + .style(|_theme, status| { + let bg = match status { + button::Status::Hovered => Palette::BG_SELECTED(), + _ => Palette::BG_INPUT(), + }; + button::Style { + background: Some(bg.into()), + text_color: Palette::TEXT_SECONDARY(), + border: iced::Border::default().rounded(8), + ..Default::default() + } + }); + let hint = text(crate::i18n::t("github_copy_hint")) + .size(self.sz(12)) + .font(self.app_font()) + .color(Palette::TEXT_DIMMEST()); + column![spinner_label, label, code_btn, hint] .spacing(8) - .align_y(iced::Alignment::Center) .into() - } } - } - GitHubState::Connected { session, repos } => { + None => row![ + text("\u{f110}") + .size(self.sz(16)) + .font(self.app_font()) + .color(Palette::ACCENT()), + text(crate::i18n::t("github_connecting")) + .size(self.sz(16)) + .font(self.app_font()) + .color(Palette::TEXT_SECONDARY()), + ] + .spacing(8) + .align_y(iced::Alignment::Center) + .into(), + }, + GitHubState::Connected { session } => { + let repos = self.colony_repos(); let connected_text = format!( "{}{}", crate::i18n::t("github_connected"), - session.username.as_ref().map(|u| format!(" — {u}")).unwrap_or_default() + session + .username + .as_ref() + .map(|u| format!(" — {u}")) + .unwrap_or_default() ); let user_label: Element<'_, Message> = if self.is_fetching_repos { row![ - text("\u{f110}").size(self.sz(14)).font(self.app_font()).color(Palette::ACCENT()), - text(format!("{} ({})", connected_text, crate::i18n::t("syncing_repos"))) + text("\u{f110}") .size(self.sz(14)) .font(self.app_font()) - .color(Palette::SUCCESS()), + .color(Palette::ACCENT()), + text(format!( + "{} ({})", + connected_text, + crate::i18n::t("syncing_repos") + )) + .size(self.sz(14)) + .font(self.app_font()) + .color(Palette::SUCCESS()), ] .spacing(8) .align_y(iced::Alignment::Center) @@ -150,18 +171,18 @@ impl App { } let repo_list = if repo_list_items.is_empty() { - column![ - text(crate::i18n::t("github_no_repos")) - .size(self.sz(13)) - .font(self.app_font()) - .color(Palette::TEXT_DIMMER()) - ] + column![text(crate::i18n::t("github_no_repos")) + .size(self.sz(13)) + .font(self.app_font()) + .color(Palette::TEXT_DIMMER())] } else { Column::with_children(repo_list_items).spacing(8) }; let refresh_btn_base = button( - text(crate::i18n::t("github_refresh")).size(self.sz(13)).font(self.app_font()), + text(crate::i18n::t("github_refresh")) + .size(self.sz(13)) + .font(self.app_font()), ) .padding([8, 16]); let refresh_btn = if self.is_fetching_repos { @@ -183,7 +204,9 @@ impl App { }); let logout_btn = button( - text(crate::i18n::t("github_logout")).size(self.sz(13)).font(self.app_font()), + text(crate::i18n::t("github_logout")) + .size(self.sz(13)) + .font(self.app_font()), ) .on_press(Message::GitHubLogout) .padding([8, 16]) @@ -221,7 +244,9 @@ impl App { .color(Palette::ERROR()); let retry_btn = button( - text(crate::i18n::t("github_retry")).size(self.sz(13)).font(self.app_font()), + text(crate::i18n::t("github_retry")) + .size(self.sz(13)) + .font(self.app_font()), ) .on_press(Message::GitHubLogin) .padding([8, 16]); @@ -232,15 +257,11 @@ impl App { } }; - let panel = column![ - header_text, - container(text("")).height(16), - content, - ] - .spacing(8) - .padding(24) - .width(Fill) - .height(Fill); + let panel = column![header_text, container(text("")).height(16), content,] + .spacing(8) + .padding(24) + .width(Fill) + .height(Fill); container(panel) .style(|_theme| container::Style { diff --git a/src/update.rs b/src/update.rs index 837c489..cb06f86 100644 --- a/src/update.rs +++ b/src/update.rs @@ -8,10 +8,16 @@ 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}; +use crate::ui::theme::{ + accent_key_to_color, set_active_accent, set_active_theme, set_high_contrast, +}; impl App { - pub fn push_notification(&mut self, message: String, level: NotificationLevel) -> Task { + pub fn push_notification( + &mut self, + message: String, + level: NotificationLevel, + ) -> Task { let id = self.next_notification_id; self.next_notification_id += 1; let timeout = match level { @@ -19,13 +25,16 @@ impl App { NotificationLevel::Warning => Duration::from_secs(7), NotificationLevel::Info => Duration::from_secs(5), }; - self.notifications.push(Notification::new(id, message, level)); + 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; }, + async move { + tokio::time::sleep(timeout).await; + }, |_| Message::TickNotifications, ) } @@ -168,7 +177,8 @@ impl App { self.is_scanning = false; match result { Ok(apps) => { - self.status_message = i18n::t_fmt("apps_found", &[("count", &apps.len().to_string())]); + 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 @@ -200,7 +210,9 @@ impl App { .args(["/C", "start", "", &exec]) .spawn() .map(|_| ()) - .map_err(|error| i18n::t_fmt("launch_error", &[("error", &error.to_string())])) + .map_err(|error| { + i18n::t_fmt("launch_error", &[("error", &error.to_string())]) + }) } #[cfg(not(windows))] @@ -214,13 +226,19 @@ impl App { .spawn() .map(|_| ()) .map_err(|error| { - i18n::t_fmt("launch_error", &[("error", &error.to_string())]) + 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())])), + Err(error) => Err(i18n::t_fmt( + "launch_error", + &[("error", &error.to_string())], + )), } } }; @@ -229,7 +247,9 @@ impl App { Ok(()) => { self.status_message = i18n::t("app_launched"); Task::perform( - async { tokio::time::sleep(Duration::from_secs(4)).await; }, + async { + tokio::time::sleep(Duration::from_secs(4)).await; + }, |_| Message::ClearStatus, ) } @@ -251,7 +271,10 @@ impl App { Task::none() } Message::ClearStatus => { - self.status_message = i18n::t_fmt("apps_found", &[("count", &self.applications.len().to_string())]); + self.status_message = i18n::t_fmt( + "apps_found", + &[("count", &self.applications.len().to_string())], + ); Task::none() } Message::FontLoaded(_) => Task::none(), @@ -272,34 +295,34 @@ impl App { 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::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(), - repos: Vec::new(), }; self.status_message = format!( "{}{}", @@ -311,10 +334,11 @@ impl App { .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 - }, + async move { github::fetch_colony_repos(Some(&token)).await }, |result| match result { Ok(repos) => Message::GitHubReposFetched(repos), Err(e) => Message::GitHubError(e.to_string()), @@ -324,7 +348,10 @@ impl App { 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) + self.push_notification( + i18n::t_fmt("oauth_error", &[("error", &e)]), + NotificationLevel::Error, + ) } } } @@ -340,15 +367,16 @@ impl App { if let Err(e) = github::save_repos_cache(&repos) { tracing::warn!("Failed to save repos cache: {e}"); } - if let GitHubState::Connected { repos: r, .. } = &mut self.github_state { - *r = repos; - } + // The catalog is stored regardless of sign-in state: anonymous + // fetches land here too. + self.colony_repo_list = repos; // Decode any freshly-cached app icons into image handles. self.reload_app_icons(); // 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())]); + self.status_message = + i18n::t_fmt("github_repos_detected", &[("count", &count.to_string())]); if self.auto_check_updates { Task::done(Message::CheckUpdates) } else { @@ -358,18 +386,27 @@ impl App { Message::GitHubError(e) => { self.is_fetching_repos = false; tracing::error!(error = %e, "GitHub error"); - if let GitHubState::Connected { repos, .. } = &mut self.github_state { - if repos.is_empty() { - if let Some(cached) = github::load_repos_cache() { - tracing::info!("Using {} cached repos as fallback", cached.len()); - *repos = cached; - } + if self.colony_repo_list.is_empty() { + if let Some(cached) = github::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)]); - self.push_notification(i18n::t_fmt("github_api_error", &[("error", &e)]), NotificationLevel::Error) + 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 { @@ -384,16 +421,19 @@ impl App { let binary = entry.binary.clone(); let expected_sha256 = entry.sha256.clone(); let repo_name = repo.name.clone(); - let token = if let GitHubState::Connected { session, .. } = &self.github_state { - Some(session.access_token.clone()) - } else { - None - }; - let display_name = file.as_deref() + let token = + if let GitHubState::Connected { session, .. } = &self.github_state { + Some(session.access_token.clone()) + } else { + None + }; + 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.status_message = + i18n::t_fmt("downloading", &[("file", &display_name)]); self.download_progress = Some((display_name.clone(), 0.0)); self.is_downloading = true; let dl_repo = repo_name.clone(); @@ -403,21 +443,33 @@ impl App { 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 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 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)? + github::find_asset_by_pattern( + &release_info.asset_names, + pattern, + )? } else { - anyhow::bail!("colony.json: 'file' or 'filePattern' is required"); + 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"))?; + let f = file.ok_or_else(|| { + anyhow::anyhow!( + "colony.json: 'file' or 'filePattern' is required" + ) + })?; (tag, f) }; @@ -429,11 +481,13 @@ impl App { binary, expected_sha256, Some(progress_tx), - ).await?; + ) + .await?; // Save resolved asset name when using filePattern (for installed_app_path) if file_pattern.is_some() { - let _ = github::save_installed_asset(&repo_name, &resolved_file); + let _ = + github::save_installed_asset(&repo_name, &resolved_file); } Ok((path, dl_repo, resolved_tag)) @@ -443,10 +497,9 @@ impl App { }, ); - let progress_task = Task::run( - progress_rx, - move |pct| Message::DownloadProgress(progress_name.clone(), pct), - ); + let progress_task = Task::run(progress_rx, move |pct| { + Message::DownloadProgress(progress_name.clone(), pct) + }); // Keep an abort handle so CancelDownload actually stops // the download and its progress stream (dropping the @@ -456,7 +509,8 @@ impl App { self.download_abort = Some(handle); return task; } else { - self.status_message = i18n::t_fmt("no_release_for", &[("platform", &platform_key)]); + self.status_message = + i18n::t_fmt("no_release_for", &[("platform", &platform_key)]); } } Task::none() @@ -478,7 +532,8 @@ impl App { if let Err(e) = github::save_installed_version(&repo_name, &tag) { tracing::warn!("Failed to save version info: {e}"); } - let display_name = path.file_name() + 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) @@ -491,7 +546,10 @@ impl App { } Err(e) => { self.status_message = i18n::t_fmt("download_error", &[("error", &e)]); - self.push_notification(i18n::t_fmt("download_error", &[("error", &e)]), NotificationLevel::Error) + self.push_notification( + i18n::t_fmt("download_error", &[("error", &e)]), + NotificationLevel::Error, + ) } } } @@ -514,15 +572,15 @@ impl App { .spawn() .map(|_| ()); #[cfg(not(windows))] - let result = std::process::Command::new(&path) - .spawn() - .map(|_| ()); + 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; }, + async { + tokio::time::sleep(Duration::from_secs(4)).await; + }, |_| Message::ClearStatus, ) } @@ -548,18 +606,23 @@ impl App { let app_dir = apps_dir.join(&repo_name); 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())]); + self.status_message = + i18n::t_fmt("uninstall_error", &[("error", &e.to_string())]); } else { - self.status_message = i18n::t_fmt("uninstalled", &[("name", &repo_name)]); + self.status_message = + i18n::t_fmt("uninstalled", &[("name", &repo_name)]); return Task::perform( - async { tokio::time::sleep(Duration::from_secs(4)).await; }, + 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())]); + self.status_message = + i18n::t_fmt("scan_error", &[("error", &e.to_string())]); } } Task::none() @@ -569,23 +632,22 @@ impl App { return Task::none(); } self.is_fetching_repos = true; - if let GitHubState::Connected { session, .. } = &self.github_state { - let token = session.access_token.clone(); - return 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()), - }, - ); - } - Task::none() - } - Message::CopyToClipboard(value) => { - iced::clipboard::write(value) + // Anonymous refresh is supported: the token only raises the + // rate limit (60 req/h unauthenticated vs 5000 signed-in). + let token = if let GitHubState::Connected { session } = &self.github_state { + Some(session.access_token.clone()) + } else { + 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()), + }, + ) } + Message::CopyToClipboard(value) => iced::clipboard::write(value), Message::OpenUrl(url) => { if let Err(err) = open::that(&url) { tracing::warn!("failed to open url {url:?}: {err}"); @@ -610,7 +672,9 @@ impl App { // 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; } + if (1.0 - notif.fade_in) < SNAP { + notif.fade_in = 1.0; + } } // Start fade-out before expiration let timeout = match notif.level { @@ -624,7 +688,9 @@ impl App { // 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; } + if notif.fade_out < SNAP { + notif.fade_out = 0.0; + } } } self.notifications.retain(|n| n.fade_out > 0.0); @@ -674,13 +740,19 @@ impl App { } } iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) - if !self.show_settings && !self.show_github_menu && !self.show_first_launch => + 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 }; + self.selected_section = if self.selected_section == 0 { + len - 1 + } else { + self.selected_section - 1 + }; } else { self.selected_section = (self.selected_section + 1) % len; } @@ -740,7 +812,8 @@ impl App { // 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() + let repos: Vec<(String, String)> = self + .colony_repos() .iter() .filter(|r| github::installed_app_path(r).is_some()) .filter_map(|r| { @@ -756,7 +829,10 @@ impl App { // 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())]); + self.status_message = i18n::t_fmt( + "apps_found", + &[("count", &self.applications.len().to_string())], + ); return Task::done(Message::CheckLauncherUpdate); } @@ -772,15 +848,24 @@ impl App { 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() + 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, ) @@ -791,11 +876,20 @@ impl App { // 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())]); + 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(", "))]); + 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 @@ -989,10 +1083,8 @@ impl App { } // Give explicit feedback that the check ran and Colony is // current, instead of silently doing nothing. - None => self.push_notification( - i18n::t("launcher_up_to_date"), - NotificationLevel::Info, - ), + None => self + .push_notification(i18n::t("launcher_up_to_date"), NotificationLevel::Info), } } Message::DownloadLauncherUpdate => { @@ -1025,10 +1117,7 @@ impl App { Message::LauncherDownloadCompleted, ); - let progress_task = Task::run( - progress_rx, - Message::LauncherDownloadProgress, - ); + let progress_task = Task::run(progress_rx, Message::LauncherDownloadProgress); let (task, handle) = Task::batch([download_task, progress_task]).abortable(); self.download_abort = Some(handle); @@ -1062,31 +1151,24 @@ impl App { } } } - Message::ApplyLauncherUpdate(new_binary) => { - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - github::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)) - } - } - }, - ) - } + Message::ApplyLauncherUpdate(new_binary) => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + github::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)), + }, + ), } } }