diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0fc317..7b940a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,16 +103,16 @@ libgtk-3-dev libxdo-dev libdbus-1-dev pkg-config See [docs/architecture.md](docs/architecture.md) for the full layout. Short version: -- `src/main.rs` — entry, Elm-architecture `update()` / `view()`. -- `src/update.rs` — `Message` handlers. -- `src/github.rs` — GitHub API layer: manifest fetching, release asset resolution, update checks. -- `src/download.rs` — release/asset downloads, archive extraction, launcher self-update. -- `src/signing.rs` — ed25519 verification of signed launcher updates (see [docs/release-signing.md](docs/release-signing.md)). -- `src/persistence.rs` — data directories, install state, on-disk caches, favorites. -- `src/config.rs` — locating external config files (`categories.json`, `colony.toml`). -- `src/scan.rs` — system app detection (Linux `.desktop`, Windows Start Menu, macOS `.app`). -- `src/sections.rs` — categories + filter logic. -- `src/ui/` — widgets and panels (sidebar, app grid, detail view, settings). +- `src/main.rs` - entry point: CLI flags, startup order, the iced launch. +- `src/app.rs` - Elm-architecture `view()`, `subscription()` and `theme()`; `src/update/` - `Message` handlers. +- `src/github/` - GitHub API layer: manifest fetching, release asset resolution, update checks. +- `src/download/` - release/asset downloads, archive extraction, launcher self-update. +- `src/signing.rs` - ed25519 verification of signed launcher updates (see [docs/release-signing.md](docs/release-signing.md)). +- `src/persistence.rs` - data directories, preferences, install state, on-disk caches, favorites. +- `src/config.rs` - locating external config files (`categories.json`, `colony.toml`). +- `src/scan.rs` - system app detection (Linux `.desktop`, Windows Start Menu, macOS `.app`). +- `src/sections.rs` - categories + filter logic. +- `src/ui/` - widgets and panels (sidebar, app grid, detail view, Preferences), on colony-ui's kit. - `src/ui/theme.rs` — a re-export of [colony-ui](https://crates.io/crates/colony-ui) plus Colony's own button styling. The 26 theme families and 59 palettes live in that crate, generated from the design tokens in diff --git a/Cargo.lock b/Cargo.lock index 6b4502b..8413993 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,12 +588,13 @@ dependencies = [ [[package]] name = "colony-ui" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9005873dd9a2d7b63d226eeaa7191d595e42e312caba03d193b3d473a25ccea8" +checksum = "31984ffb00f64c0fd0b998103ef11e1f4255e8f26c784a3776caf4b9ea28d60b" dependencies = [ "dirs 6.0.0", "iced", + "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index b43d6d9..eaa5e47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ resolver = "3" anyhow = "1.0.104" # The shared theme, palettes, accents, labels and widgets, generated from the # design tokens in Project-Colony-Resources. -colony-ui = "0.1.5" +colony-ui = "0.1.6" iced = { version = "0.14.0", features = ["tokio", "markdown", "advanced", "image-without-codecs"] } shell-words = "1.1.1" serde = { version = "1.0.229", features = ["derive"] } diff --git a/README.md b/README.md index eb7a388..b874f09 100644 --- a/README.md +++ b/README.md @@ -171,4 +171,4 @@ Requests carry a `Colony-Launcher` user agent. Beyond that, they tell GitHub onl Colony is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -The bundled fonts keep their own license, the SIL Open Font License 1.1, with the full text next to each font in `src/ui/assets/fonts/`: [JetBrains Mono Nerd Font](src/ui/assets/fonts/JetBrainsMonoNerdFont/OFL.txt), [Font Awesome Free](src/ui/assets/fonts/FontAwesome/OFL.txt) and [OpenDyslexic](src/ui/assets/fonts/OpenDyslexic/OFL.txt). +The fonts Colony embeds come from the [colony-ui](https://crates.io/crates/colony-ui) crate and keep their own license, the SIL Open Font License 1.1, with the full text next to each font in colony-ui: [JetBrains Mono Nerd Font](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/crates/colony-ui/assets/fonts/JetBrainsMonoNerdFont/OFL.txt), [Font Awesome Free](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/crates/colony-ui/assets/fonts/FontAwesome/OFL.txt) and [OpenDyslexic](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/crates/colony-ui/assets/fonts/OpenDyslexic/OFL.txt). diff --git a/config/categories.json b/config/categories.json index fa3d785..49d1104 100644 --- a/config/categories.json +++ b/config/categories.json @@ -1,13 +1,13 @@ [ { "name": "All", - "icon": "", + "icon": "\uf00a", "origin": "colony", "category": "all" }, { "name": "Windows", - "icon": "", + "icon": "\uf17a", "origin": "windows", "category": "all", "platforms": [ @@ -16,7 +16,7 @@ }, { "name": "Linux", - "icon": "", + "icon": "\uf17c", "origin": "external", "category": "all", "platforms": [ @@ -25,7 +25,7 @@ }, { "name": "macOS", - "icon": "", + "icon": "\uf179", "origin": "external", "category": "all", "platforms": [ @@ -35,61 +35,61 @@ }, { "name": "Development", - "icon": "", + "icon": "\uf121", "origin": "colony", "category": "development" }, { "name": "Graphics", - "icon": "", + "icon": "\uf1fc", "origin": "colony", "category": "graphics" }, { "name": "Network", - "icon": "", + "icon": "\uf0ac", "origin": "colony", "category": "network" }, { "name": "Office", - "icon": "", + "icon": "\uf0f6", "origin": "colony", "category": "office" }, { "name": "Multimedia", - "icon": "", + "icon": "\uf008", "origin": "colony", "category": "multimedia" }, { "name": "System", - "icon": "", + "icon": "\uf085", "origin": "colony", "category": "system" }, { "name": "Utilities", - "icon": "", + "icon": "\uf0ad", "origin": "colony", "category": "utility" }, { "name": "Security", - "icon": "", + "icon": "\uf132", "origin": "colony", "category": "Security" }, { "name": "Games", - "icon": "", + "icon": "\uf11b", "origin": "colony", "category": "game" }, { "name": "Other", - "icon": "", + "icon": "\uf128", "origin": "colony", "category": "other" } diff --git a/config/shields-colors.json b/config/shields-colors.json new file mode 100644 index 0000000..f7d9376 --- /dev/null +++ b/config/shields-colors.json @@ -0,0 +1,17 @@ +{ + "brightgreen": "#4cc140", + "green": "#97ca00", + "yellowgreen": "#a4a61d", + "yellow": "#dfb317", + "orange": "#fe7d37", + "red": "#e05d44", + "blue": "#007ec6", + "lightgrey": "#9f9f9f", + "lightgray": "#9f9f9f", + "grey": "#555555", + "gray": "#555555", + "purple": "#943aa0", + "pink": "#e730a8", + "black": "#1f1f1f", + "white": "#eeeeee" +} diff --git a/docs/architecture.md b/docs/architecture.md index 67976ee..2a93b80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,7 @@ Colony is an application launcher written in Rust with Iced 0.14 (Elm architectu |-----------|-----------| | Language | Rust (edition 2021) | | UI | Iced 0.14 (Elm architecture) | +| Shared UI | colony-ui: theme palettes, fonts, text sizing, motion, the Preferences kit | | Async | Tokio (integrated via Iced runtime) | | HTTP | reqwest (async + streaming), rustls checking certificates against the OS trust store (no OpenSSL) | | Auth | GitHub Device Flow OAuth | @@ -24,41 +25,60 @@ Colony is an application launcher written in Rust with Iced 0.14 (Elm architectu ``` src/ -├── main.rs — Crate root: modules, CLI flags, logging, entry point -├── state.rs — App struct (global state), GitHubState, UI fields -├── message.rs — Message enum (all events) -├── app.rs — impl App: boot(), view(), subscription(), theme() -├── update/ — Message dispatch table (mod.rs) + handlers -│ ├── store.rs — install / update / uninstall / notes / favorites -│ ├── github_auth.rs — device flow, catalog refresh -│ ├── launcher.rs — self-update state machine +├── main.rs - Entry point: CLI flags (--version, --help, +│ validate-manifest), startup order, iced launch +├── manifest.rs - `colony validate-manifest`: checks an app's colony.json +├── logging.rs - Log file + stderr, with the startup migration's lines +├── state.rs - App struct (global state), GitHubState, UI fields +├── message.rs - Message enum (all events) +├── app.rs - impl App: boot(), view(), subscription(), theme() +├── update/ - Message dispatch table (mod.rs) + handlers +│ ├── store.rs - install / update / uninstall / notes / favorites +│ ├── github_auth.rs - device flow, catalog refresh +│ ├── launcher.rs - self-update state machine │ ├── preferences.rs, keyboard.rs, onboarding.rs -├── github/ — split by layer, mod.rs re-exports everything -│ ├── http.rs — client, conditional-request cache, typed statuses -│ ├── types.rs — colony.json / API wire shapes -│ ├── catalog.rs — store listing -│ └── releases.rs — tags, assets, platform auto-detection -├── download.rs — Asset/archive downloads, extraction, app-signature -│ verification, self-update -├── signing.rs — ed25519 verification (launcher AND app releases) -├── icons.rs — PNG decoding for per-app grid icons -├── persistence.rs — Data dirs, install state, on-disk caches, favorites, -│ desktop entries (Linux) -├── config.rs — Locating external config (categories.json, colony.toml) -├── oauth.rs — Device Flow OAuth (login, token, keychain) -├── scan.rs — System application scanning (Linux/Windows/macOS) -├── sections.rs — Categories, origin/category filters, JSON config -├── i18n/ — fr.rs, en.rs, and the Locale lookup (mod.rs) +├── github/ - split by layer, mod.rs re-exports everything +│ ├── http.rs - client, conditional-request cache, typed statuses +│ ├── types.rs - colony.json / API wire shapes +│ ├── catalog.rs - store listing +│ └── releases.rs - tags, assets, platform auto-detection +├── download/ - split by concern, mod.rs re-exports the public API +│ ├── http.rs - download client, resumable transfers, bounded sidecar +│ │ reads, safe URL building +│ ├── files.rs - path-component checks, no-follow creates, replacing a +│ │ running binary, zip / tar.gz extraction +│ ├── verify.rs - SHA-256, signed .meta bindings, downgrade rules +│ ├── install.rs - app install: download, verify, extract, promote +│ └── launcher.rs - self-update: staging, apply with rollback +├── signing.rs - ed25519 verification (launcher AND app releases) +├── icons.rs - PNG decoding for per-app grid icons +├── persistence.rs - Data dirs, preferences (with colony-ui's +│ StandardPreferences), install state, on-disk caches, +│ favorites, desktop entries (Linux) +├── config.rs - Locating external config (categories.json, colony.toml) +├── oauth.rs - Device Flow OAuth (login, token, keychain) +├── scan.rs - System application scanning (Linux/Windows/macOS) +├── sections.rs - Categories, origin/category filters, JSON config +├── i18n/ - fr.rs, en.rs, and the Locale lookup (mod.rs); the +│ shared Preferences labels come from colony-ui └── ui/ - ├── mod.rs — UI module declarations - ├── theme.rs — 26 theme families, 59 palettes, semantic tokens - ├── sidebar.rs — Sidebar (sections, GitHub, rescan, update badge) - ├── app_grid.rs — Application card grid with search - ├── detail.rs — Detail view (README, changelog, license, actions) - ├── settings.rs — Settings panel (theme, language, about, updates) - ├── github_panel.rs — GitHub connect/disconnect, Device Flow UI - ├── markdown_blocks.rs — Cached-block Markdown rendering - └── tutorial.rs — First-launch guided tour (spotlight overlay) + ├── mod.rs - UI module declarations, the glyph + label helper + ├── theme.rs - colony-ui's 26 theme families and 59 palettes, Colony's + │ button styles + ├── sidebar.rs - Sidebar (the Colony name into Preferences, sections, + │ GitHub, rescan, update badge) + ├── app_grid.rs - Application card grid with search + ├── detail.rs - Detail view (README, changelog, license, actions) + ├── settings.rs - Preferences page on colony-ui's kit (General, + │ Appearance, Accessibility, Storage, Shortcuts, About) + ├── github_panel.rs - GitHub connect/disconnect, Device Flow UI + ├── markdown_blocks.rs - Cached-block Markdown rendering + └── tutorial.rs - First-launch guided tour (spotlight overlay) + +config/ +├── categories.json - Sidebar sections (embedded; can be overridden) +├── colony.toml - Scan directories +└── shields-colors.json - shields.io colour names for README badges (embedded) ``` ## Data flow (Elm architecture) @@ -129,7 +149,10 @@ Unit tests (`cargo test`) cover: - Application categorization - Section filters - Localization (EN/FR, key parity between languages) -- Preferences serialization +- Preferences serialization, and the migration of a file an older Colony wrote +- Preferences category order +- `validate-manifest` exit codes (0 clean, 1 invalid, 2 unreadable) +- Escaped Nerd Font glyphs in categories.json, shields colour names - Update-loop state transitions (catalog refresh, update queue, badges, launcher-check outcomes, cancel semantics) via a hermetic test App - filePattern globs with exclusions, signature parsing (strict ed25519), diff --git a/docs/faq.md b/docs/faq.md index f901e2b..3f8717e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -152,7 +152,7 @@ Yes. Edit `~/.config/Colony/Colony/preferences/preferences.json` and add custom ### I changed a theme and don't see it — why? -Themes apply immediately, no restart. If a palette looks off, verify Settings → Theme has the expected family + palette selected. All 59 palettes are compiled into the binary, so if it's missing you're on an old version — update. +Themes apply immediately, no restart. If a palette looks off, verify Preferences → Appearance → Theme has the expected family + palette selected. All 59 palettes are compiled into the binary, so if it's missing you're on an old version: update. --- diff --git a/docs/release-signing.md b/docs/release-signing.md index a4e92b2..71413d5 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -18,7 +18,7 @@ so the shipped binary needs no OpenSSL. `"signed": true`. Once an install of an app has verified a signature, that app is pinned: a later release that stops publishing `.sig` is refused, whatever the manifest now says. The same applies to the `.meta` sidecar - ([`src/download.rs`](../src/download.rs), `download_release_asset`). The + ([`src/download/install.rs`](../src/download/install.rs), `download_release_asset`). The optional `sha256` field in `colony.json` is checked on top of that. The organisation signs every program with one key, so the keys embedded here diff --git a/docs/tutorial.md b/docs/tutorial.md index d678334..1b34ab9 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -79,16 +79,17 @@ When you open Colony for the first time you'll see three zones: └───────────┴──────────────────────────────────────────┘ ``` -- **Sidebar** — Categories. The top group (`All` / `Windows` / `Linux`) is +- **Sidebar**: Categories. The top group (`All` / `Windows` / `Linux`) is the *origin* filter: Colony apps, apps already installed on your system, or both. The middle group is Colony categories (`Development`, `Graphics`, `Network`, `Office`, `Multimedia`, `System`, `Utilities`, `Security`, `Games`, `Other`). -- **App grid** — Cards for every app matching the current filters. Click a +- **App grid**: Cards for every app matching the current filters. Click a card to open its detail view. -- **Search** — A search bar above the grid filters by app name. -- **Settings gear** — Bottom of the sidebar opens preferences (theme, - language, scan directories, about). +- **Search**: A search bar above the grid filters by app name. +- **Colony**: The name at the top of the sidebar opens Preferences: General, + Appearance, Accessibility, Storage, Shortcuts and About. Click it again, or + Close, to go back. ## 3. Install your first Colony app @@ -121,9 +122,10 @@ Switch the sidebar origin to **Linux** (or **Windows**) to see only those. ## 5. Customize the theme -Click the **gear icon** at the bottom of the sidebar → **Theme**. Colony ships -26 theme families and 59 palettes (Catppuccin, Gruvbox, Nord, Dracula, Rosé -Pine, Tokyo Night, etc.). Theme changes apply instantly, no restart needed. +Click **Colony** at the top of the sidebar, then **Appearance** → **Theme**. +Colony ships 26 theme families and 59 palettes (Catppuccin, Gruvbox, Nord, +Dracula, Rosé Pine, Tokyo Night, etc.). Theme changes apply instantly, no +restart needed. ## 6. Connect your GitHub account (optional) diff --git a/src/app.rs b/src/app.rs index 37edf19..0f24197 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,33 +4,17 @@ //! point. The message handling lives in [`crate::update`], the widgets in //! [`crate::ui`]. -use iced::font; +use colony_ui::{fonts, sz, ui_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::state::{App, DetailTab, GitHubState}; +use crate::ui::theme::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(); @@ -70,8 +54,6 @@ impl App { 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, @@ -129,7 +111,6 @@ impl App { selected_section, status_message, active_colony_repo: None, - font, github_state, show_github_menu: false, colony_repo_list, @@ -149,19 +130,7 @@ 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()), - auto_accent: prefs.auto_accent.unwrap_or(false), + standard: prefs.standard.clone(), // General restore_session: prefs.restore_session.unwrap_or(true), default_view: prefs.default_view.clone().unwrap_or_else(|| "all".into()), @@ -170,18 +139,7 @@ impl App { // 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 @@ -192,7 +150,6 @@ impl App { is_fetching_repos: true, // The boot fetch is not a click. repos_refresh_manual: false, - // Settings section state persistence settings_expanded_sections: HashSet::new(), // Detail tabs detail_tab: DetailTab::ReadMe, @@ -201,9 +158,6 @@ impl App { 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(), @@ -239,11 +193,7 @@ impl App { ); } - 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)); - } + app.standard.apply(); // No direct launcher-update check here: with auto-check on, the boot // catalog fetch chains GitHubReposFetched -> CheckUpdates -> @@ -266,13 +216,7 @@ impl App { ( app, - Task::batch([ - load_fonts(), - startup_task, - launcher_check_task, - tutorial_task, - scan_task, - ]), + Task::batch([startup_task, launcher_check_task, tutorial_task, scan_task]), ) } @@ -305,8 +249,8 @@ impl App { } else { let status_bar = container( text(&self.status_message) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()), ) .padding([6, 16]) @@ -336,7 +280,7 @@ impl App { String::new() }; format!( - "\u{f019} {} — {} / {} ({}%){}", + "{}: {} / {} ({}%){}", filename, state::human_bytes(done), state::human_bytes(total), @@ -350,19 +294,14 @@ impl App { } else { String::new() }; - format!( - "\u{f019} {} — {}{}", - filename, - state::human_bytes(done), - speed - ) + format!("{}: {}{}", filename, state::human_bytes(done), speed) } - None => format!("\u{f019} {} — {}%", filename, pct), + None => format!("{}: {}%", filename, pct), }; let cancel_btn = button( text("\u{f00d}") - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(fonts::glyph_font()) .color(Palette::TEXT_DIMMER()), ) .on_press(Message::CancelDownload) @@ -374,7 +313,7 @@ impl App { // Graphical progress bar (smooth interpolation when animations enabled) let bar_width: f32 = 200.0; - let display_progress = if self.animations && !self.reduce_motion { + let display_progress = if colony_ui::effects_enabled() { self.progress_display } else { progress @@ -400,9 +339,13 @@ impl App { let progress_widget = container( column![ row![ + text("\u{f019}") + .size(sz(13)) + .font(fonts::glyph_font()) + .color(Palette::ACCENT_PROGRESS()), text(bar_label) - .size(self.sz(13)) - .font(self.app_font()) + .size(sz(13)) + .font(ui_font()) .color(Palette::ACCENT_PROGRESS()), cancel_btn, ] @@ -424,7 +367,7 @@ impl App { // 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 { + let alpha = if colony_ui::effects_enabled() { notif.opacity() } else { 1.0 @@ -437,12 +380,12 @@ impl App { let toast = button( row![ text(¬if.message) - .size(self.sz(13)) - .font(self.app_font()) + .size(sz(13)) + .font(ui_font()) .color(text_color), text("\u{f00d}") - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(fonts::glyph_font()) .color(dismiss_color), ] .spacing(12) @@ -468,7 +411,7 @@ impl App { top: 0.0, right: 0.0, bottom: 12.0, - left: 216.0, + left: ui::sidebar_width() + 16.0, }) .width(Fill) .height(Fill) @@ -485,49 +428,41 @@ impl App { let dialog = container( column![ text(confirm_msg) - .size(self.sz(16)) - .font(self.app_font()) + .size(sz(16)) + .font(ui_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() - } - }), + button(text(i18n::t("cancel")).size(sz(13)).font(ui_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(sz(13)).font(ui_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) ] @@ -600,10 +535,8 @@ impl App { } 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 { + use colony_ui::ColorExt; + if ui::theme::active_palette().bg_primary.is_light() { Theme::Light } else { Theme::Dark diff --git a/src/download.rs b/src/download.rs deleted file mode 100644 index f120a0f..0000000 --- a/src/download.rs +++ /dev/null @@ -1,2068 +0,0 @@ -//! Asset download, archive extraction, and launcher self-update. -//! -//! Downloads stream to a temporary file with length/integrity checks and are -//! atomically promoted into place; launcher self-updates are verified against a -//! signed detached signature (see [`crate::signing`]) before being applied. - -use anyhow::Result; -use sha2::{Digest, Sha256}; -use std::path::PathBuf; -use std::time::Duration; - -use crate::github::{APP_VERSION, CONNECT_TIMEOUT, GITHUB_ACCOUNT, LAUNCHER_OWNER, LAUNCHER_REPO}; -use crate::persistence::colony_cache_dir; - -/// How long a download may stall before we give up. This is an *inactivity* -/// budget, not a total one: a slow-but-alive link keeps its transfer, a dead -/// socket still dies promptly. -const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(60); - -/// Build the HTTP client used for large asset downloads. -/// -/// Deliberately no `.timeout()`: that is a *total* deadline covering connect, -/// TLS, redirects and the whole body stream, so a 300 s cap made any asset -/// larger than the line could carry in five minutes impossible to fetch at all -/// (a 40 MB binary needed a sustained ~1.2 Mbit/s or it could never finish, on -/// every attempt, with no partial progress kept). A read timeout bounds the -/// only thing worth bounding - a connection that has stopped delivering bytes. -fn download_client() -> Result { - Ok(reqwest::Client::builder() - .user_agent(format!("Colony-Launcher/{APP_VERSION}")) - .read_timeout(DOWNLOAD_READ_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .build()?) -} - -/// Sanity ceiling on an advertised body. The whole asset is written to disk -/// before any signature or digest check can run - that is unavoidable for a -/// detached signature - so a misconfigured or hostile release could otherwise -/// fill the user's home partition on its own say-so. Generous: the largest -/// asset the org ships is two orders of magnitude below this. -const MAX_ASSET_BYTES: u64 = 4 * 1024 * 1024 * 1024; - -/// Identity of the transfer a `.part` file belongs to, written beside it. -/// -/// Resuming means stitching two responses into one file, so it is only sound -/// while both halves describe the same artifact. A re-tagged release, or an -/// asset rebuilt under the same name, must restart from zero rather than -/// produce a file that never existed anywhere. -#[derive(PartialEq, Eq)] -struct PartIdentity { - etag: String, - total: u64, -} - -impl PartIdentity { - fn path(dest: &std::path::Path) -> PathBuf { - let mut name = dest.as_os_str().to_os_string(); - name.push(".id"); - PathBuf::from(name) - } - - fn read(dest: &std::path::Path) -> Option { - let raw = std::fs::read_to_string(Self::path(dest)).ok()?; - let (etag, total) = raw.split_once('\n')?; - Some(Self { - etag: etag.to_string(), - total: total.trim().parse().ok()?, - }) - } - - fn write(&self, dest: &std::path::Path) { - let _ = std::fs::write(Self::path(dest), format!("{}\n{}\n", self.etag, self.total)); - } - - fn forget(dest: &std::path::Path) { - let _ = std::fs::remove_file(Self::path(dest)); - } -} - -/// Whether a 206 response still describes the artifact a partial file belongs -/// to. Both the validator and the total length must agree. -fn response_matches_identity(resp: &reqwest::Response, id: &PartIdentity) -> bool { - let etag = resp - .headers() - .get(reqwest::header::ETAG) - .and_then(|v| v.to_str().ok()); - if etag != Some(id.etag.as_str()) { - return false; - } - // "bytes -/" - the total is the part we can compare. - resp.headers() - .get(reqwest::header::CONTENT_RANGE) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.rsplit('/').next()) - .and_then(|total| total.trim().parse::().ok()) - == Some(id.total) -} - -/// Move `new` onto `dest`, even when `dest` is a binary that is currently -/// running. -/// -/// `std::fs::rename` over a live executable fails on Windows: the image is held -/// with FILE_SHARE_READ|FILE_SHARE_DELETE, so MoveFileExW cannot delete the -/// destination and returns ERROR_ACCESS_DENIED. Colony already knew the parade -/// and applied it to its OWN self-update - rename the running file aside first, -/// then move the new one in - but the app installer never learned it, so -/// updating an app the user had left open failed AFTER downloading and -/// verifying the whole asset, with a raw "Access is denied" and no hint that -/// closing the app would fix it. -/// -/// The aside-rename is harmless on Unix (where the plain rename would have -/// worked anyway), so this takes the same path on every platform rather than -/// keeping two behaviours that only one of them exercises. -fn replace_file(new: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> { - if !dest.exists() { - return std::fs::rename(new, dest); - } - let aside = dest.with_extension("old"); - let _ = std::fs::remove_file(&aside); - // A running image can always be RENAMED, on every platform - that is the - // whole trick. If even this fails, the destination is genuinely locked and - // the caller gets the real error instead of a mystery. - std::fs::rename(dest, &aside)?; - match std::fs::rename(new, dest) { - Ok(()) => { - // Best effort: on Windows the old image stays until the process - // holding it exits, and prune_staging() sweeps it at next boot. - let _ = std::fs::remove_file(&aside); - Ok(()) - } - Err(e) => { - let _ = std::fs::rename(&aside, dest); - Err(e) - } - } -} - -/// Discard a partial transfer and the identity that described it. -fn discard_partial(dest_path: &std::path::Path) { - let _ = std::fs::remove_file(dest_path); - PartIdentity::forget(dest_path); -} - -/// Stream an HTTP GET to `dest_path`, sending throttled progress over -/// `progress_tx`, resuming a previous partial transfer when one is present and -/// provably describes the same bytes. -/// -/// Verifies the received length against Content-Length when present and rejects -/// empty/truncated downloads. A partial file is KEPT on a transport failure so -/// the next attempt can continue it, and removed on anything that makes it -/// meaningless. Shared by app-asset install and launcher self-update. -async fn download_to_file( - client: &reqwest::Client, - url: &str, - token: Option<&str>, - dest_path: &std::path::Path, - progress_tx: Option)>>, -) -> Result<()> { - // What is already on disk, and may we continue it? Both the server's ETag - // and the total length must match what the previous attempt recorded. - let resume_from = match ( - std::fs::metadata(dest_path).map(|m| m.len()).ok(), - PartIdentity::read(dest_path), - ) { - (Some(have), Some(id)) if have > 0 && have < id.total => Some((have, id)), - // Anything else - no identity file, a complete or over-long file, an - // empty one - is not resumable. Start clean. - _ => { - discard_partial(dest_path); - None - } - }; - - let mut request = client.get(url); - if let Some(t) = token { - request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); - } - if let Some((have, ref id)) = resume_from { - // If-Range is sent, but is NOT trusted: GitHub redirects release assets - // to Azure blob storage, which ignores the header and answers 206 to a - // stale validator just the same (verified against a real release - // asset). So the 206 branch below re-checks the ETag and the total - // itself rather than taking the status code as proof. - request = request - .header(reqwest::header::RANGE, format!("bytes={have}-")) - .header(reqwest::header::IF_RANGE, id.etag.clone()); - } - - let resp = request.send().await.map_err(|e| { - if e.is_timeout() { - anyhow::anyhow!("Download timed out for {url}") - } else { - anyhow::anyhow!("Download failed for {url}: {e}") - } - })?; - - if !resp.status().is_success() { - anyhow::bail!("Download failed: HTTP {} for {url}", resp.status()); - } - - // A 206 is a claim, not a proof - the storage backend ignores If-Range. - // Accept it only when the response still describes the artifact the partial - // file belongs to: same ETag, and the same total in Content-Range. On any - // disagreement, throw the partial away and let the retry start clean rather - // than stitching two different bodies into a file that never existed. - let resuming = if resp.status().as_u16() == 206 { - match resume_from { - Some((_, ref id)) if response_matches_identity(&resp, id) => true, - Some(_) => { - discard_partial(dest_path); - anyhow::bail!("The release changed while downloading {url}; restarting"); - } - None => false, - } - } else { - false - }; - let already: u64 = if resuming { - resume_from.as_ref().map(|(have, _)| *have).unwrap_or(0) - } else { - 0 - }; - - // Total size of the WHOLE asset, not of this response. - let total = if resuming { - resume_from.as_ref().map(|(_, id)| id.total) - } else { - resp.content_length() - }; - - if let Some(total) = total { - anyhow::ensure!( - total <= MAX_ASSET_BYTES, - "Refusing {url}: the release advertises {total} bytes, over Colony's {MAX_ASSET_BYTES}-byte ceiling" - ); - } - - use futures::StreamExt; - use std::io::Write; - let mut file = if resuming { - tracing::info!("resuming {url} at {already} bytes"); - std::fs::OpenOptions::new().append(true).open(dest_path)? - } else { - // Fresh transfer. Record what we are about to fetch so a later attempt - // can tell whether continuing this file is sound, and never follow - // whatever sits at the (predictable) staging name. - discard_partial(dest_path); - if let (Some(etag), Some(total)) = ( - resp.headers() - .get(reqwest::header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_string), - resp.content_length(), - ) { - PartIdentity { etag, total }.write(dest_path); - } - create_new_file(dest_path)? - }; - let mut stream = resp.bytes_stream(); - let mut downloaded: u64 = already; - let mut last_pct: u32 = 0; - - let stream_result: Result<()> = async { - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - anyhow::ensure!( - downloaded <= MAX_ASSET_BYTES, - "Refusing {url}: body exceeded Colony's {MAX_ASSET_BYTES}-byte ceiling" - ); - - if let Some(ref tx) = progress_tx { - // Throttle: send on whole-percent changes when the total is - // known, else every 256 KiB - not per network chunk. - let should_send = match total { - Some(total) if total > 0 => { - let pct = ((downloaded as f64 / total as f64) * 100.0) as u32; - if pct != last_pct { - last_pct = pct; - true - } else { - false - } - } - _ => { - let bucket = (downloaded / (256 * 1024)) as u32; - if bucket != last_pct { - last_pct = bucket; - true - } else { - false - } - } - }; - if should_send { - let _ = tx.unbounded_send((downloaded, total)); - } - } - } - file.flush()?; - Ok(()) - } - .await; - - if let Err(e) = stream_result { - // KEEP the partial file. A drop at 95% used to throw away everything - // and charge the user the full asset again on the next click; with the - // identity sidecar beside it, the next attempt continues instead. Only - // a transfer we can no longer describe is discarded. - if PartIdentity::read(dest_path).is_none() { - discard_partial(dest_path); - } - return Err(e); - } - - // Guard against a silently-truncated or empty transfer. Both are terminal - // for THIS file: a short body means the server disagrees with the length it - // advertised, so continuing from it would be guesswork. - if let Some(total) = total { - if downloaded != total { - discard_partial(dest_path); - anyhow::bail!("Incomplete download: got {downloaded} of {total} bytes for {url}"); - } - } - if downloaded == 0 { - discard_partial(dest_path); - anyhow::bail!("Empty download (0 bytes) for {url}"); - } - - // Complete: the identity file has done its job. - PartIdentity::forget(dest_path); - Ok(()) -} - -/// `download_to_file` with a bounded retry, so a dropped connection continues -/// from where it stopped without the user having to notice and click again. -/// -/// Only transport failures are retried, and only because the partial file now -/// survives them: each attempt resumes from what the previous one wrote, so -/// three tries cost three connections, not three assets. A verification failure -/// never reaches here - it happens after this returns. -async fn download_with_resume( - client: &reqwest::Client, - url: &str, - token: Option<&str>, - dest_path: &std::path::Path, - progress_tx: Option)>>, -) -> Result<()> { - const ATTEMPTS: u32 = 3; - let mut last_err = None; - for attempt in 0..ATTEMPTS { - if attempt > 0 { - // Linear backoff; the point is to ride out a blip, not to hammer. - tokio::time::sleep(Duration::from_secs(2 * attempt as u64)).await; - tracing::info!("retrying {url} (attempt {} of {ATTEMPTS})", attempt + 1); - } - let had_partial = PartIdentity::read(dest_path).is_some(); - match download_to_file(client, url, token, dest_path, progress_tx.clone()).await { - Ok(()) => return Ok(()), - Err(e) => { - last_err = Some(e); - // Retry when the next attempt would do something DIFFERENT: - // either a partial survived and will be continued, or one was - // just discarded (the release changed under us) and the next - // attempt starts clean. Otherwise - a 404, a refused URL - the - // next attempt would only repeat this one. - let resumable = PartIdentity::read(dest_path).is_some(); - if !resumable && !had_partial { - break; - } - } - } - } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Download failed for {url}"))) -} - -/// Ceiling for the small sidecars Colony buffers whole (`.sig` is 64 bytes, -/// `.meta` three short lines). Whoever controls a release can publish a -/// multi-gigabyte file named `foo-linux.sig`; without a cap that is an OOM the -/// moment a user clicks Install. Generous by four orders of magnitude. -const MAX_SIDECAR_BYTES: u64 = 64 * 1024; - -/// Read a response body with an upper bound, so a body with no Content-Length - -/// or one that lies about it - cannot be unbounded. -async fn bounded_body(resp: reqwest::Response, url: &str, max: u64) -> Result> { - if let Some(len) = resp.content_length() { - anyhow::ensure!( - len <= max, - "Refusing {url}: declares {len} bytes, over the {max}-byte limit" - ); - } - use futures::StreamExt; - let mut stream = resp.bytes_stream(); - let mut out: Vec = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - anyhow::ensure!( - out.len() as u64 + chunk.len() as u64 <= max, - "Refusing {url}: body exceeds the {max}-byte limit" - ); - out.extend_from_slice(&chunk); - } - Ok(out) -} - -/// Fetch a small OPTIONAL resource: `Ok(None)` on HTTP 404 (the resource -/// genuinely is not published), `Err` on any other failure - so a transient -/// network error can never be mistaken for "not published" (an attacker able -/// to induce errors must not be able to make a signed app look unsigned). -async fn fetch_optional_bytes( - client: &reqwest::Client, - url: &str, - token: Option<&str>, -) -> Result>> { - let mut request = client.get(url); - if let Some(t) = token { - request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); - } - let resp = request.send().await?; - if resp.status().as_u16() == 404 { - return Ok(None); - } - if !resp.status().is_success() { - anyhow::bail!("HTTP {} for {url}", resp.status()); - } - Ok(Some(bounded_body(resp, url, MAX_SIDECAR_BYTES).await?)) -} - -/// Fetch a small resource (e.g. a detached signature) fully into memory. -async fn fetch_bytes(client: &reqwest::Client, url: &str, token: Option<&str>) -> Result> { - let mut request = client.get(url); - if let Some(t) = token { - request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); - } - let resp = request.send().await?; - if !resp.status().is_success() { - anyhow::bail!("HTTP {} for {url}", resp.status()); - } - bounded_body(resp, url, MAX_SIDECAR_BYTES).await -} - -/// Lowercase hex SHA-256 of `bytes`, the form release metadata and colony.json -/// carry. sha2 0.11 returns a `hybrid_array::Array`, which has no `LowerHex`, -/// so the digest is spelled out byte by byte. -fn sha256_hex(bytes: &[u8]) -> String { - use std::fmt::Write; - Sha256::digest(bytes) - .iter() - .fold(String::with_capacity(64), |mut hex, byte| { - let _ = write!(hex, "{byte:02x}"); - hex - }) -} - -/// Verify a SHA256 digest over bytes already in memory. -/// -/// Takes bytes rather than a path so the caller checks exactly what it is about -/// to install: re-opening the staged file to hash it means the digest describes -/// one read and the install uses another. -fn verify_sha256_bytes(bytes: &[u8], expected_hex: &str) -> Result<()> { - let computed = sha256_hex(bytes); - if computed != expected_hex.to_lowercase() { - anyhow::bail!( - "SHA256 mismatch: expected {}, got {}", - expected_hex.to_lowercase(), - computed - ); - } - Ok(()) -} - -/// True when the running executable lives in a system location owned by a -/// package manager (AUR/pacman installs land in /usr/bin, manual system -/// installs in /usr/local or /opt). Self-update can NEVER apply there - the -/// backup rename of the running exe fails with EACCES after downloading the -/// whole asset - so the UI offers package-manager guidance instead of a -/// download button that is guaranteed to die. -pub fn launcher_is_system_managed() -> bool { - let Ok(exe) = std::env::current_exe() else { - return false; - }; - let Some(dir) = exe.parent() else { - return false; - }; - // Behavioural, not a list of paths: probe whether we can actually create a - // file next to the executable. That answers /usr, /opt, Program Files, - // a read-only mount and a macOS /Applications install with one test and no - // per-platform table to keep in sync. The path check was #[cfg(unix)] only, - // so a Windows install under Program Files downloaded and verified the - // entire asset before dying on the rename - exactly the failure the guard - // was written to prevent, just never extended to the other platforms. - let probe = dir.join(format!(".colony-write-probe-{}", std::process::id())); - match std::fs::File::create(&probe) { - Ok(_) => { - let _ = std::fs::remove_file(&probe); - false - } - Err(_) => true, - } -} - -/// Ensure a filename is a single normal path component (no `..`, no path -/// separators, not absolute) before it is joined into a destination directory. -/// Shared by archive extraction and raw-asset download to block path traversal. -/// Names Win32 resolves to devices no matter which directory contains them, -/// and regardless of any extension (`CON.txt` is still the console). -const RESERVED_DEVICE_NAMES: &[&str] = &[ - "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", - "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", -]; - -pub(crate) fn ensure_safe_component(name: &str) -> Result<()> { - let p = std::path::Path::new(name); - anyhow::ensure!( - p.components().count() == 1 - && matches!(p.components().next(), Some(std::path::Component::Normal(_))), - "Invalid file name (path traversal attempt?): {name}" - ); - - // The check above is the shape of a POSIX path, which is only half the - // question on the three platforms Colony supports. Rust's Windows parser - // recognises a drive prefix only when exactly one letter precedes the - // colon, so "payload:stream" is a single Normal component and joining it - // writes an NTFS alternate data stream on a file named "payload"; a - // reserved device name resolves to a device from any directory; and a - // trailing dot or space is stripped by the filesystem, so the name written - // differs from the name checked. - // - // Enforced on every platform, not behind cfg(windows): the catalog is - // shared, so a manifest that would be refused on Windows must be refused - // everywhere rather than installing differently per user. - anyhow::ensure!( - !name.contains( - |c: char| matches!(c, ':' | '\\' | '/' | '<' | '>' | '"' | '|' | '?' | '*') - || c.is_control() - ), - "Invalid file name (reserved character): {name}" - ); - anyhow::ensure!( - !name.ends_with('.') && !name.ends_with(' '), - "Invalid file name (trailing dot or space is silently stripped): {name}" - ); - let stem = name.split('.').next().unwrap_or(name); - anyhow::ensure!( - !RESERVED_DEVICE_NAMES - .iter() - .any(|d| stem.eq_ignore_ascii_case(d)), - "Invalid file name (reserved device name): {name}" - ); - Ok(()) -} - -/// Build a URL from a trusted base and remote-controlled path segments, -/// percent-encoding each segment so it can never be structural. -/// -/// `format!`-ing a remote string into a URL is not safe even when the string -/// looks harmless in a JSON diff. `reqwest::Client::get` parses through the -/// WHATWG URL parser, which collapses `..` segments (and `%2e%2e`) *before* the -/// request is made: a `colony.json` whose `tag` reads -/// `v1/../../../../../EvilOrg/EvilRepo/releases/download/v1` shortens the path -/// into a different account entirely, and Colony would install and trust bytes -/// that were never published under the org. That is the one containment claim -/// the whole trust model rests on, and it was reachable from write access to a -/// single line of a catalog repo - no release-publishing rights needed. -/// -/// Segments are pushed through `path_segments_mut`, which percent-encodes them, -/// so a `/` or a `..` inside a segment stays data. Legitimate git tags may -/// contain `/` (`release/1.0`), so encoding is the honest fix here; rejecting -/// the value outright would break them. -pub(crate) fn build_url(base: &str, segments: &[&str]) -> Result { - let mut url = reqwest::Url::parse(base)?; - { - let mut path = url - .path_segments_mut() - .map_err(|_| anyhow::anyhow!("URL base cannot have path segments: {base}"))?; - for segment in segments { - anyhow::ensure!( - !segment.is_empty(), - "empty URL path segment for base {base}" - ); - path.push(segment); - } - } - Ok(url.into()) -} - -/// Normalized form of `raw` when it is an absolute `http`/`https` URL with a -/// host, else `None`. -/// -/// Gate for everything handed to the desktop's URI opener: link destinations in -/// remote Markdown (READMEs, release notes) reach it verbatim, and `open::that` -/// is not a browser call — it execs `xdg-open`/`gio open` on Linux and -/// `Start-Process` on Windows, both of which dispatch `file://`, UNC paths and -/// any registered `x-scheme-handler/*`. A `file://` or custom-scheme link in a -/// hostile README would otherwise be a one-click execution primitive that -/// bypasses every signature and digest check in this module. -/// -/// Returns the REPARSED string rather than a bool on purpose: the WHATWG parser -/// strips tabs, newlines and leading control characters anywhere in the input, so -/// validating `raw` and then opening `raw` would judge one string and execute a -/// different one (`"ht\ntps://x"` parses as `https://x`). Callers open what was -/// actually validated. -pub(crate) fn web_url(raw: &str) -> Option { - // Relative and scheme-less inputs (including protocol-relative - // `//host/share/x.exe`) fail to parse, which is the desired answer. - let parsed = reqwest::Url::parse(raw).ok()?; - // A host is required: `http:evil` and `http:/x` parse but address nothing, - // and an opener may fall back to treating them as a local path. - if !matches!(parsed.scheme(), "http" | "https") || !parsed.has_host() { - return None; - } - Some(parsed.into()) -} - -/// Create `path` fresh, never following an existing file or symlink. -/// -/// `File::create` truncates whatever it finds, and follows a symlink to write at -/// its target: a symlink pre-planted at a predictable staging name (`.new`, -/// `.part`) would redirect the write outside the install directory, and the -/// caller's `set_permissions` then chmods that target. Unlinking first and opening -/// with `create_new` makes the create fail rather than follow anything that races -/// in between. -fn create_new_file(path: &std::path::Path) -> Result { - // Removing our own leftover staging file is expected; a failure here is not - // fatal because create_new below is the actual guard. - let _ = std::fs::remove_file(path); - Ok(std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path)?) -} - -/// `std::fs::write` for the trust path: same result, but through -/// [`create_new_file`] so a symlink planted at the (entirely predictable) -/// staging name is never followed. -fn write_new_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { - use std::io::Write; - let mut file = create_new_file(path)?; - file.write_all(contents)?; - file.flush()?; - Ok(()) -} - -/// Extract a single file from a .zip archive. -fn extract_from_zip( - archive_path: &std::path::Path, - binary_name: &str, - dest_dir: &std::path::Path, -) -> Result { - ensure_safe_component(binary_name)?; - let file = std::fs::File::open(archive_path)?; - let mut archive = zip::ZipArchive::new(file)?; - - for i in 0..archive.len() { - let mut entry = archive.by_index(i)?; - let entry_name = entry.name()?.into_owned(); - // Match by exact filename (last component), handles entries like "dir/binary" - let matches = std::path::Path::new(&entry_name) - .file_name() - .and_then(|n| n.to_str()) - .map(|n| n == binary_name) - .unwrap_or(false); - if matches { - anyhow::ensure!( - entry.is_file(), - "Refusing to extract non-regular zip entry '{binary_name}'" - ); - // Extract to a temp file, then atomically rename over any previous - // install so a failed extraction never leaves a truncated binary. - let final_dest = dest_dir.join(binary_name); - let tmp_dest = dest_dir.join(format!("{binary_name}.new")); - let mut out = create_new_file(&tmp_dest)?; - std::io::copy(&mut entry, &mut out)?; - drop(out); - replace_file(&tmp_dest, &final_dest)?; - return Ok(final_dest); - } - } - anyhow::bail!("Binary '{binary_name}' not found in zip archive") -} - -/// Extract a single file from a .tar.gz archive. -fn extract_from_tar_gz( - archive_path: &std::path::Path, - binary_name: &str, - dest_dir: &std::path::Path, -) -> Result { - ensure_safe_component(binary_name)?; - let file = std::fs::File::open(archive_path)?; - let gz = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(gz); - - for entry_result in archive.entries()? { - let mut entry = entry_result?; - let path = entry.path()?; - let matches = path - .file_name() - .and_then(|n| n.to_str()) - .map(|n| n == binary_name) - .unwrap_or(false); - if matches { - // Reject symlink / hardlink / device entries: only a regular file - // may be unpacked, so a crafted archive cannot make us follow a - // link to an arbitrary path when we later chmod the result. - anyhow::ensure!( - entry.header().entry_type().is_file(), - "Refusing to extract non-regular tar entry '{binary_name}'" - ); - let final_dest = dest_dir.join(binary_name); - let tmp_dest = dest_dir.join(format!("{binary_name}.new")); - entry.unpack(&tmp_dest)?; - replace_file(&tmp_dest, &final_dest)?; - return Ok(final_dest); - } - } - anyhow::bail!("Binary '{binary_name}' not found in tar.gz archive") -} - -/// Extract a binary from an archive based on its extension. -/// -/// `asset_name` is the original release asset filename used only to detect the -/// archive type — `archive_path` may be a staging file (e.g. `foo.zip.part`) -/// whose own extension must not be used for detection. -fn extract_binary_from_archive( - archive_path: &std::path::Path, - asset_name: &str, - binary_name: &str, - dest_dir: &std::path::Path, -) -> Result { - // `binary_name` is a manifest-supplied string that becomes a path component - // in EVERY branch below, so the traversal guard is hoisted here, before the - // dispatch. It used to live only inside the zip and tar extractors, which - // left the raw-binary branch able to write outside `dest_dir` — and, after - // the caller's chmod 0755 and desktop entry, to gain execution at next login - // — from a hostile manifest. The extractors keep their own call so they stay - // safe in isolation. - ensure_safe_component(binary_name)?; - if asset_name.ends_with(".zip") { - let result = extract_from_zip(archive_path, binary_name, dest_dir); - let _ = std::fs::remove_file(archive_path); - result - } else if asset_name.ends_with(".tar.gz") || asset_name.ends_with(".tgz") { - let result = extract_from_tar_gz(archive_path, binary_name, dest_dir); - let _ = std::fs::remove_file(archive_path); - result - } else { - // Raw binary (e.g. .exe, no archive extension) — rename to binary_name in dest_dir - let dest = dest_dir.join(binary_name); - replace_file(archive_path, &dest)?; - Ok(dest) - } -} - -/// Everything needed to install one resolved release asset. -pub struct AssetInstall { - pub repo_name: String, - /// The manifest's category, so the desktop menu files the app where it - /// belongs instead of dumping everything under Utility. - pub category: crate::scan::AppCategory, - /// What the desktop menu should call this app: the manifest's declared - /// name, which may differ from the repo slug. The slug stays the identity - /// key everywhere else (install dir, caches, entry filename). - pub display_name: String, - /// The resolved (never "latest") release tag being installed. - pub tag: String, - /// The resolved asset name to download. - pub filename: String, - /// When set, the download is an archive and this named binary is extracted. - pub binary_name: Option, - /// When set, the download is integrity-checked against this hex digest. - pub expected_sha256: Option, - /// True when `filename` was resolved from a filePattern: the name is then - /// persisted next to the binary so `installed_app_path` can find the - /// install again. - pub record_asset: bool, - /// True when the manifest declares `"signed": true`: a missing `.sig` - /// then ABORTS the install instead of being treated as a legacy unsigned - /// app - closing the "compromised repo simply omits signatures" hole. - pub require_signature: bool, -} - -/// Download a release asset to `//`, -/// verify/extract it, atomically promote it into place, and record the -/// installed version. Returns the final path on success. -pub async fn download_release_asset( - token: Option, - install: AssetInstall, - progress_tx: Option)>>, -) -> Result { - let AssetInstall { - repo_name, - display_name, - category, - tag, - filename, - binary_name, - expected_sha256, - record_asset, - require_signature, - } = install; - // The manifest-supplied filename becomes a local path — guard it against - // traversal (`../`, absolute paths) before joining, mirroring the archive - // `binary` guard. - ensure_safe_component(&filename)?; - - let dest_dir = crate::persistence::colony_app_dir(&repo_name)?; - std::fs::create_dir_all(&dest_dir)?; - let dest_path = dest_dir.join(&filename); - // Download to a temporary sibling so an interrupted or failed transfer - // never truncates the currently-installed binary. - let temp_path = dest_dir.join(format!("{filename}.part")); - - // Every segment after the host is remote-controlled (`repo_name` from the - // API listing, `tag` and `filename` from colony.json), so build the URL - // from encoded segments instead of interpolating them. - let url = build_url( - "https://github.com", - &[ - GITHUB_ACCOUNT, - &repo_name, - "releases", - "download", - &tag, - &filename, - ], - )?; - - let client = download_client()?; - download_with_resume(&client, &url, token.as_deref(), &temp_path, progress_tx).await?; - - // `manifest.signed` lives in the very repo the signature protects, so a - // compromised repo could flip it to false and drop the `.sig` to install - // unsigned code silently. Pin it: once an install of this app has been - // signature-verified, later updates must stay signed whatever the manifest - // now claims. The pin only ever raises the bar. - let signature_pinned = crate::persistence::load_installed_signed(&repo_name); - let require_signature = require_signature || signature_pinned; - - // Opportunistic app-signature verification: when the release publishes - // `.sig`, it MUST verify against the org release key (the same - // ed25519 key that signs the launcher, embedded in src/signing.rs). A - // missing signature is a legacy unsigned app and stays allowed; any - // OTHER failure fetching it aborts - a transient error must never make a - // signed app look unsigned. - let signature = - match fetch_optional_bytes(&client, &format!("{url}.sig"), token.as_deref()).await { - Ok(sig) => sig, - Err(e) => { - let _ = std::fs::remove_file(&temp_path); - anyhow::bail!("Could not check for a release signature of {filename}: {e}"); - } - }; - // Roll the signed metadata sidecar down to apps. A bare `.sig` proves only - // that the bytes came from the org key - not WHICH artefact or version they - // are - so a compromised maintainer could take an old, genuinely signed, - // known-vulnerable build, re-upload it under a new tag, and Colony would - // install it with every trust indicator green. The sidecar binds the bytes - // to an asset name, a digest and a version; the machinery already existed - // and was reachable only from the launcher's own self-update. - // - // Opportunistic and pinned, exactly like `.sig`: an app that publishes no - // sidecar today still installs, but once one has been verified, a later - // release cannot silently stop publishing it. - let metadata_pinned = crate::persistence::load_installed_metadata(&repo_name); - let metadata = match fetch_optional_bytes( - &client, - &format!("{url}{}", crate::signing::METADATA_SUFFIX), - token.as_deref(), - ) - .await - { - Ok(Some(meta_bytes)) => { - // The sidecar is only worth anything signed. A missing signature - // for a PRESENT sidecar is not "legacy", it is a broken release. - let meta_sig = fetch_optional_bytes( - &client, - &format!( - "{url}{}{}", - crate::signing::METADATA_SUFFIX, - crate::signing::SIGNATURE_SUFFIX - ), - token.as_deref(), - ) - .await - .map_err(|e| { - anyhow::anyhow!("Could not fetch the metadata signature for {filename}: {e}") - }) - .and_then(|sig| { - sig.ok_or_else(|| { - anyhow::anyhow!( - "{filename}{} was published without {filename}{}{} - refusing to install", - crate::signing::METADATA_SUFFIX, - crate::signing::METADATA_SUFFIX, - crate::signing::SIGNATURE_SUFFIX - ) - }) - }); - match meta_sig { - Ok(meta_sig) => Some((meta_bytes, meta_sig)), - Err(e) => { - discard_partial(&temp_path); - return Err(e); - } - } - } - Ok(None) => None, - Err(e) => { - discard_partial(&temp_path); - anyhow::bail!("Could not check for release metadata of {filename}: {e}"); - } - }; - if metadata_pinned && metadata.is_none() { - discard_partial(&temp_path); - anyhow::bail!( - "{repo_name} was previously installed with verified release metadata, but this release publishes no {filename}{} - refusing to install (uninstall it first to opt back out)", - crate::signing::METADATA_SUFFIX - ); - } - - if require_signature && signature.is_none() { - let _ = std::fs::remove_file(&temp_path); - // Say WHICH rule refused, because the two have different remedies: the - // manifest is the repo's own declaration, whereas the pin is our memory - // of a previously verified install and can only be cleared by - // uninstalling the app. - if signature_pinned { - anyhow::bail!( - "{repo_name} was previously installed with a verified signature, but this release publishes no {filename}.sig - refusing to install an unsigned downgrade of a signed app (uninstall it first to opt back out)" - ); - } - anyhow::bail!( - "The manifest requires signed releases, but no {filename}.sig was published - refusing to install" - ); - } - - // Integrity check, archive extraction and the atomic promotion are - // CPU/IO-bound — run them on a blocking thread. Any failure removes the - // temp file and leaves the previous install untouched. - let final_path = { - let temp_path = temp_path.clone(); - let dest_path = dest_path.clone(); - let dest_dir = dest_dir.clone(); - let expected_sha256 = expected_sha256.clone(); - let binary_name = binary_name.clone(); - let filename = filename.clone(); - - let installed_version = crate::persistence::load_installed_version(&repo_name); - let resolved_tag = tag.clone(); - tokio::task::spawn_blocking(move || -> Result { - let was_signed = signature.is_some(); - let had_metadata = metadata.is_some(); - // Read the staged file ONCE and check everything against that one - // buffer. Each separate read of `/.part` - a - // predictable path - is another chance to check one set of bytes - // and install a different set; the launcher path already avoids - // this by installing the buffer it verified. - let staged_bytes = - if signature.is_some() || metadata.is_some() || expected_sha256.is_some() { - Some(std::fs::read(&temp_path)?) - } else { - None - }; - if let Some(sig) = signature { - let bytes = staged_bytes.as_deref().unwrap_or_default(); - if let Err(e) = crate::signing::verify_release_signature(bytes, &sig) { - let _ = std::fs::remove_file(&temp_path); - anyhow::bail!( - "Signature verification FAILED for {filename} - refusing to install: {e}" - ); - } - tracing::info!("ed25519 signature verified for {filename}"); - } - // The sidecar, verified against the same trusted keys and then - // bound to THIS asset, THIS digest and THIS tag - which is what a - // bare signature cannot say. - if let Some((meta_bytes, meta_sig)) = metadata { - let bytes = staged_bytes.as_deref().unwrap_or_default(); - let checked = crate::signing::verify_release_signature(&meta_bytes, &meta_sig) - .and_then(|()| crate::signing::ReleaseMetadata::parse(&meta_bytes)) - .and_then(|meta| { - check_metadata_bindings( - &meta, - bytes, - &filename, - Some(resolved_tag.as_str()), - )?; - // ">=", not ">": an app pinned to a fixed tag - // legitimately reinstalls the same version, so the - // launcher's strictly-newer rule would lock it out. - ensure_not_a_downgrade(&meta, installed_version.as_deref())?; - Ok(()) - }); - if let Err(e) = checked { - let _ = std::fs::remove_file(&temp_path); - anyhow::bail!( - "Release metadata check FAILED for {filename} - refusing to install: {e}" - ); - } - tracing::info!("signed release metadata verified for {filename}"); - } - if let Some(ref expected) = expected_sha256 { - let bytes = staged_bytes.as_deref().unwrap_or_default(); - if let Err(e) = verify_sha256_bytes(bytes, expected) { - let _ = std::fs::remove_file(&temp_path); - return Err(e); - } - tracing::info!("SHA256 verified for {filename}"); - } - drop(staged_bytes); - - let final_path = if let Some(ref bin) = binary_name { - // Archive install: extract the named binary (atomically renamed - // into place by the extractor), then drop the archive. Detect the - // archive type from `filename`, not the `.part` staging path. - tracing::info!("Extracting '{bin}' from archive '{filename}'"); - let extracted = extract_binary_from_archive(&temp_path, &filename, bin, &dest_dir)?; - let _ = std::fs::remove_file(&temp_path); - extracted - } else { - // Raw binary: atomically promote the verified temp file over any - // previous install. - replace_file(&temp_path, &dest_path)?; - dest_path - }; - - // An app whose asset name carries the version - which is the case - // `filePattern` exists to serve - installs the new release under a - // NEW filename, so the previous binary is simply orphaned. Nothing - // ever showed or reclaimed it: SphereCord's AppImage is 166 MB, so - // three updates left half a gigabyte of invisible junk that only an - // uninstall would clear. Drop the superseded file now that the new - // one is committed. - if let Some(stale) = crate::persistence::load_installed_asset(&repo_name) { - if stale != filename - && Some(stale.as_str()) != binary_name.as_deref() - && ensure_safe_component(&stale).is_ok() - { - let stale_path = dest_dir.join(&stale); - if stale_path != final_path && stale_path.is_file() { - match std::fs::remove_file(&stale_path) { - Ok(()) => tracing::info!("removed superseded binary {stale}"), - Err(e) => tracing::warn!("could not remove superseded {stale}: {e}"), - } - } - } - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&final_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&final_path, perms)?; - } - - // Record WHAT is installed here, atomically with the install - // itself - not in the UI message handler. A cancel mid-install - // drops the awaiting future but this blocking task runs to - // completion: writing the version from the handler meant an - // installed binary with no version file, silently excluded from - // every future update check (or, in the filePattern case, an - // orphaned binary the app no longer even sees as installed). - // - // Order matters: the ASSET marker lands first and the VERSION - // marker last. The two writes are individually non-atomic, so a - // kill between them leaves a torn state - and only this order makes - // that state honest. With the version written first, a - // filePattern app whose asset name carries the version would claim - // the new version while `.colony_asset` still named the old file: - // installed_app_path resolves through the asset marker, so Colony - // would report the new version and Launch would run the old binary, - // with no update offered to correct it. This way round, a torn - // install simply re-offers the update. - if record_asset { - crate::persistence::save_installed_asset(&repo_name, &filename)?; - } - crate::persistence::save_installed_version(&repo_name, &tag)?; - // Pin the signature requirement for future updates: a repo that - // ships signatures today must not be able to stop tomorrow. Only - // ever raises the bar - the marker is written, never cleared, while - // the app stays installed. - if was_signed { - crate::persistence::save_installed_signed(&repo_name)?; - } - // Same one-way ratchet for the sidecar: a repo that binds its - // releases today must not be able to stop tomorrow. - if had_metadata { - crate::persistence::save_installed_metadata(&repo_name)?; - } - // Desktop integration (Linux): index the installed app in the - // desktop environment. Best-effort - a failure here must not fail - // an otherwise complete install. - if let Err(e) = crate::persistence::write_desktop_entry( - &repo_name, - &display_name, - category, - &final_path, - ) { - tracing::warn!("could not write desktop entry for {repo_name}: {e}"); - } - - Ok(final_path) - }) - .await? - }; - - match final_path { - Ok(p) => Ok(p), - Err(e) => { - let _ = std::fs::remove_file(&temp_path); - Err(e) - } - } -} - -/// Download a release asset from the Colony launcher repo. -/// Returns the path to the downloaded file in a staging directory. -pub async fn download_launcher_asset( - token: Option, - tag: String, - filename: String, - progress_tx: Option)>>, -) -> Result { - // Transient by definition: staging lives in the cache. - let temp_dir = colony_cache_dir()?.join("update-staging"); - std::fs::create_dir_all(&temp_dir)?; - let dest_path = temp_dir.join(&filename); - // Stream to `.part` and only rename onto the apply path once the - // whole file is here. Writing straight to the final name meant a download - // the user never applied - or a cancelled one - left a partial file at - // exactly the path apply_launcher_update consumes. Apply re-verifies, so it - // was refused fail-closed rather than being a hole, but the file sat there. - let part_path = temp_dir.join(format!("{filename}.part")); - - let url = build_url( - "https://github.com", - &[ - LAUNCHER_OWNER, - LAUNCHER_REPO, - "releases", - "download", - &tag, - &filename, - ], - )?; - - let client = download_client()?; - // Validates the length, rejects an empty/truncated body, and resumes a - // previous partial transfer when one provably describes the same asset. - download_with_resume(&client, &url, token.as_deref(), &part_path, progress_tx).await?; - let _ = std::fs::remove_file(&dest_path); - std::fs::rename(&part_path, &dest_path)?; - - // Fail-closed signature check: fetch the detached signature and verify the - // downloaded binary against the embedded release key BEFORE it can be - // applied. A missing, malformed, or invalid signature aborts the update. - let sig_url = format!("{url}{}", crate::signing::SIGNATURE_SUFFIX); - let signature = match fetch_bytes(&client, &sig_url, token.as_deref()).await { - Ok(bytes) => bytes, - Err(e) => { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!( - "Refusing to self-update: could not fetch the update signature ({sig_url}): {e}" - ); - } - }; - let binary_bytes = std::fs::read(&dest_path)?; - if let Err(e) = crate::signing::verify_release_signature(&binary_bytes, &signature) { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!("Refusing to self-update: {e}"); - } - // The signature above proves the bytes came from the release key, but not - // WHICH artefact or version they are: an attacker controlling what the - // release host serves could replay an older, genuinely signed build. The - // signed metadata sidecar closes that by binding these bytes to a version - // and a filename. Fail-closed, exactly like the signature itself. - let meta_url = format!("{url}{}", crate::signing::METADATA_SUFFIX); - let meta_bytes = match fetch_bytes(&client, &meta_url, token.as_deref()).await { - Ok(bytes) => bytes, - Err(e) => { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!( - "Refusing to self-update: could not fetch the update metadata ({meta_url}): {e}" - ); - } - }; - let meta_sig = match fetch_bytes( - &client, - &format!("{meta_url}{}", crate::signing::SIGNATURE_SUFFIX), - token.as_deref(), - ) - .await - { - Ok(bytes) => bytes, - Err(e) => { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!("Refusing to self-update: could not fetch the metadata signature: {e}"); - } - }; - if let Err(e) = - verify_launcher_metadata(&meta_bytes, &meta_sig, &binary_bytes, &filename, Some(&tag)) - { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!("Refusing to self-update: {e}"); - } - - // Persist the signature next to the staged binary so apply_launcher_update - // can re-verify at install time — closing any window in which the staged - // file could be swapped between download and apply. The metadata sidecar is - // staged for the same reason. - let sig_path = staged_signature_path(&dest_path); - if let Err(e) = write_new_file(&sig_path, &signature) { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!("Could not stage update signature: {e}"); - } - let (meta_path, meta_sig_path) = staged_metadata_paths(&dest_path); - if let Err(e) = write_new_file(&meta_path, &meta_bytes) - .and_then(|()| write_new_file(&meta_sig_path, &meta_sig)) - { - let _ = std::fs::remove_file(&dest_path); - anyhow::bail!("Could not stage update metadata: {e}"); - } - tracing::info!("Launcher update signature and metadata verified for {filename} ({tag})"); - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&dest_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&dest_path, perms)?; - } - - Ok(dest_path) -} - -/// Path of the detached signature staged next to a downloaded binary. -fn staged_signature_path(binary: &std::path::Path) -> PathBuf { - PathBuf::from(format!( - "{}{}", - binary.display(), - crate::signing::SIGNATURE_SUFFIX - )) -} - -/// Paths of the metadata sidecar and its signature, staged next to a binary. -fn staged_metadata_paths(binary: &std::path::Path) -> (PathBuf, PathBuf) { - let meta = format!("{}{}", binary.display(), crate::signing::METADATA_SUFFIX); - let sig = format!("{meta}{}", crate::signing::SIGNATURE_SUFFIX); - (PathBuf::from(meta), PathBuf::from(sig)) -} - -/// Verify a signed metadata sidecar and enforce what it binds. -/// -/// Checks, in order: the sidecar is signed by the release key; it describes the -/// asset we asked for; its digest matches the bytes in hand; it names the tag the -/// update check selected (`expected_tag`, unknown at apply time hence optional); -/// and its version is strictly newer than the running build. That last check is -/// the anti-rollback - without it, any older org-signed binary is a valid -/// "update". -fn verify_launcher_metadata( - meta_bytes: &[u8], - meta_sig: &[u8], - binary_bytes: &[u8], - expected_asset: &str, - expected_tag: Option<&str>, -) -> Result<()> { - crate::signing::verify_release_signature(meta_bytes, meta_sig) - .map_err(|e| anyhow::anyhow!("update metadata signature invalid: {e}"))?; - let meta = crate::signing::ReleaseMetadata::parse(meta_bytes)?; - check_metadata_bindings(&meta, binary_bytes, expected_asset, expected_tag)?; - ensure_strictly_newer(&meta, APP_VERSION) -} - -/// The binding rules shared by the launcher and app install paths, split out so -/// they can be tested without the release private key. Assumes the metadata -/// signature has already been verified. -/// -/// Deliberately does NOT compare versions: that rule differs between the two -/// callers (see [`ensure_strictly_newer`] and [`ensure_not_a_downgrade`]). -fn check_metadata_bindings( - meta: &crate::signing::ReleaseMetadata, - binary_bytes: &[u8], - expected_asset: &str, - expected_tag: Option<&str>, -) -> Result<()> { - anyhow::ensure!( - meta.asset == expected_asset, - "update metadata is for a different asset (signed '{}', expected '{expected_asset}')", - meta.asset - ); - - let digest = sha256_hex(binary_bytes); - anyhow::ensure!( - digest == meta.sha256, - "update metadata digest mismatch (signed {}, downloaded {digest})", - meta.sha256 - ); - - if let Some(tag) = expected_tag { - anyhow::ensure!( - meta.version == tag, - "update metadata is for a different release (signed '{}', expected '{tag}')", - meta.version - ); - } - - Ok(()) -} - -/// The launcher's extra rule: a self-update must be STRICTLY newer than the -/// build applying it. Separate from [`check_metadata_bindings`] because apps do -/// not share it - an app pinned to a fixed tag legitimately reinstalls the same -/// version, so "strictly newer" would make reinstalling impossible. -fn ensure_strictly_newer(meta: &crate::signing::ReleaseMetadata, running: &str) -> Result<()> { - let new = crate::github::parse_version_tag(&meta.version).ok_or_else(|| { - anyhow::anyhow!( - "unrecognized version in update metadata: '{}'", - meta.version - ) - })?; - let current = crate::github::parse_version_tag(running) - .ok_or_else(|| anyhow::anyhow!("unparseable running version"))?; - anyhow::ensure!( - new > current, - "refusing a downgrade: signed update is {new} but the running build is {current}" - ); - Ok(()) -} - -/// The app equivalent: never go BACKWARDS from what is installed, but allow the -/// same version (a reinstall, or an app pinned to a fixed tag). -/// -/// Non-semver tags are common in the ecosystem and are not orderable, so they -/// are accepted here rather than refused - the asset, digest and tag bindings -/// already did the work that matters, and refusing them would lock those apps -/// out of updates entirely. -fn ensure_not_a_downgrade( - meta: &crate::signing::ReleaseMetadata, - installed: Option<&str>, -) -> Result<()> { - let Some(installed) = installed else { - return Ok(()); - }; - let (Some(new), Some(current)) = ( - crate::github::parse_version_tag(&meta.version), - crate::github::parse_version_tag(installed), - ) else { - return Ok(()); - }; - anyhow::ensure!( - new >= current, - "refusing a downgrade: signed release is {new} but {current} is installed" - ); - Ok(()) -} - -/// Replace the running Colony binary with the downloaded update. -/// Returns the exe path for relaunch on success. Restores backup on failure. -pub fn apply_launcher_update(new_binary: &std::path::Path) -> Result { - let current_exe = std::env::current_exe() - .map_err(|e| anyhow::anyhow!("Cannot determine current exe path: {e}"))?; - - // Re-verify the staged binary against its staged signature at the moment of - // installation, closing any local window in which the staged file (in the - // update-staging dir) could have been swapped after the download-time check. - let sig_path = staged_signature_path(new_binary); - let signature = std::fs::read(&sig_path).map_err(|e| { - anyhow::anyhow!( - "Missing staged update signature ({}): {e}", - sig_path.display() - ) - })?; - let staged_bytes = std::fs::read(new_binary) - .map_err(|e| anyhow::anyhow!("Cannot read staged update binary: {e}"))?; - crate::signing::verify_release_signature(&staged_bytes, &signature) - .map_err(|e| anyhow::anyhow!("Refusing to apply update: {e}"))?; - - // Re-check the sidecar at install time too, for the same reason the signature - // is re-checked: the staging directory is writable, so the file could have - // been swapped after the download-time check. The requested tag is not in - // scope here, so this enforces the asset name, the digest and "strictly newer - // than the running build" - enough to stop a rollback, though a swap to - // another org-signed release that is ALSO newer than the running build would - // still pass. Closing that needs the tag threaded through the staged state. - let (meta_path, meta_sig_path) = staged_metadata_paths(new_binary); - let staged_meta = std::fs::read(&meta_path).map_err(|e| { - anyhow::anyhow!( - "Missing staged update metadata ({}): {e}", - meta_path.display() - ) - })?; - let staged_meta_sig = std::fs::read(&meta_sig_path).map_err(|e| { - anyhow::anyhow!( - "Missing staged update metadata signature ({}): {e}", - meta_sig_path.display() - ) - })?; - let expected_asset = new_binary - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| anyhow::anyhow!("staged update has no usable file name"))?; - verify_launcher_metadata( - &staged_meta, - &staged_meta_sig, - &staged_bytes, - expected_asset, - None, - ) - .map_err(|e| anyhow::anyhow!("Refusing to apply update: {e}"))?; - - // Refuse to touch the running binary if the staged update is empty/missing. - anyhow::ensure!( - !staged_bytes.is_empty(), - "Staged update binary is empty; refusing to apply" - ); - - let backup_path = current_exe.with_extension("old"); - if backup_path.exists() { - let _ = std::fs::remove_file(&backup_path); - } - - // Stage the new binary next to the current exe (same filesystem) so the - // final swap is an atomic rename rather than a non-atomic, interruptible - // copy directly over the running binary. - let staged_next = current_exe.with_extension("new"); - if staged_next.exists() { - let _ = std::fs::remove_file(&staged_next); - } - // Write the byte buffer that was just VERIFIED - copying the file again - // would re-read from disk and install bytes the signature check never saw - // (a swap between read and copy would slip through). Through - // `write_new_file`, because `.new` is a predictable name in a - // user-writable directory: `fs::write` follows a symlink planted there, so - // the verified bytes would land at the attacker's path and the rename below - // would then move the SYMLINK over the running binary - turning Colony's - // own executable into a link to a file rewritable afterwards, cleanly past - // every signature check. - write_new_file(&staged_next, &staged_bytes) - .map_err(|e| anyhow::anyhow!("Failed to stage new binary: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&staged_next)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&staged_next, perms)?; - } - - // Back up the running binary (renaming a running exe works on all - // platforms), then atomically move the staged copy into its place. - std::fs::rename(¤t_exe, &backup_path) - .map_err(|e| anyhow::anyhow!("Failed to backup current binary: {e}"))?; - - match std::fs::rename(&staged_next, ¤t_exe) { - Ok(()) => { - let _ = std::fs::remove_file(new_binary); - let _ = std::fs::remove_file(&sig_path); - // The sidecars are staged next to the binary, so they must be swept - // too or the staging directory never becomes empty. - let _ = std::fs::remove_file(&meta_path); - let _ = std::fs::remove_file(&meta_sig_path); - let _ = std::fs::remove_dir(new_binary.parent().unwrap_or(new_binary)); - Ok(current_exe) - } - Err(e) => { - tracing::error!("Failed to install new binary, restoring backup: {e}"); - let _ = std::fs::remove_file(&staged_next); - if let Err(re) = std::fs::rename(&backup_path, ¤t_exe) { - tracing::error!( - "CRITICAL: could not restore backup {} -> {}: {re}", - backup_path.display(), - current_exe.display() - ); - } - Err(anyhow::anyhow!("Failed to install new binary: {e}")) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A one-shot HTTP server that TRUNCATES the first response and honours - /// Range on the next one - the shape of a real dropped connection. - /// - /// Hand-rolled on `std::net` rather than pulled in as a dependency: the - /// point is to prove the resume path end to end without adding a test-only - /// crate to a project that counts its dependencies. - fn spawn_truncating_server( - body: Vec, - cut: usize, - etag: &str, - ) -> (String, std::thread::JoinHandle<()>) { - use std::io::{BufRead, BufReader, Write}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let url = format!("http://{}/asset", listener.local_addr().unwrap()); - let etag = etag.to_string(); - let handle = std::thread::spawn(move || { - // Two connections: the truncated one, then the ranged one. - for _ in 0..2 { - let Ok((stream, _)) = listener.accept() else { - return; - }; - let mut reader = BufReader::new(&stream); - let mut range_start: Option = None; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - return; - } - if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") { - range_start = v.split('-').next().and_then(|n| n.trim().parse().ok()); - } - if line == "\r\n" || line == "\n" { - break; - } - } - let mut stream = &stream; - match range_start { - Some(start) => { - let chunk = &body[start..]; - let head = format!( - "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {}-{}/{}\r\nContent-Length: {}\r\nETag: {}\r\nConnection: close\r\n\r\n", - start, - body.len() - 1, - body.len(), - chunk.len(), - etag - ); - let _ = stream.write_all(head.as_bytes()); - let _ = stream.write_all(chunk); - } - None => { - let head = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: {}\r\nConnection: close\r\n\r\n", - body.len(), - etag - ); - let _ = stream.write_all(head.as_bytes()); - // Promise the whole body, deliver part of it, hang up. - let _ = stream.write_all(&body[..cut]); - } - } - let _ = stream.flush(); - } - }); - (url, handle) - } - - /// The behaviour this whole batch exists for: a connection that died at 40% - /// used to throw away every byte and charge the user the full asset again. - #[test] - fn a_dropped_connection_is_resumed_instead_of_restarted() { - let body: Vec = (0..300_000usize) - .map(|i| ((i * 7 + 3) % 256) as u8) - .collect(); - let (url, server) = spawn_truncating_server(body.clone(), 120_000, "\"v1\""); - - let dir = std::env::temp_dir().join("colony_test_resume"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dest = dir.join("asset.part"); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let result = rt.block_on(async { - let client = download_client().unwrap(); - download_with_resume(&client, &url, None, &dest, None).await - }); - - assert!( - result.is_ok(), - "the retry must finish the transfer: {result:?}" - ); - assert_eq!( - std::fs::read(&dest).unwrap(), - body, - "the stitched file must be byte-identical to the asset" - ); - assert!( - !PartIdentity::path(&dest).exists(), - "a completed transfer leaves no identity file behind" - ); - - let _ = std::fs::remove_dir_all(&dir); - let _ = server.join(); - } - - /// Resuming stitches two responses into one file, so it is only sound - /// while both describe the same artifact. The identity sidecar is what - /// makes that decidable; without it, a re-tagged release could be silently - /// assembled from two different bodies. - #[test] - fn a_partial_transfer_is_only_resumable_against_its_own_identity() { - let dir = std::env::temp_dir().join("colony_test_part_identity"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let part = dir.join("grape-linux.part"); - - // A partial transfer plus the identity that describes it. - std::fs::write(&part, vec![0u8; 512]).unwrap(); - PartIdentity { - etag: "\"abc\"".into(), - total: 4096, - } - .write(&part); - - let id = PartIdentity::read(&part).expect("identity round-trips"); - assert_eq!(id.etag, "\"abc\""); - assert_eq!(id.total, 4096); - assert!( - std::fs::metadata(&part).unwrap().len() < id.total, - "a short file against a known total is what makes a resume possible" - ); - - // Discarding takes the sidecar with it, so the next attempt cannot - // resume from bytes nothing describes. - discard_partial(&part); - assert!(!part.exists()); - assert!(PartIdentity::read(&part).is_none()); - - // A truncated or garbage sidecar is not an identity. - std::fs::write(PartIdentity::path(&part), "no-newline-here").unwrap(); - assert!(PartIdentity::read(&part).is_none()); - std::fs::write(PartIdentity::path(&part), "\"abc\"\nnot-a-number\n").unwrap(); - assert!(PartIdentity::read(&part).is_none()); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// A `tag` from colony.json used to be interpolated straight into the - /// release URL. reqwest parses with the WHATWG parser, which collapses - /// `..` BEFORE the request is issued, so one line of a catalog repo could - /// redirect the install to an account outside the org entirely - defeating - /// the only containment claim the trust model has. - #[test] - fn a_hostile_tag_cannot_walk_the_release_url_out_of_the_org() { - let hostile = "v1/../../../../../EvilOrg/EvilRepo/releases/download/v1"; - let url = build_url( - "https://github.com", - &[ - "Project-Colony", - "Grape", - "releases", - "download", - hostile, - "grape-linux", - ], - ) - .expect("segments are encoded, not rejected"); - - assert!( - url.starts_with("https://github.com/Project-Colony/Grape/releases/download/"), - "the request must stay inside the org, got {url}" - ); - assert!( - !url.contains("EvilOrg/EvilRepo/releases"), - "the traversal must not survive as structure, got {url}" - ); - - // Reparsing is what reqwest itself does; the collapse must not happen - // there either, which is the whole point of encoding the segment. - let reparsed = reqwest::Url::parse(&url).expect("valid URL"); - assert_eq!( - reparsed.path_segments().map(|s| s.count()), - Some(6), - "no segment may be structural: {url}" - ); - - // A legitimate tag containing a slash (`release/1.0`) still works - - // rejecting `/` outright would have broken real repos. - let ok = build_url( - "https://github.com", - &[ - "Project-Colony", - "Grape", - "releases", - "download", - "release/1.0", - "grape-linux", - ], - ) - .expect("slashes in a tag are encoded, not an error"); - assert!(ok.contains("release%2F1.0"), "got {ok}"); - } - - /// The raw-binary branch of `extract_binary_from_archive` used to join the - /// manifest's `binary` field straight into the install dir, so a hostile - /// manifest could write anywhere (the caller then chmods 0755 and writes a - /// desktop entry pointing at it). The guard is now hoisted above the branch. - #[test] - fn raw_binary_branch_rejects_traversal_in_binary_name() { - // Every escape target below must ALREADY EXIST as a directory, so that - // without the guard the rename would genuinely succeed. A target whose - // parent is missing would make this test pass on ENOENT alone, and it - // would stay green with the fix reverted. - let root = std::env::temp_dir().join("colony_test_raw_traversal"); - let _ = std::fs::remove_dir_all(&root); - let dest_dir = root.join("apps").join("SomeApp"); - std::fs::create_dir_all(&dest_dir).unwrap(); - std::fs::create_dir_all(dest_dir.join("sub")).unwrap(); - let outside = root.join("apps").join("escaped"); - let staged = dest_dir.join("app.bin.part"); - - for hostile in ["../escaped", "../../apps/escaped", "sub/nested", ".."] { - std::fs::write(&staged, b"payload").unwrap(); - // `app.bin` has no archive extension, so this hits the raw branch. - let result = extract_binary_from_archive(&staged, "app.bin", hostile, &dest_dir); - assert!( - result.is_err(), - "traversal must be refused for binary name {hostile:?}" - ); - assert!( - staged.exists(), - "a refused extraction must leave staging intact ({hostile:?})" - ); - assert!( - !outside.exists(), - "nothing may be written outside the install dir ({hostile:?})" - ); - assert!( - !dest_dir.join("sub").join("nested").exists(), - "nothing may be written below the install dir ({hostile:?})" - ); - std::fs::remove_file(&staged).unwrap(); - } - - // Control: the same call with a plain name DOES install. Without it, the - // assertions above could be passing because of a broken fixture. - std::fs::write(&staged, b"payload").unwrap(); - let installed = extract_binary_from_archive(&staged, "app.bin", "app", &dest_dir).unwrap(); - assert_eq!(installed, dest_dir.join("app")); - assert!(installed.exists()); - - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn only_http_and_https_urls_may_reach_the_uri_opener() { - for ok in [ - "https://github.com/Project-Colony/Colony", - "http://example.com/a?b=c#d", - "HTTPS://GitHub.com/x", - ] { - assert!(web_url(ok).is_some(), "{ok} should be allowed"); - } - for bad in [ - // The one-click execution vectors from a hostile README. - "file:///home/user/.local/share/applications/colony-app.desktop", - "//198.51.100.7/share/setup.exe", - "\\\\198.51.100.7\\share\\setup.exe", - "javascript:alert(1)", - "smb://host/share", - "steam://run/1", - "data:text/html,", - "mailto:a@b.c", - "", - " ", - "not a url", - "/etc/passwd", - ] { - assert!(web_url(bad).is_none(), "{bad:?} must be refused"); - } - } - - /// The WHATWG parser strips tabs, newlines and leading control characters - /// anywhere in the input, so a bool-returning check would validate one string - /// while `open::that` received a different one. CommonMark decodes character - /// references in link destinations, so `[x](ht tps://evil)` produces - /// exactly these shapes from a hostile README. - #[test] - fn opened_url_is_the_one_that_was_validated() { - for sneaky in [ - "ht\ntps://evil.example/x", - "ht\ttps://evil.example/x", - "\thttps://evil.example/x", - " https://evil.example/x", - "https://evil.example/\rx", - ] { - // These DO parse (that is the whole problem), so the contract is not - // "refused" but "what comes back is clean": a real http(s) URL with no - // control characters, never the raw string. - let normalized = web_url(sneaky).expect("parses as https"); - assert_ne!(normalized, sneaky, "the raw form must not be handed on"); - assert!(normalized.starts_with("https://"), "{normalized}"); - assert!( - !normalized.chars().any(|c| c.is_control()), - "{normalized:?} still carries control characters" - ); - } - // Scheme-only forms are normalized by the parser rather than rejected: - // for http(s) it fills in the host, which stays a plain web URL. - assert_eq!( - web_url("http:evil.example/x").as_deref(), - Some("http://evil.example/x") - ); - } - - fn meta_for(bytes: &[u8], version: &str, asset: &str) -> crate::signing::ReleaseMetadata { - crate::signing::ReleaseMetadata { - version: version.into(), - asset: asset.into(), - sha256: sha256_hex(bytes), - } - } - - #[test] - fn signed_metadata_accepts_a_genuine_newer_release() { - let bytes = b"new colony build"; - let meta = meta_for(bytes, "v1.2.0", "colony-linux"); - assert!( - check_metadata_bindings(&meta, bytes, "colony-linux", Some("v1.2.0")).is_ok() - && ensure_strictly_newer(&meta, "1.1.0").is_ok() - ); - } - - /// The point of the sidecar: an older but genuinely org-signed binary must - /// not be installable as an "update". - #[test] - fn signed_metadata_refuses_a_downgrade_or_replay() { - let bytes = b"old colony build"; - for older in ["v1.0.0", "v0.9.0", "v1.1.0"] { - let meta = meta_for(bytes, older, "colony-linux"); - // The BINDINGS pass - the bytes really are that signed artefact. - // What refuses the replay is the version rule that sits beside - // them, which is why it lives in its own function per caller. - assert!(check_metadata_bindings(&meta, bytes, "colony-linux", Some(older)).is_ok()); - let err = ensure_strictly_newer(&meta, "1.1.0") - .unwrap_err() - .to_string(); - assert!( - err.contains("downgrade"), - "{older} vs running 1.1.0 should be refused as a downgrade, got: {err}" - ); - } - } - - #[test] - fn signed_metadata_refuses_a_substituted_artefact() { - let served = b"the macos binary"; - // Genuine, correctly signed metadata - but for a DIFFERENT asset. - let meta = meta_for(served, "v1.2.0", "colony-macos"); - assert!( - check_metadata_bindings(&meta, served, "colony-linux", Some("v1.2.0")).is_err(), - "metadata naming another asset must not validate this download" - ); - } - - #[test] - fn signed_metadata_refuses_mismatched_digest_or_tag() { - let meta = meta_for(b"expected bytes", "v1.2.0", "colony-linux"); - assert!( - check_metadata_bindings(&meta, b"tampered bytes", "colony-linux", None).is_err(), - "digest mismatch must fail" - ); - assert!( - check_metadata_bindings(&meta, b"expected bytes", "colony-linux", Some("v1.3.0")) - .is_err(), - "metadata for another tag than the one resolved must fail" - ); - } - - /// Apps do not share the launcher's strictly-newer rule - an app pinned to - /// a fixed tag reinstalls the same version - but they must still refuse a - /// genuinely-signed OLDER build replayed under a new tag. - #[test] - fn an_app_may_reinstall_the_same_version_but_never_an_older_one() { - let meta = meta_for(b"bytes", "v1.2.0", "grape-linux"); - - assert!( - ensure_not_a_downgrade(&meta, Some("v1.2.0")).is_ok(), - "reinstalling the pinned version must stay possible" - ); - assert!(ensure_not_a_downgrade(&meta, Some("v1.1.0")).is_ok()); - assert!(ensure_not_a_downgrade(&meta, None).is_ok(), "first install"); - assert!( - ensure_not_a_downgrade(&meta, Some("v2.0.0")).is_err(), - "a signed but older release must not be installable over a newer one" - ); - - // The launcher rule is the stricter one, and stays that way. - assert!(ensure_strictly_newer(&meta, "1.1.0").is_ok()); - assert!( - ensure_strictly_newer(&meta, "1.2.0").is_err(), - "the launcher must never reapply its own version" - ); - - // Non-semver tags are common in the ecosystem and are not orderable: - // accepted, because the asset/digest/tag bindings already did the work - // and refusing would lock those apps out of updates entirely. - let nightly = meta_for(b"bytes", "nightly", "grape-linux"); - assert!(ensure_not_a_downgrade(&nightly, Some("nightly")).is_ok()); - } - - #[test] - fn extract_from_zip_works() { - use std::io::Write; - let dir = std::env::temp_dir().join("colony_test_zip_extract"); - let _ = std::fs::create_dir_all(&dir); - - // Create a zip archive with a binary inside - let zip_path = dir.join("test.zip"); - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip_writer = zip::ZipWriter::new(file); - zip_writer - .start_file("subdir/my-binary", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip_writer.write_all(b"binary-content").unwrap(); - zip_writer.finish().unwrap(); - - // Extract - let result = extract_from_zip(&zip_path, "my-binary", &dir); - assert!(result.is_ok()); - let extracted = result.unwrap(); - assert_eq!( - extracted.file_name().unwrap().to_str().unwrap(), - "my-binary" - ); - assert_eq!( - std::fs::read_to_string(&extracted).unwrap(), - "binary-content" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn extract_binary_from_archive_detects_type_from_asset_name_not_staging_path() { - use std::io::Write; - let dir = std::env::temp_dir().join("colony_test_part_extract"); - let _ = std::fs::create_dir_all(&dir); - - // Build a real zip, but stage it under a `.part` name (as the download - // path does) so its own extension is NOT `.zip`. - let staged = dir.join("my-app-linux.zip.part"); - let file = std::fs::File::create(&staged).unwrap(); - let mut zip_writer = zip::ZipWriter::new(file); - zip_writer - .start_file("my-app", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip_writer.write_all(b"real-elf-bytes").unwrap(); - zip_writer.finish().unwrap(); - - // Type detection must use the asset name, so the archive is extracted - // rather than the compressed bytes being renamed to the binary. - let result = extract_binary_from_archive(&staged, "my-app-linux.zip", "my-app", &dir); - assert!(result.is_ok(), "extract failed: {:?}", result.err()); - let extracted = result.unwrap(); - assert_eq!(extracted.file_name().unwrap().to_str().unwrap(), "my-app"); - assert_eq!( - std::fs::read_to_string(&extracted).unwrap(), - "real-elf-bytes" - ); - // The staging archive is consumed. - assert!(!staged.exists()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn extract_from_tar_gz_works() { - let dir = std::env::temp_dir().join("colony_test_targz_extract"); - let _ = std::fs::create_dir_all(&dir); - - // Create a tar.gz archive with a binary inside - let tar_gz_path = dir.join("test.tar.gz"); - let file = std::fs::File::create(&tar_gz_path).unwrap(); - let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default()); - let mut tar_builder = tar::Builder::new(gz); - - let content = b"binary-content-tar"; - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - tar_builder - .append_data(&mut header, "subdir/my-cli", &content[..]) - .unwrap(); - // Finish tar, then finish gzip encoder to write the gzip footer - let gz = tar_builder.into_inner().unwrap(); - gz.finish().unwrap(); - - // Extract - let result = extract_from_tar_gz(&tar_gz_path, "my-cli", &dir); - assert!(result.is_ok(), "extract failed: {:?}", result.err()); - let extracted = result.unwrap(); - assert_eq!(extracted.file_name().unwrap().to_str().unwrap(), "my-cli"); - assert_eq!( - std::fs::read_to_string(&extracted).unwrap(), - "binary-content-tar" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn extract_from_zip_missing_binary() { - use std::io::Write; - let dir = std::env::temp_dir().join("colony_test_zip_missing"); - let _ = std::fs::create_dir_all(&dir); - - let zip_path = dir.join("empty.zip"); - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip_writer = zip::ZipWriter::new(file); - zip_writer - .start_file("other-file", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip_writer.write_all(b"data").unwrap(); - zip_writer.finish().unwrap(); - - let result = extract_from_zip(&zip_path, "nonexistent", &dir); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not found")); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn sha256_hex_matches_the_fips_180_vectors() { - assert_eq!( - sha256_hex(b""), - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ); - assert_eq!( - sha256_hex(b"abc"), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ); - } - - #[test] - fn sha256_verification_correct() { - use std::io::Write; - let dir = std::env::temp_dir().join("colony_test_sha256"); - let _ = std::fs::create_dir_all(&dir); - let file_path = dir.join("test.bin"); - let content = b"hello world"; - let mut f = std::fs::File::create(&file_path).unwrap(); - f.write_all(content).unwrap(); - f.flush().unwrap(); - - // SHA256 of "hello world" - let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; - assert!(verify_sha256_bytes(&std::fs::read(&file_path).unwrap(), expected).is_ok()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn sha256_verification_mismatch() { - use std::io::Write; - let dir = std::env::temp_dir().join("colony_test_sha256_bad"); - let _ = std::fs::create_dir_all(&dir); - let file_path = dir.join("test.bin"); - let mut f = std::fs::File::create(&file_path).unwrap(); - f.write_all(b"hello world").unwrap(); - f.flush().unwrap(); - - assert!( - verify_sha256_bytes(&std::fs::read(&file_path).unwrap(), "0000000000000000").is_err() - ); - - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/src/download/files.rs b/src/download/files.rs new file mode 100644 index 0000000..b90993a --- /dev/null +++ b/src/download/files.rs @@ -0,0 +1,414 @@ +//! Writing into install and staging directories: single-component names, +//! creates that never follow a planted link, replacing a binary that is +//! running, and extracting one file from a zip or tar.gz archive. + +use anyhow::Result; +use std::path::PathBuf; + +/// Move `new` onto `dest`, even when `dest` is a binary that is currently +/// running. +/// +/// `std::fs::rename` over a live executable fails on Windows: the image is held +/// with FILE_SHARE_READ|FILE_SHARE_DELETE, so MoveFileExW cannot delete the +/// destination and returns ERROR_ACCESS_DENIED. Colony already knew the parade +/// and applied it to its OWN self-update - rename the running file aside first, +/// then move the new one in - but the app installer never learned it, so +/// updating an app the user had left open failed AFTER downloading and +/// verifying the whole asset, with a raw "Access is denied" and no hint that +/// closing the app would fix it. +/// +/// The aside-rename is harmless on Unix (where the plain rename would have +/// worked anyway), so this takes the same path on every platform rather than +/// keeping two behaviours that only one of them exercises. +pub(super) fn replace_file(new: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> { + if !dest.exists() { + return std::fs::rename(new, dest); + } + let aside = dest.with_extension("old"); + let _ = std::fs::remove_file(&aside); + // A running image can always be RENAMED, on every platform - that is the + // whole trick. If even this fails, the destination is genuinely locked and + // the caller gets the real error instead of a mystery. + std::fs::rename(dest, &aside)?; + match std::fs::rename(new, dest) { + Ok(()) => { + // Best effort: on Windows the old image stays until the process + // holding it exits, and prune_staging() sweeps it at next boot. + let _ = std::fs::remove_file(&aside); + Ok(()) + } + Err(e) => { + let _ = std::fs::rename(&aside, dest); + Err(e) + } + } +} + +/// Ensure a filename is a single normal path component (no `..`, no path +/// separators, not absolute) before it is joined into a destination directory. +/// Shared by archive extraction and raw-asset download to block path traversal. +/// Names Win32 resolves to devices no matter which directory contains them, +/// and regardless of any extension (`CON.txt` is still the console). +const RESERVED_DEVICE_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +pub(crate) fn ensure_safe_component(name: &str) -> Result<()> { + let p = std::path::Path::new(name); + anyhow::ensure!( + p.components().count() == 1 + && matches!(p.components().next(), Some(std::path::Component::Normal(_))), + "Invalid file name (path traversal attempt?): {name}" + ); + + // The check above is the shape of a POSIX path, which is only half the + // question on the three platforms Colony supports. Rust's Windows parser + // recognises a drive prefix only when exactly one letter precedes the + // colon, so "payload:stream" is a single Normal component and joining it + // writes an NTFS alternate data stream on a file named "payload"; a + // reserved device name resolves to a device from any directory; and a + // trailing dot or space is stripped by the filesystem, so the name written + // differs from the name checked. + // + // Enforced on every platform, not behind cfg(windows): the catalog is + // shared, so a manifest that would be refused on Windows must be refused + // everywhere rather than installing differently per user. + anyhow::ensure!( + !name.contains( + |c: char| matches!(c, ':' | '\\' | '/' | '<' | '>' | '"' | '|' | '?' | '*') + || c.is_control() + ), + "Invalid file name (reserved character): {name}" + ); + anyhow::ensure!( + !name.ends_with('.') && !name.ends_with(' '), + "Invalid file name (trailing dot or space is silently stripped): {name}" + ); + let stem = name.split('.').next().unwrap_or(name); + anyhow::ensure!( + !RESERVED_DEVICE_NAMES + .iter() + .any(|d| stem.eq_ignore_ascii_case(d)), + "Invalid file name (reserved device name): {name}" + ); + Ok(()) +} + +/// Create `path` fresh, never following an existing file or symlink. +/// +/// `File::create` truncates whatever it finds, and follows a symlink to write at +/// its target: a symlink pre-planted at a predictable staging name (`.new`, +/// `.part`) would redirect the write outside the install directory, and the +/// caller's `set_permissions` then chmods that target. Unlinking first and opening +/// with `create_new` makes the create fail rather than follow anything that races +/// in between. +pub(super) fn create_new_file(path: &std::path::Path) -> Result { + // Removing our own leftover staging file is expected; a failure here is not + // fatal because create_new below is the actual guard. + let _ = std::fs::remove_file(path); + Ok(std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?) +} + +/// `std::fs::write` for the trust path: same result, but through +/// [`create_new_file`] so a symlink planted at the (entirely predictable) +/// staging name is never followed. +pub(super) fn write_new_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + use std::io::Write; + let mut file = create_new_file(path)?; + file.write_all(contents)?; + file.flush()?; + Ok(()) +} + +/// Extract a single file from a .zip archive. +fn extract_from_zip( + archive_path: &std::path::Path, + binary_name: &str, + dest_dir: &std::path::Path, +) -> Result { + ensure_safe_component(binary_name)?; + let file = std::fs::File::open(archive_path)?; + let mut archive = zip::ZipArchive::new(file)?; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let entry_name = entry.name()?.into_owned(); + // Match by exact filename (last component), handles entries like "dir/binary" + let matches = std::path::Path::new(&entry_name) + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == binary_name) + .unwrap_or(false); + if matches { + anyhow::ensure!( + entry.is_file(), + "Refusing to extract non-regular zip entry '{binary_name}'" + ); + // Extract to a temp file, then atomically rename over any previous + // install so a failed extraction never leaves a truncated binary. + let final_dest = dest_dir.join(binary_name); + let tmp_dest = dest_dir.join(format!("{binary_name}.new")); + let mut out = create_new_file(&tmp_dest)?; + std::io::copy(&mut entry, &mut out)?; + drop(out); + replace_file(&tmp_dest, &final_dest)?; + return Ok(final_dest); + } + } + anyhow::bail!("Binary '{binary_name}' not found in zip archive") +} + +/// Extract a single file from a .tar.gz archive. +fn extract_from_tar_gz( + archive_path: &std::path::Path, + binary_name: &str, + dest_dir: &std::path::Path, +) -> Result { + ensure_safe_component(binary_name)?; + let file = std::fs::File::open(archive_path)?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let path = entry.path()?; + let matches = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == binary_name) + .unwrap_or(false); + if matches { + // Reject symlink / hardlink / device entries: only a regular file + // may be unpacked, so a crafted archive cannot make us follow a + // link to an arbitrary path when we later chmod the result. + anyhow::ensure!( + entry.header().entry_type().is_file(), + "Refusing to extract non-regular tar entry '{binary_name}'" + ); + let final_dest = dest_dir.join(binary_name); + let tmp_dest = dest_dir.join(format!("{binary_name}.new")); + entry.unpack(&tmp_dest)?; + replace_file(&tmp_dest, &final_dest)?; + return Ok(final_dest); + } + } + anyhow::bail!("Binary '{binary_name}' not found in tar.gz archive") +} + +/// Extract a binary from an archive based on its extension. +/// +/// `asset_name` is the original release asset filename used only to detect the +/// archive type — `archive_path` may be a staging file (e.g. `foo.zip.part`) +/// whose own extension must not be used for detection. +pub(super) fn extract_binary_from_archive( + archive_path: &std::path::Path, + asset_name: &str, + binary_name: &str, + dest_dir: &std::path::Path, +) -> Result { + // `binary_name` is a manifest-supplied string that becomes a path component + // in EVERY branch below, so the traversal guard is hoisted here, before the + // dispatch. It used to live only inside the zip and tar extractors, which + // left the raw-binary branch able to write outside `dest_dir` — and, after + // the caller's chmod 0755 and desktop entry, to gain execution at next login + // — from a hostile manifest. The extractors keep their own call so they stay + // safe in isolation. + ensure_safe_component(binary_name)?; + if asset_name.ends_with(".zip") { + let result = extract_from_zip(archive_path, binary_name, dest_dir); + let _ = std::fs::remove_file(archive_path); + result + } else if asset_name.ends_with(".tar.gz") || asset_name.ends_with(".tgz") { + let result = extract_from_tar_gz(archive_path, binary_name, dest_dir); + let _ = std::fs::remove_file(archive_path); + result + } else { + // Raw binary (e.g. .exe, no archive extension) — rename to binary_name in dest_dir + let dest = dest_dir.join(binary_name); + replace_file(archive_path, &dest)?; + Ok(dest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The raw-binary branch of `extract_binary_from_archive` used to join the + /// manifest's `binary` field straight into the install dir, so a hostile + /// manifest could write anywhere (the caller then chmods 0755 and writes a + /// desktop entry pointing at it). The guard is now hoisted above the branch. + #[test] + fn raw_binary_branch_rejects_traversal_in_binary_name() { + // Every escape target below must ALREADY EXIST as a directory, so that + // without the guard the rename would genuinely succeed. A target whose + // parent is missing would make this test pass on ENOENT alone, and it + // would stay green with the fix reverted. + let root = std::env::temp_dir().join("colony_test_raw_traversal"); + let _ = std::fs::remove_dir_all(&root); + let dest_dir = root.join("apps").join("SomeApp"); + std::fs::create_dir_all(&dest_dir).unwrap(); + std::fs::create_dir_all(dest_dir.join("sub")).unwrap(); + let outside = root.join("apps").join("escaped"); + let staged = dest_dir.join("app.bin.part"); + + for hostile in ["../escaped", "../../apps/escaped", "sub/nested", ".."] { + std::fs::write(&staged, b"payload").unwrap(); + // `app.bin` has no archive extension, so this hits the raw branch. + let result = extract_binary_from_archive(&staged, "app.bin", hostile, &dest_dir); + assert!( + result.is_err(), + "traversal must be refused for binary name {hostile:?}" + ); + assert!( + staged.exists(), + "a refused extraction must leave staging intact ({hostile:?})" + ); + assert!( + !outside.exists(), + "nothing may be written outside the install dir ({hostile:?})" + ); + assert!( + !dest_dir.join("sub").join("nested").exists(), + "nothing may be written below the install dir ({hostile:?})" + ); + std::fs::remove_file(&staged).unwrap(); + } + + // Control: the same call with a plain name DOES install. Without it, the + // assertions above could be passing because of a broken fixture. + std::fs::write(&staged, b"payload").unwrap(); + let installed = extract_binary_from_archive(&staged, "app.bin", "app", &dest_dir).unwrap(); + assert_eq!(installed, dest_dir.join("app")); + assert!(installed.exists()); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn extract_from_zip_works() { + use std::io::Write; + let dir = std::env::temp_dir().join("colony_test_zip_extract"); + let _ = std::fs::create_dir_all(&dir); + + // Create a zip archive with a binary inside + let zip_path = dir.join("test.zip"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip_writer = zip::ZipWriter::new(file); + zip_writer + .start_file("subdir/my-binary", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip_writer.write_all(b"binary-content").unwrap(); + zip_writer.finish().unwrap(); + + // Extract + let result = extract_from_zip(&zip_path, "my-binary", &dir); + assert!(result.is_ok()); + let extracted = result.unwrap(); + assert_eq!( + extracted.file_name().unwrap().to_str().unwrap(), + "my-binary" + ); + assert_eq!( + std::fs::read_to_string(&extracted).unwrap(), + "binary-content" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn extract_binary_from_archive_detects_type_from_asset_name_not_staging_path() { + use std::io::Write; + let dir = std::env::temp_dir().join("colony_test_part_extract"); + let _ = std::fs::create_dir_all(&dir); + + // Build a real zip, but stage it under a `.part` name (as the download + // path does) so its own extension is NOT `.zip`. + let staged = dir.join("my-app-linux.zip.part"); + let file = std::fs::File::create(&staged).unwrap(); + let mut zip_writer = zip::ZipWriter::new(file); + zip_writer + .start_file("my-app", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip_writer.write_all(b"real-elf-bytes").unwrap(); + zip_writer.finish().unwrap(); + + // Type detection must use the asset name, so the archive is extracted + // rather than the compressed bytes being renamed to the binary. + let result = extract_binary_from_archive(&staged, "my-app-linux.zip", "my-app", &dir); + assert!(result.is_ok(), "extract failed: {:?}", result.err()); + let extracted = result.unwrap(); + assert_eq!(extracted.file_name().unwrap().to_str().unwrap(), "my-app"); + assert_eq!( + std::fs::read_to_string(&extracted).unwrap(), + "real-elf-bytes" + ); + // The staging archive is consumed. + assert!(!staged.exists()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn extract_from_tar_gz_works() { + let dir = std::env::temp_dir().join("colony_test_targz_extract"); + let _ = std::fs::create_dir_all(&dir); + + // Create a tar.gz archive with a binary inside + let tar_gz_path = dir.join("test.tar.gz"); + let file = std::fs::File::create(&tar_gz_path).unwrap(); + let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut tar_builder = tar::Builder::new(gz); + + let content = b"binary-content-tar"; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar_builder + .append_data(&mut header, "subdir/my-cli", &content[..]) + .unwrap(); + // Finish tar, then finish gzip encoder to write the gzip footer + let gz = tar_builder.into_inner().unwrap(); + gz.finish().unwrap(); + + // Extract + let result = extract_from_tar_gz(&tar_gz_path, "my-cli", &dir); + assert!(result.is_ok(), "extract failed: {:?}", result.err()); + let extracted = result.unwrap(); + assert_eq!(extracted.file_name().unwrap().to_str().unwrap(), "my-cli"); + assert_eq!( + std::fs::read_to_string(&extracted).unwrap(), + "binary-content-tar" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn extract_from_zip_missing_binary() { + use std::io::Write; + let dir = std::env::temp_dir().join("colony_test_zip_missing"); + let _ = std::fs::create_dir_all(&dir); + + let zip_path = dir.join("empty.zip"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip_writer = zip::ZipWriter::new(file); + zip_writer + .start_file("other-file", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip_writer.write_all(b"data").unwrap(); + zip_writer.finish().unwrap(); + + let result = extract_from_zip(&zip_path, "nonexistent", &dir); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/download/http.rs b/src/download/http.rs new file mode 100644 index 0000000..c42cca6 --- /dev/null +++ b/src/download/http.rs @@ -0,0 +1,737 @@ +//! HTTP streaming: the download client, resumable transfers checked against +//! their identity, bounded reads of small sidecars, and the URL helpers that +//! keep remote strings from becoming structure. + +use anyhow::Result; +use std::path::PathBuf; +use std::time::Duration; + +use super::files::create_new_file; +use crate::github::{APP_VERSION, CONNECT_TIMEOUT}; + +/// How long a download may stall before we give up. This is an *inactivity* +/// budget, not a total one: a slow-but-alive link keeps its transfer, a dead +/// socket still dies promptly. +const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(60); + +/// Build the HTTP client used for large asset downloads. +/// +/// Deliberately no `.timeout()`: that is a *total* deadline covering connect, +/// TLS, redirects and the whole body stream, so a 300 s cap made any asset +/// larger than the line could carry in five minutes impossible to fetch at all +/// (a 40 MB binary needed a sustained ~1.2 Mbit/s or it could never finish, on +/// every attempt, with no partial progress kept). A read timeout bounds the +/// only thing worth bounding - a connection that has stopped delivering bytes. +pub(super) fn download_client() -> Result { + Ok(reqwest::Client::builder() + .user_agent(format!("Colony-Launcher/{APP_VERSION}")) + .read_timeout(DOWNLOAD_READ_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build()?) +} + +/// Sanity ceiling on an advertised body. The whole asset is written to disk +/// before any signature or digest check can run - that is unavoidable for a +/// detached signature - so a misconfigured or hostile release could otherwise +/// fill the user's home partition on its own say-so. Generous: the largest +/// asset the org ships is two orders of magnitude below this. +const MAX_ASSET_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +/// Identity of the transfer a `.part` file belongs to, written beside it. +/// +/// Resuming means stitching two responses into one file, so it is only sound +/// while both halves describe the same artifact. A re-tagged release, or an +/// asset rebuilt under the same name, must restart from zero rather than +/// produce a file that never existed anywhere. +#[derive(PartialEq, Eq)] +struct PartIdentity { + etag: String, + total: u64, +} + +impl PartIdentity { + fn path(dest: &std::path::Path) -> PathBuf { + let mut name = dest.as_os_str().to_os_string(); + name.push(".id"); + PathBuf::from(name) + } + + fn read(dest: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(Self::path(dest)).ok()?; + let (etag, total) = raw.split_once('\n')?; + Some(Self { + etag: etag.to_string(), + total: total.trim().parse().ok()?, + }) + } + + fn write(&self, dest: &std::path::Path) { + let _ = std::fs::write(Self::path(dest), format!("{}\n{}\n", self.etag, self.total)); + } + + fn forget(dest: &std::path::Path) { + let _ = std::fs::remove_file(Self::path(dest)); + } +} + +/// Whether a 206 response still describes the artifact a partial file belongs +/// to. Both the validator and the total length must agree. +fn response_matches_identity(resp: &reqwest::Response, id: &PartIdentity) -> bool { + let etag = resp + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()); + if etag != Some(id.etag.as_str()) { + return false; + } + // "bytes -/" - the total is the part we can compare. + resp.headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.rsplit('/').next()) + .and_then(|total| total.trim().parse::().ok()) + == Some(id.total) +} + +/// Discard a partial transfer and the identity that described it. +pub(super) fn discard_partial(dest_path: &std::path::Path) { + let _ = std::fs::remove_file(dest_path); + PartIdentity::forget(dest_path); +} + +/// Stream an HTTP GET to `dest_path`, sending throttled progress over +/// `progress_tx`, resuming a previous partial transfer when one is present and +/// provably describes the same bytes. +/// +/// Verifies the received length against Content-Length when present and rejects +/// empty/truncated downloads. A partial file is KEPT on a transport failure so +/// the next attempt can continue it, and removed on anything that makes it +/// meaningless. Shared by app-asset install and launcher self-update. +async fn download_to_file( + client: &reqwest::Client, + url: &str, + token: Option<&str>, + dest_path: &std::path::Path, + progress_tx: Option)>>, +) -> Result<()> { + // What is already on disk, and may we continue it? Both the server's ETag + // and the total length must match what the previous attempt recorded. + let resume_from = match ( + std::fs::metadata(dest_path).map(|m| m.len()).ok(), + PartIdentity::read(dest_path), + ) { + (Some(have), Some(id)) if have > 0 && have < id.total => Some((have, id)), + // Anything else - no identity file, a complete or over-long file, an + // empty one - is not resumable. Start clean. + _ => { + discard_partial(dest_path); + None + } + }; + + let mut request = client.get(url); + if let Some(t) = token { + request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); + } + if let Some((have, ref id)) = resume_from { + // If-Range is sent, but is NOT trusted: GitHub redirects release assets + // to Azure blob storage, which ignores the header and answers 206 to a + // stale validator just the same (verified against a real release + // asset). So the 206 branch below re-checks the ETag and the total + // itself rather than taking the status code as proof. + request = request + .header(reqwest::header::RANGE, format!("bytes={have}-")) + .header(reqwest::header::IF_RANGE, id.etag.clone()); + } + + let resp = request.send().await.map_err(|e| { + if e.is_timeout() { + anyhow::anyhow!("Download timed out for {url}") + } else { + anyhow::anyhow!("Download failed for {url}: {e}") + } + })?; + + if !resp.status().is_success() { + anyhow::bail!("Download failed: HTTP {} for {url}", resp.status()); + } + + // A 206 is a claim, not a proof - the storage backend ignores If-Range. + // Accept it only when the response still describes the artifact the partial + // file belongs to: same ETag, and the same total in Content-Range. On any + // disagreement, throw the partial away and let the retry start clean rather + // than stitching two different bodies into a file that never existed. + let resuming = if resp.status().as_u16() == 206 { + match resume_from { + Some((_, ref id)) if response_matches_identity(&resp, id) => true, + Some(_) => { + discard_partial(dest_path); + anyhow::bail!("The release changed while downloading {url}; restarting"); + } + None => false, + } + } else { + false + }; + let already: u64 = if resuming { + resume_from.as_ref().map(|(have, _)| *have).unwrap_or(0) + } else { + 0 + }; + + // Total size of the WHOLE asset, not of this response. + let total = if resuming { + resume_from.as_ref().map(|(_, id)| id.total) + } else { + resp.content_length() + }; + + if let Some(total) = total { + anyhow::ensure!( + total <= MAX_ASSET_BYTES, + "Refusing {url}: the release advertises {total} bytes, over Colony's {MAX_ASSET_BYTES}-byte ceiling" + ); + } + + use futures::StreamExt; + use std::io::Write; + let mut file = if resuming { + tracing::info!("resuming {url} at {already} bytes"); + std::fs::OpenOptions::new().append(true).open(dest_path)? + } else { + // Fresh transfer. Record what we are about to fetch so a later attempt + // can tell whether continuing this file is sound, and never follow + // whatever sits at the (predictable) staging name. + discard_partial(dest_path); + if let (Some(etag), Some(total)) = ( + resp.headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string), + resp.content_length(), + ) { + PartIdentity { etag, total }.write(dest_path); + } + create_new_file(dest_path)? + }; + let mut stream = resp.bytes_stream(); + let mut downloaded: u64 = already; + let mut last_pct: u32 = 0; + + let stream_result: Result<()> = async { + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk)?; + downloaded += chunk.len() as u64; + anyhow::ensure!( + downloaded <= MAX_ASSET_BYTES, + "Refusing {url}: body exceeded Colony's {MAX_ASSET_BYTES}-byte ceiling" + ); + + if let Some(ref tx) = progress_tx { + // Throttle: send on whole-percent changes when the total is + // known, else every 256 KiB - not per network chunk. + let should_send = match total { + Some(total) if total > 0 => { + let pct = ((downloaded as f64 / total as f64) * 100.0) as u32; + if pct != last_pct { + last_pct = pct; + true + } else { + false + } + } + _ => { + let bucket = (downloaded / (256 * 1024)) as u32; + if bucket != last_pct { + last_pct = bucket; + true + } else { + false + } + } + }; + if should_send { + let _ = tx.unbounded_send((downloaded, total)); + } + } + } + file.flush()?; + Ok(()) + } + .await; + + if let Err(e) = stream_result { + // KEEP the partial file. A drop at 95% used to throw away everything + // and charge the user the full asset again on the next click; with the + // identity sidecar beside it, the next attempt continues instead. Only + // a transfer we can no longer describe is discarded. + if PartIdentity::read(dest_path).is_none() { + discard_partial(dest_path); + } + return Err(e); + } + + // Guard against a silently-truncated or empty transfer. Both are terminal + // for THIS file: a short body means the server disagrees with the length it + // advertised, so continuing from it would be guesswork. + if let Some(total) = total { + if downloaded != total { + discard_partial(dest_path); + anyhow::bail!("Incomplete download: got {downloaded} of {total} bytes for {url}"); + } + } + if downloaded == 0 { + discard_partial(dest_path); + anyhow::bail!("Empty download (0 bytes) for {url}"); + } + + // Complete: the identity file has done its job. + PartIdentity::forget(dest_path); + Ok(()) +} + +/// `download_to_file` with a bounded retry, so a dropped connection continues +/// from where it stopped without the user having to notice and click again. +/// +/// Only transport failures are retried, and only because the partial file now +/// survives them: each attempt resumes from what the previous one wrote, so +/// three tries cost three connections, not three assets. A verification failure +/// never reaches here - it happens after this returns. +pub(super) async fn download_with_resume( + client: &reqwest::Client, + url: &str, + token: Option<&str>, + dest_path: &std::path::Path, + progress_tx: Option)>>, +) -> Result<()> { + const ATTEMPTS: u32 = 3; + let mut last_err = None; + for attempt in 0..ATTEMPTS { + if attempt > 0 { + // Linear backoff; the point is to ride out a blip, not to hammer. + tokio::time::sleep(Duration::from_secs(2 * attempt as u64)).await; + tracing::info!("retrying {url} (attempt {} of {ATTEMPTS})", attempt + 1); + } + let had_partial = PartIdentity::read(dest_path).is_some(); + match download_to_file(client, url, token, dest_path, progress_tx.clone()).await { + Ok(()) => return Ok(()), + Err(e) => { + last_err = Some(e); + // Retry when the next attempt would do something DIFFERENT: + // either a partial survived and will be continued, or one was + // just discarded (the release changed under us) and the next + // attempt starts clean. Otherwise - a 404, a refused URL - the + // next attempt would only repeat this one. + let resumable = PartIdentity::read(dest_path).is_some(); + if !resumable && !had_partial { + break; + } + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Download failed for {url}"))) +} + +/// Ceiling for the small sidecars Colony buffers whole (`.sig` is 64 bytes, +/// `.meta` three short lines). Whoever controls a release can publish a +/// multi-gigabyte file named `foo-linux.sig`; without a cap that is an OOM the +/// moment a user clicks Install. Generous by four orders of magnitude. +const MAX_SIDECAR_BYTES: u64 = 64 * 1024; + +/// Read a response body with an upper bound, so a body with no Content-Length - +/// or one that lies about it - cannot be unbounded. +async fn bounded_body(resp: reqwest::Response, url: &str, max: u64) -> Result> { + if let Some(len) = resp.content_length() { + anyhow::ensure!( + len <= max, + "Refusing {url}: declares {len} bytes, over the {max}-byte limit" + ); + } + use futures::StreamExt; + let mut stream = resp.bytes_stream(); + let mut out: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + anyhow::ensure!( + out.len() as u64 + chunk.len() as u64 <= max, + "Refusing {url}: body exceeds the {max}-byte limit" + ); + out.extend_from_slice(&chunk); + } + Ok(out) +} + +/// Fetch a small OPTIONAL resource: `Ok(None)` on HTTP 404 (the resource +/// genuinely is not published), `Err` on any other failure - so a transient +/// network error can never be mistaken for "not published" (an attacker able +/// to induce errors must not be able to make a signed app look unsigned). +pub(super) async fn fetch_optional_bytes( + client: &reqwest::Client, + url: &str, + token: Option<&str>, +) -> Result>> { + let mut request = client.get(url); + if let Some(t) = token { + request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); + } + let resp = request.send().await?; + if resp.status().as_u16() == 404 { + return Ok(None); + } + if !resp.status().is_success() { + anyhow::bail!("HTTP {} for {url}", resp.status()); + } + Ok(Some(bounded_body(resp, url, MAX_SIDECAR_BYTES).await?)) +} + +/// Fetch a small resource (e.g. a detached signature) fully into memory. +pub(super) async fn fetch_bytes( + client: &reqwest::Client, + url: &str, + token: Option<&str>, +) -> Result> { + let mut request = client.get(url); + if let Some(t) = token { + request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {t}")); + } + let resp = request.send().await?; + if !resp.status().is_success() { + anyhow::bail!("HTTP {} for {url}", resp.status()); + } + bounded_body(resp, url, MAX_SIDECAR_BYTES).await +} + +/// Build a URL from a trusted base and remote-controlled path segments, +/// percent-encoding each segment so it can never be structural. +/// +/// `format!`-ing a remote string into a URL is not safe even when the string +/// looks harmless in a JSON diff. `reqwest::Client::get` parses through the +/// WHATWG URL parser, which collapses `..` segments (and `%2e%2e`) *before* the +/// request is made: a `colony.json` whose `tag` reads +/// `v1/../../../../../EvilOrg/EvilRepo/releases/download/v1` shortens the path +/// into a different account entirely, and Colony would install and trust bytes +/// that were never published under the org. That is the one containment claim +/// the whole trust model rests on, and it was reachable from write access to a +/// single line of a catalog repo - no release-publishing rights needed. +/// +/// Segments are pushed through `path_segments_mut`, which percent-encodes them, +/// so a `/` or a `..` inside a segment stays data. Legitimate git tags may +/// contain `/` (`release/1.0`), so encoding is the honest fix here; rejecting +/// the value outright would break them. +pub(crate) fn build_url(base: &str, segments: &[&str]) -> Result { + let mut url = reqwest::Url::parse(base)?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("URL base cannot have path segments: {base}"))?; + for segment in segments { + anyhow::ensure!( + !segment.is_empty(), + "empty URL path segment for base {base}" + ); + path.push(segment); + } + } + Ok(url.into()) +} + +/// Normalized form of `raw` when it is an absolute `http`/`https` URL with a +/// host, else `None`. +/// +/// Gate for everything handed to the desktop's URI opener: link destinations in +/// remote Markdown (READMEs, release notes) reach it verbatim, and `open::that` +/// is not a browser call — it execs `xdg-open`/`gio open` on Linux and +/// `Start-Process` on Windows, both of which dispatch `file://`, UNC paths and +/// any registered `x-scheme-handler/*`. A `file://` or custom-scheme link in a +/// hostile README would otherwise be a one-click execution primitive that +/// bypasses every signature and digest check in this module. +/// +/// Returns the REPARSED string rather than a bool on purpose: the WHATWG parser +/// strips tabs, newlines and leading control characters anywhere in the input, so +/// validating `raw` and then opening `raw` would judge one string and execute a +/// different one (`"ht\ntps://x"` parses as `https://x`). Callers open what was +/// actually validated. +pub(crate) fn web_url(raw: &str) -> Option { + // Relative and scheme-less inputs (including protocol-relative + // `//host/share/x.exe`) fail to parse, which is the desired answer. + let parsed = reqwest::Url::parse(raw).ok()?; + // A host is required: `http:evil` and `http:/x` parse but address nothing, + // and an opener may fall back to treating them as a local path. + if !matches!(parsed.scheme(), "http" | "https") || !parsed.has_host() { + return None; + } + Some(parsed.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A one-shot HTTP server that TRUNCATES the first response and honours + /// Range on the next one - the shape of a real dropped connection. + /// + /// Hand-rolled on `std::net` rather than pulled in as a dependency: the + /// point is to prove the resume path end to end without adding a test-only + /// crate to a project that counts its dependencies. + fn spawn_truncating_server( + body: Vec, + cut: usize, + etag: &str, + ) -> (String, std::thread::JoinHandle<()>) { + use std::io::{BufRead, BufReader, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}/asset", listener.local_addr().unwrap()); + let etag = etag.to_string(); + let handle = std::thread::spawn(move || { + // Two connections: the truncated one, then the ranged one. + for _ in 0..2 { + let Ok((stream, _)) = listener.accept() else { + return; + }; + let mut reader = BufReader::new(&stream); + let mut range_start: Option = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") { + range_start = v.split('-').next().and_then(|n| n.trim().parse().ok()); + } + if line == "\r\n" || line == "\n" { + break; + } + } + let mut stream = &stream; + match range_start { + Some(start) => { + let chunk = &body[start..]; + let head = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {}-{}/{}\r\nContent-Length: {}\r\nETag: {}\r\nConnection: close\r\n\r\n", + start, + body.len() - 1, + body.len(), + chunk.len(), + etag + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(chunk); + } + None => { + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: {}\r\nConnection: close\r\n\r\n", + body.len(), + etag + ); + let _ = stream.write_all(head.as_bytes()); + // Promise the whole body, deliver part of it, hang up. + let _ = stream.write_all(&body[..cut]); + } + } + let _ = stream.flush(); + } + }); + (url, handle) + } + + /// The behaviour this whole batch exists for: a connection that died at 40% + /// used to throw away every byte and charge the user the full asset again. + #[test] + fn a_dropped_connection_is_resumed_instead_of_restarted() { + let body: Vec = (0..300_000usize) + .map(|i| ((i * 7 + 3) % 256) as u8) + .collect(); + let (url, server) = spawn_truncating_server(body.clone(), 120_000, "\"v1\""); + + let dir = std::env::temp_dir().join("colony_test_resume"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("asset.part"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let result = rt.block_on(async { + let client = download_client().unwrap(); + download_with_resume(&client, &url, None, &dest, None).await + }); + + assert!( + result.is_ok(), + "the retry must finish the transfer: {result:?}" + ); + assert_eq!( + std::fs::read(&dest).unwrap(), + body, + "the stitched file must be byte-identical to the asset" + ); + assert!( + !PartIdentity::path(&dest).exists(), + "a completed transfer leaves no identity file behind" + ); + + let _ = std::fs::remove_dir_all(&dir); + let _ = server.join(); + } + + /// Resuming stitches two responses into one file, so it is only sound + /// while both describe the same artifact. The identity sidecar is what + /// makes that decidable; without it, a re-tagged release could be silently + /// assembled from two different bodies. + #[test] + fn a_partial_transfer_is_only_resumable_against_its_own_identity() { + let dir = std::env::temp_dir().join("colony_test_part_identity"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let part = dir.join("grape-linux.part"); + + // A partial transfer plus the identity that describes it. + std::fs::write(&part, vec![0u8; 512]).unwrap(); + PartIdentity { + etag: "\"abc\"".into(), + total: 4096, + } + .write(&part); + + let id = PartIdentity::read(&part).expect("identity round-trips"); + assert_eq!(id.etag, "\"abc\""); + assert_eq!(id.total, 4096); + assert!( + std::fs::metadata(&part).unwrap().len() < id.total, + "a short file against a known total is what makes a resume possible" + ); + + // Discarding takes the sidecar with it, so the next attempt cannot + // resume from bytes nothing describes. + discard_partial(&part); + assert!(!part.exists()); + assert!(PartIdentity::read(&part).is_none()); + + // A truncated or garbage sidecar is not an identity. + std::fs::write(PartIdentity::path(&part), "no-newline-here").unwrap(); + assert!(PartIdentity::read(&part).is_none()); + std::fs::write(PartIdentity::path(&part), "\"abc\"\nnot-a-number\n").unwrap(); + assert!(PartIdentity::read(&part).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `tag` from colony.json used to be interpolated straight into the + /// release URL. reqwest parses with the WHATWG parser, which collapses + /// `..` BEFORE the request is issued, so one line of a catalog repo could + /// redirect the install to an account outside the org entirely - defeating + /// the only containment claim the trust model has. + #[test] + fn a_hostile_tag_cannot_walk_the_release_url_out_of_the_org() { + let hostile = "v1/../../../../../EvilOrg/EvilRepo/releases/download/v1"; + let url = build_url( + "https://github.com", + &[ + "Project-Colony", + "Grape", + "releases", + "download", + hostile, + "grape-linux", + ], + ) + .expect("segments are encoded, not rejected"); + + assert!( + url.starts_with("https://github.com/Project-Colony/Grape/releases/download/"), + "the request must stay inside the org, got {url}" + ); + assert!( + !url.contains("EvilOrg/EvilRepo/releases"), + "the traversal must not survive as structure, got {url}" + ); + + // Reparsing is what reqwest itself does; the collapse must not happen + // there either, which is the whole point of encoding the segment. + let reparsed = reqwest::Url::parse(&url).expect("valid URL"); + assert_eq!( + reparsed.path_segments().map(|s| s.count()), + Some(6), + "no segment may be structural: {url}" + ); + + // A legitimate tag containing a slash (`release/1.0`) still works - + // rejecting `/` outright would have broken real repos. + let ok = build_url( + "https://github.com", + &[ + "Project-Colony", + "Grape", + "releases", + "download", + "release/1.0", + "grape-linux", + ], + ) + .expect("slashes in a tag are encoded, not an error"); + assert!(ok.contains("release%2F1.0"), "got {ok}"); + } + + #[test] + fn only_http_and_https_urls_may_reach_the_uri_opener() { + for ok in [ + "https://github.com/Project-Colony/Colony", + "http://example.com/a?b=c#d", + "HTTPS://GitHub.com/x", + ] { + assert!(web_url(ok).is_some(), "{ok} should be allowed"); + } + for bad in [ + // The one-click execution vectors from a hostile README. + "file:///home/user/.local/share/applications/colony-app.desktop", + "//198.51.100.7/share/setup.exe", + "\\\\198.51.100.7\\share\\setup.exe", + "javascript:alert(1)", + "smb://host/share", + "steam://run/1", + "data:text/html,", + "mailto:a@b.c", + "", + " ", + "not a url", + "/etc/passwd", + ] { + assert!(web_url(bad).is_none(), "{bad:?} must be refused"); + } + } + + /// The WHATWG parser strips tabs, newlines and leading control characters + /// anywhere in the input, so a bool-returning check would validate one string + /// while `open::that` received a different one. CommonMark decodes character + /// references in link destinations, so `[x](ht tps://evil)` produces + /// exactly these shapes from a hostile README. + #[test] + fn opened_url_is_the_one_that_was_validated() { + for sneaky in [ + "ht\ntps://evil.example/x", + "ht\ttps://evil.example/x", + "\thttps://evil.example/x", + " https://evil.example/x", + "https://evil.example/\rx", + ] { + // These DO parse (that is the whole problem), so the contract is not + // "refused" but "what comes back is clean": a real http(s) URL with no + // control characters, never the raw string. + let normalized = web_url(sneaky).expect("parses as https"); + assert_ne!(normalized, sneaky, "the raw form must not be handed on"); + assert!(normalized.starts_with("https://"), "{normalized}"); + assert!( + !normalized.chars().any(|c| c.is_control()), + "{normalized:?} still carries control characters" + ); + } + // Scheme-only forms are normalized by the parser rather than rejected: + // for http(s) it fills in the host, which stays a plain web URL. + assert_eq!( + web_url("http:evil.example/x").as_deref(), + Some("http://evil.example/x") + ); + } +} diff --git a/src/download/install.rs b/src/download/install.rs new file mode 100644 index 0000000..ae4ae59 --- /dev/null +++ b/src/download/install.rs @@ -0,0 +1,374 @@ +//! Installing an app: resolve, download, verify, extract, and promote one +//! release asset into its directory together with its install records. + +use anyhow::Result; +use std::path::PathBuf; + +use super::files::{ensure_safe_component, extract_binary_from_archive, replace_file}; +use super::http::{ + build_url, discard_partial, download_client, download_with_resume, fetch_optional_bytes, +}; +use super::verify::{check_metadata_bindings, ensure_not_a_downgrade, verify_sha256_bytes}; +use crate::github::GITHUB_ACCOUNT; + +/// Everything needed to install one resolved release asset. +pub struct AssetInstall { + pub repo_name: String, + /// The manifest's category, so the desktop menu files the app where it + /// belongs instead of dumping everything under Utility. + pub category: crate::scan::AppCategory, + /// What the desktop menu should call this app: the manifest's declared + /// name, which may differ from the repo slug. The slug stays the identity + /// key everywhere else (install dir, caches, entry filename). + pub display_name: String, + /// The resolved (never "latest") release tag being installed. + pub tag: String, + /// The resolved asset name to download. + pub filename: String, + /// When set, the download is an archive and this named binary is extracted. + pub binary_name: Option, + /// When set, the download is integrity-checked against this hex digest. + pub expected_sha256: Option, + /// True when `filename` was resolved from a filePattern: the name is then + /// persisted next to the binary so `installed_app_path` can find the + /// install again. + pub record_asset: bool, + /// True when the manifest declares `"signed": true`: a missing `.sig` + /// then ABORTS the install instead of being treated as a legacy unsigned + /// app - closing the "compromised repo simply omits signatures" hole. + pub require_signature: bool, +} + +/// Download a release asset to `//`, +/// verify/extract it, atomically promote it into place, and record the +/// installed version. Returns the final path on success. +pub async fn download_release_asset( + token: Option, + install: AssetInstall, + progress_tx: Option)>>, +) -> Result { + let AssetInstall { + repo_name, + display_name, + category, + tag, + filename, + binary_name, + expected_sha256, + record_asset, + require_signature, + } = install; + // The manifest-supplied filename becomes a local path — guard it against + // traversal (`../`, absolute paths) before joining, mirroring the archive + // `binary` guard. + ensure_safe_component(&filename)?; + + let dest_dir = crate::persistence::colony_app_dir(&repo_name)?; + std::fs::create_dir_all(&dest_dir)?; + let dest_path = dest_dir.join(&filename); + // Download to a temporary sibling so an interrupted or failed transfer + // never truncates the currently-installed binary. + let temp_path = dest_dir.join(format!("{filename}.part")); + + // Every segment after the host is remote-controlled (`repo_name` from the + // API listing, `tag` and `filename` from colony.json), so build the URL + // from encoded segments instead of interpolating them. + let url = build_url( + "https://github.com", + &[ + GITHUB_ACCOUNT, + &repo_name, + "releases", + "download", + &tag, + &filename, + ], + )?; + + let client = download_client()?; + download_with_resume(&client, &url, token.as_deref(), &temp_path, progress_tx).await?; + + // `manifest.signed` lives in the very repo the signature protects, so a + // compromised repo could flip it to false and drop the `.sig` to install + // unsigned code silently. Pin it: once an install of this app has been + // signature-verified, later updates must stay signed whatever the manifest + // now claims. The pin only ever raises the bar. + let signature_pinned = crate::persistence::load_installed_signed(&repo_name); + let require_signature = require_signature || signature_pinned; + + // Opportunistic app-signature verification: when the release publishes + // `.sig`, it MUST verify against the org release key (the same + // ed25519 key that signs the launcher, embedded in src/signing.rs). A + // missing signature is a legacy unsigned app and stays allowed; any + // OTHER failure fetching it aborts - a transient error must never make a + // signed app look unsigned. + let signature = + match fetch_optional_bytes(&client, &format!("{url}.sig"), token.as_deref()).await { + Ok(sig) => sig, + Err(e) => { + let _ = std::fs::remove_file(&temp_path); + anyhow::bail!("Could not check for a release signature of {filename}: {e}"); + } + }; + // Roll the signed metadata sidecar down to apps. A bare `.sig` proves only + // that the bytes came from the org key - not WHICH artefact or version they + // are - so a compromised maintainer could take an old, genuinely signed, + // known-vulnerable build, re-upload it under a new tag, and Colony would + // install it with every trust indicator green. The sidecar binds the bytes + // to an asset name, a digest and a version; the machinery already existed + // and was reachable only from the launcher's own self-update. + // + // Opportunistic and pinned, exactly like `.sig`: an app that publishes no + // sidecar today still installs, but once one has been verified, a later + // release cannot silently stop publishing it. + let metadata_pinned = crate::persistence::load_installed_metadata(&repo_name); + let metadata = match fetch_optional_bytes( + &client, + &format!("{url}{}", crate::signing::METADATA_SUFFIX), + token.as_deref(), + ) + .await + { + Ok(Some(meta_bytes)) => { + // The sidecar is only worth anything signed. A missing signature + // for a PRESENT sidecar is not "legacy", it is a broken release. + let meta_sig = fetch_optional_bytes( + &client, + &format!( + "{url}{}{}", + crate::signing::METADATA_SUFFIX, + crate::signing::SIGNATURE_SUFFIX + ), + token.as_deref(), + ) + .await + .map_err(|e| { + anyhow::anyhow!("Could not fetch the metadata signature for {filename}: {e}") + }) + .and_then(|sig| { + sig.ok_or_else(|| { + anyhow::anyhow!( + "{filename}{} was published without {filename}{}{} - refusing to install", + crate::signing::METADATA_SUFFIX, + crate::signing::METADATA_SUFFIX, + crate::signing::SIGNATURE_SUFFIX + ) + }) + }); + match meta_sig { + Ok(meta_sig) => Some((meta_bytes, meta_sig)), + Err(e) => { + discard_partial(&temp_path); + return Err(e); + } + } + } + Ok(None) => None, + Err(e) => { + discard_partial(&temp_path); + anyhow::bail!("Could not check for release metadata of {filename}: {e}"); + } + }; + if metadata_pinned && metadata.is_none() { + discard_partial(&temp_path); + anyhow::bail!( + "{repo_name} was previously installed with verified release metadata, but this release publishes no {filename}{} - refusing to install (uninstall it first to opt back out)", + crate::signing::METADATA_SUFFIX + ); + } + + if require_signature && signature.is_none() { + let _ = std::fs::remove_file(&temp_path); + // Say WHICH rule refused, because the two have different remedies: the + // manifest is the repo's own declaration, whereas the pin is our memory + // of a previously verified install and can only be cleared by + // uninstalling the app. + if signature_pinned { + anyhow::bail!( + "{repo_name} was previously installed with a verified signature, but this release publishes no {filename}.sig - refusing to install an unsigned downgrade of a signed app (uninstall it first to opt back out)" + ); + } + anyhow::bail!( + "The manifest requires signed releases, but no {filename}.sig was published - refusing to install" + ); + } + + // Integrity check, archive extraction and the atomic promotion are + // CPU/IO-bound — run them on a blocking thread. Any failure removes the + // temp file and leaves the previous install untouched. + let final_path = { + let temp_path = temp_path.clone(); + let dest_path = dest_path.clone(); + let dest_dir = dest_dir.clone(); + let expected_sha256 = expected_sha256.clone(); + let binary_name = binary_name.clone(); + let filename = filename.clone(); + + let installed_version = crate::persistence::load_installed_version(&repo_name); + let resolved_tag = tag.clone(); + tokio::task::spawn_blocking(move || -> Result { + let was_signed = signature.is_some(); + let had_metadata = metadata.is_some(); + // Read the staged file ONCE and check everything against that one + // buffer. Each separate read of `/.part` - a + // predictable path - is another chance to check one set of bytes + // and install a different set; the launcher path already avoids + // this by installing the buffer it verified. + let staged_bytes = + if signature.is_some() || metadata.is_some() || expected_sha256.is_some() { + Some(std::fs::read(&temp_path)?) + } else { + None + }; + if let Some(sig) = signature { + let bytes = staged_bytes.as_deref().unwrap_or_default(); + if let Err(e) = crate::signing::verify_release_signature(bytes, &sig) { + let _ = std::fs::remove_file(&temp_path); + anyhow::bail!( + "Signature verification FAILED for {filename} - refusing to install: {e}" + ); + } + tracing::info!("ed25519 signature verified for {filename}"); + } + // The sidecar, verified against the same trusted keys and then + // bound to THIS asset, THIS digest and THIS tag - which is what a + // bare signature cannot say. + if let Some((meta_bytes, meta_sig)) = metadata { + let bytes = staged_bytes.as_deref().unwrap_or_default(); + let checked = crate::signing::verify_release_signature(&meta_bytes, &meta_sig) + .and_then(|()| crate::signing::ReleaseMetadata::parse(&meta_bytes)) + .and_then(|meta| { + check_metadata_bindings( + &meta, + bytes, + &filename, + Some(resolved_tag.as_str()), + )?; + // ">=", not ">": an app pinned to a fixed tag + // legitimately reinstalls the same version, so the + // launcher's strictly-newer rule would lock it out. + ensure_not_a_downgrade(&meta, installed_version.as_deref())?; + Ok(()) + }); + if let Err(e) = checked { + let _ = std::fs::remove_file(&temp_path); + anyhow::bail!( + "Release metadata check FAILED for {filename} - refusing to install: {e}" + ); + } + tracing::info!("signed release metadata verified for {filename}"); + } + if let Some(ref expected) = expected_sha256 { + let bytes = staged_bytes.as_deref().unwrap_or_default(); + if let Err(e) = verify_sha256_bytes(bytes, expected) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + tracing::info!("SHA256 verified for {filename}"); + } + drop(staged_bytes); + + let final_path = if let Some(ref bin) = binary_name { + // Archive install: extract the named binary (atomically renamed + // into place by the extractor), then drop the archive. Detect the + // archive type from `filename`, not the `.part` staging path. + tracing::info!("Extracting '{bin}' from archive '{filename}'"); + let extracted = extract_binary_from_archive(&temp_path, &filename, bin, &dest_dir)?; + let _ = std::fs::remove_file(&temp_path); + extracted + } else { + // Raw binary: atomically promote the verified temp file over any + // previous install. + replace_file(&temp_path, &dest_path)?; + dest_path + }; + + // An app whose asset name carries the version - which is the case + // `filePattern` exists to serve - installs the new release under a + // NEW filename, so the previous binary is simply orphaned. Nothing + // ever showed or reclaimed it: SphereCord's AppImage is 166 MB, so + // three updates left half a gigabyte of invisible junk that only an + // uninstall would clear. Drop the superseded file now that the new + // one is committed. + if let Some(stale) = crate::persistence::load_installed_asset(&repo_name) { + if stale != filename + && Some(stale.as_str()) != binary_name.as_deref() + && ensure_safe_component(&stale).is_ok() + { + let stale_path = dest_dir.join(&stale); + if stale_path != final_path && stale_path.is_file() { + match std::fs::remove_file(&stale_path) { + Ok(()) => tracing::info!("removed superseded binary {stale}"), + Err(e) => tracing::warn!("could not remove superseded {stale}: {e}"), + } + } + } + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&final_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&final_path, perms)?; + } + + // Record WHAT is installed here, atomically with the install + // itself - not in the UI message handler. A cancel mid-install + // drops the awaiting future but this blocking task runs to + // completion: writing the version from the handler meant an + // installed binary with no version file, silently excluded from + // every future update check (or, in the filePattern case, an + // orphaned binary the app no longer even sees as installed). + // + // Order matters: the ASSET marker lands first and the VERSION + // marker last. The two writes are individually non-atomic, so a + // kill between them leaves a torn state - and only this order makes + // that state honest. With the version written first, a + // filePattern app whose asset name carries the version would claim + // the new version while `.colony_asset` still named the old file: + // installed_app_path resolves through the asset marker, so Colony + // would report the new version and Launch would run the old binary, + // with no update offered to correct it. This way round, a torn + // install simply re-offers the update. + if record_asset { + crate::persistence::save_installed_asset(&repo_name, &filename)?; + } + crate::persistence::save_installed_version(&repo_name, &tag)?; + // Pin the signature requirement for future updates: a repo that + // ships signatures today must not be able to stop tomorrow. Only + // ever raises the bar - the marker is written, never cleared, while + // the app stays installed. + if was_signed { + crate::persistence::save_installed_signed(&repo_name)?; + } + // Same one-way ratchet for the sidecar: a repo that binds its + // releases today must not be able to stop tomorrow. + if had_metadata { + crate::persistence::save_installed_metadata(&repo_name)?; + } + // Desktop integration (Linux): index the installed app in the + // desktop environment. Best-effort - a failure here must not fail + // an otherwise complete install. + if let Err(e) = crate::persistence::write_desktop_entry( + &repo_name, + &display_name, + category, + &final_path, + ) { + tracing::warn!("could not write desktop entry for {repo_name}: {e}"); + } + + Ok(final_path) + }) + .await? + }; + + match final_path { + Ok(p) => Ok(p), + Err(e) => { + let _ = std::fs::remove_file(&temp_path); + Err(e) + } + } +} diff --git a/src/download/launcher.rs b/src/download/launcher.rs new file mode 100644 index 0000000..5176f70 --- /dev/null +++ b/src/download/launcher.rs @@ -0,0 +1,300 @@ +//! Launcher self-update: whether this install may update itself, staging a +//! signed update, and replacing the running binary with rollback. + +use anyhow::Result; +use std::path::PathBuf; + +use super::files::write_new_file; +use super::http::{build_url, download_client, download_with_resume, fetch_bytes}; +use super::verify::verify_launcher_metadata; +use crate::github::{LAUNCHER_OWNER, LAUNCHER_REPO}; +use crate::persistence::colony_cache_dir; + +/// True when the running executable lives in a system location owned by a +/// package manager (AUR/pacman installs land in /usr/bin, manual system +/// installs in /usr/local or /opt). Self-update can NEVER apply there - the +/// backup rename of the running exe fails with EACCES after downloading the +/// whole asset - so the UI offers package-manager guidance instead of a +/// download button that is guaranteed to die. +pub fn launcher_is_system_managed() -> bool { + let Ok(exe) = std::env::current_exe() else { + return false; + }; + let Some(dir) = exe.parent() else { + return false; + }; + // Behavioural, not a list of paths: probe whether we can actually create a + // file next to the executable. That answers /usr, /opt, Program Files, + // a read-only mount and a macOS /Applications install with one test and no + // per-platform table to keep in sync. The path check was #[cfg(unix)] only, + // so a Windows install under Program Files downloaded and verified the + // entire asset before dying on the rename - exactly the failure the guard + // was written to prevent, just never extended to the other platforms. + let probe = dir.join(format!(".colony-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + false + } + Err(_) => true, + } +} + +/// Download a release asset from the Colony launcher repo. +/// Returns the path to the downloaded file in a staging directory. +pub async fn download_launcher_asset( + token: Option, + tag: String, + filename: String, + progress_tx: Option)>>, +) -> Result { + // Transient by definition: staging lives in the cache. + let temp_dir = colony_cache_dir()?.join("update-staging"); + std::fs::create_dir_all(&temp_dir)?; + let dest_path = temp_dir.join(&filename); + // Stream to `.part` and only rename onto the apply path once the + // whole file is here. Writing straight to the final name meant a download + // the user never applied - or a cancelled one - left a partial file at + // exactly the path apply_launcher_update consumes. Apply re-verifies, so it + // was refused fail-closed rather than being a hole, but the file sat there. + let part_path = temp_dir.join(format!("{filename}.part")); + + let url = build_url( + "https://github.com", + &[ + LAUNCHER_OWNER, + LAUNCHER_REPO, + "releases", + "download", + &tag, + &filename, + ], + )?; + + let client = download_client()?; + // Validates the length, rejects an empty/truncated body, and resumes a + // previous partial transfer when one provably describes the same asset. + download_with_resume(&client, &url, token.as_deref(), &part_path, progress_tx).await?; + let _ = std::fs::remove_file(&dest_path); + std::fs::rename(&part_path, &dest_path)?; + + // Fail-closed signature check: fetch the detached signature and verify the + // downloaded binary against the embedded release key BEFORE it can be + // applied. A missing, malformed, or invalid signature aborts the update. + let sig_url = format!("{url}{}", crate::signing::SIGNATURE_SUFFIX); + let signature = match fetch_bytes(&client, &sig_url, token.as_deref()).await { + Ok(bytes) => bytes, + Err(e) => { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!( + "Refusing to self-update: could not fetch the update signature ({sig_url}): {e}" + ); + } + }; + let binary_bytes = std::fs::read(&dest_path)?; + if let Err(e) = crate::signing::verify_release_signature(&binary_bytes, &signature) { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!("Refusing to self-update: {e}"); + } + // The signature above proves the bytes came from the release key, but not + // WHICH artefact or version they are: an attacker controlling what the + // release host serves could replay an older, genuinely signed build. The + // signed metadata sidecar closes that by binding these bytes to a version + // and a filename. Fail-closed, exactly like the signature itself. + let meta_url = format!("{url}{}", crate::signing::METADATA_SUFFIX); + let meta_bytes = match fetch_bytes(&client, &meta_url, token.as_deref()).await { + Ok(bytes) => bytes, + Err(e) => { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!( + "Refusing to self-update: could not fetch the update metadata ({meta_url}): {e}" + ); + } + }; + let meta_sig = match fetch_bytes( + &client, + &format!("{meta_url}{}", crate::signing::SIGNATURE_SUFFIX), + token.as_deref(), + ) + .await + { + Ok(bytes) => bytes, + Err(e) => { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!("Refusing to self-update: could not fetch the metadata signature: {e}"); + } + }; + if let Err(e) = + verify_launcher_metadata(&meta_bytes, &meta_sig, &binary_bytes, &filename, Some(&tag)) + { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!("Refusing to self-update: {e}"); + } + + // Persist the signature next to the staged binary so apply_launcher_update + // can re-verify at install time — closing any window in which the staged + // file could be swapped between download and apply. The metadata sidecar is + // staged for the same reason. + let sig_path = staged_signature_path(&dest_path); + if let Err(e) = write_new_file(&sig_path, &signature) { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!("Could not stage update signature: {e}"); + } + let (meta_path, meta_sig_path) = staged_metadata_paths(&dest_path); + if let Err(e) = write_new_file(&meta_path, &meta_bytes) + .and_then(|()| write_new_file(&meta_sig_path, &meta_sig)) + { + let _ = std::fs::remove_file(&dest_path); + anyhow::bail!("Could not stage update metadata: {e}"); + } + tracing::info!("Launcher update signature and metadata verified for {filename} ({tag})"); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&dest_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&dest_path, perms)?; + } + + Ok(dest_path) +} + +/// Path of the detached signature staged next to a downloaded binary. +fn staged_signature_path(binary: &std::path::Path) -> PathBuf { + PathBuf::from(format!( + "{}{}", + binary.display(), + crate::signing::SIGNATURE_SUFFIX + )) +} + +/// Paths of the metadata sidecar and its signature, staged next to a binary. +fn staged_metadata_paths(binary: &std::path::Path) -> (PathBuf, PathBuf) { + let meta = format!("{}{}", binary.display(), crate::signing::METADATA_SUFFIX); + let sig = format!("{meta}{}", crate::signing::SIGNATURE_SUFFIX); + (PathBuf::from(meta), PathBuf::from(sig)) +} + +/// Replace the running Colony binary with the downloaded update. +/// Returns the exe path for relaunch on success. Restores backup on failure. +pub fn apply_launcher_update(new_binary: &std::path::Path) -> Result { + let current_exe = std::env::current_exe() + .map_err(|e| anyhow::anyhow!("Cannot determine current exe path: {e}"))?; + + // Re-verify the staged binary against its staged signature at the moment of + // installation, closing any local window in which the staged file (in the + // update-staging dir) could have been swapped after the download-time check. + let sig_path = staged_signature_path(new_binary); + let signature = std::fs::read(&sig_path).map_err(|e| { + anyhow::anyhow!( + "Missing staged update signature ({}): {e}", + sig_path.display() + ) + })?; + let staged_bytes = std::fs::read(new_binary) + .map_err(|e| anyhow::anyhow!("Cannot read staged update binary: {e}"))?; + crate::signing::verify_release_signature(&staged_bytes, &signature) + .map_err(|e| anyhow::anyhow!("Refusing to apply update: {e}"))?; + + // Re-check the sidecar at install time too, for the same reason the signature + // is re-checked: the staging directory is writable, so the file could have + // been swapped after the download-time check. The requested tag is not in + // scope here, so this enforces the asset name, the digest and "strictly newer + // than the running build" - enough to stop a rollback, though a swap to + // another org-signed release that is ALSO newer than the running build would + // still pass. Closing that needs the tag threaded through the staged state. + let (meta_path, meta_sig_path) = staged_metadata_paths(new_binary); + let staged_meta = std::fs::read(&meta_path).map_err(|e| { + anyhow::anyhow!( + "Missing staged update metadata ({}): {e}", + meta_path.display() + ) + })?; + let staged_meta_sig = std::fs::read(&meta_sig_path).map_err(|e| { + anyhow::anyhow!( + "Missing staged update metadata signature ({}): {e}", + meta_sig_path.display() + ) + })?; + let expected_asset = new_binary + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("staged update has no usable file name"))?; + verify_launcher_metadata( + &staged_meta, + &staged_meta_sig, + &staged_bytes, + expected_asset, + None, + ) + .map_err(|e| anyhow::anyhow!("Refusing to apply update: {e}"))?; + + // Refuse to touch the running binary if the staged update is empty/missing. + anyhow::ensure!( + !staged_bytes.is_empty(), + "Staged update binary is empty; refusing to apply" + ); + + let backup_path = current_exe.with_extension("old"); + if backup_path.exists() { + let _ = std::fs::remove_file(&backup_path); + } + + // Stage the new binary next to the current exe (same filesystem) so the + // final swap is an atomic rename rather than a non-atomic, interruptible + // copy directly over the running binary. + let staged_next = current_exe.with_extension("new"); + if staged_next.exists() { + let _ = std::fs::remove_file(&staged_next); + } + // Write the byte buffer that was just VERIFIED - copying the file again + // would re-read from disk and install bytes the signature check never saw + // (a swap between read and copy would slip through). Through + // `write_new_file`, because `.new` is a predictable name in a + // user-writable directory: `fs::write` follows a symlink planted there, so + // the verified bytes would land at the attacker's path and the rename below + // would then move the SYMLINK over the running binary - turning Colony's + // own executable into a link to a file rewritable afterwards, cleanly past + // every signature check. + write_new_file(&staged_next, &staged_bytes) + .map_err(|e| anyhow::anyhow!("Failed to stage new binary: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&staged_next)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&staged_next, perms)?; + } + + // Back up the running binary (renaming a running exe works on all + // platforms), then atomically move the staged copy into its place. + std::fs::rename(¤t_exe, &backup_path) + .map_err(|e| anyhow::anyhow!("Failed to backup current binary: {e}"))?; + + match std::fs::rename(&staged_next, ¤t_exe) { + Ok(()) => { + let _ = std::fs::remove_file(new_binary); + let _ = std::fs::remove_file(&sig_path); + // The sidecars are staged next to the binary, so they must be swept + // too or the staging directory never becomes empty. + let _ = std::fs::remove_file(&meta_path); + let _ = std::fs::remove_file(&meta_sig_path); + let _ = std::fs::remove_dir(new_binary.parent().unwrap_or(new_binary)); + Ok(current_exe) + } + Err(e) => { + tracing::error!("Failed to install new binary, restoring backup: {e}"); + let _ = std::fs::remove_file(&staged_next); + if let Err(re) = std::fs::rename(&backup_path, ¤t_exe) { + tracing::error!( + "CRITICAL: could not restore backup {} -> {}: {re}", + backup_path.display(), + current_exe.display() + ); + } + Err(anyhow::anyhow!("Failed to install new binary: {e}")) + } + } +} diff --git a/src/download/mod.rs b/src/download/mod.rs new file mode 100644 index 0000000..3df22bb --- /dev/null +++ b/src/download/mod.rs @@ -0,0 +1,16 @@ +//! Asset download, archive extraction, and launcher self-update. +//! +//! Downloads stream to a temporary file with length/integrity checks and are +//! atomically promoted into place; launcher self-updates are verified against a +//! signed detached signature (see [`crate::signing`]) before being applied. + +mod files; +mod http; +mod install; +mod launcher; +mod verify; + +pub(crate) use files::ensure_safe_component; +pub(crate) use http::{build_url, web_url}; +pub use install::{download_release_asset, AssetInstall}; +pub use launcher::{apply_launcher_update, download_launcher_asset, launcher_is_system_managed}; diff --git a/src/download/verify.rs b/src/download/verify.rs new file mode 100644 index 0000000..f1819ff --- /dev/null +++ b/src/download/verify.rs @@ -0,0 +1,290 @@ +//! Integrity and signature rules: SHA-256 digests, and what a signed `.meta` +//! sidecar must bind before an app or a launcher update is accepted. + +use anyhow::Result; +use sha2::{Digest, Sha256}; + +use crate::github::APP_VERSION; + +/// Lowercase hex SHA-256 of `bytes`, the form release metadata and colony.json +/// carry. sha2 0.11 returns a `hybrid_array::Array`, which has no `LowerHex`, +/// so the digest is spelled out byte by byte. +fn sha256_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + Sha256::digest(bytes) + .iter() + .fold(String::with_capacity(64), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }) +} + +/// Verify a SHA256 digest over bytes already in memory. +/// +/// Takes bytes rather than a path so the caller checks exactly what it is about +/// to install: re-opening the staged file to hash it means the digest describes +/// one read and the install uses another. +pub(super) fn verify_sha256_bytes(bytes: &[u8], expected_hex: &str) -> Result<()> { + let computed = sha256_hex(bytes); + if computed != expected_hex.to_lowercase() { + anyhow::bail!( + "SHA256 mismatch: expected {}, got {}", + expected_hex.to_lowercase(), + computed + ); + } + Ok(()) +} + +/// Verify a signed metadata sidecar and enforce what it binds. +/// +/// Checks, in order: the sidecar is signed by the release key; it describes the +/// asset we asked for; its digest matches the bytes in hand; it names the tag the +/// update check selected (`expected_tag`, unknown at apply time hence optional); +/// and its version is strictly newer than the running build. That last check is +/// the anti-rollback - without it, any older org-signed binary is a valid +/// "update". +pub(super) fn verify_launcher_metadata( + meta_bytes: &[u8], + meta_sig: &[u8], + binary_bytes: &[u8], + expected_asset: &str, + expected_tag: Option<&str>, +) -> Result<()> { + crate::signing::verify_release_signature(meta_bytes, meta_sig) + .map_err(|e| anyhow::anyhow!("update metadata signature invalid: {e}"))?; + let meta = crate::signing::ReleaseMetadata::parse(meta_bytes)?; + check_metadata_bindings(&meta, binary_bytes, expected_asset, expected_tag)?; + ensure_strictly_newer(&meta, APP_VERSION) +} + +/// The binding rules shared by the launcher and app install paths, split out so +/// they can be tested without the release private key. Assumes the metadata +/// signature has already been verified. +/// +/// Deliberately does NOT compare versions: that rule differs between the two +/// callers (see [`ensure_strictly_newer`] and [`ensure_not_a_downgrade`]). +pub(super) fn check_metadata_bindings( + meta: &crate::signing::ReleaseMetadata, + binary_bytes: &[u8], + expected_asset: &str, + expected_tag: Option<&str>, +) -> Result<()> { + anyhow::ensure!( + meta.asset == expected_asset, + "update metadata is for a different asset (signed '{}', expected '{expected_asset}')", + meta.asset + ); + + let digest = sha256_hex(binary_bytes); + anyhow::ensure!( + digest == meta.sha256, + "update metadata digest mismatch (signed {}, downloaded {digest})", + meta.sha256 + ); + + if let Some(tag) = expected_tag { + anyhow::ensure!( + meta.version == tag, + "update metadata is for a different release (signed '{}', expected '{tag}')", + meta.version + ); + } + + Ok(()) +} + +/// The launcher's extra rule: a self-update must be STRICTLY newer than the +/// build applying it. Separate from [`check_metadata_bindings`] because apps do +/// not share it - an app pinned to a fixed tag legitimately reinstalls the same +/// version, so "strictly newer" would make reinstalling impossible. +fn ensure_strictly_newer(meta: &crate::signing::ReleaseMetadata, running: &str) -> Result<()> { + let new = crate::github::parse_version_tag(&meta.version).ok_or_else(|| { + anyhow::anyhow!( + "unrecognized version in update metadata: '{}'", + meta.version + ) + })?; + let current = crate::github::parse_version_tag(running) + .ok_or_else(|| anyhow::anyhow!("unparseable running version"))?; + anyhow::ensure!( + new > current, + "refusing a downgrade: signed update is {new} but the running build is {current}" + ); + Ok(()) +} + +/// The app equivalent: never go BACKWARDS from what is installed, but allow the +/// same version (a reinstall, or an app pinned to a fixed tag). +/// +/// Non-semver tags are common in the ecosystem and are not orderable, so they +/// are accepted here rather than refused - the asset, digest and tag bindings +/// already did the work that matters, and refusing them would lock those apps +/// out of updates entirely. +pub(super) fn ensure_not_a_downgrade( + meta: &crate::signing::ReleaseMetadata, + installed: Option<&str>, +) -> Result<()> { + let Some(installed) = installed else { + return Ok(()); + }; + let (Some(new), Some(current)) = ( + crate::github::parse_version_tag(&meta.version), + crate::github::parse_version_tag(installed), + ) else { + return Ok(()); + }; + anyhow::ensure!( + new >= current, + "refusing a downgrade: signed release is {new} but {current} is installed" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta_for(bytes: &[u8], version: &str, asset: &str) -> crate::signing::ReleaseMetadata { + crate::signing::ReleaseMetadata { + version: version.into(), + asset: asset.into(), + sha256: sha256_hex(bytes), + } + } + + #[test] + fn signed_metadata_accepts_a_genuine_newer_release() { + let bytes = b"new colony build"; + let meta = meta_for(bytes, "v1.2.0", "colony-linux"); + assert!( + check_metadata_bindings(&meta, bytes, "colony-linux", Some("v1.2.0")).is_ok() + && ensure_strictly_newer(&meta, "1.1.0").is_ok() + ); + } + + /// The point of the sidecar: an older but genuinely org-signed binary must + /// not be installable as an "update". + #[test] + fn signed_metadata_refuses_a_downgrade_or_replay() { + let bytes = b"old colony build"; + for older in ["v1.0.0", "v0.9.0", "v1.1.0"] { + let meta = meta_for(bytes, older, "colony-linux"); + // The BINDINGS pass - the bytes really are that signed artefact. + // What refuses the replay is the version rule that sits beside + // them, which is why it lives in its own function per caller. + assert!(check_metadata_bindings(&meta, bytes, "colony-linux", Some(older)).is_ok()); + let err = ensure_strictly_newer(&meta, "1.1.0") + .unwrap_err() + .to_string(); + assert!( + err.contains("downgrade"), + "{older} vs running 1.1.0 should be refused as a downgrade, got: {err}" + ); + } + } + + #[test] + fn signed_metadata_refuses_a_substituted_artefact() { + let served = b"the macos binary"; + // Genuine, correctly signed metadata - but for a DIFFERENT asset. + let meta = meta_for(served, "v1.2.0", "colony-macos"); + assert!( + check_metadata_bindings(&meta, served, "colony-linux", Some("v1.2.0")).is_err(), + "metadata naming another asset must not validate this download" + ); + } + + #[test] + fn signed_metadata_refuses_mismatched_digest_or_tag() { + let meta = meta_for(b"expected bytes", "v1.2.0", "colony-linux"); + assert!( + check_metadata_bindings(&meta, b"tampered bytes", "colony-linux", None).is_err(), + "digest mismatch must fail" + ); + assert!( + check_metadata_bindings(&meta, b"expected bytes", "colony-linux", Some("v1.3.0")) + .is_err(), + "metadata for another tag than the one resolved must fail" + ); + } + + /// Apps do not share the launcher's strictly-newer rule - an app pinned to + /// a fixed tag reinstalls the same version - but they must still refuse a + /// genuinely-signed OLDER build replayed under a new tag. + #[test] + fn an_app_may_reinstall_the_same_version_but_never_an_older_one() { + let meta = meta_for(b"bytes", "v1.2.0", "grape-linux"); + + assert!( + ensure_not_a_downgrade(&meta, Some("v1.2.0")).is_ok(), + "reinstalling the pinned version must stay possible" + ); + assert!(ensure_not_a_downgrade(&meta, Some("v1.1.0")).is_ok()); + assert!(ensure_not_a_downgrade(&meta, None).is_ok(), "first install"); + assert!( + ensure_not_a_downgrade(&meta, Some("v2.0.0")).is_err(), + "a signed but older release must not be installable over a newer one" + ); + + // The launcher rule is the stricter one, and stays that way. + assert!(ensure_strictly_newer(&meta, "1.1.0").is_ok()); + assert!( + ensure_strictly_newer(&meta, "1.2.0").is_err(), + "the launcher must never reapply its own version" + ); + + // Non-semver tags are common in the ecosystem and are not orderable: + // accepted, because the asset/digest/tag bindings already did the work + // and refusing would lock those apps out of updates entirely. + let nightly = meta_for(b"bytes", "nightly", "grape-linux"); + assert!(ensure_not_a_downgrade(&nightly, Some("nightly")).is_ok()); + } + + #[test] + fn sha256_hex_matches_the_fips_180_vectors() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_verification_correct() { + use std::io::Write; + let dir = std::env::temp_dir().join("colony_test_sha256"); + let _ = std::fs::create_dir_all(&dir); + let file_path = dir.join("test.bin"); + let content = b"hello world"; + let mut f = std::fs::File::create(&file_path).unwrap(); + f.write_all(content).unwrap(); + f.flush().unwrap(); + + // SHA256 of "hello world" + let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert!(verify_sha256_bytes(&std::fs::read(&file_path).unwrap(), expected).is_ok()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn sha256_verification_mismatch() { + use std::io::Write; + let dir = std::env::temp_dir().join("colony_test_sha256_bad"); + let _ = std::fs::create_dir_all(&dir); + let file_path = dir.join("test.bin"); + let mut f = std::fs::File::create(&file_path).unwrap(); + f.write_all(b"hello world").unwrap(); + f.flush().unwrap(); + + assert!( + verify_sha256_bytes(&std::fs::read(&file_path).unwrap(), "0000000000000000").is_err() + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/i18n/en.rs b/src/i18n/en.rs index ff3c46c..e72cdac 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -186,9 +186,9 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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_github_desc".into(), "Without an account: 60 requests/h. With one: 5000/h. 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()); + strings.insert("tut_finish_desc".into(), "The Colony name at the top of the sidebar opens Preferences: themes, keyboard shortcuts, accessibility. Enjoy Colony!".into()); // Loading / async feedback strings.insert("scanning".into(), "Scanning...".into()); @@ -199,29 +199,32 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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( + "shortcuts_desc".into(), + "What the keys do while keyboard navigation is on.".into(), + ); strings.insert( "shortcut_esc".into(), - "Esc — Close the current panel, dialog or detail page".into(), + "Esc - Close the current panel, dialog or detail page".into(), ); strings.insert( "shortcut_tab".into(), - "Tab / Shift+Tab — Navigate categories".into(), + "Tab / Shift+Tab - Navigate categories".into(), ); strings.insert( "shortcut_arrows".into(), - "↑ ↓ — Navigate settings and the app grid".into(), + "↑ ↓ - Navigate Preferences and the app grid".into(), ); strings.insert( "shortcut_enter".into(), - "Enter — Open the highlighted item (launches a local app directly)".into(), + "Enter - Open the highlighted item (launches a local app directly)".into(), ); strings.insert( "shortcut_pageupdown".into(), - "Page Up/Down — Fast navigation in settings".into(), + "Page Up/Down - Fast navigation in Preferences".into(), ); // Tooltips / hints @@ -236,22 +239,10 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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()); + // Preferences: Colony's own categories. The shared ones ship in colony-ui. 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 + // General: startup strings.insert("settings_section_startup".into(), "Startup".into()); strings.insert( "settings_startup_section_desc".into(), @@ -276,7 +267,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { strings.insert("settings_section_language".into(), "Language".into()); strings.insert( "settings_language_desc".into(), - "Customize the interface and time format.".into(), + "The language of every label in Colony.".into(), ); strings.insert( "settings_current_language".into(), @@ -284,13 +275,13 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "settings_current_language_desc".into(), - "Synced with system.".into(), + "Defaults to the system language.".into(), ); // Updates strings.insert("settings_section_updates".into(), "Updates".into()); strings.insert( "settings_updates_desc".into(), - "Manage update checking and channel.".into(), + "Looking for new versions of Colony and your apps.".into(), ); strings.insert( "settings_auto_check_updates".into(), @@ -301,93 +292,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { "Check for new versions on launch.".into(), ); strings.insert("settings_check_updates".into(), "Check for updates".into()); - // Privacy - // 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()); - // Theme families - // New theme families - // Colors & accents - strings.insert("settings_section_colors".into(), "Colors & accents".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_section_typography".into(), "Typography".into()); - strings.insert( - "settings_typography_desc".into(), - "Configure font and text size.".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_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(), @@ -401,26 +306,15 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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(), + "What Colony finds, caches and installs.".into(), ); strings.insert("settings_section_scan".into(), "Scan".into()); strings.insert( "settings_scan_desc".into(), - "Configure directories scanned at startup.".into(), + "Colony looks for the applications already installed on this computer.".into(), ); strings.insert("settings_startup".into(), "Scan on startup".into()); strings.insert( @@ -428,13 +322,20 @@ pub(super) fn insert_all(strings: &mut HashMap) { "Updates the library at startup.".into(), ); strings.insert("settings_section_install".into(), "Installation".into()); + strings.insert( + "settings_install_desc".into(), + "Counts of what Colony found on this computer, in the catalog and in your favorites." + .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 // About strings.insert("settings_about_title".into(), "About Colony".into()); - strings.insert("settings_about".into(), "About".into()); + strings.insert( + "settings_about_desc".into(), + "The version you are running, where it comes from, and its updates.".into(), + ); // Launcher self-update strings.insert( "launcher_update_available".into(), @@ -442,7 +343,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "launcher_update_available_short".into(), - "\u{f0aa} Update {version}".into(), + "Update {version}".into(), ); strings.insert( "launcher_update_ready".into(), @@ -450,7 +351,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "launcher_restart_to_update".into(), - "\u{f021} Restart to update".into(), + "Restart to update".into(), ); strings.insert( "launcher_download_update".into(), diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs index 84204d5..569b23d 100644 --- a/src/i18n/fr.rs +++ b/src/i18n/fr.rs @@ -9,7 +9,7 @@ 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()); + strings.insert("rescan".into(), "Relancer l'analyse".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()); @@ -59,7 +59,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { // Detail view strings.insert("back".into(), "Retour".into()); - strings.insert("language_label".into(), "Langage: {lang}".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()); @@ -80,29 +80,29 @@ pub(super) fn insert_all(strings: &mut HashMap) { strings.insert("installed".into(), "Installé : {path}".into()); strings.insert( "download_error".into(), - "Erreur téléchargement : {error}".into(), + "Erreur de 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(), + "Aucune version publiée pour {platform}".into(), ); strings.insert("uninstalled".into(), "{name} désinstallé.".into()); strings.insert( "launch_error".into(), - "Impossible de lancer: {error}".into(), + "Impossible de lancer : {error}".into(), ); strings.insert( "launch_error_empty".into(), - "Impossible de lancer: commande vide".into(), + "Impossible de lancer : commande vide".into(), ); strings.insert( "uninstall_error".into(), - "Erreur désinstallation : {error}".into(), + "Erreur de désinstallation : {error}".into(), ); // OAuth errors - strings.insert("oauth_error".into(), "Erreur OAuth: {error}".into()); + 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(), @@ -111,11 +111,11 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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("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(), + "Erreur de lancement : {error}".into(), ); strings.insert( "updates_available".into(), @@ -157,7 +157,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { // Thread errors strings.insert( "error_thread_panic".into(), - "Erreur interne : le thread a paniqué".into(), + "Erreur interne : une tâche d'arrière-plan s'est arrêtée brutalement".into(), ); // Download cancellation @@ -184,23 +184,26 @@ pub(super) fn insert_all(strings: &mut HashMap) { 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_connect_now".into(), + "Se connecter maintenant".into(), + ); strings.insert("welcome_later".into(), "Plus tard".into()); - // Tutoriel guidé (spotlight sur l'UI réelle) + // Guided tutorial (spotlight on the real UI) 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_grid_desc".into(), "Voici toutes vos apps installées et les apps Colony disponibles. Cliquez sur une carte pour voir son README, son journal des modifications 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_github_desc".into(), "Sans compte : 60 requêtes/h. Avec un compte : 5000/h. Recommandé si vous explorez beaucoup. Le bouton Relancer l'analyse, juste en dessous, actualise les applications trouvées sur le 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()); + strings.insert("tut_finish_desc".into(), "Le nom Colony, en haut de la barre latérale, ouvre les préférences : thèmes, raccourcis clavier, accessibilité. Bon voyage dans Colony !".into()); // Loading / async feedback strings.insert("scanning".into(), "Analyse en cours...".into()); @@ -220,29 +223,32 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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( + "shortcuts_desc".into(), + "Ce que font les touches quand la navigation au clavier est activée.".into(), + ); strings.insert( "shortcut_esc".into(), - "Échap — Fermer le panneau, la boîte de dialogue ou la fiche en cours".into(), + "Échap - Fermer le panneau, la boîte de dialogue ou la fiche en cours".into(), ); strings.insert( "shortcut_tab".into(), - "Tab / Maj+Tab — Naviguer entre les catégories".into(), + "Tab / Maj+Tab - Naviguer entre les catégories".into(), ); strings.insert( "shortcut_arrows".into(), - "↑ ↓ — Naviguer dans les réglages et la grille".into(), + "↑ ↓ - Naviguer dans les préférences et la grille".into(), ); strings.insert( "shortcut_enter".into(), - "Entrée — Ouvrir l'élément sélectionné (lance directement une app locale)".into(), + "Entrée - Ouvrir l'élément sélectionné (lance directement une app locale)".into(), ); strings.insert( "shortcut_pageupdown".into(), - "Page ↑/↓ — Naviguer plus vite dans les paramètres".into(), + "Page ↑/↓ - Naviguer plus vite dans les préférences".into(), ); // Tooltips / hints @@ -260,25 +266,10 @@ pub(super) fn insert_all(strings: &mut HashMap) { "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()); + // Preferences: Colony's own categories. The shared ones ship in colony-ui. 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 + // General: startup strings.insert("settings_section_startup".into(), "Démarrage".into()); strings.insert( "settings_startup_section_desc".into(), @@ -303,7 +294,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { strings.insert("settings_section_language".into(), "Langue".into()); strings.insert( "settings_language_desc".into(), - "Personnalisez l'interface et le format horaire.".into(), + "La langue de tous les libellés de Colony.".into(), ); strings.insert( "settings_current_language".into(), @@ -311,13 +302,13 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "settings_current_language_desc".into(), - "Synchronisée avec le système.".into(), + "Par défaut, celle du système.".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(), + "La recherche de nouvelles versions de Colony et de vos applications.".into(), ); strings.insert( "settings_auto_check_updates".into(), @@ -331,152 +322,49 @@ pub(super) fn insert_all(strings: &mut HashMap) { "settings_check_updates".into(), "Vérifier les mises à jour".into(), ); - // Privacy - // 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()); - // Theme families - // New theme families - // Colors & accents - strings.insert( - "settings_section_colors".into(), - "Couleurs & accents".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_section_typography".into(), "Typographie".into()); - strings.insert( - "settings_typography_desc".into(), - "Configurez la police et la taille du texte.".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_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(), + "Navigation et interaction".into(), ); strings.insert( "settings_navigation_desc".into(), - "Options de navigation au clavier et interaction.".into(), + "Options de navigation au clavier et d'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(), + "Ce que Colony trouve, met en cache et installe.".into(), ); - strings.insert("settings_section_scan".into(), "Scan".into()); + strings.insert("settings_section_scan".into(), "Analyse".into()); strings.insert( "settings_scan_desc".into(), - "Configurez les dossiers analysés au démarrage.".into(), + "Colony recherche les applications déjà installées sur cet ordinateur.".into(), ); - strings.insert("settings_startup".into(), "Scanner au démarrage".into()); + strings.insert("settings_startup".into(), "Analyser au démarrage".into()); strings.insert( "settings_startup_desc".into(), "Met à jour la bibliothèque au démarrage.".into(), ); strings.insert("settings_section_install".into(), "Installation".into()); + strings.insert( + "settings_install_desc".into(), + "Ce que Colony a trouvé sur cet ordinateur, dans le catalogue et dans vos favoris.".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 // About strings.insert("settings_about_title".into(), "À propos de Colony".into()); - strings.insert("settings_about".into(), "À propos".into()); + strings.insert( + "settings_about_desc".into(), + "La version utilisée, sa provenance et ses mises à jour.".into(), + ); // Launcher self-update strings.insert( "launcher_update_available".into(), @@ -484,7 +372,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "launcher_update_available_short".into(), - "\u{f0aa} Mise à jour {version}".into(), + "Mise à jour {version}".into(), ); strings.insert( "launcher_update_ready".into(), @@ -492,7 +380,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { ); strings.insert( "launcher_restart_to_update".into(), - "\u{f021} Relancer pour mettre à jour".into(), + "Relancer pour mettre à jour".into(), ); strings.insert( "launcher_download_update".into(), @@ -510,8 +398,11 @@ pub(super) fn insert_all(strings: &mut HashMap) { 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( + "clear_caches".into(), + "Vider les caches du catalogue".into(), + ); + strings.insert("clear_caches_desc".into(), "Supprime les descriptions et icônes mises en cache (elles sont retéléchargées à la prochaine actualisation). Les applications installées ne sont pas touchées.".into()); strings.insert( "caches_cleared".into(), "{count} cache(s) supprimé(s)".into(), @@ -519,7 +410,7 @@ pub(super) fn insert_all(strings: &mut HashMap) { 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_license".into(), "Licence".into()); + strings.insert("tab_changelog".into(), "Journal des modifications".into()); strings.insert("tab_not_available".into(), "Non disponible".into()); } diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 87d30e6..706f4b2 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -255,10 +255,17 @@ mod tests { include_str!("../github/catalog.rs"), include_str!("../github/releases.rs"), include_str!("../oauth.rs"), - include_str!("../download.rs"), + include_str!("../download/mod.rs"), + include_str!("../download/files.rs"), + include_str!("../download/http.rs"), + include_str!("../download/install.rs"), + include_str!("../download/launcher.rs"), + include_str!("../download/verify.rs"), include_str!("../persistence.rs"), include_str!("../config.rs"), + include_str!("../logging.rs"), include_str!("../main.rs"), + include_str!("../manifest.rs"), ]; let mut dead = Vec::new(); diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..ecde78f --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,72 @@ +//! Logging: to a file in the data directory and to stderr, set up once at +//! startup, after the legacy-path migration whose own lines it keeps. + +/// Run `f` with its log lines held in memory, and return them. +/// +/// The startup migration must run before anything creates a directory, the +/// log file included, yet what it reports belongs in that file. +pub fn capture(f: impl FnOnce()) -> Vec { + #[derive(Clone, Default)] + struct Buffer(std::sync::Arc>>); + impl std::io::Write for Buffer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let mut buffer = self.0.lock().unwrap_or_else(|e| e.into_inner()); + buffer.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let buffer = Buffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let mut lines = buffer.0.lock().unwrap_or_else(|e| e.into_inner()); + std::mem::take(&mut *lines) +} + +/// Start logging to stderr and to a fresh log file, which opens with `early`: +/// the lines [`capture`] held back. +pub fn init(early: &[u8]) { + // `EnvFilter::from_default_env().add_directive(INFO)` looked like "default + // to info", but a bare `RUST_LOG=debug` parses to a directive that compares + // Equal to the added one, so add_directive REPLACED it and the user's + // request was silently discarded. Build the default only when RUST_LOG is + // absent or unparseable, and otherwise honour it verbatim. + // + // The default is scoped rather than a bare "info": iced_winit and wgpu are + // chatty at info (a full pretty-printed WindowAttributes and adapter dump + // per launch), which drowned Colony's own lines in the very file a bug + // report is supposed to attach. Their warnings still come through. + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,colony=info")); + + // Log to a file as well as stderr: a .desktop launch has no terminal, and + // on Windows there is no console at all, so stderr-only meant that every + // warning in the codebase reached nobody in any shipped configuration. + // A plain truncate-on-start file in the data directory, not a rolling + // appender: one run's worth of log is what a bug report needs, and it keeps + // the dependency set unchanged. + use std::io::Write as _; + use tracing_subscriber::fmt::writer::MakeWriterExt; + let _ = std::io::stderr().write_all(early); + let log_file = crate::persistence::colony_log_path() + .ok() + .and_then(|path| std::fs::File::create(path).ok()); + match log_file { + Some(mut file) => { + let _ = file.write_all(early); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::sync::Mutex::new(file).and(std::io::stderr)) + .with_ansi(false) + .init() + } + None => tracing_subscriber::fmt().with_env_filter(filter).init(), + } +} diff --git a/src/main.rs b/src/main.rs index cc672be..cbeebe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod download; mod github; mod i18n; mod icons; +mod logging; +mod manifest; mod message; mod oauth; mod persistence; @@ -19,36 +21,7 @@ mod state; mod ui; mod update; -use state::{default_font, App}; - -/// Run `f` with its log lines held in memory, and return them. -/// -/// The startup migration must run before anything creates a directory, the -/// log file included, yet what it reports belongs in that file. -fn capture_log(f: impl FnOnce()) -> Vec { - #[derive(Clone, Default)] - struct Buffer(std::sync::Arc>>); - impl std::io::Write for Buffer { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - let mut buffer = self.0.lock().unwrap_or_else(|e| e.into_inner()); - buffer.extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - let buffer = Buffer::default(); - let writer = buffer.clone(); - let subscriber = tracing_subscriber::fmt() - .with_writer(move || writer.clone()) - .with_ansi(false) - .finish(); - tracing::subscriber::with_default(subscriber, f); - let mut lines = buffer.0.lock().unwrap_or_else(|e| e.into_inner()); - std::mem::take(&mut *lines) -} +use state::App; /// Answer `--version` / `--help` without opening a window. The bug template /// makes `colony --version` a required field, and on Windows @@ -73,7 +46,7 @@ fn handle_cli_flags() -> bool { // one - a structurally perfect manifest still resolves to nothing // if the release names its files differently. let assets: Vec = rest.collect(); - std::process::exit(validate_manifest(&path, &assets)); + std::process::exit(manifest::validate(&path, &assets)); } "--version" | "-V" => { println!("colony {}", env!("CARGO_PKG_VERSION")); @@ -106,106 +79,6 @@ fn handle_cli_flags() -> bool { } } -/// Report on a `colony.json`. Returns the process exit code: 0 clean, 1 invalid, -/// 2 unreadable. -fn validate_manifest(path: &str, assets: &[String]) -> i32 { - let raw = match std::fs::read(path) { - Ok(bytes) => bytes, - Err(e) => { - eprintln!("cannot read {path}: {e}"); - return 2; - } - }; - let manifest: github::ColonyManifest = match serde_json::from_slice(&raw) { - Ok(m) => m, - Err(e) => { - eprintln!("{path} is not a valid colony.json: {e}"); - return 1; - } - }; - let mut errors = manifest.validation_errors(); - - if assets.is_empty() { - if manifest.release_files.is_empty() { - println!( - " note: no releaseFiles - Colony auto-detects platforms from asset names \ - following the - convention. Pass your release asset names \ - (colony validate-manifest {path} ...) to check that this actually \ - resolves; without them this is a shape check only." - ); - } - } else { - errors.extend(resolution_errors(&manifest, path, assets)); - } - - if errors.is_empty() { - println!("{path}: OK ({}, {})", manifest.name, manifest.category); - return 0; - } - eprintln!("{path}: {} problem(s)", errors.len()); - for e in &errors { - eprintln!(" - {e}"); - } - 1 -} - -/// Check that the manifest resolves to something installable against the asset -/// names a release actually publishes. -/// -/// This is the check that catches the failure that really happens: a manifest -/// declaring only a name and a category is structurally perfect, and still -/// renders as a card with no Download button because none of its assets match -/// the `-` convention auto-detection looks for. -fn resolution_errors( - manifest: &github::ColonyManifest, - path: &str, - assets: &[String], -) -> Vec { - let mut errors = Vec::new(); - - if manifest.release_files.is_empty() { - // Auto-detection is the only path. `` is not in the manifest, so - // derive it the way Colony does: from the manifest name, then from the - // file's own directory, since app authors run this in their repo root. - let repo_hint = std::path::Path::new(path) - .parent() - .and_then(|p| p.canonicalize().ok()) - .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) - .unwrap_or_else(|| manifest.name.clone()); - let detected = github::detect_platforms_from_assets(&repo_hint, assets); - if detected.is_empty() { - errors.push(format!( - "no releaseFiles, and none of the {} published asset(s) match the \ - - convention for {repo_hint:?} (expected e.g. \ - {}-linux). The app will list in Colony with no Download button. \ - Declare releaseFiles explicitly, or rename the assets.", - assets.len(), - repo_hint.to_lowercase() - )); - } else { - println!(" auto-detected platforms: {}", detected.join(", ")); - } - return errors; - } - - for (platform, entry) in &manifest.release_files { - let at = format!("releaseFiles.{platform}"); - if let Some(file) = &entry.file { - if !assets.iter().any(|a| a == file) { - errors.push(format!( - "`{at}.file` is {file:?}, which this release does not publish" - )); - } - } else if let Some(pattern) = &entry.file_pattern { - match github::find_asset_by_pattern(assets, pattern) { - Ok(resolved) => println!(" {platform} -> {resolved}"), - Err(e) => errors.push(format!("`{at}.filePattern` does not resolve: {e}")), - } - } - } - errors -} - pub fn main() -> iced::Result { if handle_cli_flags() { return Ok(()); @@ -216,44 +89,8 @@ pub fn main() -> iced::Result { // directory is the very config directory the first move targets, and a // move skips a target that already exists. No-op once done, and on a fresh // install. - let migration_log = capture_log(crate::persistence::migrate_legacy_paths); - - // `EnvFilter::from_default_env().add_directive(INFO)` looked like "default - // to info", but a bare `RUST_LOG=debug` parses to a directive that compares - // Equal to the added one, so add_directive REPLACED it and the user's - // request was silently discarded. Build the default only when RUST_LOG is - // absent or unparseable, and otherwise honour it verbatim. - // - // The default is scoped rather than a bare "info": iced_winit and wgpu are - // chatty at info (a full pretty-printed WindowAttributes and adapter dump - // per launch), which drowned Colony's own lines in the very file a bug - // report is supposed to attach. Their warnings still come through. - let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,colony=info")); - - // Log to a file as well as stderr: a .desktop launch has no terminal, and - // on Windows there is no console at all, so stderr-only meant that every - // warning in the codebase reached nobody in any shipped configuration. - // A plain truncate-on-start file in the data directory, not a rolling - // appender: one run's worth of log is what a bug report needs, and it keeps - // the dependency set unchanged. - use std::io::Write as _; - use tracing_subscriber::fmt::writer::MakeWriterExt; - let _ = std::io::stderr().write_all(&migration_log); - let log_file = crate::persistence::colony_log_path() - .ok() - .and_then(|path| std::fs::File::create(path).ok()); - match log_file { - Some(mut file) => { - let _ = file.write_all(&migration_log); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::sync::Mutex::new(file).and(std::io::stderr)) - .with_ansi(false) - .init() - } - None => tracing_subscriber::fmt().with_env_filter(filter).init(), - } + let migration_log = logging::capture(persistence::migrate_legacy_paths); + logging::init(&migration_log); // Honor the saved language preference over environment locale detection, // and reopen at the last persisted window size (clamped to sanity). @@ -262,11 +99,18 @@ pub fn main() -> iced::Result { let width = prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0); let height = prefs.window_height.unwrap_or(700.0).clamp(480.0, 4320.0); - iced::application(App::boot, App::update, App::view) + // The interface, dyslexia and icon fonts ship inside colony-ui, licences + // included. + colony_ui::fonts::BYTES + .into_iter() + .fold( + iced::application(App::boot, App::update, App::view), + |app, font| app.font(font), + ) .title(App::title) .theme(App::theme) .subscription(App::subscription) - .default_font(default_font()) + .default_font(colony_ui::ui_font()) .window_size((width, height)) .run() } diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..03732f1 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,152 @@ +//! `colony validate-manifest`: the check every app in the org runs on its own +//! `colony.json` before it publishes a release. + +use crate::github; + +/// Report on a `colony.json`. Returns the process exit code: 0 clean, 1 invalid, +/// 2 unreadable. +pub fn validate(path: &str, assets: &[String]) -> i32 { + let raw = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("cannot read {path}: {e}"); + return 2; + } + }; + let manifest: github::ColonyManifest = match serde_json::from_slice(&raw) { + Ok(m) => m, + Err(e) => { + eprintln!("{path} is not a valid colony.json: {e}"); + return 1; + } + }; + let mut errors = manifest.validation_errors(); + + if assets.is_empty() { + if manifest.release_files.is_empty() { + println!( + " note: no releaseFiles - Colony auto-detects platforms from asset names \ + following the - convention. Pass your release asset names \ + (colony validate-manifest {path} ...) to check that this actually \ + resolves; without them this is a shape check only." + ); + } + } else { + errors.extend(resolution_errors(&manifest, path, assets)); + } + + if errors.is_empty() { + println!("{path}: OK ({}, {})", manifest.name, manifest.category); + return 0; + } + eprintln!("{path}: {} problem(s)", errors.len()); + for e in &errors { + eprintln!(" - {e}"); + } + 1 +} + +/// Check that the manifest resolves to something installable against the asset +/// names a release actually publishes. +/// +/// This is the check that catches the failure that really happens: a manifest +/// declaring only a name and a category is structurally perfect, and still +/// renders as a card with no Download button because none of its assets match +/// the `-` convention auto-detection looks for. +fn resolution_errors( + manifest: &github::ColonyManifest, + path: &str, + assets: &[String], +) -> Vec { + let mut errors = Vec::new(); + + if manifest.release_files.is_empty() { + // Auto-detection is the only path. `` is not in the manifest, so + // derive it the way Colony does: from the manifest name, then from the + // file's own directory, since app authors run this in their repo root. + let repo_hint = std::path::Path::new(path) + .parent() + .and_then(|p| p.canonicalize().ok()) + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_else(|| manifest.name.clone()); + let detected = github::detect_platforms_from_assets(&repo_hint, assets); + if detected.is_empty() { + errors.push(format!( + "no releaseFiles, and none of the {} published asset(s) match the \ + - convention for {repo_hint:?} (expected e.g. \ + {}-linux). The app will list in Colony with no Download button. \ + Declare releaseFiles explicitly, or rename the assets.", + assets.len(), + repo_hint.to_lowercase() + )); + } else { + println!(" auto-detected platforms: {}", detected.join(", ")); + } + return errors; + } + + for (platform, entry) in &manifest.release_files { + let at = format!("releaseFiles.{platform}"); + if let Some(file) = &entry.file { + if !assets.iter().any(|a| a == file) { + errors.push(format!( + "`{at}.file` is {file:?}, which this release does not publish" + )); + } + } else if let Some(pattern) = &entry.file_pattern { + match github::find_asset_by_pattern(assets, pattern) { + Ok(resolved) => println!(" {platform} -> {resolved}"), + Err(e) => errors.push(format!("`{at}.filePattern` does not resolve: {e}")), + } + } + } + errors +} + +#[cfg(test)] +mod tests { + use super::validate; + + /// Every org release runs `colony validate-manifest` and reads its exit + /// code: 0 clean, 1 invalid, 2 unreadable. + #[test] + fn exit_codes() { + let dir = tempfile::tempdir().unwrap(); + let write = |name: &str, json: &str| { + let path = dir.path().join(name); + std::fs::write(&path, json).unwrap(); + path.to_string_lossy().into_owned() + }; + let assets = + |names: &[&str]| -> Vec { names.iter().map(|n| n.to_string()).collect() }; + + let missing = dir.path().join("absent.json"); + assert_eq!(validate(&missing.to_string_lossy(), &[]), 2); + + assert_eq!(validate(&write("garbage.json", "not json"), &[]), 1); + + let empty_name = write("empty.json", r#"{"name": " ", "category": "games"}"#); + assert_eq!(validate(&empty_name, &[]), 1); + + let minimal = write( + "minimal.json", + r#"{"name": "Grape", "category": "multimedia"}"#, + ); + assert_eq!(validate(&minimal, &[]), 0, "a shape check without assets"); + + let pinned = write( + "pinned.json", + r#"{ + "name": "Grape", + "category": "multimedia", + "releaseFiles": { "linux": { "tag": "latest", "file": "grape-linux" } } + }"#, + ); + assert_eq!(validate(&pinned, &assets(&["grape-linux"])), 0); + assert_eq!( + validate(&pinned, &assets(&["grape-windows.exe"])), + 1, + "the release does not publish the file the manifest names" + ); + } +} diff --git a/src/message.rs b/src/message.rs index 8c1f336..4dffb1c 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,4 +1,4 @@ -use iced::font; +use colony_ui::preferences::Change; use iced::keyboard; use std::path::PathBuf; @@ -21,7 +21,6 @@ pub enum Message { ColonyRepoSelected(String), ColonyRepoBack, ClearStatus, - FontLoaded(Result<(), font::Error>), // GitHub / OAuth messages ToggleGitHubMenu, GitHubLogin, @@ -71,26 +70,20 @@ pub enum Message { WelcomeBack, WelcomeConnectGithub, TutorialBoundsUpdated(TutorialBounds), - // Settings + // Preferences + /// The Colony name at the top of the sidebar and the page's Close button: + /// one message, two ways out. ToggleSettings, SettingsCategory(usize), - SettingsToggleSection(String), - // Appearance - SelectThemeVariant(String, String), // (theme, variant) - SelectAccentColor(String), - ToggleAutoAccent, - // Preference toggles + SettingsToggleSection(&'static str), + /// A shared Appearance or Accessibility preference (colony-ui). + Preference(Change), + // Colony's own preferences ToggleRestoreSession, PickDefaultView(String), PickLanguage(String), ToggleAutoCheckUpdates, - PickFontSize(String), - ToggleAnimations, - ToggleHighContrast, - PickTextSizeA11y(String), - ToggleReduceMotion, ToggleKeyboardNav, - ToggleDyslexiaFont, ToggleScanOnStartup, // Detail tabs DetailTabSelected(DetailTab), diff --git a/src/persistence.rs b/src/persistence.rs index 39518d5..3433f23 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -3,6 +3,7 @@ //! module holds pure on-disk storage and does not touch the GitHub API. use anyhow::Result; +use colony_ui::preferences::StandardPreferences; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -581,50 +582,71 @@ pub fn save_favorites(favorites: &[String]) -> Result<()> { write_atomic(&path, serde_json::to_string(favorites)?.as_bytes()) } +/// The format [`save_preferences`] writes. Bump it when an older file needs +/// [`UserPreferences::migrate`] to rewrite a value rather than just read it. +pub const PREFS_VERSION: u32 = 1; + /// User preferences saved between sessions. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UserPreferences { + /// Theme, accent, text sizes, motion and the other Appearance and + /// Accessibility choices every Colony program shares, from colony-ui. + /// Flattened, so the file keeps the field names it has always had, and + /// lenient: a value this build does not understand falls back to its + /// default instead of discarding the whole file. + #[serde(flatten)] + pub standard: StandardPreferences, + /// Format version. Files written before versioning have none and read as 0. + #[serde(default)] + pub prefs_version: u32, pub selected_section: Option, pub window_width: Option, pub window_height: Option, pub first_launch_done: Option, - pub selected_theme: Option, - pub selected_variant: Option, - pub selected_accent: Option, - /// Whether the accent follows the theme. The toggle existed and was - /// applied at boot, but was never written, so it silently reset every - /// restart. - pub auto_accent: Option, // General pub restore_session: Option, pub default_view: Option, pub language: Option, pub auto_check_updates: Option, - // Appearance - pub font_size: Option, - pub animations: Option, // Accessibility - pub high_contrast: Option, - pub text_size_a11y: Option, - pub reduce_motion: Option, pub keyboard_nav: Option, - pub dyslexia_font: Option, // Storage pub scan_on_startup: Option, } +impl UserPreferences { + /// Bring a file written by an older Colony up to [`PREFS_VERSION`]. + /// + /// Version 0 wrote `"selected_accent": "blue"` for everyone who never + /// picked an accent, because blue was the default, and applied it over the + /// theme's own accent. Nothing in such a file tells a deliberate blue from + /// the default, so it becomes `None`, the theme's own accent, once. The + /// version written back afterwards is what keeps a later deliberate blue. + pub fn migrate(&mut self) { + if self.prefs_version < 1 + && !self.standard.auto_accent + && self.standard.selected_accent.as_deref() == Some("blue") + { + self.standard.selected_accent = None; + } + self.prefs_version = PREFS_VERSION; + } +} + fn preferences_path() -> Result { let dir = colony_data_dir()?.join("preferences"); std::fs::create_dir_all(&dir)?; Ok(dir.join("preferences.json")) } -/// Load user preferences. +/// Load user preferences, migrated to [`PREFS_VERSION`]. pub fn load_preferences() -> UserPreferences { - preferences_path() + let mut prefs: UserPreferences = preferences_path() .ok() .and_then(|p| load_json_or_quarantine(&p)) - .unwrap_or_default() + .unwrap_or_default(); + prefs.migrate(); + prefs } /// Save user preferences. @@ -1021,6 +1043,109 @@ mod tests { assert_eq!(loaded.selected_section, Some(2)); assert_eq!(loaded.first_launch_done, Some(true)); } + + /// What Colony 0.10.4 writes: every field present, the accent at its old + /// default, no format version. + const V0_10_4: &str = r#"{ + "selected_section": 3, + "window_width": 1280.0, + "window_height": 820.0, + "first_launch_done": true, + "selected_theme": "catppuccin", + "selected_variant": "mocha", + "selected_accent": "blue", + "auto_accent": false, + "restore_session": false, + "default_view": "favorites", + "language": "fr", + "auto_check_updates": false, + "font_size": "large", + "animations": false, + "high_contrast": true, + "text_size_a11y": "xlarge", + "reduce_motion": true, + "keyboard_nav": false, + "dyslexia_font": true, + "scan_on_startup": false + }"#; + + fn load(json: &str) -> UserPreferences { + let mut prefs: UserPreferences = serde_json::from_str(json).expect("parses"); + prefs.migrate(); + prefs + } + + #[test] + fn a_0_10_4_file_keeps_everything_but_the_default_accent() { + use colony_ui::{FontSize, TextSize}; + + let prefs = load(V0_10_4); + let s = &prefs.standard; + assert_eq!( + s.selected_accent, None, + "the old default is the theme's own accent now" + ); + assert_eq!( + (s.selected_theme.as_str(), s.selected_variant.as_str()), + ("catppuccin", "mocha") + ); + assert!(!s.auto_accent && !s.animations); + assert!(s.high_contrast && s.reduce_motion && s.dyslexia_font); + assert_eq!(s.font_size, FontSize::Large); + assert_eq!(s.text_size_a11y, TextSize::XLarge); + assert_eq!(prefs.selected_section, Some(3)); + assert_eq!(prefs.window_width, Some(1280.0)); + assert_eq!(prefs.first_launch_done, Some(true)); + assert_eq!(prefs.restore_session, Some(false)); + assert_eq!(prefs.default_view.as_deref(), Some("favorites")); + assert_eq!(prefs.language.as_deref(), Some("fr")); + assert_eq!(prefs.auto_check_updates, Some(false)); + assert_eq!(prefs.keyboard_nav, Some(false)); + assert_eq!(prefs.scan_on_startup, Some(false)); + assert_eq!(prefs.prefs_version, PREFS_VERSION); + } + + #[test] + fn the_accent_migration_runs_once() { + // Written back after the migration, a blue picked since is kept. + let mut migrated = load(V0_10_4); + migrated.standard.selected_accent = Some("blue".into()); + let written = serde_json::to_string(&migrated).unwrap(); + assert!(written.contains("\"prefs_version\":1")); + assert_eq!( + load(&written).standard.selected_accent.as_deref(), + Some("blue") + ); + + // Any other pick, or blue behind auto accent, was never the default. + let violet = V0_10_4.replace("\"blue\"", "\"violet\""); + assert_eq!( + load(&violet).standard.selected_accent.as_deref(), + Some("violet") + ); + let auto = V0_10_4.replace("\"auto_accent\": false", "\"auto_accent\": true"); + assert_eq!( + load(&auto).standard.selected_accent.as_deref(), + Some("blue") + ); + } + + #[test] + fn values_this_build_does_not_know_fall_back_and_keep_the_rest() { + let odd = V0_10_4 + .replace("\"large\"", "\"huge\"") + .replace("\"xlarge\"", "7") + .replace("\"high_contrast\": true", "\"high_contrast\": \"yes\""); + let prefs = load(&odd); + let defaults = StandardPreferences::default(); + assert_eq!(prefs.standard.font_size, defaults.font_size); + assert_eq!(prefs.standard.text_size_a11y, defaults.text_size_a11y); + assert_eq!(prefs.standard.high_contrast, defaults.high_contrast); + // The neighbours survive. + assert!(prefs.standard.reduce_motion); + assert_eq!(prefs.standard.selected_theme, "catppuccin"); + assert_eq!(prefs.language.as_deref(), Some("fr")); + } } #[cfg(test)] diff --git a/src/sections.rs b/src/sections.rs index 2d58769..01b9194 100644 --- a/src/sections.rs +++ b/src/sections.rs @@ -562,6 +562,32 @@ mod tests { assert!(!sections.unwrap().is_empty()); } + /// Each icon is a Nerd Font glyph, written as a JSON escape: a pasted + /// private-use character is invisible in an editor and in a diff, so a + /// wrong one could only be caught by looking at the running sidebar. + #[test] + fn category_icons_are_escaped_nerd_font_glyphs() { + assert!( + EMBEDDED_CATEGORIES.is_ascii(), + "write glyphs as \\uXXXX escapes, not pasted characters" + ); + let entries: Vec = + serde_json::from_str(EMBEDDED_CATEGORIES).expect("embedded parses"); + for entry in &entries { + let icon = entry["icon"].as_str().expect("every section has an icon"); + let mut chars = icon.chars(); + let (Some(glyph), None) = (chars.next(), chars.next()) else { + panic!("{icon:?} must be exactly one glyph"); + }; + assert!( + ('\u{e000}'..='\u{f8ff}').contains(&glyph), + "{:?}: U+{:04X} is outside the private-use area the Nerd Font fills", + entry["name"], + glyph as u32 + ); + } + } + #[test] fn embedded_categories_has_expected_names() { let sections = parse_sections(EMBEDDED_CATEGORIES).expect("embedded parses"); diff --git a/src/state.rs b/src/state.rs index 9849a19..9652f22 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,8 @@ -use iced::font::Weight; -use iced::Font; use std::collections::HashSet; use std::time::{Duration, Instant}; +use colony_ui::preferences::StandardPreferences; + use crate::github::ColonyRepo; use crate::oauth::OAuthSession; use crate::scan::{self, Application}; @@ -25,29 +25,6 @@ pub fn with_alpha(color: iced::Color, alpha: f32) -> iced::Color { } } -// --- Font constants --- - -pub const APP_FONT_NAME: &str = "JetBrainsMono Nerd Font"; -pub const APP_FONT_BYTES: [&[u8]; 3] = [ - include_bytes!("ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Regular.ttf"), - include_bytes!("ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Medium.ttf"), - include_bytes!("ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Bold.ttf"), -]; - -pub const DYSLEXIA_FONT_NAME: &str = "OpenDyslexic"; -pub const DYSLEXIA_FONT_BYTES: &[u8] = - include_bytes!("ui/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.otf"); - -pub const FA_FONT_NAME: &str = "Font Awesome 6 Free"; -pub const FA_FONT_BYTES: [&[u8]; 2] = [ - include_bytes!("ui/assets/fonts/FontAwesome/fa-solid-900.ttf"), - include_bytes!("ui/assets/fonts/FontAwesome/fa-regular-400.ttf"), -]; - -pub fn default_font() -> Font { - Font::with_name(APP_FONT_NAME) -} - // --- Notification system --- #[derive(Debug, Clone)] @@ -171,7 +148,6 @@ pub struct App { /// 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, - pub font: Font, // GitHub / OAuth pub github_state: GitHubState, pub show_github_menu: bool, @@ -217,25 +193,17 @@ pub struct App { // Settings 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 auto_accent: bool, + /// Theme, accent, text sizes, motion: the Appearance and Accessibility + /// preferences shared with every Colony program. `update` applies a change + /// to colony-ui's globals, which is what every view reads. + pub standard: StandardPreferences, // General preferences pub restore_session: bool, 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 animations: bool, // Accessibility - pub high_contrast: bool, - pub text_size_a11y: String, // "small", "default", "large", "xlarge" - pub reduce_motion: bool, pub keyboard_nav: bool, - pub dyslexia_font: bool, // Storage pub scan_on_startup: bool, // Async operation tracking @@ -248,7 +216,8 @@ pub struct App { /// is on screen is routine and stays in the status line; a click the user /// just made deserves a toast, otherwise the button looks broken. pub repos_refresh_manual: bool, - // Settings section state persistence + /// The Preferences sections the user opened. Lives while the page is + /// open; closing the page folds them all again. pub settings_expanded_sections: HashSet, // Detail tabs pub detail_tab: DetailTab, @@ -306,9 +275,6 @@ pub struct App { pub launcher_system_managed: bool, // 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 sidebar_indicator_start: Option, // when the animation started (None = idle) } impl App { @@ -425,70 +391,10 @@ impl App { self.favorites.iter().any(|f| f == name) } - pub fn app_font(&self) -> Font { - if self.dyslexia_font { - Font::with_name(DYSLEXIA_FONT_NAME) - } else { - self.font - } - } - - pub fn app_font_with_weight(&self, weight: Weight) -> Font { - if self.dyslexia_font { - Font { - weight, - ..Font::with_name(DYSLEXIA_FONT_NAME) - } - } else { - Font { - weight, - ..self.font - } - } - } - - /// Compute the font scale factor from combined font_size + text_size_a11y settings. - pub fn font_scale(&self) -> f32 { - let base = match self.font_size.as_str() { - "small" => 0.85, - "large" => 1.2, - _ => 1.0, // "default" - }; - let a11y = match self.text_size_a11y.as_str() { - "small" => 0.85, - "large" => 1.2, - "xlarge" => 1.4, - _ => 1.0, // "default" - }; - base * a11y - } - - /// Return a font size scaled by the user's font preferences. - pub fn sz(&self, base: u16) -> f32 { - (base as f32 * self.font_scale()).round() - } - - /// Duration of the sidebar slide animation. - pub const SIDEBAR_ANIM_MS: f32 = 200.0; - - /// Compute the current sidebar indicator Y position (time-based easing). - pub fn sidebar_indicator_pos(&self) -> f32 { - match self.sidebar_indicator_start { - Some(start) => { - 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 - } - None => self.sidebar_indicator_target, - } - } - /// Returns true if any animation is currently in-flight. /// Used to conditionally activate the 60fps tick subscription. pub fn has_active_animations(&self) -> bool { - if !self.animations || self.reduce_motion { + if !colony_ui::effects_enabled() { return false; } // Notification fade-in or fade-out in progress @@ -503,10 +409,6 @@ impl App { return true; } } - // Sidebar indicator animating - if self.sidebar_indicator_start.is_some() { - return true; - } false } } @@ -540,9 +442,10 @@ pub fn capitalize_platform(p: &str) -> String { #[cfg(test)] impl App { - /// A hermetic App for update-loop tests: no disk reads, no network, no - /// animation/auto-dismiss tasks (reduce_motion on, animations off, - /// auto_check_updates off). Tests that exercise disk-writing handlers must + /// A hermetic App for update-loop tests: no disk reads, no network, and + /// preferences that ask for no animation (reduce_motion on, animations + /// off, auto_check_updates off). It does not apply them: colony-ui's + /// globals are process-wide and tests run in parallel. Tests that exercise disk-writing handlers must /// additionally redirect XDG dirs - see `update::tests::with_temp_dirs`. pub(crate) fn new_for_test() -> Self { Self { @@ -552,7 +455,6 @@ impl App { selected_section: 0, status_message: String::new(), active_colony_repo: None, - font: default_font(), github_state: GitHubState::Disconnected, show_github_menu: false, colony_repo_list: Vec::new(), @@ -572,21 +474,16 @@ impl App { tutorial_bounds: Default::default(), show_settings: false, settings_category: 0, - selected_theme: "gruvbox".into(), - selected_variant: "dark".into(), - selected_accent: "blue".into(), - auto_accent: false, + standard: StandardPreferences { + animations: false, + reduce_motion: true, + ..Default::default() + }, restore_session: true, default_view: "all".into(), language: "en".into(), auto_check_updates: false, - font_size: "default".into(), - animations: false, - high_contrast: false, - text_size_a11y: "default".into(), - reduce_motion: true, keyboard_nav: true, - dyslexia_font: false, scan_on_startup: false, is_scanning: false, is_downloading: false, @@ -612,9 +509,6 @@ impl App { launcher_update_staged: None, launcher_system_managed: false, progress_display: 0.0, - sidebar_indicator_from: 0.0, - sidebar_indicator_target: 0.0, - sidebar_indicator_start: None, } } } diff --git a/src/ui/app_grid.rs b/src/ui/app_grid.rs index 78661e5..b691079 100644 --- a/src/ui/app_grid.rs +++ b/src/ui/app_grid.rs @@ -1,4 +1,4 @@ -use iced::font::Weight; +use colony_ui::{fonts, sz, ui_font, ui_font_medium}; use iced::widget::{ button, column, container, row, scrollable, stack, text, text_input, Column, Row, }; @@ -8,6 +8,7 @@ use crate::github::ColonyRepo; use crate::message::Message; use crate::scan::{AppCategory, Application}; use crate::state::App; +use crate::ui::glyph_label; use crate::ui::theme::{self, Palette}; /// Filled Material-Design hexagon glyph from the embedded Nerd Font — the @@ -105,8 +106,8 @@ impl App { use iced::alignment::{Horizontal, Vertical}; let fg = theme::contrast_on(tint); let hexagon = text(HEX_FILLED) - .size(self.sz(50)) - .font(self.app_font()) + .size(sz(50)) + .font(ui_font()) .color(tint) .width(Fill) .height(Fill) @@ -116,8 +117,8 @@ impl App { // Font carries the FontAwesome glyphs too), so the two share identical // metrics and centre on the exact same point — no manual nudging. let cat = text(glyph) - .size(self.sz(15)) - .font(self.app_font()) + .size(sz(15)) + .font(ui_font()) .color(fg) .width(Fill) .height(Fill) @@ -160,8 +161,8 @@ impl App { fn chip<'a>(&self, label: String) -> Element<'a, Message> { container( text(label) - .size(self.sz(11)) - .font(self.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()), ) .padding([2, 7]) @@ -203,22 +204,18 @@ impl App { Palette::TEXT_DIMMEST(), ) }; - return text(label) - .size(self.sz(12)) - .font(self.app_font()) - .color(color) - .into(); + return text(label).size(sz(12)).font(ui_font()).color(color).into(); } let version = cached_version; if let Some(new_tag) = self.available_updates.get(&repo.name) { let head = row![ text(HEX_FILLED) - .size(self.sz(11)) - .font(self.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::WARNING()), text(crate::i18n::t("status_update")) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::WARNING()), ] .spacing(6) @@ -228,8 +225,8 @@ impl App { version.as_deref().unwrap_or("?"), new_tag )) - .size(self.sz(11)) - .font(self.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()); column![head, delta] .spacing(3) @@ -238,12 +235,12 @@ impl App { } else { let head = row![ text(HEX_FILLED) - .size(self.sz(11)) - .font(self.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::SUCCESS()), text(crate::i18n::t("status_installed")) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::SUCCESS()), ] .spacing(6) @@ -252,8 +249,8 @@ impl App { if let Some(v) = version { col = col.push( text(v) - .size(self.sz(11)) - .font(self.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()), ); } @@ -296,7 +293,7 @@ impl App { .on_press(on_press) .padding(0) .width(Fill) - .height(Length::Fixed(self.sz(96))) + .height(Length::Fixed(sz(96))) .style(move |_theme, status| { let bg = match status { _ if selected => Palette::BG_SELECTED(), @@ -329,7 +326,7 @@ impl App { let search_input = text_input(&crate::i18n::t("search_placeholder"), &self.search_query) .on_input(Message::SearchChanged) .padding(12) - .size(self.sz(16)) + .size(sz(16)) .width(Fill); let search = container(search_input) .id(crate::ui::tutorial::ID_SEARCH) @@ -358,16 +355,16 @@ impl App { // Show spinner indicator for async operations let spinner = if self.is_scanning || self.is_checking_updates || self.is_fetching_repos { text("\u{f110} ") - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(fonts::glyph_font()) .color(Palette::ACCENT()) } else { - text("").size(self.sz(12)) + text("").size(sz(12)) }; let status = text(status_text) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()); let mut header = row![search, spinner, status] @@ -381,26 +378,22 @@ impl App { "update_all", &[("count", &self.available_updates.len().to_string())], ); - let btn = button( - text(format!("\u{f021} {label}")) - .size(self.sz(13)) - .font(self.app_font()), - ) - .padding([8, 14]) - .style(|_theme, status| { - let (bg, text_color) = theme::button_colors( - status, - Palette::BG_SELECTED(), - Palette::BG_CARD_HOVER(), - Palette::ACCENT(), - ); - button::Style { - background: Some(bg.into()), - text_color, - border: iced::Border::default().rounded(6), - ..Default::default() - } - }); + let btn = button(glyph_label("\u{f021}", label, 13, ui_font())) + .padding([8, 14]) + .style(|_theme, status| { + let (bg, text_color) = theme::button_colors( + status, + Palette::BG_SELECTED(), + Palette::BG_CARD_HOVER(), + Palette::ACCENT(), + ); + button::Style { + background: Some(bg.into()), + text_color, + border: iced::Border::default().rounded(6), + ..Default::default() + } + }); let btn = if self.is_downloading { btn } else { @@ -464,13 +457,13 @@ impl App { }; let mut empty = column![ text("\u{f002}") - .size(self.sz(32)) - .font(self.app_font()) + .size(sz(32)) + .font(fonts::glyph_font()) .color(Palette::TEXT_DIMMEST()), container(text("")).height(12), text(empty_msg) - .size(self.sz(16)) - .font(self.app_font()) + .size(sz(16)) + .font(ui_font()) .color(Palette::TEXT_PLACEHOLDER()), ] .align_x(iced::Alignment::Center); @@ -483,8 +476,8 @@ impl App { empty = empty.push(container(text("")).height(16)).push( button( text(crate::i18n::t("github_refresh")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .on_press_maybe( (!self.is_fetching_repos).then_some(Message::GitHubRefreshRepos), @@ -531,7 +524,7 @@ impl App { if repos.is_empty() { return container( text(crate::i18n::t("no_apps_found")) - .size(self.sz(16)) + .size(sz(16)) .color(Palette::TEXT_PLACEHOLDER()), ) .width(Fill) @@ -559,8 +552,8 @@ impl App { let tile = self.icon_tile(&repo.name, tint, category.glyph()); let name = text(repo.display_name()) - .size(self.sz(15)) - .font(self.app_font_with_weight(Weight::Medium)) + .size(sz(15)) + .font(ui_font_medium()) .color(Palette::TEXT_PRIMARY()); // One-line summary so every card keeps a uniform, aligned height. @@ -570,12 +563,12 @@ impl App { } else { container( text(summary) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()), ) .width(Fill) - .max_height(self.sz(12) * 1.6) + .max_height(sz(12) * 1.6) .clip(true) .into() }; @@ -641,7 +634,7 @@ impl App { .height(Fill); container(inner) .width(Fill) - .height(Length::Fixed(self.sz(96))) + .height(Length::Fixed(sz(96))) .style(move |_theme| container::Style { background: Some( if selected { @@ -670,8 +663,8 @@ impl App { let tile = self.hex_tile(tint, app.category.glyph()); let name = text(app.name.clone()) - .size(self.sz(15)) - .font(self.app_font_with_weight(Weight::Medium)) + .size(sz(15)) + .font(ui_font_medium()) .color(Palette::TEXT_PRIMARY()); let chips = row![self.chip(format!("{:?}", app.category))].spacing(7); @@ -680,21 +673,21 @@ impl App { // Favorites were a dead feature for local apps: the section filtered // on them but nothing could ever star one. let is_fav = self.is_favorite(&app.name); - let star_font = iced::Font { - weight: if is_fav { - Weight::Black - } else { - Weight::Normal - }, - ..iced::Font::with_name(crate::state::FA_FONT_NAME) + let star_font = if is_fav { + fonts::icon_font() + } else { + fonts::icon_font_regular() }; - let star_btn = button(text("\u{f005}").size(self.sz(14)).font(star_font).color( - if is_fav { - Palette::WARNING() - } else { - Palette::TEXT_DIM() - }, - )) + let star_btn = button( + text("\u{f005}") + .size(sz(14)) + .font(star_font) + .color(if is_fav { + Palette::WARNING() + } else { + Palette::TEXT_DIM() + }), + ) .on_press(Message::ToggleFavorite(app.name.clone())) .padding([8, 10]) .style(|_theme, status| { @@ -712,11 +705,12 @@ impl App { // Launching is an explicit button, not the whole row: an accidental // click on a full-width card used to start the program immediately. - let launch_btn = button( - text(format!("\u{f04b} {}", crate::i18n::t("launch_action"))) - .size(self.sz(13)) - .font(self.app_font()), - ) + let launch_btn = button(glyph_label( + "\u{f04b}", + crate::i18n::t("launch_action"), + 13, + ui_font(), + )) .on_press(Message::LaunchApp(app.exec.clone())) .padding([8, 14]) .style(move |_theme, status| { diff --git a/src/ui/assets/fonts/FontAwesome/OFL.txt b/src/ui/assets/fonts/FontAwesome/OFL.txt deleted file mode 100644 index 0167d1e..0000000 --- a/src/ui/assets/fonts/FontAwesome/OFL.txt +++ /dev/null @@ -1,96 +0,0 @@ -Font Awesome Free 6.7.2 (fa-regular-400.ttf, fa-solid-900.ttf) - -Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com) -with Reserved Font Name: "Font Awesome". - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/ui/assets/fonts/FontAwesome/fa-regular-400.ttf b/src/ui/assets/fonts/FontAwesome/fa-regular-400.ttf deleted file mode 100644 index d75d4c6..0000000 Binary files a/src/ui/assets/fonts/FontAwesome/fa-regular-400.ttf and /dev/null differ diff --git a/src/ui/assets/fonts/FontAwesome/fa-solid-900.ttf b/src/ui/assets/fonts/FontAwesome/fa-solid-900.ttf deleted file mode 100644 index a041418..0000000 Binary files a/src/ui/assets/fonts/FontAwesome/fa-solid-900.ttf and /dev/null differ diff --git a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Bold.ttf b/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Bold.ttf deleted file mode 100644 index de2d3b3..0000000 Binary files a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Bold.ttf and /dev/null differ diff --git a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Medium.ttf b/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Medium.ttf deleted file mode 100644 index 79a4b1f..0000000 Binary files a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Medium.ttf and /dev/null differ diff --git a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Regular.ttf b/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Regular.ttf deleted file mode 100644 index 235a07a..0000000 Binary files a/src/ui/assets/fonts/JetBrainsMonoNerdFont/JetBrainsMonoNerdFont-Regular.ttf and /dev/null differ diff --git a/src/ui/assets/fonts/JetBrainsMonoNerdFont/OFL.txt b/src/ui/assets/fonts/JetBrainsMonoNerdFont/OFL.txt deleted file mode 100644 index 8bee414..0000000 --- a/src/ui/assets/fonts/JetBrainsMonoNerdFont/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/ui/assets/fonts/JetBrainsMonoNerdFont/README.md b/src/ui/assets/fonts/JetBrainsMonoNerdFont/README.md deleted file mode 100644 index 99c90d5..0000000 --- a/src/ui/assets/fonts/JetBrainsMonoNerdFont/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Nerd Fonts - -Note: Colony's UI is implemented with Iced; this font README remains upstream documentation. - -This is an archived font from the Nerd Fonts release v3.4.0. - -For more information see: -* https://github.com/ryanoasis/nerd-fonts/ -* https://github.com/ryanoasis/nerd-fonts/releases/latest/ - -# JetBrains Mono - -JetBrains Mono: a typeface made for developers - -For more information have a look at the upstream website: https://github.com/JetBrains/JetBrainsMono - -Version: 2.304 - -## Which font? - -### TL;DR - -* Pick your font family: - * If you are limited to monospaced fonts (because of your terminal, etc) then pick a font with `Nerd Font Mono` (or `NFM`). - * If you want to have bigger icons (usually around 1.5 normal letters wide) pick a font without `Mono` i.e. `Nerd Font` (or `NF`). Most terminals support this, but ymmv. - * If you work in a proportional context (GUI elements or edit a presentation etc) pick a font with `Nerd Font Propo` (or `NFP`). - -### Ligatures - -Ligatures are generally preserved in the patched fonts. -Nerd Fonts `v2.0.0` had no ligatures in the `Nerd Font Mono` fonts, this has been dropped with `v2.1.0`. -If you have a ligature-aware terminal and don't want ligatures you can (usually) disable them in the terminal settings. - -### Explanation - -Once you narrow down your font choice of family (`Droid Sans`, `Inconsolata`, etc) and style (`bold`, `italic`, etc) you have 2 main choices: - -#### `Option 1: Download already patched font` - - * For a stable version download a font package from the [release page](https://github.com/ryanoasis/nerd-fonts/releases) - * Or download the development version from the folders here - -#### `Option 2: Patch your own font` - - * Patch your own variations with the various options provided by the font patcher (i.e. not include all symbols for smaller font size) - -For more information see: [The FAQ](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting#which-font) - -[SIL-RFN]:http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web_fonts_and_RFNs#14cbfd4a diff --git a/src/ui/assets/fonts/OpenDyslexic/OFL.txt b/src/ui/assets/fonts/OpenDyslexic/OFL.txt deleted file mode 100644 index 0a1d034..0000000 --- a/src/ui/assets/fonts/OpenDyslexic/OFL.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es), -with Reserved Font Name OpenDyslexic. -Copyright (c) 12/2012 - 2019 -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/ui/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.otf b/src/ui/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.otf deleted file mode 100644 index f0f51d6..0000000 Binary files a/src/ui/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.otf and /dev/null differ diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 370e254..80289da 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -1,10 +1,11 @@ -use iced::font::Weight; +use colony_ui::{fonts, sz, ui_font, ui_font_bold, ui_font_medium}; use iced::widget::{button, column, container, markdown, rich_text, row, scrollable, text, Row}; use iced::{Element, Fill, Length}; use crate::github::{self, ColonyRepo}; use crate::message::Message; use crate::state::{capitalize_platform, App, DetailTab}; +use crate::ui::glyph_label; use crate::ui::theme::Palette; /// Custom markdown viewer: same defaults as iced, minus the internal @@ -48,17 +49,13 @@ impl<'a> markdown::Viewer<'a, Message> for ColonyMdViewer { impl App { pub(crate) fn view_colony_detail<'a>(&'a self, repo: &'a ColonyRepo) -> Element<'a, Message> { - let back_button = button( - text(crate::i18n::t("back")) - .size(self.sz(13)) - .font(self.app_font()), - ) - .on_press(Message::ColonyRepoBack) - .padding([8, 16]); + let back_button = button(text(crate::i18n::t("back")).size(sz(13)).font(ui_font())) + .on_press(Message::ColonyRepoBack) + .padding([8, 16]); let title = text(repo.display_name()) - .size(self.sz(24)) - .font(self.app_font_with_weight(Weight::Bold)) + .size(sz(24)) + .font(ui_font_bold()) .color(Palette::TEXT_PRIMARY()); // Tab bar @@ -71,7 +68,7 @@ impl App { for (tab_val, label) in tabs { let is_selected = self.detail_tab == tab_val; tab_buttons.push( - button(text(label).size(self.sz(13)).font(self.app_font())) + button(text(label).size(sz(13)).font(ui_font())) .on_press(Message::DetailTabSelected(tab_val)) .padding([6, 14]) .style(move |_theme, status| { @@ -106,13 +103,13 @@ impl App { // Parsed Markdown comes from self.detail_blocks, kept up-to-date by the // update-side helper. Placeholder text falls back to plain muted text. let md_settings = markdown::Settings::with_text_size( - self.sz(14), + sz(14), markdown::Style::from_palette(iced::Theme::Dark.palette()), ); let description: Element<'_, Message> = if is_placeholder { text(crate::i18n::t("tab_not_available")) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::TEXT_MUTED()) .into() } else { @@ -123,8 +120,8 @@ impl App { "language_label", &[("lang", &repo.language)], )) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_MUTED()); // Favorite button — FontAwesome 6 Free, solid/regular variant for filled/empty. @@ -135,17 +132,14 @@ impl App { } else { Palette::TEXT_DIM() }; - let fav_font = iced::Font { - weight: if is_fav { - Weight::Black - } else { - Weight::Normal - }, - ..iced::Font::with_name(crate::state::FA_FONT_NAME) + let fav_font = if is_fav { + fonts::icon_font() + } else { + fonts::icon_font_regular() }; let fav_btn = button( text("\u{f005}") - .size(self.sz(18)) + .size(sz(18)) .font(fav_font) .color(fav_color), ) @@ -171,8 +165,8 @@ impl App { .iter() .map(|p| { text(capitalize_platform(p)) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIM()) .into() }) @@ -191,8 +185,8 @@ impl App { "installed_version", &[("version", &installed)], )) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIM()) .into(), ); @@ -218,8 +212,8 @@ impl App { "offered_version", &[("version", offered_tag)], )) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIM()) .into(), ); @@ -227,8 +221,8 @@ impl App { if let Some(size) = facts.and_then(|f| f.size) { footer_items.push( text(crate::state::human_bytes(size)) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIM()) .into(), ); @@ -238,8 +232,8 @@ impl App { let day = date.split('T').next().unwrap_or(date); footer_items.push( text(day.to_string()) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIM()) .into(), ); @@ -261,37 +255,31 @@ impl App { let action_row = if let Some(app_path) = installed_path { // App is installed — show Launch + Update buttons, and uninstall trash button - let launch_label = format!( - "\u{f04b} {}", - crate::i18n::t_fmt("launch", &[("name", &repo.manifest.name)]) - ); - let launch_btn = button( - text(launch_label) - .size(self.sz(14)) - .font(self.app_font_with_weight(Weight::Medium)), - ) - .on_press_maybe(if self.is_downloading { - None - } else { - Some(Message::LaunchColonyApp(app_path)) - }) - .padding([12, 24]) - .style(move |_theme, status| { - crate::ui::theme::action_button_style( - status, - Palette::BTN_SUCCESS(), - Palette::BTN_SUCCESS_HOVER(), - Palette::BTN_SUCCESS_PRESSED(), - ) - }); + let launch_label = crate::i18n::t_fmt("launch", &[("name", &repo.manifest.name)]); + let launch_btn = button(glyph_label("\u{f04b}", launch_label, 14, ui_font_medium())) + .on_press_maybe(if self.is_downloading { + None + } else { + Some(Message::LaunchColonyApp(app_path)) + }) + .padding([12, 24]) + .style(move |_theme, status| { + crate::ui::theme::action_button_style( + status, + Palette::BTN_SUCCESS(), + Palette::BTN_SUCCESS_HOVER(), + Palette::BTN_SUCCESS_PRESSED(), + ) + }); let repo_name = repo.name.clone(); let platform_key = current_platform.to_string(); - let update_btn = button( - text(format!("\u{f021} {}", crate::i18n::t("update"))) - .size(self.sz(13)) - .font(self.app_font()), - ) + let update_btn = button(glyph_label( + "\u{f021}", + crate::i18n::t("update"), + 13, + ui_font(), + )) .on_press_maybe(if self.is_downloading { None } else { @@ -311,8 +299,8 @@ impl App { let uninstall_repo_name = repo.name.clone(); let uninstall_btn = button( text("\u{f1f8}") - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(fonts::glyph_font()) .center(), ) // The only action here that was not gated on a running download. @@ -352,11 +340,12 @@ impl App { let repo_name = repo.name.clone(); let platform_key = current_platform.to_string(); let is_dl = self.is_downloading; - let dl_btn = button( - text(format!("\u{f019} {}", crate::i18n::t("download"))) - .size(self.sz(14)) - .font(self.app_font_with_weight(Weight::Medium)), - ) + let dl_btn = button(glyph_label( + "\u{f019}", + crate::i18n::t("download"), + 14, + ui_font_medium(), + )) .on_press_maybe(if is_dl { None } else { @@ -391,8 +380,8 @@ impl App { "no_release_platform" }; let no_release = text(crate::i18n::t(key)) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_MUTED()); let spacer2: Element<'_, Message> = container(text("")).width(Fill).into(); Row::new().push(spacer2).push(no_release) @@ -402,11 +391,12 @@ impl App { // "View on GitHub": html_url is fetched with the catalog but was never // surfaced anywhere in the UI. let github_url = repo.html_url.clone(); - let github_btn = button( - text(format!("\u{f09b} {}", crate::i18n::t("view_on_github"))) - .size(self.sz(13)) - .font(self.app_font()), - ) + let github_btn = button(glyph_label( + "\u{f09b}", + crate::i18n::t("view_on_github"), + 13, + ui_font(), + )) .on_press(Message::OpenUrl(github_url)) .padding([8, 12]) .style(|_theme, status| { @@ -465,18 +455,18 @@ impl App { match self.release_notes.get(&repo.name) { Some((tag, blocks)) if tag == new_tag => { let md_settings = markdown::Settings::with_text_size( - self.sz(13), + sz(13), markdown::Style::from_palette(iced::Theme::Dark.palette()), ); let notes_title = text(crate::i18n::t_fmt("whats_new", &[("version", new_tag)])) - .size(self.sz(14)) - .font(self.app_font_with_weight(Weight::Medium)) + .size(sz(14)) + .font(ui_font_medium()) .color(Palette::ACCENT()); let notes_body: Element<'_, Message> = if blocks.is_empty() { text(crate::i18n::t("tab_not_available")) - .size(self.sz(13)) - .font(self.app_font()) + .size(sz(13)) + .font(ui_font()) .color(Palette::TEXT_MUTED()) .into() } else { @@ -494,18 +484,9 @@ impl App { } _ => { let fetching = self.fetching_notes.contains(&repo.name); - let label = if fetching { - format!( - "\u{f110} {}", - crate::i18n::t_fmt("whats_new", &[("version", new_tag)]) - ) - } else { - format!( - "\u{f05a} {}", - crate::i18n::t_fmt("whats_new", &[("version", new_tag)]) - ) - }; - let btn = button(text(label).size(self.sz(13)).font(self.app_font())) + let glyph = if fetching { "\u{f110}" } else { "\u{f05a}" }; + let label = crate::i18n::t_fmt("whats_new", &[("version", new_tag)]); + let btn = button(glyph_label(glyph, label, 13, ui_font())) .padding([8, 14]) .style(|_theme, status| { let bg = match status { diff --git a/src/ui/github_panel.rs b/src/ui/github_panel.rs index 8454a78..3ee2dbd 100644 --- a/src/ui/github_panel.rs +++ b/src/ui/github_panel.rs @@ -1,4 +1,4 @@ -use iced::font::Weight; +use colony_ui::{fonts, sz, ui_font, ui_font_bold, ui_font_medium}; use iced::widget::{button, column, container, row, scrollable, text, Column}; use iced::{Element, Fill, Length}; @@ -10,22 +10,22 @@ use crate::ui::theme::Palette; impl App { pub(crate) fn view_github_panel(&self) -> Element<'_, Message> { let header_text = text("GitHub") - .size(self.sz(24)) - .font(self.app_font_with_weight(Weight::Bold)) + .size(sz(24)) + .font(ui_font_bold()) .color(Palette::TEXT_PRIMARY()); let content: Element<'_, Message> = match &self.github_state { GitHubState::Disconnected => { let desc = text(crate::i18n::t("github_connect_desc")) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()); let login_btn_content = row![ - text("\u{f09b}").size(self.sz(18)).font(self.app_font()), + text("\u{f09b}").size(sz(18)).font(fonts::glyph_font()), text(crate::i18n::t("github_login")) - .size(self.sz(14)) - .font(self.app_font()), + .size(sz(14)) + .font(ui_font()), ] .spacing(10) .align_y(iced::Alignment::Center); @@ -52,8 +52,8 @@ impl App { }); let info = text(crate::i18n::t("github_public_api")) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_MUTED()); // Browsing the catalog is anonymous, so a user whose boot fetch @@ -63,8 +63,8 @@ impl App { // already runs; no token, no sign-in required. let retry_btn = button( text(crate::i18n::t("github_refresh")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .on_press_maybe((!self.is_fetching_repos).then_some(Message::GitHubRefreshRepos)) .padding([8, 16]) @@ -95,17 +95,17 @@ impl App { } => match user_code { Some(code) => { let spinner_label = text("\u{f110} ") - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(fonts::glyph_font()) .color(Palette::ACCENT()); let label = text(crate::i18n::t("github_enter_code")) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::TEXT_MUTED()); let code_btn = button( text(code.as_str()) - .size(self.sz(28)) - .font(self.app_font()) + .size(sz(28)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()), ) .on_press(Message::CopyToClipboard(code.clone())) @@ -123,8 +123,8 @@ impl App { } }); let hint = text(crate::i18n::t("github_copy_hint")) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_DIMMEST()); // The browser open is best-effort; if it failed, this line @@ -132,8 +132,8 @@ impl App { let where_to: Element<'_, Message> = match verification_uri { Some(uri) => button( text(uri.as_str()) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::ACCENT()), ) .on_press(Message::OpenUrl(uri.clone())) @@ -153,12 +153,12 @@ impl App { } None => row![ text("\u{f110}") - .size(self.sz(16)) - .font(self.app_font()) + .size(sz(16)) + .font(fonts::glyph_font()) .color(Palette::ACCENT()), text(crate::i18n::t("github_connecting")) - .size(self.sz(16)) - .font(self.app_font()) + .size(sz(16)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()), ] .spacing(8) @@ -179,16 +179,16 @@ impl App { let user_label: Element<'_, Message> = if self.is_fetching_repos { row![ text("\u{f110}") - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(fonts::glyph_font()) .color(Palette::ACCENT()), text(format!( "{} ({})", connected_text, crate::i18n::t("syncing_repos") )) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::SUCCESS()), ] .spacing(8) @@ -196,8 +196,8 @@ impl App { .into() } else { text(connected_text) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::SUCCESS()) .into() }; @@ -206,8 +206,8 @@ impl App { "github_repos_detected", &[("count", &repos.len().to_string())], )) - .size(self.sz(16)) - .font(self.app_font_with_weight(Weight::Medium)) + .size(sz(16)) + .font(ui_font_medium()) .color(Palette::TEXT_PRIMARY()); let mut repo_list_items: Vec> = Vec::new(); @@ -217,8 +217,8 @@ 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()) + .size(sz(13)) + .font(ui_font()) .color(Palette::TEXT_DIMMER())] } else { Column::with_children(repo_list_items).spacing(8) @@ -226,8 +226,8 @@ impl App { let refresh_btn_base = button( text(crate::i18n::t("github_refresh")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .padding([8, 16]); let refresh_btn = if self.is_fetching_repos { @@ -252,8 +252,8 @@ impl App { let logout_btn = button( text(crate::i18n::t("github_logout")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .on_press(Message::GitHubLogout) .padding([8, 16]) @@ -286,14 +286,14 @@ impl App { } GitHubState::Error(e) => { let err = text(crate::i18n::t_fmt("github_error", &[("error", e.as_str())])) - .size(self.sz(14)) - .font(self.app_font()) + .size(sz(14)) + .font(ui_font()) .color(Palette::ERROR()); let retry_btn = button( text(crate::i18n::t("github_retry")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .on_press(Message::GitHubLogin) .padding([8, 16]); @@ -303,8 +303,8 @@ impl App { // it needs no account at all. let refresh_btn = button( text(crate::i18n::t("github_refresh")) - .size(self.sz(13)) - .font(self.app_font()), + .size(sz(13)) + .font(ui_font()), ) .on_press_maybe((!self.is_fetching_repos).then_some(Message::GitHubRefreshRepos)) .padding([8, 16]); @@ -337,8 +337,8 @@ impl App { pub(crate) fn view_repo_card<'a>(&'a self, repo: &'a ColonyRepo) -> Element<'a, Message> { let name = text(&repo.name) - .size(self.sz(15)) - .font(self.app_font_with_weight(Weight::Medium)) + .size(sz(15)) + .font(ui_font_medium()) .color(Palette::TEXT_PRIMARY()); // Clickable: opens the repo's detail page (the panel used to render an diff --git a/src/ui/markdown_blocks.rs b/src/ui/markdown_blocks.rs index 087c5d3..4ece29f 100644 --- a/src/ui/markdown_blocks.rs +++ b/src/ui/markdown_blocks.rs @@ -12,7 +12,10 @@ //! //! Everything else flows through iced's `markdown::view_with` using a //! caller-supplied `Viewer`. -use iced::font::Weight; +use std::collections::HashMap; +use std::sync::OnceLock; + +use colony_ui::{contrast_on, sz, ui_font, ui_font_bold}; use iced::widget::{column, container, markdown, row, table as tbl, text, Row}; use iced::{alignment, Color, Element, Length}; @@ -257,37 +260,43 @@ fn parse_shields_url(url: &str) -> Option<(String, String, [u8; 3])> { Some((decode(label_raw), decode(value_raw), color)) } -/// Approximate shields.io color names → RGB. Unknown names fall back to -/// a neutral grey. -fn badge_color_rgb(name: &str) -> [u8; 3] { - // Accept hex as `#rrggbb` or `rrggbb`. - if let Some(h) = name.strip_prefix('#').or(Some(name)) { - if h.len() == 6 { - if let (Ok(r), Ok(g), Ok(b)) = ( - u8::from_str_radix(&h[0..2], 16), - u8::from_str_radix(&h[2..4], 16), - u8::from_str_radix(&h[4..6], 16), - ) { - return [r, g, b]; - } - } - } - match name.to_ascii_lowercase().as_str() { - "brightgreen" => [0x4c, 0xc1, 0x40], - "green" => [0x97, 0xca, 0x00], - "yellowgreen" => [0xa4, 0xa6, 0x1d], - "yellow" => [0xdf, 0xb3, 0x17], - "orange" => [0xfe, 0x7d, 0x37], - "red" => [0xe0, 0x5d, 0x44], - "blue" => [0x00, 0x7e, 0xc6], - "lightgrey" | "lightgray" => [0x9f, 0x9f, 0x9f], - "grey" | "gray" => [0x55, 0x55, 0x55], - "purple" => [0x94, 0x3a, 0xa0], - "pink" => [0xe7, 0x30, 0xa8], - "black" => [0x1f, 0x1f, 0x1f], - "white" => [0xee, 0xee, 0xee], - _ => [0x80, 0x80, 0x80], +/// shields.io's named colours, approximated: data, so it lives with the other +/// data in `config/`. +const SHIELDS_COLORS: &str = include_str!("../../config/shields-colors.json"); + +/// What a badge with a colour this table does not know is drawn in. +const UNKNOWN_BADGE_COLOR: [u8; 3] = [0x80, 0x80, 0x80]; + +fn named_badge_colors() -> &'static HashMap { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + serde_json::from_str(SHIELDS_COLORS) + .expect("config/shields-colors.json is embedded and checked by a test") + }) +} + +/// `rrggbb` or `#rrggbb`, nothing else. +fn parse_hex(raw: &str) -> Option<[u8; 3]> { + let h = raw.strip_prefix('#').unwrap_or(raw); + // Checked byte by byte before slicing: a README is remote data, and slicing + // a six-byte string with a multi-byte character in it panics. + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; } + let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); + Some([byte(0)?, byte(2)?, byte(4)?]) +} + +/// A badge colour as shields.io takes it: hex, or one of its names. Unknown +/// names fall back to a neutral grey. +fn badge_color_rgb(name: &str) -> [u8; 3] { + parse_hex(name) + .or_else(|| { + named_badge_colors() + .get(&name.to_ascii_lowercase()) + .and_then(|hex| parse_hex(hex)) + }) + .unwrap_or(UNKNOWN_BADGE_COLOR) } // --- Rendering --- @@ -319,9 +328,16 @@ fn view_badges(badges: &[Badge]) -> Element<'_, Message> { fn badge_pill(b: &Badge) -> Element<'_, Message> { let color = Color::from_rgb8(b.color_hex[0], b.color_hex[1], b.color_hex[2]); let label_text = text(b.label.clone()) - .size(12) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_PRIMARY()); - let value_text = text(b.value.clone()).size(12).color(Color::WHITE); + // The badge's own colour comes from the README, so its text has to be + // whichever of near-black and near-white reads on it: white vanished on + // the light ones (yellow, brightgreen). + let value_text = text(b.value.clone()) + .size(sz(12)) + .font(ui_font()) + .color(contrast_on(color)); let label_box = container(label_text) .padding([3, 8]) @@ -378,15 +394,16 @@ fn view_table(data: &TableData) -> Element<'_, Message> { let h_idx = i; tbl::column( text(header.clone()) - .size(13) - .font(iced::Font { - weight: Weight::Bold, - ..iced::Font::default() - }) + .size(sz(13)) + .font(ui_font_bold()) .color(Palette::TEXT_PRIMARY()), move |row: Vec| -> Element<'_, Message> { let cell = row.get(h_idx).cloned().unwrap_or_default(); - text(cell).size(13).color(Palette::TEXT_SECONDARY()).into() + text(cell) + .size(sz(13)) + .font(ui_font()) + .color(Palette::TEXT_SECONDARY()) + .into() }, ) .align_x(alignment::Horizontal::Left) @@ -450,7 +467,23 @@ mod tests { assert_eq!(badge_color_rgb("blue"), [0x00, 0x7e, 0xc6]); assert_eq!(badge_color_rgb("#ff8800"), [0xff, 0x88, 0x00]); assert_eq!(badge_color_rgb("00ff00"), [0x00, 0xff, 0x00]); - assert_eq!(badge_color_rgb("totally-unknown"), [0x80, 0x80, 0x80]); + assert_eq!(badge_color_rgb("totally-unknown"), UNKNOWN_BADGE_COLOR); + // Six bytes, but not six hex digits: refused, not sliced mid-character. + assert_eq!(badge_color_rgb("a\u{e9}123"), UNKNOWN_BADGE_COLOR); + } + + #[test] + fn every_named_shields_color_is_a_hex_color() { + let table = named_badge_colors(); + assert!(table.contains_key("brightgreen") && table.contains_key("lightgrey")); + for (name, hex) in table { + assert_eq!( + name, + &name.to_ascii_lowercase(), + "names are looked up lowercased" + ); + assert!(parse_hex(hex).is_some(), "{name}: {hex:?} is not #rrggbb"); + } } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 35079c8..2e41437 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,4 +7,28 @@ mod sidebar; pub mod theme; pub(crate) mod tutorial; +pub(crate) use settings::SETTINGS_CATEGORIES; +pub(crate) use sidebar::sidebar_width; pub(crate) use tutorial::{fetch_bounds_task, TutorialBounds, TUTORIAL_LAST_STEP}; + +/// A Nerd Font glyph and its label, as a button's content. Two texts because +/// they are two fonts: the glyph stays in the Nerd Font whatever the dyslexia +/// setting (OpenDyslexic has none of them), the label follows it. Both take +/// the button's text colour. +pub(crate) fn glyph_label<'a, M: 'a>( + glyph: &'a str, + label: String, + size: u16, + font: iced::Font, +) -> iced::widget::Row<'a, M> { + iced::widget::row![ + iced::widget::text(glyph) + .size(colony_ui::sz(size)) + .font(colony_ui::fonts::glyph_font()), + iced::widget::text(label) + .size(colony_ui::sz(size)) + .font(font), + ] + .spacing(8) + .align_y(iced::Alignment::Center) +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index ef0fb95..644f540 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1,1340 +1,579 @@ -use iced::font::Weight; -use iced::widget::overlay::menu as overlay_menu; -use iced::widget::{button, column, container, pick_list, row, scrollable, text, Column}; -use iced::{Element, Fill, Length}; +//! The Preferences page, built from colony-ui's preferences kit. +//! +//! General's heading, Appearance and Accessibility come from +//! [`PreferencesView`]; what is Colony's own (startup, language, updates, +//! keyboard navigation, storage, shortcuts, about) is drawn here with the +//! kit's section and row widgets, so the whole page reads as one. + +use colony_ui::widgets::{self, PreferencesView, ABOUT_CATEGORY, STANDARD_CATEGORIES}; +use colony_ui::{fonts, Typography}; +use iced::widget::{button, column, container, row, text, Column}; +use iced::{Element, Fill}; use crate::i18n; use crate::message::Message; use crate::state::App; -use crate::ui::theme::{self, Palette}; - -/// Settings category names (keys for i18n). -const SETTINGS_CATEGORIES: &[&str] = &[ - "settings_cat_general", - "settings_cat_appearance", - "settings_cat_accessibility", - "settings_cat_storage", - "settings_cat_about", - "settings_cat_shortcuts", +use crate::ui::theme::Palette; + +/// The categories' label keys, in display order: the three every Colony +/// program opens with, Colony's own, then About, last. +pub(crate) const SETTINGS_CATEGORIES: [&str; 6] = [ + STANDARD_CATEGORIES[0], + STANDARD_CATEGORIES[1], + STANDARD_CATEGORIES[2], + STORAGE, + SHORTCUTS, + ABOUT_CATEGORY, ]; +const STORAGE: &str = "settings_cat_storage"; +const SHORTCUTS: &str = "settings_cat_shortcuts"; + impl App { - /// Full-page settings view (replaces content area). + /// The Preferences page. It replaces the content area; the sidebar stays. pub(crate) fn view_settings_page(&self) -> Element<'_, Message> { - // ── Settings sidebar (left) ── - let header = row![ - text(i18n::t("settings_title")) - .size(self.sz(22)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()), - container(text("")).width(Fill), - button( - text(i18n::t("settings_close")) - .size(self.sz(13)) - .font(self.app_font()) - ) - .on_press(Message::ToggleSettings) - .padding([6, 14]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => iced::Color::TRANSPARENT, - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_MUTED(), - border: iced::Border::default().rounded(6), - ..Default::default() - } - }), - ] - .align_y(iced::Alignment::Center); - - let mut cat_buttons: Vec> = Vec::new(); - for (i, key) in SETTINGS_CATEGORIES.iter().enumerate() { - let is_selected = self.settings_category == i; - let idx = i; - cat_buttons.push( - button(text(i18n::t(key)).size(self.sz(13)).font(self.app_font())) - .on_press(Message::SettingsCategory(idx)) - .padding([8, 14]) - .width(Fill) - .style(move |_theme, status| { - let bg = match status { - _ if is_selected => Palette::ACCENT(), - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => iced::Color::TRANSPARENT, - }; - button::Style { - background: Some(bg.into()), - // Not TEXT_PRIMARY: on an accent fill that pairing - // is below 4.5:1 on fifty-eight of the fifty-nine - // themes and reaches 1.01:1 on Ayu Dark, where the - // selected category was invisible. `contrast_on` - // picks the legible end for whatever accent the - // user has chosen — app_grid.rs already uses it for - // exactly this. - text_color: if is_selected { - theme::contrast_on(Palette::ACCENT()) - } else { - Palette::TEXT_MUTED() - }, - border: iced::Border::default().rounded(8), - ..Default::default() - } - }) - .into(), - ); - } - - let settings_nav = container(Column::with_children(cat_buttons).spacing(2)) - .width(Length::Fixed(160.0)) - .padding(iced::Padding { - top: 0.0, - right: 16.0, - bottom: 0.0, - left: 0.0, - }); - - // ── Settings content (right) ── - let settings_content = match self.settings_category { - 0 => self.view_settings_general(), - 1 => self.view_settings_appearance(), - 2 => self.view_settings_accessibility(), - 3 => self.view_settings_storage(), - 4 => self.view_settings_about(), - 5 => self.view_settings_shortcuts(), - _ => self.view_settings_general(), - }; - - let content_area = container( - scrollable(container(settings_content).padding(iced::Padding { - top: 0.0, - right: 24.0, - bottom: 24.0, - left: 0.0, - })) - .id(iced::widget::Id::new("settings-scroll")) - .height(Fill), - ) - .width(Fill) - .height(Fill); - - let body = row![settings_nav, content_area].spacing(0); - - let page = column![header, container(text("")).height(16), body,] - .padding(24) - .width(Fill) - .height(Fill); - - container(page) - .style(|_theme| container::Style { - background: Some(Palette::BG_PRIMARY().into()), - ..Default::default() - }) - .width(Fill) - .height(Fill) - .into() - } - - // ── General settings ── - fn view_settings_general(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("settings_general_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let cat_desc = text(i18n::t("settings_general_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); - - let mut sections = column![ - cat_title, - container(text("")).height(4), - cat_desc, - container(text("")).height(20), - ] - .spacing(0); - - // Section: Démarrage - sections = sections.push( - self.view_collapsible_section( - "startup", - &i18n::t("settings_section_startup"), - column![ - text(i18n::t("settings_startup_section_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_restore_session"), - &i18n::t("settings_restore_session_desc"), - self.restore_session, - Message::ToggleRestoreSession, - ), - container(text("")).height(4), - self.view_pick_list( - &i18n::t("settings_default_view"), - &i18n::t("settings_default_view_desc"), - vec![ - ("all".into(), i18n::t("settings_default_view_all")), - ( - "favorites".into(), - i18n::t("settings_default_view_favorites") - ), - ], - &self.default_view, - Message::PickDefaultView, - ), - ] - .spacing(0) - .into(), - ), + let typo = Typography::current(); + let kit = PreferencesView::new( + typo, + &self.standard, + &self.settings_expanded_sections, + Message::Preference, + Message::SettingsToggleSection, ); - sections = sections.push(container(text("")).height(6)); - // Section: Langue - sections = sections.push( - self.view_collapsible_section( - "lang", - &i18n::t("settings_section_language"), - column![ - text(i18n::t("settings_language_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_pick_list( - &i18n::t("settings_current_language"), - &i18n::t("settings_current_language_desc"), - vec![ - ("fr".into(), "Français".into()), - ("en".into(), "English".into()), - ], - &self.language, - Message::PickLanguage, - ), - ] - .spacing(0) - .into(), - ), - ); - sections = sections.push(container(text("")).height(6)); - - // Section: Mises à jour - sections = sections.push( - self.view_collapsible_section( - "updates", - &i18n::t("settings_section_updates"), - column![ - text(i18n::t("settings_updates_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_auto_check_updates"), - &i18n::t("settings_auto_check_updates_desc"), - self.auto_check_updates, - Message::ToggleAutoCheckUpdates, - ), - container(text("")).height(12), - self.action_button( - "\u{f0ed}", - i18n::t("settings_check_updates"), - Message::CheckUpdates, - ), - ] - .spacing(0) + let category = SETTINGS_CATEGORIES + .get(self.settings_category) + .copied() + .unwrap_or(SETTINGS_CATEGORIES[0]); + let content: Element<'_, Message> = match category { + "settings_cat_appearance" => kit.appearance().into(), + "settings_cat_accessibility" => kit + .accessibility_heading() + .push(kit.vision_section()) + .push(kit.motion_section()) + .push(self.navigation_section(&typo)) + .push(kit.reading_section()) .into(), - ), - ); - - sections.into() - } - - // ── Appearance settings ── - fn view_settings_appearance(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("settings_appearance_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let cat_desc = text(i18n::t("settings_appearance_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); - - let mut sections = column![ - cat_title, - container(text("")).height(4), - cat_desc, - container(text("")).height(20), - ] - .spacing(0); - - // Thème - sections = sections.push(self.view_collapsible_section( - "theme", - &i18n::t("settings_section_theme"), - self.view_theme_section(), - )); - sections = sections.push(container(text("")).height(6)); - - // Couleurs & accents - sections = sections.push(self.view_collapsible_section( - "colors", - &i18n::t("settings_section_colors"), - self.view_colors_section(), - )); - sections = sections.push(container(text("")).height(6)); - - // Typographie - sections = sections.push( - self.view_collapsible_section( - "typography", - &i18n::t("settings_section_typography"), - column![ - text(i18n::t("settings_typography_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_pick_list( - &i18n::t("settings_font_size"), - &i18n::t("settings_font_size_desc"), - vec![ - ("small".into(), i18n::t("settings_font_size_small")), - ("default".into(), i18n::t("settings_font_size_default")), - ("large".into(), i18n::t("settings_font_size_large")), - ], - &self.font_size, - Message::PickFontSize, - ), - ] - .spacing(0) + STORAGE => self.view_settings_storage(&typo), + SHORTCUTS => self.view_settings_shortcuts(&typo), + ABOUT_CATEGORY => self.view_settings_about(&typo), + _ => kit + .general() + .push(self.startup_section(&typo)) + .push(self.language_section(&typo)) + .push(self.updates_section(&typo)) .into(), - ), - ); - sections = sections.push(container(text("")).height(6)); + }; - // Arrière-plans & effets - sections = sections.push( - self.view_collapsible_section( - "effects", - &i18n::t("settings_section_effects"), - column![ - text(i18n::t("settings_effects_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_animations"), - &i18n::t("settings_animations_desc"), - self.animations, - Message::ToggleAnimations, - ), - ] - .spacing(0) - .into(), + let labels = SETTINGS_CATEGORIES.iter().map(|key| i18n::t(key)); + widgets::preferences_page( + &typo, + widgets::category_list( + &typo, + labels, + self.settings_category, + Message::SettingsCategory, ), - ); - sections = sections.push(container(text("")).height(6)); - - // Aperçu - sections = sections.push( - self.view_collapsible_section( - "preview", - &i18n::t("settings_section_preview"), - column![container( - column![ - text(i18n::t("settings_preview_card")) - .size(self.sz(14)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()), - container(text("")).height(6), - text(i18n::t("settings_preview_summary")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - ] - .spacing(0) - ) - .padding(16) - .width(Fill) - .style(|_theme| container::Style { - background: Some(Palette::BG_CARD().into()), - border: iced::Border { - color: Palette::BORDER_SUBTLE(), - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() - }),] - .spacing(0) - .into(), - ), - ); - - sections.into() + content, + Message::ToggleSettings, + ) } - // ── Accessibility settings ── - fn view_settings_accessibility(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("settings_accessibility_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let cat_desc = text(i18n::t("settings_accessibility_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); - - let mut sections = column![ - cat_title, - container(text("")).height(4), - cat_desc, - container(text("")).height(20), - ] - .spacing(0); - - // Vision - sections = sections.push( - self.view_collapsible_section( - "vision", - &i18n::t("settings_section_vision"), - column![ - text(i18n::t("settings_vision_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_high_contrast"), - &i18n::t("settings_high_contrast_desc"), - self.high_contrast, - Message::ToggleHighContrast, - ), - container(text("")).height(4), - self.view_pick_list( - &i18n::t("settings_text_size_a11y"), - &i18n::t("settings_text_size_a11y_desc"), - vec![ - ("small".into(), i18n::t("settings_font_size_small")), - ("default".into(), i18n::t("settings_font_size_default")), - ("large".into(), i18n::t("settings_font_size_large")), - ("xlarge".into(), i18n::t("settings_font_size_xlarge")), - ], - &self.text_size_a11y, - Message::PickTextSizeA11y, - ), - ] - .spacing(0) - .into(), - ), - ); - sections = sections.push(container(text("")).height(6)); - - // Mouvement - sections = sections.push( - self.view_collapsible_section( - "motion", - &i18n::t("settings_section_motion"), - column![ - text(i18n::t("settings_motion_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_reduce_motion"), - &i18n::t("settings_reduce_motion_desc"), - self.reduce_motion, - Message::ToggleReduceMotion, + // ── General ── + + fn startup_section(&self, typo: &Typography) -> Element<'_, Message> { + let default_view = widgets::setting_row( + typo, + &i18n::t("settings_default_view"), + &i18n::t("settings_default_view_desc"), + widgets::dropdown( + typo, + [ + ("all".to_string(), i18n::t("settings_default_view_all")), + ( + "favorites".to_string(), + i18n::t("settings_default_view_favorites"), ), - ] - .spacing(0) - .into(), + ], + &self.default_view, + Message::PickDefaultView, ), ); - sections = sections.push(container(text("")).height(6)); + self.section( + typo, + "startup", + "settings_section_startup", + "settings_startup_section_desc", + vec![ + widgets::functional_toggle( + typo, + &i18n::t("settings_restore_session"), + &i18n::t("settings_restore_session_desc"), + self.restore_session, + Message::ToggleRestoreSession, + ), + default_view, + ], + ) + } - // Navigation & interaction - sections = sections.push( - self.view_collapsible_section( - "navigation", - &i18n::t("settings_section_navigation"), - column![ - text(i18n::t("settings_navigation_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_keyboard_nav"), - &i18n::t("settings_keyboard_nav_desc"), - self.keyboard_nav, - Message::ToggleKeyboardNav, - ), - ] - .spacing(0) - .into(), + fn language_section(&self, typo: &Typography) -> Element<'_, Message> { + // Each language is named in itself, so it can be found from either. + let picker = widgets::setting_row( + typo, + &i18n::t("settings_current_language"), + &i18n::t("settings_current_language_desc"), + widgets::dropdown( + typo, + [ + ("fr".to_string(), "Français".to_string()), + ("en".to_string(), "English".to_string()), + ], + &self.language, + Message::PickLanguage, ), ); - sections = sections.push(container(text("")).height(6)); + self.section( + typo, + "lang", + "settings_section_language", + "settings_language_desc", + vec![picker], + ) + } - // Lecture - sections = sections.push( - self.view_collapsible_section( - "reading", - &i18n::t("settings_section_reading"), - column![ - text(i18n::t("settings_reading_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_dyslexia_font"), - &i18n::t("settings_dyslexia_font_desc"), - self.dyslexia_font, - Message::ToggleDyslexiaFont, - ), - ] - .spacing(0) + fn updates_section(&self, typo: &Typography) -> Element<'_, Message> { + self.section( + typo, + "updates", + "settings_section_updates", + "settings_updates_desc", + vec![ + widgets::functional_toggle( + typo, + &i18n::t("settings_auto_check_updates"), + &i18n::t("settings_auto_check_updates_desc"), + self.auto_check_updates, + Message::ToggleAutoCheckUpdates, + ), + container(action_button( + typo, + "\u{f0ed}", + i18n::t("settings_check_updates"), + Message::CheckUpdates, + )) + .padding([12, 0]) .into(), - ), - ); - - sections.into() + ], + ) } - // ── Storage settings ── - fn view_settings_storage(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("settings_storage_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let cat_desc = text(i18n::t("settings_storage_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); - - let mut sections = column![ - cat_title, - container(text("")).height(4), - cat_desc, - container(text("")).height(20), - ] - .spacing(0); + // ── Accessibility ── + + /// Colony's own Accessibility section, between Motion and Reading. + fn navigation_section(&self, typo: &Typography) -> Element<'_, Message> { + self.section( + typo, + "navigation", + "settings_section_navigation", + "settings_navigation_desc", + vec![widgets::functional_toggle( + typo, + &i18n::t("settings_keyboard_nav"), + &i18n::t("settings_keyboard_nav_desc"), + self.keyboard_nav, + Message::ToggleKeyboardNav, + )], + ) + } - // Section: Scan - sections = sections.push( - self.view_collapsible_section( - "scan", - &i18n::t("settings_section_scan"), - column![ - text(i18n::t("settings_scan_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.view_functional_toggle( - &i18n::t("settings_startup"), - &i18n::t("settings_startup_desc"), - self.scan_on_startup, - Message::ToggleScanOnStartup, - ), - container(text("")).height(12), - self.action_button("\u{f021}", i18n::t("rescan"), Message::Rescan,), - ] - .spacing(0) + // ── Storage ── + + fn view_settings_storage(&self, typo: &Typography) -> Element<'_, Message> { + let scan = self.section( + typo, + "scan", + "settings_section_scan", + "settings_scan_desc", + vec![ + widgets::functional_toggle( + typo, + &i18n::t("settings_startup"), + &i18n::t("settings_startup_desc"), + self.scan_on_startup, + Message::ToggleScanOnStartup, + ), + container(action_button( + typo, + "\u{f021}", + i18n::t("rescan"), + Message::Rescan, + )) + .padding([12, 0]) .into(), - ), + ], ); - sections = sections.push(container(text("")).height(6)); - - // Section: Caches - sections = sections.push( - self.view_collapsible_section( - "caches", - &i18n::t("clear_caches"), - column![ - text(i18n::t("clear_caches_desc")) - .size(self.sz(12)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - container(text("")).height(12), - self.action_button( - "\u{f1f8}", - i18n::t("clear_caches"), - Message::ClearStoreCaches, - ), - ] - .spacing(0) - .into(), - ), + let caches = self.section( + typo, + "caches", + "clear_caches", + "clear_caches_desc", + vec![action_button( + typo, + "\u{f1f8}", + i18n::t("clear_caches"), + Message::ClearStoreCaches, + )], ); - sections = sections.push(container(text("")).height(6)); - - // Section: Installation - let apps_count = self.applications.len().to_string(); - let repos_count = self.colony_repos().len().to_string(); - let fav_count = self.favorites.len().to_string(); - - let label_local = i18n::t("settings_local_apps"); - let label_repos = i18n::t("settings_colony_repos"); - let label_favs = i18n::t("settings_favorites"); - - sections = sections.push( - self.view_collapsible_section( - "install", - &i18n::t("settings_section_install"), - column![ - self.info_row("\u{f1c0}", label_local, apps_count), - Self::divider(), - self.info_row("\u{f0c2}", label_repos, repos_count), - Self::divider(), - self.info_row("\u{f07c}", label_favs, fav_count), - ] - .spacing(0) - .into(), - ), + let install = self.section( + typo, + "install", + "settings_section_install", + "settings_install_desc", + vec![ + info_row( + typo, + "\u{f1c0}", + i18n::t("settings_local_apps"), + self.applications.len().to_string(), + ), + divider(), + info_row( + typo, + "\u{f0c2}", + i18n::t("settings_colony_repos"), + self.colony_repos().len().to_string(), + ), + divider(), + info_row( + typo, + "\u{f07c}", + i18n::t("settings_favorites"), + self.favorites.len().to_string(), + ), + ], ); - sections.into() + widgets::category_heading(typo, &i18n::t(STORAGE), &i18n::t("settings_storage_desc")) + .push(scan) + .push(caches) + .push(install) + .into() } - // ── About settings ── - fn view_settings_about(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("settings_about_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let mut sections = column![cat_title, container(text("")).height(20),].spacing(0); + // ── Shortcuts ── - // Version + update button - let version_label = format!("Colony v{}", env!("CARGO_PKG_VERSION")); - - let update_btn: Element<'_, Message> = - if let Some((ref tag, _)) = self.launcher_update_available { - if self.launcher_system_managed { - // Package-manager install: the download/apply flow is - // guaranteed to fail (the exe dir is not user-writable), so - // show the actionable path instead of a doomed button. - text(i18n::t_fmt( - "launcher_update_system_managed", - &[("version", tag)], - )) - .size(self.sz(13)) - .font(self.app_font()) - .color(Palette::WARNING()) - .into() - } else if let Some(ref path) = self.launcher_update_staged { - let path = path.clone(); - button( - text(i18n::t_fmt("launcher_restart_to_update", &[])) - .size(self.sz(13)) - .font(self.app_font()), - ) - .on_press(Message::ApplyLauncherUpdate(path)) - .padding([6, 14]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => Palette::BG_SELECTED(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::ACCENT(), - border: iced::Border::default().rounded(6), - ..Default::default() - } - }) - .into() - } else { - let tag = tag.clone(); - let is_downloading = self.is_downloading; - let btn = button( - text(i18n::t_fmt( - "launcher_download_update", - &[("version", &tag)], - )) - .size(self.sz(13)) - .font(self.app_font()), - ) - .padding([6, 14]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => Palette::BG_SELECTED(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::ACCENT(), - border: iced::Border::default().rounded(6), - ..Default::default() - } - }); - if is_downloading { - btn.into() - } else { - btn.on_press(Message::DownloadLauncherUpdate).into() - } - } - } else { - let is_checking = self.is_checking_launcher_update; - let label = if is_checking { - format!("\u{f110} {}...", i18n::t("check_launcher_updates")) - } else { - i18n::t("check_launcher_updates") - }; - let btn = button(text(label).size(self.sz(13)).font(self.app_font())) - .padding([6, 14]) - .style(|_theme, status| { - let (bg, text_color) = crate::ui::theme::button_colors( - status, - iced::Color::TRANSPARENT, - Palette::BG_CARD_HOVER(), - Palette::TEXT_DIM(), - ); - button::Style { - background: Some(bg.into()), - text_color, - border: iced::Border::default().rounded(6), - ..Default::default() - } - }); - if is_checking { - btn.into() - } else { - btn.on_press(Message::CheckLauncherUpdate { manual: true }) - .into() - } - }; - - sections = sections.push( - self.view_collapsible_section( - "about", - &i18n::t("settings_about"), - column![ - row![ - text("\u{f015}") - .size(self.sz(15)) - .font(self.app_font()) - .color(Palette::ACCENT()), - text(version_label) - .size(self.sz(15)) - .font(self.app_font()) - .color(Palette::TEXT_PRIMARY()), - ] - .spacing(10), - container(text("")).height(8), - row![ - text("\u{f09b}") - .size(self.sz(15)) - .font(self.app_font()) - .color(Palette::TEXT_DIM()), - text("Project-Colony/Colony") - .size(self.sz(14)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()), - ] - .spacing(10), - container(text("")).height(8), - update_btn, - ] - .spacing(4) - .into(), - ), + fn view_settings_shortcuts(&self, typo: &Typography) -> Element<'_, Message> { + let mut col = widgets::category_heading( + typo, + &i18n::t("shortcuts_title"), + &i18n::t("shortcuts_desc"), ); - sections.into() - } - - // ── Keyboard shortcuts reference (task 10) ── - fn view_settings_shortcuts(&self) -> Element<'_, Message> { - let cat_title = text(i18n::t("shortcuts_title")) - .size(self.sz(18)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()); - - let shortcuts = [ + for key in [ "shortcut_esc", "shortcut_tab", "shortcut_arrows", "shortcut_enter", "shortcut_pageupdown", - ]; - - let mut col = column![cat_title, container(text("")).height(20),].spacing(0); - - for key in &shortcuts { - let shortcut_text = i18n::t(key); - col = col.push( - container( - text(shortcut_text) - .size(self.sz(13)) - .font(self.app_font()) - .color(Palette::TEXT_SECONDARY()), + ] { + col = col + .push( + container( + text(i18n::t(key)) + .size(typo.sz(13)) + .font(typo.regular) + .color(Palette::TEXT_SECONDARY()), + ) + .padding([8, 0]), ) - .padding([8, 0]), - ); - col = col.push(Self::divider()); + .push(divider()); } - // Hints section - col = col.push(container(text("")).height(20)); - let hints = [ + let mut hints = Column::new().spacing(8).padding(iced::Padding { + top: 14.0, + ..iced::Padding::ZERO + }); + for key in [ "hint_settings", "hint_search", "hint_favorites", "hint_keyboard", - ]; - for key in &hints { - col = col.push( - container( - text(format!("\u{f05a} {}", i18n::t(key))) - .size(self.sz(12)) - .font(self.app_font()) + ] { + hints = hints.push( + row![ + text("\u{f05a}") + .size(typo.sz(12)) + .font(fonts::glyph_font()) .color(Palette::TEXT_MUTED()), - ) - .padding([4, 0]), + text(i18n::t(key)) + .size(typo.sz(12)) + .font(typo.regular) + .color(Palette::TEXT_MUTED()), + ] + .spacing(10), ); } - col.into() + col.push(hints).into() } - // ── Theme sub-section ── - fn view_theme_section(&self) -> Element<'_, Message> { - let font = self.app_font(); - let medium = self.app_font_with_weight(Weight::Medium); - - let mut col = column![].spacing(12); - - // Rendered straight from colony-ui's generated catalog: adding a theme - // family upstream needs no change here. - for family in colony_ui::THEME_FAMILIES { - let (theme_key, label_key, icon) = (family.key, family.label_key, family.icon); - let is_selected_family = self.selected_theme == theme_key; - let label = i18n::t(label_key); - - // Family name label with optional themed icon - let label_text = if icon.is_empty() { - label.clone() - } else { - format!("{} {}", icon, label) - }; - let family_label = - text(label_text) - .size(self.sz(13)) - .font(medium) - .color(if is_selected_family { - Palette::TEXT_PRIMARY() - } else { - Palette::TEXT_SECONDARY() - }); - - // Variant cards as a horizontal row of mini color-swatch cards - let mut variant_row = row![].spacing(8); - - for variant in family.variants { - let (var_key, var_label_key) = (&variant.key, variant.label_key); - let is_active = is_selected_family && self.selected_variant == *var_key; - let theme_owned = theme_key.to_string(); - let var_owned = var_key.to_string(); - - let bg_color = variant.swatch_bg_color(); - let accent_color = variant.swatch_accent_color(); - - // Color swatch: bg stripe + accent dot - let swatch_bg = container(text("")) - .width(Length::Fill) - .height(Length::Fixed(4.0)) - .style(move |_theme| container::Style { - background: Some(accent_color.into()), - border: iced::Border::default().rounded(2), - ..Default::default() - }); - - let swatch = container(swatch_bg) - .width(Length::Fill) - .height(Length::Fixed(28.0)) - .padding(iced::Padding { - top: 20.0, - right: 6.0, - bottom: 4.0, - left: 6.0, - }) - .style(move |_theme| container::Style { - background: Some(bg_color.into()), - border: iced::Border::default().rounded(6), - ..Default::default() - }); - - // Variant label below the swatch - let var_label = text(i18n::t(var_label_key)) - .size(self.sz(10)) - .font(font) - .color(if is_active { - Palette::TEXT_PRIMARY() - } else { - Palette::TEXT_MUTED() - }); - - // Check indicator for active variant - let indicator: Element<'_, Message> = if is_active { - text("\u{f00c}") - .size(self.sz(8)) - .font(font) - .color(Palette::ACCENT()) - .into() - } else { - text("").size(self.sz(8)).into() - }; - - let card_content = column![ - swatch, - container( - row![var_label, indicator] - .spacing(4) - .align_y(iced::Alignment::Center) - ) - .padding(iced::Padding { - top: 4.0, - right: 0.0, - bottom: 0.0, - left: 2.0 - }), - ] - .spacing(0) - .width(Length::Fill); - - let card = button(card_content) - .on_press(Message::SelectThemeVariant(theme_owned, var_owned)) - .padding(4) - .width(Length::Fill) - .style(move |_theme, status| { - let border_color = match status { - _ if is_active => Palette::ACCENT(), - button::Status::Hovered => Palette::TEXT_DIMMER(), - _ => Palette::BORDER_SUBTLE(), - }; - button::Style { - background: Some(Palette::BG_CARD().into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border { - color: border_color, - width: if is_active { 2.0 } else { 1.0 }, - radius: 8.0.into(), - }, - ..Default::default() - } - }); - - variant_row = variant_row.push(card); - } + // ── About ── - col = col.push(column![family_label, variant_row,].spacing(6)); - } + fn view_settings_about(&self, typo: &Typography) -> Element<'_, Message> { + let version = row![ + text("\u{f015}") + .size(typo.sz(15)) + .font(fonts::glyph_font()) + .color(Palette::ACCENT()), + text(format!("Colony v{}", env!("CARGO_PKG_VERSION"))) + .size(typo.sz(15)) + .font(typo.regular) + .color(Palette::TEXT_PRIMARY()), + ] + .spacing(10); + + let source = row![ + text("\u{f09b}") + .size(typo.sz(15)) + .font(fonts::glyph_font()) + .color(Palette::TEXT_DIM()), + text("Project-Colony/Colony") + .size(typo.sz(14)) + .font(typo.regular) + .color(Palette::TEXT_MUTED()), + ] + .spacing(10); - col.into() + widgets::category_heading( + typo, + &i18n::t("settings_about_title"), + &i18n::t("settings_about_desc"), + ) + .push( + column![version, source, self.launcher_update_control(typo)] + .spacing(12) + .padding([0, 4]), + ) + .into() } - // ── Colors & accents sub-section ── - fn view_colors_section(&self) -> Element<'_, Message> { - let font = self.app_font(); - - // Accent colors: (key, i18n_label_key, hex_color) - let accent_colors: Vec<(&str, &str, u32)> = vec![ - ("red", "settings_accent_red", 0xE05555), - ("orange", "settings_accent_orange", 0xE0855A), - ("yellow", "settings_accent_yellow", 0xC8A832), - ("green", "settings_accent_green", 0x55B87A), - ("blue", "settings_accent_blue", 0x6B8BD6), - ("indigo", "settings_accent_indigo", 0x7B6BD6), - ("violet", "settings_accent_violet", 0xB06BD6), - ("amber", "settings_accent_amber", 0xD4A030), - ]; - - let mut color_row = row![].spacing(8).align_y(iced::Alignment::Center); - - for (color_key, _label_key, hex) in &accent_colors { - let is_active = self.selected_accent == *color_key; - let color_key_owned = color_key.to_string(); - let r = ((*hex >> 16) & 0xFF) as f32 / 255.0; - let g = ((*hex >> 8) & 0xFF) as f32 / 255.0; - let b = (*hex & 0xFF) as f32 / 255.0; - let dot_color = iced::Color { r, g, b, a: 1.0 }; - - // Circular color swatch button - let check_icon: Element<'_, Message> = if is_active { - text("\u{f00c}") - .size(self.sz(8)) - .font(font) - .color(iced::Color::WHITE) - .into() - } else { - text("").size(self.sz(8)).into() - }; - let swatch = button( - container(check_icon) - .center_x(Length::Fill) - .center_y(Length::Fill), - ) - .on_press(Message::SelectAccentColor(color_key_owned)) - .width(Length::Fixed(28.0)) - .height(Length::Fixed(28.0)) - .padding(0) - .style(move |_theme, status| { - let border_color = match status { - _ if is_active => Palette::TEXT_PRIMARY(), - button::Status::Hovered => Palette::TEXT_DIMMER(), - _ => iced::Color::TRANSPARENT, - }; + /// The launcher's own update, in the state it is in: up to date (check), + /// available (download), staged (restart), or managed by the system's + /// package manager (how to update instead). + fn launcher_update_control(&self, typo: &Typography) -> Element<'_, Message> { + let Some((tag, _)) = &self.launcher_update_available else { + let is_checking = self.is_checking_launcher_update; + let mut label = row![].spacing(10).align_y(iced::Alignment::Center); + if is_checking { + label = label.push(text("\u{f110}").size(typo.sz(13)).font(fonts::glyph_font())); + } + let label = label.push( + text(i18n::t("check_launcher_updates")) + .size(typo.sz(13)) + .font(typo.regular), + ); + let btn = button(label).padding([6, 14]).style(|_theme, status| { + let (bg, text_color) = crate::ui::theme::button_colors( + status, + iced::Color::TRANSPARENT, + Palette::BG_CARD_HOVER(), + Palette::TEXT_DIM(), + ); button::Style { - background: Some(dot_color.into()), - text_color: iced::Color::WHITE, - border: iced::Border { - color: border_color, - width: if is_active { 2.0 } else { 0.0 }, - radius: 14.0.into(), - }, + background: Some(bg.into()), + text_color, + border: iced::Border::default().rounded(6), ..Default::default() } }); + return if is_checking { + btn.into() + } else { + btn.on_press(Message::CheckLauncherUpdate { manual: true }) + .into() + }; + }; - color_row = color_row.push(swatch); + if self.launcher_system_managed { + // Package-manager install: the download/apply flow is guaranteed + // to fail (the exe dir is not user-writable), so show the + // actionable path instead of a doomed button. + return text(i18n::t_fmt( + "launcher_update_system_managed", + &[("version", tag.as_str())], + )) + .size(typo.sz(13)) + .font(typo.regular) + .color(Palette::WARNING()) + .into(); } - // Auto accent toggle - let auto_accent_row = self.view_functional_toggle( - &i18n::t("settings_auto_accent"), - &i18n::t("settings_auto_accent_desc"), - self.auto_accent, - Message::ToggleAutoAccent, - ); - - column![color_row, container(text("")).height(12), auto_accent_row,] - .spacing(0) - .into() - } - - // ── Collapsible section ── - fn view_collapsible_section<'a>( - &self, - key: &str, - title: &str, - content: Element<'a, Message>, - ) -> Element<'a, Message> { - let is_expanded = self.settings_expanded_sections.contains(key); - let arrow = if is_expanded { "\u{f078}" } else { "\u{f054}" }; // chevron down / right - let key_owned = key.to_string(); - let title_owned = title.to_string(); - - // Header: clean flat style, no box — just text + chevron - let header_btn = button( - row![ - text(title_owned) - .size(self.sz(15)) - .font(self.app_font_with_weight(Weight::Bold)) - .color(Palette::TEXT_PRIMARY()), - container(text("")).width(Fill), - text(arrow) - .size(self.sz(9)) - .font(self.app_font()) - .color(Palette::TEXT_DIMMER()), - ] - .spacing(8) - .align_y(iced::Alignment::Center), - ) - .on_press(Message::SettingsToggleSection(key_owned)) - .padding([12, 4]) - .width(Fill) - .style(move |_theme, status| { + let style = |_theme: &iced::Theme, status: button::Status| { let bg = match status { button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => iced::Color::TRANSPARENT, + _ => Palette::BG_SELECTED(), }; button::Style { background: Some(bg.into()), - text_color: Palette::TEXT_PRIMARY(), + text_color: Palette::ACCENT(), border: iced::Border::default().rounded(6), ..Default::default() } - }); - - if is_expanded { - // Thin divider line under header - let divider = - container(text("")) - .width(Fill) - .height(1) - .style(|_theme| container::Style { - background: Some(Palette::DIVIDER().into()), - ..Default::default() - }); + }; - let body = container(content) - .padding(iced::Padding { - top: 12.0, - right: 4.0, - bottom: 4.0, - left: 4.0, - }) - .width(Fill); + if let Some(path) = &self.launcher_update_staged { + return button(crate::ui::glyph_label( + "\u{f021}", + i18n::t("launcher_restart_to_update"), + 13, + typo.regular, + )) + .on_press(Message::ApplyLauncherUpdate(path.clone())) + .padding([6, 14]) + .style(style) + .into(); + } - column![header_btn, divider, body].spacing(0).into() + let btn = button( + text(i18n::t_fmt( + "launcher_download_update", + &[("version", tag.as_str())], + )) + .size(typo.sz(13)) + .font(typo.regular), + ) + .padding([6, 14]) + .style(style); + if self.is_downloading { + btn.into() } else { - header_btn.into() + btn.on_press(Message::DownloadLauncherUpdate).into() } } - /// A functional toggle: clicking sends the given message. - fn view_functional_toggle( + /// One of Colony's own collapsible sections, drawn the way the kit draws + /// its: the description first, then the controls. + fn section<'a>( &self, - title: &str, - desc: &str, - on: bool, - msg: Message, - ) -> Element<'_, Message> { - let font = self.app_font(); - let track_bg = if on { - Palette::ACCENT() - } else { - Palette::BG_CARD_HOVER() - }; - let knob_offset: f32 = if on { 16.0 } else { 2.0 }; - - let knob = container(text("")) - .width(Length::Fixed(14.0)) - .height(Length::Fixed(14.0)) - .style(move |_theme| container::Style { - background: Some(Palette::TEXT_PRIMARY().into()), - border: iced::Border::default().rounded(7), - ..Default::default() - }); - let toggle_visual = container(container(knob).padding(iced::Padding { - top: 1.0, - right: 0.0, - bottom: 0.0, - left: knob_offset, - })) - .width(Length::Fixed(34.0)) - .height(Length::Fixed(18.0)) - .style(move |_theme| container::Style { - background: Some(track_bg.into()), - border: iced::Border::default().rounded(9), - ..Default::default() - }); - - button( - row![ - column![ - text(title.to_string()) - .size(self.sz(13)) - .font(font) - .color(Palette::TEXT_PRIMARY()), - text(desc.to_string()) - .size(self.sz(11)) - .font(font) - .color(Palette::TEXT_DIMMER()), - ] - .spacing(2), - container(text("")).width(Fill), - toggle_visual, + typo: &Typography, + key: &'static str, + title_key: &str, + description_key: &str, + controls: Vec>, + ) -> Element<'a, Message> { + widgets::collapsible_section( + typo, + &i18n::t(title_key), + self.settings_expanded_sections.contains(key), + Message::SettingsToggleSection(key), + column![ + widgets::section_description(typo, &i18n::t(description_key)), + Column::with_children(controls).width(Fill), ] - .spacing(10) - .align_y(iced::Alignment::Center), + .into(), ) - .on_press(msg) - .padding([6, 4]) - .width(Fill) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ => iced::Color::TRANSPARENT, - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border::default().rounded(6), - ..Default::default() - } - }) - .into() } +} - /// A setting row with a pick_list dropdown for selecting from options. - /// `options`: Vec of (internal_key, display_label) pairs. - fn view_pick_list( - &self, - title: &str, - desc: &str, - options: Vec<(String, String)>, - selected_key: &str, - on_select: impl Fn(String) -> Message + 'static, - ) -> Element<'_, Message> { - let font = self.app_font(); - - let labels: Vec = options.iter().map(|(_, label)| label.clone()).collect(); - let selected_label: Option = options - .iter() - .find(|(key, _)| key == selected_key) - .map(|(_, label)| label.clone()); - - let keys: Vec = options.iter().map(|(key, _)| key.clone()).collect(); - let labels_for_map: Vec = labels.clone(); +/// An icon, a label and a value, for the Installation counts. +fn info_row<'a>( + typo: &Typography, + icon: &'a str, + label: String, + value: String, +) -> Element<'a, Message> { + row![ + text(icon) + .size(typo.sz(13)) + .font(fonts::glyph_font()) + .color(Palette::ACCENT()), + text(label) + .size(typo.sz(13)) + .font(typo.regular) + .color(Palette::TEXT_MUTED()), + container(text("")).width(Fill), + text(value) + .size(typo.sz(13)) + .font(typo.regular) + .color(Palette::TEXT_PRIMARY()), + ] + .spacing(10) + .padding([6, 0]) + .align_y(iced::Alignment::Center) + .into() +} - let pl = pick_list(labels, selected_label, move |chosen_label: String| { - let idx = labels_for_map - .iter() - .position(|l| *l == chosen_label) - .unwrap_or(0); - let key = keys.get(idx).cloned().unwrap_or_default(); - on_select(key) - }) - .text_size(12) - .padding([4, 10]) - .font(font) - .style(|_theme, status| { - let bg = match status { - pick_list::Status::Active => Palette::BG_CARD(), - pick_list::Status::Hovered | pick_list::Status::Opened { .. } => { - Palette::BG_CARD_HOVER() - } - }; - pick_list::Style { - text_color: Palette::TEXT_SECONDARY(), - placeholder_color: Palette::TEXT_DIMMER(), - handle_color: Palette::TEXT_DIMMER(), - background: bg.into(), - border: iced::Border { - color: Palette::BORDER_SUBTLE(), - width: 1.0, - radius: 6.0.into(), - }, - } +/// A subtle horizontal divider. +fn divider() -> Element<'static, Message> { + container(text("")) + .width(Fill) + .height(1) + .style(|_theme| container::Style { + background: Some(Palette::DIVIDER().into()), + ..Default::default() }) - .menu_style(|_theme| overlay_menu::Style { - background: Palette::BG_CARD().into(), - border: iced::Border { - color: Palette::BORDER_SUBTLE(), - width: 1.0, - radius: 6.0.into(), - }, - text_color: Palette::TEXT_PRIMARY(), - // The highlighted row of an open dropdown is an accent fill, and - // has the same problem as the category button above. - selected_text_color: theme::contrast_on(Palette::ACCENT()), - selected_background: Palette::ACCENT().into(), - shadow: iced::Shadow::default(), - }); - - row![ - column![ - text(title.to_string()) - .size(self.sz(13)) - .font(font) - .color(Palette::TEXT_PRIMARY()), - text(desc.to_string()) - .size(self.sz(11)) - .font(font) - .color(Palette::TEXT_DIMMER()), - ] - .spacing(2), - container(text("")).width(Fill), - pl, - ] - .spacing(10) - .padding([6, 4]) - .align_y(iced::Alignment::Center) .into() - } +} - /// Info row with icon, label and value. - fn info_row<'a>(&self, icon: &'a str, label: String, value: String) -> Element<'a, Message> { - let font = self.app_font(); +/// A button that does something now rather than set a preference: an icon in +/// the accent, then its label. +fn action_button<'a>( + typo: &Typography, + icon: &'a str, + label: String, + message: Message, +) -> Element<'a, Message> { + button( row![ text(icon) - .size(self.sz(13)) - .font(font) + .size(typo.sz(13)) + .font(fonts::glyph_font()) .color(Palette::ACCENT()), - text(label) - .size(self.sz(13)) - .font(font) - .color(Palette::TEXT_MUTED()), - container(text("")).width(Fill), - text(value) - .size(self.sz(13)) - .font(font) - .color(Palette::TEXT_PRIMARY()), + text(label).size(typo.sz(13)).font(typo.regular), ] .spacing(10) - .padding(iced::Padding { - top: 6.0, - right: 0.0, - bottom: 6.0, - left: 0.0, - }) - .align_y(iced::Alignment::Center) - .into() - } - - /// A subtle horizontal divider. - fn divider() -> Element<'static, Message> { - container(text("")) - .width(Fill) - .height(1) - .style(|_theme| container::Style { - background: Some(Palette::DIVIDER().into()), - ..Default::default() - }) - .into() - } + .align_y(iced::Alignment::Center), + ) + .on_press(message) + .padding([10, 16]) + .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() + } + }) + .into() +} - /// An action button with icon. - fn action_button<'a>( - &self, - icon: &'a str, - label: String, - message: Message, - ) -> Element<'a, Message> { - let font = self.app_font(); - button( - row![ - text(icon) - .size(self.sz(13)) - .font(font) - .color(Palette::ACCENT()), - text(label).size(self.sz(13)).font(font), +#[cfg(test)] +mod tests { + use super::*; + + /// design/settings-page.md: General, Appearance and Accessibility first and + /// in that order, the program's own next, About last. + #[test] + fn the_categories_are_in_the_shared_order() { + assert_eq!(SETTINGS_CATEGORIES[..3], STANDARD_CATEGORIES); + assert_eq!( + SETTINGS_CATEGORIES, + [ + "settings_cat_general", + "settings_cat_appearance", + "settings_cat_accessibility", + "settings_cat_storage", + "settings_cat_shortcuts", + "settings_cat_about", ] - .spacing(10) - .align_y(iced::Alignment::Center), - ) - .on_press(message) - .padding([10, 16]) - .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() - } - }) - .into() + ); + assert_eq!(SETTINGS_CATEGORIES.last(), Some(&ABOUT_CATEGORY)); } } diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index e39fa5d..6c961bc 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,4 +1,5 @@ -use iced::font::Weight; +use colony_ui::widgets; +use colony_ui::{fonts, sz, ui_font, Typography}; use iced::widget::{button, column, container, row, scrollable, text, Column}; use iced::{Element, Fill, Length}; @@ -6,53 +7,25 @@ use iced::{Element, Fill, Length}; use crate::message::Message; use crate::sections::Section; use crate::state::{App, GitHubState}; +use crate::ui::glyph_label; use crate::ui::theme::Palette; impl App { pub(crate) fn view_sidebar(&self) -> Element<'_, Message> { - let title_text = row![ - text("Colony") - .size(self.sz(30)) - .font(self.app_font_with_weight(Weight::Bold)), - text("\u{f013}") - .size(self.sz(14)) - .font(self.app_font()) - .color(if self.show_settings { - Palette::TEXT_PRIMARY() - } else { - Palette::TEXT_DIMMER() - }), - ] - .spacing(10) - .align_y(iced::Alignment::Center); - - let show_settings = self.show_settings; - let title = button(title_text) - .on_press(Message::ToggleSettings) - .padding([4, 8]) - .style(move |_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BG_CARD_HOVER(), - _ if show_settings => Palette::BG_SELECTED(), - _ => iced::Color::TRANSPARENT, - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border::default().rounded(8), - ..Default::default() - } - }); + let typo = Typography::current(); - let category_header = text(crate::i18n::t("categories")) - .size(self.sz(13)) - .font(self.app_font()) - .color(Palette::TEXT_MUTED()); + // The way into Preferences, and back out: the page's Close button + // sends the same message. + let title = widgets::identity_button( + &typo, + "Colony", + 30, + self.show_settings, + Message::ToggleSettings, + ); - let hint_text = text(crate::i18n::t("hint_keyboard")) - .size(self.sz(10)) - .font(self.app_font()) - .color(Palette::TEXT_DIMMEST()); + let category_header = widgets::nav_label(&typo, &crate::i18n::t("categories")); + let hint_text = widgets::nav_hint(&typo, &crate::i18n::t("hint_keyboard")); let category_buttons: Vec> = self .sections @@ -76,24 +49,26 @@ impl App { Palette::TEXT_DIM() }; - let github_label = if github_connected { - "GitHub \u{f00c}" - } else { - "GitHub" - }; - - let github_btn_content = row![ + let mut github_btn_content = row![ text("\u{f09b}") - .size(self.sz(18)) - .font(self.app_font()) + .size(sz(18)) + .font(fonts::glyph_font()) .color(github_icon_color), - text(github_label) - .size(self.sz(13)) - .font(self.app_font()) + text("GitHub") + .size(sz(13)) + .font(ui_font()) .color(github_icon_color), ] .spacing(10) .align_y(iced::Alignment::Center); + if github_connected { + github_btn_content = github_btn_content.push( + text("\u{f00c}") + .size(sz(13)) + .font(fonts::glyph_font()) + .color(github_icon_color), + ); + } let show_menu = self.show_github_menu; let github_btn = button(github_btn_content) @@ -114,14 +89,15 @@ impl App { ..Default::default() }); - let rescan_label = if self.is_scanning { - format!("\u{f110} {}", crate::i18n::t("scanning")) + let rescan_label: Element<'_, Message> = if self.is_scanning { + glyph_label("\u{f110}", crate::i18n::t("scanning"), 13, ui_font()).into() } else { - crate::i18n::t("rescan") + text(crate::i18n::t("rescan")) + .size(sz(13)) + .font(ui_font()) + .into() }; - let rescan_btn_base = button(text(rescan_label).size(self.sz(13)).font(self.app_font())) - .padding([8, 16]) - .width(Fill); + let rescan_btn_base = button(rescan_label).padding([8, 16]).width(Fill); let rescan_btn = if self.is_scanning { rescan_btn_base } else { @@ -138,48 +114,44 @@ impl App { // press that only ever produced a warning toast, with no dismiss, // for the whole session. if self.launcher_system_managed { - return_system_managed_badge(self, &update_info.0) + return_system_managed_badge(&update_info.0) } else { - let (label, msg): (String, Message) = if let Some(ref path) = - self.launcher_update_staged - { + let (glyph, label, msg) = if let Some(ref path) = self.launcher_update_staged { ( + "\u{f021}", crate::i18n::t("launcher_restart_to_update"), Message::ApplyLauncherUpdate(path.clone()), ) } else { let tag = &update_info.0; ( + "\u{f0aa}", crate::i18n::t_fmt("launcher_update_available_short", &[("version", tag)]), Message::DownloadLauncherUpdate, ) }; let is_downloading = self.is_downloading; - button( - text(label) - .size(self.sz(11)) - .font(self.app_font()) - .color(Palette::ACCENT()), - ) - .on_press_maybe(if is_downloading { None } else { Some(msg) }) - .padding([6, 12]) - .width(Fill) - .style(|_theme, status| { - let (bg, text_color) = crate::ui::theme::button_colors( - status, - Palette::BG_SELECTED(), - Palette::BG_CARD_HOVER(), - Palette::ACCENT(), - ); - button::Style { - background: Some(bg.into()), - text_color, - border: iced::Border::default().rounded(6), - ..Default::default() - } - }) - .into() + // The texts take button_colors' accent, dimmed while disabled. + button(glyph_label(glyph, label, 11, ui_font())) + .on_press_maybe(if is_downloading { None } else { Some(msg) }) + .padding([6, 12]) + .width(Fill) + .style(|_theme, status| { + let (bg, text_color) = crate::ui::theme::button_colors( + status, + Palette::BG_SELECTED(), + Palette::BG_CARD_HOVER(), + Palette::ACCENT(), + ); + button::Style { + background: Some(bg.into()), + text_color, + border: iced::Border::default().rounded(6), + ..Default::default() + } + }) + .into() } } else { container(text("")).height(0).into() @@ -202,7 +174,7 @@ impl App { ] .spacing(10) .padding(16) - .width(200); + .width(sidebar_width()); container(sidebar_content) .style(|_theme| container::Style { @@ -213,6 +185,8 @@ impl App { .into() } + /// One section of the sidebar: its glyph and its name, selected by + /// background like every list in a Colony program. pub(crate) fn view_section_button( &self, index: usize, @@ -220,86 +194,44 @@ impl App { ) -> Element<'_, Message> { let is_selected = self.selected_section == index && !self.show_github_menu; - let text_color = if is_selected { - Palette::TEXT_PRIMARY() - } else { - Palette::TEXT_DIM() - }; - - // Animated indicator: alpha based on distance from animated position - let indicator_alpha = if self.animations && !self.reduce_motion { - let button_y = index as f32 * 44.0; - let distance = (self.sidebar_indicator_pos() - button_y).abs(); - (1.0 - distance / 44.0).clamp(0.0, 1.0) - } else if is_selected { - 1.0 - } else { - 0.0 - }; - let accent = Palette::ACCENT(); - let indicator_color = iced::Color { - a: accent.a * indicator_alpha, - ..accent - }; - - let indicator = container(text("")) - .width(4) - .height(Length::Fill) - .style(move |_theme| container::Style { - background: Some(indicator_color.into()), - ..Default::default() - }); - - let icon = text(section.icon.clone()) - .size(self.sz(15)) - .font(self.app_font()) - .color(text_color); - - let label = text(crate::i18n::section_display_name(§ion.name)) - .size(self.sz(14)) - .font(self.app_font()) - .color(text_color); - - let content = row![indicator, icon, label] - .spacing(10) - .align_y(iced::Alignment::Center); + // No colour on the texts: they take the button's, which + // selection_style sets to contrast_on(accent) when selected. + let content = row![ + text(section.icon.clone()) + .size(sz(15)) + .font(fonts::glyph_font()), + text(crate::i18n::section_display_name(§ion.name)) + .size(sz(14)) + .font(ui_font()), + ] + .spacing(10) + .align_y(iced::Alignment::Center); - let btn = button(content) + button(content) .on_press(Message::SectionSelected(index)) .padding([10, 14]) .width(Fill) - .style(move |theme, status| { - if is_selected { - button::Style { - background: Some(Palette::BG_SELECTED().into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border::default().rounded(6), - ..button::primary(theme, status) - } - } else { - button::Style { - background: Some(iced::Color::TRANSPARENT.into()), - text_color: Palette::TEXT_DIMMER(), - border: iced::Border::default().rounded(6), - ..button::secondary(theme, status) - } - } - }); - - btn.into() + .style(widgets::selection_style(is_selected)) + .into() } } +/// The sidebar's width: 200 at a text scale of 1.0, growing with the text so +/// "Développement" still fits on one line at 1.68x. +pub(crate) fn sidebar_width() -> f32 { + 200.0 * colony_ui::font_scale() +} + /// The sidebar's update badge for an install Colony cannot update itself: /// informational text, not a button that is guaranteed to fail. -fn return_system_managed_badge<'a>(app: &App, version: &str) -> Element<'a, Message> { +fn return_system_managed_badge<'a>(version: &str) -> Element<'a, Message> { container( text(crate::i18n::t_fmt( "launcher_update_system_managed", &[("version", version)], )) - .size(app.sz(11)) - .font(app.app_font()) + .size(sz(11)) + .font(ui_font()) .color(Palette::TEXT_DIMMER()), ) .padding([6, 12]) diff --git a/src/ui/theme.rs b/src/ui/theme.rs index eb5b8ed..5fa5754 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,6 +1,6 @@ //! Colony's theme — now shared with the rest of the ecosystem. //! -//! The 38-field palette, all 57 theme palettes, the `(family, variant)` +//! The 38-field palette, every theme palette, the `(family, variant)` //! resolver, the picker catalog, the accent overrides and the high-contrast //! derivation live in `colony-ui`, generated from the design tokens in //! Project-Colony-Resources. diff --git a/src/ui/tutorial.rs b/src/ui/tutorial.rs index 1ce9e1b..a14a6ca 100644 --- a/src/ui/tutorial.rs +++ b/src/ui/tutorial.rs @@ -1,6 +1,6 @@ +use colony_ui::{sz, ui_font, ui_font_bold}; use iced::advanced::widget::operation::Outcome; use iced::advanced::widget::{Id, Operation}; -use iced::font::Weight; use iced::widget::{button, column, container, row, text, Column}; use iced::{Color, Element, Fill, Length, Rectangle, Task}; @@ -390,33 +390,29 @@ impl App { let primary_btn = |label_key: &'static str, msg: Message| -> iced::widget::Button<'a, Message> { - button( - text(i18n::t(label_key)) - .size(self.sz(13)) - .font(self.app_font()), - ) - .on_press(msg) - .padding([9, 18]) - .style(|_theme, status| { - let bg = match status { - button::Status::Hovered => Palette::BTN_SUCCESS_HOVER(), - _ => Palette::BTN_SUCCESS(), - }; - button::Style { - background: Some(bg.into()), - text_color: Palette::TEXT_PRIMARY(), - border: iced::Border::default().rounded(8), - ..Default::default() - } - }) + button(text(i18n::t(label_key)).size(sz(13)).font(ui_font())) + .on_press(msg) + .padding([9, 18]) + .style(|_theme, status| { + let bg = match status { + button::Status::Hovered => Palette::BTN_SUCCESS_HOVER(), + _ => Palette::BTN_SUCCESS(), + }; + button::Style { + background: Some(bg.into()), + text_color: Palette::TEXT_PRIMARY(), + border: iced::Border::default().rounded(8), + ..Default::default() + } + }) }; let ghost_btn = |label_key: &'static str, msg: Message| -> iced::widget::Button<'a, Message> { button( text(i18n::t(label_key)) - .size(self.sz(12)) - .font(self.app_font()) + .size(sz(12)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()), ) .on_press(msg) @@ -483,20 +479,20 @@ impl App { .align_y(iced::Alignment::Center); let title_size = if matches!(step.zone, Zone::None) { - self.sz(24) + sz(24) } else { - self.sz(18) + sz(18) }; let mut body: Column<'a, Message> = column![ text(i18n::t(step.title_key)) .size(title_size) - .font(self.app_font_with_weight(Weight::Bold)) + .font(ui_font_bold()) .color(Palette::TEXT_PRIMARY()), container(text("")).height(8), text(i18n::t(step.desc_key)) - .size(self.sz(13)) - .font(self.app_font()) + .size(sz(13)) + .font(ui_font()) .color(Palette::TEXT_SECONDARY()), ] .spacing(2); diff --git a/src/update/keyboard.rs b/src/update/keyboard.rs index 3804fee..7f7da26 100644 --- a/src/update/keyboard.rs +++ b/src/update/keyboard.rs @@ -9,6 +9,8 @@ use iced::Task; use crate::message::Message; use crate::state::App; +const LAST_CATEGORY: usize = crate::ui::SETTINGS_CATEGORIES.len() - 1; + impl App { pub(super) fn keyboard_event(&mut self, event: keyboard::Event) -> Task { if !self.keyboard_nav { @@ -18,7 +20,7 @@ impl App { match key { iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { if self.show_settings { - self.show_settings = false; + self.close_settings(); } else if self.confirm_uninstall.is_some() { self.confirm_uninstall = None; } else if self.show_first_launch { @@ -35,7 +37,6 @@ impl App { { 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 @@ -45,8 +46,6 @@ impl App { } 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(); } @@ -54,7 +53,7 @@ impl App { iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) if self.show_settings => { - self.settings_category = (self.settings_category + 1).min(5); + self.settings_category = (self.settings_category + 1).min(LAST_CATEGORY); } iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) if self.show_settings => @@ -106,7 +105,7 @@ impl App { iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) if self.show_settings => { - self.settings_category = (self.settings_category + 3).min(5); + self.settings_category = (self.settings_category + 3).min(LAST_CATEGORY); } iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) if self.show_settings => diff --git a/src/update/mod.rs b/src/update/mod.rs index 655be6f..9d4680b 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -160,34 +160,29 @@ impl App { /// (`close_behavior`, `update_channel`, `auto_install_updates`) were read /// by nothing and are gone. pub fn save_preferences(&self) { - let prefs = crate::persistence::UserPreferences { + if let Err(e) = crate::persistence::save_preferences(&self.user_preferences()) { + tracing::warn!("Failed to save preferences: {e}"); + } + } + + /// What [`save_preferences`](Self::save_preferences) writes. + pub(crate) fn user_preferences(&self) -> crate::persistence::UserPreferences { + 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()), - auto_accent: Some(self.auto_accent), + standard: self.standard.clone(), + prefs_version: crate::persistence::PREFS_VERSION, // General restore_session: Some(self.restore_session), default_view: Some(self.default_view.clone()), language: Some(self.language.clone()), auto_check_updates: Some(self.auto_check_updates), - // 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}"); } } @@ -202,10 +197,6 @@ impl App { } 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; @@ -214,7 +205,7 @@ impl App { // GitHub / Settings panel even though the underlying // filter just changed. self.show_github_menu = false; - self.show_settings = false; + self.close_settings(); self.save_preferences(); } Task::none() @@ -360,7 +351,6 @@ impl App { ); Task::none() } - Message::FontLoaded(_) => Task::none(), // --- GitHub / OAuth (update/github_auth.rs) --- Message::ToggleGitHubMenu => self.toggle_github_menu(), @@ -405,7 +395,7 @@ impl App { Task::none() } Message::TickNotifications => { - if self.animations && !self.reduce_motion { + if colony_ui::effects_enabled() { // Mark expired toasts for fade-out instead of dropping // them: `removing` re-arms the animation subscription // (has_active_animations), which previously stopped @@ -462,15 +452,6 @@ impl App { 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), @@ -513,22 +494,12 @@ impl App { 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::Preference(change) => self.change_preference(change), 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; @@ -722,43 +693,24 @@ mod tests { #[test] fn every_app_preference_survives_a_save_and_load_round_trip() { let mut app = App::new_for_test(); - app.auto_accent = true; - app.high_contrast = true; - app.reduce_motion = true; - app.selected_accent = "amber".into(); - - // Go through the same struct save_preferences builds, without touching - // the real config dir (that path is covered by the linux-gated test). - let prefs = crate::persistence::UserPreferences { - selected_section: Some(app.selected_section), - window_width: Some(app.window_size.0), - window_height: Some(app.window_size.1), - first_launch_done: Some(!app.show_first_launch), - selected_theme: Some(app.selected_theme.clone()), - selected_variant: Some(app.selected_variant.clone()), - selected_accent: Some(app.selected_accent.clone()), - auto_accent: Some(app.auto_accent), - restore_session: Some(app.restore_session), - default_view: Some(app.default_view.clone()), - language: Some(app.language.clone()), - auto_check_updates: Some(app.auto_check_updates), - font_size: Some(app.font_size.clone()), - animations: Some(app.animations), - high_contrast: Some(app.high_contrast), - text_size_a11y: Some(app.text_size_a11y.clone()), - reduce_motion: Some(app.reduce_motion), - keyboard_nav: Some(app.keyboard_nav), - dyslexia_font: Some(app.dyslexia_font), - scan_on_startup: Some(app.scan_on_startup), - }; - let json = serde_json::to_string(&prefs).expect("serializes"); + app.standard.auto_accent = true; + app.standard.high_contrast = true; + app.standard.reduce_motion = true; + app.standard.selected_accent = Some("amber".into()); + app.keyboard_nav = false; + + // The struct save_preferences writes, without touching the real config + // dir (that path is covered by the linux-gated test). + let json = serde_json::to_string(&app.user_preferences()).expect("serializes"); let back: crate::persistence::UserPreferences = serde_json::from_str(&json).expect("round-trips"); - assert_eq!(back.auto_accent, Some(true), "the field that was lost"); - assert_eq!(back.selected_accent.as_deref(), Some("amber")); - assert_eq!(back.high_contrast, Some(true)); - assert_eq!(back.reduce_motion, Some(true)); + assert_eq!( + back.standard, app.standard, + "auto_accent was the field once lost" + ); + assert_eq!(back.keyboard_nav, Some(false)); + assert_eq!(back.prefs_version, crate::persistence::PREFS_VERSION); // Every field carries a value: a None here would mean save_preferences // is dropping something on the floor. @@ -850,8 +802,8 @@ mod tests { // no animation tick, nothing ever expired the toasts, and the overlay // grows upward from the bottom so the oldest scrolled out of clicking // range and could never be dismissed at all. - app.animations = false; - app.reduce_motion = true; + app.standard.animations = false; + app.standard.reduce_motion = true; for i in 0..12 { let _ = app.push_notification(format!("toast {i}"), NotificationLevel::Info); } diff --git a/src/update/preferences.rs b/src/update/preferences.rs index f996f1c..9456e2c 100644 --- a/src/update/preferences.rs +++ b/src/update/preferences.rs @@ -1,64 +1,59 @@ -//! Settings-panel state and every user preference toggle. +//! Preferences-page state and every user preference. //! -//! 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. +//! The Appearance and Accessibility preferences are colony-ui's +//! [`StandardPreferences`](colony_ui::preferences::StandardPreferences): one +//! [`Change`] message records the change and applies it. Colony's own +//! preferences keep one explicit method each, so each stays greppable by name. +use colony_ui::preferences::Change; 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 { + /// Open or close the page. The Colony name and the page's Close button + /// both send this. pub(super) fn toggle_settings(&mut self) -> Task { - self.show_settings = !self.show_settings; - if !self.show_settings { - self.settings_category = 0; + if self.show_settings { + self.close_settings(); + } else { + self.show_settings = true; } Task::none() } + /// Close the page the way every path out should: back on the first + /// category, every section folded for the next visit. + pub(super) fn close_settings(&mut self) { + self.show_settings = false; + self.settings_category = 0; + self.settings_expanded_sections.clear(); + } + pub(super) fn select_settings_category(&mut self, idx: usize) -> Task { - self.settings_category = idx; + self.settings_category = idx.min(crate::ui::SETTINGS_CATEGORIES.len() - 1); 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); + pub(super) fn toggle_settings_section(&mut self, key: &'static str) -> Task { + if !self.settings_expanded_sections.remove(key) { + self.settings_expanded_sections.insert(key.to_string()); } 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); + /// A change from the shared Appearance or Accessibility widgets. It is in + /// effect the moment it is made: there is no Save button. + pub(super) fn change_preference(&mut self, change: Change) -> Task { + self.standard.update(change); 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); + if matches!(change, Change::Theme { .. }) { + self.push_notification(i18n::t("theme_applied"), NotificationLevel::Info) } else { - set_active_accent(accent_key_to_color(&self.selected_accent)); + Task::none() } - self.save_preferences(); - Task::none() } pub(super) fn toggle_restore_session(&mut self) -> Task { @@ -89,49 +84,12 @@ impl App { 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();