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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ const MAX_CONCURRENT_REPO_FETCHES: usize = 8;
pub use crate::download::{apply_launcher_update, download_launcher_asset, download_release_asset};
pub use crate::persistence::{
colony_apps_dir, colony_data_dir, installed_app_path, load_favorites,
load_installed_version, load_preferences, load_repos_cache, read_repo_doc, save_favorites,
save_installed_asset, save_installed_version, save_preferences, save_repos_cache, save_scan_cache,
CachedApp, UserPreferences,
load_installed_version, load_preferences, load_repos_cache, load_scan_cache, read_repo_doc,
save_favorites, save_installed_asset, save_installed_version, save_preferences,
save_repos_cache, save_scan_cache, CachedApp, UserPreferences,
};
use crate::persistence::save_repo_doc;
use crate::persistence::save_repo_icon;
Expand Down Expand Up @@ -832,21 +832,21 @@ pub fn launcher_asset_name() -> String {

/// 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,
) -> Option<(String, String)> {
let latest_tag = fetch_latest_release_tag_for(client, LAUNCHER_OWNER, LAUNCHER_REPO)
.await
.ok()?;
) -> Result<Option<(String, String)>> {
let latest_tag =
fetch_latest_release_tag_for(client, LAUNCHER_OWNER, LAUNCHER_REPO).await?;

let current = parse_version_tag(APP_VERSION)?;
let latest = parse_version_tag(&latest_tag)?;
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}'"))?;

if latest > current {
Some((latest_tag, launcher_asset_name()))
} else {
None
}
Ok((latest > current).then(|| (latest_tag, launcher_asset_name())))
}

// --- Offline cache ---
Expand Down
41 changes: 33 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,33 @@ impl App {
// 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<scan::Application> = Vec::new();
let applications: Vec<scan::Application> = 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.
github::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", "0")])
i18n::t_fmt("apps_found", &[("count", &applications.len().to_string())])
};

