From f0f8b46b64c0a9c609c6a6d5210b4f4f490d85cf Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 09:33:03 +0100 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 d929e8f8e2f1f3c7aa405bbd56b0704200974fce Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:42:17 -0500 Subject: [PATCH 25/43] test(xtask): build the feed fixture in a private temp directory (#1190) The feed test wrote its fixture under a predictable PID-based name in the shared temp root. Another local user could pre-create that tree with symlinks, so the fixture writes would follow them with the test runner's permissions. Use tempfile::tempdir, which creates a fresh 0700 directory with an unpredictable name and removes it when dropped. --- Cargo.lock | 1 + xtask/Cargo.toml | 3 +++ xtask/src/local.rs | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee22d87f..86b25d36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6226,6 +6226,7 @@ version = "0.14.0" dependencies = [ "jiff", "semver", + "tempfile", ] [[package]] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 2a36b604..14c89090 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -11,3 +11,6 @@ workspace = true [dependencies] jiff = { workspace = true } semver = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/xtask/src/local.rs b/xtask/src/local.rs index 6afd2088..18ead311 100644 --- a/xtask/src/local.rs +++ b/xtask/src/local.rs @@ -787,7 +787,10 @@ mod tests { #[test] fn writes_a_feed_the_launcher_reads() { - let mirror = std::env::temp_dir().join(format!("xtask-local-{}", std::process::id())); + // A fresh private directory with an unpredictable name: a predictable one in the shared + // temp root could be pre-created by another user, with symlinks where the fixture writes. + let tmp = tempfile::tempdir().unwrap(); + let mirror = tmp.path().to_path_buf(); let dir = mirror.join("photocraft").join("0.6.0"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("photocraft-0.6.0-macos-aarch64.dmg"), b"dmg").unwrap(); @@ -797,7 +800,6 @@ mod tests { std::fs::create_dir_all(mirror.join("photocraft").join("0.7.0")).unwrap(); std::fs::write(dir.join(COMPLETE), "").unwrap(); let json = feed(&mirror, "http://127.0.0.1:8787/"); - let _ = std::fs::remove_dir_all(&mirror); assert!(json.contains(r#""tag":"v0.6.0""#), "{json}"); assert!(json.contains(r#""download_url":"http://127.0.0.1:8787/photocraft/0.6.0/photocraft-0.6.0-macos-aarch64.dmg""#)); assert!(json.contains(r#""size_bytes":3"#) && json.contains(&"ab".repeat(32))); From cbc162030660e027eafe43513db9617b94daf9ed Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:42:24 -0500 Subject: [PATCH 26/43] fix(fonts): match CJK face names without decoding every record (#1191) * fix(fonts): match CJK face names without decoding every record face_index decoded every family and style name record of each face into an owned String, collected them, and only then compared. Name records can share one long encoded string, so a crafted font at a candidate path (a hand-installed MiSans, say) could make the UI thread decode and hold hundreds of MiB of names. Compare each name char by char against the short wanted name, stopping at the first mismatch or one char past its length, keep nothing, and look at no more than MAX_NAME_RECORDS records per lookup. * fix(fonts): cap compared name records per name ID, not across both --- crates/ui-egui/src/cjk_font.rs | 53 +++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs index 2969a8a9..b727885d 100644 --- a/crates/ui-egui/src/cjk_font.rs +++ b/crates/ui-egui/src/cjk_font.rs @@ -15,6 +15,9 @@ use crate::i18n::Script; const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024; /// Faces looked at in one collection file (PingFang has 24). const MAX_FACES: u32 = 64; +/// Name records compared per name ID in one face. Real fonts carry a few dozen translations of +/// each name; the cap bounds the decoding a crafted name table can ask for. +const MAX_NAME_RECORDS: usize = 256; /// The bundled body font (Inter), whose baseline a fallback face is nudged onto. const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf"); @@ -99,18 +102,13 @@ fn read(path: &Path) -> Option> { /// The face of `family` in a font file or collection: its Regular face, else the first face of /// that family. Matches the (typographic) family name in any language the file gives it in. pub(crate) fn face_index(data: &[u8], family: &str) -> Option { - let names = |font: &FontRef<'_>, ids: [StringId; 2]| -> Vec { - ids.into_iter().flat_map(|id| font.localized_strings(id).map(|s| s.to_string())).collect() - }; let mut first = None; for index in 0..MAX_FACES { let Ok(font) = FontRef::from_index(data, index) else { break }; - let families = names(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]); - if !families.iter().any(|f| f.eq_ignore_ascii_case(family)) { + if !has_name(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME], family) { continue; } - let styles = names(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]); - if styles.iter().any(|s| s.eq_ignore_ascii_case("Regular")) { + if has_name(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME], "Regular") { return Some(index); } first.get_or_insert(index); @@ -118,6 +116,25 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option { first } +/// Whether one of the first [`MAX_NAME_RECORDS`] names of each of `ids` in `font` is `wanted` +/// (ASCII case ignored). Each name is decoded only as far as it still matches, and never kept: a name table +/// can point many records at the same long string. +fn has_name(font: &FontRef<'_>, ids: [StringId; 2], wanted: &str) -> bool { + ids.into_iter().any(|id| font.localized_strings(id).take(MAX_NAME_RECORDS).any(|name| chars_eq_ignore_ascii_case(name.chars(), wanted))) +} + +/// `chars` spells `wanted`, ignoring ASCII case. Reads at most one char past `wanted`'s length. +fn chars_eq_ignore_ascii_case(mut chars: impl Iterator, wanted: &str) -> bool { + let mut want = wanted.chars(); + loop { + match (chars.next(), want.next()) { + (None, None) => return true, + (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b) => {} + _ => return false, + } + } +} + /// Where each system keeps a font for `script`, best first. fn candidates(script: Script) -> Vec { let found = match script { @@ -358,6 +375,28 @@ mod tests { assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None); } + /// Names compare without regard to ASCII case, and a long name stops being read once it + /// runs past the wanted one. + #[test] + fn names_compare_incrementally() { + assert!(chars_eq_ignore_ascii_case("PingFang SC".chars(), "pingfang sc")); + assert!(!chars_eq_ignore_ascii_case("PingFang".chars(), "PingFang SC")); + assert!(!chars_eq_ignore_ascii_case("PingFang SCX".chars(), "PingFang SC")); + let mut read = 0; + let endless = std::iter::repeat('a').inspect(|_| read += 1); + assert!(!chars_eq_ignore_ascii_case(endless, "aaa")); + assert_eq!(read, 4); + } + + /// The bundled body font is found by its own family, in any ASCII case, and not by a prefix. + #[test] + fn face_index_finds_a_family_by_name() { + assert_eq!(face_index(BODY_FONT, "Inter"), Some(0)); + assert_eq!(face_index(BODY_FONT, "inter"), Some(0)); + assert_eq!(face_index(BODY_FONT, "Inte"), None); + assert_eq!(face_index(BODY_FONT, "PingFang SC"), None); + } + /// The baseline nudge reads a face's ascent and height; the reference is Inter's. #[test] fn face_metrics_reads_font_metrics() { From 90cf925a44c3f251c8f22339254a677107baa66c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:42:30 -0500 Subject: [PATCH 27/43] fix(single-instance): exit only for a launcher that proved itself (#1186) * fix(single-instance): exit only for a launcher that proved itself A second start exited (Instance::Secondary) whenever the port's holder answered with a version at least as new as its own, or failed the token proof. Any local listener on 47413 could therefore keep the launcher from starting without proving it belongs to the same account. The running launcher now challenges every newcomer that offers a nonce, whatever its version, and still grants Replace only to a newer one that proves itself. The newcomer defers only to an authenticated answer; an unproven or failed one is treated as an unrelated program and it starts anyway, as it already did for a port held by something else. * fix(single-instance): re-prove the port holder before exiting after a stalled takeover * fix(single-instance): prove a stalled holder without the 0.0.0 probe Launchers from 0.8.1 to 0.14.x only challenge a newer newcomer, so a 0.0.0 probe of a busy older launcher that stayed after a takeover was never proven and the new start ran beside it. Greet with our own version, check the holder's proof, and never answer the challenge, so the probe can only show the window and never grant Replace. --- crates/native/src/single_instance.rs | 237 ++++++++++++++++++++------- 1 file changed, 182 insertions(+), 55 deletions(-) diff --git a/crates/native/src/single_instance.rs b/crates/native/src/single_instance.rs index 21ee94f9..31e419d1 100644 --- a/crates/native/src/single_instance.rs +++ b/crates/native/src/single_instance.rs @@ -13,8 +13,11 @@ //! answer: it can't be asked to quit, so the newcomer starts anyway. //! //! Launchers from before the handshake send a bare `show`, which is still understood. The worst -//! another local program can do with any of this is bring the window forward or make the launcher -//! quit. +//! another local program can do with any of this is bring the window forward. +//! +//! A newcomer only steps aside (exits) for a running launcher that proved it holds this account's +//! token. Anything else on the port, including a listener that answers like a launcher but fails +//! or skips the proof, is treated as unrelated, and the newcomer starts anyway. //! //! ## Upgrading over the published 0.7.0 build //! @@ -23,9 +26,9 @@ //! second word equals its owner-only token file. A newer build sends `hello ` //! instead, so 0.7.0 reads the nonce as a wrong token, replies `running 0.7.0`, and merely shows //! its window. If that reply arrives in time, a newer newcomer requests takeover but the old -//! process does not release its port; a same-version newcomer defers. The old reader and new -//! client have similar timeouts, so a delayed reply can instead reach the legacy fallback and -//! leave another process running without the listening socket. These timing paths need an +//! process does not release its port; since that reply carries no proof, the newcomer then starts +//! anyway without the listening socket. The old reader and new client have similar timeouts, so a +//! delayed reply can instead reach the legacy fallback, with the same result. These timing paths need an //! old-release interoperability test. Quit the running 0.7.0 launcher from its tray menu before //! starting the new version. Do not make the new side //! send the token to force a `Replace`: that would restore the clear-text shared-secret handshake @@ -129,12 +132,17 @@ pub enum Instance { /// How the running launcher answered our hello. #[derive(Clone, Debug, PartialEq, Eq)] enum Answer { - /// It's as new as we are, or newer, and shows its window: we exit. + /// It proved it is this account's launcher, as new as we are or newer, and shows its window: + /// we exit. Defer, - /// It's older and quits: we take over the port. - TakeOver, + /// It's older and quits: we take over the port. `proven` when it proved it is this account's + /// launcher; only then do we exit if it keeps the port. + TakeOver { proven: bool }, /// It closed without an answer: a launcher from before the handshake. Legacy, + /// It didn't prove it is this account's launcher (failed or skipped the proof, or answered + /// something unreadable): another program, so we start anyway (CWE-287). + Unrelated, } type Wake = std::sync::Arc>>; @@ -154,7 +162,7 @@ pub fn claim(wake: Wake) -> Instance { } match ask(addr, ours.as_ref(), token.as_deref()) { Ok(Answer::Defer) => Instance::Secondary, - Ok(Answer::TakeOver) => { + Ok(Answer::TakeOver { proven }) => { let deadline = Instant::now() + TAKE_OVER_WITHIN; while Instant::now() < deadline { if let Ok(listener) = TcpListener::bind(addr) { @@ -162,8 +170,18 @@ pub fn claim(wake: Wake) -> Instance { } std::thread::sleep(Duration::from_millis(100)); } - // It stayed (busy installing) and showed its window instead. - Instance::Secondary + if proven && holder_proves_itself(addr, ours.as_ref(), token.as_deref()) { + // It stayed (busy installing) and showed its window instead. The proof is asked + // again because the proven launcher may have quit and something else taken the port. + Instance::Secondary + } else { + log::warn!("whatever holds the launcher's port didn't prove it is ArtCraft Launcher and kept the port; starting anyway"); + Instance::Primary(channel().1) + } + } + Ok(Answer::Unrelated) => { + log::warn!("something on the launcher's port didn't prove it is this account's ArtCraft Launcher; starting anyway"); + Instance::Primary(channel().1) } Ok(Answer::Legacy) => { log::warn!("an older ArtCraft Launcher is running and can't be asked to quit; starting anyway"); @@ -174,6 +192,34 @@ pub fn claim(wake: Wake) -> Instance { } } +/// Whether whatever holds the port now proves it is this account's launcher. +/// +/// The probe greets with our own version, because launchers from 0.8.1 to 0.14.x only challenge a +/// newer newcomer, and checks the holder's proof. It never answers the challenge, so the holder +/// can't grant `Replace`: it can only bring its window forward. +fn holder_proves_itself(addr: SocketAddr, ours: Option<&Version>, token: Option<&str>) -> bool { + let (Some(ours), Some(tok), Some(client_nonce)) = (ours, token.filter(|t| !t.is_empty()), fresh_secret()) else { return false }; + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(1)) else { return false }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(1))); + if stream.write_all(format!("{HELLO}{ours} {client_nonce}\n").as_bytes()).is_err() { + return false; + } + let reply = read_message(&mut stream); + let proven = + std::str::from_utf8(&reply).ok().and_then(|r| r.trim().strip_prefix(CHALLENGE)).is_some_and(|rest| server_proved(rest, tok, &client_nonce)); + let _ = stream.shutdown(std::net::Shutdown::Both); + proven +} + +/// Whether a `challenge ` (without its prefix) proves the sender holds `token`. +fn server_proved(challenge_rest: &str, token: &str, client_nonce: &str) -> bool { + let mut parts = challenge_rest.split_whitespace(); + let server_nonce = parts.next().unwrap_or(""); + let server_proof = parts.next().unwrap_or(""); + constant_time_eq(server_proof, &compute_proof("server", token, client_nonce, server_nonce)) +} + fn compute_proof(role: &str, token: &str, client_nonce: &str, server_nonce: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); @@ -272,24 +318,11 @@ fn handle_incoming(stream: &mut TcpStream, ours: Option<&Version>, token: Option let reply_running = format!("{RUNNING}{}\n", ours.map_or_else(|| "unknown".to_string(), ToString::to_string)); - if !newer { - let _ = stream.write_all(reply_running.as_bytes()); - return Some(Request::Show); - } - - // Newcomer is newer and asks to replace us. - // Mutual challenge-response: require proof of token possession before granting Replace. - let Some(ours_token) = token.filter(|t| !t.is_empty()) else { - let _ = stream.write_all(reply_running.as_bytes()); - return Some(Request::Show); - }; - - let Some(client_nonce) = client_nonce else { - let _ = stream.write_all(reply_running.as_bytes()); - return Some(Request::Show); - }; - - let Some(server_nonce) = fresh_secret() else { + // Mutual challenge-response whenever the newcomer offers a nonce, whatever its version: a + // newer one must prove itself before we grant Replace, and every newcomer needs our proof + // before it exits in our favour. Without a token or a nonce nothing can be proven: answer, + // and only show the window. + let (Some(ours_token), Some(client_nonce), Some(server_nonce)) = (token.filter(|t| !t.is_empty()), client_nonce, fresh_secret()) else { let _ = stream.write_all(reply_running.as_bytes()); return Some(Request::Show); }; @@ -307,7 +340,7 @@ fn handle_incoming(stream: &mut TcpStream, ours: Option<&Version>, token: Option let _ = stream.write_all(reply_running.as_bytes()); - if constant_time_eq(client_proof, &expected_client_proof) { Some(Request::Replace) } else { Some(Request::Show) } + if newer && constant_time_eq(client_proof, &expected_client_proof) { Some(Request::Replace) } else { Some(Request::Show) } } /// The new start's side: says hello to whatever holds the port and reads the answer. @@ -345,21 +378,18 @@ fn ask_once(addr: SocketAddr, ours: &Version, token: Option<&str>, with_nonce: b let reply_str = match std::str::from_utf8(&reply) { Ok(s) => s.trim(), - Err(_) => return Ok(Answer::Defer), + Err(_) => return Ok(Answer::Unrelated), }; if let Some(challenge_rest) = reply_str.strip_prefix(CHALLENGE) { - let mut parts = challenge_rest.split_whitespace(); - let server_nonce = parts.next().unwrap_or(""); - let server_proof = parts.next().unwrap_or(""); + let server_nonce = challenge_rest.split_whitespace().next().unwrap_or(""); let (Some(client_nonce), Some(tok)) = (client_nonce.as_deref(), token) else { - return Ok(Answer::Defer); + return Ok(Answer::Unrelated); }; - let expected_server_proof = compute_proof("server", tok, client_nonce, server_nonce); - if !constant_time_eq(server_proof, &expected_server_proof) { + if !server_proved(challenge_rest, tok, client_nonce) { // Server failed to prove it knows the shared token (untrusted peer). - // Do NOT disclose client proof; abort immediately. - return Ok(Answer::Defer); + // Do NOT disclose client proof; abort immediately, and don't step aside for it. + return Ok(Answer::Unrelated); } // Server is authentic. Prove our identity to server: @@ -368,22 +398,24 @@ fn ask_once(addr: SocketAddr, ours: &Version, token: Option<&str>, with_nonce: b stream.write_all(auth_msg.as_bytes())?; let final_reply = read_message(&mut stream); - return Ok(verdict(&final_reply, ours)); + return Ok(verdict(&final_reply, ours, true)); } - Ok(verdict(&reply, ours)) + Ok(verdict(&reply, ours, false)) } -/// Reads the running launcher's answer. -fn verdict(reply: &[u8], ours: &Version) -> Answer { +/// Reads the running launcher's answer. `proven` when it proved it holds this account's token: +/// only then is an answer that it is as new as we are a reason to exit. +fn verdict(reply: &[u8], ours: &Version, proven: bool) -> Answer { if reply.is_empty() { return Answer::Legacy; } let trimmed = std::str::from_utf8(reply).ok().map(str::trim); let running = trimmed.and_then(|r| r.strip_prefix(RUNNING)).and_then(version::parse); match running { - Some(running) if version::is_newer(ours, &running) => Answer::TakeOver, - _ => Answer::Defer, + Some(running) if version::is_newer(ours, &running) => Answer::TakeOver { proven }, + _ if proven => Answer::Defer, + _ => Answer::Unrelated, } } @@ -411,7 +443,7 @@ mod tests { #[test] fn a_newer_start_replaces_an_older_running_launcher() { let (addr, rx) = primary("0.1.0"); - assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::TakeOver)); + assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::TakeOver { proven: true })); assert_eq!(next(&rx), Request::Replace); } @@ -469,7 +501,7 @@ mod tests { }); let ans = ask(addr, Some(&v("0.1.2")), Some(TOK)).unwrap(); - assert_eq!(ans, Answer::Defer); + assert_eq!(ans, Answer::Unrelated, "a peer that fails the proof must not make us exit"); let bytes_received = handle.join().unwrap(); assert_eq!(bytes_received, 0, "client must immediately disconnect and send no auth proof"); } @@ -509,7 +541,7 @@ mod tests { stream.write_all(format!("{rest}\n").as_bytes()).unwrap(); let mut reply = Vec::new(); stream.read_to_end(&mut reply).unwrap(); - assert_eq!(verdict(&reply, &v(version)), Answer::TakeOver); + assert_eq!(verdict(&reply, &v(version), false), Answer::TakeOver { proven: false }); assert_eq!(next(&rx), Request::Show); } } @@ -522,7 +554,7 @@ mod tests { stream.write_all(format!("{HELLO}0.1.2\n").as_bytes()).unwrap(); let mut reply = Vec::new(); stream.read_to_end(&mut reply).unwrap(); - assert_eq!(verdict(&reply, &v("0.1.2")), Answer::TakeOver); + assert_eq!(verdict(&reply, &v("0.1.2"), false), Answer::TakeOver { proven: false }); assert_eq!(next(&rx), Request::Show); } @@ -600,7 +632,7 @@ mod tests { } } }); - assert_eq!(ask(addr, Some(&v("0.6.2")), Some(TOK)).ok(), Some(Answer::TakeOver)); + assert_eq!(ask(addr, Some(&v("0.6.2")), Some(TOK)).ok(), Some(Answer::TakeOver { proven: false })); let _ = old.join(); } @@ -615,10 +647,105 @@ mod tests { #[test] fn verdict_reads_defensively() { - assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2")), Answer::TakeOver); - assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2")), Answer::Defer); - assert_eq!(verdict(b"artcraft-launcher:running unknown\n", &v("0.1.2")), Answer::Defer); - assert_eq!(verdict(b"garbage\n", &v("0.1.2")), Answer::Defer); - assert_eq!(verdict(b"", &v("0.1.2")), Answer::Legacy); + assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2"), true), Answer::TakeOver { proven: true }); + assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2"), true), Answer::Defer); + assert_eq!(verdict(b"artcraft-launcher:running unknown\n", &v("0.1.2"), true), Answer::Defer); + assert_eq!(verdict(b"garbage\n", &v("0.1.2"), true), Answer::Defer); + assert_eq!(verdict(b"", &v("0.1.2"), true), Answer::Legacy); + // Without the proof, nothing makes us exit. + assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2"), false), Answer::TakeOver { proven: false }); + assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2"), false), Answer::Unrelated); + assert_eq!(verdict(b"artcraft-launcher:running 9.9.9\n", &v("0.1.2"), false), Answer::Unrelated); + assert_eq!(verdict(b"garbage\n", &v("0.1.2"), false), Answer::Unrelated); + assert_eq!(verdict(b"", &v("0.1.2"), false), Answer::Legacy); + } + + /// After a proven takeover stalls, the newcomer exits only if the port's holder proves itself + /// again, and the probe never asks that holder to quit, even though it greets as newer. + #[test] + fn the_port_holder_is_proven_again_before_stepping_aside() { + let (addr, rx) = primary("0.1.0"); + assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK))); + assert_eq!(next(&rx), Request::Show, "the probe must not ask the running launcher to quit"); + + // A holder of the same version (this build) is challenged too. + let (addr, rx) = primary("0.1.2"); + assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK))); + assert_eq!(next(&rx), Request::Show); + + // Something that took the port without the token can't keep the newcomer from starting. + let (addr, _rx) = primary("0.1.0"); + assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), Some("another-accounts-token"))); + let (addr, _rx) = primary("0.1.0"); + assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), None)); + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let imposter = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let _ = read_message(&mut s); + let _ = s.write_all(b"artcraft-launcher:running 9.9.9\n"); + }); + assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK))); + imposter.join().unwrap(); + } + + /// A busy 0.8.1 to 0.14.x launcher that stayed after a takeover only challenges a newer + /// newcomer. The probe greets as newer, so it is still proven, and it never sends a client proof. + #[test] + fn the_probe_proves_a_running_launcher_that_only_challenges_newer_starts() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let old = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let hello = String::from_utf8(read_message(&mut s)).unwrap(); + let mut parts = hello.strip_prefix(HELLO).unwrap().split_whitespace(); + let theirs = v(parts.next().unwrap()); + let Some(client_nonce) = parts.next().filter(|_| version::is_newer(&theirs, &v("0.1.0"))) else { + let _ = s.write_all(b"artcraft-launcher:running 0.1.0\n"); + return Vec::new(); + }; + let server_nonce = fresh_secret().unwrap(); + let proof = compute_proof("server", TOK, client_nonce, &server_nonce); + s.write_all(format!("{CHALLENGE}{server_nonce} {proof}\n").as_bytes()).unwrap(); + let auth = read_message(&mut s); + let _ = s.write_all(b"artcraft-launcher:running 0.1.0\n"); + auth + }); + assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK))); + assert!(old.join().unwrap().is_empty(), "the probe must never answer the challenge"); + } + + /// A listener that answers like a newer launcher but never proves it holds our token (it + /// skips the challenge) is unrelated: the newcomer doesn't exit for it (CWE-287). + #[test] + fn an_unproven_running_answer_does_not_make_a_start_exit() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let imposter = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let _ = read_message(&mut s); + let _ = s.write_all(b"artcraft-launcher:running 9.9.9\n"); + }); + assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::Unrelated)); + imposter.join().unwrap(); + } + + /// A same-version start is challenged too, and steps aside only after the running launcher + /// proved itself; it never gets to replace it. + #[test] + fn a_same_version_start_is_challenged_and_cannot_replace() { + let (addr, rx) = primary("0.1.2"); + let nonce = fresh_secret().unwrap(); + let mut stream = TcpStream::connect(addr).unwrap(); + stream.write_all(format!("{HELLO}0.1.2 {nonce}\n").as_bytes()).unwrap(); + let challenge = read_message(&mut stream); + let rest = std::str::from_utf8(&challenge).unwrap().strip_prefix(CHALLENGE).unwrap().to_string(); + let mut parts = rest.split_whitespace(); + let (server_nonce, server_proof) = (parts.next().unwrap(), parts.next().unwrap()); + assert_eq!(server_proof, compute_proof("server", TOK, &nonce, server_nonce)); + stream.write_all(format!("{AUTH}{}\n", compute_proof("client", TOK, &nonce, server_nonce)).as_bytes()).unwrap(); + assert!(std::str::from_utf8(&read_message(&mut stream)).unwrap().starts_with(RUNNING)); + assert_eq!(next(&rx), Request::Show, "a same-version start must never replace the running one"); } } From 57143114d12cb9f43586143d73295dd5837443d5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:42:36 -0500 Subject: [PATCH 28/43] fix(install): bind a Replace answer to the path the user was shown (#1189) Command::Replace carried only the app. Between the "already exists" question and the answer, the release can change (a new version, another asset name, another folder), and the install then recomputed its destination and wrote over a different existing file with replace=true. Replace now carries the path from Event::Occupied. The backend checks what is in the way again: if it is exactly that path the install overwrites it; if something else is in the way it asks again; if nothing is, it installs without leave to overwrite. --- crates/core/src/protocol.rs | 5 ++- crates/native/src/backend.rs | 65 +++++++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/tests/ui.rs | 2 +- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index c3a64dcd..599ff159 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -29,9 +29,12 @@ pub enum Command { Install { app: String, }, - /// `Install`, writing over what [`Event::Occupied`] reported. + /// `Install`, writing over what [`Event::Occupied`] reported. `path` is the path the user + /// agreed to overwrite: if the install would now write somewhere else, nothing is replaced and + /// the user is asked again. Replace { app: String, + path: String, }, Uninstall { app: String, diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index d0d4fd72..9e6e58d0 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -306,7 +306,7 @@ impl NativeBackend { } fn start_job(&self, app_id: &str, work: Work) { - let uninstall = work == Work::Uninstall; + let uninstall = matches!(work, Work::Uninstall); let Some(app) = CraftApp::by_id(app_id) else { return }; log::info!("{}: starting {} job", app.name, if uninstall { "uninstall" } else { "install/update" }); let stop = Arc::new(Stop::default()); @@ -361,7 +361,10 @@ impl NativeBackend { existing.as_ref(), record.as_ref(), &stop, - work == Work::Replace, + match &work { + Work::Replace(path) => Some(path.as_str()), + Work::Install | Work::Uninstall => None, + }, source.as_deref(), ); // Stopped by the connection rather than by the user: say so, and how to go on. @@ -456,10 +459,11 @@ impl NativeBackend { } } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] enum Work { Install, - Replace, + /// Install over the path the user agreed to overwrite (see [`Command::Replace`]). + Replace(String), Uninstall, } @@ -509,7 +513,7 @@ fn run_install( existing: Option<&Installed>, record: Option<&crate::manifest::Record>, stop: &Stop, - replace: bool, + approved: Option<&str>, source: Option<&dyn ReleaseSource>, ) -> Result { if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) { @@ -566,10 +570,15 @@ fn run_install( } else { previous.as_ref().zip(previous_asset.as_ref()).map(|(previous, asset)| install::Downgrade::Reinstall { previous, asset }) }; - if !replace && let Some(path) = install::occupied(app, &asset, kind, existing, record, &options.install_locations, paths)? { - shared.emit(Event::Occupied { app: app.id.to_string(), path: path.display().to_string() }); - return Err(format!("{} already exists", path.display())); - } + let occupied = install::occupied(app, &asset, kind, existing, record, &options.install_locations, paths)?; + let replace = match replace_consent(occupied.as_deref(), approved) { + Consent::Free => false, + Consent::Given => true, + Consent::Ask(path) => { + shared.emit(Event::Occupied { app: app.id.to_string(), path: path.display().to_string() }); + return Err(format!("{} already exists", path.display())); + } + }; let mut last_emit = Instant::now() - Duration::from_secs(1); let mut phase = |p: JobPhase| { // Downloads report thousands of times; the UI needs ~20 a second. @@ -606,6 +615,29 @@ fn run_install( Ok(installed_text(app, existing.is_some(), &release)) } +/// Whether an install may write over what is at its destination. +#[derive(Debug, PartialEq, Eq)] +enum Consent<'a> { + /// Nothing the launcher didn't put there is in the way. + Free, + /// The user agreed to overwrite exactly this destination. + Given, + /// Something is in the way and the user hasn't agreed to overwrite it: ask. + Ask(&'a std::path::Path), +} + +/// The user's agreement to replace a file covers the path they were shown (`approved`), and only +/// that one. The release can change between the question and the answer (a new version, another +/// asset, another folder), and the install then writes somewhere else: it must not take the old +/// "yes" as leave to overwrite a different file (CWE-367). +fn replace_consent<'a>(occupied: Option<&'a std::path::Path>, approved: Option<&str>) -> Consent<'a> { + match occupied { + None => Consent::Free, + Some(path) if approved == Some(path.display().to_string().as_str()) => Consent::Given, + Some(path) => Consent::Ask(path), + } +} + /// What a finished install tells the user. fn installed_text(app: &CraftApp, update: bool, release: &Release) -> String { let verb = if update { "updated to" } else { "installed:" }; @@ -1146,7 +1178,7 @@ impl Backend for NativeBackend { }); } Command::Install { app } => self.start_job(&app, Work::Install), - Command::Replace { app } => self.start_job(&app, Work::Replace), + Command::Replace { app, path } => self.start_job(&app, Work::Replace(path)), Command::Uninstall { app } => self.start_job(&app, Work::Uninstall), Command::Pause { app } => { if let Some(stop) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) { @@ -1258,6 +1290,19 @@ mod tests { Release { tag: format!("v{v}"), version: version::parse(v), ..Default::default() } } + /// A "Replace" answer covers the path that was shown, never a destination that changed since. + #[test] + fn replacing_needs_consent_for_the_exact_path_in_the_way() { + let shown = std::path::Path::new("/home/a/Applications/photocraft-0.5.0-linux-x86_64.AppImage"); + let moved = std::path::Path::new("/home/a/Applications/photocraft-0.6.0-linux-x86_64.AppImage"); + let approved = shown.display().to_string(); + assert_eq!(replace_consent(None, None), Consent::Free); + assert_eq!(replace_consent(None, Some(&approved)), Consent::Free, "nothing in the way: nothing to overwrite"); + assert_eq!(replace_consent(Some(shown), None), Consent::Ask(shown)); + assert_eq!(replace_consent(Some(shown), Some(&approved)), Consent::Given); + assert_eq!(replace_consent(Some(moved), Some(&approved)), Consent::Ask(moved), "a yes for one file must not overwrite another"); + } + #[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 }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f5be900e..e5ca4af0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -248,7 +248,7 @@ impl LauncherUi { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::button(ui, tl!("Replace"), Kind::Danger, 110.0).clicked() { - self.act_command(Command::Replace { app: id.to_string() }); + self.act_command(Command::Replace { app: id.to_string(), path: path.to_string() }); close = true; } if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 10c1fad8..6a77d84c 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -2816,7 +2816,7 @@ fn an_occupied_folder_asks_before_replacing_it() { sent.borrow_mut().clear(); h.get_by_label("Replace").click(); h.run_steps(2); - assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into() }]); + assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into(), path: path.into() }]); assert!(h.query_by_label("Replace").is_none()); } From 8c651be7478da2a4f1014bd36c092d33b49d9607 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 3 Nov 2026 03:42:43 +0000 Subject: [PATCH 29/43] fix(windows): read an MSI's install context without starting PowerShell (#1082) * fix(windows): read an MSI's install context without starting PowerShell The update path ran a hidden PowerShell that asked the WindowsInstaller COM object which context a product was installed in. That is the pattern behavioural antivirus flagged in #1045. The context now comes from Windows Installer's own registration keys, read the way ProductsEx reads them: this account's managed and unmanaged products and the machine's, under the product code's packed name. A key the launcher may not read is still a lookup that didn't run, so the uninstall-record fallback is unchanged. Nothing in an update starts PowerShell any more. Co-Authored-By: Claude Opus 5.5 * fix(windows): declare winsafe's advapi feature and avoid a panicking split From review: - current_user_sid uses HACCESSTOKEN and TokenInfo, which need winsafe's advapi feature. It built only because craft-update enables it; now declared here. - packed_code uses split_at_checked, like the .get() calls after it. - The incident note lists the replaced lookup under "What changed", not "Still open". Co-Authored-By: Claude Opus 5.5 * test: accept any well-formed account SID, not only S-1-5- A Microsoft Entra ID account's SID starts S-1-12-1-, so the test failed on those machines although the lookup works. It now checks the SID's shape. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/native/Cargo.toml | 4 +- crates/native/src/msi_context.ps1 | 23 ---- crates/native/src/msi_context.rs | 129 ++++++++++++++++----- docs/incident-2026-10-windows-antivirus.md | 6 +- 4 files changed, 102 insertions(+), 60 deletions(-) delete mode 100644 crates/native/src/msi_context.ps1 diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 404b2967..ef7ecf6b 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -46,7 +46,7 @@ winreg = "0.55" # Resolves junctions/symlinks without forcing short MSI paths into the verbatim namespace. dunce = "1" zip = { version = "9", default-features = false, features = ["deflate"] } -# Safe wrappers for waiting for another process and reading a file's VERSIONINFO. -winsafe = { version = "0.0.29", features = ["kernel", "version"] } +# Safe wrappers for waiting for another process, reading a file's VERSIONINFO and this account's SID. +winsafe = { version = "0.0.29", features = ["kernel", "version", "advapi"] } # Authenticode checks through WinTrust: the update helper installs only files from the launcher's publisher. verifysign = "1" diff --git a/crates/native/src/msi_context.ps1 b/crates/native/src/msi_context.ps1 deleted file mode 100644 index d0e0e0eb..00000000 --- a/crates/native/src/msi_context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# Read-only MSI registration, limited to the current account and machine contexts. -# $code is supplied by the caller only after strict braced-GUID validation. -$ErrorActionPreference = 'Stop' -$installer = $null -$products = $null -try { - $installer = New-Object -ComObject WindowsInstaller.Installer - $products = $installer.ProductsEx($code, '', 7) - foreach ($product in $products) { - try { - $context = $product.GetType().InvokeMember('Context', 'GetProperty', $null, $product, $null) - Write-Output ([int]$context) - } finally { - [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($product) - } - } -} finally { - foreach ($com in @($products, $installer)) { - if ($null -ne $com) { - [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) - } - } -} diff --git a/crates/native/src/msi_context.rs b/crates/native/src/msi_context.rs index a3b6fc85..998625a7 100644 --- a/crates/native/src/msi_context.rs +++ b/crates/native/src/msi_context.rs @@ -13,7 +13,7 @@ pub(crate) enum ProbeError { Unknown, /// The same product is registered for this account and for everyone. Conflicting, - /// The lookup could not run (a blocked shell, a timeout, a slow first start). Carries the real + /// The lookup could not run (a registration key it may not read, no account identity). Carries the real /// error so it can be logged. Unavailable(String), } @@ -72,12 +72,14 @@ pub(crate) fn valid_product_code(code: &str) -> bool { }) } -fn parse_contexts(output: &str) -> Result { +/// The one context the product is registered in: 1 and 2 (managed and unmanaged, this account) are +/// per-user, 4 is the machine. None, any other value, or both kinds is never guessed from. +fn scope_of(contexts: &[u32]) -> Result { let mut scope = None; - for line in output.lines().map(str::trim).filter(|line| !line.is_empty()) { - let context = match line { - "1" | "2" => MsiScope::PerUser, - "4" => MsiScope::PerMachine, + for context in contexts { + let context = match context { + 1 | 2 => MsiScope::PerUser, + 4 => MsiScope::PerMachine, _ => return Err(ProbeError::Unknown), }; if scope.is_some_and(|previous| previous != context) { @@ -88,26 +90,59 @@ fn parse_contexts(output: &str) -> Result { scope.ok_or(ProbeError::Unknown) } +/// Windows Installer's packed form of a validated product code, the name of its registration keys: +/// the first three groups reversed, then each remaining byte's two digits swapped. +fn packed_code(code: &str) -> Option { + if !valid_product_code(code) { + return None; + } + let hex: Vec = code.chars().filter(char::is_ascii_hexdigit).map(|c| c.to_ascii_uppercase()).collect(); + let (groups, rest) = hex.split_at_checked(16)?; + let mut packed = String::with_capacity(32); + for range in [0..8, 8..12, 12..16] { + packed.extend(groups.get(range)?.iter().rev()); + } + for pair in rest.chunks(2) { + packed.extend(pair.iter().rev()); + } + Some(packed) +} + +/// Reads the product's registration for this account and the machine, as `ProductsEx` does, from +/// Windows Installer's own keys: no other account, and never `Win32_Product` (which repairs). #[cfg(windows)] pub(crate) fn probe(product_code: &str) -> Result { - use std::path::PathBuf; - use std::time::Duration; + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; - if !valid_product_code(product_code) { - return Err(ProbeError::Unknown); + let packed = packed_code(product_code).ok_or(ProbeError::Unknown)?; + let sid = current_user_sid().map_err(ProbeError::Unavailable)?; + let keys = [ + (1, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Managed\{sid}\Installer\Products\{packed}")), + (2, HKEY_CURRENT_USER, format!(r"Software\Microsoft\Installer\Products\{packed}")), + (4, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Classes\Installer\Products\{packed}")), + ]; + let mut contexts = Vec::new(); + for (context, hive, path) in keys { + match RegKey::predef(hive).open_subkey_with_flags(&path, KEY_READ | KEY_WOW64_64KEY) { + Ok(_) => contexts.push(context), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + // A key we may not read is a lookup that didn't run, not a product that isn't there. + Err(e) => return Err(ProbeError::Unavailable(format!("couldn't read the Windows Installer registration ({e})"))), + } + } + scope_of(&contexts) +} + +/// This account's SID, which names its managed (policy-installed) registrations. +#[cfg(windows)] +fn current_user_sid() -> Result { + use winsafe::{HACCESSTOKEN, TokenInfo, co}; + let failed = |e: winsafe::co::ERROR| format!("couldn't identify this account ({e})"); + match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(co::TOKEN_INFORMATION_CLASS::User).map_err(failed)? { + TokenInfo::User(user) => user.User.Sid().map(|sid| sid.to_string()).ok_or_else(|| "couldn't identify this account".into()), + _ => Err("couldn't identify this account".into()), } - let root = std::env::var_os("SystemRoot") - .map(PathBuf::from) - .filter(|root| root.is_absolute()) - .ok_or_else(|| ProbeError::Unavailable("SystemRoot is not set to an absolute path".into()))?; - let powershell = root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"); - // Only a validated GUID is interpolated. ProductsEx reads registration for the current - // account and the machine, never another account and never Win32_Product (which repairs). - let script = format!("$code='{product_code}';\n{}", include_str!("msi_context.ps1")); - let mut command = crate::process::command(powershell); - command.args(["-NoProfile", "-NonInteractive", "-Command", &script]); - let output = crate::process::run_command(command, "Windows installation lookup", Duration::from_secs(10)).map_err(ProbeError::Unavailable)?; - parse_contexts(&output) } #[cfg(test)] @@ -118,23 +153,53 @@ mod tests { #[test] fn authoritative_machine_context_is_preserved() { - assert_eq!(parse_contexts("4\r\n"), Ok(MsiScope::PerMachine)); + assert_eq!(scope_of(&[4]), Ok(MsiScope::PerMachine)); } #[test] fn managed_and_unmanaged_accounts_are_both_per_user() { - assert_eq!(parse_contexts("1\n"), Ok(MsiScope::PerUser)); - assert_eq!(parse_contexts("2\n"), Ok(MsiScope::PerUser)); - assert_eq!(parse_contexts("1\n2\n"), Ok(MsiScope::PerUser)); + assert_eq!(scope_of(&[1]), Ok(MsiScope::PerUser)); + assert_eq!(scope_of(&[2]), Ok(MsiScope::PerUser)); + assert_eq!(scope_of(&[1, 2]), Ok(MsiScope::PerUser)); } #[test] fn missing_unknown_or_conflicting_contexts_are_not_guessed() { - for output in ["", " \r\n", "0", "7", "user", "2\n4", "4\n1", "4\nnoise"] { - assert!(parse_contexts(output).is_err(), "{output:?}"); + for contexts in [&[][..], &[0], &[7], &[2, 4], &[4, 1], &[4, 3]] { + assert!(scope_of(contexts).is_err(), "{contexts:?}"); } - assert_eq!(parse_contexts("4\n1"), Err(ProbeError::Conflicting)); - assert_eq!(parse_contexts("7"), Err(ProbeError::Unknown)); + assert_eq!(scope_of(&[4, 1]), Err(ProbeError::Conflicting)); + assert_eq!(scope_of(&[7]), Err(ProbeError::Unknown)); + } + + #[test] + fn a_product_code_packs_the_way_windows_installer_names_its_keys() { + assert_eq!(packed_code("{12345678-ABCD-EF01-2345-6789ABCDEF01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10")); + assert_eq!(packed_code("{12345678-abcd-ef01-2345-6789abcdef01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10")); + for code in ["", "12345678-ABCD-EF01-2345-6789ABCDEF01", "{12345678-ABCD-EF01-2345-6789ABCDEF0Z}", r"{1234567\-ABCD-EF01-2345-6789ABCDEF01}"] + { + assert_eq!(packed_code(code), None, "{code:?}"); + } + } + + /// A product nobody installed is registered nowhere: the lookup runs and finds no context. + #[cfg(windows)] + #[test] + fn a_product_that_is_not_installed_has_no_context() { + assert_eq!(probe("{00000000-1111-2222-3333-444444444444}"), Err(ProbeError::Unknown)); + } + + #[cfg(windows)] + #[test] + fn this_accounts_sid_is_found() { + // Local and domain accounts are S-1-5-..., Microsoft Entra ID accounts S-1-12-1-...: only the + // shape is the same, `S-1-` and then numbers. + let sid = current_user_sid().unwrap(); + let parts: Vec<&str> = sid.split('-').collect(); + let numbers = parts.get(1..).unwrap_or_default(); + assert!(parts.first() == Some(&"S") && numbers.len() >= 3, "{sid}"); + assert!(numbers.iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())), "{sid}"); + assert_eq!(numbers.first(), Some(&"1"), "{sid}"); } #[test] @@ -168,7 +233,7 @@ mod tests { } #[test] - fn malformed_product_codes_stop_before_any_shell_call() { + fn malformed_product_codes_stop_before_any_lookup() { for code in ["", "not-a-guid", "{12345678-1234-1234-1234-123456789ABZ}", "';exit 0;#", "{12345678-1234-1234-1234-123456789ABC}\n"] { let called = std::cell::Cell::new(false); assert!( @@ -200,7 +265,7 @@ mod tests { let result = update_scope( true, Some(CODE), - |_| Err(ProbeError::Unavailable("PowerShell is blocked".into())), + |_| Err(ProbeError::Unavailable("the registry could not be read".into())), |code| { *seen.borrow_mut() = code.to_owned(); Some(scope) diff --git a/docs/incident-2026-10-windows-antivirus.md b/docs/incident-2026-10-windows-antivirus.md index a96f9f69..dfcd212d 100644 --- a/docs/incident-2026-10-windows-antivirus.md +++ b/docs/incident-2026-10-windows-antivirus.md @@ -83,6 +83,9 @@ the route. - v0.11.0 was revoked, and its release page points users to v0.10.0. v0.10.0 also contains this route, so its users stay exposed until a release with #1077 ships. - #1076, an alternative that also removed the downgrade route, was closed in favour of #1077. +- #1082: `msi_context.rs` ran a hidden, non-elevated PowerShell lookup on MSI updates. It now + reads Windows Installer's own registration keys for this account and the machine instead, so no + update starts PowerShell. ## Still open @@ -93,9 +96,6 @@ the route. - **Confirmation:** test on a Windows VM with Kaspersky and behaviour detection on. Run Update all with two or more outdated all-users apps on an affected build, then on a build with #1077. The test only counts if the affected build reproduces the flag. -- **Remaining lookup:** `msi_context.rs` runs a hidden, non-elevated PowerShell lookup on MSI - updates. It runs for single-app updates too, which #1027 says are fine, so it was left in place. - If flags continue, look there next. - **Affected users:** the revert does not repair installs the antivirus has already damaged. The "isn't where it was installed" errors are tracked in #993. From 651e3929600afa3523df18ddbd31e2fed4a03c3a Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:50 -0200 Subject: [PATCH 30/43] fix(ui): the release notes card grows with its zoom (#594) (#1062) * fix(ui): the release notes card grows with its zoom (#594) The card's size limit now scales with notes_zoom, and sheet::size still holds it inside the window. Below 100% it keeps the usual size. * test(ui): the notes card's height follows the zoom too (#594) * test(ui): the notes card stays in the smallest window, and keeps its size below 100% (#594) --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/tests/ui.rs | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e5ca4af0..4bfc098b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -115,7 +115,7 @@ impl LauncherUi { let Some(entry) = self.entry(id).cloned() else { return true }; let Some(release) = entry.release().cloned() else { return true }; let t = Tokens::get(ui.ctx()); - let size = crate::sheet::size(ui.ctx(), vec2(880.0, 760.0)); + let size = crate::sheet::size(ui.ctx(), vec2(880.0, 760.0) * self.notes_zoom.max(1.0)); ui.set_width(size.x); ui.set_max_height(size.y); let date = release.published_at.map(|d| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default(); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 6a77d84c..7d658fc0 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -2784,6 +2784,78 @@ fn changelog_shortcuts_zoom_only_the_notes_text() { assert!(h.ctx.zoom_factor() > global, "main-UI keyboard zoom should still work after closing notes"); } +#[test] +/// The notes card grows with the zoomed text, stops at the window, and comes back on reset (#594). +fn the_notes_card_follows_the_zoom_within_the_window() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(4); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(2); + h.get_by_label("Release notes").click(); + h.run_steps(4); + let span = |h: &Harness<'_, Option>| h.get_by_label("Close").rect().right() - h.get_by_label("Open on GitHub").rect().left(); + let usual = span(&h); + for _ in 0..3 { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus); + h.run_steps(3); + } + assert!(span(&h) > usual * 1.2, "the card kept its width: {usual} -> {}", span(&h)); + for _ in 0..30 { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus); + h.run_steps(3); + } + let in_window = |h: &Harness<'_, Option>| { + let window = h.ctx.content_rect(); + for label in ["Close", "Open on GitHub"] { + assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} left {window:?}"); + } + }; + in_window(&h); + // The launcher's smallest window. + let full = h.ctx.content_rect().size(); + h.set_size(egui::vec2(720.0, 500.0)); + h.run_steps(3); + in_window(&h); + h.set_size(full); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Num0); + h.run_steps(3); + assert!((span(&h) - usual).abs() < 0.5, "reset left the card at {}, was {usual}", span(&h)); + // Below 100% only the text shrinks. + for _ in 0..3 { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Minus); + h.run_steps(3); + } + assert!((span(&h) - usual).abs() < 0.5, "zooming out left the card at {}, was {usual}", span(&h)); +} + +#[test] +/// Notes too long to fit take the zoom's extra height too (#594). +fn long_notes_get_a_taller_card_when_zoomed() { + let notes: String = (0..200).map(|n| format!("Line {n}\n\n")).collect(); + let mut ev = events(); + for e in &mut ev { + if let Event::Latest { latest: LatestRelease::Found(r), .. } = e { + r.notes.clone_from(¬es); + } + } + let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); + h.run_steps(4); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(2); + h.get_by_label("Release notes").click(); + h.run_steps(4); + let bottom = |h: &Harness<'_, Option>| h.get_by_label("Close").rect().bottom(); + let usual = bottom(&h); + for _ in 0..3 { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus); + h.run_steps(3); + } + assert!(bottom(&h) > usual + 60.0, "the card kept its height: {usual} -> {}", bottom(&h)); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Num0); + h.run_steps(3); + assert!((bottom(&h) - usual).abs() < 0.5, "reset left the card at {}, was {usual}", bottom(&h)); +} + #[test] fn command_m_minimizes_the_mac_window_only() { use artcraft_launcher_ui_egui::TitleBar; From 45e3cf9e721a5f27cf486f6d71083ae4b6a2b532 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:57 -0200 Subject: [PATCH 31/43] fix(ui): a press on the window edge only resizes (#571) (#1055) * fix(ui): a press on the window edge only resizes (#571) egui gives a press with no direct hit to the closest widget within 5 px, so a press in the resize band landed on the title-bar control below it, and a quick drag clicked it. Put a click-sensing Area over each band, above dialogs too. * fix(ui): cover a grown window's edge from its first frame (#571) An Area's own hit rect keeps last frame's size, so a press on the new part of an edge still reached Close. A widget sized from this frame's rect closes that gap. * fix(ui): an edge press on the first restored frame only resizes (#571) That frame has no edge areas yet and egui hit-tests last frame's widgets, so the press lands on a full-height caption button. window_buttons now drops a pointer click inside the resize band. Key presses carry no pointer position, so they still work. * test(ui): press either side of the band's edge on the first restored frame (#571) --- crates/ui-egui/src/widgets.rs | 20 ++++++- crates/ui-egui/tests/ui.rs | 104 ++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5442b638..fdedd686 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -173,14 +173,17 @@ enum WindowButton { pub fn window_buttons(ui: &mut Ui, height: f32) { let ctx = ui.ctx().clone(); let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); - if window_button(ui, WindowButton::Close, height).clicked() { + // The first frame after a restore hit-tests the maximized buttons, so an edge press can land on them. + let inside = ctx.content_rect().shrink(resize_band(&ctx)); + let clicked = |resp: Response| resp.clicked() && resp.interact_pointer_pos().is_none_or(|p| inside.contains(p)); + if clicked(window_button(ui, WindowButton::Close, height)) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } let size = if maximized { WindowButton::Restore } else { WindowButton::Maximize }; - if window_button(ui, size, height).clicked() { + if clicked(window_button(ui, size, height)) { ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); } - if window_button(ui, WindowButton::Minimize, height).clicked() { + if clicked(window_button(ui, WindowButton::Minimize, height)) { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); } rule(ui, height); @@ -255,6 +258,17 @@ pub fn window_edges(ctx: &egui::Context) { t.hairline(), egui::StrokeKind::Inside, ); + // Above every layer, so egui's click tolerance can't hand a resize's press to what's nearby (#571). + let e = RESIZE_EDGE; + let bands = [r.with_max_y(r.top() + e), r.with_min_y(r.bottom() - e), r.with_max_x(r.left() + e), r.with_min_x(r.right() - e)]; + for (edge, rect) in bands.into_iter().enumerate() { + egui::Area::new(egui::Id::unique(("window-edge", edge))) + .order(egui::Order::Tooltip) + .movable(false) + .constrain(false) + .fixed_pos(rect.min) + .show(ctx, |ui| ui.allocate_response(rect.size(), egui::Sense::CLICK)); + } let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else { return }; let near = |d: f32| d < RESIZE_EDGE; // A corner takes a little of each edge next to it. diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 7d658fc0..ade06694 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -2135,6 +2135,110 @@ fn maximized_the_close_button_reaches_the_screens_corner() { assert!(asked.iter().any(|c| matches!(c, egui::ViewportCommand::BeginResize(_))), "{asked:?}"); } +/// A quick drag on an edge only resizes: egui sees it as one spot, so nothing nearby may click (#571). +#[test] +fn a_press_on_the_window_edge_only_resizes() { + use egui::ViewportCommand as Asked; + struct FramelessHost; + impl Host for FramelessHost { + fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar { + artcraft_launcher_ui_egui::TitleBar::Custom + } + } + artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN); + let mut backend = Some(Fake { + events: events(), + sent: Rc::new(RefCell::new(Vec::new())), + manage: true, + pictures: Rc::default(), + asked: Vec::new(), + clock: Rc::new(Cell::new(NOW)), + }); + let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state( + move |ui, state: &mut Option| { + if state.is_none() + && let Some(b) = backend.take() + { + *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); + } + if let Some(l) = state.as_mut() { + l.logic(&ui.ctx().clone(), &mut FramelessHost); + // The harness keeps its app 8 px in; a real header starts at the window's top. + let window = ui.ctx().content_rect(); + ui.scope_builder(egui::UiBuilder::new().max_rect(window), |ui| l.ui(ui, &mut FramelessHost)); + } + }, + None, + ); + h.run_steps(6); + let press = |h: &mut Harness<'_, Option>, pos: egui::Pos2| -> Vec { + let mut asked = Vec::new(); + for pressed in [true, false] { + let input = h.input_mut(); + input.events.push(egui::Event::PointerMoved(pos)); + input.events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + asked.extend(h.output().viewport_output.values().flat_map(|v| v.commands.iter().cloned())); + } + h.run_steps(2); + asked + }; + let settings_open = + |h: &Harness<'_, Option>| h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "Launcher version").is_some(); + for label in ["Minimize", "Maximize", "Close", "Settings"] { + let x = h.get_by_label(label).rect().center().x; + let asked = press(&mut h, egui::pos2(x, 2.0)); + assert!(asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{label}: {asked:?}"); + assert!(!asked.iter().any(|c| matches!(c, Asked::Minimized(_) | Asked::Maximized(_) | Asked::Close)), "{label}: {asked:?}"); + } + assert!(!settings_open(&h), "the gear was pressed through the edge"); + // The first frame at a new size already covers the new edge, or Close slips out from under it. + h.set_size(egui::vec2(1300.0, 1200.0)); + h.step(); + let x = h.get_by_label("Close").rect().center().x; + let asked = press(&mut h, egui::pos2(x, 2.0)); + assert!(!asked.iter().any(|c| matches!(c, Asked::Close)), "after growing: {asked:?}"); + // The first frame after a restore has no edge areas to hit yet, only last frame's full buttons. + let press_on_restore = |h: &mut Harness<'_, Option>, label: &str, y: f32| -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); + h.run_steps(3); + let x = h.get_by_label(label).rect().center().x; + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(false); + press(h, egui::pos2(x, y)) + }; + for label in ["Minimize", "Restore", "Close"] { + let asked = press_on_restore(&mut h, label, 4.5); + assert!(!asked.iter().any(|c| matches!(c, Asked::Minimized(_) | Asked::Maximized(_) | Asked::Close)), "{label} after restoring: {asked:?}"); + } + // Just below the band, that frame's click is the button's. + let asked = press_on_restore(&mut h, "Minimize", 5.5); + assert!(asked.contains(&Asked::Minimized(true)), "below the band after restoring: {asked:?}"); + // A key press still works with the pointer resting on the band, and so does a click below it. + let x = h.get_by_label("Minimize").rect().center().x; + press(&mut h, egui::pos2(x, 4.5)); + h.get_by_label("Minimize").focus(); + h.run_steps(2); + h.key_down(egui::Key::Enter); + h.step(); + let asked: Vec<_> = h.output().viewport_output.values().flat_map(|v| v.commands.iter().cloned()).collect(); + assert!(asked.contains(&Asked::Minimized(true)), "by key: {asked:?}"); + h.key_up(egui::Key::Enter); + h.step(); + for (label, want) in [("Minimize", Asked::Minimized(true)), ("Maximize", Asked::Maximized(true)), ("Close", Asked::Close)] { + let x = h.get_by_label(label).rect().center().x; + let asked = press(&mut h, egui::pos2(x, 5.5)); + assert!(asked.contains(&want), "{label} below the band: {asked:?}"); + assert!(!asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{label} below the band: {asked:?}"); + } + // A dialog's backdrop reaches the edges too; the press doesn't dismiss it. + h.get_by_label("Settings").click(); + h.run_steps(4); + assert!(settings_open(&h)); + let asked = press(&mut h, egui::pos2(590.0, 1198.0)); + assert!(asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{asked:?}"); + assert!(settings_open(&h), "the dialog closed on a resize"); +} + #[test] /// Clicking a card opens its details drawer with the page links, wrapping picture arrows and /// Esc-to-close. From d90d2e1c6ca133c4040d00c346bc58bb08a04458 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:43:03 -0200 Subject: [PATCH 32/43] fix(core): tighten tag, file name and notice link checks (#776, #782) (#1024) * fix(core): tighten tag, file name and notice link checks (#776, #782) - Tags from GitHub, the feed and the latest-release redirect must be up to 64 of [A-Za-z0-9._+-], not starting with a dot (release::valid_tag). A feed tag like `v0.6.0~beta-` plus a bidi override still read as 0.6.0-beta and was served; such a release now falls back to GitHub. - Asset file names use the same allowlist, with no trailing dot and no Windows device names (CON, nul.txt, COM1.msi). - Notice links go through safe_web_link, so plain http and user-info links are dropped, as in release notes. - The 20-notice cap now counts the rename notices too. * fix(core): treat only COM1-COM9 and LPT1-LPT9 as Windows device names Microsoft's naming rules reserve COM1 to COM9 and LPT1 to LPT9 (plus the superscript forms, which the ASCII allowlist already refuses), not COM0 or LPT0. Match them with a slice pattern instead of indexing. * test(core): cover the hostile tags, names and links from review (#776, #782) Adds a NUL in a tag, a file: notice link, 1,000 announcements, and direct checks that a download link and a redirect use the same rules. * fix(core): keep rename notices inside the notice cap (#782) Twenty announcements left no room, so the cap cut the rename added after them. * docs(core): notice links are https only now (#776) --- crates/core/src/feed.rs | 56 +++++++++++++++++++------ crates/core/src/release.rs | 84 ++++++++++++++++++++++++++++++++------ 2 files changed, 115 insertions(+), 25 deletions(-) diff --git a/crates/core/src/feed.rs b/crates/core/src/feed.rs index 5e66acb5..0a790c5c 100644 --- a/crates/core/src/feed.rs +++ b/crates/core/src/feed.rs @@ -90,7 +90,7 @@ pub struct Notice { pub id: String, pub title: String, pub body: String, - /// Only `https://` / `http://` links survive parsing. + /// Only `https://` links survive parsing. pub link: Option, pub created_at: Option, } @@ -120,7 +120,8 @@ impl Feed { alerts: raw.important_notices.alerts.into_iter().filter_map(|n| n.into_notice("alert")).take(MAX_NOTICES).collect(), mirror: None, }; - // App renames are news too: show them with the announcements. + // App renames are news too: show them with the announcements, inside the cap. + feed.announcements.truncate(MAX_NOTICES.saturating_sub(feed.app_renames.len())); for r in &feed.app_renames { feed.announcements.push(Notice { id: format!("rename:{}:{}", r.old_name_lower, r.new_name_lower), @@ -130,6 +131,7 @@ impl Feed { created_at: r.renamed_at, }); } + feed.announcements.truncate(MAX_NOTICES); Some(feed) } @@ -183,9 +185,9 @@ impl Feed { impl FeedRelease { /// The release with only the downloads that belong to `repo` (or sit under `mirror`). `None` - /// when it doesn't hold up (no tag, or not a version). + /// when it doesn't hold up (no tag, a tag with odd characters, or not a version). fn to_release(&self, repo: &str, mirror: Option<&str>) -> Option { - let version = version::parse(&self.tag)?; + let version = version::parse(&self.tag).filter(|_| release::valid_tag(&self.tag))?; let allowed = |name: &str, url: &str| { (release::download_url_under(url, &release::download_prefix(repo)) && url == format!("{}{}/{}", release::download_prefix(repo), self.tag, name)) @@ -415,13 +417,7 @@ impl RawNotice { if self.title.trim().is_empty() && self.body.trim().is_empty() { return None; } - // The link may reach the system opener (whose WSL fallback shells out), so refuse any - // shell/cmd metacharacter or control character in it as well as whitespace (CWE-78). - let link = self.maybe_web_link.filter(|l| { - (l.starts_with("https://") || l.starts_with("http://")) - && l.len() <= 2000 - && !l.chars().any(|c| c.is_whitespace() || c.is_control() || release::is_shell_meta(c)) - }); + let link = self.maybe_web_link.filter(|l| release::safe_web_link(l)); Some(Notice { id: format!("{kind}:{}:{}", self.created_at.as_deref().unwrap_or(""), self.title.trim()), title: truncate(self.title.trim(), 200), @@ -633,7 +629,10 @@ mod tests { r#""alerts": [ {"title": "Security update", "body": "Update PdfCraft now.", "maybe_web_link": "https://getartcraft.com/security", "created_at": null}, {"title": "Bad link", "body": "x", "maybe_web_link": "javascript:alert(1)"}, - {"title": "Shell link", "body": "x", "maybe_web_link": "https://getartcraft.com/a|calc.exe"}]"#, + {"title": "Shell link", "body": "x", "maybe_web_link": "https://getartcraft.com/a|calc.exe"}, + {"title": "Plain http", "body": "x", "maybe_web_link": "http://getartcraft.com/security"}, + {"title": "User-info", "body": "x", "maybe_web_link": "https://getartcraft.com@evil.example/"}, + {"title": "File link", "body": "x", "maybe_web_link": "file:///etc/passwd"}]"#, ) .replace( "\"app_renames\": []", @@ -641,13 +640,44 @@ mod tests { "new_name_lower": "docucraft", "description": "Same app, new name.", "renamed_at": "2026-10-09T08:00:00Z"}]"#, ); let feed = Feed::parse(&with_notices).unwrap(); - assert_eq!(feed.alerts.len(), 3); + assert_eq!(feed.alerts.len(), 6); assert_eq!(feed.alerts[0].link.as_deref(), Some("https://getartcraft.com/security")); assert_eq!(feed.alerts[1].link, None, "only web links are kept"); assert_eq!(feed.alerts[2].link, None, "a link with shell metacharacters is dropped"); + assert_eq!(feed.alerts[3].link, None, "plain http is dropped"); + assert_eq!(feed.alerts[4].link, None, "a link with user-info is dropped"); + assert_eq!(feed.alerts[5].link, None, "a file link is dropped"); let titles: Vec<&str> = feed.announcements.iter().map(|n| n.title.as_str()).collect(); assert_eq!(titles, ["New: CADCraft 1.0", "PdfCraft is now DocuCraft"]); // Ids are stable across parses, so dismissals stick. assert_eq!(Feed::parse(&with_notices).unwrap().alerts[0].id, feed.alerts[0].id); } + + #[test] + fn rename_notices_count_toward_the_cap() { + let rename = |i: usize| { + format!( + r#"{{"old_name": "PdfCraft{i}", "old_name_lower": "pdfcraft{i}", "new_name": "DocuCraft{i}", "new_name_lower": "docucraft{i}", + "description": "Same app, new name.", "renamed_at": "2026-10-09T08:00:00Z"}}"# + ) + }; + for (news, renames) in [(MAX_NOTICES, 1), (1000, 1), (0, 30)] { + let many: Vec = (0..news).map(|i| format!(r#"{{"title": "News {i}", "body": "x"}}"#)).collect(); + let moved: Vec = (0..renames).map(rename).collect(); + let full = sample() + .replace("\"announcements\": []", &format!("\"announcements\": [{}]", many.join(","))) + .replace("\"app_renames\": []", &format!("\"app_renames\": [{}]", moved.join(","))); + let notices = Feed::parse(&full).unwrap().announcements; + assert_eq!(notices.len(), MAX_NOTICES, "{news} announcements, {renames} renames"); + assert!(notices.iter().any(|n| n.title == "PdfCraft0 is now DocuCraft0"), "{news} announcements: the rename was cut"); + } + } + + #[test] + fn a_feed_release_with_an_odd_tag_asks_github() { + for odd in ["v0.6.0~beta-\\u202e", "v0.6.0~beta-a/b"] { + let feed = sample().replace("\"tag\": \"v0.6.0\"", &format!("\"tag\": \"{odd}\"")); + assert_eq!(Feed::parse(&feed).unwrap().answer(app("photocraft"), NOW), FeedAnswer::AskGitHub, "{odd:?}"); + } + } } diff --git a/crates/core/src/release.rs b/crates/core/src/release.rs index f599b999..f9b30557 100644 --- a/crates/core/src/release.rs +++ b/crates/core/src/release.rs @@ -143,7 +143,7 @@ fn download_suffix_ok(rest: &str) -> bool { } let mut parts = rest.split('/'); let (Some(tag), Some(name), None) = (parts.next(), parts.next(), parts.next()) else { return false }; - !tag.is_empty() && tag != "." && tag != ".." && tag.len() <= 64 && !tag.contains('\\') && valid_file_name(name) + valid_tag(tag) && valid_file_name(name) } /// Any `.` or `..` path segment that could climb out of the repository. @@ -228,7 +228,7 @@ fn release_from_value(v: &serde_json::Value, repo: &str) -> Result Re /// The tag in a `https://github.com//releases/tag/` redirect target. pub fn tag_from_redirect(location: &str, repo: &str) -> Option { let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?; - (!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string()) + valid_tag(rest).then(|| rest.to_string()) +} + +/// A release tag we'd put in a URL, a file or the UI: up to 64 of `[A-Za-z0-9._+-]`, not +/// starting with a dot, so no separators, control or bidi characters and no `.`/`..`. +pub fn valid_tag(tag: &str) -> bool { + tag.len() <= 64 && !tag.starts_with('.') && plain_name(tag) +} + +/// Non-empty and only `[A-Za-z0-9._+-]`, the characters every Craft release tag and asset uses. +fn plain_name(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) } /// Reads `sha256sum` output: ` ` (or ` *`) per line. @@ -298,14 +309,16 @@ pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> { .collect() } -/// A bare file name we'd be willing to save: no paths, no control characters. +/// A bare file name we'd be willing to save: only `[A-Za-z0-9._+-]`, no leading or trailing dot, +/// and not a name Windows reserves for a device (`CON`, `nul.txt`, `COM1.msi`). pub fn valid_file_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 200 - && name != "." - && name != ".." - && !name.starts_with('.') - && !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*')) + name.len() <= 200 && !name.starts_with('.') && !name.ends_with('.') && plain_name(name) && !windows_device(name) +} + +fn windows_device(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || matches!(stem.as_bytes(), [b'C', b'O', b'M', b'1'..=b'9'] | [b'L', b'P', b'T', b'1'..=b'9']) } /// `s` cut to at most `max` bytes (on a char boundary) with an ellipsis, for text from a @@ -524,9 +537,56 @@ mod tests { #[test] fn file_names_cannot_escape() { - for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] { + for bad in [ + "", + "..", + "a/b", + "a\\b", + ".hidden", + "c:x", + "x\u{0}", + "a b.dmg", + "a%2e.msi", + "a#b", + "evil\u{202e}gpj.exe", + "x.msi.", + "CON", + "nul.txt", + "COM1.msi", + "lpt9", + "prn", + "aux.log", + "a".repeat(201).as_str(), + ] { assert!(!valid_file_name(bad), "{bad:?}"); } - assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage")); + for good in [ + "photocraft-0.5.0-linux-x86_64.AppImage", + "lightcraft_0.9.0_amd64.deb", + "x-1.0+git.tar.gz", + "console.zip", + "COM10.txt", + "COM0.msi", + "lpt0", + ] { + assert!(valid_file_name(good), "{good:?}"); + } + } + + #[test] + fn tags_are_plain() { + for good in ["v0.6.0", "v1.0.0-beta.2", "nightly-2026-10-29", "v1+build.7"] { + assert!(valid_tag(good), "{good:?}"); + } + for bad in ["", ".", "..", ".v1", "v1/x", "v1\\x", "v1\nx", "v1\u{0}x", "v1\u{202e}", "v 1", "v1?x", "v".repeat(65).as_str()] { + assert!(!valid_tag(bad), "{bad:?}"); + } + let api = r#"{"tag_name": "v1\u202egpj", "html_url": "", "assets": []}"#; + assert!(parse_github_release(api, REPO).is_err()); + let prefix = download_prefix(REPO); + assert!(download_url_under(&format!("{prefix}v1/a.dmg"), &prefix)); + assert!(!download_url_under(&format!("{prefix}v1\u{202e}/a.dmg"), &prefix), "an odd tag in a download link"); + assert!(!download_url_under(&format!("{prefix}v1/a\u{202e}.dmg"), &prefix), "an odd name in a download link"); + assert_eq!(tag_from_redirect(&format!("https://github.com/{REPO}/releases/tag/v1\u{202e}"), REPO), None); } } From 50ea99324d4dbc8924dbd4ba9a464d9bd728186a Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:43:10 -0200 Subject: [PATCH 33/43] fix(ui): keep long Retry/Update labels from pushing the menu out of the row (#956) (#1012) * fix(ui): keep a long Retry update button from pushing the menu out of the row (#956) The table gives the main button about 160 px, but buttons grow to fit their label, so "Retry update to 0.12.0" (205 px) pushed the menu out of the actions column. When the full label doesn't fit, the button now says "Retry update"; the version column already shows the version. "Update to {v}" gets the same fallback to "Update". Adds "Retry update" to the ten complete catalogs. * fix(ui): fall back to the plain action when a short retry label still doesn't fit Running the launcher in German showed "Update wiederholen" (173 px) is itself wider than the table's 162 px slot, so the menu was still pushed out. Retry and update buttons now try their labels longest first and end on the plain action ("Update", "Install"), which fits in every catalog. The German test checks both retry rows against an Install button. * test(ui): check which Retry label each view shows (#956) The slot test matched "Retry update" by substring, so it passed whether the button kept its version or dropped it. Now it names the label. The table's narrow slot shows "Retry update", while a card with room for it keeps the full "Retry update to 0.12.0", which is what a wide enough slot should do. The doc of `fitted` also names the card's top strip, which shows the version there. * test(ui): main buttons fit the narrowest window in every language (#956) --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-BR.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/zh-CN.tsv | 1 + crates/ui-egui/src/views.rs | 32 +++++++++++++----- crates/ui-egui/tests/german.rs | 55 ++++++++++++++++++++++++++++--- crates/ui-egui/tests/ui.rs | 33 ++++++++++++++++--- 13 files changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f5a3a2c..f91a311a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -134,6 +134,7 @@ Update cancelled Aktualizace zrušena Uninstall cancelled Odinstalace zrušena Retry install Zkusit instalaci znovu + Retry update Zkusit aktualizaci znovu Retry update to {version} Zkusit aktualizaci na {version} znovu The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje. Waiting for another app… Čekání na jinou aplikaci… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5846b6a4..668c8258 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -148,6 +148,7 @@ Update cancelled Update abgebrochen Uninstall cancelled Deinstallation abgebrochen Retry install Installation wiederholen + Retry update Update wiederholen Retry update to {version} Update auf {version} wiederholen The download is still here. It's checked again, then installed. Der Download ist noch da. Er wird erneut geprüft und dann installiert. Waiting for another app… Warten auf eine andere App… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2fae1366..524d1a2b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -147,6 +147,7 @@ Update cancelled Actualización cancelada Uninstall cancelled Desinstalación cancelada Retry install Reintentar la instalación + Retry update Reintentar la actualización Retry update to {version} Reintentar la actualización a {version} The download is still here. It's checked again, then installed. La descarga sigue aquí. Se vuelve a comprobar y luego se instala. Waiting for another app… Esperando a otra aplicación… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d48871b3..80086d87 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -146,6 +146,7 @@ Update cancelled Mise à jour annulée Uninstall cancelled Désinstallation annulée Retry install Réessayer l'installation + Retry update Réessayer la mise à jour Retry update to {version} Réessayer la mise à jour vers {version} The download is still here. It's checked again, then installed. Le téléchargement est toujours là. Il sera vérifié à nouveau, puis installé. Waiting for another app… En attente d'une autre application… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fc8a746f..2c20fd00 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -148,6 +148,7 @@ Update cancelled Frissítés megszakítva Uninstall cancelled Eltávolítás megszakítva Retry install Telepítés újrapróbálása + Retry update Frissítés újrapróbálása Retry update to {version} Frissítés újrapróbálása: {version} The download is still here. It's checked again, then installed. A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük. Waiting for another app… Várakozás egy másik alkalmazásra… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c34c833..9205e44f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -150,6 +150,7 @@ 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/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d604e27b..880aa123 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -149,6 +149,7 @@ Update cancelled Anulowano aktualizację Uninstall cancelled Anulowano odinstalowanie Retry install Ponów instalację + Retry update Ponów aktualizację Retry update to {version} Ponów aktualizację do {version} The download is still here. It's checked again, then installed. Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany. Waiting for another app… Oczekiwanie na inną aplikację… diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 5de6d3d3..202b3de0 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -136,6 +136,7 @@ Update cancelled Atualização cancelada Uninstall cancelled Desinstalação cancelada Retry install Tentar instalar novamente + Retry update Tentar atualizar novamente Retry update to {version} Tentar atualizar novamente para {version} The download is still here. It's checked again, then installed. O arquivo baixado ainda está aqui. Ele será verificado novamente e instalado. Waiting for another app… Aguardando outro aplicativo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e0cdebe5..c4294a2e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -146,6 +146,7 @@ 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/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index 43096028..ad23924a 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -283,6 +283,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/src/views.rs b/crates/ui-egui/src/views.rs index ef6bf13d..ccdb34a2 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -39,6 +39,17 @@ fn ago_text(secs: i64, now: i64) -> String { } } +/// `long`, or the first of `shorter` whose button fits `width` (the last one if none does): a +/// wider button would push the `⋯` menu beside it out of the row (#956). The table's version +/// column and the card's top strip show the version, so the shorter labels can leave it out. +fn fitted<'a>(ui: &Ui, long: &'a str, shorter: &[&'a str], width: f32) -> &'a str { + std::iter::once(long) + .chain(shorter.iter().copied()) + .find(|label| widgets::button_width(ui, label, 0.0) <= width) + .or_else(|| shorter.last().copied()) + .unwrap_or(long) +} + /// Translate an absolute date ("Oct 8, 2026" → "8 окт. 2026") into the current language, on this /// computer's calendar (`local_time`). Every date the launcher shows is written by this. pub(crate) fn date_text(secs: i64) -> String { @@ -50,8 +61,9 @@ pub(crate) fn date_text(secs: i64) -> String { /// What the main button of an app does. enum Primary { Install, - /// The download is already here; only the install step is left. Carries the button's label. - Retry(String), + /// The download is already here; only the install step is left. Carries the button's label + /// and shorter ones for when it doesn't fit. + Retry(String, Vec<&'static str>), Update(String), /// A pinned release only installs after the backend has checked that exact tag. SelectedVersion { @@ -837,10 +849,11 @@ impl LauncherUi { return Primary::SelectedVersion { tag: tag.clone(), checked }; } match status { - AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into()), - AppStatus::UpdateAvailable if entry.ready => { - Primary::Retry(fmt(tl!("Retry update to {version}"), &[("version", &entry.latest_label().unwrap_or_default())])) - } + AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into(), vec![tl!("Install")]), + AppStatus::UpdateAvailable if entry.ready => Primary::Retry( + fmt(tl!("Retry update to {version}"), &[("version", &entry.latest_label().unwrap_or_default())]), + vec![tl!("Retry update"), tl!("Update")], + ), AppStatus::Available => Primary::Install, AppStatus::UpdateAvailable => Primary::Update(entry.latest_label().unwrap_or_default()), AppStatus::UpToDate | AppStatus::Installed => Primary::Open, @@ -971,14 +984,15 @@ impl LauncherUi { self.install_app(id); } } - Primary::Retry(label) => { + Primary::Retry(label, shorter) => { let tip = tl!("The download is still here. It's checked again, then installed."); - if widgets::button(ui, &label, Kind::Primary, main_w).on_hover_text(tip).clicked() { + if widgets::button(ui, fitted(ui, &label, &shorter, main_w), Kind::Primary, main_w).on_hover_text(tip).clicked() { self.install_app(id); } } Primary::Update(v) => { - if widgets::button(ui, &fmt(tl!("Update to {v}"), &[("v", &v)]), Kind::Primary, main_w).clicked() { + let label = fmt(tl!("Update to {v}"), &[("v", &v)]); + if widgets::button(ui, fitted(ui, &label, &[tl!("Update")], main_w), Kind::Primary, main_w).clicked() { self.install_app(id); } } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index f4393e97..846b0a3f 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -109,11 +109,14 @@ fn harness_language( theme: egui::Theme, manage: bool, language: &str, + extra: Vec, ) -> (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 mut events = events(); + events.extend(extra); + let mut backend = Some(Fake { 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| { @@ -147,7 +150,7 @@ fn snapshot(h: &mut Harness<'_, Option>, name: &str) { // One test: the UI language is process-wide. #[test] fn german_switches_persists_and_keeps_install_actions() { - let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en"); + let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en", Vec::new()); h.run_steps(6); h.get_by_label("Settings").click(); h.run_steps(3); @@ -176,12 +179,27 @@ fn german_switches_persists_and_keeps_install_actions() { 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); + let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language, Vec::new()); table.run_steps(6); table.get_by_label("Datum"); assert!(table.query_all_by_label_contains(". Okt. 2026").count() >= 1); snapshot(&mut table, "table-de-light"); - let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language); + // Kept downloads: German retry labels are long, but a button never outgrows its slot (#956). + let kept = ["photocraft", "vectorcraft"].map(|app| Event::ReadyToInstall { app: app.into(), ready: true }); + let (mut retry, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language, kept.into()); + retry.run_steps(6); + let slot = retry.query_all_by_label("Installieren").next().unwrap().rect().width(); + for app in ["PhotoCraft", "VectorCraft"] { + let menu = retry.get_by_label(&format!("Weitere Aktionen für {app}")).rect(); + // The main button is the one ending just left of the menu (or overlapping it). + let main = retry + .query_all_by_role(egui::accesskit::Role::Button) + .filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left() && n.rect().right() > menu.left() - 20.0) + .find(|n| n.rect().height() < 40.0) + .unwrap(); + assert!(main.rect().width() <= slot + 0.5, "{app}: {:?} wider than Installieren ({slot})", main.rect()); + } + let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language, Vec::new()); web.run_steps(6); assert!(web.query_all_by_label("Für macOS herunterladen").count() >= 3); for width in [720.0, 800.0, 1180.0] { @@ -195,4 +213,33 @@ fn german_switches_persists_and_keeps_install_actions() { } } } + for language in &artcraft_launcher_ui_egui::i18n::LANGUAGES { + main_buttons_fit_the_narrowest_window(language.code); + } +} + +/// At the launcher's narrowest, no main button outgrows its slot or crowds out the `⋯` menu (#956): +/// a kept update, a kept install and a plain update, in both views, whichever label `language` has. +fn main_buttons_fit_the_narrowest_window(language: &str) { + use artcraft_launcher_ui_egui::i18n::{fmt, t}; + for view in [ViewMode::Table, ViewMode::Grid] { + let kept = ["photocraft", "vectorcraft"].map(|app| Event::ReadyToInstall { app: app.into(), ready: true }); + let (mut h, _) = harness_language(view, egui::Theme::Dark, true, language, kept.into()); + h.set_size(egui::vec2(720.0, 1600.0)); + h.run_steps(8); + let slot = h.query_all_by_label(t("Install")).next().unwrap().rect().width(); + for app in ["PhotoCraft", "VectorCraft", "EffectCraft"] { + let menu = h.get_by_label(&fmt(t("More actions for {name}"), &[("name", app)])).rect(); + let main = h + .query_all_by_role(egui::accesskit::Role::Button) + .filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left()) + .max_by(|a, b| a.rect().right().total_cmp(&b.rect().right())) + .unwrap() + .rect(); + assert!(main.width() <= slot + 0.5, "{language} {view:?} {app}: {main:?} wider than Install ({slot})"); + assert!(main.right() <= menu.left(), "{language} {view:?} {app}: {main:?} overlaps the menu {menu:?}"); + // The table scrolls sideways, with a fixed slot; cards have to fit the window. + assert!(view == ViewMode::Table || menu.right() <= 720.0, "{language} {view:?} {app}: the menu left the window: {menu:?}"); + } + } } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index ade06694..d316a6cf 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1282,9 +1282,9 @@ fn a_dismissed_install_prompt_is_not_called_a_cancelled_download() { } #[test] -/// A ready update names its version and installs on Retry, while an up-to-date app offers -/// Reinstall instead of Download again. -fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again() { +/// A ready update installs on Retry (the table's narrow button leaves the version to its column), +/// while an up-to-date app offers Reinstall instead of Download again. +fn a_kept_update_installs_on_retry_and_an_up_to_date_app_is_not_downloaded_again() { let mut ev = events(); for app in ["effectcraft", "wordcraft"] { ev.push(Event::ReadyToInstall { app: app.into(), ready: true }); @@ -1294,7 +1294,7 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again snapshot(&mut h, "table-retry-update-dark"); sent.borrow_mut().clear(); assert!(h.query_by_label("Update to 0.6.0").is_none()); - h.get_by_label("Retry update to 0.6.0").click(); + h.get_by_label("Retry update").click(); h.run_steps(2); assert_eq!(*sent.borrow(), [Command::Install { app: "effectcraft".into() }]); // WordCraft is up to date: its menu offers Reinstall, which uses the kept file. @@ -1306,6 +1306,31 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again assert!(h.query_by_label("Download again").is_none()); } +#[test] +/// A Retry update button too wide for its slot drops the version instead of pushing the menu out +/// of the row (#956): it is no wider than an Install button in the same view. A card with room +/// for the version keeps it. +fn a_retry_update_button_keeps_to_its_slot_and_leaves_room_for_the_menu() { + for view in [ViewMode::Table, ViewMode::Grid] { + let mut ev = events(); + // Two-digit minor, as in the report: "Retry update to 0.12.0". + ev.push(Event::Latest { + app: "effectcraft".into(), + latest: LatestRelease::Found(release("effectcraft", "0.12.0", NOW)), + from: Origin::ArtCraftApi, + }); + ev.push(Event::ReadyToInstall { app: "effectcraft".into(), ready: true }); + let (mut h, _) = harness_with(ev, view, egui::Theme::Dark, true, 1.0); + h.run_steps(8); + let label = if view == ViewMode::Table { "Retry update" } else { "Retry update to 0.12.0" }; + let retry = h.get_by_label(label).rect(); + let install = h.query_all_by_label("Install").next().unwrap().rect(); + let menu = h.get_by_label("More actions for EffectCraft").rect(); + assert!(retry.width() <= install.width() + 0.5, "{view:?}: retry {retry:?} wider than install {install:?}"); + assert!(retry.right() <= menu.left(), "{view:?}: retry {retry:?} overlaps menu {menu:?}"); + } +} + #[test] fn an_install_waiting_for_another_says_so_and_can_be_cancelled() { let mut events = events(); From 293fefacdaed25d614496107238f1a549b505175 Mon Sep 17 00:00:00 2001 From: Capri <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:43:16 +0100 Subject: [PATCH 34/43] Timestamps saved to disk never wrap the schedule arithmetic (#774) (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Timestamps saved to disk never wrap the schedule arithmetic (#774) A saved feed-throttle `not_before` outside the sane range — the far-future check missed anything at or below zero — survived `load`, and `wait`'s `at - now` wrapped: near `i64::MIN` it panicked a debug build and, wrapped in release, kept the feed from ever being asked again. `load` now drops any wait outside 0..=now + 2 * MAX_THROTTLE_BACKOFF and the subtraction saturates. The update-check schedule had the same shape: a `last_check` in the future (an edited app.ron, a clock that ran fast and was later corrected) postponed checks until real time caught up, and i64::MIN panicked. The condition is now `update_check_due`, which treats a future time as due and saturates; the two rescan comparisons that share the pattern saturate too. Co-Authored-By: GLM-5.3 * Test the saved throttle state at its limits (#774) Covers the review's remaining cases: a saved `not_before` of i64::MAX is dropped while a sane one (now + 600) stands, and `failures`/`throttles` saved at u32::MAX cap the next backoff at its maximum without overflowing (`doubled` caps the doublings at 16 and the counts add saturating). Co-Authored-By: Claude Opus 5.5 * Merge schedule tests into existing test module * Preserve long Retry-After waits --------- Co-authored-by: GLM-5.3 Co-authored-by: Claude Opus 5.5 --- crates/native/src/feed_throttle.rs | 71 ++++++++++++++++++++++++++++-- crates/ui-egui/src/app.rs | 32 ++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/native/src/feed_throttle.rs b/crates/native/src/feed_throttle.rs index 6f4e60f3..48b67580 100644 --- a/crates/native/src/feed_throttle.rs +++ b/crates/native/src/feed_throttle.rs @@ -90,8 +90,11 @@ impl FeedThrottle { /// changes. A missing or malformed file just means no backoff is pending. pub fn load(file: &Path, now: i64) -> Self { let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::(&b).ok()).unwrap_or_default(); - // A wait far in the future means a broken file or a clock that jumped back: drop it. - if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 { + // A saved wait outside the sane range means a broken file, an edited value or a clock + // that jumped: drop it. A `not_before` near `i64::MIN` would otherwise wrap the + // subtraction in `wait` into a centuries-long wait (#774). The upper limit covers the + // longest accepted Retry-After stretched by the maximum 429 spread. + if !(0..=now.saturating_add(MAX_RETRY_AFTER * 3 / 2)).contains(&saved.not_before) { saved.not_before = 0; } Self { saved, file: Some(file.to_path_buf()), ..Self::new() } @@ -112,7 +115,7 @@ impl FeedThrottle { let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW); at = at.max(last + gap); } - (at - now).max(0) + at.saturating_sub(now).max(0) } /// Notes that a request is being sent now. @@ -375,4 +378,66 @@ mod tests { std::fs::write(&file, br#"{"unknown": true}"#).unwrap(); assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); } + + #[test] + fn an_absurdly_old_saved_not_before_is_no_wait() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("feed-throttle.json"); + // A hostile or corrupted `not_before` near i64::MIN once wrapped `at - now` into a + // wait of centuries (a debug-build panic, #774). + std::fs::write(&file, br#"{"not_before": -9223372036854775808, "throttles": 1}"#).unwrap(); + assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); + std::fs::write(&file, br#"{"not_before": -60, "throttles": 1}"#).unwrap(); + assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); + } + + #[test] + fn saved_not_before_at_the_maximum_retry_after_spread_survives_but_one_second_more_does_not() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("feed-throttle.json"); + let max_wait = MAX_RETRY_AFTER * 3 / 2; + std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + max_wait)).unwrap(); + assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), max_wait); + std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + max_wait + 1)).unwrap(); + assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); + } + + #[test] + fn a_maximum_retry_after_with_maximum_429_spread_survives_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("feed-throttle.json"); + let mut t = FeedThrottle::load(&file, NOW); + t.finished(Outcome::Throttled { retry_after: Some(MAX_RETRY_AFTER) }, NOW, 1.0); + assert_eq!(t.wait(NOW), MAX_RETRY_AFTER * 3 / 2); + let again = FeedThrottle::load(&file, NOW); + assert_eq!(again.wait(NOW), MAX_RETRY_AFTER * 3 / 2); + } + + #[test] + fn an_absurdly_late_saved_not_before_is_no_wait_but_a_sane_one_stands() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("feed-throttle.json"); + std::fs::write(&file, br#"{"not_before": 9223372036854775807, "throttles": 1}"#).unwrap(); + assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); + std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + 600)).unwrap(); + let t = FeedThrottle::load(&file, NOW); + assert_eq!(t.wait(NOW), 600); + assert!(t.throttled(NOW)); + } + + #[test] + fn saved_counts_at_their_maximum_cap_the_backoff_without_overflowing() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("feed-throttle.json"); + let saved = format!(r#"{{"failures": {m}, "throttles": {m}}}"#, m = u32::MAX); + std::fs::write(&file, &saved).unwrap(); + let mut t = FeedThrottle::load(&file, NOW); + t.warmed = true; + t.finished(Outcome::Failed { retry_after: None }, NOW, 0.5); + assert_eq!(t.wait(NOW), MAX_FAILURE_BACKOFF); + std::fs::write(&file, &saved).unwrap(); + let mut t = FeedThrottle::load(&file, NOW); + t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0); + assert_eq!(t.wait(NOW), MAX_THROTTLE_BACKOFF); + } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5c57ef9b..f6ee3b01 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -229,6 +229,13 @@ const RESCAN_EVERY: i64 = 5 * 60; /// Coming back to the window re-checks, but not more often than this. const RESCAN_ON_FOCUS_AFTER: i64 = 30; +/// A periodic update check is due `every` seconds after the last one. The saved time is +/// hostile (an edited `app.ron`, a clock that ran fast and was later corrected): a time in +/// the future or a corrupted `i64::MIN` never postpones the check (#774). +fn update_check_due(last: Option, now: i64, every: i64) -> bool { + last.is_none_or(|t| t > now || now.saturating_sub(t) >= every) +} + impl LauncherUi { /// Builds the UI shell around `backend`: installs the bundled fonts and an available system /// CJK face when the effective language needs it, plus image loaders; snapshots capabilities and @@ -498,15 +505,15 @@ impl LauncherUi { self.last_detect = now; } let focused = ctx.input(|i| i.viewport().focused).unwrap_or(true); - let came_back = focused && !self.was_focused && now - self.last_detect >= RESCAN_ON_FOCUS_AFTER; + let came_back = focused && !self.was_focused && now.saturating_sub(self.last_detect) >= RESCAN_ON_FOCUS_AFTER; self.was_focused = focused; - if came_back || now - self.last_detect >= RESCAN_EVERY { + if came_back || now.saturating_sub(self.last_detect) >= RESCAN_EVERY { self.last_detect = now; self.backend.send(Command::Detect); } if let Some(every) = self.settings.check_every.seconds() && !self.checking - && self.settings.last_check.is_none_or(|t| now - t >= every) + && update_check_due(self.settings.last_check, now, every) { self.backend.send(Command::CheckForUpdates { force: false }); } @@ -1342,4 +1349,23 @@ mod tests { let _guard = crate::i18n::StrictCjkDrawing::on(None); let _ = crate::i18n::tr(Lang::ZH_CN, "Settings"); } + + const NOW: i64 = 1_791_500_000; + const EVERY: i64 = 24 * 60 * 60; + + #[test] + fn a_check_is_due_after_its_interval() { + assert!(update_check_due(None, NOW, EVERY)); + assert!(!update_check_due(Some(NOW - EVERY + 1), NOW, EVERY)); + assert!(update_check_due(Some(NOW - EVERY), NOW, EVERY)); + } + + #[test] + fn a_saved_time_in_the_future_or_at_i64_min_never_postpones_a_check() { + // A clock that ran fast and was later corrected, or an edited `app.ron` (#774): the + // launcher would sit in the tray for weeks; `i64::MIN` panicked the subtraction. + assert!(update_check_due(Some(NOW + 60), NOW, EVERY)); + assert!(update_check_due(Some(i64::MAX), NOW, EVERY)); + assert!(update_check_due(Some(i64::MIN), NOW, EVERY)); + } } From e46328fe375fb0ada9cf0a33b02550c441d702ef Mon Sep 17 00:00:00 2001 From: smittie2000 Date: Tue, 3 Nov 2026 05:43:23 +0200 Subject: [PATCH 35/43] refactor(native): one PATH lookup (#784) * refactor(native): one PATH lookup for detect and process process.rs (`which_on_path`) and two places in detect.rs's Linux module each searched PATH the same way. They call `process::which` now. These all ask what the user can run, so PATH is the right place to look. install.rs is left alone: programs run as root come from the root-owned SYSTEM_BIN_DIRS, never from PATH. Co-Authored-By: Claude Opus 5.5 * Document the Linux detect, find_binary and binary_version functions Co-Authored-By: Claude Opus 5.5 * Say every case where binary_version gives up The 5-second limit matches the code. The doc also missed that the binary can fail to start or exit with an error, and that it is killed on timeout. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/native/src/detect.rs | 13 +++++++------ crates/native/src/process.rs | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 98dfaf07..2f17e1b8 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -507,11 +507,7 @@ pub mod linux { /// Where a package put the app's binary: on `PATH`, else `/usr/bin/`. fn binary_on_path(id: &str) -> String { - std::env::var_os("PATH") - .and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file())) - .unwrap_or_else(|| PathBuf::from("/usr/bin").join(id)) - .display() - .to_string() + crate::process::which(id).unwrap_or_else(|| PathBuf::from("/usr/bin").join(id)).display().to_string() } /// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention). @@ -560,6 +556,8 @@ pub mod linux { appimages_in(app, &appimage_dirs()) } + /// How the app is installed, in this order: an AppImage the launcher put down, a package + /// (deb, rpm, pacman or Flatpak), else a binary found on disk. 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 }); @@ -582,12 +580,15 @@ pub mod linux { Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) } + /// The app's binary on `PATH`, else in `/usr/bin`, `/usr/local/bin` or `~/.local/bin`. fn find_binary(id: &str) -> Option { - let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file())); + let on_path = crate::process::which(id); let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin"))); on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file())) } + /// The version `bin --version` prints, or `None` when it can't be started, doesn't finish + /// within 5 seconds (it is then killed), exits with an error, or prints no version. pub(crate) fn binary_version(bin: &Path, app: &CraftApp) -> Option { let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?; parse_cli_version(out.trim(), app) diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index 6a86933f..a8c29744 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -159,7 +159,7 @@ pub fn is_flatpak_available() -> bool { if in_sandbox() { capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some() } else { - which_on_path("flatpak").is_some() + which("flatpak").is_some() } } @@ -290,7 +290,8 @@ fn detect_discord_scheme_handler() -> bool { false } -fn which_on_path(name: &str) -> Option { +/// Finds a program on `PATH`. +pub(crate) fn which(name: &str) -> Option { let path = std::env::var_os("PATH")?; std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file()) } From afe9bf31622c38c0d95711262e58b74e25932e72 Mon Sep 17 00:00:00 2001 From: C0uki <251395721+C0uki@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:43:30 +0900 Subject: [PATCH 36/43] i18n(ja): align spacing around Latin text and fix two lines (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Put a space between Japanese and Latin words/placeholders in the install-folder messages and "Show {n} more changes", as the rest of the catalog does. - "Installing, then restarting…": drop the completed-state wording. - "On Linux, custom folders use AppImage format.": no longer identical to the line above it. Drafted with AI assistance; native-speaker review welcome. Claude-Session: https://claude.ai/code/session_01K5NRZNWfaSLBWVzV8hivyS Co-authored-by: Claude --- crates/ui-egui/src/i18n/ja.tsv | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9205e44f..0af50a21 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -62,7 +62,7 @@ Downloading… ダウンロード中… Reconnecting… 再接続中… Verifying… 検証中… - Installing, then restarting… インストール完了後再起動します… + Installing, then restarting… インストールして再起動します… Installing… インストール中… Removing… アンインストール中… Pause download ダウンロードを一時停止 @@ -367,14 +367,14 @@ filter Installed インストール済み 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を使用します。 + 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 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で削除してください。 +@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} をインストールすると上書きされます。 @@ -385,7 +385,7 @@ filter Installed インストール済み Frequently used よく使う順 Next app 次のアプリ Previous app 前のアプリ - Show {n} more changes さらに{n}件の変更を表示 + Show {n} more changes さらに {n} 件の変更を表示 Last updated: {date} 最終更新: {date} column Version バージョン column Author 作者 From dd21313655c60c5ced18397fa14e75b2e4b8058e Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:43:36 -0200 Subject: [PATCH 37/43] fix(packaging): no second menu entry when an AppImage manager already lists the launcher (#474, #394) (#1032) * fix(packaging): declare the launcher's real window class in its .desktop file (#1026) The window sets its app id (Wayland) and WM_CLASS (X11) to ai.storyteller.artcraft-launcher, but the entry said StartupWMClass=artcraft-launcher. Desktops that find the entry by its file name were fine; an entry under another name (AppImageLauncher, Gear Lever, a hand-made one) never matched, so the dock showed a generic icon and the launcher could not be pinned. * fix(packaging): no second menu entry when an AppImage manager already lists the launcher (#474, #394) appimaged, AppImageLauncher and Gear Lever write their own entry for the AppImage under another file name. AppRun added ai.storyteller.artcraft-launcher.desktop next to it on every start, so the launcher showed up twice in the app menu, and deleting the extra entry only lasted until the next launch. AppRun now installs nothing while another entry's Exec runs this AppImage, and removes the entry it wrote earlier when own_entry says it is still unchanged. own_entry also accepts entries that carry the old StartupWMClass=artcraft-launcher, since that line is the only difference from what earlier releases wrote. * fix(packaging): a hidden manager entry doesn't stand in for ours Menu editors hide an entry with NoDisplay=true or Hidden=true. When a user hid the manager's duplicate that way, AppRun still counted it and removed our entry, leaving the launcher out of the menu. Such an entry no longer counts. Test 15e covers both keys. * fix(packaging): a manager entry stands in for ours only with our window class Read only the [Desktop Entry] group, so an Exec in a [Desktop Action] group doesn't count, and require StartupWMClass=ai.storyteller.artcraft-launcher. Gear Lever doesn't refresh its entry when the launcher replaces the AppImage in place, so its entry can keep the old class, and without ours GNOME would have nothing to match the window against. --- packaging/linux/AppRun | 43 ++++++++++++++++++++++-- packaging/linux/apprun-test.sh | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index d3f6117e..36e0d211 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -8,7 +8,8 @@ # (and whenever the AppImage's path changes) install the .desktop entry and the # hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave # the launch untouched. AppImage managers can opt out with DESKTOPINTEGRATION=1, -# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1. +# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1. Nothing is installed +# while another entry (appimaged, AppImageLauncher, Gear Lever) runs this AppImage. set -u @@ -38,10 +39,17 @@ integrate() { data="${XDG_DATA_HOME:-$home/.local/share}" src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 + apps="$data/applications" + # A second entry would list the app twice; drop the one we wrote if it is unchanged. + if other_entry_runs "$apps" "$appimage"; then + if own_entry "$apps/$DESK_ID.desktop" && rm -f "$apps/$DESK_ID.desktop"; then + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 + fi + return 0 + fi # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop # TryExec, which would hide the entry; everything else is shipped as packaged. - apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 tmp="$apps/.$DESK_ID.tmp.$$" esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" @@ -97,7 +105,36 @@ own_entry() { [ "$existing_exec" = "$generated_exec" ] || return 1 expected_body="$(sed -e '/^Exec=/d' -e '/^TryExec=/d' "$src_desktop")" || return 1 existing_body="$(sed -e '/^Exec=/d' -e '/^X-ArtCraft-Launcher-Generated-Exec=/d' "$1")" || return 1 - [ "$expected_body" = "$existing_body" ] + # Entries written before the StartupWMClass fix differ only in that line. + old_body="$(printf '%s\n' "$expected_body" | sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/')" + [ "$expected_body" = "$existing_body" ] || [ "$old_body" = "$existing_body" ] +} + +# True when a visible entry in $1 other than ours runs the AppImage $2 and has our window class. +other_entry_runs() { + grep -lF -e "$2" "$1"/*.desktop 2>/dev/null | ( + while IFS= read -r f; do + [ "$f" = "$1/$DESK_ID.desktop" ] && continue + main=false runs=false class=false + while IFS= read -r line || [ -n "$line" ]; do + # Only the [Desktop Entry] group; an action's Exec isn't what the entry launches. + case "$line" in + "[Desktop Entry]") main=true && continue ;; + "["*) main=false ;; + esac + [ "$main" = true ] || continue + case "$line" in + # Menu editors hide an entry this way; it lists nothing. + Hidden=true | NoDisplay=true) runs=false && break ;; + # A class from before an in-place update can't match the window, so ours has to stay. + "StartupWMClass=$DESK_ID") class=true ;; + Exec=*"$2" | Exec=*"$2 "* | Exec=*"$2\""* | Exec=*"$2'"*) runs=true ;; + esac + done <"$f" + [ "$runs" = true ] && [ "$class" = true ] && exit 0 + done + exit 1 + ) } integrate diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 28c9890f..9eb4837b 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -181,4 +181,64 @@ ln -s "$TMP/manager-entry" "$DESKTOP" "$APPDIR/AppRun" >/dev/null || fail "launch with desktop symlink failed" [[ -L "$DESKTOP" ]] || fail "desktop symlink replaced" +# 15. appimaged lists this AppImage under its own name: drop our entry, install nothing more. +rm -rf "$XDG_DATA_HOME" +"$APPDIR/AppRun" >/dev/null || fail "launch before appimaged failed" +apps="$XDG_DATA_HOME/applications" +real="$(readlink -f "$APPIMAGE")" +printf '[Desktop Entry]\nName=ArtCraft Launcher\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\nActions=Trash;\n\n[Desktop Action Trash]\nName=Move to Trash\nExec=gio trash "%s"\n' "$real" "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop" +rm -rf "$XDG_DATA_HOME/icons" +out="$("$APPDIR/AppRun" managed)" || fail "launch beside appimaged failed" +[[ "$out" = "stub: managed" ]] || fail "launch beside appimaged lost arguments" +[[ ! -e "$DESKTOP" ]] || fail "own entry kept beside appimaged's" +[[ ! -e "$XDG_DATA_HOME/icons" ]] || fail "icons installed beside appimaged's entry" + +# 15a. Gear Lever's entry (single-quoted path, no trailing newline) counts too. +rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps" +spaced="$TMP/Art Craft.AppImage" +: >"$spaced" +printf "[Desktop Entry]\nName=ArtCraft Launcher\nStartupWMClass=%s\nExec=env DESKTOPINTEGRATION=1 '%s'" "$APP_ID" "$(readlink -f "$spaced")" >"$apps/artcraft_launcher.desktop" +APPIMAGE="$spaced" "$APPDIR/AppRun" >/dev/null || fail "launch beside Gear Lever failed" +[[ ! -e "$DESKTOP" ]] || fail "integrated beside Gear Lever's entry" + +# 15b. Our entry with user changes stays beside a manager's entry. +cp "$apps/artcraft_launcher.desktop" "$TMP/gear-lever-entry" +sed "s|^Exec=.*|Exec=\"$(readlink -f "$APPIMAGE")\"|" "$TMP/gear-lever-entry" >"$apps/artcraft_launcher.desktop" +sed 's/^Name=.*/Name=My Launcher/' "$TMP/generated-entry" >"$DESKTOP" +cp "$DESKTOP" "$TMP/renamed-entry" +"$APPDIR/AppRun" >/dev/null || fail "launch with renamed entry failed" +cmp -s "$DESKTOP" "$TMP/renamed-entry" || fail "user-changed entry removed" + +# 15c. An entry for another file that shares our path as a prefix doesn't count. +rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps" +printf '[Desktop Entry]\nStartupWMClass=%s\nExec="%s.old"\n' "$APP_ID" "$(readlink -f "$APPIMAGE")" >"$apps/old-copy.desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch beside an older copy's entry failed" +[[ -f "$DESKTOP" ]] || fail "an entry for another file blocked integration" + +# 15d. An entry from a release with the old StartupWMClass is ours: refreshed, and dropped beside appimaged's. +sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP" +"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry failed" +cmp -s "$DESKTOP" "$TMP/generated-entry" || fail "older release's entry not refreshed" +sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP" +printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n' "$real" "$APP_ID" >"$apps/appimagekit_0123abcd.desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry beside appimaged failed" +[[ ! -e "$DESKTOP" ]] || fail "older release's entry kept beside appimaged's" + +# 15e. A manager's entry hidden with a menu editor lists nothing: ours is installed and kept. +for key in NoDisplay Hidden; do + printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n%s=true\n' "$real" "$APP_ID" "$key" >"$apps/appimagekit_0123abcd.desktop" + "$APPDIR/AppRun" >/dev/null || fail "launch beside a hidden entry failed" + [[ -f "$DESKTOP" ]] || fail "no entry of ours beside a $key=true manager entry" +done + +# 15f. A manager's entry left with the old window class by an in-place update: ours stays. +printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=artcraft-launcher\n' "$real" >"$apps/appimagekit_0123abcd.desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch beside an old-class manager entry failed" +[[ -f "$DESKTOP" ]] || fail "own entry dropped beside a manager entry with the old class" + +# 15g. Only the [Desktop Entry] group counts: an action that runs this AppImage doesn't. +printf '[Desktop Entry]\nExec=other-app\nStartupWMClass=%s\nActions=Launcher;\n\n[Desktop Action Launcher]\nExec="%s"\n' "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch beside an entry that runs this AppImage from an action failed" +[[ -f "$DESKTOP" ]] || fail "own entry dropped beside an entry that runs this AppImage only from an action" + echo "AppRun desktop integration ok" From 39a200f25e1e81fa71ba00253b96107b4833e990 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:43:43 -0500 Subject: [PATCH 38/43] macOS: the Dock icon brings the window back after closing it (#456) (#799) * macOS: the Dock icon brings the window back after closing it (#456) Closing the window to the menu bar ordered it out, and clicking the Dock icon afterward brought nothing back: the Dock sends a "reopen" Apple Event, which neither winit 0.30 nor eframe passes on, and handling it directly needs unsafe code. On macOS, closing to the tray now hides the app, as Cmd-H does. macOS unhides a hidden app by itself, window and all, when its Dock icon is clicked, it is opened again from Finder or Spotlight, or it is picked with Cmd-Tab. "Show ArtCraft Launcher" unhides it before showing the window. A start in the tray keeps ordering the window out, as the app is still being activated then, which would unhide it. * macOS: say why Shell.visible isn't resynced after the Dock unhides the app A left click on the tray icon opens its menu on macOS, so the toggle that reads this flag never arrives there. --- apps/artcraft-launcher/src/hiding.rs | 26 ++++++++++++++++++++++++++ apps/artcraft-launcher/src/main.rs | 21 +++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/artcraft-launcher/src/hiding.rs diff --git a/apps/artcraft-launcher/src/hiding.rs b/apps/artcraft-launcher/src/hiding.rs new file mode 100644 index 00000000..14e97a73 --- /dev/null +++ b/apps/artcraft-launcher/src/hiding.rs @@ -0,0 +1,26 @@ +//! macOS only: closing the window to the menu bar hides the app, as ⌘H does, rather than ordering +//! the window out. +//! +//! macOS brings a hidden app back by itself: clicking its Dock icon, opening it again from Finder +//! or Spotlight, or picking it with ⌘Tab all unhide it, window and all. A window that was only +//! ordered out stays gone, as the Dock asks the app to "reopen" instead, and neither winit nor +//! eframe passes that on (issue #456). Handling that event here would take an Apple Event handler, +//! which needs `unsafe`. + +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; + +/// Hides the app and its window. Must run on the main thread, as the UI does; elsewhere it does +/// nothing. +pub fn hide() { + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).hide(None); + } +} + +/// Brings the app back from [`hide`] and makes it the active app. Main thread only, as [`hide`]. +pub fn unhide() { + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).unhide(None); + } +} diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs index 2c0e9109..7df41274 100644 --- a/apps/artcraft-launcher/src/main.rs +++ b/apps/artcraft-launcher/src/main.rs @@ -5,6 +5,8 @@ #![cfg_attr(windows, windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_os = "macos")] +mod hiding; mod logging; mod motion; mod settings_file; @@ -745,6 +747,9 @@ impl Launcher { struct Shell<'a> { launcher: &'a mut Launcher, window_hides: bool, + /// Whether the launcher last showed its window or put it away. On macOS the Dock, Finder and + /// ⌘Tab can bring the window back without the launcher knowing; that only matters for the + /// tray's left-click toggle, which macOS never sends, as a left click opens the tray menu. visible: bool, quitting: bool, /// The "show on the current desktop" setting last applied to the window (macOS). @@ -758,7 +763,13 @@ impl<'a> Shell<'a> { // Started hidden at login but there's no tray to come back from: show the window. let visible = !hidden || launcher.tray.is_none(); let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None }; - if !visible { + if !visible && window_hides { + // Started in the tray: the window stays out of sight. On macOS this doesn't hide the + // app as `put_away` does, since the app is still being activated as it starts, which + // would unhide it, window and all. + shell.visible = false; + cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } else if !visible { shell.put_away(&cc.egui_ctx); } else if hidden { cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); @@ -775,6 +786,8 @@ impl<'a> Shell<'a> { let on = self.launcher.ui.settings.show_on_current_desktop; spaces::follow_current_desktop(on); self.applied_follow = Some(on); + // Back from `put_away`, which hides the whole app here. + hiding::unhide(); } if self.window_hides { ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); @@ -786,10 +799,14 @@ impl<'a> Shell<'a> { } } - /// Puts the window away in the tray: hidden where it can hide, closed where it can't. + /// Puts the window away in the tray: hidden where it can hide, closed where it can't. On macOS + /// the whole app hides, so its Dock icon brings the window back (`hiding`). fn put_away(&mut self, ctx: &egui::Context) { if self.window_hides { self.visible = false; + #[cfg(target_os = "macos")] + hiding::hide(); + #[cfg(not(target_os = "macos"))] ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); } else { self.launcher.closed_to_tray = true; From ba0335b52c3a5bd9ccc0db51b8176287509f79c9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 00:01:58 -0500 Subject: [PATCH 39/43] 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 40/43] 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 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 41/43] 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 42/43] 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 43/43] 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.