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.
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.
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