let sections = sections::load_sections();
Expand Down Expand Up @@ -168,7 +190,10 @@ impl App {
.language
.clone()
.unwrap_or_else(|| i18n::current_lang().to_string()),
auto_check_updates: prefs.auto_check_updates.unwrap_or(false),
// 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),
Expand Down Expand Up @@ -218,11 +243,11 @@ impl App {
set_active_accent(accent_key_to_color(&app.selected_accent));
}

let launcher_check_task = if app.auto_check_updates {
Task::done(Message::CheckLauncherUpdate)
} else {
Task::none()
};
// 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()
Expand Down
14 changes: 11 additions & 3 deletions src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ pub enum Message {
Rescan,
RescanCompleted(Result<Vec<Application>, String>),
LaunchApp(String),
ColonyRepoSelected(usize),
/// Open a repo's detail page, keyed by repo NAME (indexes go stale when a
/// catalog refresh reorders the vector).
ColonyRepoSelected(String),
ColonyRepoBack,
ClearStatus,
FontLoaded(Result<(), font::Error>),
Expand Down Expand Up @@ -78,8 +80,14 @@ pub enum Message {
DetailTabSelected(DetailTab),
OpenUrl(String),
// Launcher self-update
CheckLauncherUpdate,
LauncherUpdateChecked(Option<(String, String)>), // Option<(tag, asset_filename)>
/// `manual` is true when the user clicked "Check for updates": only a
/// manual check gets an "up to date" toast (an automatic one would toast
/// on every boot) and network failures surface as errors instead of the
/// check lying that Colony is current.
CheckLauncherUpdate { manual: bool },
/// (manual, result of the check). Ok(None) = genuinely up to date;
/// Err = the check could not run (network/rate limit/bad tag).
LauncherUpdateChecked(bool, Result<Option<(String, String)>, String>),
DownloadLauncherUpdate,
LauncherDownloadProgress(f32),
LauncherDownloadCompleted(Result<std::path::PathBuf, String>),
Expand Down
10 changes: 10 additions & 0 deletions src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ pub struct CachedApp {
pub origin: String,
}

/// Load the cached application scan (`None` if absent or unreadable). Read at
/// boot when the startup scan is disabled, so the local-apps grid restores the
/// last known state instead of showing "0 apps" at every launch.
pub fn load_scan_cache() -> Option<Vec<CachedApp>> {
let path = scan_cache_path().ok()?;
let json = std::fs::read_to_string(path).ok()?;
let cached: CachedScanResult = serde_json::from_str(&json).ok()?;
Some(cached.apps)
}

/// Save scanned applications to cache.
pub fn save_scan_cache(apps: &[CachedApp]) -> Result<()> {
let timestamp = std::time::SystemTime::now()
Expand Down
17 changes: 13 additions & 4 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ pub struct App {
pub sections: Vec<Section>,
pub selected_section: usize,
pub status_message: String,
pub active_colony_repo: Option<usize>,
/// The repo whose detail page is open, tracked by NAME: the catalog vector
/// is replaced and reordered by every refresh (GitHub sorts by last push),
/// so an index here would silently swap the open page to a different app.
pub active_colony_repo: Option<String>,
pub font: Font,
// GitHub / OAuth
pub github_state: GitHubState,
Expand Down Expand Up @@ -236,6 +239,13 @@ impl App {
&self.colony_repo_list
}

/// Resolve the repo whose detail page is open, surviving catalog refreshes
/// (`None` if the repo disappeared from the catalog).
pub fn active_repo(&self) -> Option<&ColonyRepo> {
let name = self.active_colony_repo.as_deref()?;
self.colony_repo_list.iter().find(|r| r.name == name)
}

/// Filter local applications by the currently selected section.
pub fn filtered_applications(&self) -> Vec<&Application> {
let query = self.search_query.to_lowercase();
Expand All @@ -262,15 +272,14 @@ impl App {
}

/// Filter Colony repos by the currently selected section's category.
pub fn filtered_colony_repos(&self) -> Vec<(usize, &ColonyRepo)> {
pub fn filtered_colony_repos(&self) -> Vec<&ColonyRepo> {
let query = self.search_query.to_lowercase();
let selected_section = self.sections.get(self.selected_section);
let is_favorites = selected_section.map(|s| s.is_favorites).unwrap_or(false);

self.colony_repos()
.iter()
.enumerate()
.filter(|(_, repo)| {
.filter(|repo| {
if is_favorites {
if !self.is_favorite(&repo.name) {
return false;
Expand Down
34 changes: 19 additions & 15 deletions src/ui/app_grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,11 @@ impl App {
}

pub(crate) fn view_content(&self) -> Element<'_, Message> {
// Show Colony repo detail view if one is selected
if let Some(index) = self.active_colony_repo {
let repos = self.colony_repos();
if let Some(repo) = repos.get(index) {
return self.view_colony_detail(repo);
}
// Show Colony repo detail view if one is selected (resolved by name,
// so a catalog refresh landing mid-view cannot swap the page to a
// different app; a repo that vanished falls back to the grid).
if let Some(repo) = self.active_repo() {
return self.view_colony_detail(repo);
}

let search_input = text_input(&crate::i18n::t("search_placeholder"), &self.search_query)
Expand Down Expand Up @@ -399,8 +398,8 @@ impl App {
iced::widget::responsive(move |size| {
let cols = App::grid_columns(size.width);
let mut all_cards: Vec<Element<'_, Message>> = Vec::new();
for (index, repo) in &colony_repos {
all_cards.push(self.view_colony_card(*index, repo));
for repo in &colony_repos {
all_cards.push(self.view_colony_card(repo));
}
for app in &filtered {
all_cards.push(self.view_app_card(app));
Expand All @@ -410,7 +409,7 @@ impl App {
.into()
}

pub(crate) fn view_colony_grid<'a>(&'a self, repos: &[(usize, &'a ColonyRepo)]) -> Element<'a, Message> {
pub(crate) fn view_colony_grid<'a>(&'a self, repos: &[&'a ColonyRepo]) -> Element<'a, Message> {
if repos.is_empty() {
return container(
text(crate::i18n::t("no_apps_found"))
Expand All @@ -424,19 +423,19 @@ impl App {
.into();
}

let repos: Vec<(usize, &'a ColonyRepo)> = repos.to_vec();
let repos: Vec<&'a ColonyRepo> = repos.to_vec();
iced::widget::responsive(move |size| {
let cols = App::grid_columns(size.width);
let mut cards: Vec<Element<'_, Message>> = Vec::new();
for (index, repo) in &repos {
cards.push(self.view_colony_card(*index, repo));
for repo in &repos {
cards.push(self.view_colony_card(repo));
}
scrollable(build_card_grid(cards, cols)).height(Fill).into()
})
.into()
}

pub(crate) fn view_colony_card<'a>(&'a self, index: usize, repo: &'a ColonyRepo) -> Element<'a, Message> {
pub(crate) fn view_colony_card<'a>(&'a self, repo: &'a ColonyRepo) -> Element<'a, Message> {
let category = AppCategory::from_name(&repo.manifest.category);
let tint = theme::app_tint(&repo.name);
let tile = self.icon_tile(&repo.name, tint, category.glyph());
Expand Down Expand Up @@ -483,8 +482,13 @@ impl App {
.align_y(iced::Alignment::Center)
.width(Fill);

let selected = self.active_colony_repo == Some(index);
self.cell_shell(content.into(), Message::ColonyRepoSelected(index), selected, tint)
let selected = self.active_colony_repo.as_deref() == Some(repo.name.as_str());
self.cell_shell(
content.into(),
Message::ColonyRepoSelected(repo.name.clone()),
selected,
tint,
)
}

pub(crate) fn view_app_card(&self, app: &Application) -> Element<'_, Message> {
Expand Down
2 changes: 1 addition & 1 deletion src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ impl App {
if is_checking {
btn.into()
} else {
btn.on_press(Message::CheckLauncherUpdate).into()
btn.on_press(Message::CheckLauncherUpdate { manual: true }).into()
}
};

Expand Down
Loading
Loading