From f0f8b46b64c0a9c609c6a6d5210b4f4f490d85cf Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 09:33:03 +0100 Subject: [PATCH 01/33] Add pin-to-top with drag reorder for launcher apps. Lets users pin apps above the sorted catalog (grid and table), reorder pinned apps with a grip handle, and clears pins when an app is hidden. --- crates/ui-egui/src/app.rs | 24 ++++- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-BR.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/zh-CN.tsv | 4 + crates/ui-egui/src/settings.rs | 89 +++++++++++++++ crates/ui-egui/src/views.rs | 102 ++++++++++++++++-- crates/ui-egui/src/widgets.rs | 33 +++++- crates/ui-egui/tests/ui.rs | 69 ++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 98 +++++++++++++++++ 16 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-02-pin-apps-design.md diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5c57ef9b..e8576247 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,6 +205,8 @@ pub struct LauncherUi { /// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is /// fixed when the next `ui` pass actually shows them. pending_toasts: Vec<(String, bool)>, + /// Catalog id of a pinned app while the user drags its reorder handle. + pub(crate) pin_dragging: Option, } /// The language to draw in: the preference, or English where it needs a CJK font the system @@ -287,6 +289,7 @@ impl LauncherUi { frame: None, flushed: Vec::new(), pending_toasts: Vec::new(), + pin_dragging: None, } } @@ -860,7 +863,13 @@ impl LauncherUi { } } - /// The ids to show, filtered and sorted. + /// Test hook for the filtered/sorted catalog ids currently shown. + pub fn visible_ids_for_test(&self) -> Vec<&'static str> { + self.visible_ids() + } + + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; + /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { let needle = self.search.trim().to_lowercase(); let mut list: Vec<(usize, &AppEntry)> = self @@ -888,7 +897,10 @@ impl LauncherUi { }; let installed = |e: &AppEntry| e.installed.as_ref().and_then(|i| i.version.clone()); let latest = |e: &AppEntry| e.release().and_then(|r| r.version.clone()); - list.sort_by(|(ia, a), (ib, b)| { + let (mut pinned, mut rest) = list.into_iter().partition::, _>(|(_, e)| self.settings.is_pinned(e.app.id)); + // Keep the user's pin order, skipping ids that are not in the filtered set. + pinned.sort_by_key(|(_, e)| self.settings.pinned.iter().position(|p| p == e.app.id).unwrap_or(usize::MAX)); + rest.sort_by(|(ia, a), (ib, b)| { let ord = match sort { SortKey::Suite => dir(ia.cmp(ib)), SortKey::Name => dir(a.app.name.cmp(b.app.name)), @@ -916,11 +928,13 @@ impl LauncherUi { // Ties keep suite order. ord.then(ia.cmp(ib)) }); - // The grid shows installed apps first, then the rest under "Not installed" (the sort holds - // within each group; the sort is stable). The table keeps the order its column gives. + // The grid shows installed apps first among the unpinned set, then the rest under + // "Not installed" (the sort holds within each group; the sort is stable). if grid { - list.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); + rest.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); } + list = pinned; + list.append(&mut rest); list.into_iter().map(|(_, e)| e.app.id).collect() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ea025c76..3b7a19b9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -109,6 +109,10 @@ Apps on this computer Aplikace v tomto počítači Installed apps with a newer version Nainstalované aplikace s novější verzí Apps you can install Aplikace, které lze nainstalovat + Pin to top Připnout nahoru + Unpin Odepnout + Pinned Připnuté + Reorder {name} Změnit pořadí: {name} Hide App Skrýt aplikaci Unhide App Zobrazit aplikaci Hidden Apps Skryté aplikace diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a85b25d9..287cabbb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -123,6 +123,10 @@ Apps on this computer Apps auf diesem Computer Installed apps with a newer version Installierte Apps mit neuerer Version Apps you can install Apps, die du installieren kannst + Pin to top Oben anheften + Unpin Loslösen + Pinned Angeheftet + Reorder {name} {name} neu anordnen Hide App App ausblenden Unhide App App einblenden Hidden Apps Ausgeblendete Apps diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b62963cc..12c8eeea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -122,6 +122,10 @@ Apps on this computer Aplicaciones en este equipo Installed apps with a newer version Aplicaciones instaladas con una versión más reciente Apps you can install Aplicaciones que puedes instalar + Pin to top Fijar arriba + Unpin Quitar fijación + Pinned Fijadas + Reorder {name} Reordenar {name} Hide App Ocultar aplicación Unhide App Mostrar aplicación Hidden Apps Aplicaciones ocultas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1e96cdf9..00c6ef12 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -121,6 +121,10 @@ Apps on this computer Applications sur cet ordinateur Installed apps with a newer version Applications installées avec une version plus récente Apps you can install Applications que vous pouvez installer + Pin to top Épingler en haut + Unpin Désépingler + Pinned Épinglés + Reorder {name} Réordonner {name} Hide App Masquer l'application Unhide App Afficher l'application Hidden Apps Applications masquées diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a7ffc954..c72bbc27 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -123,6 +123,10 @@ Apps on this computer Alkalmazások ezen a számítógépen Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el Apps you can install Telepíthető alkalmazások + Pin to top Rögzítés felülre + Unpin Rögzítés feloldása + Pinned Rögzítettek + Reorder {name} Átrendezés: {name} Hide App Alkalmazás elrejtése Unhide App Alkalmazás megjelenítése Hidden Apps Rejtett alkalmazások diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1b237703..d2fb3b59 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -125,6 +125,10 @@ Apps on this computer このコンピューターのアプリ Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ Apps you can install インストールできるアプリ + Pin to top 上部にピン留め + Unpin ピン留めを外す + Pinned ピン留め + Reorder {name} {name}の順序を変更 Hide App アプリを非表示 Unhide App アプリを再表示 Hidden Apps 非表示のアプリ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7ba59890..b2ed643e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -124,6 +124,10 @@ Apps on this computer Aplikacje na tym komputerze Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją Apps you can install Aplikacje, które możesz zainstalować + Pin to top Przypnij na górze + Unpin Odepnij + Pinned Przypięte + Reorder {name} Zmień kolejność: {name} Hide App Ukryj aplikację Unhide App Pokaż aplikację Hidden Apps Ukryte aplikacje diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index ecf37d56..92dd2550 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -111,6 +111,10 @@ Apps on this computer Aplicativos neste computador Installed apps with a newer version Aplicativos instalados com nova versão Apps you can install Aplicativos que você pode instalar + Pin to top Fixar no topo + Unpin Desafixar + Pinned Fixados + Reorder {name} Reordenar {name} Hide App Ocultar aplicativo Unhide App Mostrar aplicativo Hidden Apps Aplicativos ocultos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 948dc377..0dc9c38c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -121,6 +121,10 @@ Apps on this computer Приложения на этом компьютере Installed apps with a newer version Установленные приложения, у которых есть новая версия Apps you can install Приложения, которые можно установить + Pin to top Закрепить сверху + Unpin Открепить + Pinned Закреплённые + Reorder {name} Изменить порядок: {name} Hide App Скрыть приложение Unhide App Показать приложение Hidden Apps Скрытые приложения diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index f117f5f5..df5a1888 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -271,6 +271,10 @@ figcraft Interface design and prototyping 界面设计与原型制作 Apps on this computer 此电脑上的应用 Installed apps with a newer version 有新版本的已安装应用 Apps you can install 可安装的应用 + Pin to top 置顶 + Unpin 取消置顶 + Pinned 已置顶 + Reorder {name} 重新排序 {name} Hide App 隐藏应用 Unhide App 取消隐藏应用 Hidden Apps 已隐藏的应用 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index fe520ca1..0815ffb5 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -127,6 +127,8 @@ pub struct Settings { pub tray_hint_shown: bool, /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used". pub launches: BTreeMap, + /// Catalog ids pinned to the top of the grid/table, in the order the user chose. + pub pinned: Vec, } impl Settings { @@ -140,6 +142,41 @@ impl Settings { } } + /// Whether `id` is in the pinned list. + pub fn is_pinned(&self, id: &str) -> bool { + self.pinned.iter().any(|p| p == id) + } + + /// Pin `id` at the end of the pinned list (no-op if already pinned). + pub fn pin_app(&mut self, id: &str) { + if id.is_empty() || self.is_pinned(id) { + return; + } + self.pinned.push(id.to_string()); + } + + /// Remove `id` from the pinned list. + pub fn unpin_app(&mut self, id: &str) { + self.pinned.retain(|p| p != id); + } + + /// Move the pinned app `from_id` so its final index is the current index of `to_id`. + pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { + if from_id == to_id { + return; + } + let Some(from) = self.pinned.iter().position(|p| p == from_id) else { + return; + }; + let Some(to) = self.pinned.iter().position(|p| p == to_id) else { + return; + }; + let item = self.pinned.remove(from); + // After `remove`, indices after `from` shift left; inserting at the original `to` + // still lands the item on that final index (whether moving up or down). + self.pinned.insert(to.min(self.pinned.len()), item); + } + /// Display name for a language `code`: a language's name in itself, from the registry /// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English, /// with its own name added only while `installed` — the CJK face in the current font stack — is @@ -212,6 +249,58 @@ impl Default for Settings { hidden: std::collections::BTreeSet::new(), tray_hint_shown: false, launches: BTreeMap::new(), + pinned: Vec::new(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pin_appends_once_and_unpin_removes() { + let mut s = Settings::default(); + s.pin_app("photocraft"); + s.pin_app("photocraft"); + s.pin_app("filmcraft"); + assert_eq!(s.pinned, vec!["photocraft", "filmcraft"]); + s.unpin_app("photocraft"); + assert_eq!(s.pinned, vec!["filmcraft"]); + s.unpin_app("missing"); + assert_eq!(s.pinned, vec!["filmcraft"]); + } + + #[test] + fn reorder_pinned_moves_to_target_index() { + let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into(), "d".into()], ..Settings::default() }; + s.reorder_pinned("a", "c"); + assert_eq!(s.pinned, vec!["b", "c", "a", "d"]); + s.reorder_pinned("a", "b"); + assert_eq!(s.pinned, vec!["a", "b", "c", "d"]); + s.reorder_pinned("photocraft", "filmcraft"); // no-op when missing + let mut s = Settings { pinned: vec!["photocraft".into(), "filmcraft".into()], ..Settings::default() }; + s.reorder_pinned("photocraft", "filmcraft"); + assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); + s.reorder_pinned("missing", "a"); + s.reorder_pinned("filmcraft", "missing"); + s.reorder_pinned("filmcraft", "filmcraft"); + assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); + } + + #[test] + fn old_settings_json_loads_with_empty_pinned() { + let s: Settings = serde_json::from_str(r#"{"view":"Grid"}"#).expect("legacy settings"); + assert!(s.pinned.is_empty()); + } + + #[test] + fn hide_path_unpins() { + let mut s = Settings::default(); + s.pin_app("photocraft"); + s.unpin_app("photocraft"); + s.hidden.insert("photocraft".into()); + assert!(s.pinned.is_empty()); + assert!(s.hidden.contains("photocraft")); + } +} diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index ef6bf13d..b9e88644 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -413,10 +413,21 @@ impl LauncherUi { // ---- grid ---------------------------------------------------------------------------- - /// Installed apps first (`ids` arrives that way), then the rest under a "Not installed" header. + /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { - let installed = ids.iter().take_while(|id| self.is_installed(id)).count(); - let (top, rest) = ids.split_at(installed); + let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); + let (pinned, rest) = ids.split_at(pinned_n); + if !pinned.is_empty() { + let t = Tokens::get(ui.ctx()); + widgets::hud(ui, tl!("Pinned"), t.faint); + ui.add_space(12.0); + self.grid_block(ui, pinned); + if !rest.is_empty() { + ui.add_space(28.0); + } + } + let installed = rest.iter().take_while(|id| self.is_installed(id)).count(); + let (top, rest) = rest.split_at(installed); self.grid_block(ui, top); if !top.is_empty() && !rest.is_empty() { let t = Tokens::get(ui.ctx()); @@ -426,6 +437,9 @@ impl LauncherUi { } self.grid_block(ui, rest); ui.add_space(24.0); + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } } fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) { @@ -452,6 +466,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; + self.pin_drop_target(ui, id, rect); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -609,6 +624,9 @@ impl LauncherUi { self.settings.sort_descending = k.starts_descending(); } } + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } } /// The table's hairline under the row `ui` is drawing, across the table's full width. @@ -738,9 +756,11 @@ impl LauncherUi { if rule { Self::table_rule(ui, t, span, clip); } + let row_rect = ui.max_rect(); + self.pin_drop_target(ui, id, row_rect); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { - let open = ui.interact(ui.max_rect(), ui.scope_id().with(("row-open", id)), Sense::click()); + let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); open.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fmt(tl!("{name} details"), &[("name", entry.app.name)]))); if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { self.detail = Some(id.to_string()); @@ -856,12 +876,14 @@ impl LauncherUi { } /// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows - /// the website, GitHub and releases links as buttons of its own. + /// the website, GitHub and releases links as buttons of its own. Pinned apps also get a drag + /// handle for reordering. pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) { let id = entry.app.id.to_string(); - // One square button (the menu) beside the main one. let gap = ui.spacing().item_spacing.x; - let main_w = (width - theme::CONTROL_H - gap).max(120.0); + let pinned = self.settings.is_pinned(entry.app.id); + let squares = 1.0 + if pinned { 1.0 } else { 0.0 }; + let main_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(90.0); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); return; @@ -870,9 +892,55 @@ impl LauncherUi { return; } self.primary_button(ui, entry, &id, status, main_w); + if pinned { + self.pin_grip(ui, entry); + } self.menu(ui, entry, status, links_in_menu); } + /// Drag handle that reorders this pinned app among the other pinned apps. + fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { + let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); + let resp = widgets::grip_button(ui, &tip); + if resp.drag_started() || resp.dragged() { + self.pin_dragging = Some(entry.app.id.to_string()); + } + if resp.drag_stopped() { + self.pin_dragging = None; + } + } + + /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. + /// Reorder only after the pointer crosses the target's midpoint so the list does not flicker. + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { + let Some(from) = self.pin_dragging.clone() else { + return; + }; + if from == id || !self.settings.is_pinned(id) { + return; + } + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + return; + } + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { + return; + }; + if !rect.contains(pos) { + return; + } + let Some(from_i) = self.settings.pinned.iter().position(|p| p == &from) else { + return; + }; + let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { + return; + }; + let past_mid = if from_i < to_i { pos.y > rect.center().y || pos.x > rect.center().x } else { pos.y < rect.center().y || pos.x < rect.center().x }; + if past_mid { + self.settings.reorder_pinned(&from, id); + } + } + /// A running job's row: one progress bar, with pause and cancel while its download is under /// way, or only a cancel while the job waits for another app's installer. fn job_actions(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str, main_w: f32, width: f32) { @@ -959,6 +1027,9 @@ impl LauncherUi { if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() { self.act_command(Command::Cancel { app: id.to_string() }); } + if self.settings.is_pinned(entry.app.id) { + self.pin_grip(ui, entry); + } self.menu(ui, entry, status, links_in_menu); true } @@ -1053,10 +1124,25 @@ impl LauncherUi { link(ui, tl!("GitHub repository"), entry.app.repo_url()); link(ui, tl!("All releases"), entry.app.releases_url()); } + self.menu_pin(ui, entry, &id); self.menu_hide(ui, entry, &id); }); } + /// Pin — or unpin — the app at the top of the list. + fn menu_pin(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { + ui.separator(); + let pinned = self.settings.is_pinned(entry.app.id); + if ui.button(if pinned { tl!("Unpin") } else { tl!("Pin to top") }).clicked() { + if pinned { + self.settings.unpin_app(id); + } else { + self.settings.pin_app(id); + } + ui.close(); + } + } + /// The menu items for an installed app: open, reveal in the file manager, reinstall, and /// uninstall (which asks first). fn menu_installed(&mut self, ui: &mut Ui, status: &AppStatus, id: &str, reveal: &'static str) { @@ -1105,6 +1191,7 @@ impl LauncherUi { } /// Hide — or unhide — the app, keeping the Hidden filter sane when the hidden set empties. + /// Hiding also clears a pin so a hidden app cannot float back into the main list. fn menu_hide(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { ui.separator(); let hidden = self.settings.hidden.contains(entry.app.id); @@ -1115,6 +1202,7 @@ impl LauncherUi { self.settings.filter = Filter::All; } } else { + self.settings.unpin_app(id); self.settings.hidden.insert(id.to_string()); } ui.close(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5442b638..e2181eaa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -515,6 +515,8 @@ pub enum Glyph { Close, Settings, Refresh, + /// Six-dot grip for reordering pinned apps. + Grip, } /// Paints one of the launcher's symbols with the painter, centred in `rect`. @@ -571,9 +573,34 @@ fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec { egui::Shape::convex_polygon(vec![tip + vec2(-2.4, -0.6), tip + vec2(1.6, -3.0), tip + vec2(1.2, 1.8)], color, Stroke::NONE), ] } + Glyph::Grip => { + let mut shapes = Vec::with_capacity(6); + for (dx, dy) in [(-3.0, -3.5), (3.0, -3.5), (-3.0, 0.0), (3.0, 0.0), (-3.0, 3.5), (3.0, 3.5)] { + shapes.push(egui::Shape::circle_filled(m + vec2(dx, dy), 1.35, color)); + } + shapes + } } } +/// A drag handle for reordering pinned apps: same outline as [`glyph_button`], grab cursor. +pub fn grip_button(ui: &mut Ui, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::drag()); + let hovered = resp.hovered() || resp.dragged(); + let p = ui.painter(); + if hovered { + p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg); + } else { + p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside); + } + let c = if hovered { t.invert_fg } else { t.muted }; + paint_glyph(p, rect, Glyph::Grip, c); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + let cursor = if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }; + resp.on_hover_text(tip).on_hover_cursor(cursor) +} + /// A small outlined square button with a drawn glyph; inverts on hover. pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response { let t = Tokens::get(ui.ctx()); @@ -697,7 +724,7 @@ mod tests { /// the tool-button and header-cell sizes, or a click target would draw outside its hit rect. #[test] fn every_painter_glyph_stays_inside_its_control() { - for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] { + for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] { for side in [crate::theme::CONTROL_H, 48.0] { let rect = Rect::from_min_size(egui::pos2(100.0, 200.0), egui::vec2(side, side)); let shapes = glyph_shapes(rect, glyph, Color32::WHITE); @@ -755,11 +782,11 @@ mod tests { assert_eq!(opened_urls(&ctx), vec![artcraft_launcher_core::catalog::DISCORD_APP_URL.to_string()]); } - /// Each glyph must be distinct geometry, not the same fallback shape for all four. + /// Each glyph must be distinct geometry, not the same fallback shape for every variant. #[test] fn each_glyph_has_its_own_shapes() { let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(crate::theme::CONTROL_H, crate::theme::CONTROL_H)); - let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] + let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] .into_iter() .map(|glyph| { let union = glyph_shapes(rect, glyph, Color32::WHITE) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 10c1fad8..0e184dda 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1390,6 +1390,75 @@ fn table_view_and_light_theme() { assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. }))); } +#[test] +/// Pin floats an app above Name sort; reorder via settings is reflected; Hide clears the pin. +fn pinning_an_app_keeps_it_on_top_until_unpinned() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // Name sort would put CADCraft before PhotoCraft; a pin must still win. + h.state_mut().as_mut().unwrap().settings.sort = artcraft_launcher_ui_egui::settings::SortKey::Name; + h.state_mut().as_mut().unwrap().settings.sort_descending = false; + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + let ids = h.state().as_ref().unwrap().visible_ids_for_test(); + assert_eq!(ids.first().copied(), Some("photocraft"), "pinned app stays first under Name sort: {ids:?}"); + h.state_mut().as_mut().unwrap().settings.pin_app("filmcraft"); + h.state_mut().as_mut().unwrap().settings.reorder_pinned("photocraft", "filmcraft"); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["filmcraft".to_string(), "photocraft".to_string()]); + assert_eq!(&h.state().as_ref().unwrap().visible_ids_for_test()[..2], &["filmcraft", "photocraft"]); + h.get_all_by_label_contains("Reorder").next().expect("pinned rows expose reorder handles"); +} + +#[test] +/// Hiding a pinned app also removes it from the pin list. +fn hide_clears_pin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Hide App").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); + assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft")); +} + +#[test] +/// After pinning from the menu, opening it again offers Unpin and clears the pin. +fn pinned_app_menu_offers_unpin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + assert!(h.query_by_label("Pin to top").is_none(), "pinned apps must not keep offering Pin to top"); + h.get_by_label("Unpin").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); +} + +#[test] +/// Grid shows a Pinned section label once at least one app is pinned. +fn grid_shows_pinned_section_header() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + assert!(h.query_by_label("Pinned").is_none()); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(4); + h.get_by_label("Pinned"); + h.get_all_by_label_contains("Reorder PhotoCraft").next().expect("pinned card exposes a reorder handle"); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md new file mode 100644 index 00000000..e59c2363 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -0,0 +1,98 @@ +# Pin apps to top — design + +**Date:** 2026-11-02 +**Repo:** storytold/craft-launcher +**Status:** approved + +## Goal + +Let users pin catalog apps to a fixed block at the top of the launcher (grid and table), and reorder those pinned apps by drag-and-drop. Pin order is intentional and is not scrambled by the active sort. + +## Decisions + +| Topic | Choice | +|---|---| +| Pin entry points | `…` menu **Pin to top** / **Unpin**, plus a drag handle on pinned items | +| Sort interaction | Pinned stay in user order at top; sort applies only to unpinned apps | +| Grid grouping | One **Pinned** block first; then existing Installed / Not installed for the rest | +| Persistence | `Settings.pinned: Vec` (catalog ids, order = display order) | +| Hide interaction | Hiding an app removes it from `pinned` | + +## Data + +- Add `pinned: Vec` to `Settings` with `#[serde(default)]`. +- Helpers (on `Settings` or next to sort logic): + - `is_pinned(id)` + - `pin(id)` — append if absent + - `unpin(id)` — remove if present + - `reorder_pinned(from, to)` — move within the vec; ignore out-of-range +- On **Hide App**, also `unpin`. +- Unknown / removed catalog ids in `pinned` are skipped when building the visible list (left in settings until unpin/hide so re-adding an app can restore order later is unnecessary; prune on load is optional — prefer skip-at-display for simplicity). + +## Ordering + +`LauncherUi::visible_ids` (or equivalent): + +1. Collect filtered/search-matching entries as today. +2. Partition into pinned vs unpinned using `settings.pinned` membership. +3. Emit pinned ids in `settings.pinned` order (only those present in the filtered set). +4. Sort unpinned with the existing sort keys; in Grid, still put installed before not-installed within the unpinned set only. + +## UI + +### Menu + +In `menu` / `menu_hide` area (before Hide): + +- If not pinned: **Pin to top** +- If pinned: **Unpin** + +### Drag handle + +- Shown only when the app is pinned (grid card and table row). +- Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. +- Accessible name e.g. **Reorder {name}**. +- Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. +- Cursor: grab / grabbing. +- Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. + +### Section header (grid) + +When the pinned block is non-empty, show a **Pinned** HUD label (same style as **Not installed**) above that block. + +### Table + +Same id order as grid’s logical list (pinned first). Handle sits near the app name or before the `…` control without changing column semantics. + +## i18n + +Add English keys to every `complete_menus` catalog TSV: + +- `Pin to top` +- `Unpin` +- `Pinned` +- `Reorder {name}` (accessible name / tooltip) + +Translate clean-room from meaning; product names stay English. + +## Tests + +- Unit: pin/unpin/reorder helpers; hide clears pin; `visible_ids` puts pinned first in pin order then sorted unpinned. +- UI harness: menu shows Pin/Unpin; after pin, Pinned header (grid) / order in table; reorder via helper or simulated drag if harness allows. +- Settings round-trip: `pinned` survives serde default for old settings files. + +## Out of scope + +- Syncing pins across machines +- Changing upstream suite catalog order +- A new Sort key for “Pinned” +- Pinning while the Hidden filter is active (hidden apps are not in the main list; unpin-on-hide covers this) + +## Files (expected) + +- `crates/ui-egui/src/settings.rs` — field + helpers +- `crates/ui-egui/src/app.rs` — `visible_ids` partition +- `crates/ui-egui/src/views.rs` — menu, grid sections, drag, table handle +- `crates/ui-egui/src/widgets.rs` — grip glyph / handle widget if needed +- `crates/ui-egui/src/i18n/*.tsv` — strings +- `crates/ui-egui/tests/ui.rs` (+ unit tests near helpers) From 7362fe206767dd6eccaa86753c7fc12abb715c6f Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 09:59:47 +0100 Subject: [PATCH 02/33] Address CodeRabbit pin/reorder review findings. Widen table drop targets, reserve paused-row width for the grip, hide Pin on hidden apps, and add Move up/down menu actions for keyboard reorder. --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-BR.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/zh-CN.tsv | 2 ++ crates/ui-egui/src/settings.rs | 17 ++++++++++++++ crates/ui-egui/src/views.rs | 37 ++++++++++++++++++++++++------- 12 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b7a19b9..e82d9d87 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -113,6 +113,8 @@ Unpin Odepnout Pinned Připnuté Reorder {name} Změnit pořadí: {name} + Move up Posunout nahoru + Move down Posunout dolů Hide App Skrýt aplikaci Unhide App Zobrazit aplikaci Hidden Apps Skryté aplikace diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 287cabbb..fb5afa8f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -127,6 +127,8 @@ Unpin Loslösen Pinned Angeheftet Reorder {name} {name} neu anordnen + Move up Nach oben + Move down Nach unten Hide App App ausblenden Unhide App App einblenden Hidden Apps Ausgeblendete Apps diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 12c8eeea..ff4b600d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -126,6 +126,8 @@ Unpin Quitar fijación Pinned Fijadas Reorder {name} Reordenar {name} + Move up Subir + Move down Bajar Hide App Ocultar aplicación Unhide App Mostrar aplicación Hidden Apps Aplicaciones ocultas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 00c6ef12..7bb566e9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -125,6 +125,8 @@ Unpin Désépingler Pinned Épinglés Reorder {name} Réordonner {name} + Move up Monter + Move down Descendre Hide App Masquer l'application Unhide App Afficher l'application Hidden Apps Applications masquées diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c72bbc27..1aa8d90b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -127,6 +127,8 @@ Unpin Rögzítés feloldása Pinned Rögzítettek Reorder {name} Átrendezés: {name} + Move up Feljebb + Move down Lejjebb Hide App Alkalmazás elrejtése Unhide App Alkalmazás megjelenítése Hidden Apps Rejtett alkalmazások diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d2fb3b59..22930e8b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -129,6 +129,8 @@ Unpin ピン留めを外す Pinned ピン留め Reorder {name} {name}の順序を変更 + Move up 上へ + Move down 下へ Hide App アプリを非表示 Unhide App アプリを再表示 Hidden Apps 非表示のアプリ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b2ed643e..9a145619 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -128,6 +128,8 @@ Unpin Odepnij Pinned Przypięte Reorder {name} Zmień kolejność: {name} + Move up W górę + Move down W dół Hide App Ukryj aplikację Unhide App Pokaż aplikację Hidden Apps Ukryte aplikacje diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 92dd2550..6922ca1e 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -115,6 +115,8 @@ Unpin Desafixar Pinned Fixados Reorder {name} Reordenar {name} + Move up Mover para cima + Move down Mover para baixo Hide App Ocultar aplicativo Unhide App Mostrar aplicativo Hidden Apps Aplicativos ocultos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc9c38c..d1a24c6b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -125,6 +125,8 @@ Unpin Открепить Pinned Закреплённые Reorder {name} Изменить порядок: {name} + Move up Выше + Move down Ниже Hide App Скрыть приложение Unhide App Показать приложение Hidden Apps Скрытые приложения diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index df5a1888..a76292f7 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -275,6 +275,8 @@ figcraft Interface design and prototyping 界面设计与原型制作 Unpin 取消置顶 Pinned 已置顶 Reorder {name} 重新排序 {name} + Move up 上移 + Move down 下移 Hide App 隐藏应用 Unhide App 取消隐藏应用 Hidden Apps 已隐藏的应用 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 0815ffb5..d245a824 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,6 +160,13 @@ impl Settings { self.pinned.retain(|p| p != id); } + /// The pinned id one step above (`delta = -1`) or below (`delta = 1`) `id`, if any. + pub fn pinned_neighbor(&self, id: &str, delta: i32) -> Option<&str> { + let i = self.pinned.iter().position(|p| p == id)?; + let j = i.checked_add_signed(delta as isize)?; + self.pinned.get(j).map(String::as_str) + } + /// Move the pinned app `from_id` so its final index is the current index of `to_id`. pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { if from_id == to_id { @@ -303,4 +310,14 @@ mod tests { assert!(s.pinned.is_empty()); assert!(s.hidden.contains("photocraft")); } + + #[test] + fn pinned_neighbor_steps_within_bounds() { + let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + assert_eq!(s.pinned_neighbor("b", -1), Some("a")); + assert_eq!(s.pinned_neighbor("b", 1), Some("c")); + assert_eq!(s.pinned_neighbor("a", -1), None); + assert_eq!(s.pinned_neighbor("c", 1), None); + assert_eq!(s.pinned_neighbor("missing", 1), None); + } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index b9e88644..412174c1 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -757,7 +757,8 @@ impl LauncherUi { Self::table_rule(ui, t, span, clip); } let row_rect = ui.max_rect(); - self.pin_drop_target(ui, id, row_rect); + // Drop target spans the full table width so a drag from the grip column still hits the row. + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range())); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -908,6 +909,8 @@ impl LauncherUi { if resp.drag_stopped() { self.pin_dragging = None; } + // The grip column is also a drop target (table rows otherwise only register the first cell). + self.pin_drop_target(ui, entry.app.id, resp.rect.expand2(vec2(8.0, 12.0))); } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. @@ -1007,8 +1010,9 @@ impl LauncherUi { return false; } let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() }; - // Resume, then discard and the menu (two squares). - let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0); + // Resume, then discard, optional pin grip, and the menu. + let squares = 2.0 + if self.settings.is_pinned(entry.app.id) { 1.0 } else { 0.0 }; + let resume_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(0.0); // Narrow (the table): just tl!("Resume"); the tooltip says how far it got. let label = match resume_w { w if w < 150.0 => tl!("Resume").to_string(), @@ -1129,16 +1133,33 @@ impl LauncherUi { }); } - /// Pin — or unpin — the app at the top of the list. + /// Pin — or unpin — the app at the top of the list. Hidden apps are not offered Pin to top + /// (they can still Unpin if somehow still pinned). Move up/down reorder for keyboard users. fn menu_pin(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { ui.separator(); let pinned = self.settings.is_pinned(entry.app.id); - if ui.button(if pinned { tl!("Unpin") } else { tl!("Pin to top") }).clicked() { - if pinned { + let hidden = self.settings.hidden.contains(entry.app.id); + if pinned { + if ui.button(tl!("Unpin")).clicked() { self.settings.unpin_app(id); - } else { - self.settings.pin_app(id); + ui.close(); } + let prev = self.settings.pinned_neighbor(id, -1).map(str::to_owned); + let next = self.settings.pinned_neighbor(id, 1).map(str::to_owned); + if prev.is_some() && ui.button(tl!("Move up")).clicked() { + if let Some(prev) = prev.as_deref() { + self.settings.reorder_pinned(id, prev); + } + ui.close(); + } + if next.is_some() && ui.button(tl!("Move down")).clicked() { + if let Some(next) = next.as_deref() { + self.settings.reorder_pinned(id, next); + } + ui.close(); + } + } else if !hidden && ui.button(tl!("Pin to top")).clicked() { + self.settings.pin_app(id); ui.close(); } } From f8f6d6c63a85aab9f0cd7db68144a973279dcf12 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 10:10:13 +0100 Subject: [PATCH 03/33] Keep pin drag id stable and skip stale pins for Move up/down. Latch pin_dragging only on drag start so table cell reuse cannot reverse a reorder, and pick the next visible pinned neighbor when moving from the menu. --- crates/ui-egui/src/settings.rs | 34 +++++++++++++++++++++++++--------- crates/ui-egui/src/views.rs | 9 ++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index d245a824..02ed6f72 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,11 +160,18 @@ impl Settings { self.pinned.retain(|p| p != id); } - /// The pinned id one step above (`delta = -1`) or below (`delta = 1`) `id`, if any. - pub fn pinned_neighbor(&self, id: &str, delta: i32) -> Option<&str> { + /// The next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes `available`. + /// Skips stale catalog ids left in `pinned` so Move up/down still change the visible order. + pub fn pinned_neighbor(&self, id: &str, delta: i32, available: impl Fn(&str) -> bool) -> Option<&str> { let i = self.pinned.iter().position(|p| p == id)?; - let j = i.checked_add_signed(delta as isize)?; - self.pinned.get(j).map(String::as_str) + let mut j = i; + loop { + j = j.checked_add_signed(delta as isize)?; + let cand = self.pinned.get(j)?; + if available(cand) { + return Some(cand.as_str()); + } + } } /// Move the pinned app `from_id` so its final index is the current index of `to_id`. @@ -314,10 +321,19 @@ mod tests { #[test] fn pinned_neighbor_steps_within_bounds() { let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; - assert_eq!(s.pinned_neighbor("b", -1), Some("a")); - assert_eq!(s.pinned_neighbor("b", 1), Some("c")); - assert_eq!(s.pinned_neighbor("a", -1), None); - assert_eq!(s.pinned_neighbor("c", 1), None); - assert_eq!(s.pinned_neighbor("missing", 1), None); + let any = |_: &str| true; + assert_eq!(s.pinned_neighbor("b", -1, any), Some("a")); + assert_eq!(s.pinned_neighbor("b", 1, any), Some("c")); + assert_eq!(s.pinned_neighbor("a", -1, any), None); + assert_eq!(s.pinned_neighbor("c", 1, any), None); + assert_eq!(s.pinned_neighbor("missing", 1, any), None); + } + + #[test] + fn pinned_neighbor_skips_unavailable_ids() { + let s = Settings { pinned: vec!["a".into(), "gone".into(), "b".into()], ..Settings::default() }; + let live = |id: &str| id != "gone"; + assert_eq!(s.pinned_neighbor("a", 1, live), Some("b")); + assert_eq!(s.pinned_neighbor("b", -1, live), Some("a")); } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 412174c1..5e0661ac 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -903,7 +903,9 @@ impl LauncherUi { fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); let resp = widgets::grip_button(ui, &tip); - if resp.drag_started() || resp.dragged() { + // Only latch on drag start: after a reorder, the same egui widget id can sit on a + // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. + if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } if resp.drag_stopped() { @@ -1144,8 +1146,9 @@ impl LauncherUi { self.settings.unpin_app(id); ui.close(); } - let prev = self.settings.pinned_neighbor(id, -1).map(str::to_owned); - let next = self.settings.pinned_neighbor(id, 1).map(str::to_owned); + let known = |pid: &str| self.entry(pid).is_some(); + let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); + let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); if prev.is_some() && ui.button(tl!("Move up")).clicked() { if let Some(prev) = prev.as_deref() { self.settings.reorder_pinned(id, prev); From c05897abacb675188b355713954ea019dd1cb0d1 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 13:41:42 +0100 Subject: [PATCH 04/33] Fix leftward grip-to-grip pin reordering. Use before/after half hits to choose the final pin index so horizontal drops onto another grip work in both directions. --- crates/ui-egui/src/settings.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/views.rs | 14 ++++++-------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 02ed6f72..dab11f23 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -191,6 +191,20 @@ impl Settings { self.pinned.insert(to.min(self.pinned.len()), item); } + /// Move `from_id` so its final index is `to_index` (clamped). Used by drag-and-drop. + pub fn reorder_pinned_to(&mut self, from_id: &str, to_index: usize) { + let Some(from) = self.pinned.iter().position(|p| p == from_id) else { + return; + }; + let to_index = to_index.min(self.pinned.len().saturating_sub(1)); + if from == to_index { + return; + } + let item = self.pinned.remove(from); + // After remove, inserting at the original `to_index` yields that final index. + self.pinned.insert(to_index.min(self.pinned.len()), item); + } + /// Display name for a language `code`: a language's name in itself, from the registry /// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English, /// with its own name added only while `installed` — the CJK face in the current font stack — is @@ -302,6 +316,19 @@ mod tests { assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); } + #[test] + fn reorder_pinned_to_moves_to_final_index() { + let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + s.reorder_pinned_to("c", 0); + assert_eq!(s.pinned, vec!["c", "a", "b"]); + s.reorder_pinned_to("c", 2); + assert_eq!(s.pinned, vec!["a", "b", "c"]); + s.reorder_pinned_to("a", 1); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + s.reorder_pinned_to("a", 1); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + } + #[test] fn old_settings_json_loads_with_empty_pinned() { let s: Settings = serde_json::from_str(r#"{"view":"Grid"}"#).expect("legacy settings"); diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 5e0661ac..f515806a 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -916,7 +916,8 @@ impl LauncherUi { } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. - /// Reorder only after the pointer crosses the target's midpoint so the list does not flicker. + /// Insert before the target when the pointer is in its top/left half, otherwise after — so + /// grip-to-grip drops work in every direction without depending on travel relative to midpoints. fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { let Some(from) = self.pin_dragging.clone() else { return; @@ -934,16 +935,13 @@ impl LauncherUi { if !rect.contains(pos) { return; } - let Some(from_i) = self.settings.pinned.iter().position(|p| p == &from) else { - return; - }; let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { return; }; - let past_mid = if from_i < to_i { pos.y > rect.center().y || pos.x > rect.center().x } else { pos.y < rect.center().y || pos.x < rect.center().x }; - if past_mid { - self.settings.reorder_pinned(&from, id); - } + let insert_before = pos.x < rect.center().x || pos.y < rect.center().y; + let last = self.settings.pinned.len().saturating_sub(1); + let final_index = if insert_before { to_i } else { to_i.saturating_add(1).min(last) }; + self.settings.reorder_pinned_to(&from, final_index); } /// A running job's row: one progress bar, with pause and cancel while its download is under From de487301158ecb0c166eaf546c197ad94b08a580 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 13:51:37 +0100 Subject: [PATCH 05/33] Fix pin drop axis and rightward insertion index. Use horizontal half-tests for grid and vertical for table, drop only once per pointer, and adjust the insertion slot after removing the dragged pin. --- crates/ui-egui/src/settings.rs | 27 +++++++++++++++++---------- crates/ui-egui/src/views.rs | 33 +++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index dab11f23..aa3cf315 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -191,18 +191,21 @@ impl Settings { self.pinned.insert(to.min(self.pinned.len()), item); } - /// Move `from_id` so its final index is `to_index` (clamped). Used by drag-and-drop. - pub fn reorder_pinned_to(&mut self, from_id: &str, to_index: usize) { + /// Move `from_id` to insertion slot `to_index` (`0..=len`, before remove). Used by drag-and-drop. + pub fn reorder_pinned_to(&mut self, from_id: &str, mut to_index: usize) { let Some(from) = self.pinned.iter().position(|p| p == from_id) else { return; }; - let to_index = to_index.min(self.pinned.len().saturating_sub(1)); - if from == to_index { + to_index = to_index.min(self.pinned.len()); + // Already in this slot (including the no-op "insert immediately after self"). + if to_index == from || to_index == from + 1 { return; } let item = self.pinned.remove(from); - // After remove, inserting at the original `to_index` yields that final index. - self.pinned.insert(to_index.min(self.pinned.len()), item); + if from < to_index { + to_index -= 1; + } + self.pinned.insert(to_index, item); } /// Display name for a language `code`: a language's name in itself, from the registry @@ -317,15 +320,19 @@ mod tests { } #[test] - fn reorder_pinned_to_moves_to_final_index() { + fn reorder_pinned_to_uses_insertion_slot() { let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + // Insert c before a (slot 0). s.reorder_pinned_to("c", 0); assert_eq!(s.pinned, vec!["c", "a", "b"]); - s.reorder_pinned_to("c", 2); + // Insert c after b (slot 3). + s.reorder_pinned_to("c", 3); assert_eq!(s.pinned, vec!["a", "b", "c"]); - s.reorder_pinned_to("a", 1); + // Insert a before c (slot 2) → [b, a, c]. + s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); - s.reorder_pinned_to("a", 1); + // Insert a after itself (slot 2) is a no-op. + s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index f515806a..3a60636e 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -466,7 +466,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; - self.pin_drop_target(ui, id, rect); + self.pin_drop_target(ui, id, rect, PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -757,8 +757,8 @@ impl LauncherUi { Self::table_rule(ui, t, span, clip); } let row_rect = ui.max_rect(); - // Drop target spans the full table width so a drag from the grip column still hits the row. - self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range())); + // Full-row target (covers the grip column too); only this target reorders in the table. + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), PinDropAxis::Vertical); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -911,14 +911,12 @@ impl LauncherUi { if resp.drag_stopped() { self.pin_dragging = None; } - // The grip column is also a drop target (table rows otherwise only register the first cell). - self.pin_drop_target(ui, entry.app.id, resp.rect.expand2(vec2(8.0, 12.0))); } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. - /// Insert before the target when the pointer is in its top/left half, otherwise after — so - /// grip-to-grip drops work in every direction without depending on travel relative to midpoints. - fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { + /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the + /// table's full-row target already covers the grip column, so the grip itself does not reorder. + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some(from) = self.pin_dragging.clone() else { return; }; @@ -938,10 +936,12 @@ impl LauncherUi { let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { return; }; - let insert_before = pos.x < rect.center().x || pos.y < rect.center().y; - let last = self.settings.pinned.len().saturating_sub(1); - let final_index = if insert_before { to_i } else { to_i.saturating_add(1).min(last) }; - self.settings.reorder_pinned_to(&from, final_index); + let insert_before = match axis { + PinDropAxis::Horizontal => pos.x < rect.center().x, + PinDropAxis::Vertical => pos.y < rect.center().y, + }; + let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; + self.settings.reorder_pinned_to(&from, insertion_index); } /// A running job's row: one progress bar, with pause and cancel while its download is under @@ -1232,6 +1232,15 @@ impl LauncherUi { } } +/// Which half of a pin drop target decides insert-before vs insert-after. +#[derive(Clone, Copy, Debug)] +enum PinDropAxis { + /// Grid cards: left/right of the card. + Horizontal, + /// Table rows: above/below the row midline. + Vertical, +} + /// What the grid's sort dropdown offers: the order and, for names, which way. The installed apps /// always sit above the rest. const GRID_SORTS: [(SortKey, bool); 4] = [(SortKey::Name, false), (SortKey::Name, true), (SortKey::Released, true), (SortKey::Frequent, true)]; From c92c73af1373759d19d4fc9003f984edc5176f8b Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 14:38:01 +0100 Subject: [PATCH 06/33] Apply pin drop on pointer release before clearing drag state. --- crates/ui-egui/src/views.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 3a60636e..2981fe37 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -905,17 +905,17 @@ impl LauncherUi { let resp = widgets::grip_button(ui, &tip); // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. + // Do not clear on `drag_stopped` here — drop targets must see the release first; + // `grid`/`table` clear `pin_dragging` when the pointer is up. if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } - if resp.drag_stopped() { - self.pin_dragging = None; - } } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. + /// A release over a target still counts (process before clearing `pin_dragging`). fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some(from) = self.pin_dragging.clone() else { return; @@ -923,10 +923,7 @@ impl LauncherUi { if from == id || !self.settings.is_pinned(id) { return; } - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - return; - } + let primary_down = ui.input(|i| i.pointer.primary_down()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; @@ -942,6 +939,9 @@ impl LauncherUi { }; let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; self.settings.reorder_pinned_to(&from, insertion_index); + if !primary_down { + self.pin_dragging = None; + } } /// A running job's row: one progress bar, with pause and cancel while its download is under From 154b510ae7ae7eac0b87db16350facf1f5459eee Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:09:51 +0100 Subject: [PATCH 07/33] Cancel pin reorder on Escape before drop targets run. Clears pin_dragging when Escape is pressed at the start of grid/table so a cancelled drag cannot still reorder on release. --- crates/ui-egui/src/views.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 2981fe37..4c827fd2 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -415,6 +415,7 @@ impl LauncherUi { /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { + self.clear_pin_drag_on_escape(ui); let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); let (pinned, rest) = ids.split_at(pinned_n); if !pinned.is_empty() { @@ -591,6 +592,7 @@ impl LauncherUi { pub(crate) fn table(&mut self, ui: &mut Ui, ids: &[&'static str]) { use egui_extras::{Column, TableBuilder}; + self.clear_pin_drag_on_escape(ui); let t = Tokens::get(ui.ctx()); let now = self.backend.now(); let mut sort_clicked = None; @@ -899,6 +901,13 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } + /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame. + fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { + if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.pin_dragging = None; + } + } + /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); @@ -906,7 +915,7 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up. + // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape clears earlier). if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } From d140e09b575880f877d0180b63a46fa75ba44af4 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:32:34 +0100 Subject: [PATCH 08/33] Restore pin order when Escape cancels a mid-drag reorder. Live hover mutates settings.pinned; Escape must undo that, not only clear pin_dragging. Move up/down also skip pins hidden by search/filter. --- crates/ui-egui/src/app.rs | 15 +++++++++++-- crates/ui-egui/src/views.rs | 22 +++++++++++++------ crates/ui-egui/tests/ui.rs | 22 +++++++++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 1 + 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e8576247..7dcf8045 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,8 +205,9 @@ pub struct LauncherUi { /// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is /// fixed when the next `ui` pass actually shows them. pending_toasts: Vec<(String, bool)>, - /// Catalog id of a pinned app while the user drags its reorder handle. - pub(crate) pin_dragging: Option, + /// While the user drags a pin handle: dragged catalog id and the pinned order at drag start + /// (so Escape can restore it after live mid-drag reorders). + pub(crate) pin_dragging: Option<(String, Vec)>, } /// The language to draw in: the preference, or English where it needs a CJK font the system @@ -868,6 +869,16 @@ impl LauncherUi { self.visible_ids() } + /// Test hook: start a pin drag as if the grip had just been grabbed (stores a restore snapshot). + pub fn begin_pin_drag_for_test(&mut self, id: &str) { + self.pin_dragging = Some((id.to_string(), self.settings.pinned.clone())); + } + + /// Test hook: whether a pin reorder drag is in progress. + pub fn pin_dragging_for_test(&self) -> bool { + self.pin_dragging.is_some() + } + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 4c827fd2..86f283fc 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -901,10 +901,14 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } - /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame. + /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame, restoring + /// the pinned order from drag start (live hover already mutated `settings.pinned`). fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { - if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - self.pin_dragging = None; + if self.pin_dragging.is_some() + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + && let Some((_, original)) = self.pin_dragging.take() + { + self.settings.pinned = original; } } @@ -915,9 +919,9 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape clears earlier). + // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape restores earlier). if resp.drag_started() { - self.pin_dragging = Some(entry.app.id.to_string()); + self.pin_dragging = Some((entry.app.id.to_string(), self.settings.pinned.clone())); } } @@ -926,9 +930,10 @@ impl LauncherUi { /// table's full-row target already covers the grip column, so the grip itself does not reorder. /// A release over a target still counts (process before clearing `pin_dragging`). fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { - let Some(from) = self.pin_dragging.clone() else { + let Some((from, _)) = self.pin_dragging.as_ref() else { return; }; + let from = from.clone(); if from == id || !self.settings.is_pinned(id) { return; } @@ -1153,7 +1158,10 @@ impl LauncherUi { self.settings.unpin_app(id); ui.close(); } - let known = |pid: &str| self.entry(pid).is_some(); + // Neighbors must be on screen under the current search/filter — catalog presence alone + // would let Move skip over a filtered pin with no visible change. + let visible: std::collections::HashSet<&str> = self.visible_ids().into_iter().collect(); + let known = |pid: &str| visible.contains(pid); let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); if prev.is_some() && ui.button(tl!("Move up")).clicked() { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 0e184dda..3aba025e 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1447,6 +1447,28 @@ fn pinned_app_menu_offers_unpin() { assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); } +#[test] +/// Escape during a pin drag restores the order from drag start (live hover already mutated pins). +fn escape_cancels_pin_drag_and_restores_order() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.settings.pin_app("wordcraft"); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); + ui.begin_pin_drag_for_test("photocraft"); + // Mid-drag: pointer has already reordered via drop targets. + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into(), "wordcraft".into()]; + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); +} + #[test] /// Grid shows a Pinned section label once at least one app is pinned. fn grid_shows_pinned_section_header() { diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md index e59c2363..0873dc7e 100644 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -53,6 +53,7 @@ In `menu` / `menu_hide` area (before Hide): - Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. - Accessible name e.g. **Reorder {name}**. - Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. +- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live). - Cursor: grab / grabbing. - Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. From 8e5a52b442a64e472ff318c290f57d5b3915cd7a Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:47:14 +0100 Subject: [PATCH 09/33] Handle Escape pin cancel when search leaves no visible apps. grid/table were skipped on empty results, so Escape never restored the drag snapshot; poll cancel before that branch and end the drag on release. --- crates/ui-egui/src/app.rs | 9 ++++++++ crates/ui-egui/src/views.rs | 17 ++++++++++---- crates/ui-egui/tests/ui.rs | 23 +++++++++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7dcf8045..f4161682 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -841,7 +841,11 @@ impl LauncherUi { ViewMode::Table => egui::ScrollArea::both(), }; scroll.auto_shrink(false).show(ui, |ui| { + // Escape must run even when search/filter leaves no rows (grid/table skipped). + self.clear_pin_drag_on_escape(ui); if ids.is_empty() { + // No drop targets this frame — still end the drag on pointer up. + self.clear_pin_drag_if_pointer_up(ui); ui.add_space(40.0); ui.vertical_centered(|ui| { crate::widgets::hud(ui, tl!("No apps match"), tokens.muted); @@ -879,6 +883,11 @@ impl LauncherUi { self.pin_dragging.is_some() } + /// Test hook: set the toolbar search string without driving the text field. + pub fn set_search_for_test(&mut self, needle: &str) { + self.search = needle.to_string(); + } + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 86f283fc..e09499cc 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -415,7 +415,6 @@ impl LauncherUi { /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { - self.clear_pin_drag_on_escape(ui); let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); let (pinned, rest) = ids.split_at(pinned_n); if !pinned.is_empty() { @@ -592,7 +591,6 @@ impl LauncherUi { pub(crate) fn table(&mut self, ui: &mut Ui, ids: &[&'static str]) { use egui_extras::{Column, TableBuilder}; - self.clear_pin_drag_on_escape(ui); let t = Tokens::get(ui.ctx()); let now = self.backend.now(); let mut sort_clicked = None; @@ -901,9 +899,10 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } - /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame, restoring - /// the pinned order from drag start (live hover already mutated `settings.pinned`). - fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { + /// Cancel an in-progress pin reorder (Escape), restoring the pinned order from drag start + /// (live hover already mutated `settings.pinned`). Called from [`Self::ui`] before the + /// empty-results branch so Escape still works when search/filter hides every app mid-drag. + pub(crate) fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) && let Some((_, original)) = self.pin_dragging.take() @@ -912,6 +911,14 @@ impl LauncherUi { } } + /// End a pin drag when the pointer is up (commit current order). Used when no grid/table + /// drop targets run — e.g. empty search results mid-drag. + pub(crate) fn clear_pin_drag_if_pointer_up(&mut self, ui: &Ui) { + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } + } + /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 3aba025e..044a3e82 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1469,6 +1469,29 @@ fn escape_cancels_pin_drag_and_restores_order() { assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); } +#[test] +/// Escape still restores pin order when search hides every app (grid/table never run). +fn escape_cancels_pin_drag_with_empty_results() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.begin_pin_drag_for_test("photocraft"); + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into()]; + // Apply search without an intervening empty frame (pointer-up would end the drag). + ui.set_search_for_test("zzzz-no-match"); + assert!(ui.visible_ids_for_test().is_empty()); + assert!(ui.pin_dragging_for_test()); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft"]); +} + #[test] /// Grid shows a Pinned section label once at least one app is pinned. fn grid_shows_pinned_section_header() { diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md index 0873dc7e..b99f4aa9 100644 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -53,7 +53,7 @@ In `menu` / `menu_hide` area (before Hide): - Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. - Accessible name e.g. **Reorder {name}**. - Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. -- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live). +- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live), including when search/filter leaves no visible apps. - Cursor: grab / grabbing. - Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. From 6708ee4c64742f4ef83731059b80be243cf97913 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 21:34:43 -0500 Subject: [PATCH 10/33] update: mark FlatPark flatpaks as externally managed and drive updates through FlatPark --- crates/core/src/status.rs | 23 ++- crates/native/src/backend.rs | 55 ++++++- crates/native/src/detect.rs | 80 +++++++-- crates/native/src/install.rs | 178 +++++++++++++++++++-- crates/native/src/install/macos_verify.rs | 9 +- crates/native/src/packages.rs | 65 +++++++- crates/native/tests/windows_msi_context.rs | 2 + crates/ui-egui/src/card_parts.rs | 9 +- crates/ui-egui/tests/chinese.rs | 2 + crates/ui-egui/tests/french.rs | 2 + crates/ui-egui/tests/german.rs | 2 + crates/ui-egui/tests/hungarian.rs | 2 + crates/ui-egui/tests/japanese.rs | 2 + crates/ui-egui/tests/polish.rs | 2 + crates/ui-egui/tests/portuguese.rs | 2 + crates/ui-egui/tests/russian.rs | 2 + crates/ui-egui/tests/spanish.rs | 2 + crates/ui-egui/tests/ui.rs | 4 + 18 files changed, 403 insertions(+), 40 deletions(-) diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index 03876852..875b326e 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -61,6 +61,20 @@ pub struct Installed { /// The package that installed it, for package-manager installs (`photocraft-bin`). #[serde(default)] pub package: Option, + /// Whether this copy is managed externally (such as a Flatpak from FlatPark) and + /// will not be replaced with a downloaded bundle. + #[serde(default)] + pub externally_managed: bool, + /// The remote repository or origin (`flatpark`, `-origin`), when known. + #[serde(default)] + pub origin: Option, +} + +impl Installed { + /// Whether this copy is managed externally and should not be replaced by a downloaded bundle. + pub fn is_externally_managed(&self) -> bool { + self.externally_managed + } } /// The status shown on an app's card or row. @@ -124,7 +138,14 @@ mod tests { use super::*; fn inst(v: &str) -> Installed { - Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new(), package: None } + Installed { + version: version::parse(v), + source: InstallSource::Launcher, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + } } #[test] diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index d0d4fd72..fabe71bb 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -512,6 +512,20 @@ fn run_install( replace: bool, source: Option<&dyn ReleaseSource>, ) -> Result { + if let Some(inst) = existing + && inst.source == artcraft_launcher_core::InstallSource::Flatpak + && inst.externally_managed + { + #[cfg(target_os = "linux")] + { + shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Installing }); + let mut phase = |p| shared.emit(Event::Progress { app: app.id.to_string(), phase: p }); + install::linux::update_flatpak_app(app, true, stop, &mut phase)?; + return Ok(format!("{} was updated through FlatPark", app.name)); + } + #[cfg(not(target_os = "linux"))] + return Err(format!("{} is a Flatpak, which is not supported on this platform", app.name)); + } if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) { return Err(msg); } @@ -1260,7 +1274,14 @@ mod tests { #[test] fn a_downgrade_is_only_a_version_chosen_by_name_below_the_installed_one() { - let installed = Installed { version: version::parse("0.14.0"), source: InstallSource::MacApp, location: String::new(), package: None }; + let installed = Installed { + version: version::parse("0.14.0"), + source: InstallSource::MacApp, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + }; let pinned = VersionTrack::Pinned("v0.13.0".into()); assert!(is_chosen_downgrade(&pinned, &tagged("0.13.0"), Some(&installed))); assert!(!is_chosen_downgrade(&VersionTrack::Stable, &tagged("0.13.0"), Some(&installed))); @@ -1272,7 +1293,14 @@ mod tests { #[test] fn only_a_version_chosen_by_name_below_the_installed_one_looks_for_a_package_to_put_back() { let app = CraftApp::by_id("filmcraft").unwrap(); - let installed = Installed { version: version::parse("0.14.0"), source: InstallSource::Msi, location: String::new(), package: None }; + let installed = Installed { + version: version::parse("0.14.0"), + source: InstallSource::Msi, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + }; let pinned = VersionTrack::Pinned("v0.13.0".into()); let never = || -> Result, String> { panic!("no list is needed") }; // An update, the latest release, nothing installed, or a stale release: no downgrade. @@ -1657,6 +1685,8 @@ mod tests { source: artcraft_launcher_core::InstallSource::Msi, location: r"C:\Apps\PhotoCraft\photocraft.exe".into(), package: None, + externally_managed: false, + origin: None, }; assert_eq!(planned_asset(app, &release, platform, &portable_options, Some(&msi), None).unwrap().1, PackageKind::Msi); let launcher = Installed { @@ -1664,6 +1694,8 @@ mod tests { source: artcraft_launcher_core::InstallSource::Launcher, location: r"D:\Craft Apps\PhotoCraft\photocraft.exe".into(), package: None, + externally_managed: false, + origin: None, }; let record = crate::manifest::Record { version: "0.4.0".into(), @@ -1695,8 +1727,14 @@ mod tests { assert_eq!(planned_asset(app, &release, platform, &options, None, None).unwrap().1, PackageKind::AppImage); release.assets.retain(|a| !a.name.ends_with(".AppImage")); assert!(planned_asset(app, &release, platform, &options, None, None).is_none()); - let existing = - Installed { version: None, source: artcraft_launcher_core::InstallSource::Deb, location: "/usr/bin/photocraft".into(), package: None }; + let existing = Installed { + version: None, + source: artcraft_launcher_core::InstallSource::Deb, + location: "/usr/bin/photocraft".into(), + package: None, + externally_managed: false, + origin: None, + }; let system = BackendOptions { kinds: options.kinds.clone(), ..Default::default() }; assert_eq!( planned_asset(app, &release, platform, &options, Some(&existing), None), @@ -1896,7 +1934,14 @@ mod tests { } fn copy(v: &str) -> Installed { - Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None } + Installed { + version: version::parse(v), + source: InstallSource::Deb, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + } } #[test] diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 98dfaf07..db90603d 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -67,6 +67,8 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option { source: InstallSource::Launcher, location: rec.path.display().to_string(), package: None, + externally_managed: false, + origin: None, }) } @@ -194,7 +196,14 @@ pub mod macos { let full_key = format!("{}Version", app.name); let text = [full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string)); - Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None }) + Some(Installed { + version: text.and_then(version::parse), + source: InstallSource::MacApp, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }) } } @@ -421,7 +430,14 @@ pub mod windows { pub fn detect(app: &CraftApp) -> Option { let m = msi_installs(app).into_iter().next()?; let location = m.exe.or_else(|| exe_in_any_scope(app, m.version.as_ref())).map_or_else(|| app.name.to_string(), |p| location_text(&p)); - Some(Installed { version: m.version, source: InstallSource::Msi, location, package: Some(m.product_code) }) + Some(Installed { + version: m.version, + source: InstallSource::Msi, + location, + package: Some(m.product_code), + externally_managed: false, + origin: None, + }) } } @@ -447,7 +463,7 @@ pub mod linux { let t = Duration::from_secs(10); let mut out = Vec::new(); let mut add = |manager, user, list: Vec<(String, String)>| { - out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version, user })); + out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version, user, origin: None })); }; if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) { add(InstallSource::Deb, false, packages::parse_dpkg(&list)); @@ -468,25 +484,32 @@ pub mod linux { // named ones included (`--system` is only the default one). let mut user_ids = Vec::new(); for scope in [&["--user"][..], &[]] { - let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version"]].concat(); + let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version,origin"]].concat(); if let Some(list) = crate::process::capture_flatpak(&list_args, t) { - let mut flatpaks = packages::parse_flatpak_list(&list); + let mut flatpaks = packages::parse_flatpak_list_with_origin(&list); // The unscoped list repeats the user installs: the user pass already has them. - flatpaks.retain(|(id, _)| !user_ids.contains(id)); + flatpaks.retain(|(id, _, _)| !user_ids.contains(id)); if !scope.is_empty() { - user_ids.extend(flatpaks.iter().map(|(id, _)| id.clone())); + user_ids.extend(flatpaks.iter().map(|(id, _, _)| id.clone())); } // Bundles built from a release tarball often list no version: ask Flatpak directly. - for (id, version) in flatpaks + for (id, version, _) in flatpaks .iter_mut() - .filter(|(id, v)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) + .filter(|(id, v, _)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) { let info_args = [&["info"][..], scope, &[&arch, id]].concat(); if let Some(v) = crate::process::capture_flatpak(&info_args, t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } } - add(InstallSource::Flatpak, !scope.is_empty(), flatpaks); + let is_user = !scope.is_empty(); + out.extend(flatpaks.into_iter().filter(|(n, _, _)| ours(n)).map(|(name, version, origin)| Package { + manager: InstallSource::Flatpak, + name, + version, + user: is_user, + origin, + })); } } out @@ -562,13 +585,28 @@ pub mod linux { pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option { if let Some((path, version)) = appimages(app).into_iter().next() { - return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None }); + return Some(Installed { + version, + source: InstallSource::AppImage, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }); } let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]; if let Some(p) = packages::find(app, &snapshot.packages, &order) { // A Flatpak runs under the id it was found under, which may be an older one. let location = if p.manager == InstallSource::Flatpak { p.name.clone() } else { binary_on_path(app.id) }; - return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) }); + let externally_managed = p.manager == InstallSource::Flatpak && p.origin.as_deref().is_some_and(packages::is_flatpark_origin); + return Some(Installed { + version: packages::version_of(p), + source: p.manager, + location, + package: Some(p.name.clone()), + externally_managed, + origin: p.origin.clone(), + }); } detect_system_install(app) } @@ -579,7 +617,14 @@ pub mod linux { /// to whatever installed it, since it can't tell which package manager that was. fn detect_system_install(app: &CraftApp) -> Option { let bin = find_binary(app.id)?; - Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + Some(Installed { + version: binary_version(&bin, app), + source: InstallSource::Binary, + location: bin.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }) } fn find_binary(id: &str) -> Option { @@ -606,7 +651,14 @@ pub mod freebsd { if !bin.exists() { return None; } - Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + Some(Installed { + version: binary_version(&bin, app), + source: InstallSource::Binary, + location: bin.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }) } /// ` --version` prints ` 0.5.0 (sha, date)`. diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 3917f5cc..4de8edf2 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -524,6 +524,9 @@ pub(crate) fn choose_kinds_with( // opens and removes them but never replaces them. InstallSource::Pacman if is_release_arch_package(app, inst) => Some(PackageKind::ArchPkg), InstallSource::Pacman => return Vec::new(), + // Flatpaks installed from FlatPark are externally managed: the launcher will not + // replace them with a downloaded local .flatpak bundle. + InstallSource::Flatpak if inst.externally_managed => return Vec::new(), InstallSource::Flatpak => Some(PackageKind::Flatpak), InstallSource::AppImage => Some(PackageKind::AppImage), InstallSource::Binary => Some(PackageKind::TarGz), @@ -625,6 +628,9 @@ fn managed_elsewhere_with(app: &CraftApp, installed: &Installed, uninstall: bool let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string()); Some(pacman_update_hint(app, &pkg, repo_fn(&pkg).as_deref())) } + InstallSource::Flatpak if !uninstall && installed.externally_managed => { + Some(format!("{} was installed from FlatPark. Update it with flatpak update {}", app.name, app.bundle_id())) + } InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), _ => None, } @@ -1213,6 +1219,39 @@ pub(crate) mod linux { } } + /// Updates an installed Flatpak from its remote repository (such as FlatPark). + pub fn update_flatpak_app(app: &CraftApp, user: bool, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + phase(JobPhase::Installing); + stop.check()?; + let id = app.bundle_id(); + let mut cmd = if user { + let mut c = process::flatpak_command(); + c.args(["update", "--user", "-y", "--noninteractive", &id]); + c + } else if process::in_sandbox() { + let mut c = process::flatpak_command(); + c.args(["update", "--system", "-y", "--noninteractive", &id]); + c + } else { + let mut c = process::command("pkexec"); + c.args(["/usr/bin/flatpak", "update", "--system", "-y", "--noninteractive", &id]); + c + }; + let output = cmd.output().map_err(|e| format!("couldn't run flatpak ({e})"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let err = stderr.trim(); + let err = if err.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { err.to_string() }; + if err.is_empty() { + Err(format!("Flatpak couldn't update {}", app.name)) + } else { + let clean_err = err.strip_prefix("error: ").or_else(|| err.strip_prefix("Error: ")).unwrap_or(&err); + Err(format!("Flatpak couldn't update {}: {clean_err}", app.name)) + } + } + /// The older Flatpak id `app` was found under before an update, if it's one of its own /// ([`CraftApp::former_bundles`]) and not the current id. pub(crate) fn former_flatpak<'a>(app: &CraftApp, existing: Option<&'a Installed>) -> Option<&'a str> { @@ -2288,6 +2327,8 @@ mod tests { source: InstallSource::Msi, location: dir.path().join("MSI/PhotoCraft/photocraft.exe").display().to_string(), package: None, + externally_managed: false, + origin: None, }; assert_eq!(destination(app, PackageKind::Msi, Some(&msi), Some(&old), &unavailable).unwrap(), Some(dir.path().join("MSI/PhotoCraft"))); assert!(destination(app, PackageKind::TarGz, None, None, &locations).unwrap_err().contains("aren't supported")); @@ -2299,8 +2340,22 @@ mod tests { let app = CraftApp::by_id("photocraft").unwrap(); let path = dir.path().join("old.AppImage"); let rec = record("0.5.0", PackageKind::AppImage, path.clone(), vec![], false); - let launcher = Installed { version: None, source: InstallSource::Launcher, location: path.display().to_string(), package: None }; - let users = Installed { version: None, source: InstallSource::AppImage, location: "/home/me/AppImages/p.AppImage".into(), package: None }; + let launcher = Installed { + version: None, + source: InstallSource::Launcher, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }; + let users = Installed { + version: None, + source: InstallSource::AppImage, + location: "/home/me/AppImages/p.AppImage".into(), + package: None, + externally_managed: false, + origin: None, + }; let chosen = dir.path().join("New apps"); std::fs::create_dir(&chosen).unwrap(); let mut locations = InstallLocations { root: Some(chosen.display().to_string()), ..Default::default() }; @@ -2526,6 +2581,8 @@ mod tests { source: InstallSource::Launcher, location: dir.path().join("photocraft.exe").display().to_string(), package: None, + externally_managed: false, + origin: None, }; let mut phases = vec![]; let error = { @@ -2548,7 +2605,14 @@ mod tests { #[test] fn each_way_back_is_allowed_only_for_the_install_it_can_undo() { - let installed = |source| Installed { version: version::parse("0.5.0"), source, location: String::new(), package: None }; + let installed = |source| Installed { + version: version::parse("0.5.0"), + source, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + }; let (mac, msi, portable) = (installed(InstallSource::MacApp), installed(InstallSource::Msi), installed(InstallSource::Launcher)); let (release, asset) = (Release::default(), Asset::default()); let reinstall = Some(Downgrade::Reinstall { previous: &release, asset: &asset }); @@ -2624,7 +2688,14 @@ mod tests { let release = Release { tag: "v0.3.0".into(), version: version::parse("0.3.0"), assets: vec![asset.clone()], ..Release::default() }; let paths = Paths::under(&dir.path().join(name)); let http = Http::new().unwrap(); - let installed = Installed { version: version::parse("0.4.0"), source, location: location.display().to_string(), package: None }; + let installed = Installed { + version: version::parse("0.4.0"), + source, + location: location.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }; let run = |downgrade| { let mut phase = |_: JobPhase| {}; let mut job = Job { @@ -3143,7 +3214,7 @@ mod tests { #[test] fn updates_keep_the_installed_format() { - let inst = |source| Installed { version: None, source, location: String::new(), package: None }; + let inst = |source| Installed { version: None, source, location: String::new(), package: None, externally_managed: false, origin: None }; let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); let available = available_kinds(); if let Some(first) = available.first() { @@ -3160,7 +3231,14 @@ mod tests { #[test] fn only_the_release_arch_package_is_updated_by_the_launcher() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("wordcraft").unwrap(); assert!(is_release_arch_package(app, &pacman("ai.storyteller.wordcraft"))); for aur in ["wordcraft", "wordcraft-bin", "wordcraft-git"] { @@ -3188,7 +3266,14 @@ mod tests { #[test] fn aur_packages_are_not_updated_as_arch_pkg() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("filmcraft-bin"); assert!(managed_elsewhere_with(app, &installed, false, |_| None).is_some()); @@ -3210,7 +3295,14 @@ mod tests { #[test] fn only_an_apps_own_older_flatpak_id_is_retired() { use super::linux::former_flatpak; - let flatpak = |id: &str| Installed { version: None, source: InstallSource::Flatpak, location: id.into(), package: Some(id.into()) }; + let flatpak = |id: &str| Installed { + version: None, + source: InstallSource::Flatpak, + location: id.into(), + package: Some(id.into()), + externally_managed: false, + origin: None, + }; let sc = artcraft_launcher_core::CraftApp::by_id("solidcraft").unwrap(); let old = flatpak("ai.storyteller.solvecraft"); assert_eq!(former_flatpak(sc, Some(&old)), Some("ai.storyteller.solvecraft")); @@ -3223,7 +3315,14 @@ mod tests { #[test] fn release_arch_packages_are_updated_as_arch_pkg() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("ai.storyteller.filmcraft"); assert_eq!(managed_elsewhere_with(app, &installed, false, |_| None), None); @@ -3231,6 +3330,47 @@ mod tests { assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]); } + #[test] + fn flatpark_flatpaks_are_externally_managed_and_not_updated_via_bundle() { + let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); + let flatpark_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft".into(), + package: Some("ai.storyteller.photocraft".into()), + externally_managed: true, + origin: Some("flatpark".into()), + }; + assert!(flatpark_installed.is_externally_managed()); + assert_eq!(choose_kinds_with(app, Some(&flatpark_installed), None, &[], &[PackageKind::Flatpak], false), Vec::::new()); + let hint = managed_elsewhere_with(app, &flatpark_installed, false, |_| None); + assert_eq!(hint.as_deref(), Some("PhotoCraft was installed from FlatPark. Update it with flatpak update ai.storyteller.photocraft")); + // During uninstall, managed_elsewhere returns None so it can be removed + assert_eq!(managed_elsewhere_with(app, &flatpark_installed, true, |_| None), None); + + let bundle_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft".into(), + package: Some("ai.storyteller.photocraft".into()), + externally_managed: false, + origin: Some("photocraft-origin".into()), + }; + assert!(!bundle_installed.is_externally_managed()); + assert_eq!(choose_kinds_with(app, Some(&bundle_installed), None, &[], &[PackageKind::Flatpak], false), vec![PackageKind::Flatpak]); + assert_eq!(managed_elsewhere_with(app, &bundle_installed, false, |_| None), None); + } + + #[test] + #[cfg(target_os = "linux")] + fn update_flatpak_app_respects_cancellation() { + let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); + let stop = Stop::default(); + stop.cancel(); + let mut phase = |_| (); + assert_eq!(linux::update_flatpak_app(app, true, &stop, &mut phase), Err(CANCELLED.into())); + } + #[test] fn unavailable_arch_package_not_selected_for_new_install() { let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); @@ -3254,6 +3394,8 @@ mod tests { source: InstallSource::Launcher, location: dir.path().join("PhotoCraft.AppImage").display().to_string(), package: None, + externally_managed: false, + origin: None, }; let stop = Stop::default(); let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false, downgrade: None }; @@ -3481,7 +3623,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let apps = dir.path().join("Applications"); let current = bundle(&apps, "0.5.0"); - let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let existing = Installed { + version: None, + source: InstallSource::MacApp, + location: current.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }; let paths = Paths::under(dir.path()); let image = dmg(dir.path(), "0.6.0"); @@ -3514,7 +3663,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let apps = dir.path().join("Applications"); let current = bundle(&apps, "0.5.0"); - let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let existing = Installed { + version: None, + source: InstallSource::MacApp, + location: current.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }; let image = dmg(dir.path(), "0.6.0"); let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() }; diff --git a/crates/native/src/install/macos_verify.rs b/crates/native/src/install/macos_verify.rs index 95cbc947..aa302e93 100644 --- a/crates/native/src/install/macos_verify.rs +++ b/crates/native/src/install/macos_verify.rs @@ -216,7 +216,14 @@ mod tests { std::fs::write(&old, b"old fixture bytes").unwrap(); std::fs::write(&manifest, b"old install record").unwrap(); std::fs::write(&download, b"cached download").unwrap(); - let found = Installed { version: None, source: InstallSource::MacApp, location: staged.display().to_string(), package: None }; + let found = Installed { + version: None, + source: InstallSource::MacApp, + location: staged.display().to_string(), + package: None, + externally_managed: false, + origin: None, + }; let expect = Expect { team: app.mac_team, version: None }; // Injected tool outcomes exercise the real pre-swap boundary, not real signatures. for failed in 0..4 { diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 65464c14..46a4bed2 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -15,6 +15,8 @@ pub struct Package { pub version: String, /// A per-user Flatpak install (`flatpak --user`), which the launcher installs and updates. pub user: bool, + /// The origin remote (`flatpark`, `-origin`, `flathub`), when known. + pub origin: Option, } /// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only. @@ -48,18 +50,34 @@ pub fn parse_pacman(text: &str) -> Vec<(String, String)> { .collect() } -/// `flatpak list --app --columns=application,version`: the version column is often empty for -/// bundles built from a release tarball. -pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { +/// `flatpak list --app --columns=application,version,origin`: parses id, version and origin. +pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option)> { text.lines() .filter_map(|line| { let mut f = line.split('\t'); let id = f.next()?.trim(); - (!id.is_empty()).then(|| (id.to_string(), f.next().unwrap_or("").trim().to_string())) + if id.is_empty() { + return None; + } + let version = f.next().unwrap_or("").trim().to_string(); + let origin = f.next().map(str::trim).filter(|o| !o.is_empty()).map(ToString::to_string); + Some((id.to_string(), version, origin)) }) .collect() } +/// `flatpak list --app --columns=application,version`: the version column is often empty for +/// bundles built from a release tarball. +pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { + parse_flatpak_list_with_origin(text).into_iter().map(|(id, v, _)| (id, v)).collect() +} + +/// Whether an origin string indicates an install from FlatPark. +pub fn is_flatpark_origin(origin: &str) -> bool { + let o = origin.trim(); + o.eq_ignore_ascii_case("flatpark") || o.to_ascii_lowercase().contains("flatpark") +} + /// The `Version:` line of `flatpak info `. pub fn parse_flatpak_info(text: &str) -> Option { text.lines().find_map(|l| l.trim().strip_prefix("Version:").map(|v| v.trim().to_string())).filter(|v| !v.is_empty()) @@ -101,7 +119,7 @@ mod tests { use super::*; fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec { - list.into_iter().map(|(name, version)| Package { manager, name, version, user: false }).collect() + list.into_iter().map(|(name, version)| Package { manager, name, version, user: false, origin: None }).collect() } #[test] @@ -137,10 +155,22 @@ mod tests { fn a_user_flatpak_beats_a_system_one_under_any_id() { let sc = CraftApp::by_id("solidcraft").unwrap(); let mut installed = pkgs(InstallSource::Flatpak, parse_flatpak_list("io.tangencylabs.solidcraft\t0.25.1\n")); - installed.push(Package { manager: InstallSource::Flatpak, name: "ai.storyteller.solidcraft".into(), version: "0.26.0".into(), user: true }); + installed.push(Package { + manager: InstallSource::Flatpak, + name: "ai.storyteller.solidcraft".into(), + version: "0.26.0".into(), + user: true, + origin: None, + }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| p.name.as_str()), Some("ai.storyteller.solidcraft")); // Within the user scope the current id still wins. - installed.push(Package { manager: InstallSource::Flatpak, name: "io.tangencylabs.solidcraft".into(), version: "0.26.1".into(), user: true }); + installed.push(Package { + manager: InstallSource::Flatpak, + name: "io.tangencylabs.solidcraft".into(), + version: "0.26.1".into(), + user: true, + origin: None, + }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| (p.name.as_str(), p.user)), Some(("io.tangencylabs.solidcraft", true))); } @@ -186,4 +216,25 @@ mod tests { assert_eq!(version_of(hit).unwrap().to_string(), "0.5.0"); assert_eq!(find(photo, &installed, &[InstallSource::Flatpak]).unwrap().version, "0.4.0"); } + + #[test] + fn parses_flatpak_list_with_origin_and_spots_flatpark() { + let text = + "ai.storyteller.photocraft\t0.19.0\tflatpark\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\nai.storyteller.wordcraft\t0.14.0\t\n"; + let list = parse_flatpak_list_with_origin(text); + assert_eq!( + list, + [ + ("ai.storyteller.photocraft".into(), "0.19.0".into(), Some("flatpark".into())), + ("ai.storyteller.cadcraft".into(), "0.16.0".into(), Some("cadcraft-origin".into())), + ("ai.storyteller.wordcraft".into(), "0.14.0".into(), None), + ] + ); + assert!(is_flatpark_origin("flatpark")); + assert!(is_flatpark_origin("FlatPark")); + assert!(is_flatpark_origin("flatpark-origin")); + assert!(!is_flatpark_origin("cadcraft-origin")); + assert!(!is_flatpark_origin("flathub")); + assert!(!is_flatpark_origin("")); + } } diff --git a/crates/native/tests/windows_msi_context.rs b/crates/native/tests/windows_msi_context.rs index bcda3e4c..87d777b5 100644 --- a/crates/native/tests/windows_msi_context.rs +++ b/crates/native/tests/windows_msi_context.rs @@ -27,6 +27,8 @@ fn uncertain_msi_updates_do_not_download_or_start_an_installer() { source: InstallSource::Msi, location: root.path().join("not-running-context-regression.exe").display().to_string(), package: code.map(str::to_owned), + externally_managed: false, + origin: None, }; let mut phases = Vec::new(); let mut phase = |phase| phases.push(phase); diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs index 4fc3354d..d7ae72c0 100644 --- a/crates/ui-egui/src/card_parts.rs +++ b/crates/ui-egui/src/card_parts.rs @@ -57,7 +57,14 @@ mod tests { fn entry(installed: &str, latest: &str) -> AppEntry { let mut e = AppEntry::new(CraftApp::by_id("photocraft").unwrap()); - e.installed = Some(Installed { version: version::parse(installed), source: InstallSource::Msi, location: String::new(), package: None }); + e.installed = Some(Installed { + version: version::parse(installed), + source: InstallSource::Msi, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + }); e.latest = Some(LatestRelease::Found(Release { tag: format!("v{latest}"), version: version::parse(latest), ..Default::default() })); e } diff --git a/crates/ui-egui/tests/chinese.rs b/crates/ui-egui/tests/chinese.rs index c0669db5..76cfd0e7 100644 --- a/crates/ui-egui/tests/chinese.rs +++ b/crates/ui-egui/tests/chinese.rs @@ -95,6 +95,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/french.rs b/crates/ui-egui/tests/french.rs index 89a4f15d..eda74267 100644 --- a/crates/ui-egui/tests/french.rs +++ b/crates/ui-egui/tests/french.rs @@ -69,6 +69,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index f4393e97..df6573fe 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -94,6 +94,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/hungarian.rs b/crates/ui-egui/tests/hungarian.rs index 6c1738d9..5b0c8094 100644 --- a/crates/ui-egui/tests/hungarian.rs +++ b/crates/ui-egui/tests/hungarian.rs @@ -77,6 +77,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/japanese.rs b/crates/ui-egui/tests/japanese.rs index 8a8f4d42..f718510d 100644 --- a/crates/ui-egui/tests/japanese.rs +++ b/crates/ui-egui/tests/japanese.rs @@ -95,6 +95,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/polish.rs b/crates/ui-egui/tests/polish.rs index e4ea4e41..4d168978 100644 --- a/crates/ui-egui/tests/polish.rs +++ b/crates/ui-egui/tests/polish.rs @@ -69,6 +69,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/portuguese.rs b/crates/ui-egui/tests/portuguese.rs index 1f3d8157..28027471 100644 --- a/crates/ui-egui/tests/portuguese.rs +++ b/crates/ui-egui/tests/portuguese.rs @@ -67,6 +67,8 @@ fn events() -> Vec { source: InstallSource::Msi, location: format!("C:\\Program Files\\{}\\{}.exe", app.name, app.id), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/russian.rs b/crates/ui-egui/tests/russian.rs index c96bc2d6..b9ac0745 100644 --- a/crates/ui-egui/tests/russian.rs +++ b/crates/ui-egui/tests/russian.rs @@ -69,6 +69,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/spanish.rs b/crates/ui-egui/tests/spanish.rs index ceb1bad2..af6a8889 100644 --- a/crates/ui-egui/tests/spanish.rs +++ b/crates/ui-egui/tests/spanish.rs @@ -69,6 +69,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 10c1fad8..b2f6380e 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -189,6 +189,8 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } @@ -558,6 +560,8 @@ fn choosing_an_app_version_turns_its_main_action_into_install() { source: InstallSource::MacApp, location: "/Applications/PhotoCraft.app".into(), package: None, + externally_managed: false, + origin: None, }), }); h.run_steps(4); From b17ca19ae826507923bc3b7155b0e69bbda2c6a5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:29:47 -0500 Subject: [PATCH 11/33] fix: preserve Flatpak update scope and handle cancellation --- crates/core/src/status.rs | 4 + crates/native/src/backend.rs | 34 +++++++- crates/native/src/detect.rs | 50 ++++++++---- crates/native/src/install.rs | 91 +++++++++++++++++----- crates/native/src/install/macos_verify.rs | 1 + crates/native/src/packages.rs | 24 +++--- crates/native/src/process.rs | 58 ++++++++++++++ crates/native/tests/windows_msi_context.rs | 1 + crates/ui-egui/src/card_parts.rs | 1 + crates/ui-egui/tests/chinese.rs | 1 + crates/ui-egui/tests/french.rs | 1 + crates/ui-egui/tests/german.rs | 1 + crates/ui-egui/tests/hungarian.rs | 1 + crates/ui-egui/tests/japanese.rs | 1 + crates/ui-egui/tests/polish.rs | 1 + crates/ui-egui/tests/portuguese.rs | 1 + crates/ui-egui/tests/russian.rs | 1 + crates/ui-egui/tests/spanish.rs | 1 + crates/ui-egui/tests/ui.rs | 2 + 19 files changed, 231 insertions(+), 44 deletions(-) diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index 875b326e..aac0618b 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -68,6 +68,9 @@ pub struct Installed { /// The remote repository or origin (`flatpark`, `-origin`), when known. #[serde(default)] pub origin: Option, + /// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs. + #[serde(default)] + pub installation: Option, } impl Installed { @@ -145,6 +148,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, } } diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index fabe71bb..0c347cac 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -379,6 +379,13 @@ impl NativeBackend { // One installer call can cover several apps, and fail after some of them went in. let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned(); let result = match (result, latest) { + (Ok(_), _) + if existing.as_ref().is_some_and(|e| e.externally_managed) + && found.is_some() + && existing.as_ref().and_then(|e| e.version.as_ref()) == found.as_ref().and_then(|f| f.version.as_ref()) => + { + Ok(format!("{} is already up to date", app.name)) + } (Err(e), Some(release)) if !uninstall && release.version.as_ref().is_some_and(|v| landed_despite(&e, existing.as_ref(), found.as_ref(), v)) => { @@ -520,8 +527,25 @@ fn run_install( { shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Installing }); let mut phase = |p| shared.emit(Event::Progress { app: app.id.to_string(), phase: p }); - install::linux::update_flatpak_app(app, true, stop, &mut phase)?; - return Ok(format!("{} was updated through FlatPark", app.name)); + let default_id = app.bundle_id(); + let target_id = inst.package.as_deref().unwrap_or(&default_id); + let scope = match inst.installation.as_deref() { + Some("user") => "--user".to_string(), + Some("system") | None => "--system".to_string(), + Some(name) => format!("--installation={name}"), + }; + let deployed_commit = || { + crate::process::capture_flatpak(&["info", &scope, "--show-commit", target_id], std::time::Duration::from_secs(15)) + .filter(|value| !value.trim().is_empty()) + }; + let before = deployed_commit(); + install::linux::update_flatpak_app(app, target_id, inst.installation.as_deref(), stop, &mut phase)?; + let after = deployed_commit(); + return Ok(match (before, after) { + (Some(before), Some(after)) if before.trim() == after.trim() => format!("{} is already up to date", app.name), + (Some(_), Some(_)) => format!("{} was updated through FlatPark", app.name), + _ => format!("Flatpak finished checking {} for updates", app.name), + }); } #[cfg(not(target_os = "linux"))] return Err(format!("{} is a Flatpak, which is not supported on this platform", app.name)); @@ -1281,6 +1305,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let pinned = VersionTrack::Pinned("v0.13.0".into()); assert!(is_chosen_downgrade(&pinned, &tagged("0.13.0"), Some(&installed))); @@ -1300,6 +1325,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let pinned = VersionTrack::Pinned("v0.13.0".into()); let never = || -> Result, String> { panic!("no list is needed") }; @@ -1687,6 +1713,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; assert_eq!(planned_asset(app, &release, platform, &portable_options, Some(&msi), None).unwrap().1, PackageKind::Msi); let launcher = Installed { @@ -1696,6 +1723,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let record = crate::manifest::Record { version: "0.4.0".into(), @@ -1734,6 +1762,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let system = BackendOptions { kinds: options.kinds.clone(), ..Default::default() }; assert_eq!( @@ -1941,6 +1970,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, } } diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index db90603d..9057d43f 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -69,6 +69,7 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option { package: None, externally_managed: false, origin: None, + installation: None, }) } @@ -203,6 +204,7 @@ pub mod macos { package: None, externally_managed: false, origin: None, + installation: None, }) } } @@ -437,6 +439,7 @@ pub mod windows { package: Some(m.product_code), externally_managed: false, origin: None, + installation: None, }) } } @@ -463,7 +466,14 @@ pub mod linux { let t = Duration::from_secs(10); let mut out = Vec::new(); let mut add = |manager, user, list: Vec<(String, String)>| { - out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version, user, origin: None })); + out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { + manager, + name, + version, + user, + origin: None, + installation: None, + })); }; if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) { add(InstallSource::Deb, false, packages::parse_dpkg(&list)); @@ -484,31 +494,41 @@ pub mod linux { // named ones included (`--system` is only the default one). let mut user_ids = Vec::new(); for scope in [&["--user"][..], &[]] { - let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version,origin"]].concat(); + let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version,origin,installation"]].concat(); if let Some(list) = crate::process::capture_flatpak(&list_args, t) { let mut flatpaks = packages::parse_flatpak_list_with_origin(&list); // The unscoped list repeats the user installs: the user pass already has them. - flatpaks.retain(|(id, _, _)| !user_ids.contains(id)); + flatpaks.retain(|(id, _, _, _)| !user_ids.contains(id)); if !scope.is_empty() { - user_ids.extend(flatpaks.iter().map(|(id, _, _)| id.clone())); + user_ids.extend(flatpaks.iter().map(|(id, _, _, _)| id.clone())); } // Bundles built from a release tarball often list no version: ask Flatpak directly. - for (id, version, _) in flatpaks + for (id, version, _, installation) in flatpaks .iter_mut() - .filter(|(id, v, _)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) + .filter(|(id, v, _, _)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) { - let info_args = [&["info"][..], scope, &[&arch, id]].concat(); + let custom_scope; + let scope_arg = if !scope.is_empty() { + vec!["--user"] + } else { + match installation.as_deref() { + Some("system") | None => vec!["--system"], + Some(named) => { + custom_scope = format!("--installation={named}"); + vec![custom_scope.as_str()] + } + } + }; + let info_args = [&["info"][..], &scope_arg, &[&arch, id]].concat(); if let Some(v) = crate::process::capture_flatpak(&info_args, t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } } let is_user = !scope.is_empty(); - out.extend(flatpaks.into_iter().filter(|(n, _, _)| ours(n)).map(|(name, version, origin)| Package { - manager: InstallSource::Flatpak, - name, - version, - user: is_user, - origin, + out.extend(flatpaks.into_iter().filter(|(n, _, _, _)| ours(n)).map(|(name, version, origin, installation)| { + let user_install = is_user || installation.as_deref() == Some("user"); + let resolved_installation = installation.or_else(|| if user_install { Some("user".into()) } else { Some("system".into()) }); + Package { manager: InstallSource::Flatpak, name, version, user: user_install, origin, installation: resolved_installation } })); } } @@ -592,6 +612,7 @@ pub mod linux { package: None, externally_managed: false, origin: None, + installation: None, }); } let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]; @@ -606,6 +627,7 @@ pub mod linux { package: Some(p.name.clone()), externally_managed, origin: p.origin.clone(), + installation: p.installation.clone(), }); } detect_system_install(app) @@ -624,6 +646,7 @@ pub mod linux { package: None, externally_managed: false, origin: None, + installation: None, }) } @@ -658,6 +681,7 @@ pub mod freebsd { package: None, externally_managed: false, origin: None, + installation: None, }) } diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 4de8edf2..f9c3163c 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -629,7 +629,14 @@ fn managed_elsewhere_with(app: &CraftApp, installed: &Installed, uninstall: bool Some(pacman_update_hint(app, &pkg, repo_fn(&pkg).as_deref())) } InstallSource::Flatpak if !uninstall && installed.externally_managed => { - Some(format!("{} was installed from FlatPark. Update it with flatpak update {}", app.name, app.bundle_id())) + let default_id = app.bundle_id(); + let pkg = installed.package.as_deref().unwrap_or(&default_id); + let scope = match installed.installation.as_deref() { + Some("user") => "--user".to_string(), + Some("system") | None => "--system".to_string(), + Some(name) => format!("--installation={name}"), + }; + Some(format!("{} was installed from FlatPark. Update it with flatpak update {scope} {pkg}", app.name)) } InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), _ => None, @@ -1220,24 +1227,31 @@ pub(crate) mod linux { } /// Updates an installed Flatpak from its remote repository (such as FlatPark). - pub fn update_flatpak_app(app: &CraftApp, user: bool, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + pub fn update_flatpak_app( + app: &CraftApp, + id: &str, + installation: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { phase(JobPhase::Installing); stop.check()?; - let id = app.bundle_id(); - let mut cmd = if user { - let mut c = process::flatpak_command(); - c.args(["update", "--user", "-y", "--noninteractive", &id]); - c - } else if process::in_sandbox() { - let mut c = process::flatpak_command(); - c.args(["update", "--system", "-y", "--noninteractive", &id]); - c - } else { - let mut c = process::command("pkexec"); - c.args(["/usr/bin/flatpak", "update", "--system", "-y", "--noninteractive", &id]); - c + let scope_arg = match installation { + Some("user") => "--user".to_string(), + Some("system") | None => "--system".to_string(), + Some(named) => format!("--installation={named}"), }; - let output = cmd.output().map_err(|e| format!("couldn't run flatpak ({e})"))?; + // Let Flatpak's system helper authorize system updates through polkit. + // watch-bus also terminates the host command if the sandbox proxy exits. + let mut cmd = if process::in_sandbox() { + let mut command = process::command("flatpak-spawn"); + command.args(["--host", "--watch-bus", "flatpak"]); + command + } else { + process::command("flatpak") + }; + cmd.args(["update", &scope_arg, "-y", "--noninteractive", id]); + let output = process::cancellable_output(cmd, || stop.check())?; if output.status.success() { return Ok(()); } @@ -2329,6 +2343,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; assert_eq!(destination(app, PackageKind::Msi, Some(&msi), Some(&old), &unavailable).unwrap(), Some(dir.path().join("MSI/PhotoCraft"))); assert!(destination(app, PackageKind::TarGz, None, None, &locations).unwrap_err().contains("aren't supported")); @@ -2347,6 +2362,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let users = Installed { version: None, @@ -2355,6 +2371,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let chosen = dir.path().join("New apps"); std::fs::create_dir(&chosen).unwrap(); @@ -2583,6 +2600,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let mut phases = vec![]; let error = { @@ -2612,6 +2630,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let (mac, msi, portable) = (installed(InstallSource::MacApp), installed(InstallSource::Msi), installed(InstallSource::Launcher)); let (release, asset) = (Release::default(), Asset::default()); @@ -2695,6 +2714,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let run = |downgrade| { let mut phase = |_: JobPhase| {}; @@ -3214,7 +3234,15 @@ mod tests { #[test] fn updates_keep_the_installed_format() { - let inst = |source| Installed { version: None, source, location: String::new(), package: None, externally_managed: false, origin: None }; + let inst = |source| Installed { + version: None, + source, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); let available = available_kinds(); if let Some(first) = available.first() { @@ -3238,6 +3266,7 @@ mod tests { package: Some(name.into()), externally_managed: false, origin: None, + installation: None, }; let app = artcraft_launcher_core::CraftApp::by_id("wordcraft").unwrap(); assert!(is_release_arch_package(app, &pacman("ai.storyteller.wordcraft"))); @@ -3273,6 +3302,7 @@ mod tests { package: Some(name.into()), externally_managed: false, origin: None, + installation: None, }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("filmcraft-bin"); @@ -3302,6 +3332,7 @@ mod tests { package: Some(id.into()), externally_managed: false, origin: None, + installation: None, }; let sc = artcraft_launcher_core::CraftApp::by_id("solidcraft").unwrap(); let old = flatpak("ai.storyteller.solvecraft"); @@ -3322,6 +3353,7 @@ mod tests { package: Some(name.into()), externally_managed: false, origin: None, + installation: None, }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("ai.storyteller.filmcraft"); @@ -3340,14 +3372,31 @@ mod tests { package: Some("ai.storyteller.photocraft".into()), externally_managed: true, origin: Some("flatpark".into()), + installation: Some("user".into()), }; assert!(flatpark_installed.is_externally_managed()); assert_eq!(choose_kinds_with(app, Some(&flatpark_installed), None, &[], &[PackageKind::Flatpak], false), Vec::::new()); let hint = managed_elsewhere_with(app, &flatpark_installed, false, |_| None); - assert_eq!(hint.as_deref(), Some("PhotoCraft was installed from FlatPark. Update it with flatpak update ai.storyteller.photocraft")); + assert_eq!(hint.as_deref(), Some("PhotoCraft was installed from FlatPark. Update it with flatpak update --user ai.storyteller.photocraft")); // During uninstall, managed_elsewhere returns None so it can be removed assert_eq!(managed_elsewhere_with(app, &flatpark_installed, true, |_| None), None); + // When installed under a former bundle ID, the hint targets that detected ID + let former_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft-old".into(), + package: Some("ai.storyteller.photocraft-old".into()), + externally_managed: true, + origin: Some("flatpark".into()), + installation: Some("system".into()), + }; + let hint_former = managed_elsewhere_with(app, &former_installed, false, |_| None); + assert_eq!( + hint_former.as_deref(), + Some("PhotoCraft was installed from FlatPark. Update it with flatpak update --system ai.storyteller.photocraft-old") + ); + let bundle_installed = Installed { version: version::parse("0.19.0"), source: InstallSource::Flatpak, @@ -3355,6 +3404,7 @@ mod tests { package: Some("ai.storyteller.photocraft".into()), externally_managed: false, origin: Some("photocraft-origin".into()), + installation: Some("user".into()), }; assert!(!bundle_installed.is_externally_managed()); assert_eq!(choose_kinds_with(app, Some(&bundle_installed), None, &[], &[PackageKind::Flatpak], false), vec![PackageKind::Flatpak]); @@ -3368,7 +3418,7 @@ mod tests { let stop = Stop::default(); stop.cancel(); let mut phase = |_| (); - assert_eq!(linux::update_flatpak_app(app, true, &stop, &mut phase), Err(CANCELLED.into())); + assert_eq!(linux::update_flatpak_app(app, "ai.storyteller.photocraft", Some("user"), &stop, &mut phase), Err(CANCELLED.into())); } #[test] @@ -3396,6 +3446,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let stop = Stop::default(); let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false, downgrade: None }; @@ -3630,6 +3681,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let paths = Paths::under(dir.path()); let image = dmg(dir.path(), "0.6.0"); @@ -3670,6 +3722,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let image = dmg(dir.path(), "0.6.0"); let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() }; diff --git a/crates/native/src/install/macos_verify.rs b/crates/native/src/install/macos_verify.rs index aa302e93..eeb2e0c6 100644 --- a/crates/native/src/install/macos_verify.rs +++ b/crates/native/src/install/macos_verify.rs @@ -223,6 +223,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }; let expect = Expect { team: app.mac_team, version: None }; // Injected tool outcomes exercise the real pre-swap boundary, not real signatures. diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 46a4bed2..913d07e8 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -17,6 +17,8 @@ pub struct Package { pub user: bool, /// The origin remote (`flatpark`, `-origin`, `flathub`), when known. pub origin: Option, + /// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs. + pub installation: Option, } /// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only. @@ -50,8 +52,8 @@ pub fn parse_pacman(text: &str) -> Vec<(String, String)> { .collect() } -/// `flatpak list --app --columns=application,version,origin`: parses id, version and origin. -pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option)> { +/// `flatpak list --app --columns=application,version,origin,installation`: parses id, version, origin, and installation. +pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option, Option)> { text.lines() .filter_map(|line| { let mut f = line.split('\t'); @@ -61,7 +63,8 @@ pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option } let version = f.next().unwrap_or("").trim().to_string(); let origin = f.next().map(str::trim).filter(|o| !o.is_empty()).map(ToString::to_string); - Some((id.to_string(), version, origin)) + let installation = f.next().map(str::trim).filter(|i| !i.is_empty()).map(ToString::to_string); + Some((id.to_string(), version, origin, installation)) }) .collect() } @@ -69,7 +72,7 @@ pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option /// `flatpak list --app --columns=application,version`: the version column is often empty for /// bundles built from a release tarball. pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { - parse_flatpak_list_with_origin(text).into_iter().map(|(id, v, _)| (id, v)).collect() + parse_flatpak_list_with_origin(text).into_iter().map(|(id, v, _, _)| (id, v)).collect() } /// Whether an origin string indicates an install from FlatPark. @@ -119,7 +122,7 @@ mod tests { use super::*; fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec { - list.into_iter().map(|(name, version)| Package { manager, name, version, user: false, origin: None }).collect() + list.into_iter().map(|(name, version)| Package { manager, name, version, user: false, origin: None, installation: None }).collect() } #[test] @@ -161,6 +164,7 @@ mod tests { version: "0.26.0".into(), user: true, origin: None, + installation: None, }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| p.name.as_str()), Some("ai.storyteller.solidcraft")); // Within the user scope the current id still wins. @@ -170,6 +174,7 @@ mod tests { version: "0.26.1".into(), user: true, origin: None, + installation: None, }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| (p.name.as_str(), p.user)), Some(("io.tangencylabs.solidcraft", true))); } @@ -219,15 +224,14 @@ mod tests { #[test] fn parses_flatpak_list_with_origin_and_spots_flatpark() { - let text = - "ai.storyteller.photocraft\t0.19.0\tflatpark\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\nai.storyteller.wordcraft\t0.14.0\t\n"; + let text = "ai.storyteller.photocraft\t0.19.0\tflatpark\tuser\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\tsystem\nai.storyteller.wordcraft\t0.14.0\t\t\n"; let list = parse_flatpak_list_with_origin(text); assert_eq!( list, [ - ("ai.storyteller.photocraft".into(), "0.19.0".into(), Some("flatpark".into())), - ("ai.storyteller.cadcraft".into(), "0.16.0".into(), Some("cadcraft-origin".into())), - ("ai.storyteller.wordcraft".into(), "0.14.0".into(), None), + ("ai.storyteller.photocraft".into(), "0.19.0".into(), Some("flatpark".into()), Some("user".into())), + ("ai.storyteller.cadcraft".into(), "0.16.0".into(), Some("cadcraft-origin".into()), Some("system".into())), + ("ai.storyteller.wordcraft".into(), "0.14.0".into(), None, None), ] ); assert!(is_flatpark_origin("flatpark")); diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index 6a86933f..869e7221 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -789,3 +789,61 @@ mod inherited_pipe_tests { } } } + +/// Runs a helper while polling cancellation and draining its bounded output. +pub(crate) fn cancellable_output(mut cmd: Command, check: impl Fn() -> Result<(), String>) -> Result { + check()?; + let mut child = + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run flatpak ({e})"))?; + let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take())); + let status = loop { + if let Err(e) = check() { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("couldn't wait for flatpak ({e})")); + } + } + }; + let deadline = Instant::now(); + while !stdout.is_finished() || !stderr.is_finished() { + check()?; + if deadline.elapsed() > Duration::from_secs(5) { + return Err("Flatpak output did not close after completion".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + Ok(std::process::Output { status, stdout: stdout.join().unwrap_or_default(), stderr: stderr.join().unwrap_or_default() }) +} + +#[cfg(all(test, unix))] +mod update_cancel_tests { + use super::*; + + #[test] + fn cancels_a_running_helper_promptly() { + let mut cmd = command("sleep"); + cmd.arg("30"); + let start = Instant::now(); + let result = cancellable_output(cmd, || if start.elapsed() >= Duration::from_millis(100) { Err("cancelled".into()) } else { Ok(()) }); + assert_eq!(result.unwrap_err(), "cancelled"); + assert!(start.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn retains_both_output_streams_and_exit_status() { + let mut cmd = command("sh"); + cmd.args(["-c", "printf output; printf error >&2; exit 7"]); + let output = cancellable_output(cmd, || Ok(())).unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(output.stdout, b"output"); + assert_eq!(output.stderr, b"error"); + } +} diff --git a/crates/native/tests/windows_msi_context.rs b/crates/native/tests/windows_msi_context.rs index 87d777b5..f41938fe 100644 --- a/crates/native/tests/windows_msi_context.rs +++ b/crates/native/tests/windows_msi_context.rs @@ -29,6 +29,7 @@ fn uncertain_msi_updates_do_not_download_or_start_an_installer() { package: code.map(str::to_owned), externally_managed: false, origin: None, + installation: None, }; let mut phases = Vec::new(); let mut phase = |phase| phases.push(phase); diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs index d7ae72c0..e9cf97cb 100644 --- a/crates/ui-egui/src/card_parts.rs +++ b/crates/ui-egui/src/card_parts.rs @@ -64,6 +64,7 @@ mod tests { package: None, externally_managed: false, origin: None, + installation: None, }); e.latest = Some(LatestRelease::Found(Release { tag: format!("v{latest}"), version: version::parse(latest), ..Default::default() })); e diff --git a/crates/ui-egui/tests/chinese.rs b/crates/ui-egui/tests/chinese.rs index 76cfd0e7..6f580cb4 100644 --- a/crates/ui-egui/tests/chinese.rs +++ b/crates/ui-egui/tests/chinese.rs @@ -97,6 +97,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/french.rs b/crates/ui-egui/tests/french.rs index eda74267..fdbdadb6 100644 --- a/crates/ui-egui/tests/french.rs +++ b/crates/ui-egui/tests/french.rs @@ -71,6 +71,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index df6573fe..44b47c16 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -96,6 +96,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/hungarian.rs b/crates/ui-egui/tests/hungarian.rs index 5b0c8094..dfaaa3a0 100644 --- a/crates/ui-egui/tests/hungarian.rs +++ b/crates/ui-egui/tests/hungarian.rs @@ -79,6 +79,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/japanese.rs b/crates/ui-egui/tests/japanese.rs index f718510d..77bb057c 100644 --- a/crates/ui-egui/tests/japanese.rs +++ b/crates/ui-egui/tests/japanese.rs @@ -97,6 +97,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/polish.rs b/crates/ui-egui/tests/polish.rs index 4d168978..dd7ea75f 100644 --- a/crates/ui-egui/tests/polish.rs +++ b/crates/ui-egui/tests/polish.rs @@ -71,6 +71,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/portuguese.rs b/crates/ui-egui/tests/portuguese.rs index 28027471..93229a7d 100644 --- a/crates/ui-egui/tests/portuguese.rs +++ b/crates/ui-egui/tests/portuguese.rs @@ -69,6 +69,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/russian.rs b/crates/ui-egui/tests/russian.rs index b9ac0745..5b019ed3 100644 --- a/crates/ui-egui/tests/russian.rs +++ b/crates/ui-egui/tests/russian.rs @@ -71,6 +71,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/spanish.rs b/crates/ui-egui/tests/spanish.rs index af6a8889..b7501135 100644 --- a/crates/ui-egui/tests/spanish.rs +++ b/crates/ui-egui/tests/spanish.rs @@ -71,6 +71,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index b2f6380e..10f32d0f 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -191,6 +191,7 @@ fn events() -> Vec { package: None, externally_managed: false, origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } @@ -562,6 +563,7 @@ fn choosing_an_app_version_turns_its_main_action_into_install() { package: None, externally_managed: false, origin: None, + installation: None, }), }); h.run_steps(4); From 4a237c9aff5b59bc7fc42205899d05a297dafc7d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:34:25 -0500 Subject: [PATCH 12/33] fix: update ArtCraft repository Flatpaks through their remote --- crates/native/src/backend.rs | 2 +- crates/native/src/detect.rs | 14 +++++++++++++- crates/native/src/install.rs | 11 +++++++---- crates/native/src/packages.rs | 23 +++++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 0c347cac..03eb9aab 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -543,7 +543,7 @@ fn run_install( let after = deployed_commit(); return Ok(match (before, after) { (Some(before), Some(after)) if before.trim() == after.trim() => format!("{} is already up to date", app.name), - (Some(_), Some(_)) => format!("{} was updated through FlatPark", app.name), + (Some(_), Some(_)) => format!("{} was updated through its Flatpak repository", app.name), _ => format!("Flatpak finished checking {} for updates", app.name), }); } diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 9057d43f..f62630d2 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -619,7 +619,19 @@ pub mod linux { if let Some(p) = packages::find(app, &snapshot.packages, &order) { // A Flatpak runs under the id it was found under, which may be an older one. let location = if p.manager == InstallSource::Flatpak { p.name.clone() } else { binary_on_path(app.id) }; - let externally_managed = p.manager == InstallSource::Flatpak && p.origin.as_deref().is_some_and(packages::is_flatpark_origin); + let externally_managed = p.manager == InstallSource::Flatpak + && p.origin.as_deref().is_some_and(|origin| { + if packages::is_flatpark_origin(origin) { + return true; + } + let scope = match p.installation.as_deref() { + Some("user") => "--user".to_string(), + Some("system") | None => "--system".to_string(), + Some(name) => format!("--installation={name}"), + }; + crate::process::capture_flatpak(&["remotes", &scope, "--columns=name,url"], Duration::from_secs(10)) + .is_some_and(|remotes| packages::is_artcraft_remote(origin, &remotes)) + }); return Some(Installed { version: packages::version_of(p), source: p.manager, diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index f9c3163c..56c17711 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -524,7 +524,7 @@ pub(crate) fn choose_kinds_with( // opens and removes them but never replaces them. InstallSource::Pacman if is_release_arch_package(app, inst) => Some(PackageKind::ArchPkg), InstallSource::Pacman => return Vec::new(), - // Flatpaks installed from FlatPark are externally managed: the launcher will not + // Repository-managed Flatpaks are externally managed: the launcher will not // replace them with a downloaded local .flatpak bundle. InstallSource::Flatpak if inst.externally_managed => return Vec::new(), InstallSource::Flatpak => Some(PackageKind::Flatpak), @@ -636,7 +636,7 @@ fn managed_elsewhere_with(app: &CraftApp, installed: &Installed, uninstall: bool Some("system") | None => "--system".to_string(), Some(name) => format!("--installation={name}"), }; - Some(format!("{} was installed from FlatPark. Update it with flatpak update {scope} {pkg}", app.name)) + Some(format!("{} was installed from a Flatpak repository. Update it with flatpak update {scope} {pkg}", app.name)) } InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), _ => None, @@ -3377,7 +3377,10 @@ mod tests { assert!(flatpark_installed.is_externally_managed()); assert_eq!(choose_kinds_with(app, Some(&flatpark_installed), None, &[], &[PackageKind::Flatpak], false), Vec::::new()); let hint = managed_elsewhere_with(app, &flatpark_installed, false, |_| None); - assert_eq!(hint.as_deref(), Some("PhotoCraft was installed from FlatPark. Update it with flatpak update --user ai.storyteller.photocraft")); + assert_eq!( + hint.as_deref(), + Some("PhotoCraft was installed from a Flatpak repository. Update it with flatpak update --user ai.storyteller.photocraft") + ); // During uninstall, managed_elsewhere returns None so it can be removed assert_eq!(managed_elsewhere_with(app, &flatpark_installed, true, |_| None), None); @@ -3394,7 +3397,7 @@ mod tests { let hint_former = managed_elsewhere_with(app, &former_installed, false, |_| None); assert_eq!( hint_former.as_deref(), - Some("PhotoCraft was installed from FlatPark. Update it with flatpak update --system ai.storyteller.photocraft-old") + Some("PhotoCraft was installed from a Flatpak repository. Update it with flatpak update --system ai.storyteller.photocraft-old") ); let bundle_installed = Installed { diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 913d07e8..54d05c00 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -242,3 +242,26 @@ mod tests { assert!(!is_flatpark_origin("")); } } + +/// Matches the first party repository by URL, independently of its local remote name. +pub fn is_artcraft_remote(origin: &str, remotes: &str) -> bool { + remotes.lines().any(|line| { + let Some((name, url)) = line.split_once('\t') else { return false }; + let url = url.trim().trim_end_matches('/'); + name.trim() == origin && (url == "https://getartcraft.com/linux" || url.starts_with("https://getartcraft.com/linux/")) + }) +} + +#[cfg(test)] +mod remote_url_tests { + use super::*; + #[test] + fn recognizes_custom_names_and_rejects_unrelated_urls() { + assert!(is_artcraft_remote("custom", "custom\thttps://getartcraft.com/linux/\n")); + assert!(is_artcraft_remote("custom", "custom\thttps://getartcraft.com/linux/repo\n")); + for url in ["https://getartcraft.com.evil/linux", "https://getartcraft.com/linux-other", "http://getartcraft.com/linux"] { + assert!(!is_artcraft_remote("custom", &format!("custom\t{url}"))); + } + assert!(!is_artcraft_remote("other", "custom\thttps://getartcraft.com/linux")); + } +} From 1794bc154183b44fff87cc0ced19460b970c1988 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:43:45 -0500 Subject: [PATCH 13/33] fix: match the published ArtCraft OCI remote endpoint --- crates/native/src/packages.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 54d05c00..22a72b11 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -243,12 +243,13 @@ mod tests { } } -/// Matches the first party repository by URL, independently of its local remote name. +/// Matches the OCI URL from https://getartcraft.com/artcraft-oci.flatpakrepo, +/// independently of its local remote name (normally `artcraft-oci`). pub fn is_artcraft_remote(origin: &str, remotes: &str) -> bool { remotes.lines().any(|line| { let Some((name, url)) = line.split_once('\t') else { return false }; let url = url.trim().trim_end_matches('/'); - name.trim() == origin && (url == "https://getartcraft.com/linux" || url.starts_with("https://getartcraft.com/linux/")) + name.trim() == origin && url == "oci+https://getartcraft.com" }) } @@ -257,11 +258,18 @@ mod remote_url_tests { use super::*; #[test] fn recognizes_custom_names_and_rejects_unrelated_urls() { - assert!(is_artcraft_remote("custom", "custom\thttps://getartcraft.com/linux/\n")); - assert!(is_artcraft_remote("custom", "custom\thttps://getartcraft.com/linux/repo\n")); - for url in ["https://getartcraft.com.evil/linux", "https://getartcraft.com/linux-other", "http://getartcraft.com/linux"] { + assert!(is_artcraft_remote("artcraft-oci", "artcraft-oci\toci+https://getartcraft.com\n")); + assert!(is_artcraft_remote("custom", "custom\toci+https://getartcraft.com/\n")); + for url in [ + "oci+https://getartcraft.com.evil", + "oci+https://getartcraft.com/other", + "oci+http://getartcraft.com", + "https://getartcraft.com/linux", + "https://getartcraft.com/artcraft-oci.flatpakrepo", + "https://getartcraft.com", + ] { assert!(!is_artcraft_remote("custom", &format!("custom\t{url}"))); } - assert!(!is_artcraft_remote("other", "custom\thttps://getartcraft.com/linux")); + assert!(!is_artcraft_remote("other", "custom\toci+https://getartcraft.com")); } } From 3e09878c6b1eaad5ee5349ac82ae6d57e39a9fe8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:46:05 -0500 Subject: [PATCH 14/33] fix: compile Flatpak cancellation helper only on Linux --- crates/native/src/process.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index 869e7221..04b70099 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -791,6 +791,7 @@ mod inherited_pipe_tests { } /// Runs a helper while polling cancellation and draining its bounded output. +#[cfg(target_os = "linux")] pub(crate) fn cancellable_output(mut cmd: Command, check: impl Fn() -> Result<(), String>) -> Result { check()?; let mut child = @@ -823,7 +824,7 @@ pub(crate) fn cancellable_output(mut cmd: Command, check: impl Fn() -> Result<() Ok(std::process::Output { status, stdout: stdout.join().unwrap_or_default(), stderr: stderr.join().unwrap_or_default() }) } -#[cfg(all(test, unix))] +#[cfg(all(test, target_os = "linux"))] mod update_cancel_tests { use super::*; From 774bc395eb45ee0eb4d0d09c80f5c4de8d9b50fa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:49:10 -0500 Subject: [PATCH 15/33] feat: gate bundle to OCI migration behind experimental feature --- README.md | 27 ++++++++++++ apps/artcraft-launcher/Cargo.toml | 2 + crates/native/Cargo.toml | 2 + crates/native/src/backend.rs | 10 +++++ crates/native/src/install.rs | 68 +++++++++++++++++++++++++++++++ crates/native/src/packages.rs | 32 +++++++++++++++ 6 files changed, 141 insertions(+) diff --git a/README.md b/README.md index abf0a4c8..abede633 100644 --- a/README.md +++ b/README.md @@ -345,3 +345,30 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +## Experimental OCI migration + +The `experimental-oci-migration` Cargo feature is disabled by default and only acts on Linux. +Enable it when building to test migrating existing first party Flatpak bundles to the +ArtCraft OCI remote. For the launcher: + +```sh +cargo run -p artcraft-launcher --features experimental-oci-migration +``` + +On an update, a first party installation with a bundle-created `-origin` remote may be +reinstalled from the OCI repository in the same installation scope, using the same full +Flatpak ref. Existing installations managed by another repository are not redirected. +The migration reuses a remote with URL `oci+https://getartcraft.com`, or adds `artcraft-oci` +using `https://getartcraft.com/artcraft-oci.flatpakrepo`. A conflicting remote name is an error. +The current descriptor is a preview with `GPGVerify=false`; enabling this feature explicitly +opts into that repository configuration. The launcher does not uninstall the app or remove +its data. Subsequent updates use the detected OCI remote. + +If the exact ref is absent, the existing verified bundle update proceeds. A repository error, +cancellation, or missing/different repository version stops the update; migration requires +an exact match to the selected release version. Launcher pinned/nightly tracks and explicit +replacement operations do not trigger migration. The feature does not change new installs. + +Test with an isolated Flatpak installation before enabling this in release builds. The +feature is not enabled by the packaging defaults. diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml index 208c7122..685bb062 100644 --- a/apps/artcraft-launcher/Cargo.toml +++ b/apps/artcraft-launcher/Cargo.toml @@ -13,6 +13,8 @@ publish = false workspace = true [features] +# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +experimental-oci-migration = ["artcraft-launcher-native/experimental-oci-migration"] # Development only: install from `cargo xtask local serve` (see the README). local-mirror = ["artcraft-launcher-native/local-mirror"] diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 404b2967..b04c8b09 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -11,6 +11,8 @@ publish = false workspace = true [features] +# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +experimental-oci-migration = [] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped # launcher can't be steered away from GitHub's releases by an environment variable. diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 03eb9aab..fca8e3b7 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -566,6 +566,16 @@ fn run_install( if !release_matches_track(&release, track, options.prereleases) { return Err("the selected version isn't available yet. Check for updates before installing.".into()); } + #[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] + if matches!(track, VersionTrack::Stable) + && !release.prerelease + && !replace + && let Some(installed) = existing + && let Some(expected) = release.version.as_ref() + && install::linux::migrate_bundle_to_oci(app, installed, expected, stop)? + { + return Ok(format!("{} now updates through the ArtCraft OCI repository", app.name)); + } let (asset, kind) = planned_asset(app, &release, platform, options, existing, record).ok_or_else(|| { if platform.os == artcraft_launcher_core::Os::Linux && options.install_locations.folder(app.id).is_some() diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 56c17711..49af6a8c 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1226,6 +1226,74 @@ pub(crate) mod linux { } } + /// Experimental in-place migration; only an absent ref permits bundle fallback. + #[cfg(feature = "experimental-oci-migration")] + pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { + if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { + return Ok(false); + } + // Only bundle-created origins qualify; never redirect another repository's install. + if !installed.origin.as_deref().is_some_and(|origin| origin.ends_with("-origin")) { + return Ok(false); + } + let id = installed.package.as_deref().ok_or("Flatpak installation has no package ID")?; + let scope = match installed.installation.as_deref() { + Some("user") => "--user".to_string(), + Some("system") => "--system".to_string(), + Some(name) => format!("--installation={name}"), + None => return Err("Flatpak installation scope is unknown".into()), + }; + let run = |args: &[&str]| -> Result { + let mut cmd = if process::in_sandbox() { + let mut cmd = process::command("flatpak-spawn"); + cmd.args(["--host", "--watch-bus", "flatpak"]); + cmd + } else { + process::command("flatpak") + }; + cmd.arg(&scope).args(args); + let output = process::cancellable_output(cmd, || stop.check())?; + if !output.status.success() { + return Err(format!("Flatpak OCI migration failed: {}", String::from_utf8_lossy(&output.stderr).trim())); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + }; + let reference = run(&["info", "--show-ref", id])?; + let reference = reference.trim(); + if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { + return Ok(false); + } + let remotes = run(&["remotes", "--columns=name,url"])?; + let remote = remotes.lines().find_map(|line| { + let (name, _) = line.split_once('\t')?; + crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) + }); + let remote = match remote { + Some(remote) => remote, + None => { + if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { + return Err("The artcraft-oci remote points to a different repository".into()); + } + run(&["remote-add", "--noninteractive", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; + let remotes = run(&["remotes", "--columns=name,url"])?; + if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { + return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); + } + "artcraft-oci".to_string() + } + }; + let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; + if !crate::packages::oci_ref_available(reference, expected, &refs)? { + return Ok(false); + } + run(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference])?; + let origin = run(&["info", "--show-origin", id])?; + if origin.trim() != remote { + return Err("Flatpak did not switch the installed app to the ArtCraft OCI remote".into()); + } + Ok(true) + } + /// Updates an installed Flatpak from its remote repository (such as FlatPark). pub fn update_flatpak_app( app: &CraftApp, diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 22a72b11..e50d87e5 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -273,3 +273,35 @@ mod remote_url_tests { assert!(!is_artcraft_remote("other", "custom\toci+https://getartcraft.com")); } } + +/// A missing ref permits bundle fallback; an unknown or different version must not replace it. +#[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] +pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> Result { + for line in listing.lines() { + let mut fields = line.split('\t'); + if fields.next().map(str::trim) != Some(reference) { + continue; + } + let candidate = fields.next().and_then(|value| version::parse(value.trim())); + if candidate.as_ref() != Some(expected) { + return Err("The ArtCraft OCI repository does not yet provide the selected version".into()); + } + return Ok(true); + } + Ok(false) +} + +#[cfg(all(test, target_os = "linux", feature = "experimental-oci-migration"))] +mod migration_tests { + use super::*; + #[test] + fn migration_requires_the_exact_ref_and_selected_version() { + let reference = "app/ai.storyteller.photocraft/x86_64/stable"; + let expected = version::parse("1.2.3").unwrap(); + assert_eq!(oci_ref_available(reference, &expected, &format!("{reference}\t1.2.3")), Ok(true)); + assert_eq!(oci_ref_available(reference, &expected, "app/ai.storyteller.photocraft/aarch64/stable\t1.2.3"), Ok(false)); + for value in ["", "1.2.2", "1.2.4", "unknown"] { + assert!(oci_ref_available(reference, &expected, &format!("{reference}\t{value}")).is_err()); + } + } +} From fc9ef02feeda40b5c2f0abbf78365449c7b7b611 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:52:16 -0500 Subject: [PATCH 16/33] feat: expose OCI migration as an opt-in advanced setting --- README.md | 16 ++++++---------- apps/artcraft-launcher/Cargo.toml | 2 +- crates/core/src/protocol.rs | 2 ++ crates/native/Cargo.toml | 2 +- crates/native/src/backend.rs | 5 +++-- crates/native/src/install.rs | 1 - crates/native/src/packages.rs | 4 ++-- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 4 ++++ crates/ui-egui/src/i18n/de.tsv | 4 ++++ crates/ui-egui/src/i18n/es.tsv | 4 ++++ crates/ui-egui/src/i18n/fr.tsv | 4 ++++ crates/ui-egui/src/i18n/hu.tsv | 4 ++++ crates/ui-egui/src/i18n/ja.tsv | 4 ++++ crates/ui-egui/src/i18n/pl.tsv | 4 ++++ crates/ui-egui/src/i18n/pt-BR.tsv | 4 ++++ crates/ui-egui/src/i18n/ru.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-CN.tsv | 4 ++++ crates/ui-egui/src/settings.rs | 17 +++++++++++++++++ crates/ui-egui/src/settings_dialog.rs | 12 ++++++++++++ 20 files changed, 85 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index abede633..c2a3de5a 100644 --- a/README.md +++ b/README.md @@ -348,20 +348,17 @@ Forks and modified versions must remove them. ## Experimental OCI migration -The `experimental-oci-migration` Cargo feature is disabled by default and only acts on Linux. -Enable it when building to test migrating existing first party Flatpak bundles to the -ArtCraft OCI remote. For the launcher: - -```sh -cargo run -p artcraft-launcher --features experimental-oci-migration -``` +On Linux, enable **Settings → Advanced → Migrate Flatpak bundles to ArtCraft OCI +(experimental)** to opt in. The preference is saved and defaults to off, including for +existing settings files. No custom build is needed. The legacy `experimental-oci-migration` +Cargo feature remains accepted but does not opt users in. On an update, a first party installation with a bundle-created `-origin` remote may be reinstalled from the OCI repository in the same installation scope, using the same full Flatpak ref. Existing installations managed by another repository are not redirected. The migration reuses a remote with URL `oci+https://getartcraft.com`, or adds `artcraft-oci` using `https://getartcraft.com/artcraft-oci.flatpakrepo`. A conflicting remote name is an error. -The current descriptor is a preview with `GPGVerify=false`; enabling this feature explicitly +The current descriptor is a preview with `GPGVerify=false`; enabling this setting explicitly opts into that repository configuration. The launcher does not uninstall the app or remove its data. Subsequent updates use the detected OCI remote. @@ -370,5 +367,4 @@ cancellation, or missing/different repository version stops the update; migratio an exact match to the selected release version. Launcher pinned/nightly tracks and explicit replacement operations do not trigger migration. The feature does not change new installs. -Test with an isolated Flatpak installation before enabling this in release builds. The -feature is not enabled by the packaging defaults. +Test with an isolated Flatpak installation before enabling this in release builds. The setting is off in packaged builds unless the user enables it. diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml index 685bb062..1355a6bf 100644 --- a/apps/artcraft-launcher/Cargo.toml +++ b/apps/artcraft-launcher/Cargo.toml @@ -13,7 +13,7 @@ publish = false workspace = true [features] -# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +# Compatibility feature; migration now requires the saved Advanced setting. experimental-oci-migration = ["artcraft-launcher-native/experimental-oci-migration"] # Development only: install from `cargo xtask local serve` (see the README). local-mirror = ["artcraft-launcher-native/local-mirror"] diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index c3a64dcd..6d905816 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -99,6 +99,8 @@ pub struct BackendOptions { pub launcher_version: VersionTrack, /// Offer pre-releases (`0.6.0-rc.1`) as updates. pub prereleases: bool, + /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. + pub experimental_oci_migration: bool, } /// The answer to "what's the latest release of this app?". diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index b04c8b09..3e6d39ae 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -11,7 +11,7 @@ publish = false workspace = true [features] -# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +# Compatibility feature; migration now requires the saved Advanced setting. experimental-oci-migration = [] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index fca8e3b7..a5b7a522 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -566,8 +566,9 @@ fn run_install( if !release_matches_track(&release, track, options.prereleases) { return Err("the selected version isn't available yet. Check for updates before installing.".into()); } - #[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] - if matches!(track, VersionTrack::Stable) + #[cfg(target_os = "linux")] + if options.experimental_oci_migration + && matches!(track, VersionTrack::Stable) && !release.prerelease && !replace && let Some(installed) = existing diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 49af6a8c..b034bfe3 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1227,7 +1227,6 @@ pub(crate) mod linux { } /// Experimental in-place migration; only an absent ref permits bundle fallback. - #[cfg(feature = "experimental-oci-migration")] pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { return Ok(false); diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index e50d87e5..3ec4157a 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -275,7 +275,7 @@ mod remote_url_tests { } /// A missing ref permits bundle fallback; an unknown or different version must not replace it. -#[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] +#[cfg(target_os = "linux")] pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> Result { for line in listing.lines() { let mut fields = line.split('\t'); @@ -291,7 +291,7 @@ pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, li Ok(false) } -#[cfg(all(test, target_os = "linux", feature = "experimental-oci-migration"))] +#[cfg(all(test, target_os = "linux"))] mod migration_tests { use super::*; #[test] diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5c57ef9b..d17c2f85 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -436,6 +436,7 @@ impl LauncherUi { let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases, + experimental_oci_migration: self.settings.experimental_oci_migration, versions: self.settings.versions.clone(), launcher_version: self.settings.launcher_version.clone(), install_locations: self.settings.install_locations.clone(), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f5a3a2c..69786511 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -378,3 +378,7 @@ materialcraft Node graphs Uzlové grafy materialcraft Combine noises, patterns and filters. Kombinujte šumy, vzory a filtry. materialcraft Live previews Živé náhledy materialcraft Preview materials in 2D and on 3D models. Prohlížejte materiály ve 2D a na 3D modelech. + Advanced Pokročilé + Migrate Flatpak bundles to ArtCraft OCI (experimental) Převést balíčky Flatpak na ArtCraft OCI (experimentální) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Při aktualizaci převést aplikace ArtCraft, pokud je dostupný odpovídající balíček. Jinak nadále používat samostatné balíčky. + The OCI repository is a preview with GPG verification disabled. Repozitář OCI je předběžná verze s vypnutým ověřováním GPG. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5846b6a4..f4cf3285 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -410,3 +410,7 @@ materialcraft Node graphs Knotengraphen materialcraft Combine noises, patterns and filters. Rauschen, Muster und Filter kombinieren. materialcraft Live previews Live-Vorschauen materialcraft Preview materials in 2D and on 3D models. Materialien in 2D und auf 3D-Modellen anzeigen. + Advanced Erweitert + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak-Bundles zu ArtCraft OCI migrieren (experimentell) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. ArtCraft-Apps beim Aktualisieren umstellen, wenn das passende Paket verfügbar ist. Andernfalls weiterhin Bundles verwenden. + The OCI repository is a preview with GPG verification disabled. Das OCI-Repository ist eine Vorschau mit deaktivierter GPG-Prüfung. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2fae1366..cd69f654 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -391,3 +391,7 @@ materialcraft Node graphs Grafos de nodos materialcraft Combine noises, patterns and filters. Combina ruidos, patrones y filtros. materialcraft Live previews Vistas previas en vivo materialcraft Preview materials in 2D and on 3D models. Previsualiza materiales en 2D y sobre modelos 3D. + Advanced Avanzado + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrar paquetes Flatpak a ArtCraft OCI (experimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Al actualizar, migrar las aplicaciones de ArtCraft si está disponible el paquete correspondiente. De lo contrario, seguir usando paquetes independientes. + The OCI repository is a preview with GPG verification disabled. El repositorio OCI es una versión preliminar con la verificación GPG desactivada. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d48871b3..8f7037d4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -408,3 +408,7 @@ materialcraft Node graphs Graphes de nœuds materialcraft Combine noises, patterns and filters. Combinez bruits, motifs et filtres. materialcraft Live previews Aperçus en direct materialcraft Preview materials in 2D and on 3D models. Visualisez les matériaux en 2D et sur des modèles 3D. + Advanced Avancé + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrer les paquets Flatpak vers ArtCraft OCI (expérimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Lors des mises à jour, migrer les applications ArtCraft si le paquet correspondant est disponible. Sinon, continuer à utiliser les paquets autonomes. + The OCI repository is a preview with GPG verification disabled. Le dépôt OCI est une version préliminaire avec la vérification GPG désactivée. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fc8a746f..dbea7894 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -411,3 +411,7 @@ materialcraft Node graphs Csomópontgráfok materialcraft Combine noises, patterns and filters. Kombinálj zajokat, mintákat és szűrőket. materialcraft Live previews Élő előnézetek materialcraft Preview materials in 2D and on 3D models. Tekintsd meg az anyagokat 2D-ben és 3D modelleken. + Advanced Haladó + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak-csomagok átállítása ArtCraft OCI-ra (kísérleti) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Frissítéskor állítsa át az ArtCraft alkalmazásait, ha elérhető a megfelelő csomag. Egyébként továbbra is önálló csomagokat használjon. + The OCI repository is a preview with GPG verification disabled. Az OCI-tároló előzetes verzió, kikapcsolt GPG-ellenőrzéssel. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c34c833..054ed34c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -413,3 +413,7 @@ materialcraft Node graphs ノードグラフ materialcraft Combine noises, patterns and filters. ノイズ、パターン、フィルターを組み合わせます。 materialcraft Live previews リアルタイムプレビュー materialcraft Preview materials in 2D and on 3D models. マテリアルを2Dと3Dモデル上でプレビューします。 + Advanced 詳細設定 + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak バンドルを ArtCraft OCI に移行する(実験的) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. 更新時に対応するパッケージがあれば ArtCraft 公式アプリを移行します。なければ引き続きバンドルを使用します。 + The OCI repository is a preview with GPG verification disabled. OCI リポジトリはプレビュー版で、GPG 検証は無効になっています。 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d604e27b..a9c2a80f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -412,3 +412,7 @@ materialcraft Node graphs Grafy węzłów materialcraft Combine noises, patterns and filters. Łącz szumy, wzory i filtry. materialcraft Live previews Podgląd na żywo materialcraft Preview materials in 2D and on 3D models. Przeglądaj materiały w 2D i na modelach 3D. + Advanced Zaawansowane + Migrate Flatpak bundles to ArtCraft OCI (experimental) Przenoś pakiety Flatpak do ArtCraft OCI (eksperymentalne) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Podczas aktualizacji przenoś aplikacje ArtCraft, gdy dostępny jest odpowiedni pakiet. W przeciwnym razie nadal używaj samodzielnych pakietów. + The OCI repository is a preview with GPG verification disabled. Repozytorium OCI jest wersją testową z wyłączoną weryfikacją GPG. diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 5de6d3d3..f0f708c7 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -398,3 +398,7 @@ materialcraft Node graphs Grafos de nós materialcraft Combine noises, patterns and filters. Combine ruídos, padrões e filtros. materialcraft Live previews Pré-visualizações ao vivo materialcraft Preview materials in 2D and on 3D models. Visualize materiais em 2D e em modelos 3D. + Advanced Avançado + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrar pacotes Flatpak para ArtCraft OCI (experimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Ao atualizar, migrar os aplicativos da ArtCraft quando o pacote correspondente estiver disponível. Caso contrário, continuar usando pacotes independentes. + The OCI repository is a preview with GPG verification disabled. O repositório OCI é uma prévia com a verificação GPG desativada. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e0cdebe5..204e3ed3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -406,3 +406,7 @@ materialcraft Node graphs Графы узлов materialcraft Combine noises, patterns and filters. Комбинируйте шумы, узоры и фильтры. materialcraft Live previews Предпросмотр в реальном времени materialcraft Preview materials in 2D and on 3D models. Просматривайте материалы в 2D и на 3D-моделях. + Advanced Дополнительно + Migrate Flatpak bundles to ArtCraft OCI (experimental) Переводить пакеты Flatpak на ArtCraft OCI (экспериментально) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. При обновлении переводить приложения ArtCraft на репозиторий, если доступен соответствующий пакет. Иначе продолжать использовать отдельные пакеты. + The OCI repository is a preview with GPG verification disabled. Репозиторий OCI работает в предварительном режиме с отключённой проверкой GPG. diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index 43096028..ff6dd2d1 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -378,3 +378,7 @@ materialcraft Node graphs 节点图 materialcraft Combine noises, patterns and filters. 组合噪声、图案和滤镜。 materialcraft Live previews 实时预览 materialcraft Preview materials in 2D and on 3D models. 在二维视图和三维模型上预览材质。 + Advanced 高级 + Migrate Flatpak bundles to ArtCraft OCI (experimental) 将 Flatpak 软件包迁移到 ArtCraft OCI(实验性) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. 更新时,如果有对应的软件包,则迁移 ArtCraft 官方应用;否则继续使用独立软件包。 + The OCI repository is a preview with GPG verification disabled. OCI 仓库目前处于预览阶段,GPG 验证已禁用。 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index fe520ca1..868f555f 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -108,6 +108,8 @@ pub struct Settings { /// Show a system notification when an installed app has an update. pub notify: bool, pub prereleases: bool, + /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. + pub experimental_oci_migration: bool, /// Version track per app. Missing entries follow stable releases. pub versions: BTreeMap, pub launcher_version: artcraft_launcher_core::protocol::VersionTrack, @@ -202,6 +204,7 @@ impl Default for Settings { auto_update: false, notify: true, prereleases: false, + experimental_oci_migration: false, versions: BTreeMap::new(), launcher_version: Default::default(), preferred_kinds: default_preferred_kinds(), @@ -215,3 +218,17 @@ impl Default for Settings { } } } + +#[cfg(test)] +mod oci_settings_tests { + use super::*; + + #[test] + fn migration_requires_opt_in_and_survives_settings_roundtrip() { + let old: Settings = serde_json::from_str("{}").unwrap(); + assert!(!old.experimental_oci_migration); + let enabled = Settings { experimental_oci_migration: true, ..old }; + let restored: Settings = serde_json::from_str(&serde_json::to_string(&enabled).unwrap()).unwrap(); + assert!(restored.experimental_oci_migration); + } +} diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs index 34d7442a..2e9493a5 100644 --- a/crates/ui-egui/src/settings_dialog.rs +++ b/crates/ui-egui/src/settings_dialog.rs @@ -123,6 +123,7 @@ impl LauncherUi { this.settings_install(ui, &mut **host); this.settings_background(ui, &mut **host); this.settings_community(ui); + this.settings_advanced(ui); }, ); } @@ -202,6 +203,17 @@ impl LauncherUi { } /// Where apps go and in what format, for the desktop builds that manage installs. + fn settings_advanced(&mut self, ui: &mut Ui) { + if !self.caps.manage_apps || !self.platform.is_some_and(|p| p.os == Os::Linux) { + return; + } + sheet::panel(ui, tl!("Advanced"), |ui| { + ui.checkbox(&mut self.settings.experimental_oci_migration, tl!("Migrate Flatpak bundles to ArtCraft OCI (experimental)")); + note(ui, tl!("On update, switch first party apps when the matching package is available. Otherwise, keep using bundles.")); + note(ui, tl!("The OCI repository is a preview with GPG verification disabled.")); + }); + } + fn settings_install(&mut self, ui: &mut Ui, host: &mut dyn Host) { if !self.caps.manage_apps { return; From 53509be742504cc6ee8ea75b97b0e58f8b346ada Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:55:13 -0500 Subject: [PATCH 17/33] docs: keep experimental OCI migration details in the PR --- README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/README.md b/README.md index c2a3de5a..abf0a4c8 100644 --- a/README.md +++ b/README.md @@ -345,26 +345,3 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

- -## Experimental OCI migration - -On Linux, enable **Settings → Advanced → Migrate Flatpak bundles to ArtCraft OCI -(experimental)** to opt in. The preference is saved and defaults to off, including for -existing settings files. No custom build is needed. The legacy `experimental-oci-migration` -Cargo feature remains accepted but does not opt users in. - -On an update, a first party installation with a bundle-created `-origin` remote may be -reinstalled from the OCI repository in the same installation scope, using the same full -Flatpak ref. Existing installations managed by another repository are not redirected. -The migration reuses a remote with URL `oci+https://getartcraft.com`, or adds `artcraft-oci` -using `https://getartcraft.com/artcraft-oci.flatpakrepo`. A conflicting remote name is an error. -The current descriptor is a preview with `GPGVerify=false`; enabling this setting explicitly -opts into that repository configuration. The launcher does not uninstall the app or remove -its data. Subsequent updates use the detected OCI remote. - -If the exact ref is absent, the existing verified bundle update proceeds. A repository error, -cancellation, or missing/different repository version stops the update; migration requires -an exact match to the selected release version. Launcher pinned/nightly tracks and explicit -replacement operations do not trigger migration. The feature does not change new installs. - -Test with an isolated Flatpak installation before enabling this in release builds. The setting is off in packaged builds unless the user enables it. From 25e53ccbbaa228fc22f7a10967fe1d27daa15786 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:56:55 -0500 Subject: [PATCH 18/33] refactor: name OCI migration controls oci_migration --- apps/artcraft-launcher/Cargo.toml | 2 +- crates/core/src/protocol.rs | 2 +- crates/native/Cargo.toml | 2 +- crates/native/src/backend.rs | 2 +- crates/native/src/install.rs | 2 +- crates/ui-egui/src/app.rs | 2 +- crates/ui-egui/src/settings.rs | 10 +++++----- crates/ui-egui/src/settings_dialog.rs | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml index 1355a6bf..3fc2e3e8 100644 --- a/apps/artcraft-launcher/Cargo.toml +++ b/apps/artcraft-launcher/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [features] # Compatibility feature; migration now requires the saved Advanced setting. -experimental-oci-migration = ["artcraft-launcher-native/experimental-oci-migration"] +oci_migration = ["artcraft-launcher-native/oci_migration"] # Development only: install from `cargo xtask local serve` (see the README). local-mirror = ["artcraft-launcher-native/local-mirror"] diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 6d905816..ccd37be2 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -100,7 +100,7 @@ pub struct BackendOptions { /// Offer pre-releases (`0.6.0-rc.1`) as updates. pub prereleases: bool, /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. - pub experimental_oci_migration: bool, + pub oci_migration: bool, } /// The answer to "what's the latest release of this app?". diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 3e6d39ae..e356dc00 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [features] # Compatibility feature; migration now requires the saved Advanced setting. -experimental-oci-migration = [] +oci_migration = [] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped # launcher can't be steered away from GitHub's releases by an environment variable. diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index a5b7a522..e3e43782 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -567,7 +567,7 @@ fn run_install( return Err("the selected version isn't available yet. Check for updates before installing.".into()); } #[cfg(target_os = "linux")] - if options.experimental_oci_migration + if options.oci_migration && matches!(track, VersionTrack::Stable) && !release.prerelease && !replace diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index b034bfe3..229b7096 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1226,7 +1226,7 @@ pub(crate) mod linux { } } - /// Experimental in-place migration; only an absent ref permits bundle fallback. + /// Opt-in OCI migration; only an absent ref permits bundle fallback. pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { return Ok(false); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d17c2f85..f85c04d5 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -436,7 +436,7 @@ impl LauncherUi { let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases, - experimental_oci_migration: self.settings.experimental_oci_migration, + oci_migration: self.settings.oci_migration, versions: self.settings.versions.clone(), launcher_version: self.settings.launcher_version.clone(), install_locations: self.settings.install_locations.clone(), diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 868f555f..b634dec3 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -109,7 +109,7 @@ pub struct Settings { pub notify: bool, pub prereleases: bool, /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. - pub experimental_oci_migration: bool, + pub oci_migration: bool, /// Version track per app. Missing entries follow stable releases. pub versions: BTreeMap, pub launcher_version: artcraft_launcher_core::protocol::VersionTrack, @@ -204,7 +204,7 @@ impl Default for Settings { auto_update: false, notify: true, prereleases: false, - experimental_oci_migration: false, + oci_migration: false, versions: BTreeMap::new(), launcher_version: Default::default(), preferred_kinds: default_preferred_kinds(), @@ -226,9 +226,9 @@ mod oci_settings_tests { #[test] fn migration_requires_opt_in_and_survives_settings_roundtrip() { let old: Settings = serde_json::from_str("{}").unwrap(); - assert!(!old.experimental_oci_migration); - let enabled = Settings { experimental_oci_migration: true, ..old }; + assert!(!old.oci_migration); + let enabled = Settings { oci_migration: true, ..old }; let restored: Settings = serde_json::from_str(&serde_json::to_string(&enabled).unwrap()).unwrap(); - assert!(restored.experimental_oci_migration); + assert!(restored.oci_migration); } } diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs index 2e9493a5..94026123 100644 --- a/crates/ui-egui/src/settings_dialog.rs +++ b/crates/ui-egui/src/settings_dialog.rs @@ -208,7 +208,7 @@ impl LauncherUi { return; } sheet::panel(ui, tl!("Advanced"), |ui| { - ui.checkbox(&mut self.settings.experimental_oci_migration, tl!("Migrate Flatpak bundles to ArtCraft OCI (experimental)")); + ui.checkbox(&mut self.settings.oci_migration, tl!("Migrate Flatpak bundles to ArtCraft OCI (experimental)")); note(ui, tl!("On update, switch first party apps when the matching package is available. Otherwise, keep using bundles.")); note(ui, tl!("The OCI repository is a preview with GPG verification disabled.")); }); From a4e76179ccb2fa740d4ff2515049c3d704e294b8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 23:02:03 -0500 Subject: [PATCH 19/33] fix: preserve repository ownership and Flatpak update results --- crates/native/src/backend.rs | 7 ------- crates/native/src/detect.rs | 6 +++--- crates/native/src/install.rs | 9 +++++++-- crates/native/src/packages.rs | 28 ++++++++++++++++++++++++++++ crates/native/src/process.rs | 6 ++++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index e3e43782..bea5c506 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -379,13 +379,6 @@ impl NativeBackend { // One installer call can cover several apps, and fail after some of them went in. let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned(); let result = match (result, latest) { - (Ok(_), _) - if existing.as_ref().is_some_and(|e| e.externally_managed) - && found.is_some() - && existing.as_ref().and_then(|e| e.version.as_ref()) == found.as_ref().and_then(|f| f.version.as_ref()) => - { - Ok(format!("{} is already up to date", app.name)) - } (Err(e), Some(release)) if !uninstall && release.version.as_ref().is_some_and(|v| landed_despite(&e, existing.as_ref(), found.as_ref(), v)) => { diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index f62630d2..75dcfe8d 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -620,7 +620,7 @@ pub mod linux { // A Flatpak runs under the id it was found under, which may be an older one. let location = if p.manager == InstallSource::Flatpak { p.name.clone() } else { binary_on_path(app.id) }; let externally_managed = p.manager == InstallSource::Flatpak - && p.origin.as_deref().is_some_and(|origin| { + && p.origin.as_deref().is_none_or(|origin| { if packages::is_flatpark_origin(origin) { return true; } @@ -629,8 +629,8 @@ pub mod linux { Some("system") | None => "--system".to_string(), Some(name) => format!("--installation={name}"), }; - crate::process::capture_flatpak(&["remotes", &scope, "--columns=name,url"], Duration::from_secs(10)) - .is_some_and(|remotes| packages::is_artcraft_remote(origin, &remotes)) + crate::process::capture_flatpak(&["remotes", &scope, "--show-disabled", "--columns=name,url"], Duration::from_secs(10)) + .is_none_or(|remotes| packages::remote_manages_flatpak(origin, &remotes)) }); return Some(Installed { version: packages::version_of(p), diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 229b7096..c67510d9 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -121,6 +121,11 @@ pub fn install_at( { return Err(format!("{} is running. Quit it, then try again.", app.name)); } + if kind == PackageKind::Flatpak + && let Some(installed) = existing.filter(|installed| installed.source == InstallSource::Flatpak && installed.externally_managed) + { + return Err(managed_elsewhere(app, installed, false).unwrap_or_else(|| "Update this app through its Flatpak repository".into())); + } bind_artifact_version(app, release, asset)?; // A feed can't send an app back to an older build; only a version the person chose by name // (a downgrade with a way back if it fails) gets past this. @@ -1262,7 +1267,7 @@ pub(crate) mod linux { if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { return Ok(false); } - let remotes = run(&["remotes", "--columns=name,url"])?; + let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; let remote = remotes.lines().find_map(|line| { let (name, _) = line.split_once('\t')?; crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) @@ -1274,7 +1279,7 @@ pub(crate) mod linux { return Err("The artcraft-oci remote points to a different repository".into()); } run(&["remote-add", "--noninteractive", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; - let remotes = run(&["remotes", "--columns=name,url"])?; + let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); } diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 3ec4157a..913bb67c 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -305,3 +305,31 @@ mod migration_tests { } } } + +/// Only a confirmed bundle origin with no repository URL permits bundle replacement. +/// Missing rows are unknown, so preserve remote ownership conservatively. +pub fn remote_manages_flatpak(origin: &str, remotes: &str) -> bool { + remotes + .lines() + .find_map(|line| { + let mut fields = line.split('\t'); + if fields.next().map(str::trim) != Some(origin) { + return None; + } + Some(fields.next().is_some_and(|url| !url.trim().is_empty())) + }) + .unwrap_or(true) +} + +#[cfg(test)] +mod remote_ownership_tests { + use super::*; + #[test] + fn only_confirmed_empty_urls_allow_bundle_replacement() { + assert!(!remote_manages_flatpak("app-origin", "app-origin\t\n")); + assert!(remote_manages_flatpak("renamed", "renamed\thttps://example.org/repo\n")); + assert!(remote_manages_flatpak("renamed", "renamed\toci+https://getartcraft.com\n")); + assert!(remote_manages_flatpak("missing", "other\t\n")); + assert!(!remote_manages_flatpak("app-origin", "app-origin")); + } +} diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index 04b70099..e4c38e36 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -797,7 +797,13 @@ pub(crate) fn cancellable_output(mut cmd: Command, check: impl Fn() -> Result<() let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run flatpak ({e})"))?; let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take())); + let started = Instant::now(); let status = loop { + if started.elapsed() >= Duration::from_secs(60 * 60) { + let _ = child.kill(); + let _ = child.wait(); + return Err("Flatpak did not finish within one hour".into()); + } if let Err(e) = check() { let _ = child.kill(); let _ = child.wait(); From 55b6d660be92fc99051e29fc74ed548693ed58b8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 23:11:13 -0500 Subject: [PATCH 20/33] test: exercise OCI settings and fix Flatpak remote creation --- crates/native/src/install.rs | 2 +- crates/ui-egui/tests/ui.rs | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index c67510d9..a19069fd 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1278,7 +1278,7 @@ pub(crate) mod linux { if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { return Err("The artcraft-oci remote points to a different repository".into()); } - run(&["remote-add", "--noninteractive", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; + run(&["remote-add", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 10f32d0f..a3fb1f7b 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -2943,3 +2943,54 @@ fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes h.run_steps(30); assert!(!showing(&h, "photocraft"), "a click outside did not close it"); } + +#[test] +fn oci_migration_setting_is_linux_only_and_configures_the_backend() { + let label = "Migrate Flatpak bundles to ArtCraft OCI (experimental)"; + for (os, manage) in [(Os::Linux, true), (Os::MacOs, true), (Os::Windows, true), (Os::Linux, false)] { + let (mut h, sent) = harness_full( + events(), + ViewMode::Grid, + egui::Theme::Dark, + manage, + 1.0, + artcraft_launcher_ui_egui::TitleBar::Native, + Rc::default(), + Some(Platform { os, arch: Arch::X64 }), + None, + ); + h.run_steps(6); + h.get_by_label("Settings").click(); + h.run_steps(4); + if os != Os::Linux || !manage { + assert!(h.query_by_label(label).is_none()); + continue; + } + assert!(!h.state().as_ref().unwrap().settings.oci_migration); + h.get_by_label(label).click(); + h.run_steps(3); + assert!(h.state().as_ref().unwrap().settings.oci_migration); + h.get_by_label("The OCI repository is a preview with GPG verification disabled.").scroll_to_me(); + h.run_steps(4); + snapshot(&mut h, "oci-migration-enabled"); + h.get_by_label("Done").click(); + h.run_steps(4); + assert!(sent.borrow().iter().any(|command| matches!(command, Command::Configure(options) if options.oci_migration))); + h.get_by_label("Settings").click(); + h.run_steps(4); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("Done").click(); + h.run_steps(4); + assert!(!h.state().as_ref().unwrap().settings.oci_migration); + let last = sent + .borrow() + .iter() + .filter_map(|command| match command { + Command::Configure(options) => Some(options.oci_migration), + _ => None, + }) + .next_back(); + assert_eq!(last, Some(false)); + } +} From f8386cd1d402ef4c59b591aae1d961ad1540ce44 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 23:31:28 -0500 Subject: [PATCH 21/33] fix: refresh the OCI remote's AppStream data before checking versions --- crates/native/src/install.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index a19069fd..a2668086 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1286,6 +1286,9 @@ pub(crate) mod linux { "artcraft-oci".to_string() } }; + // The Version column comes from the remote's AppStream catalog, which Flatpak only fetches + // on demand; without a refresh a new remote lists refs with empty versions. + run(&["update", "--appstream", &remote])?; let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; if !crate::packages::oci_ref_available(reference, expected, &refs)? { return Ok(false); From 89abb2e5d3c6081a4760aed670ee40e306083dd6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 23:44:37 -0500 Subject: [PATCH 22/33] fix: keep Flatpak OCI migration safe to cancel and retry Add the artcraft-oci remote with --if-not-exists so concurrent migrations do not fail, let the --reinstall step finish once started because killing flatpak-spawn does not stop the host transaction, and when the reinstall fails after removing the ref, point to the .flatpak bundle on the releases page. --- crates/native/src/install.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index a2668086..a9ec3675 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1247,7 +1247,9 @@ pub(crate) mod linux { Some(name) => format!("--installation={name}"), None => return Err("Flatpak installation scope is unknown".into()), }; - let run = |args: &[&str]| -> Result { + // `cancellable` is false for steps that must not stop part-way: killing flatpak-spawn + // doesn't wait for the host transaction, and `--reinstall` removes the old ref first. + let flatpak = |args: &[&str], cancellable: bool| -> Result { let mut cmd = if process::in_sandbox() { let mut cmd = process::command("flatpak-spawn"); cmd.args(["--host", "--watch-bus", "flatpak"]); @@ -1256,12 +1258,13 @@ pub(crate) mod linux { process::command("flatpak") }; cmd.arg(&scope).args(args); - let output = process::cancellable_output(cmd, || stop.check())?; + let output = process::cancellable_output(cmd, || if cancellable { stop.check() } else { Ok(()) })?; if !output.status.success() { return Err(format!("Flatpak OCI migration failed: {}", String::from_utf8_lossy(&output.stderr).trim())); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) }; + let run = |args: &[&str]| flatpak(args, true); let reference = run(&["info", "--show-ref", id])?; let reference = reference.trim(); if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { @@ -1278,7 +1281,8 @@ pub(crate) mod linux { if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { return Err("The artcraft-oci remote points to a different repository".into()); } - run(&["remote-add", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; + // Another app's migration may add the remote first; the URL is checked below. + run(&["remote-add", "--if-not-exists", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); @@ -1293,7 +1297,18 @@ pub(crate) mod linux { if !crate::packages::oci_ref_available(reference, expected, &refs)? { return Ok(false); } - run(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference])?; + stop.check()?; + if let Err(e) = flatpak(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference], false) { + // The bundle origin has no repository to restore from, so point at the bundle itself. + if flatpak(&["info", id], false).is_err() { + return Err(format!( + "{e}. {} is no longer installed; reinstall it from the .flatpak bundle at {}", + app.name, + app.releases_url() + )); + } + return Err(e); + } let origin = run(&["info", "--show-origin", id])?; if origin.trim() != remote { return Err("Flatpak did not switch the installed app to the ArtCraft OCI remote".into()); From b1c3e8025d9f052200672ff854769a783598c32b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 17:37:50 -0500 Subject: [PATCH 23/33] style: format Flatpak migration error --- crates/native/src/install.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index a9ec3675..92f80d67 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1301,11 +1301,7 @@ pub(crate) mod linux { if let Err(e) = flatpak(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference], false) { // The bundle origin has no repository to restore from, so point at the bundle itself. if flatpak(&["info", id], false).is_err() { - return Err(format!( - "{e}. {} is no longer installed; reinstall it from the .flatpak bundle at {}", - app.name, - app.releases_url() - )); + return Err(format!("{e}. {} is no longer installed; reinstall it from the .flatpak bundle at {}", app.name, app.releases_url())); } return Err(e); } From e5d30397472f74ae44fb8d7cedbb59f8b950d140 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:29:10 -0500 Subject: [PATCH 24/33] fix: Flatpak review fixes, and add the signature lookaside for signed bundles Review of #1147: - A failed OCI migration precheck (no version in the remote's AppStream data, polkit refusing remote-add, offline) now falls back to the bundle update instead of failing it; only errors once the reinstall starts are reported. The ref must list exactly the selected version. - `flatpak list` names a named installation `system ()`; the parser keeps ``, and one packages::flatpak_scope builds the --user/--system/--installation= argument everywhere. - A repository-managed Flatpak on a pinned or nightly track gets the "update with flatpak" hint instead of a plain `flatpak update`, and status no longer offers a GitHub version for it. - The migration refuses while the app runs; the origin check after the reinstall can't be cancelled; is_flatpark_origin matches only `flatpark`; settings_install keeps its doc; the no-op oci_migration Cargo features are gone. Signed bundles (storytold/craft-common-actions#129): a signed bundle's origin has the release key but no signature lookaside, which `flatpak build-bundle` can't record, so its first update from the ArtCraft OCI repository fails with "no valid signatures". The updater then sets --signature-lookaside=https://getartcraft.com/signatures on that origin once and retries; any other origin or error is left alone. --- apps/artcraft-launcher/Cargo.toml | 2 - crates/core/src/status.rs | 5 + crates/native/Cargo.toml | 2 - crates/native/src/backend.rs | 12 +-- crates/native/src/detect.rs | 21 +--- crates/native/src/install.rs | 132 +++++++++++++++----------- crates/native/src/packages.rs | 77 +++++++++++---- crates/ui-egui/src/settings_dialog.rs | 3 +- 8 files changed, 147 insertions(+), 107 deletions(-) diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml index 3fc2e3e8..208c7122 100644 --- a/apps/artcraft-launcher/Cargo.toml +++ b/apps/artcraft-launcher/Cargo.toml @@ -13,8 +13,6 @@ publish = false workspace = true [features] -# Compatibility feature; migration now requires the saved Advanced setting. -oci_migration = ["artcraft-launcher-native/oci_migration"] # Development only: install from `cargo xtask local serve` (see the README). local-mirror = ["artcraft-launcher-native/local-mirror"] diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index aac0618b..0e5a5be7 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -128,6 +128,8 @@ pub fn status(installed: Option<&Installed>, latest: Latest<'_>) -> AppStatus { } (Some(_), Latest::Pending | Latest::Failed | Latest::NoRelease) => AppStatus::Installed, (Some(inst), Latest::Found { version: latest, .. }) => match (latest, inst.version.as_ref()) { + // A repository-managed copy updates to what its repository has, which GitHub doesn't say. + (Some(l), Some(i)) if version::is_update(l, i) && inst.externally_managed => AppStatus::Installed, (Some(l), Some(i)) if version::is_update(l, i) => AppStatus::UpdateAvailable, (Some(_), Some(_)) => AppStatus::UpToDate, // Version unknown on one side: we can't promise "up to date". @@ -166,6 +168,9 @@ mod tests { assert_eq!(status(Some(&inst("0.6.0")), found(true)), AppStatus::UpToDate); assert_eq!(status(Some(&inst("0.4.0")), Latest::Failed), AppStatus::Installed); assert_eq!(status(Some(&inst("garbage")), found(true)), AppStatus::Installed); + let repo = Installed { externally_managed: true, ..inst("0.4.0") }; + assert_eq!(status(Some(&repo), found(true)), AppStatus::Installed); + assert_eq!(status(Some(&Installed { externally_managed: true, ..inst("0.5.0") }), found(true)), AppStatus::UpToDate); assert!(AppStatus::UpdateAvailable.is_installed() && !AppStatus::Available.is_installed()); } } diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index e356dc00..404b2967 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -11,8 +11,6 @@ publish = false workspace = true [features] -# Compatibility feature; migration now requires the saved Advanced setting. -oci_migration = [] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped # launcher can't be steered away from GitHub's releases by an environment variable. diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index bea5c506..379daf0e 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -516,23 +516,23 @@ fn run_install( && inst.source == artcraft_launcher_core::InstallSource::Flatpak && inst.externally_managed { + // `flatpak update` only follows the repository's latest build, never a pinned or nightly track. + if !matches!(options.versions.get(app.id), None | Some(VersionTrack::Stable)) { + return Err(install::managed_elsewhere(app, inst, false).unwrap_or_else(|| "Update this app through its Flatpak repository".into())); + } #[cfg(target_os = "linux")] { shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Installing }); let mut phase = |p| shared.emit(Event::Progress { app: app.id.to_string(), phase: p }); let default_id = app.bundle_id(); let target_id = inst.package.as_deref().unwrap_or(&default_id); - let scope = match inst.installation.as_deref() { - Some("user") => "--user".to_string(), - Some("system") | None => "--system".to_string(), - Some(name) => format!("--installation={name}"), - }; + let scope = crate::packages::flatpak_scope(inst.installation.as_deref()); let deployed_commit = || { crate::process::capture_flatpak(&["info", &scope, "--show-commit", target_id], std::time::Duration::from_secs(15)) .filter(|value| !value.trim().is_empty()) }; let before = deployed_commit(); - install::linux::update_flatpak_app(app, target_id, inst.installation.as_deref(), stop, &mut phase)?; + install::linux::update_flatpak_app(app, target_id, inst.installation.as_deref(), inst.origin.as_deref(), stop, &mut phase)?; let after = deployed_commit(); return Ok(match (before, after) { (Some(before), Some(after)) if before.trim() == after.trim() => format!("{} is already up to date", app.name), diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 75dcfe8d..4a685c05 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -507,19 +507,8 @@ pub mod linux { .iter_mut() .filter(|(id, v, _, _)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) { - let custom_scope; - let scope_arg = if !scope.is_empty() { - vec!["--user"] - } else { - match installation.as_deref() { - Some("system") | None => vec!["--system"], - Some(named) => { - custom_scope = format!("--installation={named}"); - vec![custom_scope.as_str()] - } - } - }; - let info_args = [&["info"][..], &scope_arg, &[&arch, id]].concat(); + let scope_arg = packages::flatpak_scope(if scope.is_empty() { installation.as_deref() } else { Some("user") }); + let info_args = ["info", &scope_arg, &arch, id]; if let Some(v) = crate::process::capture_flatpak(&info_args, t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } @@ -624,11 +613,7 @@ pub mod linux { if packages::is_flatpark_origin(origin) { return true; } - let scope = match p.installation.as_deref() { - Some("user") => "--user".to_string(), - Some("system") | None => "--system".to_string(), - Some(name) => format!("--installation={name}"), - }; + let scope = packages::flatpak_scope(p.installation.as_deref()); crate::process::capture_flatpak(&["remotes", &scope, "--show-disabled", "--columns=name,url"], Duration::from_secs(10)) .is_none_or(|remotes| packages::remote_manages_flatpak(origin, &remotes)) }); diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 92f80d67..9da0be50 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -636,11 +636,7 @@ fn managed_elsewhere_with(app: &CraftApp, installed: &Installed, uninstall: bool InstallSource::Flatpak if !uninstall && installed.externally_managed => { let default_id = app.bundle_id(); let pkg = installed.package.as_deref().unwrap_or(&default_id); - let scope = match installed.installation.as_deref() { - Some("user") => "--user".to_string(), - Some("system") | None => "--system".to_string(), - Some(name) => format!("--installation={name}"), - }; + let scope = crate::packages::flatpak_scope(installed.installation.as_deref()); Some(format!("{} was installed from a Flatpak repository. Update it with flatpak update {scope} {pkg}", app.name)) } InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), @@ -1231,7 +1227,8 @@ pub(crate) mod linux { } } - /// Opt-in OCI migration; only an absent ref permits bundle fallback. + /// Opt-in OCI migration. Ok(false) keeps the bundle: anything that fails before the reinstall + /// starts (no matching ref or version, polkit refusal, offline) falls back to the bundle update. pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { return Ok(false); @@ -1240,13 +1237,10 @@ pub(crate) mod linux { if !installed.origin.as_deref().is_some_and(|origin| origin.ends_with("-origin")) { return Ok(false); } - let id = installed.package.as_deref().ok_or("Flatpak installation has no package ID")?; - let scope = match installed.installation.as_deref() { - Some("user") => "--user".to_string(), - Some("system") => "--system".to_string(), - Some(name) => format!("--installation={name}"), - None => return Err("Flatpak installation scope is unknown".into()), + let (Some(id), Some(installation)) = (installed.package.as_deref(), installed.installation.as_deref()) else { + return Ok(false); }; + let scope = crate::packages::flatpak_scope(Some(installation)); // `cancellable` is false for steps that must not stop part-way: killing flatpak-spawn // doesn't wait for the host transaction, and `--reinstall` removes the old ref first. let flatpak = |args: &[&str], cancellable: bool| -> Result { @@ -1265,47 +1259,58 @@ pub(crate) mod linux { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) }; let run = |args: &[&str]| flatpak(args, true); - let reference = run(&["info", "--show-ref", id])?; - let reference = reference.trim(); - if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { - return Ok(false); - } - let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; - let remote = remotes.lines().find_map(|line| { - let (name, _) = line.split_once('\t')?; - crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) - }); - let remote = match remote { - Some(remote) => remote, - None => { - if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { - return Err("The artcraft-oci remote points to a different repository".into()); - } - // Another app's migration may add the remote first; the URL is checked below. - run(&["remote-add", "--if-not-exists", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; - let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; - if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { - return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); - } - "artcraft-oci".to_string() + let precheck = || -> Result, String> { + let reference = run(&["info", "--show-ref", id])?.trim().to_string(); + if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { + return Ok(None); } + let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; + let remote = remotes.lines().find_map(|line| { + let (name, _) = line.split_once('\t')?; + crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) + }); + let remote = match remote { + Some(remote) => remote, + None => { + if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { + return Ok(None); + } + // Another app's migration may add the remote first; the URL is checked below. + run(&["remote-add", "--if-not-exists", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; + let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; + if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { + return Ok(None); + } + "artcraft-oci".to_string() + } + }; + // The Version column comes from the remote's AppStream catalog, which Flatpak only fetches + // on demand; without a refresh a new remote lists refs with empty versions. + run(&["update", "--appstream", &remote])?; + let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; + Ok(crate::packages::oci_ref_available(&reference, expected, &refs).then_some((remote, reference))) }; - // The Version column comes from the remote's AppStream catalog, which Flatpak only fetches - // on demand; without a refresh a new remote lists refs with empty versions. - run(&["update", "--appstream", &remote])?; - let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; - if !crate::packages::oci_ref_available(reference, expected, &refs)? { - return Ok(false); + let (remote, reference) = match precheck() { + Ok(Some(found)) => found, + Err(e) if e == CANCELLED => return Err(e), + Ok(None) | Err(_) => return Ok(false), + }; + // `install_at` won't replace a running app either; the reinstall can't be cancelled. + if process::capture_flatpak(&["ps", "--columns=application"], std::time::Duration::from_secs(10)) + .is_some_and(|ps| ps.lines().any(|line| line.trim() == id)) + { + return Err(format!("{} is running. Quit it, then try again.", app.name)); } stop.check()?; - if let Err(e) = flatpak(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference], false) { + if let Err(e) = flatpak(&["install", "--reinstall", "-y", "--noninteractive", &remote, &reference], false) { // The bundle origin has no repository to restore from, so point at the bundle itself. if flatpak(&["info", id], false).is_err() { return Err(format!("{e}. {} is no longer installed; reinstall it from the .flatpak bundle at {}", app.name, app.releases_url())); } return Err(e); } - let origin = run(&["info", "--show-origin", id])?; + // Past the reinstall, a cancel would misreport a finished migration. + let origin = flatpak(&["info", "--show-origin", id], false)?; if origin.trim() != remote { return Err("Flatpak did not switch the installed app to the ArtCraft OCI remote".into()); } @@ -1317,27 +1322,40 @@ pub(crate) mod linux { app: &CraftApp, id: &str, installation: Option<&str>, + origin: Option<&str>, stop: &Stop, phase: &mut dyn FnMut(JobPhase), ) -> Result<(), String> { phase(JobPhase::Installing); stop.check()?; - let scope_arg = match installation { - Some("user") => "--user".to_string(), - Some("system") | None => "--system".to_string(), - Some(named) => format!("--installation={named}"), - }; + let scope_arg = crate::packages::flatpak_scope(installation); // Let Flatpak's system helper authorize system updates through polkit. // watch-bus also terminates the host command if the sandbox proxy exits. - let mut cmd = if process::in_sandbox() { - let mut command = process::command("flatpak-spawn"); - command.args(["--host", "--watch-bus", "flatpak"]); - command - } else { - process::command("flatpak") + let flatpak = |args: &[&str]| { + let mut cmd = if process::in_sandbox() { + let mut command = process::command("flatpak-spawn"); + command.args(["--host", "--watch-bus", "flatpak"]); + command + } else { + process::command("flatpak") + }; + cmd.args(args); + process::cancellable_output(cmd, || stop.check()) }; - cmd.args(["update", &scope_arg, "-y", "--noninteractive", id]); - let output = process::cancellable_output(cmd, || stop.check())?; + let update = ["update", &scope_arg, "-y", "--noninteractive", id]; + let mut output = flatpak(&update)?; + // A signed bundle's origin can't carry the ArtCraft OCI signature lookaside, so its first + // update from the repository finds no signature: add the lookaside once and retry. + if !output.status.success() + && let Some(origin) = origin + && let Some(remotes) = process::capture_flatpak(&["remotes", &scope_arg, "--columns=name,url"], std::time::Duration::from_secs(10)) + && crate::packages::needs_signature_lookaside(&String::from_utf8_lossy(&output.stderr), origin, &remotes) + { + let lookaside = format!("--signature-lookaside={}", crate::packages::ARTCRAFT_SIGNATURE_LOOKASIDE); + if flatpak(&["remote-modify", &scope_arg, &lookaside, origin])?.status.success() { + output = flatpak(&update)?; + } + } if output.status.success() { return Ok(()); } @@ -3507,7 +3525,7 @@ mod tests { let stop = Stop::default(); stop.cancel(); let mut phase = |_| (); - assert_eq!(linux::update_flatpak_app(app, "ai.storyteller.photocraft", Some("user"), &stop, &mut phase), Err(CANCELLED.into())); + assert_eq!(linux::update_flatpak_app(app, "ai.storyteller.photocraft", Some("user"), None, &stop, &mut phase), Err(CANCELLED.into())); } #[test] diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 913bb67c..a6e249f6 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -63,7 +63,12 @@ pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option } let version = f.next().unwrap_or("").trim().to_string(); let origin = f.next().map(str::trim).filter(|o| !o.is_empty()).map(ToString::to_string); - let installation = f.next().map(str::trim).filter(|i| !i.is_empty()).map(ToString::to_string); + // A named installation lists as `system ()`, but `--installation=` takes the bare name. + let installation = f + .next() + .map(str::trim) + .filter(|i| !i.is_empty()) + .map(|i| i.strip_prefix("system (").and_then(|n| n.strip_suffix(')')).unwrap_or(i).to_string()); Some((id.to_string(), version, origin, installation)) }) .collect() @@ -77,8 +82,16 @@ pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { /// Whether an origin string indicates an install from FlatPark. pub fn is_flatpark_origin(origin: &str) -> bool { - let o = origin.trim(); - o.eq_ignore_ascii_case("flatpark") || o.to_ascii_lowercase().contains("flatpark") + origin.trim().eq_ignore_ascii_case("flatpark") +} + +/// The `flatpak` argument that selects an installation (`user`, `system`, or a named one). +pub fn flatpak_scope(installation: Option<&str>) -> String { + match installation { + Some("user") => "--user".into(), + Some("system") | None => "--system".into(), + Some(name) => format!("--installation={name}"), + } } /// The `Version:` line of `flatpak info `. @@ -224,7 +237,7 @@ mod tests { #[test] fn parses_flatpak_list_with_origin_and_spots_flatpark() { - let text = "ai.storyteller.photocraft\t0.19.0\tflatpark\tuser\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\tsystem\nai.storyteller.wordcraft\t0.14.0\t\t\n"; + let text = "ai.storyteller.photocraft\t0.19.0\tflatpark\tuser\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\tsystem\nai.storyteller.wordcraft\t0.14.0\t\t\nai.storyteller.filmcraft\t0.3.0\tflatpark\tsystem (extra)\n"; let list = parse_flatpak_list_with_origin(text); assert_eq!( list, @@ -232,11 +245,15 @@ mod tests { ("ai.storyteller.photocraft".into(), "0.19.0".into(), Some("flatpark".into()), Some("user".into())), ("ai.storyteller.cadcraft".into(), "0.16.0".into(), Some("cadcraft-origin".into()), Some("system".into())), ("ai.storyteller.wordcraft".into(), "0.14.0".into(), None, None), + ("ai.storyteller.filmcraft".into(), "0.3.0".into(), Some("flatpark".into()), Some("extra".into())), ] ); + assert_eq!(flatpak_scope(Some("extra")), "--installation=extra"); + assert_eq!(flatpak_scope(Some("user")), "--user"); + assert_eq!(flatpak_scope(None), "--system"); assert!(is_flatpark_origin("flatpark")); assert!(is_flatpark_origin("FlatPark")); - assert!(is_flatpark_origin("flatpark-origin")); + assert!(!is_flatpark_origin("flatpark-origin")); assert!(!is_flatpark_origin("cadcraft-origin")); assert!(!is_flatpark_origin("flathub")); assert!(!is_flatpark_origin("")); @@ -253,6 +270,16 @@ pub fn is_artcraft_remote(origin: &str, remotes: &str) -> bool { }) } +/// Where Flatpak finds the ArtCraft OCI images' signatures (artcraft-oci.flatpakrepo's +/// `SignatureLookaside`), redirected to each release's `.oci.sig` asset. +pub const ARTCRAFT_SIGNATURE_LOOKASIDE: &str = "https://getartcraft.com/signatures"; + +/// Did an update fail only because `origin`, an ArtCraft OCI remote, has no signature lookaside +/// (a signed bundle's origin, which `flatpak build-bundle` can't give one)? +pub fn needs_signature_lookaside(stderr: &str, origin: &str, remotes: &str) -> bool { + stderr.contains(&format!("{origin}: no valid signatures for ")) && is_artcraft_remote(origin, remotes) +} + #[cfg(test)] mod remote_url_tests { use super::*; @@ -272,23 +299,29 @@ mod remote_url_tests { } assert!(!is_artcraft_remote("other", "custom\toci+https://getartcraft.com")); } + + #[test] + fn only_an_artcraft_origin_missing_its_signatures_gets_the_lookaside() { + // Flatpak 1.18's error for a verified OCI remote with no lookaside. + let stderr = "error: Failed to update ai.storyteller.photocraft: photocraft-origin: no valid signatures for \ + ghcr.io/storytold/photocraft@sha256:968d75c9d16d5f7d089775f14a3b418d72a4c70d86986811ee2dd818da956b92"; + let remotes = "photocraft-origin\toci+https://getartcraft.com\nflathub\thttps://dl.flathub.org/repo/\n"; + assert!(needs_signature_lookaside(stderr, "photocraft-origin", remotes)); + // Another remote, another origin's failure, or another error: leave the remote alone. + assert!(!needs_signature_lookaside(stderr, "photocraft-origin", "photocraft-origin\thttps://example.org\n")); + assert!(!needs_signature_lookaside(stderr, "flathub", remotes)); + assert!(!needs_signature_lookaside("error: Server returned status 404", "photocraft-origin", remotes)); + } } -/// A missing ref permits bundle fallback; an unknown or different version must not replace it. +/// Whether the remote lists `reference` at exactly `expected`; a missing ref, or one with no or +/// another version (the AppStream catalog may list none), keeps the bundle. #[cfg(target_os = "linux")] -pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> Result { - for line in listing.lines() { +pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> bool { + listing.lines().any(|line| { let mut fields = line.split('\t'); - if fields.next().map(str::trim) != Some(reference) { - continue; - } - let candidate = fields.next().and_then(|value| version::parse(value.trim())); - if candidate.as_ref() != Some(expected) { - return Err("The ArtCraft OCI repository does not yet provide the selected version".into()); - } - return Ok(true); - } - Ok(false) + fields.next().map(str::trim) == Some(reference) && fields.next().and_then(|v| version::parse(v.trim())).as_ref() == Some(expected) + }) } #[cfg(all(test, target_os = "linux"))] @@ -298,10 +331,12 @@ mod migration_tests { fn migration_requires_the_exact_ref_and_selected_version() { let reference = "app/ai.storyteller.photocraft/x86_64/stable"; let expected = version::parse("1.2.3").unwrap(); - assert_eq!(oci_ref_available(reference, &expected, &format!("{reference}\t1.2.3")), Ok(true)); - assert_eq!(oci_ref_available(reference, &expected, "app/ai.storyteller.photocraft/aarch64/stable\t1.2.3"), Ok(false)); + assert!(oci_ref_available(reference, &expected, &format!("{reference}\t1.2.3"))); + assert!(!oci_ref_available(reference, &expected, "app/ai.storyteller.photocraft/aarch64/stable\t1.2.3")); + // The live remote prints no version column at all. + assert!(!oci_ref_available(reference, &expected, reference)); for value in ["", "1.2.2", "1.2.4", "unknown"] { - assert!(oci_ref_available(reference, &expected, &format!("{reference}\t{value}")).is_err()); + assert!(!oci_ref_available(reference, &expected, &format!("{reference}\t{value}"))); } } } diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs index 94026123..336a151e 100644 --- a/crates/ui-egui/src/settings_dialog.rs +++ b/crates/ui-egui/src/settings_dialog.rs @@ -202,7 +202,7 @@ impl LauncherUi { }); } - /// Where apps go and in what format, for the desktop builds that manage installs. + /// Opt-in Linux behaviour that is still experimental. fn settings_advanced(&mut self, ui: &mut Ui) { if !self.caps.manage_apps || !self.platform.is_some_and(|p| p.os == Os::Linux) { return; @@ -214,6 +214,7 @@ impl LauncherUi { }); } + /// Where apps go and in what format, for the desktop builds that manage installs. fn settings_install(&mut self, ui: &mut Ui, host: &mut dyn Host) { if !self.caps.manage_apps { return; From e2589a6c6726cc3459adb63f85688a95eabf641f Mon Sep 17 00:00:00 2001 From: SHXJ <21096283+tommy1616@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:58:57 +0800 Subject: [PATCH 25/33] i18n: add the Traditional Chinese (Taiwan) interface Add a zh-TW catalog (368 entries), register the language, map zh-TW/zh-HK/zh-MO/zh-Hant locales to it, and prefer an installed Noto Sans CJK TC before the system's PingFang TC, Microsoft JhengHei or MingLiU faces. Add locale, catalog, font and UI tests plus bilingual documentation. --- README.md | 16 +- crates/ui-egui/src/cjk_font.rs | 79 +++- crates/ui-egui/src/i18n/lang.rs | 50 ++- crates/ui-egui/src/i18n/tests.rs | 100 ++++-- crates/ui-egui/src/i18n/zh-TW.tsv | 376 ++++++++++++++++++++ crates/ui-egui/tests/traditional_chinese.rs | 205 +++++++++++ docs/localization-zh-hant.md | 89 +++++ 7 files changed, 860 insertions(+), 55 deletions(-) create mode 100644 crates/ui-egui/src/i18n/zh-TW.tsv create mode 100644 crates/ui-egui/tests/traditional_chinese.rs create mode 100644 docs/localization-zh-hant.md diff --git a/README.md b/README.md index abf0a4c8..7ec228cf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ The web version shows the same catalog in the browser, with the latest download The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French (Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian -(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the +(Русский), Simplified Chinese (简体中文), Traditional Chinese (繁體中文) or Spanish (Español). +**Auto**, the default, follows the system or browser language; **Settings → Language** picks one and switches at once, and the choice is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`, `de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones @@ -101,14 +102,19 @@ is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, Ge Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…) Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select -Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the +Simplified Chinese; Traditional Chinese ones (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant`, and POSIX forms +such as `zh_TW.UTF-8`) select Traditional Chinese, with an explicit script winning over the region. +To try a language without changing the setting, start the desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address. The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS, Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and -FreeBSD). Where the system has no font for one, and in the web version, the interface stays in -English and Settings says why. Product names and release notes stay as published; text without a -translation falls back to English. +FreeBSD); Traditional Chinese prefers an installed Noto Sans CJK TC, then PingFang TC, Microsoft +JhengHei or MingLiU. Where the system has no font for one, and in the web version, the interface +stays in English and Settings says why. Product names and release notes stay as published; text +without a translation falls back to English. See +[`docs/localization-zh-hant.md`](docs/localization-zh-hant.md) for the Traditional Chinese catalog +and font details. ## Where apps are installed diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs index 2969a8a9..6407b63b 100644 --- a/crates/ui-egui/src/cjk_font.rs +++ b/crates/ui-egui/src/cjk_font.rs @@ -39,6 +39,7 @@ fn face_metrics(data: &[u8], index: u32) -> (f32, f32) { fn script_name(script: Script) -> &'static str { match script { Script::Chinese => "Chinese", + Script::TraditionalChinese => "Traditional Chinese", Script::Japanese => "Japanese", } } @@ -47,6 +48,7 @@ fn script_name(script: Script) -> &'static str { fn script_families(script: Script) -> &'static str { match script { Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC, WenQuanYi and MiSans", + Script::TraditionalChinese => "Noto Sans CJK TC, Noto Sans TC, PingFang TC, Heiti TC, Microsoft JhengHei and MingLiU", Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic", } } @@ -122,6 +124,7 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option { fn candidates(script: Script) -> Vec { let found = match script { Script::Chinese => chinese_candidates(), + Script::TraditionalChinese => traditional_chinese_candidates(), Script::Japanese => japanese_candidates(), }; let mut out = Vec::with_capacity(found.len() * 2); @@ -249,6 +252,67 @@ fn chinese_candidates() -> Vec { out } +/// The Noto files that carry a Traditional Chinese face, by family: the merged CJK collection, the +/// CJK TC-only OTF, and the standalone Noto Sans TC. +const NOTO_TC_FILES: [(&str, &str); 5] = [ + ("NotoSansCJKtc-Regular.otf", "Noto Sans CJK TC"), + ("NotoSansCJK-Regular.ttc", "Noto Sans CJK TC"), + ("NotoSansCJK-VF.ttc", "Noto Sans CJK TC"), + ("NotoSansTC-Regular.otf", "Noto Sans TC"), + ("NotoSansTC-VF.ttf", "Noto Sans TC"), +]; + +/// Where each system keeps a Traditional Chinese font: an installed Noto CJK TC first (the face +/// GridCraft prefers for Taiwan), then the system's own faces. +fn traditional_chinese_candidates() -> Vec { + let mut out = Vec::new(); + let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family }); + // A Noto installed by hand or by the system's font manager. The `NotoSansCJKtc` subdirectory + // lets a user keep an updated file apart from an older installed face. + for dir in user_font_dirs() { + for (file, family) in NOTO_TC_FILES { + add(dir.join(file), family); + add(dir.join("NotoSansCJKtc").join(file), family); + } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports. + const NOTO_DIRS: [&str; 9] = [ + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/google-noto-sans-cjk-fonts", + "/usr/share/fonts/google-noto-sans-cjk-vf-fonts", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/local/share/fonts/noto", + "/usr/local/share/fonts/noto-cjk", + ]; + for dir in NOTO_DIRS { + for (file, family) in NOTO_TC_FILES { + add(Path::new(dir).join(file), family); + } + } + } + #[cfg(target_os = "macos")] + { + // PingFang moved from the system fonts into a downloaded asset in later macOS versions. + add("/System/Library/Fonts/PingFang.ttc".into(), "PingFang TC"); + for path in mac_font_assets("PingFang.ttc") { + add(path, "PingFang TC"); + } + add("/System/Library/Fonts/STHeiti Medium.ttc".into(), "Heiti TC"); + } + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + add(root.join("Fonts").join("msjh.ttc"), "Microsoft JhengHei"); + add(root.join("Fonts").join("mingliu.ttc"), "MingLiU"); + } + out +} + /// Where each system keeps a Japanese font, best first. fn japanese_candidates() -> Vec { let mut out = Vec::new(); @@ -413,6 +477,17 @@ mod tests { assert!(first_misans > 0, "the system's own fonts come first"); } + /// Traditional Chinese prefers an installed Noto CJK TC over the system's own face, matching + /// GridCraft's Taiwan font order. + #[test] + fn traditional_chinese_prefers_noto_cjk_tc() { + let found = traditional_chinese_candidates(); + let first = found.first().expect("at least one Traditional Chinese candidate"); + assert!(first.family.starts_with("Noto Sans"), "first is {:?}", first.family); + let first_system = found.iter().position(|c| !c.family.starts_with("Noto Sans")).expect("a system face comes after Noto"); + assert!(first_system > 0); + } + /// Inside Flatpak the host's fonts are under `/run/host` (#620), each right after its own path. #[cfg(target_os = "linux")] #[test] @@ -426,7 +501,9 @@ mod tests { ] { assert_eq!(flatpak_host_path(Path::new(path)), host.map(PathBuf::from), "{path}"); } - for (script, family) in [(Script::Chinese, "Noto Sans CJK SC"), (Script::Japanese, "Noto Sans CJK JP")] { + for (script, family) in + [(Script::Chinese, "Noto Sans CJK SC"), (Script::TraditionalChinese, "Noto Sans CJK TC"), (Script::Japanese, "Noto Sans CJK JP")] + { let found = candidates(script); let at = found.iter().position(|c| c.path == Path::new("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc")).unwrap(); assert_eq!(found[at + 1].path, Path::new("/run/host/fonts/noto-cjk/NotoSansCJK-Regular.ttc")); diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs index b6328759..08a320f5 100644 --- a/crates/ui-egui/src/i18n/lang.rs +++ b/crates/ui-egui/src/i18n/lang.rs @@ -12,17 +12,18 @@ use super::detect::system_lang; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Script { Chinese, + TraditionalChinese, Japanese, } impl Script { /// Every script a language can be written in. - pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese]; + pub const ALL: [Script; 3] = [Script::Chinese, Script::TraditionalChinese, Script::Japanese]; } /// One supported UI language. pub struct LangInfo { - /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`). + /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `zh-TW`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`). /// Also the `interfaceLanguage` value. pub code: &'static str, /// The language's name in itself, shown in the Preferences dropdown. @@ -94,7 +95,7 @@ pub(super) fn plural_polish(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 11] = [ +pub static LANGUAGES: [LangInfo; 12] = [ LangInfo { code: "en", name: "English", @@ -128,6 +129,17 @@ pub static LANGUAGES: [LangInfo; 11] = [ complete_menus: true, catalog: OnceLock::new(), }, + LangInfo { + code: "zh-TW", + name: "繁體中文", + english_name: "Traditional Chinese (Taiwan)", + script: Some(Script::TraditionalChinese), + source: include_str!("zh-TW.tsv"), + plural: plural_none, + plural_forms: 1, + complete_menus: true, + catalog: OnceLock::new(), + }, LangInfo { code: "de", name: "Deutsch", @@ -246,14 +258,15 @@ impl Lang { pub const EN: Lang = Lang(&LANGUAGES[0]); pub const RU: Lang = Lang(&LANGUAGES[1]); pub const ZH_CN: Lang = Lang(&LANGUAGES[2]); - pub const DE: Lang = Lang(&LANGUAGES[3]); - pub const FR: Lang = Lang(&LANGUAGES[4]); - pub const PT_BR: Lang = Lang(&LANGUAGES[5]); - pub const ES: Lang = Lang(&LANGUAGES[6]); - pub const CS: Lang = Lang(&LANGUAGES[7]); - pub const PL: Lang = Lang(&LANGUAGES[8]); - pub const HU: Lang = Lang(&LANGUAGES[9]); - pub const JA: Lang = Lang(&LANGUAGES[10]); + pub const ZH_TW: Lang = Lang(&LANGUAGES[3]); + pub const DE: Lang = Lang(&LANGUAGES[4]); + pub const FR: Lang = Lang(&LANGUAGES[5]); + pub const PT_BR: Lang = Lang(&LANGUAGES[6]); + pub const ES: Lang = Lang(&LANGUAGES[7]); + pub const CS: Lang = Lang(&LANGUAGES[8]); + pub const PL: Lang = Lang(&LANGUAGES[9]); + pub const HU: Lang = Lang(&LANGUAGES[10]); + pub const JA: Lang = Lang(&LANGUAGES[11]); pub fn code(self) -> &'static str { self.0.code @@ -353,14 +366,19 @@ pub fn lang_from_tag(tag: &str) -> Option { } } -/// Chinese by script: Simplified for `zh-Hans`, and for a bare `zh` or the regions that write -/// Simplified (mainland China, Singapore) when no script is given. An explicit script wins over -/// the region (`zh-Hant-CN` is Traditional); Traditional Chinese isn't offered. +/// Chinese by script: Traditional for an explicit `Hant`, and for Taiwan, Hong Kong and Macao when +/// no script is given; Simplified for an explicit `Hans`, a bare `zh`, and the regions that write +/// Simplified (mainland China, Singapore). An explicit script wins over the region +/// (`zh-Hant-CN` is Traditional). fn chinese(tag: &str) -> Option { let parts: Vec<&str> = tag.split('-').collect(); if parts.contains(&"hant") { - None - } else if parts.contains(&"hans") || parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) { + Some(Lang::ZH_TW) + } else if parts.contains(&"hans") { + Some(Lang::ZH_CN) + } else if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { + Some(Lang::ZH_TW) + } else if parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) { Some(Lang::ZH_CN) } else { None diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6f2793f3..c10e9415 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -372,15 +372,19 @@ fn polish_locales_resolve() { } #[test] -fn simplified_chinese_locales_respect_the_script() { +fn chinese_locales_respect_the_script() { for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] { assert_eq!(lang_from_tag(tag), Some(Lang::ZH_CN), "{tag}"); } - for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8"] { - assert_eq!(lang_from_tag(tag), None, "{tag}"); + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8", "ZH_hant"] { + assert_eq!(lang_from_tag(tag), Some(Lang::ZH_TW), "{tag}"); } - // A Traditional Chinese system that also lists English gets English, not Simplified. - assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::EN)); + // An explicit script wins over the region. + assert_eq!(lang_from_tag("zh-Hans-TW"), Some(Lang::ZH_CN)); + // A Traditional Chinese system that also lists English gets Traditional Chinese. + assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::ZH_TW)); + // A region with no explicit script and no Traditional Chinese convention stays unsupported. + assert_eq!(lang_from_tag("zh-MY"), None); } #[test] @@ -394,6 +398,29 @@ fn chinese_counts_and_english_fallback_work() { assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0"); } +#[test] +fn traditional_chinese_locales_and_counts_work() { + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8", "ZH_hant"] { + assert_eq!(lang_from_tag(tag), Some(Lang::ZH_TW), "{tag}"); + } + assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::ZH_TW)); + // Traditional Chinese has one plural form. + for n in [0, 1, 2, 11, 100] { + assert_eq!(trn(Lang::ZH_TW, n, "{n} app", "{n} apps"), format!("{n} 個應用程式")); + assert_eq!(trn(Lang::ZH_TW, n, "{n} min ago", "{n} min ago"), format!("{n} 分鐘前")); + assert_eq!(trn(Lang::ZH_TW, n, "{n} day ago", "{n} days ago"), format!("{n} 天前")); + } + assert_eq!(trn(Lang::ZH_TW, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items"); + assert_eq!(tr(Lang::ZH_TW, "Untranslated diagnostic"), "Untranslated diagnostic"); + assert_eq!(tr(Lang::ZH_TW, "{month} {day}, {year}"), "{year}年{month}{day}日"); + assert_eq!(super::tr_ctx(Lang::ZH_TW, "month", "Mar"), "3月"); + assert_eq!(tr(Lang::ZH_TW, "Settings"), "設定"); + assert_eq!(tr(Lang::ZH_TW, "Uninstall…"), "解除安裝…"); + // The product's "Launcher" tag stays in English (AGENTS.md). + assert_eq!(tr(Lang::ZH_TW, "Launcher"), "Launcher"); + assert_eq!(message(Lang::ZH_TW, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0"); +} + #[test] fn japanese_locales_and_counts_work() { for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] { @@ -412,37 +439,42 @@ fn japanese_locales_and_counts_work() { assert_eq!(message(Lang::JA, "PhotoCraft updated to 0.5.0"), "PhotoCraft を 0.5.0 に更新しました"); } -/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog. -/// Machines without one (CI, the web) skip this; the UI then stays in English. +/// With the system's Chinese font installed, every UI family can draw the whole Simplified and +/// Traditional Chinese catalogs. Machines without one (CI, the web) skip this; the UI then stays +/// in English. #[test] fn system_font_covers_chinese_in_every_ui_family() { use egui::{FontFamily, FontId}; - if !crate::theme::cjk_font_available(Lang::ZH_CN) { - return; - } - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN)); - let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); - output.textures_delta.clear(); - let text: String = Lang::ZH_CN - .0 - .source - .lines() - .filter_map(|line| line.split('\t').nth(2)) - .flat_map(str::chars) - .filter(|c| ('\u{3400}'..='\u{9fff}').contains(c)) - .collect(); - assert!(text.chars().count() > 500); - for family in [ - FontFamily::Proportional, - FontFamily::Monospace, - crate::theme::medium(), - crate::theme::semibold(), - crate::theme::display(), - crate::theme::mono(), - crate::theme::mono_bold(), - ] { - ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}")); + for lang in [Lang::ZH_CN, Lang::ZH_TW] { + if !crate::theme::cjk_font_available(lang) { + continue; + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, Some(lang)); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + let text: String = lang + .0 + .source + .lines() + .filter_map(|line| line.split('\t').nth(2)) + .flat_map(str::chars) + .filter(|c| ('\u{3400}'..='\u{9fff}').contains(c)) + .collect(); + assert!(text.chars().count() > 500, "{}", lang.code()); + for family in [ + FontFamily::Proportional, + FontFamily::Monospace, + crate::theme::medium(), + crate::theme::semibold(), + crate::theme::display(), + crate::theme::mono(), + crate::theme::mono_bold(), + ] { + ctx.fonts_mut(|fonts| { + assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "{}: missing glyph in {family:?}", lang.code()); + }); + } } } @@ -490,6 +522,8 @@ fn language_labels_come_from_the_registry() { // in the font stack: no other face draws it. assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese"); assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)"); + assert_eq!(Settings::language_label(Lang::ZH_TW.code(), None), "Traditional Chinese (Taiwan)"); + assert_eq!(Settings::language_label(Lang::ZH_TW.code(), Some(Lang::ZH_TW)), "Traditional Chinese (Taiwan) (繁體中文)"); assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese"); assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)"); assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto"); diff --git a/crates/ui-egui/src/i18n/zh-TW.tsv b/crates/ui-egui/src/i18n/zh-TW.tsv new file mode 100644 index 00000000..3589b297 --- /dev/null +++ b/crates/ui-egui/src/i18n/zh-TW.tsv @@ -0,0 +1,376 @@ +# Traditional Chinese (Taiwan) catalog for ArtCraft Launcher. +# Format: contextEnglish sourcetranslation. +# Original translations from the English UI. Product and app names (ArtCraft Launcher and its +# "Launcher" tag, Craft App Suite, PhotoCraft...) stay unchanged. +# Shown with the system's Traditional Chinese font (Noto Sans CJK TC, PingFang TC, Microsoft +# JhengHei...: see cjk_font.rs). Chinese has one plural form. Placeholders and trailing ellipses +# are preserved. + + Launcher Launcher + Craft App Suite Craft App Suite + by {name} 由 {name} 製作 + Open Source Tools for Creatives, Makers, and Builders 為創作者、自造者與打造者而生的開源工具 + Settings 設定 + Check for updates 檢查更新 + Grid 網格 + Table 表格 + Show apps as cards 以卡片檢視應用程式 + Show apps as a table 以表格檢視應用程式 + All 全部 + Updates 更新 + Not installed 未安裝 + Search apps 搜尋應用程式 + Sort 排序 + Suite order 系列順序 + Name 名稱 + Status 狀態 + Installed version 已安裝版本 + Latest version 最新版本 + Newest release 最新發行版 + Discord Discord + GitHub GitHub + Website 網站 + Classic apps 經典應用程式 + New apps 全新應用程式 + NEW 新 + Install 安裝 + Update to {v} 更新至 {v} + Open 開啟 + Download for {os} 下載 {os} 版 + Download 下載 + Coming soon 即將推出 + Checking… 檢查中… + Not available 無法使用 + Offline 離線 + Downloading {done} of {total} 正在下載 {done}/{total} + Downloading… 下載中… + Reconnecting… 重新連線中… + Verifying… 驗證中… + Installing, then restarting… 安裝中,隨後重新啟動… + Installing… 安裝中… + Removing… 移除中… + Pause download 暫停下載 + Cancel download 取消下載 + Resume download 繼續下載 + Discard download 捨棄下載 + Release notes 更新說明 + Reinstall 重新安裝 + Uninstall… 解除安裝… + All releases 所有版本 + ArtCraft Launcher {version} is available. ArtCraft Launcher {version} 已推出。 + Dismiss 關閉 + Update and restart 更新並重新啟動 + A new version is available. Would you like to relaunch with the updated version? 有新版本可以使用。要使用更新後的版本重新啟動嗎? + A new version is available. Download it to replace this version? 有新版本可以使用。要下載並取代目前版本嗎? + Later 稍後 + What's new 新功能 + No notes were published with this release. 此版本未提供任何更新說明。 + Released {date} 發行於 {date} + Open on GitHub 在 GitHub 上開啟 + Close 關閉 + ArtCraft Launcher {version} ArtCraft Launcher {version} + Only when I ask 僅在我要求時 + Automatically download and install updates 自動下載並安裝更新 + Notify me when an installed app has an update 已安裝的應用程式有更新時通知我 + Include pre-releases (betas and release candidates) 包含預先發行版(Beta 版與候選發行版) + Installing on Linux 在 Linux 上安裝 + Installing on Windows 在 Windows 上安裝 + Install new apps as 安裝新應用程式的方式 + System package (recommended) 系統套件(建議) + Updates always use the format an app was installed with. 更新一律沿用應用程式安裝時的格式。 + Running in the background 在背景執行 + Keep running in the tray when the window is closed 關閉視窗後仍在系統匣中執行 + ArtCraft Launcher is still running ArtCraft Launcher 仍在執行 + It's in the tray: open it or quit it from the tray icon, or turn this off in the settings. 它位於系統匣中:請從系統匣圖示開啟或結束,或在設定中關閉此功能。 + Show the window on the current desktop 在目前的桌面顯示視窗 + Start ArtCraft Launcher when I log in 登入時啟動 ArtCraft Launcher + Community 社群 + Questions, ideas and news from the ArtCraft team 來自 ArtCraft 團隊的提問、想法與最新消息 + Appearance 外觀 + Quit ArtCraft Launcher 結束 ArtCraft Launcher + Done 完成 + Uninstall {name}? 要解除安裝 {name} 嗎? + The app will be moved to the Trash. Your documents and settings stay. 應用程式會移到垃圾桶,你的文件與設定會保留。 + Windows will remove it like any installed program. Your documents and settings stay. Windows 會像移除任何已安裝程式一樣將它移除,你的文件與設定會保留。 + Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. 系統的套件管理員會將它移除,並可能要求你輸入密碼。你的文件與設定會保留。 + The app will be removed. Your documents and settings stay. 應用程式會被移除,你的文件與設定會保留。 + Uninstall 解除安裝 + Cancel 取消 + Checked {ago} 上次檢查:{ago} + just now 剛剛 + yesterday 昨天 + {month} {day}, {year} {year}年{month}{day}日 +month Jan 1月 +month Feb 2月 +month Mar 3月 +month Apr 4月 +month May 5月 +month Jun 6月 +month Jul 7月 +month Aug 8月 +month Sep 9月 +month Oct 10月 +month Nov 11月 +month Dec 12月 + Not checked yet 尚未檢查 + GitHub's hourly limit was reached; full details return in {min} min 已達到 GitHub 的每小時上限;完整資訊將在 {min} 分鐘後恢復 + GitHub limit hit; back in {min} min 已觸及 GitHub 上限;{min} 分鐘後恢復 + Installing for {platform} 正在為 {platform} 安裝 + Downloads for {platform} {platform} 適用的下載項目 + No downloads for this system 此系統沒有可下載的項目 + Join the ArtCraft community on Discord 在 Discord 加入 ArtCraft 社群 + ArtCraft on {name} ArtCraft 在 {name} + Available to install 可安裝 + Available 可用 + Installed · up to date 已安裝 · 已是最新版本 + Update available 有可用更新 + Installed 已安裝 + Not available here 此處無法使用 + Updating… 更新中… + Update paused 更新已暫停 + Download paused 下載已暫停 + {version} installed → {latest} released {version} 已安裝 → {latest} 已發行 + Version {version} 版本 {version} + {installed} installed · latest {latest} 已安裝 {installed} · 最新版本 {latest} + Installed · latest {latest} 已安裝 · 最新版本 {latest} + Version {version} installed 已安裝版本 {version} + Version {latest} 版本 {latest} + No release published yet 尚未發行任何版本 + ArtCraft Launcher ArtCraft Launcher + App 應用程式 + Latest 最新 + Released 發行日期 + Auto 自動 + Language 語言 + Ascending 遞增 + Descending 遞減 + Click to reverse the order 按一下以反轉順序 + Click to sort by this column 按一下以依此欄排序 + {done} of {total} {done}/{total} + Downloading 下載中 + Verifying checksum… 正在驗證檢查碼… + Resume 繼續 + {done} of {total} downloaded 已下載 {done},共 {total} + {done} downloaded 已下載 {done} + Checking for updates… 正在檢查更新… + Downloads the new version, replaces this one and opens it again 下載新版本、取代目前版本並重新開啟 + Couldn't check for updates: {f} 無法檢查更新:{f} + GitHub repository GitHub 儲存庫 + ArtCraft ArtCraft + Update 1 app 更新 1 個應用程式 + Update all ({count}) 全部更新({count}) + ⊞ Grid ⊞ 網格 + ☰ Table ☰ 表格 + 🔍 Search apps 🔍 搜尋應用程式 + {name} {version} {name} {version} + Match system 跟隨系統 + Dark 深色 + Light 淺色 + Full changelog 完整變更記錄 + link 連結 + No apps match 沒有符合的應用程式 + Open ArtCraft Launcher to update. 開啟 ArtCraft Launcher 以進行更新。 + {name} {v} is available {name} {v} 已推出 + {count} updates are available 有 {count} 項更新可用 + These notes weren't fetched (GitHub's request limit was reached). They're on the release page. 無法擷取這些說明(已達到 GitHub 的請求上限)。請至發行頁面查看。 + When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. 開啟時,從選單列開啟啟動器會將其視窗帶到你目前所在的桌面。關閉時,macOS 會將你切換到視窗所在的桌面。 + Couldn't change the login item: {e} 無法變更登入項目:{e} +column App 應用程式 +column Installed 已安裝 +column Latest 最新版本 +column Released 發行日期 +filter Installed 已安裝 + {name} details {name} 詳細資訊 +@plural {n} min ago|{n} min ago {n} 分鐘前 +@plural {n} h ago|{n} h ago {n} 小時前 +@plural {n} day ago|{n} days ago {n} 天前 +@id app.install 安裝 +@id app.update 更新至 {v} +@id app.open 開啟 +@id app.download 下載 {os} 版 +@id app.reveal 在 Finder 中顯示 +@id app.reinstall 重新安裝 +@id app.uninstall 解除安裝 +@id app.notes 更新說明 +@id app.website 網站 +@id app.github GitHub +@id app.releases 所有版本 +@id launcher.update 更新並重新啟動 +@id launcher.download 下載 +@id launcher.notes 更新說明 +@id launcher.dismiss 關閉 +@id settings.open 設定 +@id settings.quit 結束 ArtCraft Launcher +@id view.grid 網格 +@id view.table 表格 +@id filter.all 全部 +@id filter.installed 已安裝 +@id filter.updates 更新 +@id filter.notinstalled 未安裝 +@id sort.suite 系列順序 +@id sort.name 名稱 +@id sort.status 狀態 +@id sort.installed 已安裝版本 +@id sort.latest 最新版本 +@id sort.released 發行日期 +@id help.discord Discord +@id help.github GitHub +@id help.website 網站 +@id update.check 檢查更新 +@id update.check_auto 自動檢查更新 +@id update.notify 已安裝的應用程式有更新時通知我 +@id update.prereleases 包含預先發行版(Beta 版與候選發行版) +@id linux.preferred_kind 新應用程式安裝格式 +@id tray.close_to_tray 關閉視窗後繼續在系統匣執行 +@id tray.show_on_current_desktop 在目前桌面顯示視窗 +@id tray.login_item 登入時啟動 ArtCraft Launcher +@id appearance.theme 外觀 + Update 更新 + New 新 + Classic 經典 + Join the Discord 加入 Discord + Minimize 最小化 + Maximize 最大化 + Restore 還原 + Show in Finder 在 Finder 中顯示 + Show in Explorer 在檔案總管中顯示 + Show ArtCraft Launcher 顯示 ArtCraft Launcher + Check for Updates 檢查更新 + Visit getartcraft.com 前往 getartcraft.com + ArtCraft on GitHub GitHub 上的 ArtCraft + Join the ArtCraft Discord 加入 ArtCraft Discord +@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 個更新可用 +@plural {n} app|{n} apps {n} 個應用程式 +photocraft Image editing 影像編輯 +vectorcraft Vector illustration 向量插畫 +filmcraft Video editing, color and sound 影片剪輯、調色與音效 +lightcraft Photo library and raw development 相片圖庫與 RAW 顯像 +pdfcraft Reading, organizing and protecting PDFs 閱讀、整理與保護 PDF +effectcraft Motion graphics and visual effects 動態圖像與視覺特效 +designcraft Page layout and publishing 版面配置與出版 +wordcraft Writing and document design 寫作與文件設計 +deckcraft Presentations and slide shows 簡報與幻燈片放映 +gridcraft Spreadsheets and data 試算表與資料 +craft-anything Find out how any software works, then craft your own 了解任何軟體如何運作,然後打造屬於你自己的版本 +solidcraft Parametric 3D CAD 參數式 3D CAD +cadcraft Computer-aided design and drafting 電腦輔助設計與製圖 +soundcraft Recording, editing and mixing audio 錄音、剪輯與混音 +figcraft Interface design and prototyping 介面設計與原型製作 + Show in Files 在檔案管理員中顯示 + Loading ArtCraft Launcher… 正在載入 ArtCraft Launcher… + Download cancelled 已取消下載 + Automatically 自動 + Checked {ago} · using {sources} 上次檢查:{ago} · 使用 {sources} + {a} and {b} {a} 與 {b} + Learn more 瞭解更多 + OK 確定 + Open link 開啟連結 + {name} icon {name} 圖示 + installed 已安裝 + Every Craft app 所有 Craft 應用程式 + Apps on this computer 這台電腦上的應用程式 + Installed apps with a newer version 有較新版本的已安裝應用程式 + Apps you can install 可安裝的應用程式 + Hide App 隱藏應用程式 + Unhide App 取消隱藏應用程式 + Hidden Apps 已隱藏的應用程式 + Apps you hid 你隱藏的應用程式 + Cancel update 取消更新 + Stop waiting; the update can be started again later 停止等待;稍後可以再次開始更新 + Download again 重新下載 + Install cancelled 已取消安裝 + Install cancelled. The download is kept for the next try. 已取消安裝。下載的檔案會保留供下次嘗試使用。 + Update cancelled 已取消更新 + Uninstall cancelled 已取消解除安裝 + Retry install 重試安裝 + Retry update to {version} 重試更新至 {version} + The download is still here. It's checked again, then installed. 下載的檔案仍在。會再次檢查,然後安裝。 + Waiting for another app… 正在等待另一個應用程式… + Waiting for other installs… 正在等待其他安裝… + Installer (.msi) 安裝程式(.msi) + Portable (.zip) 免安裝版(.zip) + Disk image (.dmg) 磁碟影像(.dmg) + Debian package (.deb) Debian 套件(.deb) + RPM package (.rpm) RPM 套件(.rpm) + Arch package (.pkg.tar.zst) Arch 套件(.pkg.tar.zst) + Flatpak bundle Flatpak 組合包 + Tarball (.tar.gz) Tarball(.tar.gz) + App details 應用程式詳細資訊 + More actions for {name} {name} 的其他動作 + Close (Esc) 關閉(Esc) + Features 功能 + Latest release 最新發行版 + Version 版本 + Platforms 平台 + Releases 發行版本 + Enlarge picture 放大圖片 + Click to enlarge 按一下以放大 + Previous picture (←) 上一張圖片(←) + Next picture (→) 下一張圖片(→) + Loading pictures… 正在載入圖片… + Pictures couldn't be loaded. Reopen this page to try again. 無法載入圖片。請重新開啟此頁面再試一次。 +@msg Couldn't open {name}: {e} 無法開啟 {name}:{e} +@msg Download cancelled 已取消下載 +@msg {name} updated to {version} {name} 已更新至 {version} +@msg {name} installed: {version} {name} 已安裝:{version} +@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} 已更新至 {version}(此版本未公布檢查碼,因此僅核對檔案大小) +@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} 已安裝:{version}(此版本未公布檢查碼,因此僅核對檔案大小) +@msg {name} was removed {name} 已解除安裝 +@msg {name} is running. Quit it, then try again. {name} 正在執行。請結束後再試一次。 +@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with {cmd} or sudo pacman -Syu {name} 是從 {repo} 儲存庫或 AUR 安裝的({pkg})。請使用 {cmd} 或 sudo pacman -Syu 更新。 +@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) or sudo pacman -Syu {name} 是從 {repo} 儲存庫或 AUR 安裝的({pkg})。請使用你的 AUR 助手(例如:yay -S {pkg})或 sudo pacman -Syu 更新。 +@msg {name} was installed from the AUR ({pkg}). Update it with {cmd} {name} 是從 AUR 安裝的({pkg})。請使用 {cmd} 更新。 +@msg {name} was installed from the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) {name} 是從 AUR 安裝的({pkg})。請使用你的 AUR 助手(例如:yay -S {pkg})更新。 + Install folder 安裝資料夾 + Default install folder 預設安裝資料夾 + System default 系統預設 + Browse… 瀏覽… + Use default 使用預設值 + New installs use this folder. Updates stay in their current location. 新的安裝會使用此資料夾。更新會保留在原來的位置。 + Custom folders use AppImage on Linux. 在 Linux 上,自訂資料夾會使用 AppImage。 + On Linux, custom folders use AppImage format. 在 Linux 上,自訂資料夾會使用 AppImage 格式。 + The system manages install locations here. 安裝位置由系統管理。 +@msg couldn't use install folder {path}: {reason} 無法使用安裝資料夾 {path}:{reason} +@msg this release has no AppImage for a custom install folder 此版本沒有適用於自訂安裝資料夾的 AppImage +@msg custom install folders aren't supported on this system 此系統不支援自訂安裝資料夾 +@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} 已存在,且不是由 ArtCraft Launcher 安裝的 +@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. 無法將 {path} 移至垃圾桶({reason})。請改在 Finder 中將其移除。 + Apps are placed inside this folder. 應用程式會放在此資料夾內。 + Replace what's there? 要取代現有內容嗎? + {path} already exists. Installing {name} will overwrite it. {path} 已存在。安裝 {name} 將會覆寫它。 + Replace 取代 + Recently updated 最近更新 + Name (A–Z) 名稱(A–Z) + Name (Z–A) 名稱(Z–A) + Frequently used 常用 + Next app 下一個應用程式 + Previous app 上一個應用程式 + Show {n} more changes 再顯示 {n} 項變更 + Last updated: {date} 上次更新:{date} +column Author 作者 +column Version 版本 + Choose version 選擇版本 + latest {version} 最新 {version} + {version} (latest release) {version}(最新版本) + Launcher version 啟動器版本 + Nightly Nightly + Nightly (none published yet) Nightly(尚未發行) + Install version {version} 安裝版本 {version} + Download selected version 下載所選版本 + Older launcher versions require a manual reinstall. 較舊的啟動器版本需要手動重新安裝。 + Your files may not open in older versions. 你的檔案可能無法在較舊的版本中開啟。 + Enable automatic installs in Settings to follow new nightly builds. 在設定中啟用自動安裝,即可跟隨新的 Nightly 建置。 +texturecraft 3D texture painting 3D 紋理繪製 +texturecraft Paint materials on 3D models and export textures. 在 3D 模型上繪製材質並匯出紋理。 +texturecraft Materials 材質 +texturecraft Mesh maps 網格貼圖 +texturecraft Bake mesh maps and use smart materials. 烘焙網格貼圖並使用智慧材質。 +texturecraft Paint textures on 3D models. 在 3D 模型上繪製紋理。 + Hide community apps 隱藏社群應用程式 +materialcraft Procedural material design 程序化材質設計 +materialcraft Build materials with node graphs. 使用節點圖建構材質。 +materialcraft Create tileable PBR materials and export texture maps. 建立可拼接的 PBR 材質並匯出紋理貼圖。 +materialcraft Node graphs 節點圖 +materialcraft Combine noises, patterns and filters. 組合雜訊、圖樣與濾鏡。 +materialcraft Live previews 即時預覽 +materialcraft Preview materials in 2D and on 3D models. 在 2D 與 3D 模型上預覽材質。 diff --git a/crates/ui-egui/tests/traditional_chinese.rs b/crates/ui-egui/tests/traditional_chinese.rs new file mode 100644 index 00000000..462405bd --- /dev/null +++ b/crates/ui-egui/tests/traditional_chinese.rs @@ -0,0 +1,205 @@ +//! The Traditional Chinese (Taiwan) UI, in its own test binary: the UI language is process-wide, +//! so it can't share one with the English UI tests. Traditional Chinese is drawn with the system's +//! Traditional Chinese font (Noto Sans CJK TC first, then PingFang TC or Microsoft JhengHei); +//! where there is none (CI, minimal Linux installs), the UI stays in English and says why. +//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=` also saves screenshots (run with `-- --test-threads=1`). + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source}; +use artcraft_launcher_core::release::{Asset, Release}; +use artcraft_launcher_core::status::InstallSource; +use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version}; +use artcraft_launcher_ui_egui::settings::ViewMode; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const NOW: i64 = 1_791_500_000; + +struct Fake { + events: Vec, + sent: Rc>>, + manage: bool, + clock: Rc>, +} + +impl Backend for Fake { + fn send(&mut self, command: Command) { + self.sent.borrow_mut().push(command); + } + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + fn capabilities(&self) -> Capabilities { + Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage } + } + fn platform(&self) -> Option { + Some(Platform { os: Os::MacOs, arch: Arch::Arm64 }) + } + fn now(&self) -> i64 { + self.clock.get() + } +} + +struct NoHost; +impl Host for NoHost {} + +fn release(id: &str, v: &str, at: i64) -> Release { + let name = format!("{id}-{v}-macos-universal.dmg"); + Release { + tag: format!("v{v}"), + version: version::parse(v), + name: format!("v{v}"), + published_at: Some(at), + html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"), + notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(), + prerelease: false, + assets: vec![Asset { + name: name.clone(), + size: 150 << 20, + url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"), + sha256: None, + }], + } +} + +/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release. +fn events() -> Vec { + let mut ev = Vec::new(); + let versions = [ + ("photocraft", "0.5.0"), + ("vectorcraft", "0.7.0"), + ("filmcraft", "0.4.0"), + ("lightcraft", "0.4.0"), + ("pdfcraft", "0.4.0"), + ("effectcraft", "0.6.0"), + ("designcraft", "0.4.0"), + ("wordcraft", "0.3.0"), + ("deckcraft", "0.3.0"), + ("gridcraft", "0.3.0"), + ("cadcraft", "0.3.0"), + ]; + for (i, (id, v)) in versions.iter().enumerate() { + ev.push(Event::Latest { + app: (*id).into(), + latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)), + from: Origin::ArtCraftApi, + }); + } + ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi }); + let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")]; + for app in &SUITE { + let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed { + version: version::parse(v), + source: InstallSource::MacApp, + location: format!("/Applications/{}.app", app.name), + package: None, + }); + ev.push(Event::Detected { app: app.id.into(), installed: inst }); + } + ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } }); + ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) }); + ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } }); + ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None }); + ev +} + +fn harness_language( + view: ViewMode, + theme: egui::Theme, + manage: bool, + language: &str, +) -> (Harness<'static, Option>, Rc>>) { + artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN); + let sent = Rc::new(RefCell::new(Vec::new())); + let sent2 = sent.clone(); + let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) }); + let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() }; + let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state( + move |ui, state: &mut Option| { + if state.is_none() + && let Some(b) = backend.take() + { + let mut s = settings.clone(); + s.theme = if theme == egui::Theme::Dark { + artcraft_launcher_ui_egui::theme::ThemeChoice::Dark + } else { + artcraft_launcher_ui_egui::theme::ThemeChoice::Light + }; + *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s)); + } + if let Some(l) = state.as_mut() { + l.logic(&ui.ctx().clone(), &mut NoHost); + l.ui(ui, &mut NoHost); + } + }, + None, + ); + (harness, sent) +} + +fn snapshot(h: &mut Harness<'_, Option>, name: &str) { + let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return }; + let img = h.render().unwrap(); + img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap(); +} + +// One test: the UI language is process-wide. +#[test] +fn traditional_chinese_switches_persists_and_keeps_install_actions() { + use artcraft_launcher_ui_egui::i18n::Lang; + let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en"); + h.run_steps(6); + h.get_by_label("Settings").click(); + h.run_steps(3); + h.get_by_value("English").click(); + h.run_steps(2); + // Opening Settings doesn't load the (large) Traditional Chinese font: the picker names it in + // English. + assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::ZH_TW)); + // The list is long enough to scroll, so the last option may be out of view: click it through + // accesskit, which reaches widgets that aren't visible. + h.get_by_label("Traditional Chinese (Taiwan)").click_accesskit(); + h.run_steps(3); + if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::ZH_TW) { + // No font for Traditional Chinese: choosing it keeps the UI in English, and Settings says + // why. + assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-TW"); + h.get_by_label_contains("No font for this language was found"); + h.get_by_label("Notify me when an installed app has an update"); + snapshot(&mut h, "settings-zh-tw-no-font"); + return; + } + // Choosing it loaded the font: now the picker shows both names. + h.get_by_value("Traditional Chinese (Taiwan) (繁體中文)"); + h.get_by_label("已安裝的應用程式有更新時通知我"); + snapshot(&mut h, "settings-zh-tw-dark"); + let settings = h.state().as_ref().unwrap().settings.clone(); + assert_eq!(settings.language, "zh-TW"); + let saved = serde_json::to_string(&settings).unwrap(); + let restored: Settings = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, "zh-TW"); + h.get_by_label("完成").click(); + h.run_steps(3); + h.get_by_label("影像編輯"); + h.get_by_label_contains("上次檢查:3 分鐘前"); + h.get_by_label("更新至 0.5.0"); + // Product names stay in English. + assert!(h.query_by_label("Launcher").is_none()); + snapshot(&mut h, "grid-zh-tw-dark"); + h.get_by_label("全部更新(2)").click(); + h.run_steps(2); + let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect(); + assert_eq!(installs, ["photocraft", "effectcraft"]); + + let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language); + table.run_steps(6); + table.get_by_label("版本"); + assert!(table.query_all_by_label_contains("年10月").count() >= 1); + snapshot(&mut table, "table-zh-tw-light"); + let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language); + web.run_steps(6); + assert!(web.query_all_by_label("下載 macOS 版").count() >= 3); +} diff --git a/docs/localization-zh-hant.md b/docs/localization-zh-hant.md new file mode 100644 index 00000000..54fa7355 --- /dev/null +++ b/docs/localization-zh-hant.md @@ -0,0 +1,89 @@ +# 繁體中文(台灣)介面 / Traditional Chinese (Taiwan) interface + +更新日期 / Updated: 2026-11-03 + +## 繁體中文 + +在 **設定 → 語言** 選擇「繁體中文」,或把介面語言設為 `zh-TW`。設定會保存,並在下次啟動時 +沿用。首次啟動時,系統語系為 `zh-TW`、`zh-HK`、`zh-MO` 或 `zh-Hant` 會自動選擇繁體中文; +明確指定的 `Hans` 或 `Hant` 文字系統優先於地區,因此 `zh-Hant-CN` 仍使用繁體中文, +`zh-Hans-TW` 仍使用簡體中文。原有的 `zh`、`zh-CN`、`zh-SG` 與 `zh-Hans` 設定維持簡體中文。 + +翻譯檔位於 `crates/ui-egui/src/i18n/zh-TW.tsv`,沿用現有的英文查表機制,涵蓋 368 個介面 +項目,包含按鈕、選單、狀態、錯誤訊息、月份、表格欄位與各應用程式標語。譯文由英文原始字串 +重新撰寫,採用「應用程式、資料夾、設定、解除安裝、下載、更新、系統匣、捷徑、儲存庫、檢查碼」 +等台灣用語,並經台灣用語審核;沒有複製其他語系的翻譯。未翻譯的文字仍顯示英文。產品名稱 +(ArtCraft Launcher、Launcher、Craft App Suite、PhotoCraft 等)與指令 ID 維持原文。 + +想在桌面版臨時試用而不改設定,可以設定環境變數: + +```powershell +$env:ARTCRAFT_LAUNCHER_LOCALE = "zh-TW" +``` + +網頁版可在網址加上 `?lang=zh-TW`。 + +### Noto CJK 繁中字型 + +桌面版繁中介面優先使用已安裝的 **Noto Sans CJK TC**,其次使用支援的繁中系統字型: +macOS 的 PingFang TC/Heiti TC、Windows 的 Microsoft JhengHei/MingLiU,以及 Linux 與 +FreeBSD 的 Noto Sans CJK TC。請從 [Noto CJK 官方專案](https://github.com/notofonts/noto-cjk) +下載 `Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf`,並保留其 SIL Open Font License。 +字型檔不放入 ArtCraft Launcher 原始碼庫,也不會在執行時自動下載。 + +- Windows:將檔案安裝到 `%LOCALAPPDATA%\Microsoft\Windows\Fonts` 或 `%WINDIR%\Fonts`, + 保留檔名 `NotoSansCJKtc-Regular.otf`。也可放在使用者字型目錄下的 `NotoSansCJKtc` + 子目錄,讓更新版本優先於系統中正在使用的舊字型。 +- macOS:放在 `~/Library/Fonts` 或 `/Library/Fonts`。 +- Linux:放在 `~/.local/share/fonts`、`~/.fonts` 或既有的 Noto 字型目錄。 + `NotoSansCJK-Regular.ttc` 會依字型家族名稱選用其中的台灣字形。 + +安裝後重新啟動 ArtCraft Launcher。找不到繁中字型時,介面會維持英文,並在設定中說明原因; +網頁版沒有系統字型可用,也維持英文。這項修改不新增瀏覽器字型下載或打包。 + +## English + +Choose **繁體中文** in **Settings → Language**, or set the interface language to `zh-TW`. The +preference is saved and kept between launches. On first launch, a `zh-TW`, `zh-HK`, `zh-MO` or +`zh-Hant` system locale selects Traditional Chinese. An explicit `Hans` or `Hant` script takes +precedence over the region, so `zh-Hant-CN` still selects Traditional Chinese and `zh-Hans-TW` +still selects Simplified Chinese. Existing `zh`, `zh-CN`, `zh-SG` and `zh-Hans` preferences remain +Simplified Chinese. + +The catalog is `crates/ui-egui/src/i18n/zh-TW.tsv`. It uses the existing English-keyed lookup +mechanism and covers 368 interface entries: buttons, menus, status and error messages, months, +table columns and app taglines. Translations were written from the English source strings with +Taiwan software terminology (應用程式, 資料夾, 設定, 解除安裝, 下載, 更新, 系統匣, 捷徑, 儲存庫, +檢查碼) and reviewed for Taiwan usage; no other catalog's translations were copied. Untranslated +strings fall back to English. Product names (ArtCraft Launcher, Launcher, Craft App Suite, +PhotoCraft and the other apps) and command IDs stay as published. + +To try it on the desktop without changing the setting: + +```powershell +$env:ARTCRAFT_LAUNCHER_LOCALE = "zh-TW" +``` + +For the web build, add `?lang=zh-TW` to the address. + +### Noto CJK fonts + +The desktop Traditional Chinese interface prefers an installed **Noto Sans CJK TC**, then +supported Traditional Chinese system faces: PingFang TC or Heiti TC on macOS, Microsoft JhengHei +or MingLiU on Windows, and Noto Sans CJK TC on Linux and FreeBSD. Download +`Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf` from the +[official Noto CJK repository](https://github.com/notofonts/noto-cjk) and retain its SIL Open Font +License. Font binaries stay outside the ArtCraft Launcher repository and are never downloaded at +runtime. + +- Windows: install the file in `%LOCALAPPDATA%\Microsoft\Windows\Fonts` or `%WINDIR%\Fonts`, + keeping the filename `NotoSansCJKtc-Regular.otf`. The `NotoSansCJKtc` subdirectory of the + per-user font directory is checked first, so an updated file can take precedence over a face + already in use. +- macOS: use `~/Library/Fonts` or `/Library/Fonts`. +- Linux: use `~/.local/share/fonts`, `~/.fonts` or an existing Noto font directory. The merged + `NotoSansCJK-Regular.ttc` is matched by its Traditional Chinese family name. + +Restart ArtCraft Launcher after installing. Without a Traditional Chinese face the interface stays +in English and Settings explains why; the web build has no system font and does so too. This +change adds no browser font download or packaging. From 6c58f3701918975caa4fb1bad4dcaf3417df7e06 Mon Sep 17 00:00:00 2001 From: SHXJ <21096283+tommy1616@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:05:50 +0800 Subject: [PATCH 26/33] i18n: fix Traditional Chinese font candidate order Check the NotoSansCJKtc override folder before the root file, recognize the official variable CJK TC filenames, and keep the font-order test valid on Linux and FreeBSD. --- crates/ui-egui/src/cjk_font.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs index 6407b63b..689c9ac7 100644 --- a/crates/ui-egui/src/cjk_font.rs +++ b/crates/ui-egui/src/cjk_font.rs @@ -253,11 +253,13 @@ fn chinese_candidates() -> Vec { } /// The Noto files that carry a Traditional Chinese face, by family: the merged CJK collection, the -/// CJK TC-only OTF, and the standalone Noto Sans TC. -const NOTO_TC_FILES: [(&str, &str); 5] = [ +/// CJK TC-only OTF, the variable TC fonts, and the standalone Noto Sans TC. +const NOTO_TC_FILES: [(&str, &str); 7] = [ ("NotoSansCJKtc-Regular.otf", "Noto Sans CJK TC"), ("NotoSansCJK-Regular.ttc", "Noto Sans CJK TC"), ("NotoSansCJK-VF.ttc", "Noto Sans CJK TC"), + ("NotoSansCJKtc-VF.otf", "Noto Sans CJK TC"), + ("NotoSansCJKtc-VF.ttf", "Noto Sans CJK TC"), ("NotoSansTC-Regular.otf", "Noto Sans TC"), ("NotoSansTC-VF.ttf", "Noto Sans TC"), ]; @@ -271,8 +273,10 @@ fn traditional_chinese_candidates() -> Vec { // lets a user keep an updated file apart from an older installed face. for dir in user_font_dirs() { for (file, family) in NOTO_TC_FILES { - add(dir.join(file), family); + // The override subdirectory is checked first, so an updated file wins over the older + // face at the root. add(dir.join("NotoSansCJKtc").join(file), family); + add(dir.join(file), family); } } #[cfg(all(unix, not(target_os = "macos")))] @@ -484,8 +488,25 @@ mod tests { let found = traditional_chinese_candidates(); let first = found.first().expect("at least one Traditional Chinese candidate"); assert!(first.family.starts_with("Noto Sans"), "first is {:?}", first.family); - let first_system = found.iter().position(|c| !c.family.starts_with("Noto Sans")).expect("a system face comes after Noto"); - assert!(first_system > 0); + // macOS and Windows add their own face after the Noto candidates; the Unix list is all + // Noto, so there is no system face to order against there. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + let first_system = found.iter().position(|c| !c.family.starts_with("Noto Sans")).expect("a system face comes after Noto"); + assert!(first_system > 0); + } + } + + /// The `NotoSansCJKtc` override folder is read before the older file at the font folder's root. + #[test] + fn the_tc_override_folder_comes_before_the_root_file() { + let found = traditional_chinese_candidates(); + let dir = user_font_dirs().into_iter().next().expect("a user font folder"); + let override_file = dir.join("NotoSansCJKtc").join("NotoSansCJKtc-Regular.otf"); + let root_file = dir.join("NotoSansCJKtc-Regular.otf"); + let override_at = found.iter().position(|c| c.path == override_file).expect("the override path"); + let root_at = found.iter().position(|c| c.path == root_file).expect("the root path"); + assert!(override_at < root_at); } /// Inside Flatpak the host's fonts are under `/run/host` (#620), each right after its own path. From ba0335b52c3a5bd9ccc0db51b8176287509f79c9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 00:01:58 -0500 Subject: [PATCH 27/33] Fix Flatpak bundle ownership and signed updates --- crates/core/src/protocol.rs | 2 - crates/native/src/backend.rs | 11 --- crates/native/src/install.rs | 101 +++++--------------------- crates/native/src/packages.rs | 33 ++------- crates/ui-egui/src/app.rs | 1 - crates/ui-egui/src/settings.rs | 17 ----- crates/ui-egui/src/settings_dialog.rs | 13 ---- crates/ui-egui/tests/ui.rs | 51 ------------- 8 files changed, 22 insertions(+), 207 deletions(-) diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index ccd37be2..c3a64dcd 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -99,8 +99,6 @@ pub struct BackendOptions { pub launcher_version: VersionTrack, /// Offer pre-releases (`0.6.0-rc.1`) as updates. pub prereleases: bool, - /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. - pub oci_migration: bool, } /// The answer to "what's the latest release of this app?". diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 379daf0e..98a08cf8 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -559,17 +559,6 @@ fn run_install( if !release_matches_track(&release, track, options.prereleases) { return Err("the selected version isn't available yet. Check for updates before installing.".into()); } - #[cfg(target_os = "linux")] - if options.oci_migration - && matches!(track, VersionTrack::Stable) - && !release.prerelease - && !replace - && let Some(installed) = existing - && let Some(expected) = release.version.as_ref() - && install::linux::migrate_bundle_to_oci(app, installed, expected, stop)? - { - return Ok(format!("{} now updates through the ArtCraft OCI repository", app.name)); - } let (asset, kind) = planned_asset(app, &release, platform, options, existing, record).ok_or_else(|| { if platform.os == artcraft_launcher_core::Os::Linux && options.install_locations.folder(app.id).is_some() diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 9da0be50..00e07e84 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -217,6 +217,7 @@ pub fn install_at( PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => { linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| { if kind == PackageKind::Flatpak { + linux::configure_bundle_lookaside(app); linux::retire_former_flatpak(app, existing); } None @@ -1227,94 +1228,26 @@ pub(crate) mod linux { } } - /// Opt-in OCI migration. Ok(false) keeps the bundle: anything that fails before the reinstall - /// starts (no matching ref or version, polkit refusal, offline) falls back to the bundle update. - pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { - if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { - return Ok(false); + /// Configure signature verification for a bundle-created ArtCraft OCI origin. + pub fn configure_bundle_lookaside(app: &CraftApp) { + let id = app.bundle_id(); + let Some(origin) = process::capture_flatpak(&["info", "--user", "--show-origin", &id], std::time::Duration::from_secs(10)) else { return }; + let origin = origin.trim(); + if !origin.ends_with("-origin") { + return; } - // Only bundle-created origins qualify; never redirect another repository's install. - if !installed.origin.as_deref().is_some_and(|origin| origin.ends_with("-origin")) { - return Ok(false); - } - let (Some(id), Some(installation)) = (installed.package.as_deref(), installed.installation.as_deref()) else { - return Ok(false); + let Some(remotes) = + process::capture_flatpak(&["remotes", "--user", "--show-disabled", "--columns=name,url"], std::time::Duration::from_secs(10)) + else { + return; }; - let scope = crate::packages::flatpak_scope(Some(installation)); - // `cancellable` is false for steps that must not stop part-way: killing flatpak-spawn - // doesn't wait for the host transaction, and `--reinstall` removes the old ref first. - let flatpak = |args: &[&str], cancellable: bool| -> Result { - let mut cmd = if process::in_sandbox() { - let mut cmd = process::command("flatpak-spawn"); - cmd.args(["--host", "--watch-bus", "flatpak"]); - cmd - } else { - process::command("flatpak") - }; - cmd.arg(&scope).args(args); - let output = process::cancellable_output(cmd, || if cancellable { stop.check() } else { Ok(()) })?; - if !output.status.success() { - return Err(format!("Flatpak OCI migration failed: {}", String::from_utf8_lossy(&output.stderr).trim())); - } - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - }; - let run = |args: &[&str]| flatpak(args, true); - let precheck = || -> Result, String> { - let reference = run(&["info", "--show-ref", id])?.trim().to_string(); - if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { - return Ok(None); - } - let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; - let remote = remotes.lines().find_map(|line| { - let (name, _) = line.split_once('\t')?; - crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) - }); - let remote = match remote { - Some(remote) => remote, - None => { - if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { - return Ok(None); - } - // Another app's migration may add the remote first; the URL is checked below. - run(&["remote-add", "--if-not-exists", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; - let remotes = run(&["remotes", "--show-disabled", "--columns=name,url"])?; - if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { - return Ok(None); - } - "artcraft-oci".to_string() - } - }; - // The Version column comes from the remote's AppStream catalog, which Flatpak only fetches - // on demand; without a refresh a new remote lists refs with empty versions. - run(&["update", "--appstream", &remote])?; - let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; - Ok(crate::packages::oci_ref_available(&reference, expected, &refs).then_some((remote, reference))) - }; - let (remote, reference) = match precheck() { - Ok(Some(found)) => found, - Err(e) if e == CANCELLED => return Err(e), - Ok(None) | Err(_) => return Ok(false), - }; - // `install_at` won't replace a running app either; the reinstall can't be cancelled. - if process::capture_flatpak(&["ps", "--columns=application"], std::time::Duration::from_secs(10)) - .is_some_and(|ps| ps.lines().any(|line| line.trim() == id)) - { - return Err(format!("{} is running. Quit it, then try again.", app.name)); + if !crate::packages::is_artcraft_remote(origin, &remotes) { + return; } - stop.check()?; - if let Err(e) = flatpak(&["install", "--reinstall", "-y", "--noninteractive", &remote, &reference], false) { - // The bundle origin has no repository to restore from, so point at the bundle itself. - if flatpak(&["info", id], false).is_err() { - return Err(format!("{e}. {} is no longer installed; reinstall it from the .flatpak bundle at {}", app.name, app.releases_url())); - } - return Err(e); + let lookaside = format!("--signature-lookaside={}", crate::packages::ARTCRAFT_SIGNATURE_LOOKASIDE); + if process::capture_flatpak(&["remote-modify", "--user", &lookaside, origin], std::time::Duration::from_secs(10)).is_none() { + log::warn!("Could not configure the Flatpak signature lookaside for {origin}"); } - // Past the reinstall, a cancel would misreport a finished migration. - let origin = flatpak(&["info", "--show-origin", id], false)?; - if origin.trim() != remote { - return Err("Flatpak did not switch the installed app to the ArtCraft OCI remote".into()); - } - Ok(true) } /// Updates an installed Flatpak from its remote repository (such as FlatPark). diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index a6e249f6..ace37628 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -314,36 +314,12 @@ mod remote_url_tests { } } -/// Whether the remote lists `reference` at exactly `expected`; a missing ref, or one with no or -/// another version (the AppStream catalog may list none), keeps the bundle. -#[cfg(target_os = "linux")] -pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> bool { - listing.lines().any(|line| { - let mut fields = line.split('\t'); - fields.next().map(str::trim) == Some(reference) && fields.next().and_then(|v| version::parse(v.trim())).as_ref() == Some(expected) - }) -} - -#[cfg(all(test, target_os = "linux"))] -mod migration_tests { - use super::*; - #[test] - fn migration_requires_the_exact_ref_and_selected_version() { - let reference = "app/ai.storyteller.photocraft/x86_64/stable"; - let expected = version::parse("1.2.3").unwrap(); - assert!(oci_ref_available(reference, &expected, &format!("{reference}\t1.2.3"))); - assert!(!oci_ref_available(reference, &expected, "app/ai.storyteller.photocraft/aarch64/stable\t1.2.3")); - // The live remote prints no version column at all. - assert!(!oci_ref_available(reference, &expected, reference)); - for value in ["", "1.2.2", "1.2.4", "unknown"] { - assert!(!oci_ref_available(reference, &expected, &format!("{reference}\t{value}"))); - } - } -} - -/// Only a confirmed bundle origin with no repository URL permits bundle replacement. +/// A bundle origin permits replacement when its URL is empty or points at ArtCraft OCI. /// Missing rows are unknown, so preserve remote ownership conservatively. pub fn remote_manages_flatpak(origin: &str, remotes: &str) -> bool { + if origin.ends_with("-origin") && is_artcraft_remote(origin, remotes) { + return false; + } remotes .lines() .find_map(|line| { @@ -364,6 +340,7 @@ mod remote_ownership_tests { assert!(!remote_manages_flatpak("app-origin", "app-origin\t\n")); assert!(remote_manages_flatpak("renamed", "renamed\thttps://example.org/repo\n")); assert!(remote_manages_flatpak("renamed", "renamed\toci+https://getartcraft.com\n")); + assert!(!remote_manages_flatpak("app-origin", "app-origin\toci+https://getartcraft.com\n")); assert!(remote_manages_flatpak("missing", "other\t\n")); assert!(!remote_manages_flatpak("app-origin", "app-origin")); } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f85c04d5..5c57ef9b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -436,7 +436,6 @@ impl LauncherUi { let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases, - oci_migration: self.settings.oci_migration, versions: self.settings.versions.clone(), launcher_version: self.settings.launcher_version.clone(), install_locations: self.settings.install_locations.clone(), diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index b634dec3..fe520ca1 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -108,8 +108,6 @@ pub struct Settings { /// Show a system notification when an installed app has an update. pub notify: bool, pub prereleases: bool, - /// Opt in to migrating first party Flatpak bundles to the ArtCraft OCI repository. - pub oci_migration: bool, /// Version track per app. Missing entries follow stable releases. pub versions: BTreeMap, pub launcher_version: artcraft_launcher_core::protocol::VersionTrack, @@ -204,7 +202,6 @@ impl Default for Settings { auto_update: false, notify: true, prereleases: false, - oci_migration: false, versions: BTreeMap::new(), launcher_version: Default::default(), preferred_kinds: default_preferred_kinds(), @@ -218,17 +215,3 @@ impl Default for Settings { } } } - -#[cfg(test)] -mod oci_settings_tests { - use super::*; - - #[test] - fn migration_requires_opt_in_and_survives_settings_roundtrip() { - let old: Settings = serde_json::from_str("{}").unwrap(); - assert!(!old.oci_migration); - let enabled = Settings { oci_migration: true, ..old }; - let restored: Settings = serde_json::from_str(&serde_json::to_string(&enabled).unwrap()).unwrap(); - assert!(restored.oci_migration); - } -} diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs index 336a151e..34d7442a 100644 --- a/crates/ui-egui/src/settings_dialog.rs +++ b/crates/ui-egui/src/settings_dialog.rs @@ -123,7 +123,6 @@ impl LauncherUi { this.settings_install(ui, &mut **host); this.settings_background(ui, &mut **host); this.settings_community(ui); - this.settings_advanced(ui); }, ); } @@ -202,18 +201,6 @@ impl LauncherUi { }); } - /// Opt-in Linux behaviour that is still experimental. - fn settings_advanced(&mut self, ui: &mut Ui) { - if !self.caps.manage_apps || !self.platform.is_some_and(|p| p.os == Os::Linux) { - return; - } - sheet::panel(ui, tl!("Advanced"), |ui| { - ui.checkbox(&mut self.settings.oci_migration, tl!("Migrate Flatpak bundles to ArtCraft OCI (experimental)")); - note(ui, tl!("On update, switch first party apps when the matching package is available. Otherwise, keep using bundles.")); - note(ui, tl!("The OCI repository is a preview with GPG verification disabled.")); - }); - } - /// Where apps go and in what format, for the desktop builds that manage installs. fn settings_install(&mut self, ui: &mut Ui, host: &mut dyn Host) { if !self.caps.manage_apps { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index a3fb1f7b..10f32d0f 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -2943,54 +2943,3 @@ fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes h.run_steps(30); assert!(!showing(&h, "photocraft"), "a click outside did not close it"); } - -#[test] -fn oci_migration_setting_is_linux_only_and_configures_the_backend() { - let label = "Migrate Flatpak bundles to ArtCraft OCI (experimental)"; - for (os, manage) in [(Os::Linux, true), (Os::MacOs, true), (Os::Windows, true), (Os::Linux, false)] { - let (mut h, sent) = harness_full( - events(), - ViewMode::Grid, - egui::Theme::Dark, - manage, - 1.0, - artcraft_launcher_ui_egui::TitleBar::Native, - Rc::default(), - Some(Platform { os, arch: Arch::X64 }), - None, - ); - h.run_steps(6); - h.get_by_label("Settings").click(); - h.run_steps(4); - if os != Os::Linux || !manage { - assert!(h.query_by_label(label).is_none()); - continue; - } - assert!(!h.state().as_ref().unwrap().settings.oci_migration); - h.get_by_label(label).click(); - h.run_steps(3); - assert!(h.state().as_ref().unwrap().settings.oci_migration); - h.get_by_label("The OCI repository is a preview with GPG verification disabled.").scroll_to_me(); - h.run_steps(4); - snapshot(&mut h, "oci-migration-enabled"); - h.get_by_label("Done").click(); - h.run_steps(4); - assert!(sent.borrow().iter().any(|command| matches!(command, Command::Configure(options) if options.oci_migration))); - h.get_by_label("Settings").click(); - h.run_steps(4); - h.get_by_label(label).click(); - h.run_steps(3); - h.get_by_label("Done").click(); - h.run_steps(4); - assert!(!h.state().as_ref().unwrap().settings.oci_migration); - let last = sent - .borrow() - .iter() - .filter_map(|command| match command { - Command::Configure(options) => Some(options.oci_migration), - _ => None, - }) - .next_back(); - assert_eq!(last, Some(false)); - } -} From 5ea8d5bcd987c5ccfa3ba1e3162a15a8006eb0fb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 00:03:22 -0500 Subject: [PATCH 28/33] Preserve Flatpak bundle installation scope --- crates/native/src/install.rs | 52 ++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 00e07e84..36b12e44 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -215,13 +215,15 @@ pub fn install_at( } #[cfg(target_os = "linux")] PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => { - linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| { - if kind == PackageKind::Flatpak { - linux::configure_bundle_lookaside(app); - linux::retire_former_flatpak(app, existing); - } - None - }) + linux::install_package(kind, &file, existing.and_then(|installed| installed.installation.as_deref()), job.stop, &mut *job.phase).map( + |_| { + if kind == PackageKind::Flatpak { + linux::configure_bundle_lookaside(app, existing.and_then(|installed| installed.installation.as_deref())); + linux::retire_former_flatpak(app, existing); + } + None + }, + ) } #[cfg(target_os = "linux")] PackageKind::AppImage => { @@ -1148,27 +1150,41 @@ pub(crate) mod linux { } /// Installs (or upgrades) a downloaded package with the system's own tools. - pub fn install_package(kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + pub fn install_package( + kind: PackageKind, + file: &Path, + installation: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { if kind == PackageKind::Deb && system_tool("apt-get").is_none() { return Err("this system has no apt-get to install a Debian package with".into()); } if kind == PackageKind::ArchPkg && system_tool("pacman").is_none() { return Err("this system has no pacman to install an Arch package with".into()); } - install_package_via(&SYSTEM, kind, file, stop, phase) + install_package_via(&SYSTEM, kind, file, installation, stop, phase) } - pub fn install_package_via(root: &Root<'_>, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + pub fn install_package_via( + root: &Root<'_>, + kind: PackageKind, + file: &Path, + installation: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { let f = file.as_os_str(); if let Some(command) = install_command(kind) { return as_root(root, command, f, stop, phase, JobPhase::Installing); } match kind { PackageKind::Flatpak => { + let scope = installation.map_or_else(|| "--user".to_string(), |name| crate::packages::flatpak_scope(Some(name))); let install_one = || { log::info!("executing flatpak install --reinstall --bundle for {}", file.display()); let output = process::flatpak_command() - .args(["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"]) + .args(["install", &scope, "-y", "--noninteractive", "--reinstall", "--bundle"]) .arg(f) .output() .map_err(|e| format!("couldn't start Flatpak ({e})"))?; @@ -1229,15 +1245,16 @@ pub(crate) mod linux { } /// Configure signature verification for a bundle-created ArtCraft OCI origin. - pub fn configure_bundle_lookaside(app: &CraftApp) { + pub fn configure_bundle_lookaside(app: &CraftApp, installation: Option<&str>) { + let scope = installation.map_or_else(|| "--user".to_string(), |name| crate::packages::flatpak_scope(Some(name))); let id = app.bundle_id(); - let Some(origin) = process::capture_flatpak(&["info", "--user", "--show-origin", &id], std::time::Duration::from_secs(10)) else { return }; + let Some(origin) = process::capture_flatpak(&["info", &scope, "--show-origin", &id], std::time::Duration::from_secs(10)) else { return }; let origin = origin.trim(); if !origin.ends_with("-origin") { return; } let Some(remotes) = - process::capture_flatpak(&["remotes", "--user", "--show-disabled", "--columns=name,url"], std::time::Duration::from_secs(10)) + process::capture_flatpak(&["remotes", &scope, "--show-disabled", "--columns=name,url"], std::time::Duration::from_secs(10)) else { return; }; @@ -1245,7 +1262,7 @@ pub(crate) mod linux { return; } let lookaside = format!("--signature-lookaside={}", crate::packages::ARTCRAFT_SIGNATURE_LOOKASIDE); - if process::capture_flatpak(&["remote-modify", "--user", &lookaside, origin], std::time::Duration::from_secs(10)).is_none() { + if process::capture_flatpak(&["remote-modify", &scope, &lookaside, origin], std::time::Duration::from_secs(10)).is_none() { log::warn!("Could not configure the Flatpak signature lookaside for {origin}"); } } @@ -3143,7 +3160,7 @@ mod tests { waiting.send(()).unwrap(); } }; - linux::install_package_via(root, PackageKind::Deb, Path::new(file), &Stop::default(), &mut phase) + linux::install_package_via(root, PackageKind::Deb, Path::new(file), None, &Stop::default(), &mut phase) }) }); waits.recv().unwrap(); @@ -3192,7 +3209,8 @@ mod tests { } let queue = crate::queue::Queue::new(); let root = linux::Root { queue: &queue, run: &|_| Ok(()) }; - let result = linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), &Stop::default(), &mut |_| ()); + let result = + linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), None, &Stop::default(), &mut |_| ()); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.starts_with("Flatpak couldn't install the bundle: "), "unexpected error: {err}"); From 23aab338f64dea14cde46a5f7f0f778279961fa3 Mon Sep 17 00:00:00 2001 From: SHXJ <21096283+tommy1616@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:44:47 +0800 Subject: [PATCH 29/33] i18n: search openSUSE's Traditional Chinese font directory openSUSE's google-noto-sans-tc-fonts installs NotoSansCJKtc-VF.otf directly in /usr/share/fonts/truetype; search it so the Traditional Chinese interface does not fall back to English there. --- crates/ui-egui/src/cjk_font.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs index 689c9ac7..08203e50 100644 --- a/crates/ui-egui/src/cjk_font.rs +++ b/crates/ui-egui/src/cjk_font.rs @@ -281,8 +281,8 @@ fn traditional_chinese_candidates() -> Vec { } #[cfg(all(unix, not(target_os = "macos")))] { - // Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports. - const NOTO_DIRS: [&str; 9] = [ + // Debian/Ubuntu, Arch, Fedora (static and variable), openSUSE and FreeBSD's ports. + const NOTO_DIRS: [&str; 10] = [ "/usr/share/fonts/opentype/noto", "/usr/share/fonts/noto-cjk", "/usr/share/fonts/google-noto-cjk", @@ -290,6 +290,7 @@ fn traditional_chinese_candidates() -> Vec { "/usr/share/fonts/google-noto-sans-cjk-vf-fonts", "/usr/share/fonts/noto", "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/truetype", "/usr/local/share/fonts/noto", "/usr/local/share/fonts/noto-cjk", ]; @@ -497,6 +498,15 @@ mod tests { } } + /// openSUSE's `google-noto-sans-tc-fonts` installs the variable CJK TC font directly in + /// `/usr/share/fonts/truetype`. + #[cfg(target_os = "linux")] + #[test] + fn traditional_chinese_searches_the_opensuse_font_dir() { + let found = traditional_chinese_candidates(); + assert!(found.iter().any(|c| c.path == Path::new("/usr/share/fonts/truetype/NotoSansCJKtc-VF.otf") && c.family == "Noto Sans CJK TC")); + } + /// The `NotoSansCJKtc` override folder is read before the older file at the font folder's root. #[test] fn the_tc_override_folder_comes_before_the_root_file() { From cbe1582624e30021ae102858e87ca19146f6c9fb Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:18 +0100 Subject: [PATCH 30/33] fix(ui): keep pinned rows' buttons in their slot, one place ends the drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The action row's width lost a gap for every app, pinned or not (`+ gap`), so each row's menu ran 8 px past its column. The main button keeps its old slot; a pinned app's grip now leads the row and the table widens its actions column by one square while a pinned app is shown, so no button is squeezed (#956) and every row stays in line. A test checks a pinned row against an unpinned one. - The grip only shows in the grid and the table, where dropping works; on the details page it was a control that did nothing. - A released drag ends in one place, after the list draws, instead of in the grid, the table, the empty list and each drop target. - Move up and Move down use the drag's `reorder_pinned_to`; `reorder_pinned` and its test go, and `pinned_neighbor` returns the slot. - Drop the settings test that only re-ran its own steps (the UI test covers hiding a pinned app), the tool-generated design note, and use "Lösen" for Unpin in German. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/settings.rs | 75 ++++---------- crates/ui-egui/src/views.rs | 91 ++++++++--------- crates/ui-egui/tests/ui.rs | 26 ++++- .../specs/2026-11-02-pin-apps-design.md | 99 ------------------- 6 files changed, 89 insertions(+), 210 deletions(-) delete mode 100644 docs/superpowers/specs/2026-11-02-pin-apps-design.md diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f4161682..5fbea855 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -844,8 +844,6 @@ impl LauncherUi { // Escape must run even when search/filter leaves no rows (grid/table skipped). self.clear_pin_drag_on_escape(ui); if ids.is_empty() { - // No drop targets this frame — still end the drag on pointer up. - self.clear_pin_drag_if_pointer_up(ui); ui.add_space(40.0); ui.vertical_centered(|ui| { crate::widgets::hud(ui, tl!("No apps match"), tokens.muted); @@ -857,6 +855,10 @@ impl LauncherUi { } } ui.add_space(16.0); + // The drop targets have seen this frame's pointer: a released drag ends here. + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } }); }); self.details_drawer(&ctx); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fb5afa8f..afc61539 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -124,7 +124,7 @@ Installed apps with a newer version Installierte Apps mit neuerer Version Apps you can install Apps, die du installieren kannst Pin to top Oben anheften - Unpin Loslösen + Unpin Lösen Pinned Angeheftet Reorder {name} {name} neu anordnen Move up Nach oben diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index aa3cf315..f8be326a 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,37 +160,19 @@ impl Settings { self.pinned.retain(|p| p != id); } - /// The next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes `available`. - /// Skips stale catalog ids left in `pinned` so Move up/down still change the visible order. - pub fn pinned_neighbor(&self, id: &str, delta: i32, available: impl Fn(&str) -> bool) -> Option<&str> { - let i = self.pinned.iter().position(|p| p == id)?; - let mut j = i; + /// The index of the next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes + /// `available`. Skips stale catalog ids left in `pinned` so Move up/down still change the + /// visible order. + pub fn pinned_neighbor(&self, id: &str, delta: isize, available: impl Fn(&str) -> bool) -> Option { + let mut j = self.pinned.iter().position(|p| p == id)?; loop { - j = j.checked_add_signed(delta as isize)?; - let cand = self.pinned.get(j)?; - if available(cand) { - return Some(cand.as_str()); + j = j.checked_add_signed(delta)?; + if available(self.pinned.get(j)?) { + return Some(j); } } } - /// Move the pinned app `from_id` so its final index is the current index of `to_id`. - pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { - if from_id == to_id { - return; - } - let Some(from) = self.pinned.iter().position(|p| p == from_id) else { - return; - }; - let Some(to) = self.pinned.iter().position(|p| p == to_id) else { - return; - }; - let item = self.pinned.remove(from); - // After `remove`, indices after `from` shift left; inserting at the original `to` - // still lands the item on that final index (whether moving up or down). - self.pinned.insert(to.min(self.pinned.len()), item); - } - /// Move `from_id` to insertion slot `to_index` (`0..=len`, before remove). Used by drag-and-drop. pub fn reorder_pinned_to(&mut self, from_id: &str, mut to_index: usize) { let Some(from) = self.pinned.iter().position(|p| p == from_id) else { @@ -302,23 +284,6 @@ mod tests { assert_eq!(s.pinned, vec!["filmcraft"]); } - #[test] - fn reorder_pinned_moves_to_target_index() { - let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into(), "d".into()], ..Settings::default() }; - s.reorder_pinned("a", "c"); - assert_eq!(s.pinned, vec!["b", "c", "a", "d"]); - s.reorder_pinned("a", "b"); - assert_eq!(s.pinned, vec!["a", "b", "c", "d"]); - s.reorder_pinned("photocraft", "filmcraft"); // no-op when missing - let mut s = Settings { pinned: vec!["photocraft".into(), "filmcraft".into()], ..Settings::default() }; - s.reorder_pinned("photocraft", "filmcraft"); - assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); - s.reorder_pinned("missing", "a"); - s.reorder_pinned("filmcraft", "missing"); - s.reorder_pinned("filmcraft", "filmcraft"); - assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); - } - #[test] fn reorder_pinned_to_uses_insertion_slot() { let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; @@ -331,9 +296,13 @@ mod tests { // Insert a before c (slot 2) → [b, a, c]. s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); - // Insert a after itself (slot 2) is a no-op. + // Insert a after itself (slot 2) is a no-op, and so is an unknown id; a slot past the end + // means the end. s.reorder_pinned_to("a", 2); + s.reorder_pinned_to("missing", 0); assert_eq!(s.pinned, vec!["b", "a", "c"]); + s.reorder_pinned_to("b", 99); + assert_eq!(s.pinned, vec!["a", "c", "b"]); } #[test] @@ -342,22 +311,12 @@ mod tests { assert!(s.pinned.is_empty()); } - #[test] - fn hide_path_unpins() { - let mut s = Settings::default(); - s.pin_app("photocraft"); - s.unpin_app("photocraft"); - s.hidden.insert("photocraft".into()); - assert!(s.pinned.is_empty()); - assert!(s.hidden.contains("photocraft")); - } - #[test] fn pinned_neighbor_steps_within_bounds() { let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; let any = |_: &str| true; - assert_eq!(s.pinned_neighbor("b", -1, any), Some("a")); - assert_eq!(s.pinned_neighbor("b", 1, any), Some("c")); + assert_eq!(s.pinned_neighbor("b", -1, any), Some(0)); + assert_eq!(s.pinned_neighbor("b", 1, any), Some(2)); assert_eq!(s.pinned_neighbor("a", -1, any), None); assert_eq!(s.pinned_neighbor("c", 1, any), None); assert_eq!(s.pinned_neighbor("missing", 1, any), None); @@ -367,7 +326,7 @@ mod tests { fn pinned_neighbor_skips_unavailable_ids() { let s = Settings { pinned: vec!["a".into(), "gone".into(), "b".into()], ..Settings::default() }; let live = |id: &str| id != "gone"; - assert_eq!(s.pinned_neighbor("a", 1, live), Some("b")); - assert_eq!(s.pinned_neighbor("b", -1, live), Some("a")); + assert_eq!(s.pinned_neighbor("a", 1, live), Some(2)); + assert_eq!(s.pinned_neighbor("b", -1, live), Some(0)); } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index e09499cc..79584e13 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -437,9 +437,6 @@ impl LauncherUi { } self.grid_block(ui, rest); ui.add_space(24.0); - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } } fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) { @@ -601,6 +598,7 @@ impl LauncherUi { // clipped to the scroll area. let clip = ui.clip_rect(); let span = ui.available_rect_before_wrap().x_range(); + let grip_w = self.grip_w(ui, ids); egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| { TableBuilder::new(ui) @@ -612,7 +610,7 @@ impl LauncherUi { .column(Column::exact(150.0)) .column(Column::exact(132.0)) .column(Column::exact(104.0)) - .column(Column::exact(206.0)) + .column(Column::exact(206.0 + grip_w)) .header(42.0, |header| self.table_header(header, &mut sort_clicked, &t, span, clip)) .body(|body| self.table_rows(body, ids, &t, (title_h, tagline_h), now, (span, clip))); }); @@ -624,9 +622,6 @@ impl LauncherUi { self.settings.sort_descending = k.starts_descending(); } } - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } } /// The table's hairline under the row `ui` is drawing, across the table's full width. @@ -723,6 +718,7 @@ impl LauncherUi { rule: (egui::emath::Rangef, egui::Rect), ) { let last = ids.len().saturating_sub(1); + let grip_w = self.grip_w(body.ui_mut(), ids); for (i, id) in ids.iter().enumerate() { let Some(entry) = self.entry(id).cloned() else { continue }; let status = entry.status(self.platform, self.caps.manage_apps); @@ -734,7 +730,16 @@ impl LauncherUi { row.col(|ui| { ui.label(RichText::new(entry.app.publisher).color(t.muted)); }); - row.col(|ui| self.actions(ui, &entry, &status, 204.0)); + // A pinned row's grip leads its actions; the other rows leave its room empty, so + // every row's buttons stay in line. + row.col(|ui| { + if self.settings.is_pinned(id) { + self.actions(ui, &entry, &status, 204.0 + grip_w); + } else { + ui.add_space(grip_w); + self.actions(ui, &entry, &status, 204.0); + } + }); }); } } @@ -877,14 +882,18 @@ impl LauncherUi { } /// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows - /// the website, GitHub and releases links as buttons of its own. Pinned apps also get a drag - /// handle for reordering. - pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) { + /// the website, GitHub and releases links as buttons of its own. In the grid and the table + /// (`links_in_menu`), a pinned app's actions start with the handle that reorders it, which takes + /// one square of `width` (the table widens its column by [`Self::grip_w`] for it). + pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, mut width: f32, links_in_menu: bool) { let id = entry.app.id.to_string(); let gap = ui.spacing().item_spacing.x; - let pinned = self.settings.is_pinned(entry.app.id); - let squares = 1.0 + if pinned { 1.0 } else { 0.0 }; - let main_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(90.0); + if links_in_menu && self.settings.is_pinned(entry.app.id) { + self.pin_grip(ui, entry); + width -= theme::CONTROL_H + gap; + } + // One square button (the menu) beside the main one. + let main_w = (width - theme::CONTROL_H - gap).max(120.0); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); return; @@ -893,12 +902,15 @@ impl LauncherUi { return; } self.primary_button(ui, entry, &id, status, main_w); - if pinned { - self.pin_grip(ui, entry); - } self.menu(ui, entry, status, links_in_menu); } + /// The room a pin grip takes: one square and a gap while the list shows a pinned app (they come + /// first), else none. + fn grip_w(&self, ui: &Ui, ids: &[&'static str]) -> f32 { + if ids.first().is_some_and(|id| self.settings.is_pinned(id)) { theme::CONTROL_H + ui.spacing().item_spacing.x } else { 0.0 } + } + /// Cancel an in-progress pin reorder (Escape), restoring the pinned order from drag start /// (live hover already mutated `settings.pinned`). Called from [`Self::ui`] before the /// empty-results branch so Escape still works when search/filter hides every app mid-drag. @@ -911,14 +923,6 @@ impl LauncherUi { } } - /// End a pin drag when the pointer is up (commit current order). Used when no grid/table - /// drop targets run — e.g. empty search results mid-drag. - pub(crate) fn clear_pin_drag_if_pointer_up(&mut self, ui: &Ui) { - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } - } - /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); @@ -926,7 +930,7 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape restores earlier). + // `LauncherUi::ui` clears `pin_dragging` after the list once the pointer is up. if resp.drag_started() { self.pin_dragging = Some((entry.app.id.to_string(), self.settings.pinned.clone())); } @@ -935,7 +939,7 @@ impl LauncherUi { /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. - /// A release over a target still counts (process before clearing `pin_dragging`). + /// A release over a target still counts: `LauncherUi::ui` ends the drag after the list. fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some((from, _)) = self.pin_dragging.as_ref() else { return; @@ -944,7 +948,6 @@ impl LauncherUi { if from == id || !self.settings.is_pinned(id) { return; } - let primary_down = ui.input(|i| i.pointer.primary_down()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; @@ -960,9 +963,6 @@ impl LauncherUi { }; let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; self.settings.reorder_pinned_to(&from, insertion_index); - if !primary_down { - self.pin_dragging = None; - } } /// A running job's row: one progress bar, with pause and cancel while its download is under @@ -1031,9 +1031,8 @@ impl LauncherUi { return false; } let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() }; - // Resume, then discard, optional pin grip, and the menu. - let squares = 2.0 + if self.settings.is_pinned(entry.app.id) { 1.0 } else { 0.0 }; - let resume_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(0.0); + // Resume, then discard and the menu (two squares). + let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0); // Narrow (the table): just tl!("Resume"); the tooltip says how far it got. let label = match resume_w { w if w < 150.0 => tl!("Resume").to_string(), @@ -1052,9 +1051,6 @@ impl LauncherUi { if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() { self.act_command(Command::Cancel { app: id.to_string() }); } - if self.settings.is_pinned(entry.app.id) { - self.pin_grip(ui, entry); - } self.menu(ui, entry, status, links_in_menu); true } @@ -1169,19 +1165,16 @@ impl LauncherUi { // would let Move skip over a filtered pin with no visible change. let visible: std::collections::HashSet<&str> = self.visible_ids().into_iter().collect(); let known = |pid: &str| visible.contains(pid); - let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); - let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); - if prev.is_some() && ui.button(tl!("Move up")).clicked() { - if let Some(prev) = prev.as_deref() { - self.settings.reorder_pinned(id, prev); + // Up: into the slot before the neighbour above; down: into the slot after the one below. + let up = self.settings.pinned_neighbor(id, -1, known); + let down = self.settings.pinned_neighbor(id, 1, known).map(|j| j + 1); + for (slot, label) in [(up, tl!("Move up")), (down, tl!("Move down"))] { + if let Some(slot) = slot + && ui.button(label).clicked() + { + self.settings.reorder_pinned_to(id, slot); + ui.close(); } - ui.close(); - } - if next.is_some() && ui.button(tl!("Move down")).clicked() { - if let Some(next) = next.as_deref() { - self.settings.reorder_pinned(id, next); - } - ui.close(); } } else if !hidden && ui.button(tl!("Pin to top")).clicked() { self.settings.pin_app(id); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 044a3e82..51804dfc 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1407,7 +1407,7 @@ fn pinning_an_app_keeps_it_on_top_until_unpinned() { let ids = h.state().as_ref().unwrap().visible_ids_for_test(); assert_eq!(ids.first().copied(), Some("photocraft"), "pinned app stays first under Name sort: {ids:?}"); h.state_mut().as_mut().unwrap().settings.pin_app("filmcraft"); - h.state_mut().as_mut().unwrap().settings.reorder_pinned("photocraft", "filmcraft"); + h.state_mut().as_mut().unwrap().settings.reorder_pinned_to("photocraft", 2); h.run_steps(2); assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["filmcraft".to_string(), "photocraft".to_string()]); assert_eq!(&h.state().as_ref().unwrap().visible_ids_for_test()[..2], &["filmcraft", "photocraft"]); @@ -1504,6 +1504,30 @@ fn grid_shows_pinned_section_header() { h.get_all_by_label_contains("Reorder PhotoCraft").next().expect("pinned card exposes a reorder handle"); } +#[test] +/// A pinned table row's grip leads its actions without taking room from them: its Open button is +/// as wide as an unpinned row's, and the menus stay in one line (#956: a squeezed button pushes +/// the menu out of the column). +fn a_pinned_table_row_keeps_its_buttons_in_line() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // LightCraft and WordCraft are both installed and up to date: each row offers Open. + h.state_mut().as_mut().unwrap().settings.pin_app("lightcraft"); + h.run_steps(4); + let grip = h.get_all_by_label_contains("Reorder LightCraft").next().expect("pinned row has a grip").rect(); + let open_in_row = |menu: egui::Rect| { + let row = menu.y_range(); + h.query_all_by_label("Open").map(|n| n.rect()).find(|r| row.contains(r.center().y)).expect("an Open button in the row") + }; + let (pinned_menu, other_menu) = + (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); + let (pinned_open, other_open) = (open_in_row(pinned_menu), open_in_row(other_menu)); + assert!(grip.right() <= pinned_open.left(), "grip {grip:?} leads the row's Open {pinned_open:?}"); + assert!((pinned_open.width() - other_open.width()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_open.left() - other_open.left()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_menu.right() - other_menu.right()).abs() < 0.5, "pinned {pinned_menu:?} vs unpinned {other_menu:?}"); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md deleted file mode 100644 index b99f4aa9..00000000 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# Pin apps to top — design - -**Date:** 2026-11-02 -**Repo:** storytold/craft-launcher -**Status:** approved - -## Goal - -Let users pin catalog apps to a fixed block at the top of the launcher (grid and table), and reorder those pinned apps by drag-and-drop. Pin order is intentional and is not scrambled by the active sort. - -## Decisions - -| Topic | Choice | -|---|---| -| Pin entry points | `…` menu **Pin to top** / **Unpin**, plus a drag handle on pinned items | -| Sort interaction | Pinned stay in user order at top; sort applies only to unpinned apps | -| Grid grouping | One **Pinned** block first; then existing Installed / Not installed for the rest | -| Persistence | `Settings.pinned: Vec` (catalog ids, order = display order) | -| Hide interaction | Hiding an app removes it from `pinned` | - -## Data - -- Add `pinned: Vec` to `Settings` with `#[serde(default)]`. -- Helpers (on `Settings` or next to sort logic): - - `is_pinned(id)` - - `pin(id)` — append if absent - - `unpin(id)` — remove if present - - `reorder_pinned(from, to)` — move within the vec; ignore out-of-range -- On **Hide App**, also `unpin`. -- Unknown / removed catalog ids in `pinned` are skipped when building the visible list (left in settings until unpin/hide so re-adding an app can restore order later is unnecessary; prune on load is optional — prefer skip-at-display for simplicity). - -## Ordering - -`LauncherUi::visible_ids` (or equivalent): - -1. Collect filtered/search-matching entries as today. -2. Partition into pinned vs unpinned using `settings.pinned` membership. -3. Emit pinned ids in `settings.pinned` order (only those present in the filtered set). -4. Sort unpinned with the existing sort keys; in Grid, still put installed before not-installed within the unpinned set only. - -## UI - -### Menu - -In `menu` / `menu_hide` area (before Hide): - -- If not pinned: **Pin to top** -- If pinned: **Unpin** - -### Drag handle - -- Shown only when the app is pinned (grid card and table row). -- Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. -- Accessible name e.g. **Reorder {name}**. -- Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. -- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live), including when search/filter leaves no visible apps. -- Cursor: grab / grabbing. -- Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. - -### Section header (grid) - -When the pinned block is non-empty, show a **Pinned** HUD label (same style as **Not installed**) above that block. - -### Table - -Same id order as grid’s logical list (pinned first). Handle sits near the app name or before the `…` control without changing column semantics. - -## i18n - -Add English keys to every `complete_menus` catalog TSV: - -- `Pin to top` -- `Unpin` -- `Pinned` -- `Reorder {name}` (accessible name / tooltip) - -Translate clean-room from meaning; product names stay English. - -## Tests - -- Unit: pin/unpin/reorder helpers; hide clears pin; `visible_ids` puts pinned first in pin order then sorted unpinned. -- UI harness: menu shows Pin/Unpin; after pin, Pinned header (grid) / order in table; reorder via helper or simulated drag if harness allows. -- Settings round-trip: `pinned` survives serde default for old settings files. - -## Out of scope - -- Syncing pins across machines -- Changing upstream suite catalog order -- A new Sort key for “Pinned” -- Pinning while the Hidden filter is active (hidden apps are not in the main list; unpin-on-hide covers this) - -## Files (expected) - -- `crates/ui-egui/src/settings.rs` — field + helpers -- `crates/ui-egui/src/app.rs` — `visible_ids` partition -- `crates/ui-egui/src/views.rs` — menu, grid sections, drag, table handle -- `crates/ui-egui/src/widgets.rs` — grip glyph / handle widget if needed -- `crates/ui-egui/src/i18n/*.tsv` — strings -- `crates/ui-egui/tests/ui.rs` (+ unit tests near helpers) From 644b2adb6f8c28d8d773b4738f3167b638ad1531 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:06:33 +0100 Subject: [PATCH 31/33] style(ui): rustfmt the pinned-row test Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/ui.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 440e3956..8a519fe2 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1550,8 +1550,7 @@ fn a_pinned_table_row_keeps_its_buttons_in_line() { let row = menu.y_range(); h.query_all_by_label("Open").map(|n| n.rect()).find(|r| row.contains(r.center().y)).expect("an Open button in the row") }; - let (pinned_menu, other_menu) = - (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); + let (pinned_menu, other_menu) = (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); let (pinned_open, other_open) = (open_in_row(pinned_menu), open_in_row(other_menu)); assert!(grip.right() <= pinned_open.left(), "grip {grip:?} leads the row's Open {pinned_open:?}"); assert!((pinned_open.width() - other_open.width()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); From 8aa8df744a560393cb0ff8c0e21026d88d9d350d Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 3 Nov 2026 09:08:50 +0100 Subject: [PATCH 32/33] Fix pinned app drops outside the scroll viewport --- crates/ui-egui/src/views.rs | 10 ++++--- crates/ui-egui/tests/ui.rs | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 4c6ca3dd..b7085e4f 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -475,7 +475,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; - self.pin_drop_target(ui, id, rect, PinDropAxis::Horizontal); + self.pin_drop_target(ui, id, rect, ui.clip_rect(), PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -775,7 +775,7 @@ impl LauncherUi { } let row_rect = ui.max_rect(); // Full-row target (covers the grip column too); only this target reorders in the table. - self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), PinDropAxis::Vertical); + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), clip, PinDropAxis::Vertical); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -953,7 +953,7 @@ impl LauncherUi { /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. /// A release over a target still counts: `LauncherUi::ui` ends the drag after the list. - fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, viewport: Rect, axis: PinDropAxis) { let Some((from, _)) = self.pin_dragging.as_ref() else { return; }; @@ -964,7 +964,9 @@ impl LauncherUi { let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; - if !rect.contains(pos) { + // Layout includes clipped-off entries. Only the scroll viewport accepts drops; the + // table passes its full viewport rather than the narrower first-column clip. + if !rect.contains(pos) || !viewport.contains(pos) { return; } let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 8a519fe2..cdd7f792 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1558,6 +1558,61 @@ fn a_pinned_table_row_keeps_its_buttons_in_line() { assert!((pinned_menu.right() - other_menu.right()).abs() < 0.5, "pinned {pinned_menu:?} vs unpinned {other_menu:?}"); } +#[test] +fn pin_drag_ignores_targets_clipped_by_the_scroll_viewport() { + for view in [ViewMode::Grid, ViewMode::Table] { + let (mut h, _) = harness(view, egui::Theme::Dark, true); + h.run_steps(6); + let pins = vec!["photocraft".to_string(), "lightcraft".to_string(), "wordcraft".to_string()]; + h.state_mut().as_mut().unwrap().settings.pinned = pins.clone(); + h.run_steps(4); + let grip = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("WordCraft details").rect(); + let at = match view { + ViewMode::Grid => egui::pos2(target.right() - 10.0, target.bottom() - 20.0), + ViewMode::Table => egui::pos2(h.get_by_label("Reorder WordCraft").rect().center().x, target.bottom() - 10.0), + }; + h.hover_at(grip); + h.run_steps(2); + h.drag_at(grip); + h.run_steps(2); + h.hover_at(grip + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test(), "{view:?}: real grip starts drag"); + // Keep the target's layout position, but clip it behind the window footer. + h.set_size(egui::vec2(1180.0, at.y + 2.0)); + h.hover_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped hover"); + h.drop_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped release"); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); + } +} + +#[test] +fn pin_drag_accepts_the_table_grip_column() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pinned = vec!["photocraft".into(), "lightcraft".into(), "wordcraft".into()]; + h.run_steps(4); + let from = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("Reorder WordCraft").rect(); + h.hover_at(from); + h.run_steps(2); + h.drag_at(from); + h.run_steps(2); + h.hover_at(from + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test()); + // Release directly in the last column, outside the first app cell's clip rectangle. + h.drop_at(egui::pos2(target.center().x, target.bottom() - 2.0)); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, ["lightcraft", "wordcraft", "photocraft"]); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. From 46134849117c863ddfe7b6971d6e4cea9dd57153 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 08:11:27 -0500 Subject: [PATCH 33/33] i18n: fix Traditional Chinese after main's Installed and catalog changes Fill the new Installed fields in the TC test and add zh-TW rows for the pinning and retry strings that main added, so the catalog completeness test passes again. --- crates/ui-egui/src/i18n/zh-TW.tsv | 7 +++++++ crates/ui-egui/tests/traditional_chinese.rs | 3 +++ 2 files changed, 10 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh-TW.tsv b/crates/ui-egui/src/i18n/zh-TW.tsv index 3589b297..ee3d6acd 100644 --- a/crates/ui-egui/src/i18n/zh-TW.tsv +++ b/crates/ui-egui/src/i18n/zh-TW.tsv @@ -271,6 +271,12 @@ figcraft Interface design and prototyping 介面設計與原型製作 Apps on this computer 這台電腦上的應用程式 Installed apps with a newer version 有較新版本的已安裝應用程式 Apps you can install 可安裝的應用程式 + Pin to top 置頂 + Unpin 取消置頂 + Pinned 已置頂 + Reorder {name} 重新排序 {name} + Move up 上移 + Move down 下移 Hide App 隱藏應用程式 Unhide App 取消隱藏應用程式 Hidden Apps 已隱藏的應用程式 @@ -283,6 +289,7 @@ figcraft Interface design and prototyping 介面設計與原型製作 Update cancelled 已取消更新 Uninstall cancelled 已取消解除安裝 Retry install 重試安裝 + Retry update 重試更新 Retry update to {version} 重試更新至 {version} The download is still here. It's checked again, then installed. 下載的檔案仍在。會再次檢查,然後安裝。 Waiting for another app… 正在等待另一個應用程式… diff --git a/crates/ui-egui/tests/traditional_chinese.rs b/crates/ui-egui/tests/traditional_chinese.rs index 462405bd..597e983e 100644 --- a/crates/ui-egui/tests/traditional_chinese.rs +++ b/crates/ui-egui/tests/traditional_chinese.rs @@ -96,6 +96,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); }