Merge remote-tracking branch 'upstream/main' into mac-b1-edges

# Conflicts:
#	crates/ui/src/panes/mod.rs
This commit is contained in:
aiongg committed 2026-10-25 13:49:59 +00:00
commit 3e72e33dae
42 files changed
+12490 -31

No files matched your search

Generated
+237 -5
View File
@@ -974,7 +974,7 @@ name = "craft-settings"
version = "0.1.0"
dependencies = [
"craft-atomic",
"dirs",
"dirs 7.0.0",
"serde",
"tempfile",
"toml_edit",
@@ -997,11 +997,38 @@ dependencies = [
"serde_json",
]
[[package]]
name = "craft-update"
version = "0.1.0"
dependencies = [
"chrono",
"dirs 6.0.0",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"log",
"plist",
"semver",
"serde",
"serde_json",
"sha2",
"tar",
"tempfile",
"ureq",
"verifysign",
"winreg",
"winsafe",
"zip",
]
[[package]]
name = "craft-window"
version = "0.1.0"
dependencies = [
"egui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"wayland-client",
]
@@ -1120,6 +1147,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1130,6 +1163,15 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1821,6 +1863,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -1830,7 +1884,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -3034,6 +3088,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3615,6 +3675,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
dependencies = [
"base64 0.23.1",
"indexmap",
"quick-xml 0.42.0",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.17.16"
@@ -3708,6 +3781,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "presser"
version = "0.3.1"
@@ -3809,6 +3888,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
@@ -3875,6 +3963,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -4195,12 +4289,14 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -4380,6 +4476,10 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -5116,6 +5216,36 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -5382,6 +5512,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -5480,6 +5616,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -5520,6 +5685,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5611,6 +5782,16 @@ dependencies = [
"vello_common 0.1.0",
]
[[package]]
name = "verifysign"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
dependencies = [
"core-foundation 0.10.1",
"windows-sys 0.61.2",
]
[[package]]
name = "version_check"
version = "0.9.5"
@@ -5651,6 +5832,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -5852,7 +6042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.41.0",
"quote",
]
@@ -6455,12 +6645,28 @@ dependencies = [
"xml",
]
[[package]]
name = "winreg"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -6807,6 +7013,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -6819,6 +7039,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+1
View File
@@ -36,6 +36,7 @@ craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
craft-update = { path = "crates/update" }
craft-window = { path = "crates/window" }
egui = { version = "0.36", default-features = false }
rayon = "1"
+20 -1
View File
@@ -32,6 +32,8 @@ pub struct ChromeStyle {
pub text: Color32,
pub weak_text: Color32,
pub accent: Color32,
/// Fill of a selected or hovered control, such as an icon rail's open item.
pub active_background: Color32,
pub font: FontId,
/// Mirror the title bar: buttons at the left, the title at the right.
pub right_to_left: bool,
@@ -39,7 +41,23 @@ pub struct ChromeStyle {
impl ChromeStyle {
pub fn from_ui(ui: &Ui) -> Self {
Self::from_dock(&DockStyle::from_ui(ui))
Self::from_visuals(ui.visuals())
}
/// The colors [`Self::from_dock`] takes from [`DockStyle::from_visuals`], without building
/// the dock style's labels.
pub fn from_visuals(visuals: &egui::Visuals) -> Self {
Self {
background: visuals.window_fill,
stroke: visuals.window_stroke,
title_background: visuals.extreme_bg_color,
text: visuals.text_color(),
weak_text: visuals.weak_text_color(),
accent: visuals.selection.stroke.color,
active_background: visuals.widgets.active.bg_fill,
font: FontId::proportional(12.0),
right_to_left: false,
}
}
pub fn from_dock(style: &DockStyle) -> Self {
@@ -50,6 +68,7 @@ impl ChromeStyle {
text: style.text,
weak_text: style.inactive_text,
accent: style.accent,
active_background: style.active_background,
font: style.font.clone(),
right_to_left: style.right_to_left,
}
+1 -1
View File
@@ -374,7 +374,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
});
let response = if elided { response.on_hover_text(&label) } else { response };
self.register_interaction(ui.ctx(), response.id);
if response.is_pointer_button_down_on() || response.clicked() {
if response.is_pointer_button_down_on() || response.clicked_by(egui::PointerButton::Primary) {
ui.ctx().data_mut(|d| d.insert_temp(tab_id.with("pointer-focus"), true));
}
// The click that expands a minimized group is not the first half of a double-click.
+6
View File
@@ -855,6 +855,10 @@ pub trait DockContent<P> {
0.0
}
/// Draw the group header into `ui.max_rect()`, sized by [`Self::group_header_width`].
/// The header's widgets take their own input, so a drag on them moves nothing. For a widget
/// that should drag (a ≡ button, say), call [`begin_drag`] with the id given to
/// [`DockArea::new`], `active` and the widget's rect when its response reports
/// `drag_started()`; the panel then moves as its tab would.
fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {}
}
@@ -983,6 +987,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
/// one it is on, and a restored layout puts each window back on its monitor, or on the
/// main window's when that monitor is gone. egui 0.36 reports no monitors; without a list
/// windows store the monitor size egui reports and restore onto the main window's monitor.
/// eframe 0.36 keeps winit's event loop, and with it `available_monitors`, to itself, so an
/// eframe application builds the list from the platform's own display API.
pub fn monitors(mut self, monitors: Vec<Monitor>) -> Self {
self.monitors = monitors;
self
+8
View File
@@ -213,6 +213,14 @@ fn a_clicked_tab_shows_no_focus_outline_but_tab_key_focus_does() {
assert_eq!(d.ctx.memory(|m| m.focused()), Some(id));
d.step(vec![]);
assert!(outlined(&d));
// Space and Enter activate the tab without counting as a pointer click.
for activate in [egui::Key::Space, egui::Key::Enter] {
let out = d.step(vec![key(activate)]);
assert!(out.actions.contains(&Action::Activate { panel: 3 }), "{:?}", out.actions);
d.step(vec![]);
assert!(outlined(&d), "{activate:?} hid the focus outline");
}
}
#[test]
+52 -9
View File
@@ -5,13 +5,16 @@
//! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and
//! where they are: the host takes the open list and each pane's [`PaneState`] every frame and
//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome
//! ([`crate::docking::chrome`]) draws the floating frames and title bars.
//! ([`crate::docking::chrome`]) draws the floating frames and title bars. [`Rail`] draws an icon
//! strip at one edge that opens one pane at a time.
use std::fmt::Debug;
use std::hash::Hash;
use std::ops::RangeInclusive;
use egui::{Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, pos2, vec2};
use egui::{
Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, WidgetInfo, WidgetType, pos2, vec2,
};
use crate::docking::PanelLimits;
use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar};
@@ -20,6 +23,10 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
mod rail;
pub use rail::{RAIL_BUTTON, RAIL_WIDTH, Rail, RailItem, RailOutput, RailRequest};
/// A title dragged this far out of a docked pane floats it; a pane dropped this close to the
/// host's left or right edge docks there.
pub const DOCK_SNAP: f32 = 32.0;
@@ -136,6 +143,13 @@ pub trait PaneContent<P> {
fn closable(&self, _pane: &P) -> bool {
true
}
/// `false`: a docked pane draws no title bar (no title, ▾ menu, × or title drag), for bodies
/// that draw their own header. The body fills the pane, the resize edge stays and F6 still
/// reaches the pane. Close then comes only from the app. A floating pane always draws its
/// title bar, which moves it.
fn title_bar(&self, _pane: &P) -> bool {
true
}
/// App items, shown above the Move, Float and Close items of the ▾ menu.
fn menu(&mut self, _ui: &mut Ui, _pane: &P) {}
}
@@ -560,21 +574,40 @@ impl PaneHost {
Side::Bottom => egui::Panel::bottom(panel_id),
};
let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke));
let title_bar = content.title_bar(&s.pane);
let drawn = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| {
let top = ui.max_rect();
let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT));
ui.allocate_rect(title, Sense::hover());
let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests);
let (bar, body_top) = if title_bar {
let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT));
ui.allocate_rect(title, Sense::hover());
(Some(self.chrome(ui, title, s, content, style, focus, bounds, requests)), title.bottom())
} else {
(None, top.top())
};
// The pane reserves exactly its size and clips its body. egui cuts a panel wider
// than its exact size at its outer edge, so a wide body would shift a right or
// bottom pane's reserved rect inwards and the document would draw over the pane.
let body = Rect::from_min_max(pos2(top.left(), title.bottom()), top.max);
let body = Rect::from_min_max(pos2(top.left(), body_top), top.max);
// Without a title bar, F6 focuses the body itself; Tab goes on into its widgets,
// which register after it.
let stop = (!title_bar).then(|| {
let stop = ui.interact(body, s.id.with("body"), Sense::focusable_noninteractive());
let title = content.title(&s.pane);
stop.widget_info(|| WidgetInfo::labeled(WidgetType::Panel, true, &title));
if focus {
stop.request_focus();
}
stop
});
let mut child = ui.new_child(UiBuilder::new().max_rect(body).layout(*ui.layout()));
child.set_clip_rect(body.intersect(ui.clip_rect()));
Frame::NONE.inner_margin(self.margin).show(&mut child, |ui| {
ui.set_min_width(ui.available_width());
content.body(ui, &s.pane);
});
if stop.as_ref().is_some_and(Response::has_focus) {
ui.painter().rect_stroke(body.shrink(1.0), 0.0, egui::Stroke::new(1.0, style.accent), egui::StrokeKind::Inside);
}
ui.allocate_rect(body, Sense::hover());
bar
});
@@ -587,7 +620,13 @@ impl PaneHost {
Side::Bottom => (SplitAxis::Vertical, Rect::from_x_y_ranges(rect.x_range(), rect.top() - 2.0..=rect.top() + 2.0)),
};
let handle_style = ResizeHandleStyle { hovered: style.accent.gamma_multiply(0.5), dragged: style.accent, focused: style.accent };
let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(ui);
// The edge's hit strip reaches over the document, whose widgets register after the host
// and would take presses there on the host's layer. A sublayer sits directly above it:
// over the document, under floating windows.
let edge_layer = LayerId::new(ui.layer_id().order, s.id.with("resize-layer"));
ui.ctx().set_sublayer(ui.layer_id(), edge_layer);
let mut edge_ui = ui.new_child(UiBuilder::new().layer_id(edge_layer));
let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(&mut edge_ui);
let next = if resize.response.dragged() {
resize.response.interact_pointer_pos().map(|p| match side {
Side::Left => p.x - rect.left(),
@@ -606,8 +645,10 @@ impl PaneHost {
requests.push(PaneRequest::Resize(s.pane.clone(), next));
}
if content.movable(&s.pane) {
self.drag_out(ui.ctx(), &bar, shown, s, edge, rect, content, bounds, memory, preview, style, requests);
if content.movable(&s.pane)
&& let Some(bar) = &bar
{
self.drag_out(ui.ctx(), bar, shown, s, edge, rect, content, bounds, memory, preview, style, requests);
}
rect
}
@@ -811,5 +852,7 @@ pub fn float_rect(saved: Option<[f32; 4]>, bounds: Rect, min: Vec2) -> Rect {
Rect::from_min_size(pos, size)
}
#[cfg(test)]
mod rail_tests;
#[cfg(test)]
mod tests;
+299
View File
@@ -0,0 +1,299 @@
//! An icon rail: a vertical strip of icons, always shown at one edge, that opens one task pane at
//! a time beside it.
//!
//! The rail stores nothing. The app passes its items and the pane it has open every frame, shows
//! that pane with [`super::PaneHost`] ([`super::SidePolicy::OneSlot`], not movable), and runs the
//! [`RailRequest`] a click returns.
use std::fmt::Debug;
use std::hash::Hash;
use egui::{
Align, EventFilter, Frame, Id, ImageSource, Key, Layout, Margin, Modifiers, Rect, Response, Sense, Stroke, StrokeKind, Ui, WidgetInfo,
WidgetType, vec2,
};
use super::Edge;
use crate::docking::chrome::ChromeStyle;
/// Default width of the rail.
pub const RAIL_WIDTH: f32 = 48.0;
/// Default width and height of one item.
pub const RAIL_BUTTON: f32 = 34.0;
/// Room between two items.
const SPACING: f32 = 4.0;
/// Room between an item's edge and its icon.
const ICON_INSET: f32 = 8.0;
/// Radius of the marker dot.
const MARKER_RADIUS: f32 = 3.0;
/// Largest width or item size the rail accepts; larger or non-finite values fall back.
const MAX_SIZE: f32 = 4096.0;
/// One icon of the rail, standing for the pane it opens.
#[derive(Clone, Debug)]
pub struct RailItem<P> {
pub pane: P,
/// Painted tinted with the chrome's text colors, like docking's panel icons.
pub icon: ImageSource<'static>,
/// Tooltip and accessible name. Translate it in the app.
pub label: String,
/// `false`: drawn disabled, not focusable, and a click asks for nothing.
pub enabled: bool,
/// A small dot telling that the pane has content (comments exist, the document has
/// bookmarks). Drawn only while the pane is not open.
pub marker: bool,
/// The item's accessible description while the marker shows ("Has comments"). Translate it
/// in the app.
pub marker_label: Option<String>,
}
impl<P> RailItem<P> {
/// An enabled item without a marker.
pub fn new(pane: P, icon: ImageSource<'static>, label: impl Into<String>) -> Self {
Self { pane, icon, label: label.into(), enabled: true, marker: false, marker_label: None }
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn marker(mut self, marker: bool) -> Self {
self.marker = marker;
self
}
pub fn marker_label(mut self, label: impl Into<String>) -> Self {
self.marker_label = Some(label.into());
self
}
}
/// What a click on an item asks the app to do.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum RailRequest<P> {
/// Open this pane in place of the open one.
Open(P),
/// Close this pane, which is the open one.
Close(P),
}
/// What [`Rail::show`] returns.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct RailOutput<P> {
/// The click of this frame, if any.
pub request: Option<RailRequest<P>>,
/// The item holding keyboard focus.
pub focused: Option<P>,
/// Where the rail was drawn.
pub rect: Rect,
}
/// Draws the icon rail. Build it every frame.
pub struct Rail {
id: Id,
edge: Edge,
rtl: bool,
width: f32,
button: f32,
frame: Option<Frame>,
}
impl Rail {
pub fn new(id: impl Into<Id>) -> Self {
Self { id: id.into(), edge: Edge::End, rtl: false, width: RAIL_WIDTH, button: RAIL_BUTTON, frame: None }
}
/// The edge the rail sits at. Default [`Edge::End`]. The rail is vertical, so
/// [`Edge::Bottom`] draws it at the end edge.
pub fn edge(mut self, edge: Edge) -> Self {
self.edge = edge;
self
}
/// Right-to-left interface: `Start` is the right edge, and the marker dot sits at the
/// item's top-left corner.
pub fn rtl(mut self, rtl: bool) -> Self {
self.rtl = rtl;
self
}
/// Width of the rail. Default [`RAIL_WIDTH`].
pub fn width(mut self, width: f32) -> Self {
self.width = if width.is_finite() { width.clamp(0.0, MAX_SIZE) } else { RAIL_WIDTH };
self
}
/// Width and height of one item. Default [`RAIL_BUTTON`]. The icon is 16 points smaller.
pub fn button_size(mut self, size: f32) -> Self {
self.button = if size.is_finite() { size.clamp(0.0, MAX_SIZE) } else { RAIL_BUTTON };
self
}
/// Frame of the rail. Default: the panel fill and an 8 pt margin above and below. The panel's
/// separator line draws the inner edge.
pub fn frame(mut self, frame: Frame) -> Self {
self.frame = Some(frame);
self
}
/// The widget id of `pane`'s item, to give it keyboard focus (`Memory::request_focus`), for
/// example when the app's F6 cycle reaches the rail.
pub fn item_id<P: Hash + Debug>(&self, pane: &P) -> Id {
self.id.with(("rail-item", pane))
}
fn left(&self) -> bool {
matches!((self.edge, self.rtl), (Edge::Start, false) | (Edge::End | Edge::Bottom, true))
}
/// Draw the rail as the outermost panel on its edge: call it before [`super::PaneHost::show`]
/// (and after the app's top and bottom bars), so the open pane sits between the document and
/// the rail. `items` are drawn top-down and scroll in a short window; `foot` draws below
/// them, always visible (page navigation, zoom), and may draw nothing. `open` is the pane
/// the app has open.
pub fn show<P: Clone + Hash + Debug>(self, ui: &mut Ui, items: &[RailItem<P>], open: Option<&P>, foot: impl FnOnce(&mut Ui)) -> RailOutput<P> {
let style = ChromeStyle::from_ui(ui);
let panel_id = self.id.with("rail-panel");
let panel = if self.left() { egui::Panel::left(panel_id) } else { egui::Panel::right(panel_id) };
let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).inner_margin(Margin::symmetric(0, 8)));
let shown = panel.exact_size(self.width).resizable(false).frame(frame).show(ui, |ui| {
// The foot is a panel of its own, so that in a short window the items scroll instead
// of running into it.
egui::Panel::bottom(self.id.with("rail-foot")).frame(Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| {
ui.with_layout(Layout::top_down(Align::Center), foot);
});
egui::ScrollArea::vertical()
.id_salt(self.id.with("rail-items"))
.auto_shrink([false, false])
.show(ui, |ui| {
ui.with_layout(Layout::top_down(Align::Center), |ui| {
ui.spacing_mut().item_spacing.y = SPACING;
self.items(ui, items, open, &style)
})
.inner
})
.inner
});
let (request, focused) = shown.inner;
RailOutput { request, focused, rect: shown.response.rect }
}
fn items<P: Clone + Hash + Debug>(
&self,
ui: &mut Ui,
items: &[RailItem<P>],
open: Option<&P>,
style: &ChromeStyle,
) -> (Option<RailRequest<P>>, Option<P>) {
let mut request = None;
let mut focused = None;
let open = open.map(|pane| self.item_id(pane));
let mut drawn: Vec<Id> = Vec::new();
// Focus order for Up and Down: the enabled items with their place in `drawn`.
let mut enabled: Vec<(usize, Id, &P)> = Vec::new();
let mut focus_index = None;
// An item disabled while focused, by its place. egui drops a disabled widget's focus as
// it draws it, so this is read before.
let mut lost_focus = None;
for item in items {
let id = self.item_id(&item.pane);
// A pane listed twice would give two widgets one id.
if drawn.contains(&id) {
continue;
}
let place = drawn.len();
drawn.push(id);
let selected = open == Some(id);
let had_focus = ui.memory(|m| m.has_focus(id));
let response = ui
.add_enabled_ui(item.enabled, |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(self.button, self.button), Sense::hover());
let response = ui.interact(rect, id, Sense::click());
response.widget_info(|| WidgetInfo::selected(WidgetType::Button, ui.is_enabled(), selected, &item.label));
if item.marker
&& !selected
&& let Some(label) = &item.marker_label
{
ui.ctx().accesskit_node_builder(id, |node| node.set_description(label.as_str()));
}
self.paint(ui, &response, item, selected, style);
response
})
.inner;
let response = if item.enabled { response.on_hover_text(&item.label) } else { response.on_disabled_hover_text(&item.label) };
if !item.enabled {
if had_focus {
lost_focus = Some(place);
}
continue;
}
if response.gained_focus() {
response.scroll_to_me(None);
}
if response.has_focus() {
focus_index = Some(enabled.len());
focused = Some(item.pane.clone());
}
enabled.push((place, id, &item.pane));
if response.clicked() {
request = Some(if selected { RailRequest::Close(item.pane.clone()) } else { RailRequest::Open(item.pane.clone()) });
}
}
if let Some(place) = lost_focus
&& focus_index.is_none()
{
// Focus moves to the nearest enabled item below, else above, else leaves the rail.
let next = enabled.iter().find(|(p, ..)| *p > place).or_else(|| enabled.iter().rev().find(|(p, ..)| *p < place));
if let Some(&(_, next, pane)) = next {
ui.memory_mut(|m| m.request_focus(next));
focused = Some(pane.clone());
}
}
if let Some(index) = focus_index
&& let Some(&(_, id, _)) = enabled.get(index)
{
// Up and Down move between the items, never out of the rail by egui's geometric focus.
ui.memory_mut(|m| m.set_focus_lock_filter(id, EventFilter { vertical_arrows: true, ..Default::default() }));
let next = ui.input_mut(|input| {
if input.consume_key(Modifiers::NONE, Key::ArrowDown) {
Some(index.checked_add(1))
} else if input.consume_key(Modifiers::NONE, Key::ArrowUp) {
Some(index.checked_sub(1))
} else {
None
}
});
if let Some(&(_, next, _)) = next.flatten().and_then(|i| enabled.get(i)) {
ui.memory_mut(|m| m.request_focus(next));
}
}
(request, focused)
}
fn paint<P>(&self, ui: &Ui, response: &Response, item: &RailItem<P>, selected: bool, style: &ChromeStyle) {
let rect = response.rect;
if !rect.is_positive() {
return;
}
let painter = ui.painter();
let hovered = response.hovered() && ui.is_enabled();
if selected || hovered {
painter.rect_filled(rect.shrink(1.0), 4.0, style.active_background);
}
if response.has_focus() {
painter.rect_stroke(rect.shrink(1.0), 4.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
}
let tint = if selected || hovered { style.text } else { style.weak_text };
let icon = rect.shrink(ICON_INSET);
if icon.is_positive() {
egui::Image::new(item.icon.clone()).tint(tint).paint_at(ui, icon);
}
if item.marker && !selected {
let corner = if self.rtl { rect.left_top() + vec2(6.0, 6.0) } else { rect.right_top() + vec2(-6.0, 6.0) };
painter.circle_filled(corner, MARKER_RADIUS, style.accent);
}
}
}
+384
View File
@@ -0,0 +1,384 @@
//! Behavior tests for the icon rail, driven by egui pointer and key events. The harness's root ui
//! has an 8 pt margin, so in an 800 pt wide window the rail and panes span x 8..792.
use egui::accesskit::{Role, Toggled};
use egui::{Event, ImageSource, Key, Modifiers, PointerButton, Pos2, Rect, Ui, vec2};
use egui_kittest::{Harness, kittest::NodeT, kittest::Queryable};
use super::*;
const L: f32 = 8.0;
const R: f32 = 792.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Tool {
Comments,
Bookmarks,
Pages,
Layers,
Files,
Tags,
}
const TOOLS: [Tool; 6] = [Tool::Comments, Tool::Bookmarks, Tool::Pages, Tool::Layers, Tool::Files, Tool::Tags];
fn items() -> Vec<RailItem<Tool>> {
TOOLS.iter().map(|t| RailItem::new(*t, ImageSource::Uri(format!("rail-test://{t:?}").into()), format!("{t:?}"))).collect()
}
/// The one-slot task pane the rail opens, at the rail's edge.
struct Content {
edge: Edge,
}
impl PaneContent<Tool> for Content {
fn title(&self, pane: &Tool) -> String {
format!("{pane:?} pane")
}
fn body(&mut self, ui: &mut Ui, pane: &Tool) {
ui.label(format!("{pane:?} body"));
}
fn default_state(&self, _: &Tool) -> PaneState {
PaneState::docked(self.edge, 250.0)
}
fn movable(&self, _: &Tool) -> bool {
false
}
}
/// The app side: which pane is open, and every request the rail sent.
struct App {
items: Vec<RailItem<Tool>>,
open: Option<Tool>,
log: Vec<RailRequest<Tool>>,
edge: Edge,
rtl: bool,
/// Show the open pane with a `PaneHost` after the rail.
pane: bool,
foot: bool,
out: Option<RailOutput<Tool>>,
/// `PaneOutput::focused` of the pane host.
pane_focused: Option<Tool>,
document: Rect,
}
impl App {
fn new() -> Self {
Self {
items: items(),
open: None,
log: Vec::new(),
edge: Edge::End,
rtl: false,
pane: false,
foot: false,
out: None,
pane_focused: None,
document: Rect::NOTHING,
}
}
fn frame(&mut self, ui: &mut Ui) {
let foot = self.foot;
let out = Rail::new("rail").edge(self.edge).rtl(self.rtl).show(ui, &self.items, self.open.as_ref(), |ui| {
if foot {
let _ = ui.button("Foot");
}
});
if self.pane {
let open: Vec<Tool> = self.open.into_iter().collect();
let panes = PaneHost::new("panes").rtl(self.rtl).policy(SidePolicy::OneSlot).show(ui, &open, |_| None, &mut Content { edge: self.edge });
self.pane_focused = panes.focused;
}
self.document = egui::CentralPanel::default().show(ui, |ui| ui.label("Document")).response.rect;
match &out.request {
Some(RailRequest::Open(p)) => self.open = Some(*p),
Some(RailRequest::Close(_)) => self.open = None,
None => {}
}
self.log.extend(out.request.clone());
self.out = Some(out);
}
fn out(&self) -> &RailOutput<Tool> {
self.out.as_ref().unwrap()
}
}
fn harness(app: App, height: f32) -> Harness<'static, App> {
let mut h = Harness::builder().with_size(vec2(800.0, height)).build_ui_state(|ui, app: &mut App| app.frame(ui), app);
h.input_mut().focused = true;
h.run_steps(2);
h
}
fn item(h: &Harness<'_, App>, tool: Tool) -> Rect {
h.ctx.read_response(Rail::new("rail").item_id(&tool)).unwrap().rect
}
fn click(h: &mut Harness<'_, App>, at: Pos2) {
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.input_mut().events.extend([Event::PointerMoved(at), button(true)]);
h.run_steps(1);
h.input_mut().events.push(button(false));
h.run_steps(2);
}
fn click_item(h: &mut Harness<'_, App>, tool: Tool) {
let at = item(h, tool).center();
click(h, at);
}
fn key(h: &mut Harness<'_, App>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn focused(h: &Harness<'_, App>) -> Option<Tool> {
h.state().out().focused
}
#[test]
fn a_click_opens_the_open_item_closes_and_another_swaps() {
let mut h = harness(App::new(), 600.0);
click_item(&mut h, Tool::Comments);
assert_eq!(h.state().log, [RailRequest::Open(Tool::Comments)]);
assert_eq!(h.state().open, Some(Tool::Comments));
click_item(&mut h, Tool::Comments);
assert_eq!(h.state().log[1..], [RailRequest::Close(Tool::Comments)]);
assert_eq!(h.state().open, None);
click_item(&mut h, Tool::Comments);
click_item(&mut h, Tool::Pages);
assert_eq!(h.state().log[2..], [RailRequest::Open(Tool::Comments), RailRequest::Open(Tool::Pages)]);
assert_eq!(h.state().open, Some(Tool::Pages));
}
#[test]
fn a_disabled_item_sends_nothing_and_the_marker_shows_only_while_closed() {
let mut app = App::new();
app.items[1].enabled = false;
app.items[0].marker = true;
app.items[0].marker_label = Some("Has comments".into());
app.items[3] = app.items[3].clone().marker(true).marker_label("Has layers");
app.open = Some(Tool::Comments);
let mut h = harness(app, 600.0);
click_item(&mut h, Tool::Bookmarks);
assert!(h.state().log.is_empty());
assert_eq!(h.state().open, Some(Tool::Comments));
let markers = |h: &Harness<'_, App>| -> Vec<Pos2> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Circle(circle) if circle.radius == 3.0 => Some(circle.center),
_ => None,
})
.collect()
};
let shown = markers(&h);
assert_eq!(shown.len(), 1, "{shown:?}");
assert!(item(&h, Tool::Layers).contains(shown[0]));
assert!(shown[0].x > item(&h, Tool::Layers).center().x, "the marker sits at the top-right corner");
let description = |h: &Harness<'_, App>, label: &str| h.get_by_label(label).accesskit_node().description();
assert_eq!(description(&h, "Layers").as_deref(), Some("Has layers"));
assert_eq!(description(&h, "Comments"), None, "no description while the pane is open");
// Closed, Comments shows its marker too.
click_item(&mut h, Tool::Comments);
assert_eq!(h.state().open, None);
let shown = markers(&h);
assert_eq!(shown.len(), 2, "{shown:?}");
assert!(shown.iter().any(|p| item(&h, Tool::Comments).contains(*p)));
h.run_steps(1);
// Hovering Comments shows a tooltip with its label; move away before reading by label.
h.input_mut().events.push(Event::PointerMoved(Pos2::new(400.0, 300.0)));
h.run_steps(2);
assert_eq!(description(&h, "Comments").as_deref(), Some("Has comments"));
let mut app = App::new();
app.rtl = true;
app.items[3].marker = true;
let h = harness(app, 600.0);
let shown = markers(&h);
assert_eq!(shown.len(), 1, "{shown:?}");
assert!(shown[0].x < item(&h, Tool::Layers).center().x, "right-to-left: the marker sits at the top-left corner");
}
#[test]
fn the_rail_is_outermost_and_the_open_pane_sits_between_it_and_the_document() {
for (edge, rtl, left) in [(Edge::End, false, false), (Edge::End, true, true), (Edge::Start, false, true), (Edge::Start, true, false)] {
let mut app = App::new();
app.edge = edge;
app.rtl = rtl;
app.pane = true;
app.open = Some(Tool::Comments);
let h = harness(app, 600.0);
let rail = h.state().out().rect;
let pane_id = Id::new("panes").with(("pane", &Tool::Comments));
// The resize handle lies on the pane's inner edge; the pane is 250 wide.
let inner = h.ctx.read_response(pane_id.with("resize")).unwrap().rect.center().x;
let pane = if left {
Rect::from_x_y_ranges(inner - 250.0..=inner, rail.y_range())
} else {
Rect::from_x_y_ranges(inner..=inner + 250.0, rail.y_range())
};
let title = h.ctx.read_response(pane_id.with("title")).unwrap().rect;
let document = h.state().document;
let case = format!("{edge:?} rtl {rtl}: rail {rail:?}, pane {pane:?}, title {title:?}, document {document:?}");
assert_eq!(rail.width(), RAIL_WIDTH, "{case}");
assert!(pane.x_range().contains(title.left()) && pane.x_range().contains(title.right()), "{case}");
if left {
assert_eq!(rail.left(), L, "{case}");
assert_eq!(pane.left(), rail.right(), "{case}");
assert!(document.left() >= pane.right(), "{case}");
} else {
assert_eq!(rail.right(), R, "{case}");
assert_eq!(pane.right(), rail.left(), "{case}");
assert!(document.right() <= pane.left(), "{case}");
}
assert!(rail.x_range().contains(item(&h, Tool::Comments).center().x), "{case}");
}
}
#[test]
fn in_a_short_window_the_items_scroll_and_the_foot_stays_visible() {
let mut app = App::new();
app.foot = true;
let mut h = harness(app, 200.0);
let foot = h.get_by_label("Foot").rect();
assert!(foot.bottom() <= 192.0, "{foot:?}");
assert!(item(&h, Tool::Comments).top() < 30.0);
assert!(item(&h, Tool::Tags).bottom() > foot.top(), "the last item starts out of view");
let over = item(&h, Tool::Comments).center();
h.input_mut().events.extend([
Event::PointerMoved(over),
Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -500.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
]);
h.run_steps(3);
let tags = item(&h, Tool::Tags);
assert!(tags.bottom() <= h.get_by_label("Foot").rect().top(), "{tags:?} above the foot after scrolling");
assert_eq!(h.get_by_label("Foot").rect(), foot);
}
#[test]
fn items_are_named_toggle_buttons_and_the_keyboard_moves_between_and_activates_them() {
let mut app = App::new();
app.items[1].enabled = false;
app.open = Some(Tool::Comments);
let mut h = harness(app, 600.0);
let comments = h.get_by_label("Comments").accesskit_node();
assert_eq!(comments.role(), Role::Button);
assert_eq!(comments.toggled(), Some(Toggled::True));
assert!(!comments.is_disabled());
let pages = h.get_by_label("Pages").accesskit_node();
assert_eq!(pages.role(), Role::Button);
assert_eq!(pages.toggled(), Some(Toggled::False));
assert!(h.get_by_label("Bookmarks").accesskit_node().is_disabled());
h.ctx.memory_mut(|m| m.request_focus(Rail::new("rail").item_id(&Tool::Comments)));
h.run_steps(2);
assert_eq!(focused(&h), Some(Tool::Comments));
key(&mut h, Key::ArrowUp);
assert_eq!(focused(&h), Some(Tool::Comments), "Up on the first item stays");
key(&mut h, Key::ArrowDown);
assert_eq!(focused(&h), Some(Tool::Pages), "Down skips the disabled item");
key(&mut h, Key::Enter);
assert_eq!(h.state().log, [RailRequest::Open(Tool::Pages)]);
key(&mut h, Key::Space);
assert_eq!(h.state().log[1..], [RailRequest::Close(Tool::Pages)]);
key(&mut h, Key::ArrowUp);
assert_eq!(focused(&h), Some(Tool::Comments));
for _ in 0..TOOLS.len() {
key(&mut h, Key::ArrowDown);
}
assert_eq!(focused(&h), Some(Tool::Tags), "Down on the last item stays");
}
#[test]
fn an_empty_rail_draws_and_bad_sizes_fall_back() {
let mut app = App::new();
app.items.clear();
let h = harness(app, 600.0);
let rail = h.state().out().rect;
assert_eq!((rail.right(), rail.width()), (R, RAIL_WIDTH));
assert_eq!(h.state().out().request, None);
let mut h = Harness::builder().with_size(vec2(400.0, 300.0)).build_ui(|ui| {
let out = Rail::new("bad").width(f32::NAN).button_size(f32::INFINITY).show(ui, &items(), None, |_| {});
assert_eq!(out.rect.width(), RAIL_WIDTH);
});
h.run_steps(2);
}
#[test]
fn a_disabled_item_takes_no_tab_focus_and_a_focused_item_disabled_hands_focus_on() {
let mut app = App::new();
app.items[1].enabled = false;
let mut h = harness(app, 600.0);
let focused_id = |h: &Harness<'_, App>| h.ctx.memory(|m| m.focused());
let id = |tool: Tool| Rail::new("rail").item_id(&tool);
h.ctx.memory_mut(|m| m.request_focus(id(Tool::Comments)));
h.run_steps(2);
key(&mut h, Key::Tab);
assert_eq!(focused_id(&h), Some(id(Tool::Pages)), "Tab skips the disabled item");
// Pages is disabled while focused: focus goes to the next enabled item.
h.state_mut().items[2].enabled = false;
h.run_steps(2);
assert_eq!(focused_id(&h), Some(id(Tool::Layers)));
assert_eq!(focused(&h), Some(Tool::Layers));
// The last item disabled while focused: focus goes to the enabled one above.
h.ctx.memory_mut(|m| m.request_focus(id(Tool::Tags)));
h.run_steps(2);
h.state_mut().items[5].enabled = false;
h.run_steps(2);
assert_eq!(focused_id(&h), Some(id(Tool::Files)));
// Nothing left enabled: focus leaves the rail.
for item in &mut h.state_mut().items {
item.enabled = false;
}
h.run_steps(2);
assert_eq!((focused_id(&h), focused(&h)), (None, None));
}
#[test]
fn a_press_on_the_rail_clears_the_pane_hosts_focused_pane() {
let mut app = App::new();
app.pane = true;
app.open = Some(Tool::Comments);
let mut h = harness(app, 600.0);
let body = h.get_by_label("Comments body").rect().center();
click(&mut h, body);
assert_eq!(h.state().pane_focused, Some(Tool::Comments));
// Below the items: the rail, not a pane.
let rail = h.state().out().rect;
click(&mut h, Pos2::new(rail.center().x, rail.bottom() - 40.0));
assert_eq!(h.state().pane_focused, None);
assert!(h.state().log.is_empty());
assert_eq!(h.state().open, Some(Tool::Comments));
}
#[test]
fn a_bottom_edge_draws_the_rail_at_the_end() {
let mut app = App::new();
app.edge = Edge::Bottom;
let h = harness(app, 600.0);
let rail = h.state().out().rect;
assert_eq!((rail.right(), rail.height()), (R, 584.0), "{rail:?}");
}
#[test]
fn chrome_style_from_visuals_matches_the_dock_style_it_skips() {
use crate::docking::{DockStyle, chrome::ChromeStyle};
for visuals in [egui::Visuals::light(), egui::Visuals::dark()] {
let direct = ChromeStyle::from_visuals(&visuals);
let through_dock = ChromeStyle::from_dock(&DockStyle::from_visuals(&visuals));
assert_eq!(format!("{direct:?}"), format!("{through_dock:?}"));
}
}
+129 -4
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2, Rect, Ui, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use egui_kittest::{Harness, kittest::NodeT, kittest::Queryable};
use super::*;
@@ -41,6 +41,30 @@ impl PaneContent<Pane> for Content {
}
}
/// [`Content`] with the title bar on or off.
struct Titled(bool);
impl PaneContent<Pane> for Titled {
fn title(&self, pane: &Pane) -> String {
Content.title(pane)
}
fn body(&mut self, ui: &mut Ui, pane: &Pane) {
Content.body(ui, pane);
}
fn default_state(&self, pane: &Pane) -> PaneState {
Content.default_state(pane)
}
fn movable(&self, pane: &Pane) -> bool {
Content.movable(pane)
}
fn closable(&self, pane: &Pane) -> bool {
Content.closable(pane)
}
fn title_bar(&self, _: &Pane) -> bool {
self.0
}
}
/// The app side: open list, persisted states and every request it received.
struct App {
open: Vec<Pane>,
@@ -50,17 +74,45 @@ struct App {
to_document: usize,
rtl: bool,
policy: SidePolicy,
title_bar: bool,
/// The document is one drag-sensing widget, like a canvas.
drag_document: bool,
document_drags: usize,
}
impl App {
fn new(open: &[Pane]) -> Self {
Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns }
Self {
open: open.to_vec(),
states: HashMap::new(),
log: Vec::new(),
focused: None,
to_document: 0,
rtl: false,
policy: SidePolicy::Columns,
title_bar: true,
drag_document: false,
document_drags: 0,
}
}
fn frame(&mut self, ui: &mut Ui) {
let states = self.states.clone();
let out = PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content);
egui::CentralPanel::default().show(ui, |ui| ui.label("Document"));
let out =
PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Titled(self.title_bar));
let drag_document = self.drag_document;
// A canvas fills the panel, without the default frame's margin.
let panel = if drag_document { egui::CentralPanel::default().frame(Frame::NONE) } else { egui::CentralPanel::default() };
let dragged = panel
.show(ui, |ui| {
if drag_document {
ui.interact(ui.max_rect(), Id::new("document-drag"), Sense::click_and_drag()).dragged()
} else {
ui.label("Document").dragged()
}
})
.inner;
self.document_drags += usize::from(dragged);
for request in &out.requests {
match request {
PaneRequest::Close(p) => self.open.retain(|o| o != p),
@@ -169,6 +221,79 @@ fn last_resize(app: &App, pane: Pane) -> Option<f32> {
})
}
#[test]
fn a_docked_pane_without_a_title_bar_fills_the_pane_and_still_resizes() {
for title_bar in [true, false] {
let mut app = App::new(&[Pane::Nav]);
app.title_bar = title_bar;
let mut h = harness(app);
let chrome = [
rect(&h, pane_id(Pane::Nav).with("title")).is_some(),
h.query_by_label("Move Nav pane").is_some(),
h.query_by_label("Pane options").is_some(),
h.query_by_label("Close").is_some(),
];
assert_eq!(chrome, [title_bar; 4], "title bar {title_bar}: title, its node, ▾ and ×");
// The body's first line sits under the body margin, below the title bar when there is one.
let body = h.get_by_label("Nav body").rect();
let top = if title_bar { L + TITLE_BAR_HEIGHT + 8.0 } else { L + 8.0 };
assert!((body.top() - top).abs() < 2.0, "title bar {title_bar}: body {body:?}");
// Without the bar, the body is the pane's accessible region, named by its title.
let regions = h.query_all_by_label("Nav").filter(|n| n.accesskit_node().role() == egui::accesskit::Role::Pane).count();
assert_eq!(regions, usize::from(!title_bar), "title bar {title_bar}");
assert_eq!(h.get_by_label("Resize pane").accesskit_node().role(), egui::accesskit::Role::Splitter);
drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0));
assert_eq!(last_resize(h.state(), Pane::Nav), Some(300.0), "title bar {title_bar}");
assert_eq!(edge(&h, Pane::Nav), L + 300.0);
}
}
#[test]
fn f6_reaches_a_pane_without_a_title_bar() {
let mut app = App::new(&[Pane::Nav, Pane::Comments]);
app.title_bar = false;
let mut h = harness(app);
key(&mut h, Key::F6, Modifiers::NONE);
assert_eq!(h.state().focused, Some(Pane::Nav));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(pane_id(Pane::Nav).with("body")), "F6 focuses the body");
key(&mut h, Key::F6, Modifiers::NONE);
assert_eq!(h.state().focused, Some(Pane::Comments));
key(&mut h, Key::F6, Modifiers::NONE);
assert_eq!((h.state().focused, h.state().to_document), (None, 1));
}
#[test]
fn the_resize_edge_works_on_both_sides_of_the_line_over_a_dragging_document() {
for offset in [-3.5, 3.5] {
let mut app = App::new(&[Pane::Nav]);
app.drag_document = true;
let mut h = harness(app);
// The line is at L + 250; positive offsets lie over the document.
drag(&mut h, pos2(L + 250.0 + offset, 300.0), pos2(L + 300.0 + offset, 300.0));
// The edge follows the pointer.
let size = last_resize(h.state(), Pane::Nav);
assert!(size.is_some_and(|size| (size - (300.0 + offset)).abs() <= 1.0), "offset {offset}: {size:?}");
assert_eq!(h.state().document_drags, 0, "offset {offset}: the document took the drag");
}
// A floating pane over the edge keeps its presses: the edge stays under floating windows.
let mut app = App::new(&[Pane::Nav, Pane::Styles]);
app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: None, float_rect: Some([L + 150.0, 100.0, 220.0, 300.0]) });
let mut h = harness(app);
h.run_steps(2);
drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0));
assert_eq!(last_resize(h.state(), Pane::Nav), None, "the edge under a floating pane took the drag");
// Away from the edge, the document still drags.
let mut app = App::new(&[Pane::Nav]);
app.drag_document = true;
let mut h = harness(app);
drag(&mut h, pos2(L + 400.0, 300.0), pos2(L + 450.0, 300.0));
assert!(h.state().document_drags > 0);
assert_eq!(last_resize(h.state(), Pane::Nav), None);
}
#[test]
fn columns_sit_side_by_side_resize_and_move_to_end() {
let mut h = harness(App::new(&[Pane::Nav, Pane::Styles]));
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "craft-update"
description = "Release checking, downloading, installing and self-updating for the Craft App Suite and ArtCraft Launcher"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[features]
default = []
egui = ["dep:egui"]
local-mirror = []
[dependencies]
egui = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = "0.4"
dirs = "6"
getrandom = "0.3"
tempfile = "3"
sha2 = "0.10"
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring", "gzip", "platform-verifier"] }
chrono = { workspace = true }
semver = { version = "1", features = ["serde"] }
[dev-dependencies]
flate2 = "1"
[target.'cfg(unix)'.dependencies]
flate2 = "1"
tar = { version = "0.4", default-features = false }
[target.'cfg(target_os = "macos")'.dependencies]
plist = "1"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
dunce = "1"
zip = { version = "8", default-features = false, features = ["deflate"] }
winsafe = { version = "0.0.29", features = ["kernel", "version"] }
verifysign = "1"
+75
View File
@@ -0,0 +1,75 @@
//! Which app an installer or an update works on.
/// A Craft App, as its releases and installers know it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct App {
/// Machine name: repo, binary, package and asset stem (`photocraft`).
pub id: &'static str,
/// Display name (`PhotoCraft`).
pub name: &'static str,
/// What it's for, in a few words (English). Written into the menu entries installers add.
pub tagline: &'static str,
/// GitHub `owner/repo`. Releases are only ever downloaded from this repository.
pub repo: &'static str,
/// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1).
pub former_ids: &'static [&'static str],
/// Older display names its installers registered (PdfCraft's MSI said PrintCraft until 0.2.1).
pub former_names: &'static [&'static str],
/// Its 128 px PNG icon, for the menu entry written beside an AppImage. `None`: no icon.
pub icon_png: Option<&'static [u8]>,
}
impl App {
/// An app with no former names and no icon.
pub const fn new(id: &'static str, name: &'static str, tagline: &'static str, repo: &'static str) -> Self {
Self { id, name, tagline, repo, former_ids: &[], former_names: &[], icon_png: None }
}
/// `ai.storyteller.<id>`: macOS bundle id, Flatpak id and Linux desktop-file id.
pub fn bundle_id(&self) -> String {
format!("ai.storyteller.{}", self.id)
}
pub fn releases_url(&self) -> String {
format!("https://github.com/{}/releases", self.repo)
}
pub fn repo_url(&self) -> String {
format!("https://github.com/{}", self.repo)
}
/// The app's page on getartcraft.com.
pub fn website(&self) -> String {
format!("https://getartcraft.com/apps/{}", self.id)
}
/// Asset stems this app's releases may use, current first.
pub fn stems(&self) -> impl Iterator<Item = &'static str> + '_ {
std::iter::once(self.id).chain(self.former_ids.iter().copied())
}
/// Display names this app's installers may have registered, current first.
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
std::iter::once(self.name).chain(self.former_names.iter().copied())
}
/// Looks up an app in the Craft App Suite catalog by its id.
pub fn by_id(id: &str) -> Option<&'static App> {
crate::suite::by_id(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn former_names_follow_the_current_one() {
let pdf =
App { former_ids: &["printcraft"], former_names: &["PrintCraft"], ..App::new("pdfcraft", "PdfCraft", "PDFs", "storytold/pdfcraft") };
assert_eq!(pdf.stems().collect::<Vec<_>>(), ["pdfcraft", "printcraft"]);
assert_eq!(pdf.names().collect::<Vec<_>>(), ["PdfCraft", "PrintCraft"]);
assert_eq!(pdf.bundle_id(), "ai.storyteller.pdfcraft");
assert_eq!(pdf.releases_url(), "https://github.com/storytold/pdfcraft/releases");
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Menu entries (freedesktop `.desktop` files) for AppImages the launcher installs.
//!
//! An AppImage installs nothing by itself, so without an entry the app is missing from the
//! desktop's menu and, on Wayland, the dock can't match the window's app id to an icon. The
//! launcher writes `<data>/applications/ai.storyteller.<app>.desktop` (the id each app's window
//! reports) and marks it as its own, so it only ever replaces or removes entries it wrote.
//!
//! The text is built by pure functions, tested on every platform; only Linux writes the files.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::Path;
use crate::App;
/// The key that marks an entry as written by the launcher.
pub const MARKER: &str = "X-ArtCraft-Launcher=true";
/// The menu categories and the window class each app's own `.desktop` file declares.
pub fn meta(app: &App) -> (&'static str, Option<&'static str>) {
match app.id {
"photocraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", Some("photocraft")),
"pdfcraft" => ("Office;Viewer;", Some("pdfcraft")),
"lightcraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", None),
"filmcraft" => ("AudioVideo;Video;AudioVideoEditing;", None),
"designcraft" => ("Graphics;Publishing;", None),
"vectorcraft" => ("Graphics;2DGraphics;VectorGraphics;", None),
"effectcraft" => ("AudioVideo;Video;Graphics;2DGraphics;", None),
"wordcraft" => ("Office;WordProcessor;", None),
"deckcraft" => ("Office;Presentation;", None),
"gridcraft" => ("Office;Spreadsheet;", None),
"cadcraft" => ("Graphics;Engineering;VectorGraphics;", None),
"soundcraft" => ("AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;", None),
"figcraft" => ("Graphics;2DGraphics;VectorGraphics;X-Design;", Some("figcraft")),
_ => ("Graphics;", None),
}
}
/// `path` as one quoted argument of an `Exec=` key, per the Desktop Entry spec: inside double
/// quotes `"`, `` ` ``, `$` and `\` take a backslash, then the string-value rule doubles every
/// backslash, and `%` (field codes) is written `%%`. `None` for paths that can't be written
/// safely (not UTF-8, holding a control character such as a line break, or holding `=`, which the
/// spec forbids in the executable name or path even when quoted).
pub fn exec_quote(path: &Path) -> Option<String> {
let s = path.to_str()?;
if s.chars().any(|c| c.is_control() || c == '=') {
return None;
}
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' | '`' | '$' => {
out.push_str("\\\\");
out.push(c);
}
'\\' => out.push_str("\\\\\\\\"),
'%' => out.push_str("%%"),
c => out.push(c),
}
}
out.push('"');
Some(out)
}
/// A localestring/string value: backslashes and line breaks escaped.
fn value(s: &str) -> String {
s.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t")
}
/// The entry for `app` installed as the AppImage `appimage`; `icon` when its icon is installed
/// under the app's id. `None` when the path can't be put in an `Exec=` line.
pub fn render(app: &App, appimage: &Path, icon: bool) -> Option<String> {
let exec = exec_quote(appimage)?;
let (categories, wm_class) = meta(app);
let mut text = String::from("[Desktop Entry]\nType=Application\n");
text.push_str(&format!("Name={}\n", value(app.name)));
text.push_str(&format!("Comment={}\n", value(app.tagline)));
text.push_str(&format!("Exec={exec} %F\n"));
if icon {
text.push_str(&format!("Icon={}\n", app.bundle_id()));
}
text.push_str("Terminal=false\n");
text.push_str(&format!("Categories={categories}\n"));
text.push_str(&format!("StartupWMClass={}\n", wm_class.map_or_else(|| app.bundle_id(), str::to_string)));
text.push_str(MARKER);
text.push('\n');
Some(text)
}
/// Whether the launcher wrote this entry.
pub fn is_ours(text: &str) -> bool {
text.lines().any(|l| l.trim() == MARKER)
}
/// The program an entry's `Exec=` runs: its first argument, unquoted. Reads the launcher's own
/// quoting and the plain `Exec="<path>" %F` the apps' AppImages write for themselves.
pub fn exec_program(text: &str) -> Option<String> {
let line = text.lines().find_map(|l| l.strip_prefix("Exec="))?;
// String-value escapes first, then the quoting rule.
let mut unescaped = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('s') => unescaped.push(' '),
Some('n') => unescaped.push('\n'),
Some('t') => unescaped.push('\t'),
Some('r') => unescaped.push('\r'),
Some(other) => unescaped.push(other),
None => unescaped.push('\\'),
}
} else {
unescaped.push(c);
}
}
let mut out = String::new();
let mut chars = unescaped.trim_start().chars();
if unescaped.trim_start().starts_with('"') {
chars.next();
loop {
match chars.next()? {
'"' => break,
'\\' => out.push(chars.next()?),
c => out.push(c),
}
}
} else {
out.extend(chars.take_while(|c| !c.is_whitespace()));
}
Some(out.replace("%%", "%"))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn app(id: &str) -> &'static App {
App::by_id(id).unwrap()
}
#[test]
fn writes_a_complete_entry() {
let text = render(app("photocraft"), Path::new("/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage"), true).unwrap();
let expected = format!(
"[Desktop Entry]\nType=Application\nName=PhotoCraft\nComment={}\n\
Exec=\"/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage\" %F\n\
Icon=ai.storyteller.photocraft\nTerminal=false\nCategories=Graphics;2DGraphics;RasterGraphics;Photography;\n\
StartupWMClass=photocraft\nX-ArtCraft-Launcher=true\n",
app("photocraft").tagline
);
assert_eq!(text, expected);
assert!(is_ours(&text));
let no_icon = render(app("wordcraft"), Path::new("/x/w.AppImage"), false).unwrap();
assert!(!no_icon.contains("Icon="));
assert!(no_icon.contains("StartupWMClass=ai.storyteller.wordcraft\n"));
assert!(no_icon.contains("Categories=Office;WordProcessor;\n"));
}
#[test]
fn quotes_exec_paths_per_the_spec() {
assert_eq!(exec_quote(Path::new("/home/ana/My Apps/p.AppImage")).unwrap(), "\"/home/ana/My Apps/p.AppImage\"");
assert_eq!(exec_quote(Path::new("/a/\"q\" $HOME `x`.AppImage")).unwrap(), r#""/a/\\"q\\" \\$HOME \\`x\\`.AppImage""#);
assert_eq!(exec_quote(Path::new(r"/a\b/100%.AppImage")).unwrap(), r#""/a\\\\b/100%%.AppImage""#);
assert_eq!(exec_quote(Path::new("/a/line\nbreak.AppImage")), None);
// The spec forbids `=` in the executable name or path, even quoted.
assert_eq!(exec_quote(Path::new("/opt/artcraft=beta/app")), None);
assert!(render(app("photocraft"), Path::new("/a/line\nbreak.AppImage"), true).is_none());
}
#[test]
fn reads_back_the_program_it_wrote_and_the_apps_own_entries() {
for p in ["/home/ana/My Apps/p.AppImage", "/a/\"q\" $HOME `x`.AppImage", r"/a\b/100%.AppImage", "/plain.AppImage"] {
let text = render(app("gridcraft"), Path::new(p), false).unwrap();
assert_eq!(exec_program(&text).map(PathBuf::from), Some(PathBuf::from(p)), "{p}");
}
// PhotoCraft's AppRun writes its own entry, unmarked.
let theirs = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage\" %F\n";
assert_eq!(exec_program(theirs).as_deref(), Some("/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage"));
assert!(!is_ours(theirs));
assert_eq!(exec_program("[Desktop Entry]\nExec=photocraft %F\n").as_deref(), Some("photocraft"));
assert_eq!(exec_program("[Desktop Entry]\nName=x\n"), None);
}
}
+777
View File
@@ -0,0 +1,777 @@
//! Finding Craft Apps that are already installed, however they got there.
//!
//! - macOS: `<App>.app` in `/Applications` or `~/Applications`, read from its `Info.plist`.
//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable
//! copies the launcher unpacked.
//! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them),
//! `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball
//! installs.
//! - FreeBSD: tarball installs and `/usr/local/bin/<app>`.
use std::path::{Path, PathBuf};
use crate::{App, InstallSource, Installed, version};
use crate::manifest::Manifest;
/// One look at the system, shared by every app's detection (package lists are read once).
#[derive(Default)]
pub struct Snapshot {
#[allow(dead_code)]
packages: Vec<crate::packages::Package>,
}
impl Snapshot {
pub fn take() -> Self {
#[cfg(target_os = "linux")]
{
Self { packages: linux::packages() }
}
#[cfg(not(target_os = "linux"))]
{
Self::default()
}
}
}
/// What's installed of `app`, best source first.
pub fn detect(app: &App, manifest: &Manifest, snapshot: &Snapshot) -> Option<Installed> {
let _ = snapshot;
if let Some(found) = from_manifest(app, manifest) {
return Some(found);
}
#[cfg(target_os = "macos")]
return macos::detect(app);
#[cfg(windows)]
return windows::detect(app);
#[cfg(target_os = "linux")]
return linux::detect(app, snapshot);
#[cfg(target_os = "freebsd")]
return freebsd::detect(app);
#[allow(unreachable_code)]
None
}
fn from_manifest(app: &App, manifest: &Manifest) -> Option<Installed> {
let rec = manifest.apps.get(app.id)?;
if !rec.path.exists() {
return None;
}
// On macOS the bundle itself is the truth (the app may have updated itself).
#[cfg(target_os = "macos")]
if rec.kind == crate::PackageKind::Dmg {
return macos::read_bundle(app, &rec.path);
}
Some(Installed {
version: version::parse(&rec.version),
source: InstallSource::Launcher,
location: rec.path.display().to_string(),
package: None,
})
}
/// Whether a file name is an AppImage of the app with asset stems `stems`, and the version its
/// name gives. Matches only the names a release AppImage ends up with: the release's own
/// (`photocraft-0.6.0-linux-x86_64.AppImage`) and the bare app name Gear Lever renames it to
/// (`photocraft.appimage`, version unknown). The extension's case doesn't matter.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option<Option<String>> {
let split = name.len().checked_sub(".appimage".len())?;
let (base, ext) = (name.get(..split)?, name.get(split..)?);
if !ext.eq_ignore_ascii_case(".appimage") {
return None;
}
if stems.iter().any(|s| base.eq_ignore_ascii_case(s)) {
return Some(None);
}
let parsed = crate::platform::parse_asset_name(&format!("{base}.AppImage"), stems)?;
(parsed.kind == crate::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version))
}
/// Who our Windows Installer packages name as their publisher.
#[cfg_attr(not(windows), allow(dead_code))]
const PUBLISHER: &str = "Learning Machines LLC";
/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its
/// installers have registered.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_installer_entry(app: &App, display_name: &str, publisher: &str) -> bool {
publisher == PUBLISHER && app.names().any(|n| n == display_name)
}
/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's
/// `InstallLocation`, then `<App>` under each Program Files root for each name the app has had,
/// and in each folder `<stem>.exe` for each stem, current first. Doesn't touch the disk.
#[cfg_attr(not(windows), allow(dead_code))]
fn exe_candidates(app: &App, location: Option<&Path>, program_files: &[PathBuf]) -> Vec<PathBuf> {
let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n)));
let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files);
dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect()
}
/// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn parse_cli_version(line: &str, app: &App) -> Option<version::Version> {
let line = line.trim();
for prefix in app.stems().chain(std::iter::once(app.name)) {
if let Some(rest) = strip_cli_prefix(line, prefix)
&& let Some(tok) = rest.split_whitespace().next()
&& let Some(v) = version::parse(tok)
{
return Some(v);
}
}
version::parse_package(line)
}
fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let (head, rest) = line.split_at_checked(prefix.len())?;
if !head.eq_ignore_ascii_case(prefix) {
return None;
}
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_ascii_whitespace() => Some(rest),
_ => None,
}
}
#[cfg(target_os = "macos")]
pub mod macos {
use std::path::{Path, PathBuf};
use super::{App, InstallSource, Installed, version};
/// Where a `.app` might be: the system and the user's Applications folders.
pub fn candidates(app: &App) -> Vec<PathBuf> {
let bundle = format!("{}.app", app.name);
let mut out = vec![Path::new("/Applications").join(&bundle)];
if let Some(home) = dirs::home_dir() {
out.push(home.join("Applications").join(&bundle));
}
out
}
pub fn detect(app: &App) -> Option<Installed> {
candidates(app).iter().find_map(|p| read_bundle(app, p))
}
/// Reads a bundle's `Info.plist`; `None` unless it's really this app.
pub fn read_bundle(app: &App, path: &Path) -> Option<Installed> {
let plist = plist::Value::from_file(path.join("Contents/Info.plist")).ok()?;
let dict = plist.as_dictionary()?;
if dict.get("CFBundleIdentifier")?.as_string()? != app.bundle_id() {
return None;
}
// `<App>Version` carries the full version including a pre-release suffix;
// CFBundleShortVersionString only has the numbers.
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 })
}
}
#[cfg(windows)]
pub mod windows {
use std::path::PathBuf;
use winreg::RegKey;
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY};
use super::{App, InstallSource, Installed, exe_candidates, is_installer_entry, version};
const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
/// Reads the ProductVersion string from a program's VERSIONINFO, in the first language that
/// has one, without running the program or any other process.
pub fn executable_version(path: &std::path::Path) -> Option<crate::version::Version> {
let path = dunce::canonicalize(path).ok()?;
let info = winsafe::HVERSIONINFO::GetFileVersionInfo(path.to_str()?).ok()?;
let listed = info.langs_and_cps().map(<[_]>::to_vec).unwrap_or_default();
let product = string_tables(&listed).into_iter().find_map(|table| info.str_val(table, "ProductVersion").ok())?;
version::parse(product.trim())
}
/// The string tables to look in: the languages the file lists, then U.S. English in UTF-16,
/// Windows-1252 and no code page, as .NET's `FileVersionInfo` also tries when a file lists none
/// or its listed one has no strings.
pub(crate) fn string_tables(listed: &[(winsafe::LANGID, winsafe::co::CP)]) -> Vec<(winsafe::LANGID, winsafe::co::CP)> {
use winsafe::{LANGID, co};
let us = LANGID::new(co::LANG::ENGLISH, co::SUBLANG::ENGLISH_US);
listed.iter().copied().chain([(us, co::CP::UTF16), (us, co::CP::WINDOWS_1252), (us, co::CP::ACP)]).collect()
}
/// An MSI install found in the registry.
pub struct MsiInstall {
pub product_code: String,
pub version: Option<crate::version::Version>,
pub exe: Option<PathBuf>,
}
/// Every Windows Installer entry for `app`, newest first.
pub fn msi_installs(app: &App) -> Vec<MsiInstall> {
let mut out = Vec::new();
for (hive, flags) in [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)] {
let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue };
for code in root.enum_keys().flatten() {
let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue };
let name: String = key.get_value("DisplayName").unwrap_or_default();
let publisher: String = key.get_value("Publisher").unwrap_or_default();
if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {
continue;
}
let version = key.get_value::<String, _>("DisplayVersion").ok().and_then(|v| version::parse(&v));
let location: String = key.get_value("InstallLocation").unwrap_or_default();
let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)).or_else(|| install_dir(app)));
out.push(MsiInstall { product_code: code, version, exe });
}
}
out.sort_by(|a, b| match (&b.version, &a.version) {
(Some(x), Some(y)) => version::compare(x, y),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
out
}
/// The folder the app's MSI remembers for its next upgrade, which is where it went when the
/// launcher chose the folder.
fn install_dir(app: &App) -> Option<PathBuf> {
[KEY_WOW64_64KEY, KEY_WOW64_32KEY].into_iter().find_map(|flags| {
let key = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?;
key.get_value::<String, _>("InstallDir").ok().filter(|d| !d.is_empty()).map(PathBuf::from)
})
}
/// The app's exe: from `App Paths`, the install folder, or Program Files.
fn exe_path(app: &App, location: Option<PathBuf>) -> Option<PathBuf> {
for stem in app.stems() {
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.exe");
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
&& let Ok(path) = key.get_value::<String, _>("")
{
let p = PathBuf::from(path.trim_matches('"'));
if p.exists() {
return Some(p);
}
}
}
}
let mut roots = Vec::new();
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
if let Some(pf) = std::env::var_os(var) {
roots.push(PathBuf::from(pf));
}
}
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
}
pub fn detect(app: &App) -> Option<Installed> {
let m = msi_installs(app).into_iter().next()?;
Some(Installed {
version: m.version,
source: InstallSource::Msi,
location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),
package: Some(m.product_code),
})
}
}
#[cfg(target_os = "linux")]
pub mod linux {
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::{App, InstallSource, Installed, Snapshot, appimage_version, parse_cli_version, version};
use crate::packages::{self, Package};
use crate::process::capture;
/// Installed Craft App packages from every package manager on this system (dpkg, rpm,
/// pacman, Flatpak). Read once per detection pass.
pub fn packages() -> Vec<Package> {
let ours = |name: &str| {
crate::suite::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
let t = Duration::from_secs(10);
let mut out = Vec::new();
let mut add = |manager, list: Vec<(String, String)>| {
out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version }));
};
if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) {
add(InstallSource::Deb, packages::parse_dpkg(&list));
}
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
add(InstallSource::Rpm, packages::parse_rpm(&list));
}
// Only run `pacman` where its database exists: elsewhere `pacman` on PATH may be an unrelated program
// (the Pac-Man game in /usr/games on Debian-family systems).
if has_pacman_db()
&& let Some(list) = capture("pacman", &["-Q"], t)
{
add(InstallSource::Pacman, packages::parse_pacman(&list));
}
let arch = format!("--arch={}", std::env::consts::ARCH);
if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--columns=application,version"], t) {
let mut flatpaks = packages::parse_flatpak_list(&list);
// Bundles built from a release tarball often list no version: ask Flatpak directly.
for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) {
if let Some(v) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) {
*version = v;
}
}
add(InstallSource::Flatpak, flatpaks);
}
out
}
/// The local package database of Arch's pacman.
pub(crate) const PACMAN_DB: &str = "/var/lib/pacman/local";
/// True when `db` is a directory, i.e. this system really uses pacman.
pub(crate) fn pacman_db_present(db: &Path) -> bool {
db.is_dir()
}
/// True when the system's default pacman database directory exists.
pub(crate) fn has_pacman_db() -> bool {
pacman_db_present(Path::new(PACMAN_DB))
}
/// Where a package put the app's binary: on `PATH`, else `/usr/bin/<id>`.
fn binary_on_path(id: &str) -> String {
std::env::var_os("PATH")
.and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()))
.unwrap_or_else(|| PathBuf::from("/usr/bin").join(id))
.display()
.to_string()
}
/// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention).
pub fn appimage_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Applications"))
}
/// Every folder searched for AppImages, the launcher's own first: `~/Applications`,
/// `~/AppImages` (Gear Lever's default) and `~/.local/bin`.
pub fn appimage_dirs() -> Vec<PathBuf> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
vec![home.join("Applications"), home.join("AppImages"), home.join(".local").join("bin")]
}
/// AppImages of `app` in `dirs`, newest first (unknown versions last; earlier folders win
/// ties).
pub fn appimages_in(app: &App, dirs: &[PathBuf]) -> Vec<(PathBuf, Option<version::Version>)> {
let stems: Vec<&str> = app.stems().collect();
let mut out: Vec<(PathBuf, Option<version::Version>)> = Vec::new();
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
let mut found: Vec<(PathBuf, Option<version::Version>)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let version = appimage_version(&name, &stems)?;
let path = e.path();
path.is_file().then(|| (path, version.as_deref().and_then(version::parse)))
})
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
out.extend(found);
}
// Stable sort: equal versions keep the folder order above.
out.sort_by(|a, b| match (&b.1, &a.1) {
(Some(x), Some(y)) => version::compare(x, y),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
out
}
/// AppImages of `app` in any of the usual folders, newest first.
pub fn appimages(app: &App) -> Vec<(PathBuf, Option<version::Version>)> {
appimages_in(app, &appimage_dirs())
}
pub fn detect(app: &App, snapshot: &Snapshot) -> Option<Installed> {
if let Some((path, version)) = appimages(app).into_iter().next() {
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
}
let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
if let Some(p) = packages::find(app, &snapshot.packages, &order) {
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) });
}
detect_system_install(app)
}
/// When the package managers don't report the app (a query timed out or was locked, an
/// unlisted package name, or a sandbox that can't see them) but its binary is there. It is
/// reported as a system binary: the launcher can open it but leaves updating and removing it
/// to whatever installed it, since it can't tell which package manager that was.
fn detect_system_install(app: &App) -> Option<Installed> {
let bin = find_binary(app.id)?;
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
fn find_binary(id: &str) -> Option<PathBuf> {
let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()));
let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin")));
on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file()))
}
pub(crate) fn binary_version(bin: &Path, app: &App) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
}
}
#[cfg(target_os = "freebsd")]
pub mod freebsd {
use std::path::Path;
use std::time::Duration;
use super::{App, InstallSource, Installed, parse_cli_version, version};
pub fn detect(app: &App) -> Option<Installed> {
let bin = Path::new("/usr/local/bin").join(app.id);
if !bin.exists() {
return None;
}
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
fn binary_version(bin: &Path, app: &App) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_tests {
use super::*;
#[test]
fn ours_filter_matches_rpm_packages() {
let ours = |name: &str| {
crate::suite::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
assert!(ours("photocraft"));
assert!(ours("ai.storyteller.photocraft"));
assert!(!ours("unrelated-package"));
}
#[test]
fn pacman_is_only_queried_where_its_database_exists() {
let root = tempfile::tempdir().unwrap();
assert!(!linux::pacman_db_present(&root.path().join("local")));
std::fs::create_dir(root.path().join("local")).unwrap();
assert!(linux::pacman_db_present(&root.path().join("local")));
}
/// `linux::binary_version` on a script the test just wrote. Another test thread that forks
/// while the script is still open for writing holds that descriptor until it execs, and running
/// the script in that window fails with ETXTBSY ("Text file busy"), so a miss is retried.
fn binary_version_of_new_script(bin: &Path, app: &App) -> Option<version::Version> {
for _ in 0..20 {
if let Some(v) = linux::binary_version(bin, app) {
return Some(v);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
None
}
#[test]
fn reads_binary_version_from_cli() {
let photo = App::by_id("photocraft").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let bin = temp_dir.path().join("photocraft");
std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let ver = binary_version_of_new_script(&bin, photo);
assert_eq!(ver.unwrap().to_string(), "0.6.0");
}
#[test]
fn reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id() {
let temp_dir = tempfile::tempdir().unwrap();
use std::os::unix::fs::PermissionsExt;
let write = |name: &str, script: &str| {
let bin = temp_dir.path().join(name);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
};
let sound = write("soundcraft", "#!/bin/sh\necho 'SoundCraft 0.5.0'\n");
assert_eq!(binary_version_of_new_script(&sound, App::by_id("soundcraft").unwrap()).unwrap().to_string(), "0.5.0");
let pdf = write("pdfcraft", "#!/bin/sh\necho 'printcraft 0.2.1'\n");
assert_eq!(binary_version_of_new_script(&pdf, App::by_id("pdfcraft").unwrap()).unwrap().to_string(), "0.2.1");
}
}
#[cfg(test)]
mod tests {
use crate::SUITE;
use super::*;
/// A file without VERSIONINFO, or one that isn't there, has no version.
#[cfg(windows)]
#[test]
fn a_file_without_version_info_has_no_version() {
let dir = tempfile::tempdir().unwrap();
let text = dir.path().join("photocraft.exe");
std::fs::write(&text, "not a program").unwrap();
assert_eq!(windows::executable_version(&text), None);
assert_eq!(windows::executable_version(&dir.path().join("missing.exe")), None);
}
/// Without a listed language, the version is still looked for in the string tables .NET's
/// `FileVersionInfo` falls back to (040904B0, 040904E4, 04090000), after any listed one.
#[cfg(windows)]
#[test]
fn the_version_is_looked_for_in_the_usual_string_tables_too() {
use winsafe::{LANGID, co};
let key = |(lang, cp): (LANGID, co::CP)| format!("{:04x}{:04x}", u16::from(lang), u16::from(cp));
let keys = |listed: &[(LANGID, co::CP)]| windows::string_tables(listed).into_iter().map(key).collect::<Vec<_>>();
assert_eq!(keys(&[]), ["040904b0", "040904e4", "04090000"]);
let german = (LANGID::new(co::LANG::GERMAN, co::SUBLANG::GERMAN), co::CP::UTF16);
assert_eq!(keys(&[german]), ["040704b0", "040904b0", "040904e4", "04090000"]);
}
/// A program whose VERSIONINFO has a ProductVersion in the U.S. English UTF-16 table but no
/// `\VarFileInfo\Translation` entry still has a version.
#[cfg(windows)]
#[test]
fn a_version_without_a_translation_entry_is_found() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("photocraft.exe");
std::fs::write(&exe, resource_only_program("1.2.3")).unwrap();
assert_eq!(windows::executable_version(&exe), version::parse("1.2.3"));
}
/// A VERSIONINFO block node: length, value length, type, the key, then the value and the
/// children, each aligned to 4 bytes.
#[cfg(windows)]
fn version_node(key: &str, value: &[u8], value_length: u16, text: bool, children: &[Vec<u8>]) -> Vec<u8> {
let align = |bytes: &mut Vec<u8>| bytes.resize(bytes.len().next_multiple_of(4), 0);
let mut node = vec![0, 0];
node.extend(value_length.to_le_bytes());
node.extend(u16::from(text).to_le_bytes());
node.extend(key.encode_utf16().chain([0]).flat_map(u16::to_le_bytes));
align(&mut node);
node.extend(value);
for child in children {
align(&mut node);
node.extend(child);
}
let length = u16::try_from(node.len()).unwrap();
node[..2].copy_from_slice(&length.to_le_bytes());
node
}
/// A 64-bit program file holding only a version resource (`ProductVersion` = `product`, in
/// the 040904B0 string table) and no `\VarFileInfo\Translation` entry.
#[cfg(windows)]
fn resource_only_program(product: &str) -> Vec<u8> {
let put = |bytes: &mut Vec<u8>, at: usize, value: &[u8]| bytes[at..at + value.len()].copy_from_slice(value);
let text: Vec<u8> = product.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
let string = version_node("ProductVersion", &text, u16::try_from(product.len() + 1).unwrap(), true, &[]);
let table = version_node("040904b0", &[], 0, true, &[string]);
let strings = version_node("StringFileInfo", &[], 0, true, &[table]);
let mut fixed = vec![0u8; 52];
put(&mut fixed, 0, &0xFEEF_04BDu32.to_le_bytes());
put(&mut fixed, 4, &0x0001_0000u32.to_le_bytes());
let info = version_node("VS_VERSION_INFO", &fixed, 52, false, &[strings]);
// The resource section: type 16 (RT_VERSION) > id 1 > language 0x0409 > the data.
const RVA: u32 = 0x1000;
let mut rsrc = vec![0u8; 0x58];
for (dir, id, next) in [(0x00, 16u32, 0x8000_0018u32), (0x18, 1, 0x8000_0030), (0x30, 0x0409, 0x48)] {
put(&mut rsrc, dir + 14, &1u16.to_le_bytes());
put(&mut rsrc, dir + 16, &id.to_le_bytes());
put(&mut rsrc, dir + 20, &next.to_le_bytes());
}
put(&mut rsrc, 0x48, &(RVA + 0x58).to_le_bytes());
put(&mut rsrc, 0x4C, &u32::try_from(info.len()).unwrap().to_le_bytes());
rsrc.extend(&info);
let raw = u32::try_from(rsrc.len().next_multiple_of(0x200)).unwrap();
let rsrc_len = u32::try_from(rsrc.len()).unwrap();
let mut file = vec![0u8; 0x200];
put(&mut file, 0, b"MZ");
put(&mut file, 0x3C, &0x40u32.to_le_bytes());
put(&mut file, 0x40, b"PE\0\0");
// COFF header: x64, one section, a 240-byte optional header, an executable DLL image.
put(&mut file, 0x44, &0x8664u16.to_le_bytes());
put(&mut file, 0x46, &1u16.to_le_bytes());
put(&mut file, 0x54, &240u16.to_le_bytes());
put(&mut file, 0x56, &0x2022u16.to_le_bytes());
// PE32+ optional header.
let opt = 0x58;
put(&mut file, opt, &0x20Bu16.to_le_bytes());
put(&mut file, opt + 8, &raw.to_le_bytes());
put(&mut file, opt + 24, &0x1_4000_0000u64.to_le_bytes());
put(&mut file, opt + 32, &0x1000u32.to_le_bytes());
put(&mut file, opt + 36, &0x200u32.to_le_bytes());
put(&mut file, opt + 40, &6u16.to_le_bytes());
put(&mut file, opt + 48, &6u16.to_le_bytes());
put(&mut file, opt + 56, &(RVA + rsrc_len.next_multiple_of(0x1000)).to_le_bytes());
put(&mut file, opt + 60, &0x200u32.to_le_bytes());
put(&mut file, opt + 68, &2u16.to_le_bytes());
put(&mut file, opt + 72, &0x10_0000u64.to_le_bytes());
put(&mut file, opt + 80, &0x1000u64.to_le_bytes());
put(&mut file, opt + 88, &0x10_0000u64.to_le_bytes());
put(&mut file, opt + 96, &0x1000u64.to_le_bytes());
put(&mut file, opt + 108, &16u32.to_le_bytes());
put(&mut file, opt + 112 + 2 * 8, &RVA.to_le_bytes());
put(&mut file, opt + 112 + 2 * 8 + 4, &rsrc_len.to_le_bytes());
// The .rsrc section header.
let section = opt + 240;
put(&mut file, section, b".rsrc\0\0\0");
put(&mut file, section + 8, &rsrc_len.to_le_bytes());
put(&mut file, section + 12, &RVA.to_le_bytes());
put(&mut file, section + 16, &raw.to_le_bytes());
put(&mut file, section + 20, &0x200u32.to_le_bytes());
put(&mut file, section + 36, &0x4000_0040u32.to_le_bytes());
rsrc.resize(usize::try_from(raw).unwrap(), 0);
file.extend(rsrc);
file
}
#[test]
fn recognises_appimage_names_conservatively() {
let pdf = ["pdfcraft", "printcraft"];
let v = |n: &str| appimage_version(n, &pdf);
assert_eq!(v("pdfcraft-0.3.0-linux-x86_64.AppImage"), Some(Some("0.3.0".into())));
assert_eq!(v("printcraft-0.2.1-linux-aarch64.appimage"), Some(Some("0.2.1".into())));
assert_eq!(v("pdfcraft-0.3.0-rc.1-linux-x86_64.AppImage"), Some(Some("0.3.0-rc.1".into())));
// Gear Lever's rename, and a plain rename by hand.
assert_eq!(v("pdfcraft.appimage"), Some(None));
assert_eq!(v("PdfCraft.AppImage"), Some(None));
for other in [
"pdfcraft-0.3.0-linux-x86_64.AppImage.zsync",
"pdfcraft-0.3.0-linux-x86_64.deb",
"pdfcraft-0.3.0-windows-x64.AppImage",
"pdfcraft-cli-0.3.0-linux-x86_64.AppImage",
"photocraft-0.6.0-linux-x86_64.AppImage",
"mypdfcraft.AppImage",
"pdfcraft-latest.AppImage",
".appimage",
"x",
] {
assert_eq!(v(other), None, "{other}");
}
}
#[test]
fn parse_cli_version_accepts_id_display_name_and_former_id() {
let photo = App::by_id("photocraft").unwrap();
let sound = App::by_id("soundcraft").unwrap();
let pdf = App::by_id("pdfcraft").unwrap();
assert_eq!(parse_cli_version("photocraft 0.7.0 (dev build)", photo).unwrap().to_string(), "0.7.0");
assert_eq!(parse_cli_version("SoundCraft 0.5.0", sound).unwrap().to_string(), "0.5.0");
assert_eq!(parse_cli_version("printcraft 0.2.1", pdf).unwrap().to_string(), "0.2.1");
assert_eq!(parse_cli_version("PdfCraft 0.6.0", pdf).unwrap().to_string(), "0.6.0");
assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn finds_appimages_in_every_folder_newest_first() {
let app = App::by_id("photocraft").unwrap();
let root = tempfile::tempdir().unwrap();
let (apps, gear, bin) = (root.path().join("Applications"), root.path().join("AppImages"), root.path().join("bin"));
for d in [&apps, &gear, &bin] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(apps.join("wordcraft-0.3.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(gear.join("photocraft.appimage"), b"").unwrap();
std::fs::write(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::create_dir(bin.join("photocraft.AppImage")).unwrap();
let found = linux::appimages_in(app, &[apps.clone(), gear.clone(), bin.clone(), root.path().join("missing")]);
let names: Vec<_> = found.iter().map(|(p, v)| (p.clone(), v.as_ref().map(ToString::to_string))).collect();
assert_eq!(
names,
[
(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), Some("0.6.0".to_string())),
(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), Some("0.5.0".to_string())),
(gear.join("photocraft.appimage"), None),
]
);
}
const LEARNING_MACHINES: &str = "Learning Machines LLC";
fn roots() -> Vec<PathBuf> {
vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")]
}
#[test]
fn pdfcraft_matches_its_former_printcraft_installer_name() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's");
}
#[test]
fn pdfcraft_still_matches_its_own_installer_name() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES));
}
#[test]
fn printcraft_entries_match_nothing_else() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher"));
assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES));
for a in SUITE.iter().filter(|a| a.id != "pdfcraft") {
assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name);
}
}
#[test]
fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() {
let pdf = App::by_id("pdfcraft").unwrap();
let location = PathBuf::from(r"D:\Apps\PrintCraft");
let roots = roots();
let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")];
for root in &roots {
for dir in ["PdfCraft", "PrintCraft"] {
want.push(root.join(dir).join("pdfcraft.exe"));
want.push(root.join(dir).join("printcraft.exe"));
}
}
assert_eq!(exe_candidates(pdf, Some(&location), &roots), want);
}
#[test]
fn apps_without_former_names_match_and_find_their_exe_as_before() {
let roots = roots();
let location = PathBuf::from(r"D:\Apps\Somewhere");
for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) {
assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name);
assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name);
assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name);
let exe = format!("{}.exe", a.id);
let in_program_files: Vec<PathBuf> = roots.iter().map(|r| r.join(a.name).join(&exe)).collect();
assert_eq!(exe_candidates(a, None, &roots), in_program_files);
let with_location: Vec<PathBuf> = std::iter::once(location.join(&exe)).chain(in_program_files).collect();
assert_eq!(exe_candidates(a, Some(&location), &roots), with_location);
}
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Startup update prompt dialog for egui frontends.
use egui::{Align, Align2, Color32, Layout, RichText, Vec2};
use crate::app::App;
use crate::release::Release;
/// What the user chose in the startup update prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StartupPromptResponse {
/// Dialog is still open and waiting for user input.
Pending,
/// User chose to update and restart now.
UpdateAndRestart,
/// User chose to download manually via browser.
Download,
/// User dismissed the dialog for now.
Later,
}
/// State for the startup update prompt dialog.
#[derive(Clone, Debug)]
pub struct StartupPrompt {
pub app: &'static App,
pub release: Release,
pub running_version: String,
pub is_self_updating: bool,
pub open: bool,
pub status_message: Option<String>,
pub is_installing: bool,
}
impl StartupPrompt {
/// Creates a new prompt dialog state for the given app and newly discovered release.
pub fn new(app: &'static App, release: Release, running_version: impl Into<String>) -> Self {
let is_self_updating = crate::self_update::is_self_updating(app);
Self { app, release, running_version: running_version.into(), is_self_updating, open: true, status_message: None, is_installing: false }
}
/// Renders the modal window on the given egui context.
pub fn show(&mut self, ctx: &egui::Context) -> StartupPromptResponse {
if !self.open {
return StartupPromptResponse::Later;
}
let mut response = StartupPromptResponse::Pending;
// Modal dimmed background overlay; it senses clicks so they don't reach widgets underneath.
let screen_rect = ctx.content_rect();
egui::Area::new(egui::Id::new("craft_update_backdrop")).order(egui::Order::Middle).fixed_pos(screen_rect.min).show(ctx, |ui| {
let (rect, _) = ui.allocate_exact_size(screen_rect.size(), egui::Sense::click_and_drag());
ui.painter().rect_filled(rect, 0.0, Color32::from_black_alpha(150));
});
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
response = StartupPromptResponse::Later;
}
let is_dark = ctx.theme() == egui::Theme::Dark;
let card_bg = if is_dark { Color32::from_rgb(26, 26, 28) } else { Color32::from_rgb(255, 255, 255) };
let border_color = if is_dark { Color32::from_rgb(52, 52, 56) } else { Color32::from_rgb(215, 215, 220) };
let text_primary = if is_dark { Color32::from_rgb(245, 245, 248) } else { Color32::from_rgb(20, 20, 25) };
let text_muted = if is_dark { Color32::from_rgb(150, 150, 160) } else { Color32::from_rgb(105, 105, 115) };
let accent_color = Color32::from_rgb(52, 120, 246);
egui::Area::new(egui::Id::new("craft_update.startup_prompt")).anchor(Align2::CENTER_CENTER, Vec2::ZERO).order(egui::Order::Foreground).show(
ctx,
|ui| {
egui::Frame::new()
.fill(card_bg)
.stroke(egui::Stroke::new(1.0, border_color))
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(20, 18))
.show(ui, |ui| {
ui.set_width(400.0);
ui.spacing_mut().item_spacing = Vec2::new(8.0, 6.0);
// Header row: App badge + title + close
ui.horizontal(|ui| {
let (badge_rect, _) = ui.allocate_exact_size(Vec2::new(34.0, 34.0), egui::Sense::hover());
ui.painter().rect_filled(badge_rect, egui::CornerRadius::ZERO, accent_color);
let initial = self.app.name.chars().next().map(|c| c.to_string()).unwrap_or_else(|| "A".to_string());
ui.painter().text(badge_rect.center(), Align2::CENTER_CENTER, initial, egui::FontId::monospace(18.0), Color32::WHITE);
ui.add_space(4.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(RichText::new(self.app.name).size(15.0).strong().color(text_primary));
ui.label(RichText::new("UPDATE").size(10.0).monospace().color(accent_color));
});
let version_pill = format!("v{} → {}", self.running_version, self.release.version_label());
ui.label(RichText::new(version_pill).size(11.0).monospace().color(text_muted));
});
ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
let x_btn = egui::Button::new(RichText::new("✕").size(12.0).color(text_muted)).frame(false);
if ui.add(x_btn).clicked() {
response = StartupPromptResponse::Later;
}
});
});
ui.add_space(8.0);
if let Some(msg) = &self.status_message {
let alert_bg = if is_dark { Color32::from_rgb(50, 30, 20) } else { Color32::from_rgb(255, 242, 235) };
let alert_color = Color32::from_rgb(225, 95, 30);
egui::Frame::new()
.fill(alert_bg)
.stroke(egui::Stroke::new(1.0, alert_color))
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(msg).size(12.0).color(alert_color));
});
ui.add_space(4.0);
}
if self.is_installing {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("Downloading and installing update…").size(12.0).color(text_primary));
});
} else {
let desc =
format!("{} {} is available with the latest features and bug fixes.", self.app.name, self.release.version_label());
ui.label(RichText::new(desc).size(12.5).color(text_muted));
}
ui.add_space(10.0);
let (hairline_rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 1.0), egui::Sense::hover());
ui.painter().hline(hairline_rect.x_range(), hairline_rect.top(), egui::Stroke::new(1.0, border_color));
ui.add_space(8.0);
// Footer actions
ui.horizontal(|ui| {
let what_new = egui::Hyperlink::from_label_and_url(
RichText::new("What's new").size(11.5).color(accent_color),
&self.release.html_url,
);
ui.add(what_new);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if self.is_installing {
let dismiss_btn = egui::Button::new(RichText::new("DISMISS").size(10.5).monospace().color(text_muted))
.stroke(egui::Stroke::new(1.0, border_color))
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(72.0, 26.0));
if ui.add(dismiss_btn).clicked() {
response = StartupPromptResponse::Later;
}
} else {
if self.is_self_updating {
let update_btn = egui::Button::new(
RichText::new("UPDATE AND RESTART").size(10.5).strong().monospace().color(Color32::WHITE),
)
.fill(accent_color)
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(140.0, 26.0));
if ui.add(update_btn).clicked() {
response = StartupPromptResponse::UpdateAndRestart;
}
} else {
let dl_btn =
egui::Button::new(RichText::new("DOWNLOAD").size(10.5).strong().monospace().color(Color32::WHITE))
.fill(accent_color)
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(90.0, 26.0));
if ui.add(dl_btn).clicked() {
response = StartupPromptResponse::Download;
}
}
let later_btn = egui::Button::new(RichText::new("LATER").size(10.5).monospace().color(text_primary))
.stroke(egui::Stroke::new(1.0, border_color))
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(65.0, 26.0));
if ui.add(later_btn).clicked() {
response = StartupPromptResponse::Later;
}
}
});
});
});
},
);
if response == StartupPromptResponse::Later {
self.open = false;
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::suite::PHOTOCRAFT;
#[test]
fn creates_startup_prompt_dialog() {
let release = Release { version: crate::version::parse("0.2.0"), tag: "v0.2.0".to_string(), ..Release::default() };
let prompt = StartupPrompt::new(&PHOTOCRAFT, release, "0.1.0");
assert_eq!(prompt.app.name, "PhotoCraft");
assert_eq!(prompt.running_version, "0.1.0");
assert!(prompt.open);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Linux distributions: reading `os-release` and choosing the package formats that suit them.
//!
//! The distro decides the system package format, not which tools happen to be on `PATH`: Fedora
//! can have `apt-get` and `dpkg` installed (for building Debian images), and an `.rpm` system
//! must never be handed to apt.
use crate::platform::PackageKind;
/// The parts of `/etc/os-release` that say which distro family this is.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OsRelease {
/// `ID`, lower-cased (`fedora`, `ubuntu`, `opensuse-tumbleweed`, …). Empty when missing.
pub id: String,
/// `ID_LIKE`, lower-cased and split on spaces (`["rhel", "centos", "fedora"]`).
pub id_like: Vec<String>,
}
/// Reads `os-release` text (`KEY=value` lines, values optionally quoted).
pub fn parse_os_release(text: &str) -> OsRelease {
let mut out = OsRelease::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else { continue };
let value = unquote(value.trim()).to_ascii_lowercase();
match key.trim() {
"ID" => out.id = value,
"ID_LIKE" => out.id_like = value.split_whitespace().map(str::to_string).collect(),
_ => {}
}
}
out
}
fn unquote(v: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = v.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
return inner;
}
}
v
}
/// The distro's own package format: `.deb` for the Debian family, `.rpm` for the Fedora, Red Hat
/// and SUSE families, `.pkg.tar.zst` for the Arch Linux family, `None` for anything else
/// (unknown distros), which gets AppImages.
pub fn native_package(os: &OsRelease) -> Option<PackageKind> {
std::iter::once(&os.id).chain(&os.id_like).find_map(|id| family(id))
}
fn family(id: &str) -> Option<PackageKind> {
match id {
"debian" | "ubuntu" => Some(PackageKind::Deb),
"fedora" | "rhel" | "centos" | "suse" | "sles" => Some(PackageKind::Rpm),
_ if id.starts_with("opensuse") => Some(PackageKind::Rpm),
"arch" | "manjaro" | "endeavouros" | "garuda" | "cachyos" | "artix" => Some(PackageKind::ArchPkg),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FEDORA: &str = r#"NAME="Fedora Linux"
VERSION="40 (Workstation Edition)"
ID=fedora
VERSION_ID=40
PRETTY_NAME="Fedora Linux 40 (Workstation Edition)"
"#;
const UBUNTU: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
ID=ubuntu
ID_LIKE=debian
"#;
const MINT: &str = "NAME=\"Linux Mint\"\nID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
const ARCH: &str = "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n";
const TUMBLEWEED: &str = "NAME=\"openSUSE Tumbleweed\"\n# a comment\nID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n";
const ROCKY: &str = "NAME=\"Rocky Linux\"\nID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n";
const UNKNOWN: &str = "NAME=\"Some OS\"\nID=someos\n";
fn native(text: &str) -> Option<PackageKind> {
native_package(&parse_os_release(text))
}
#[test]
fn os_release_is_parsed() {
assert_eq!(parse_os_release(FEDORA), OsRelease { id: "fedora".into(), id_like: vec![] });
assert_eq!(parse_os_release(MINT), OsRelease { id: "linuxmint".into(), id_like: vec!["ubuntu".into(), "debian".into()] });
assert_eq!(parse_os_release("ID='opensuse-leap'\n").id, "opensuse-leap");
assert_eq!(parse_os_release(""), OsRelease::default());
}
#[test]
fn the_distro_family_picks_the_package_format() {
assert_eq!(native(FEDORA), Some(PackageKind::Rpm));
assert_eq!(native(ROCKY), Some(PackageKind::Rpm));
assert_eq!(native(TUMBLEWEED), Some(PackageKind::Rpm));
assert_eq!(native(UBUNTU), Some(PackageKind::Deb));
assert_eq!(native(MINT), Some(PackageKind::Deb));
assert_eq!(native(ARCH), Some(PackageKind::ArchPkg));
assert_eq!(native(UNKNOWN), None);
assert_eq!(native(""), None);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
//! Release checking, downloading, installing and self-updating for the Craft App Suite and ArtCraft Launcher.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod app;
pub mod desktop_entry;
pub mod detect;
pub mod distro;
pub mod http;
pub mod install;
pub mod manifest;
pub mod packages;
pub mod paths;
pub mod platform;
#[cfg(windows)]
pub mod portable;
pub mod process;
pub mod protocol;
pub mod proxy;
pub mod queue;
pub mod release;
pub mod self_update;
pub mod source;
pub mod status;
pub mod suite;
pub mod time;
pub mod version;
#[cfg(feature = "egui")]
pub mod dialog;
pub use app::App;
pub use http::{Http, Stop};
pub use paths::Paths;
pub use platform::{Arch, Os, PackageKind, Platform, pick_asset};
pub use protocol::{InstallLocations, JobPhase, LatestRelease, Source};
pub use release::{Asset, Release, parse_github_release, parse_github_release_list};
pub use status::{InstallSource, Installed};
pub use suite::SUITE;
pub use time::parse_rfc3339;
pub use version::{Version, is_newer, parse as parse_version};
#[cfg(feature = "egui")]
pub use dialog::{StartupPrompt, StartupPromptResponse};
/// Checks for an update against GitHub releases for the given app.
pub fn check_for_update(app: &App, http: &Http, current_version: &str) -> Result<Option<Release>, String> {
let url = format!("https://api.github.com/repos/{}/releases?per_page=30", app.repo);
match http.get_api(&url, None)? {
crate::http::Fetched::Body { text, .. } => {
let latest = crate::release::parse_github_release_list(&text, app.repo, false)?;
if let Some(release) = latest
&& let Some(rel_v) = &release.version
&& let Some(cur_v) = crate::version::parse(current_version)
&& crate::version::is_newer(rel_v, &cur_v)
{
return Ok(Some(release));
}
Ok(None)
}
crate::http::Fetched::RateLimited { .. } => Err("GitHub's request limit was reached".into()),
crate::http::Fetched::NotModified | crate::http::Fetched::NotFound => Ok(None),
}
}
/// Unix seconds now.
pub fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
#[cfg(test)]
pub(crate) const TEST_AGENT: &str = "ArtCraft-Launcher-Test/0.0";
+61
View File
@@ -0,0 +1,61 @@
//! The launcher's record of what it installed itself.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::PackageKind;
use serde::{Deserialize, Serialize};
/// One app the launcher installed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub version: String,
pub kind: PackageKind,
/// The `.app`, the exe, the AppImage, or the folder of an unpacked tarball.
pub path: PathBuf,
/// Unix seconds.
pub installed_at: i64,
/// Every file installed, for tree installs (FreeBSD), so removal takes exactly those.
#[serde(default)]
pub files: Vec<PathBuf>,
/// The files are in a system prefix and removing them needs root.
#[serde(default)]
pub needs_root: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub apps: BTreeMap<String, Record>,
}
impl Manifest {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
crate::paths::write_atomic(path, &bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_and_survives_garbage() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("installed.json");
assert_eq!(Manifest::load(&path), Manifest::default());
let mut m = Manifest::default();
m.apps.insert(
"photocraft".into(),
Record { version: "0.5.0".into(), kind: PackageKind::AppImage, path: "/x/y".into(), installed_at: 1, files: vec![], needs_root: false },
);
m.save(&path).unwrap();
assert_eq!(Manifest::load(&path), m);
std::fs::write(&path, b"{not json").unwrap();
assert_eq!(Manifest::load(&path), Manifest::default());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Reading system package lists (dpkg, rpm, pacman, Flatpak) and matching them to Craft Apps.
//!
//! Pure functions over the tools' text output, so they're tested on every platform; only Linux
//! runs the tools (`detect::linux`).
use crate::{App, InstallSource, version};
/// One installed package that belongs to a Craft App.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Package {
pub manager: InstallSource,
/// The package's name (`photocraft-bin`) or Flatpak app id.
pub name: String,
/// As the package manager reports it (`0.5.0-1`); empty when it doesn't say.
pub version: String,
}
/// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only.
pub fn parse_dpkg(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut f = line.split('|');
match (f.next(), f.next(), f.next()) {
(Some(name), Some("installed"), Some(ver)) => {
let name = name.split_once(':').map(|(n, _)| n).unwrap_or(name);
Some((name.to_string(), ver.to_string()))
}
_ => None,
}
})
.collect()
}
/// `rpm -qa --qf '%{NAME}|%{VERSION}\n'`.
pub fn parse_rpm(text: &str) -> Vec<(String, String)> {
text.lines().filter_map(|line| line.split_once('|').map(|(n, v)| (n.to_string(), v.to_string()))).collect()
}
/// `pacman -Q`: `name version` per line.
pub fn parse_pacman(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut f = line.split_whitespace();
Some((f.next()?.to_string(), f.next()?.to_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)> {
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()))
})
.collect()
}
/// The `Version:` line of `flatpak info <id>`.
pub fn parse_flatpak_info(text: &str) -> Option<String> {
text.lines().find_map(|l| l.trim().strip_prefix("Version:").map(|v| v.trim().to_string())).filter(|v| !v.is_empty())
}
/// The package names a Craft App may be installed under with `manager`. On Arch the AUR carries
/// each app three ways: built from source (`photocraft`), the release binary (`photocraft-bin`)
/// and the latest commit (`photocraft-git`). Debian and RPM packages may use either the short stem
/// (`photocraft`) or the reverse-DNS bundle id (`ai.storyteller.photocraft`).
pub fn candidate_names(app: &App, manager: InstallSource) -> Vec<String> {
match manager {
InstallSource::Flatpak => vec![app.bundle_id()],
InstallSource::Pacman => {
app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).chain(std::iter::once(app.bundle_id())).collect()
}
InstallSource::Deb | InstallSource::Rpm => app.stems().map(str::to_string).chain(std::iter::once(app.bundle_id())).collect(),
_ => app.stems().map(str::to_string).collect(),
}
}
/// The package of `app` among `installed`, preferring the manager listed first in `order`.
pub fn find<'a>(app: &App, installed: &'a [Package], order: &[InstallSource]) -> Option<&'a Package> {
order.iter().find_map(|manager| {
let names = candidate_names(app, *manager);
names.iter().find_map(|n| installed.iter().find(|p| p.manager == *manager && p.name == *n))
})
}
/// A package's version, read the package-manager way (`0.5.0-1` is 0.5.0).
pub fn version_of(p: &Package) -> Option<version::Version> {
version::parse_package(&p.version)
}
#[cfg(test)]
mod tests {
use super::*;
fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec<Package> {
list.into_iter().map(|(name, version)| Package { manager, name, version }).collect()
}
#[test]
fn reads_each_package_managers_listing() {
assert_eq!(
parse_dpkg("photocraft|installed|0.5.0-1\nphotocraft:amd64|installed|0.6.0-1\nwordcraft|config-files|0.3.0-1\n"),
[("photocraft".into(), "0.5.0-1".into()), ("photocraft".into(), "0.6.0-1".into())]
);
assert_eq!(parse_rpm("gridcraft|0.3.0\n"), [("gridcraft".into(), "0.3.0".into())]);
assert_eq!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2);
assert_eq!(
parse_flatpak_list("ai.storyteller.wordcraft\t\nai.storyteller.cadcraft\t0.3.0\n"),
[("ai.storyteller.wordcraft".into(), String::new()), ("ai.storyteller.cadcraft".into(), "0.3.0".into())]
);
let info = "\nWordCraft - Word processor\n\n ID: ai.storyteller.wordcraft\n Version: 0.3.0\n Branch: master\n";
assert_eq!(parse_flatpak_info(info).as_deref(), Some("0.3.0"));
assert_eq!(parse_flatpak_info("Version: \n"), None);
}
#[test]
fn candidate_names_cover_bundle_ids_for_deb_rpm_and_pacman() {
let photo = App::by_id("photocraft").unwrap();
let deb_names = candidate_names(photo, InstallSource::Deb);
assert!(deb_names.contains(&"photocraft".to_string()));
assert!(deb_names.contains(&"ai.storyteller.photocraft".to_string()));
let rpm_names = candidate_names(photo, InstallSource::Rpm);
assert!(rpm_names.contains(&"photocraft".to_string()));
assert!(rpm_names.contains(&"ai.storyteller.photocraft".to_string()));
let pacman_names = candidate_names(photo, InstallSource::Pacman);
assert!(pacman_names.contains(&"photocraft".to_string()));
assert!(pacman_names.contains(&"photocraft-bin".to_string()));
assert!(pacman_names.contains(&"ai.storyteller.photocraft".to_string()));
}
#[test]
fn finds_aur_packages_in_every_flavour() {
let photo = App::by_id("photocraft").unwrap();
let pdf = App::by_id("pdfcraft").unwrap();
let installed =
pkgs(InstallSource::Pacman, parse_pacman("linux 6.11.1.arch1-1\nphotocraft-git 0.3.0.r47.g47f9306-1\nprintcraft-bin 0.2.1-1\n"));
let hit = find(photo, &installed, &[InstallSource::Pacman]).unwrap();
assert_eq!(hit.name, "photocraft-git");
assert_eq!(version_of(hit).unwrap().to_string(), "0.3.0");
// PdfCraft under its old name.
assert_eq!(find(pdf, &installed, &[InstallSource::Pacman]).unwrap().name, "printcraft-bin");
assert!(find(App::by_id("wordcraft").unwrap(), &installed, &[InstallSource::Pacman]).is_none());
}
#[test]
fn prefers_managers_in_order_and_reads_revisions() {
let photo = App::by_id("photocraft").unwrap();
let mut installed = pkgs(InstallSource::Deb, parse_dpkg("photocraft|installed|0.5.0-1\n"));
installed.extend(pkgs(InstallSource::Flatpak, parse_flatpak_list("ai.storyteller.photocraft\t0.4.0\n")));
let hit = find(photo, &installed, &[InstallSource::Deb, InstallSource::Flatpak]).unwrap();
assert_eq!(hit.manager, InstallSource::Deb);
// 0.5.0-1 is the 0.5.0 release, not a pre-release of it.
assert_eq!(version_of(hit).unwrap().to_string(), "0.5.0");
assert_eq!(find(photo, &installed, &[InstallSource::Flatpak]).unwrap().version, "0.4.0");
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Where an installer keeps its own files: downloads, records, logs.
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::app::App;
/// An installer's folders.
#[derive(Clone, Debug)]
pub struct Paths {
/// Records of what was installed, and tarball installs.
pub data: PathBuf,
/// Saved release answers and in-progress downloads.
pub cache: PathBuf,
}
impl Paths {
/// Discovers paths for an app, respecting portable mode if `portable.txt` is next to current exe.
pub fn for_app(app: &App) -> Result<Self, String> {
if let Ok(exe) = std::env::current_exe() {
return Self::for_exe(app, &exe);
}
let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { app.name } else { app.id };
Self::per_user(dir)
}
/// Discovers paths for an app from a specific executable path.
pub fn for_exe(app: &App, exe: &Path) -> Result<Self, String> {
if let Some(parent) = exe.parent()
&& parent.join("portable.txt").is_file()
{
let data_dir_name = format!("{}Data", app.name.replace(' ', ""));
return Ok(Self::under(&parent.join(data_dir_name)));
}
let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { app.name } else { app.id };
Self::per_user(dir)
}
/// The per-user folders named `dir` (`ArtCraft Launcher` on macOS and Windows,
/// `artcraft-launcher` elsewhere, by convention).
pub fn per_user(dir: &str) -> Result<Self, String> {
let data = dirs::data_local_dir().ok_or("no per-user data folder on this system")?.join(dir);
let cache = dirs::cache_dir().ok_or("no per-user cache folder on this system")?.join(dir);
Ok(Self { data, cache })
}
/// Everything under one folder (portable mode, tests).
pub fn under(root: &std::path::Path) -> Self {
Self { data: root.join("data"), cache: root.join("cache") }
}
pub fn downloads(&self) -> PathBuf {
self.cache.join("downloads")
}
/// Log files, and the error of a failed self-update waiting for the next start.
pub fn logs(&self) -> PathBuf {
self.data.join("logs")
}
/// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `<data>/apps/<app>`.
pub fn apps(&self) -> PathBuf {
self.data.join("apps")
}
/// The folder an app unpacked in [`Self::apps`] sees as its home: `<data>/apps/<app>/home`.
/// A Craft App keeps its settings in the XDG folders (`~/.config`, `~/.local/share`,
/// `~/.cache`), so pointing `HOME` and those variables here keeps every app's configuration
/// beside it, inside the folder the installer owns.
#[cfg(target_os = "linux")]
pub fn app_home(&self, id: &str) -> PathBuf {
self.apps().join(id).join("home")
}
}
/// Writes `bytes` to `path` via a temporary file and a rename, so a crash never leaves half a file.
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
let dir = path.parent().ok_or("no parent folder")?;
std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| format!("couldn't create a temporary file in {} ({e})", dir.display()))?;
tmp.write_all(bytes).map_err(|e| format!("couldn't write {} ({e})", tmp.path().display()))?;
tmp.as_file().sync_all().map_err(|e| format!("couldn't sync {} ({e})", tmp.path().display()))?;
tmp.persist(path).map_err(|e| format!("couldn't replace {} ({e})", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_write_does_not_overwrite_an_unrelated_temporary_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("installed.json");
let unrelated = path.with_extension("tmp");
std::fs::write(&unrelated, b"another writer's data").unwrap();
write_atomic(&path, b"new manifest").unwrap();
assert_eq!(std::fs::read(&unrelated).unwrap(), b"another writer's data");
assert_eq!(std::fs::read(&path).unwrap(), b"new manifest");
}
#[test]
fn atomic_write_replaces_a_complete_file_and_cleans_up_on_failure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("installed.json");
std::fs::write(&path, b"old manifest").unwrap();
write_atomic(&path, b"new manifest").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new manifest");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
let blocked = dir.path().join("directory.json");
std::fs::create_dir(&blocked).unwrap();
std::fs::write(blocked.join("keep.txt"), b"keep").unwrap();
assert!(write_atomic(&blocked, b"replacement").is_err());
assert_eq!(std::fs::read(blocked.join("keep.txt")).unwrap(), b"keep");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[cfg(unix)]
#[test]
fn atomic_write_does_not_follow_a_temporary_file_symlink() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("installed.json");
let victim = dir.path().join("unrelated.txt");
std::fs::write(&victim, b"keep this file").unwrap();
std::os::unix::fs::symlink(&victim, path.with_extension("tmp")).unwrap();
write_atomic(&path, b"new manifest").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep this file");
assert_eq!(std::fs::read(&path).unwrap(), b"new manifest");
}
}
+423
View File
@@ -0,0 +1,423 @@
//! Platforms, package formats, and choosing the right release asset.
//!
//! Every Craft App release names its files `<app>-<version>-<os>-<arch>[-portable].<ext>`, for
//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`.
//!
//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are
//! shown as they are.
use serde::{Deserialize, Serialize};
use crate::release::{Asset, Release};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Os {
Windows,
MacOs,
Linux,
FreeBsd,
}
impl Os {
pub fn label(self) -> &'static str {
match self {
Self::Windows => "Windows",
Self::MacOs => "macOS",
Self::Linux => "Linux",
Self::FreeBsd => "FreeBSD",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arch {
X64,
X86,
Arm64,
Riscv64,
}
impl Arch {
pub fn label(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::X86 => "x86",
Self::Arm64 => "ARM64",
Self::Riscv64 => "RISC-V",
}
}
}
/// A package format a release ships.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PackageKind {
/// Windows Installer package (installs for all users into Program Files).
Msi,
/// Windows zip with the exe at the top level.
PortableZip,
/// macOS disk image with the `.app` bundle.
Dmg,
AppImage,
Deb,
Rpm,
/// Arch Linux package (`.pkg.tar.zst`).
ArchPkg,
Flatpak,
/// Linux or FreeBSD tarball (`bin/<app>`, `share/…`).
TarGz,
}
impl PackageKind {
pub fn label(self) -> &'static str {
match self {
Self::Msi => "Installer (.msi)",
Self::PortableZip => "Portable (.zip)",
Self::Dmg => "Disk image (.dmg)",
Self::AppImage => "AppImage",
Self::Deb => "Debian package (.deb)",
Self::Rpm => "RPM package (.rpm)",
Self::ArchPkg => "Arch package (.pkg.tar.zst)",
Self::Flatpak => "Flatpak bundle",
Self::TarGz => "Tarball (.tar.gz)",
}
}
fn suffix(self) -> &'static str {
match self {
Self::Msi => ".msi",
Self::PortableZip => "-portable.zip",
Self::Dmg => ".dmg",
Self::AppImage => ".AppImage",
Self::Deb => ".deb",
Self::Rpm => ".rpm",
Self::ArchPkg => ".pkg.tar.zst",
Self::Flatpak => ".flatpak",
Self::TarGz => ".tar.gz",
}
}
}
/// Where the launcher is running (or, on the web, the visitor's machine).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Platform {
pub os: Os,
pub arch: Arch,
}
impl Platform {
/// The platform this binary was built for, or `None` for wasm and unsupported targets.
pub fn current() -> Option<Self> {
let os = if cfg!(target_os = "windows") {
Os::Windows
} else if cfg!(target_os = "macos") {
Os::MacOs
} else if cfg!(target_os = "linux") {
Os::Linux
} else if cfg!(target_os = "freebsd") {
Os::FreeBsd
} else {
return None;
};
let arch = if cfg!(target_arch = "x86_64") {
Arch::X64
} else if cfg!(target_arch = "x86") {
Arch::X86
} else if cfg!(target_arch = "aarch64") {
Arch::Arm64
} else if cfg!(target_arch = "riscv64") {
Arch::Riscv64
} else {
return None;
};
Some(Self { os, arch })
}
pub fn label(self) -> String {
match self.os {
Os::MacOs => "macOS".into(),
os => format!("{} {}", os.label(), self.arch.label()),
}
}
/// Asset platform tokens to try, best first. Windows on ARM and 64-bit Windows can fall back
/// to emulated builds; macOS prefers universal, then native builds. Apple silicon can fall
/// back to Intel builds, but Intel Macs cannot run ARM builds.
fn tokens(self) -> &'static [&'static str] {
match (self.os, self.arch) {
(Os::Windows, Arch::X64) => &["windows-x64", "windows-x86"],
(Os::Windows, Arch::X86) => &["windows-x86"],
(Os::Windows, Arch::Arm64) => &["windows-arm64", "windows-x64", "windows-x86"],
(Os::Windows, _) => &[],
(Os::MacOs, Arch::X64) => &["macos-universal", "macos-x86_64"],
(Os::MacOs, Arch::Arm64) => &["macos-universal", "macos-aarch64", "macos-arm64", "macos-x86_64"],
(Os::MacOs, _) => &[],
(Os::Linux, Arch::X64) => &["linux-x86_64"],
(Os::Linux, Arch::Arm64) => &["linux-aarch64"],
(Os::Linux, Arch::Riscv64) => &["linux-riscv64"],
(Os::Linux, Arch::X86) => &[],
(Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"],
(Os::FreeBsd, Arch::Arm64) => &["freebsd-aarch64"],
(Os::FreeBsd, _) => &[],
}
}
/// Package formats the launcher installs on this OS, in order of preference.
pub fn default_kinds(self) -> &'static [PackageKind] {
match self.os {
Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],
Os::MacOs => &[PackageKind::Dmg],
Os::Linux => &[PackageKind::AppImage, PackageKind::TarGz],
Os::FreeBsd => &[PackageKind::TarGz],
}
}
/// Every format offered as a manual download on this OS (the web build's download menu).
pub fn download_kinds(self) -> &'static [PackageKind] {
match self.os {
Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],
Os::MacOs => &[PackageKind::Dmg],
Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz],
Os::FreeBsd => &[PackageKind::TarGz],
}
}
}
/// What an asset name says about itself.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetName {
pub stem: String,
pub version: String,
pub token: String,
pub kind: PackageKind,
}
/// Reads `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `None` for checksums, zsync files, CLI
/// and web bundles, or anything else.
pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {
const KINDS: [PackageKind; 9] = [
PackageKind::PortableZip,
PackageKind::TarGz,
PackageKind::Msi,
PackageKind::Dmg,
PackageKind::AppImage,
PackageKind::Deb,
PackageKind::Rpm,
PackageKind::ArchPkg,
PackageKind::Flatpak,
];
let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?;
for stem in stems {
let Some(rest) = base.strip_prefix(stem).and_then(|r| r.strip_prefix('-')) else { continue };
// rest = <version>-<os>-<arch>; os and arch never contain '-', versions may.
let mut parts = rest.rsplitn(3, '-');
let (arch, os, version) = (parts.next()?, parts.next()?, parts.next()?);
if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind });
}
None
}
/// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the
/// platform's tokens in order (native before emulated).
pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, kinds: &[PackageKind]) -> Option<(&'a Asset, PackageKind)> {
let parsed: Vec<(&Asset, AssetName)> = release.assets.iter().filter_map(|a| parse_asset_name(&a.name, stems).map(|n| (a, n))).collect();
for kind in kinds {
for token in platform.tokens() {
if let Some((asset, _)) = parsed.iter().find(|(_, n)| n.kind == *kind && n.token == *token) {
return Some((asset, *kind));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn release(names: &[&str]) -> Release {
Release {
assets: names.iter().map(|n| Asset { name: (*n).into(), size: 1, url: format!("https://x/{n}"), sha256: None }).collect(),
..Release::default()
}
}
const PHOTO: &[&str] = &[
"photocraft-0.5.0-freebsd-x86_64.tar.gz",
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.AppImage.zsync",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-riscv64.tar.gz",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-x86_64.tar.gz",
"photocraft-0.5.0-macos-universal.dmg",
"photocraft-0.5.0-windows-x64-portable.zip",
"photocraft-0.5.0-windows-x64.msi",
"photocraft-0.5.0-windows-x86.msi",
"photocraft-cli-0.5.0-macos-universal.zip",
"photocraft-web-0.5.0.zip",
"SHA256SUMS.txt",
];
#[test]
fn macos_prefers_the_published_universal_build() {
// PhotoCraft v0.9.0's GitHub metadata, trimmed to its desktop DMG.
let mut r = crate::release::parse_github_release(include_str!("../tests/fixtures/photocraft-release.json"), "storytold/photocraft").unwrap();
// Split builds must not outrank the published universal DMG, even when listed first.
r.assets.splice(0..0, release(&["photocraft-0.9.0-macos-arm64.dmg", "photocraft-0.9.0-macos-x86_64.dmg"]).assets);
for arch in [Arch::X64, Arch::Arm64] {
let (asset, kind) = pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).unwrap();
assert_eq!(asset.name, "photocraft-0.9.0-macos-universal.dmg");
assert_eq!(kind, PackageKind::Dmg);
}
}
#[test]
fn freebsd_picks_the_tarball_for_the_processor() {
// SoundCraft v0.10.0's GitHub metadata, trimmed to its FreeBSD tarballs.
let r = crate::release::parse_github_release(include_str!("../tests/fixtures/soundcraft-release.json"), "storytold/soundcraft").unwrap();
let pick = |arch| {
let platform = Platform { os: Os::FreeBsd, arch };
pick_asset(&r, &["soundcraft"], platform, platform.default_kinds()).map(|(a, k)| (a.name.as_str(), k))
};
assert_eq!(pick(Arch::X64), Some(("soundcraft-0.10.0-freebsd-x86_64.tar.gz", PackageKind::TarGz)));
assert_eq!(pick(Arch::Arm64), Some(("soundcraft-0.10.0-freebsd-aarch64.tar.gz", PackageKind::TarGz)));
for arch in [Arch::X86, Arch::Riscv64] {
assert_eq!(pick(arch), None);
}
// An x86_64 machine never takes the ARM tarball, nor an ARM machine the x86_64 one.
let only_arm = release(&["soundcraft-0.10.0-freebsd-aarch64.tar.gz"]);
assert_eq!(pick_asset(&only_arm, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::X64 }, &[PackageKind::TarGz]), None);
let only_x64 = release(&["soundcraft-0.10.0-freebsd-x86_64.tar.gz"]);
assert_eq!(pick_asset(&only_x64, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::Arm64 }, &[PackageKind::TarGz]), None);
}
#[test]
fn macos_split_builds_match_the_processor() {
let r = release(&["photocraft-0.5.0-macos-arm64.dmg", "photocraft-0.5.0-macos-x86_64.dmg"]);
let pick = |arch| pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str());
assert_eq!(pick(Arch::X64), Some("photocraft-0.5.0-macos-x86_64.dmg"));
assert_eq!(pick(Arch::Arm64), Some("photocraft-0.5.0-macos-arm64.dmg"));
}
#[test]
fn intel_macos_never_selects_an_arm_only_build() {
for token in ["macos-aarch64", "macos-arm64"] {
let name = format!("photocraft-0.5.0-{token}.dmg");
let r = release(&[&name]);
assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::X64 }, &[PackageKind::Dmg]), None);
assert_eq!(
pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),
Some(name.as_str())
);
}
}
#[test]
fn apple_silicon_keeps_the_intel_fallback() {
let r = release(&["photocraft-0.5.0-macos-x86_64.dmg"]);
assert_eq!(
pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),
Some("photocraft-0.5.0-macos-x86_64.dmg")
);
for arch in [Arch::X86, Arch::Riscv64] {
assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]), None);
}
}
#[test]
fn reads_asset_names() {
let n = parse_asset_name("photocraft-0.2.0-rc.1-windows-arm64-portable.zip", &["photocraft"]).unwrap();
assert_eq!((n.version.as_str(), n.token.as_str(), n.kind), ("0.2.0-rc.1", "windows-arm64", PackageKind::PortableZip));
let n = parse_asset_name("printcraft-0.2.1-linux-x86_64.tar.gz", &["pdfcraft", "printcraft"]).unwrap();
assert_eq!((n.stem.as_str(), n.kind), ("printcraft", PackageKind::TarGz));
for other in
["photocraft-cli-0.5.0-macos-universal.zip", "photocraft-web-0.5.0.zip", "SHA256SUMS.txt", "photocraft-0.5.0-linux-x86_64.AppImage.zsync"]
{
assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");
}
// FigCraft also ships its collaboration server; that tarball is not the app.
assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);
assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());
}
#[test]
fn picks_the_native_build_first_and_falls_back_to_emulation() {
let r = release(PHOTO);
let pick = |os, arch, kinds: &[PackageKind]| pick_asset(&r, &["photocraft"], Platform { os, arch }, kinds).map(|(a, _)| a.name.clone());
assert_eq!(pick(Os::MacOs, Arch::Arm64, &[PackageKind::Dmg]).as_deref(), Some("photocraft-0.5.0-macos-universal.dmg"));
assert_eq!(pick(Os::Windows, Arch::X64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));
// No arm64 MSI in this release: Windows on ARM runs the x64 build.
assert_eq!(pick(Os::Windows, Arch::Arm64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));
assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::AppImage]).as_deref(), Some("photocraft-0.5.0-linux-aarch64.AppImage"));
assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::TarGz]), None);
assert_eq!(pick(Os::Linux, Arch::Riscv64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-linux-riscv64.tar.gz"));
assert_eq!(pick(Os::FreeBsd, Arch::X64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-freebsd-x86_64.tar.gz"));
// Preference order across kinds.
assert_eq!(
pick(Os::Windows, Arch::X64, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(),
Some("photocraft-0.5.0-windows-x64-portable.zip")
);
assert_eq!(pick(Os::Windows, Arch::X86, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x86.msi"));
let p_riscv = Platform { os: Os::Linux, arch: Arch::Riscv64 };
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.default_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
let r_riscv_deb =
release(&["photocraft-0.5.0-linux-riscv64.tar.gz", "photocraft-0.5.0-linux-riscv64.deb", "photocraft-0.5.0-linux-riscv64.rpm"]);
assert_eq!(
pick_asset(&r_riscv_deb, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.deb")
);
}
#[test]
fn strictly_matches_architecture_for_all_linux_package_formats() {
let multi_arch_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-x86_64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-x86_64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-x86_64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-x86_64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
"photocraft-0.5.0-linux-x86_64.tar.gz",
]);
let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 };
let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 };
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}");
let pick_arm64 = pick_asset(&multi_arch_release, &["photocraft"], p_arm64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_arm64.as_deref().unwrap().contains("linux-aarch64"), "Expected aarch64 asset for {kind:?}, got {pick_arm64:?}");
}
// On x86_64 Linux, an aarch64-only asset must NEVER match or fall back
let arm_only_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
]);
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}");
}
}
}
+857
View File
@@ -0,0 +1,857 @@
//! Portable Windows packages: bounded extraction and replacement of recorded files only.
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use crate::manifest::Record;
use crate::paths::Paths;
use crate::{App, PackageKind};
const MAX_FILES: usize = 10_000;
const MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
/// Custom destinations may have OS-managed links in their host ancestors.
pub fn validate_directory(path: &Path) -> Result<(), String> {
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) {
return Err("Choose an absolute installation folder without parent-directory components.".into());
}
// Resolve the closest existing ancestor: /home and /var can be OS-managed
// symlinks, and a custom destination need not exist yet.
for ancestor in path.ancestors() {
match std::fs::canonicalize(ancestor) {
Ok(resolved) => {
if !resolved.is_dir() {
return Err("The installation folder contains a non-directory component.".into());
}
return Ok(());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// A dangling link is not a missing directory we can safely create.
if std::fs::symlink_metadata(ancestor).is_ok() {
return Err("The installation folder contains a dangling link.".into());
}
}
Err(error) => return Err(error.to_string()),
}
}
Err("The installation folder has no existing ancestor.".into())
}
/// Links are allowed above the ownership boundary, never inside it (including root).
fn validate_owned_directory(root: &Path, path: &Path) -> Result<(), String> {
validate_directory(path)?;
if !path.starts_with(root) {
return Err("An installation path points outside its folder.".into());
}
for ancestor in path.ancestors().take_while(|ancestor| ancestor.starts_with(root)) {
match std::fs::symlink_metadata(ancestor) {
Ok(meta) if linked(&meta) || !meta.is_dir() => {
return Err("The installation folder contains a link or a non-directory component.".into());
}
Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.to_string()),
_ => {}
}
}
Ok(())
}
fn resolve_missing(path: &Path) -> Result<PathBuf, String> {
for ancestor in path.ancestors() {
match std::fs::canonicalize(ancestor) {
Ok(resolved) => {
let suffix = path.strip_prefix(ancestor).map_err(|e| e.to_string())?;
return Ok(resolved.join(suffix));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(ancestor).is_ok() {
return Err("The installation folder contains a dangling link.".into());
}
}
Err(error) => return Err(error.to_string()),
}
}
Err("The installation folder has no existing ancestor.".into())
}
/// Whether two paths name the same Windows folder or file: case, trailing separators and the
/// verbatim `\\?\` form that `canonicalize` returns don't count.
fn same_windows_path(left: &Path, right: &Path) -> bool {
/// `canonicalize` writes `\\?\C:\…` and, for a network share, `\\?\UNC\server\share\…`;
/// Windows reads either prefix in any case.
fn normalized(path: &Path) -> String {
let text = path.to_string_lossy().to_lowercase();
let text = match text.strip_prefix(r"\\?\unc\") {
Some(share) => format!(r"\\{share}"),
None => text.strip_prefix(r"\\?\").unwrap_or(&text).to_string(),
};
text.trim_end_matches(['\\', '/']).to_string()
}
normalized(left) == normalized(right)
}
fn linked(meta: &std::fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
meta.file_type().is_symlink()
}
}
struct Staging(PathBuf);
impl Staging {
/// Stop cleaning up and hand the directory back: used when a rollback failed and the backup
/// is the only copy left for manual recovery.
fn keep(self) -> PathBuf {
let path = self.0.clone();
// Deliberately skipping Drop here is the whole point — it would delete the recovery
// files. The directory is handed to the operator, not leaked into nothing.
std::mem::forget(self); // NOSONAR
path
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn extract(file: &Path, destination: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>), String> {
let file = std::fs::File::open(file).map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("couldn't read the portable ZIP ({e})"))?;
if archive.len() > MAX_FILES {
return Err("The portable ZIP contains too many files.".into());
}
let mut files = Vec::new();
let mut entries = Vec::new();
let mut total = 0u64;
let mut seen = std::collections::BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|e| e.to_string())?;
let relative = entry.enclosed_name().ok_or("The portable ZIP contains an unsafe path.")?;
if relative.components().any(|c| !matches!(c, Component::Normal(_)))
|| entry.name().contains(['\\', ':'])
|| entry.unix_mode().is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err("The portable ZIP contains an unsafe path or link.".into());
}
// Windows treats case variants as the same file; reject duplicate and colliding names.
if !seen.insert(relative.to_string_lossy().to_lowercase()) {
return Err("The portable ZIP contains duplicate paths.".into());
}
let target = destination.join(&relative);
entries.push(relative.clone());
if entry.is_dir() {
std::fs::create_dir_all(&target).map_err(|e| e.to_string())?;
continue;
}
total = total.checked_add(entry.size()).filter(|size| *size <= MAX_BYTES).ok_or("The portable ZIP is too large.")?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?;
use std::io::Read;
let declared = entry.size();
let copied = std::io::copy(&mut (&mut entry).take(declared.saturating_add(1)), &mut output).map_err(|e| e.to_string())?;
if copied != declared {
return Err("The portable ZIP has an unexpected unpacked size.".into());
}
files.push(relative);
}
Ok((files, entries))
}
fn recorded_files<'a>(app: &App, record: &'a Record) -> Result<(&'a Path, Vec<PathBuf>), String> {
let root = record.path.parent().ok_or("The portable installation has no parent folder.")?;
if record.kind != PackageKind::PortableZip || root.file_name().is_none_or(|n| n != app.id) {
return Err("The launcher has no valid ownership record for this portable folder.".into());
}
let resolved = resolve_missing(root)?;
if !same_windows_path(root, &resolved) {
return Err("The recorded portable folder resolves through a changed alias. Reinstall it to choose a safe location.".into());
}
validate_owned_directory(root, root)?;
let mut files = Vec::new();
for file in &record.files {
let relative = file.strip_prefix(root).map_err(|_| "An installation record points outside its folder.")?;
if relative.as_os_str().is_empty() || relative.components().any(|c| !matches!(c, Component::Normal(_))) {
return Err("The installation record contains an unsafe path.".into());
}
validate_owned_directory(root, file.parent().ok_or("A recorded file has no parent folder.")?)?;
if let Ok(meta) = std::fs::symlink_metadata(file)
&& (linked(&meta) || !meta.is_file())
{
return Err("A recorded file was replaced by a link or directory.".into());
}
files.push(relative.to_path_buf());
}
Ok((root, files))
}
/// Keeps existing portable installations at their location and preserves unrecorded user files,
/// unless `replace` allows writing over them.
#[allow(clippy::too_many_arguments)]
pub fn install(
app: &App,
file: &Path,
version: &str,
previous: Option<&Record>,
paths: &Paths,
directory: Option<&Path>,
replace: bool,
commit: &mut dyn FnMut(&Record) -> Result<(), String>,
) -> Result<Record, String> {
let (destination, mut old_files) = match previous {
Some(record) => {
let (root, files) = recorded_files(app, record)?;
(root.to_path_buf(), files)
}
None => {
let parent = directory.map(Path::to_path_buf).unwrap_or_else(|| paths.apps());
validate_directory(&parent)?;
let parent = resolve_missing(&parent)?;
(parent.join(app.id), Vec::new())
}
};
validate_owned_directory(&destination, &destination)?;
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos();
let staging = paths.cache.join("staging").join(format!("{}-{}-{stamp}", app.id, std::process::id()));
validate_owned_directory(&paths.cache, &staging)?;
std::fs::create_dir_all(staging.parent().ok_or("no staging folder")?).map_err(|e| e.to_string())?;
std::fs::create_dir(&staging).map_err(|e| e.to_string())?;
let staging = Staging(staging);
let unpacked = staging.0.join("unpacked");
std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?;
let (files, entries) = extract(file, &unpacked)?;
// Accept both a flat layout (files at the ZIP root) and a single wrapper-folder layout,
// but never arbitrary nesting.
let (unpacked, files) = flatten_zip_layout(app, &unpacked, files, &entries)?;
let executable = files
.iter()
.find(|relative| is_root_executable(relative, app))
.ok_or("The portable ZIP does not contain the expected executable at its root.")?
.clone();
// Validate the entire transaction before moving any existing program file.
for relative in &files {
let target = destination.join(relative);
validate_owned_directory(&destination, target.parent().ok_or("no destination folder")?)?;
if let Ok(meta) = std::fs::symlink_metadata(&target) {
let recorded = old_files.iter().any(|old| same_windows_path(old, relative));
if linked(&meta) || !meta.is_file() || !(recorded || replace) {
return Err("The installation would overwrite an unrecorded file.".into());
}
if !recorded {
old_files.push(relative.clone());
}
}
}
std::fs::create_dir_all(&destination).map_err(|e| e.to_string())?;
let backup = staging.0.join("backup");
let record = Record {
version: version.to_string(),
kind: PackageKind::PortableZip,
path: destination.join(executable),
installed_at: crate::now(),
files: files.iter().map(|relative| destination.join(relative)).collect(),
needs_root: false,
};
let mut moved = Vec::new();
let mut copied = Vec::new();
let transaction = (|| {
for relative in &old_files {
let old = destination.join(relative);
if !old.exists() {
continue;
}
let saved = backup.join(relative);
std::fs::create_dir_all(saved.parent().ok_or("no backup folder")?).map_err(|e| e.to_string())?;
// Copy the rollback copy: cache and custom installation can be on different drives.
std::fs::copy(&old, &saved).map_err(|e| e.to_string())?;
std::fs::remove_file(&old).map_err(|e| e.to_string())?;
moved.push(relative.clone());
}
for relative in &files {
let target = destination.join(relative);
std::fs::create_dir_all(target.parent().ok_or("no destination folder")?).map_err(|e| e.to_string())?;
let mut input = std::fs::File::open(unpacked.join(relative)).map_err(|e| e.to_string())?;
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?;
copied.push(relative.clone());
std::io::copy(&mut input, &mut output).map_err(|e| e.to_string())?;
}
commit(&record)?;
Ok::<(), String>(())
})();
if let Err(error) = transaction {
for relative in copied {
let _ = std::fs::remove_file(destination.join(relative));
}
for relative in moved {
if let Err(restore) = std::fs::copy(backup.join(&relative), destination.join(&relative)) {
// Preserve backups for manual recovery if the disk won't accept a rollback.
let backup_path = staging.keep();
return Err(format!("{error}; could not restore the previous version ({restore}). Recovery files: {}", backup_path.display()));
}
}
return Err(error);
}
Ok(record)
}
/// Whether a ZIP-relative path sits at the archive root and is one of the app's executables.
fn is_root_executable(relative: &Path, app: &App) -> bool {
relative.parent().is_some_and(|parent| parent.as_os_str().is_empty())
&& relative.file_name().and_then(OsStr::to_str).is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe"))))
}
/// Flattens a portable ZIP whose files all sit in one top-level folder (the official Windows
/// packaging script's `Compress-Archive -Path $Portable` puts everything under a versioned
/// directory) into the flat layout a root launcher exe requires, passing already-flat layouts
/// through. ZIPs mixing unrelated top-level folders are rejected.
fn flatten_zip_layout(app: &App, unpacked: &Path, files: Vec<PathBuf>, entries: &[PathBuf]) -> Result<(PathBuf, Vec<PathBuf>), String> {
if files.iter().any(|relative| is_root_executable(relative, app)) {
return Ok((unpacked.to_path_buf(), files));
}
let prefix = files.first().and_then(|path| path.components().next()).ok_or("The portable ZIP contains no files.")?.as_os_str().to_os_string();
let prefix = PathBuf::from(prefix);
// Include explicit ZIP directory entries as well as extracted files.
// The wrapper directory itself is allowed; unrelated roots are not.
if !entries.iter().all(|path| path.starts_with(&prefix))
|| !files.iter().all(|path| path.strip_prefix(&prefix).is_ok_and(|relative| relative.components().next().is_some()))
{
return Err("The portable ZIP has an unsupported directory layout.".into());
}
let unpacked = unpacked.join(&prefix);
let files: Vec<PathBuf> =
files.into_iter().map(|path| path.strip_prefix(&prefix).map(Path::to_path_buf).map_err(|e| e.to_string())).collect::<Result<Vec<_>, _>>()?;
Ok((unpacked, files))
}
/// Removes only recorded program files. Personal settings and documents remain.
pub fn remove(app: &App, record: &Record) -> Result<(), String> {
let (root, files) = recorded_files(app, record)?;
for relative in &files {
let file = root.join(relative);
if file.exists() {
std::fs::remove_file(&file).map_err(|e| e.to_string())?;
}
}
for relative in &files {
let mut directory = root.join(relative).parent().map(Path::to_path_buf);
while let Some(path) = directory.filter(|p| p.starts_with(root)) {
if std::fs::remove_dir(&path).is_err() || path == root {
break;
}
directory = path.parent().map(Path::to_path_buf);
}
}
Ok(())
}
/// Unpacks the launcher's own portable zip into `staging`, with the same checks as an app's, and
/// copies it over `folder` for a self-update. `check` must accept every unpacked file before
/// any is copied, and again before the backup is removed. The archive must include `executable`.
/// When every file sits in one top-level folder, that folder's contents are the program. A copy
/// that fails puts the previous files back. When one of them can't be put back,
/// the previous files move to a `<staging>-recovery-<time>` folder beside `staging`, which no
/// later update removes, and the error names it. `staging` is removed afterwards.
pub fn unpack_over(
zip: &Path,
staging: &Path,
folder: &Path,
executable: &std::ffi::OsStr,
check: &dyn Fn(&Path) -> Result<(), String>,
) -> Result<(), String> {
let backup = staging.join("backup");
// A failed update whose files couldn't be moved to a recovery folder: never clear them.
if backup.exists() {
return Err(format!("the previous files of a failed update are in {}; move them out of the way first", backup.display()));
}
let _ = std::fs::remove_dir_all(staging);
let mut keep = false;
let result = replace_with(zip, staging, folder, executable, check, &mut keep);
if !keep {
let _ = std::fs::remove_dir_all(staging);
}
result
}
fn replace_with(
zip: &Path,
staging: &Path,
folder: &Path,
executable: &std::ffi::OsStr,
check: &dyn Fn(&Path) -> Result<(), String>,
keep: &mut bool,
) -> Result<(), String> {
let unpacked = staging.join("unpacked");
let backup = staging.join("backup");
std::fs::create_dir_all(&unpacked).map_err(|e| e.to_string())?;
let (files, _) = extract(zip, &unpacked)?;
let top = single_top_folder(&files);
let files = files
.into_iter()
.map(|relative| {
let inside = match &top {
Some(top) => relative.strip_prefix(top).map_err(|e| e.to_string())?.to_path_buf(),
None => relative.clone(),
};
Ok((relative, inside))
})
.collect::<Result<Vec<_>, String>>()?;
if !files.iter().any(|(_, inside)| inside == Path::new(executable)) {
return Err(format!("the update doesn't contain {}", executable.to_string_lossy()));
}
for (relative, _) in &files {
check(&unpacked.join(relative))?;
}
let mut saved = Vec::new();
let mut created = Vec::new();
let copied = (|| {
for (relative, inside) in &files {
let target = folder.join(inside);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
if target.is_file() {
let previous = backup.join(inside);
std::fs::create_dir_all(previous.parent().ok_or("no backup folder")?).map_err(|e| e.to_string())?;
std::fs::copy(&target, &previous).map_err(|e| e.to_string())?;
saved.push(inside.to_path_buf());
} else {
created.push(target.clone());
}
std::fs::copy(unpacked.join(relative), &target).map_err(|e| format!("couldn't replace {} ({e})", target.display()))?;
}
// Keep the previous files until the installed copies have passed the same checks.
for (_, inside) in &files {
check(&folder.join(inside))?;
}
Ok::<(), String>(())
})();
if let Err(error) = copied {
for target in &created {
let _ = std::fs::remove_file(target);
}
for inside in &saved {
if let Err(restore) = std::fs::copy(backup.join(inside), folder.join(inside)) {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let mut recovery = staging.as_os_str().to_owned();
recovery.push(format!("-recovery-{stamp}"));
let recovery = PathBuf::from(recovery);
let kept = if std::fs::rename(&backup, &recovery).is_ok() {
recovery
} else {
*keep = true;
backup.clone()
};
return Err(format!(
"{error}; couldn't put back {} ({restore}). The previous files are in {}",
folder.join(inside).display(),
kept.display()
));
}
}
return Err(error);
}
Ok(())
}
/// The one folder that holds every file, when there is one.
fn single_top_folder(files: &[PathBuf]) -> Option<PathBuf> {
let first = files.first()?.components().next()?;
files
.iter()
.all(|file| {
let mut parts = file.components();
parts.next() == Some(first) && parts.next().is_some()
})
.then(|| PathBuf::from(first.as_os_str()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// A folder on a network share is the same folder after `canonicalize`.
#[cfg(windows)]
#[test]
fn verbatim_paths_match_their_plain_form() {
assert!(same_windows_path(Path::new(r"C:\Apps\ArtCraft"), Path::new(r"\\?\C:\apps\artcraft\")));
assert!(same_windows_path(Path::new(r"\\server\share\Apps\ArtCraft"), Path::new(r"\\?\UNC\server\share\Apps\ArtCraft")));
assert!(same_windows_path(Path::new(r"\\?\unc\server\share\Apps\ArtCraft"), Path::new(r"\\Server\Share\Apps\ArtCraft")));
assert!(!same_windows_path(Path::new(r"C:\Apps\ArtCraft"), Path::new(r"\\?\D:\Apps\ArtCraft")));
}
/// Previous files left in the staging folder by a failed update are never cleared by the next one.
#[test]
fn a_self_update_keeps_the_previous_files_of_a_failed_one() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("launcher-update");
std::fs::create_dir_all(staging.join("backup")).unwrap();
std::fs::write(staging.join("backup").join("artcraft-launcher.exe"), "0.1.0").unwrap();
let zip = archive(dir.path(), "update.zip", &[("artcraft-launcher.exe", b"0.2.0")]);
let folder = dir.path().join("ArtCraft Launcher");
std::fs::create_dir_all(&folder).unwrap();
let error = unpack_over(&zip, &staging, &folder, std::ffi::OsStr::new("artcraft-launcher.exe"), &|_| Ok(())).unwrap_err();
assert!(error.contains("previous files"), "{error}");
assert_eq!(std::fs::read_to_string(staging.join("backup").join("artcraft-launcher.exe")).unwrap(), "0.1.0");
assert!(!folder.join("artcraft-launcher.exe").exists());
}
/// Validation of the installed copy is part of the transaction, while a backup still exists.
#[test]
fn a_self_update_that_fails_validation_puts_the_previous_files_back() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("launcher-update");
let folder = dir.path().join("ArtCraft Launcher");
std::fs::create_dir_all(&folder).unwrap();
let exe = folder.join("artcraft-launcher.exe");
std::fs::write(&exe, "old launcher").unwrap();
let zip = archive(dir.path(), "update.zip", &[("launcher/artcraft-launcher.exe", b"new launcher"), ("launcher/README.md", b"new readme")]);
let checked = std::cell::Cell::new(false);
let error = unpack_over(&zip, &staging, &folder, std::ffi::OsStr::new("artcraft-launcher.exe"), &|file| {
if file == exe {
assert_eq!(std::fs::read(file).unwrap(), b"new launcher");
assert!(folder.join("README.md").is_file());
checked.set(true);
Err("installed launcher failed validation".into())
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error, "installed launcher failed validation");
assert!(checked.get());
assert_eq!(std::fs::read(&exe).unwrap(), b"old launcher");
assert!(!folder.join("README.md").exists());
assert!(!staging.exists());
}
fn archive(root: &Path, name: &str, files: &[(&str, &[u8])]) -> PathBuf {
let path = root.join(name);
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for (name, content) in files {
if name.ends_with('/') {
zip.add_directory(*name, zip::write::SimpleFileOptions::default()).unwrap();
} else {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(content).unwrap();
}
}
zip.finish().unwrap();
path
}
#[test]
fn wrapped_layout_validates_explicit_directory_entries() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let invalid = archive(dir.path(), "invalid-dirs.zip", &[("wrapper/", b""), ("wrapper/photocraft.exe", b"program"), ("outside/", b"")]);
let error = install(app, &invalid, "0.12.0", None, &paths, None, false, &mut |_| Ok(())).unwrap_err();
assert!(error.contains("unsupported directory layout"), "{error}");
assert!(!paths.apps().join(app.id).exists());
let valid = archive(dir.path(), "valid-dirs.zip", &[("wrapper/", b""), ("wrapper/photocraft.exe", b"program")]);
let record = install(app, &valid, "0.12.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
assert!(record.path.exists());
}
#[test]
fn wrapped_packages_install_update_and_remove_at_a_stable_location() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let parent = dir.path().join("My Tools");
let mut previous = None;
// The published ZIPs wrap the app, CLI and resources in a versioned directory.
// Also exercise transitions to and from the older, flat layout.
for (version, prefix) in [
("0.12.0", "photocraft-0.12.0-windows-x64-portable/"),
("0.13.0", "photocraft-0.13.0-windows-x64-portable/"),
("0.11.0", ""),
("0.12.0", "photocraft-0.12.0-windows-x64-portable/"),
] {
let zip = archive(
dir.path(),
"app.zip",
&[
(&format!("{prefix}photocraft-cli.exe"), b"cli"),
(&format!("{prefix}PhotoCraft.exe"), version.as_bytes()),
(&format!("{prefix}lib/runtime.dll"), b"library"),
(&format!("{prefix}LICENSE-MIT"), b"license"),
],
);
let record = install(app, &zip, version, previous.as_ref(), &paths, Some(&parent), false, &mut |_| Ok(())).unwrap();
let root = std::fs::canonicalize(parent.join(app.id)).unwrap();
assert_eq!(record.path, root.join("PhotoCraft.exe"));
assert_eq!(std::fs::read(&record.path).unwrap(), version.as_bytes());
assert_eq!(std::fs::read(root.join("lib/runtime.dll")).unwrap(), b"library");
assert_eq!(record.files, ["photocraft-cli.exe", "PhotoCraft.exe", "lib/runtime.dll", "LICENSE-MIT"].map(|p| root.join(p)));
let personal = root.join("settings.json");
if previous.is_some() {
assert_eq!(std::fs::read(&personal).unwrap(), b"personal");
} else {
std::fs::write(&personal, b"personal").unwrap();
}
previous = Some(record);
}
let record = previous.unwrap();
remove(app, &record).unwrap();
assert!(record.files.iter().all(|p| !p.exists()));
assert_eq!(std::fs::read(parent.join("photocraft/settings.json")).unwrap(), b"personal");
}
#[test]
fn rejects_missing_executables_and_ambiguous_or_deep_wrappers() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
for names in [
vec![],
vec!["package/photocraft-cli.exe"],
vec!["package/bin/photocraft.exe"],
vec!["one/photocraft.exe", "two/photocraft.exe"],
vec!["package/photocraft.exe", "outside.txt"],
vec!["package/photocraft.exe", "other/runtime.dll"],
] {
let files: Vec<_> = names.iter().map(|name| (*name, b"content".as_slice())).collect();
let zip = archive(dir.path(), "invalid.zip", &files);
assert!(install(app, &zip, "0.12.0", None, &paths, None, false, &mut |_| Ok(())).is_err(), "{names:?}");
assert!(!paths.apps().join(app.id).exists());
}
}
#[test]
fn wrapped_update_keeps_collision_checks_and_rolls_back_failed_commits() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let old = archive(dir.path(), "old.zip", &[("photocraft.exe", b"old"), ("lib.dll", b"library")]);
let record = install(app, &old, "0.11.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
let personal = paths.apps().join("photocraft/settings.json");
std::fs::write(&personal, b"keep").unwrap();
let collision = archive(dir.path(), "collision.zip", &[("package/photocraft.exe", b"new"), ("package/settings.json", b"overwrite")]);
let error = install(app, &collision, "0.12.0", Some(&record), &paths, None, false, &mut |_| Ok(())).unwrap_err();
assert_eq!(error, "The installation would overwrite an unrecorded file.");
let next = archive(dir.path(), "next.zip", &[("package/photocraft.exe", b"new"), ("package/new.dll", b"new")]);
let error = install(app, &next, "0.12.0", Some(&record), &paths, None, false, &mut |_| Err("manifest disk full".into())).unwrap_err();
assert_eq!(error, "manifest disk full");
assert_eq!(std::fs::read(record.path).unwrap(), b"old");
assert_eq!(std::fs::read(paths.apps().join("photocraft/lib.dll")).unwrap(), b"library");
assert!(!paths.apps().join("photocraft/new.dll").exists());
assert_eq!(std::fs::read(personal).unwrap(), b"keep");
}
#[cfg(unix)]
#[test]
fn host_symlinks_are_allowed_but_owned_links_are_rejected() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let host = dir.path().join("var-home");
std::fs::create_dir(&host).unwrap();
let home = dir.path().join("home");
symlink(&host, &home).unwrap();
let root = home.join("photocraft");
assert!(validate_directory(&root).is_ok());
assert!(validate_owned_directory(&root, &root).is_ok());
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(&home);
let zip = archive(dir.path(), "app.zip", &[("photocraft.exe", b"program"), ("lib/runtime.dll", b"library")]);
let record = install(app, &zip, "0.5.0", None, &paths, Some(&home), false, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(&record.path).unwrap(), b"program");
std::fs::remove_file(root.join("lib/runtime.dll")).unwrap();
std::fs::remove_dir(root.join("lib")).unwrap();
symlink(&host, root.join("lib")).unwrap();
assert!(validate_owned_directory(&root, &root.join("lib/nested")).is_err());
assert!(remove(app, &record).is_err());
assert!(install(app, &zip, "0.6.0", Some(&record), &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(&record.path).unwrap(), b"program");
let linked_root = home.join("linked-app");
symlink(&root, &linked_root).unwrap();
assert!(validate_owned_directory(&linked_root, &linked_root).is_err());
assert!(validate_owned_directory(&root, &home).is_err());
let dangling = home.join("dangling");
symlink(home.join("missing"), &dangling).unwrap();
assert!(validate_directory(&dangling.join("nested")).is_err());
}
#[test]
fn stale_junction_record_cannot_remove_files_after_retargeting() {
use std::process::Command;
fn junction(link: &Path, target: &Path) {
let status = Command::new("cmd.exe").args(["/C", "mklink", "/J"]).arg(link).arg(target).status().unwrap();
assert!(status.success());
}
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first");
let second = dir.path().join("second");
let link = dir.path().join("Apps junction");
std::fs::create_dir(&first).unwrap();
std::fs::create_dir(&second).unwrap();
junction(&link, &first);
let alias_root = link.join("photocraft");
std::fs::create_dir(&alias_root).unwrap();
std::fs::write(alias_root.join("photocraft.exe"), b"old program").unwrap();
let record = Record {
version: "0.5.0".into(),
kind: PackageKind::PortableZip,
path: alias_root.join("photocraft.exe"),
installed_at: 0,
files: vec![alias_root.join("photocraft.exe")],
needs_root: false,
};
std::process::Command::new("cmd.exe").args(["/C", "rmdir"]).arg(&link).status().unwrap();
junction(&link, &second);
let foreign_root = link.join("photocraft");
std::fs::create_dir(&foreign_root).unwrap();
std::fs::write(foreign_root.join("photocraft.exe"), b"foreign program").unwrap();
assert!(remove(App::by_id("photocraft").unwrap(), &record).is_err());
assert_eq!(std::fs::read(foreign_root.join("photocraft.exe")).unwrap(), b"foreign program");
}
#[test]
fn upgrade_downgrade_and_uninstall_preserve_personal_files_and_location() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let parent = dir.path().join("My Tools");
let first = archive(dir.path(), "first.zip", &[("photocraft.exe", b"first"), ("obsolete.dll", b"old")]);
let mut record = install(app, &first, "0.5.0", None, &paths, Some(&parent), false, &mut |_| Ok(())).unwrap();
let personal = parent.join("photocraft").join("settings.json");
std::fs::write(&personal, b"personal").unwrap();
for (version, exe) in [("0.6.0", b"new".as_slice()), ("0.4.0", b"older".as_slice())] {
let executable = if version == "0.4.0" { "PhotoCraft.exe" } else { "photocraft.exe" };
let package = archive(dir.path(), "next.zip", &[(executable, exe), ("lib/runtime.dll", b"dll")]);
record = install(app, &package, version, Some(&record), &paths, Some(&dir.path().join("Ignored")), false, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(&record.path).unwrap(), exe);
assert_eq!(record.path.parent().unwrap(), std::fs::canonicalize(personal.parent().unwrap()).unwrap());
assert!(!parent.join("photocraft/obsolete.dll").exists());
assert_eq!(std::fs::read(&personal).unwrap(), b"personal");
let mut manifest = crate::manifest::Manifest::default();
manifest.apps.insert(app.id.into(), record.clone());
manifest.save(&paths.data.join("installed.json")).unwrap();
let restored = crate::manifest::Manifest::load(&paths.data.join("installed.json"));
let detected = crate::detect::detect(app, &restored, &crate::detect::Snapshot::take()).unwrap();
assert_eq!(detected.version, crate::version::parse(version));
assert_eq!(detected.location, record.path.to_string_lossy());
}
remove(app, &record).unwrap();
assert!(!record.path.exists());
assert_eq!(std::fs::read(personal).unwrap(), b"personal");
}
#[test]
fn a_failed_manifest_commit_restores_the_previous_program() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let old = archive(dir.path(), "old.zip", &[("photocraft.exe", b"old"), ("lib.dll", b"library")]);
let record = install(app, &old, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
let next = archive(dir.path(), "next.zip", &[("photocraft.exe", b"new"), ("new.dll", b"new")]);
let result = install(app, &next, "0.6.0", Some(&record), &paths, None, false, &mut |_| Err("manifest disk full".into()));
assert_eq!(result.unwrap_err(), "manifest disk full");
assert_eq!(std::fs::read(record.path).unwrap(), b"old");
assert_eq!(std::fs::read(paths.apps().join("photocraft/lib.dll")).unwrap(), b"library");
assert!(!paths.apps().join("photocraft/new.dll").exists());
}
#[test]
fn collision_is_rejected_before_replacing_any_old_file() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let old = archive(dir.path(), "old.zip", &[("photocraft.exe", b"old")]);
let record = install(app, &old, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
let personal = paths.apps().join("photocraft/settings.json");
std::fs::write(&personal, b"keep").unwrap();
let next = archive(dir.path(), "next.zip", &[("photocraft.exe", b"new"), ("settings.json", b"overwrite")]);
assert!(install(app, &next, "0.6.0", Some(&record), &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(record.path).unwrap(), b"old");
assert_eq!(std::fs::read(personal).unwrap(), b"keep");
}
#[test]
fn a_new_install_writes_over_unrecorded_files_only_when_asked_to() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let folder = paths.apps().join("photocraft");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("photocraft.exe"), b"theirs").unwrap();
std::fs::write(folder.join("notes.txt"), b"keep").unwrap();
let zip = archive(dir.path(), "new.zip", &[("photocraft.exe", b"new")]);
assert!(install(app, &zip, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(folder.join("photocraft.exe")).unwrap(), b"theirs");
install(app, &zip, "0.5.0", None, &paths, None, true, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(folder.join("photocraft.exe")).unwrap(), b"new");
assert_eq!(std::fs::read(folder.join("notes.txt")).unwrap(), b"keep");
}
#[test]
fn installs_official_single_directory_portable_zip() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let zip = archive(
dir.path(),
"release.zip",
&[
("photocraft-0.6.1-windows-x64-portable/photocraft.exe", b"program"),
("photocraft-0.6.1-windows-x64-portable/portable.txt", b""),
("photocraft-0.6.1-windows-x64-portable/lib/runtime.dll", b"library"),
],
);
let record = install(app, &zip, "0.6.1", None, &paths, None, false, &mut |_| Ok(())).unwrap();
assert_eq!(record.path.file_name().unwrap(), "photocraft.exe");
assert_eq!(record.path.parent().unwrap().file_name().unwrap(), "photocraft");
assert_eq!(std::fs::read(&record.path).unwrap(), b"program");
assert_eq!(std::fs::read(paths.apps().join("photocraft/lib/runtime.dll")).unwrap(), b"library");
assert_eq!(record.files.len(), 3);
remove(app, &record).unwrap();
assert!(!record.path.exists());
}
#[test]
fn rejects_mixed_root_and_multiple_directory_archives() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
for (name, entries) in [
("mixed.zip", vec![("package/photocraft.exe", b"program".as_slice()), ("another/readme.txt", b"other".as_slice())]),
("nested.zip", vec![("wrapper/inner/photocraft.exe", b"program".as_slice())]),
] {
let zip = archive(dir.path(), name, &entries);
assert!(install(app, &zip, "0.6.1", None, &paths, None, false, &mut |_| Ok(())).is_err(), "{name}");
assert!(!paths.apps().join("photocraft/photocraft.exe").exists());
}
}
#[test]
fn rejects_traversal_and_case_collisions() {
let dir = tempfile::tempdir().unwrap();
for (name, files) in [
("traversal.zip", vec![("../escape.exe", b"bad".as_slice())]),
("colon.zip", vec![("data:stream", b"bad".as_slice())]),
("duplicate.zip", vec![("PhotoCraft.exe", b"one".as_slice()), ("photocraft.exe", b"two".as_slice())]),
] {
let zip = archive(dir.path(), name, &files);
let output = dir.path().join(name.replace('.', "-"));
std::fs::create_dir(&output).unwrap();
assert!(extract(&zip, &output).is_err(), "{name}");
}
assert!(!dir.path().join("escape.exe").exists());
assert!(validate_directory(Path::new("relative/folder")).is_err());
assert!(validate_directory(&dir.path().join("../escape")).is_err());
}
}
+597
View File
@@ -0,0 +1,597 @@
//! Starting programs: the Craft Apps themselves, the file manager, and helper tools.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// A `Command` that won't flash a console window on Windows, and clears AppImage-injected
/// environment variables that corrupt system helper binaries and external applications.
pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
#[allow(unused_mut)]
let mut cmd = Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(unix)]
clean_appimage_env(&mut cmd);
cmd
}
/// Cleans AppImage runtime environment variables so child processes don't load incompatible
/// libraries from the launcher's bundled AppImage.
#[cfg(unix)]
pub fn clean_appimage_env(cmd: &mut Command) {
if std::env::var_os("APPIMAGE").is_some() {
for var in [
"LD_LIBRARY_PATH",
"LD_PRELOAD",
"APPDIR",
"APPIMAGE",
"ARGV0",
"OWD",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GTK_PATH",
"PYTHONHOME",
"QT_PLUGIN_PATH",
] {
cmd.env_remove(var);
}
}
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
run(program, args, timeout).ok()
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`; otherwise
/// the reason, in the helper's own words (its standard error) where it gave one.
pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
let mut cmd = command(program);
cmd.args(args);
run_command(cmd, program, timeout)
}
/// Runs a configured helper with bounded output and a timeout.
pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result<String, String> {
let mut child =
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't start {program} ({e})"))?;
// Read both pipes while it runs, so a chatty helper can't block on a full one.
let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take()));
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(15)),
_ => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
}
}
};
// A descendant may retain a pipe after the direct child exits. Keep reader completion
// under the same deadline; on timeout the readers detach, as in the child timeout path.
while !stdout.is_finished() || !stderr.is_finished() {
if start.elapsed() >= timeout {
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(15));
}
let text = |pipe: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&pipe.join().unwrap_or_default()).into_owned();
let (out, err) = (text(stdout), text(stderr));
if status.success() {
return Ok(out);
}
let reason = err.trim();
Err(if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() })
}
/// Captures at most 8 MB of a child's pipe and drains the rest on its own thread.
fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(mut pipe) = pipe {
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut pipe, 8 << 20), &mut out);
// Keep reading without retaining more output: closing a full pipe early can make a
// successful helper fail with a broken pipe (SIGPIPE on Unix).
let _ = std::io::copy(&mut pipe, &mut std::io::sink());
}
out
})
}
/// Starts `cmd` without waiting for it, and reaps it when it exits.
pub fn spawn_detached(mut cmd: Command) -> Result<(), String> {
let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().map_err(|e| format!("couldn't start it ({e})"))?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
/// Waits up to `timeout` for the process `pid` to exit, and says whether it has. A process that
/// can't be opened, because it's gone or never was, counts as exited.
#[cfg(windows)]
pub fn wait_for_exit(pid: u32, timeout: Duration) -> bool {
use winsafe::{HPROCESS, co};
let Ok(process) = HPROCESS::OpenProcess(co::PROCESS::SYNCHRONIZE, false, pid) else { return true };
// u32::MAX would mean INFINITE.
let ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX - 1);
process.WaitForSingleObject(Some(ms)).is_ok_and(|w| w == co::WAIT::OBJECT_0)
}
/// Whether this process is running inside a Flatpak container sandbox.
pub fn in_sandbox() -> bool {
std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()
}
/// A command to run `flatpak`, delegating to the host via `flatpak-spawn --host` if inside a sandbox.
pub fn flatpak_command() -> Command {
if in_sandbox() {
let mut cmd = command("flatpak-spawn");
cmd.args(["--host", "flatpak"]);
cmd
} else {
command("flatpak")
}
}
/// Runs a flatpak command and captures its standard output.
pub fn capture_flatpak(args: &[&str], timeout: Duration) -> Option<String> {
if in_sandbox() {
let mut spawn_args = vec!["--host", "flatpak"];
spawn_args.extend_from_slice(args);
capture("flatpak-spawn", &spawn_args, timeout)
} else {
capture("flatpak", args, timeout)
}
}
/// Whether Flatpak is available on the machine (or on the host when sandboxed).
pub fn is_flatpak_available() -> bool {
if in_sandbox() {
capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some()
} else {
which_on_path("flatpak").is_some()
}
}
fn which_on_path(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}
/// Starts an app binary from its own folder.
pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> {
let mut cmd = if in_sandbox() {
let mut c = command("flatpak-spawn");
c.arg("--host").arg(exe);
c
} else {
command(exe)
};
cmd.args(args);
if let Some(dir) = exe.parent() {
cmd.current_dir(dir);
}
spawn_detached(cmd)
}
/// Opens a file, folder or URL with the system's default handler.
pub fn open(target: &std::ffi::OsStr) -> Result<(), String> {
spawn_detached(opener(target)?)
}
/// The system's default opener for `target`: `explorer` on Windows, `open` on macOS, and
/// `xdg-open` elsewhere (on the host, through `flatpak-spawn --host`, inside the sandbox).
fn opener(target: &std::ffi::OsStr) -> Result<Command, String> {
let mut cmd = if cfg!(target_os = "macos") {
command("open")
} else if cfg!(windows) {
command(windows_system_program("explorer.exe")?)
} else if in_sandbox() {
let mut c = command("flatpak-spawn");
c.arg("--host").arg("xdg-open");
c
} else {
command("xdg-open")
};
cmd.arg(target);
Ok(cmd)
}
/// Opens `primary_url` with the system's default handler. If opening fails (for example,
/// if a custom scheme handler is not found or fails to execute), falls back to `fallback_url`.
pub fn open_url_with_fallback(primary_url: &str, fallback_url: &str) {
let primary = primary_url.to_string();
let fallback = fallback_url.to_string();
std::thread::spawn(move || {
if !open_and_wait_success(&primary, Duration::from_millis(1500)) {
log::warn!("couldn't open {primary}; falling back to {fallback}");
let _ = open(std::ffi::OsStr::new(&fallback));
}
});
}
/// Starts the default system opener for `target` and returns `true` unless it fails to start
/// or exits in error within `timeout`. An opener still running after `timeout` counts as
/// success and is reaped in the background.
///
/// `explorer` exits with an error status even when it opens the target, so on Windows only a
/// failure to start counts; the scheme check covers the rest.
fn open_and_wait_success(target: &str, timeout: Duration) -> bool {
let mut cmd = match opener(std::ffi::OsStr::new(target)) {
Ok(cmd) => cmd,
Err(error) => {
log::warn!("couldn't resolve the system opener for {target}: {error}");
return false;
}
};
if cfg!(windows) {
return spawn_detached(cmd).is_ok();
}
let mut child = match cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
Ok(c) => c,
Err(e) => {
log::warn!("couldn't start the opener for {target}: {e}");
return false;
}
};
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(25)),
Ok(None) => {
std::thread::spawn(move || {
let _ = child.wait();
});
return true;
}
Err(_) => return false,
}
}
}
/// Shows `path` selected in the file manager.
pub fn reveal(path: &Path) -> Result<(), String> {
if cfg!(target_os = "macos") {
let mut cmd = command("open");
cmd.arg("-R").arg(path);
spawn_detached(cmd)
} else if cfg!(windows) {
reveal_in_explorer(path)
} else {
open(path.parent().unwrap_or(path).as_os_str())
}
}
#[cfg(windows)]
fn reveal_in_explorer(path: &Path) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let mut cmd = command(windows_system_program("explorer.exe")?);
// Passed raw: `arg` would wrap all of `/select,...` in quotes when the path has a space
// (`C:\Program Files\...`), and Explorer then ignores the switch and opens Documents.
// A quote in the path would end the quoted part early and let the rest reach Explorer as
// switches; no Windows path has one, so refuse it.
let arg = explorer_select_arg(path).ok_or_else(|| format!("{} isn't a usable path", path.display()))?;
cmd.raw_arg(arg);
spawn_detached(cmd)
}
#[cfg(not(windows))]
fn reveal_in_explorer(_path: &Path) -> Result<(), String> {
Err("Explorer is only on Windows".into())
}
/// Explorer's `/select,"<path>"` argument as it goes on the command line: only the path is quoted,
/// and forward slashes become backslashes, which Explorer needs to find the item. `None` for a path
/// with a `"` in it.
#[cfg_attr(not(windows), allow(dead_code))]
fn explorer_select_arg(path: &Path) -> Option<std::ffi::OsString> {
let s = path.to_str()?;
if s.contains('"') {
return None;
}
let mut arg = std::ffi::OsString::from("/select,\"");
arg.push(s.replace('/', "\\"));
arg.push("\"");
Some(arg)
}
/// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running.
pub fn is_running(path: &Path) -> bool {
if cfg!(windows) {
let Some(name) = path.file_name().and_then(OsStr::to_str) else { return false };
let filter = format!("IMAGENAME eq {name}");
let Ok(tasklist) = windows_system_program(r"System32\tasklist.exe") else { return false };
let mut cmd = command(tasklist);
cmd.args(["/FI", &filter, "/NH", "/FO", "CSV"]);
run_command(cmd, "tasklist", Duration::from_secs(5)).ok().is_some_and(|out| out.to_ascii_lowercase().contains(&name.to_ascii_lowercase()))
} else {
let Some(p) = path.to_str() else { return false };
let pattern = pgrep_pattern(p);
// pgrep exits 0 when something matches; it never matches itself.
if in_sandbox() {
capture("flatpak-spawn", &["--host", "pgrep", "-f", "--", &pattern], Duration::from_secs(5)).is_some()
} else {
capture("pgrep", &["-f", "--", &pattern], Duration::from_secs(5)).is_some()
}
}
}
fn windows_system_program(relative: &str) -> Result<PathBuf, String> {
let root = std::env::var_os("SystemRoot")
.filter(|root| !root.is_empty())
.map(PathBuf::from)
.ok_or_else(|| "Windows didn't provide its system folder".to_string())?;
if !root.is_absolute() {
return Err("Windows provided a non-absolute system folder".into());
}
Ok(root.join(relative))
}
/// Quote the path's metacharacters for pgrep's extended regular expression.
fn pgrep_pattern(path: &str) -> String {
let mut pattern = String::with_capacity(path.len());
for c in path.chars() {
if matches!(c, '.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\') {
pattern.push('\\');
}
pattern.push(c);
}
pattern
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use std::io::{BufRead, BufReader};
use std::process::Child;
use super::*;
struct RunningProbe(Child);
impl RunningProbe {
fn start(path: &str) -> Self {
let child = command("/bin/sh")
.args(["-c", "printf 'ready\\n'; read line", path])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut probe = Self(child);
let mut ready = String::new();
BufReader::new(probe.0.stdout.take().unwrap()).read_line(&mut ready).unwrap();
assert_eq!(ready, "ready\n");
probe
}
}
impl Drop for RunningProbe {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn running_paths_are_matched_literally() {
let root = format!("/tmp/artcraft-launcher-process-{}", std::process::id());
for (actual, requested, expected) in [
("plain/PhotoCraft.app", "plain/PhotoCraft.app", true),
("Apps (work)/PhotoCraft.app", "Apps (work)/PhotoCraft.app", true),
("Apps [work]/PhotoCraft.app", "Apps [work]/PhotoCraft.app", true),
("other/PhotoCraftXapp", "other/PhotoCraft.app", false),
("symbols/应用.^$*+?()[]{}|\\.app", "symbols/应用.^$*+?()[]{}|\\.app", true),
] {
let actual = format!("{root}/{actual}");
let requested = format!("{root}/{requested}");
let _probe = RunningProbe::start(&actual);
assert_eq!(is_running(Path::new(&requested)), expected, "{requested}");
}
}
}
#[cfg(test)]
mod run_tests {
use super::*;
/// A shell command line, for the platform's shell.
fn shell(script: &str) -> (&'static str, [&str; 2]) {
if cfg!(windows) { ("cmd", ["/C", script]) } else { ("/bin/sh", ["-c", script]) }
}
#[cfg(windows)]
#[test]
fn waiting_for_a_process_ends_when_it_exits() {
let mut child = Command::new("ping").args(["-n", "3", "127.0.0.1"]).stdout(Stdio::null()).spawn().unwrap();
assert!(!wait_for_exit(child.id(), Duration::from_millis(200)), "it's still running");
assert!(wait_for_exit(child.id(), Duration::from_secs(30)), "it has exited");
child.wait().unwrap();
}
#[test]
fn a_failing_helper_reports_its_own_error() {
let (program, args) = shell(if cfg!(windows) { "echo no such image 1>&2 & exit /b 3" } else { "echo no such image >&2; exit 3" });
assert_eq!(run(program, &args, Duration::from_secs(30)), Err("no such image".into()));
assert_eq!(capture(program, &args, Duration::from_secs(30)), None);
}
#[cfg(unix)]
#[test]
fn successful_helpers_can_write_past_the_stdout_capture_limit() {
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero"], Duration::from_secs(10)).unwrap();
assert_eq!(out.len(), 8 << 20);
}
#[cfg(unix)]
#[test]
fn successful_helpers_can_write_past_the_stderr_capture_limit() {
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero >&2"], Duration::from_secs(10));
assert!(out.is_ok(), "the helper should finish without a broken pipe");
assert!(out.unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn a_helper_that_keeps_writing_still_times_out() {
let start = Instant::now();
let err = run("yes", &[], Duration::from_millis(100)).unwrap_err();
assert!(err.contains("didn't finish within"), "{err}");
assert!(start.elapsed() < Duration::from_secs(5));
}
#[test]
fn a_silent_failure_and_a_success() {
let (program, args) = shell("exit 4");
let err = run(program, &args, Duration::from_secs(30)).unwrap_err();
assert!(err.starts_with(&format!("{program} failed")), "{err}");
let (program, args) = shell("echo hello");
assert_eq!(run(program, &args, Duration::from_secs(30)).map(|out| out.trim().to_string()), Ok("hello".into()));
}
}
#[cfg(test)]
mod explorer_tests {
use super::*;
#[cfg(windows)]
#[test]
fn system_helpers_use_absolute_system_paths() {
let root = PathBuf::from(std::env::var_os("SystemRoot").unwrap());
assert_eq!(windows_system_program("explorer.exe").unwrap(), root.join("explorer.exe"));
assert_eq!(windows_system_program(r"System32\tasklist.exe").unwrap(), root.join(r"System32\tasklist.exe"));
}
#[test]
fn explorer_select_quotes_only_the_path() {
let arg = explorer_select_arg(Path::new(r"C:\Program Files\PhotoCraft\photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Program Files\PhotoCraft\photocraft.exe""#);
}
#[test]
fn explorer_select_without_spaces() {
let arg = explorer_select_arg(Path::new(r"C:\Apps\photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Apps\photocraft.exe""#);
assert_eq!(explorer_select_arg(Path::new(r#"C:\Apps" /root,C:\x.exe"#)), None);
}
#[test]
fn explorer_select_uses_backslashes() {
let arg = explorer_select_arg(Path::new("C:/Users/A B/AppData/Local/ArtCraft Launcher/apps/photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Users\A B\AppData\Local\ArtCraft Launcher\apps\photocraft.exe""#);
}
}
#[cfg(test)]
mod inherited_pipe_tests {
use super::*;
use std::io::Write;
fn fixture_args(name: &str, marker: &str) -> Vec<String> {
let (_, module) = module_path!().split_once("::").unwrap();
// libtest accepts arbitrary skip filters; this one carries our private fixture path.
vec!["--ignored".into(), "--exact".into(), format!("{module}::{name}"), "--nocapture".into(), "--skip".into(), marker.into()]
}
fn fixture_dir() -> std::path::PathBuf {
let args: Vec<String> = std::env::args().collect();
std::path::PathBuf::from(&args[args.iter().position(|arg| arg == "--skip").unwrap() + 1])
}
#[test]
#[ignore = "self-spawned subprocess fixture"]
fn pipe_holder() {
let dir = fixture_dir();
let buffer = [b'x'; 64 << 10];
// Both streams exceed the capture cap before the direct child exits.
for _ in 0..144 {
std::io::stdout().write_all(&buffer).unwrap();
std::io::stderr().write_all(&buffer).unwrap();
}
std::fs::write(dir.join("ready"), []).unwrap();
// Hold the pipes far longer than any deadline the test tries, so a capture that waited
// for them would show; the test's guard stops this early.
let start = Instant::now();
while !dir.join("stop").exists() && start.elapsed() < HOLD {
std::thread::sleep(Duration::from_millis(10));
}
std::fs::write(dir.join("done"), []).unwrap();
}
#[test]
#[ignore = "self-spawned subprocess fixture"]
fn pipe_parent() {
let dir = fixture_dir();
let descendant = command(std::env::current_exe().unwrap()).args(fixture_args("pipe_holder", dir.to_str().unwrap())).spawn().unwrap();
std::fs::write(dir.join("spawned"), []).unwrap();
// No deadline of its own: run() kills this process at its deadline if it is too slow.
let start = Instant::now();
while !dir.join("ready").exists() && start.elapsed() < HOLD {
std::thread::sleep(Duration::from_millis(10));
}
assert!(dir.join("ready").exists(), "descendant did not fill both captured pipes");
std::fs::write(dir.join("parent-returning"), []).unwrap();
// Dropping Child leaves the finite fixture running with inherited stdout/stderr.
drop(descendant);
}
/// How long the descendant holds the pipes when nothing stops it.
const HOLD: Duration = Duration::from_secs(120);
struct StopHolder(tempfile::TempDir);
impl Drop for StopHolder {
fn drop(&mut self) {
let _ = std::fs::write(self.0.path().join("stop"), []);
if !self.0.path().join("spawned").exists() {
return;
}
let start = Instant::now();
while !self.0.path().join("done").exists() && start.elapsed() < Duration::from_secs(30) {
std::thread::sleep(Duration::from_millis(10));
}
}
}
#[test]
fn exited_helper_does_not_wait_past_deadline_for_inherited_pipes() {
let exe = std::env::current_exe().unwrap();
// The direct child has to start a second test process and fill both pipes before the
// deadline, which can take seconds on a slow or emulated machine. An attempt where it
// didn't get that far says nothing about inherited pipes: retry with a longer deadline.
let mut deadline = Duration::from_secs(1);
loop {
let holder = StopHolder(tempfile::tempdir().unwrap());
let args = fixture_args("pipe_parent", holder.0.path().to_str().unwrap());
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let start = Instant::now();
let result = run(exe.to_str().unwrap(), &args, deadline);
let elapsed = start.elapsed();
if !holder.0.path().join("parent-returning").exists() && deadline < Duration::from_secs(16) {
deadline *= 4;
continue;
}
assert!(holder.0.path().join("parent-returning").exists(), "direct child did not exit its fixture within {deadline:?}");
assert!(
matches!(&result, Err(error) if error.contains("didn't finish within")),
"expected a pipe deadline error; helper returned success: {}, elapsed: {elapsed:?}",
result.is_ok()
);
// The descendant would hold the pipes for HOLD, far past this bound.
assert!(deadline * 2 < HOLD);
assert!(elapsed < deadline * 2, "capture exceeded its caller deadline {deadline:?}: {elapsed:?}");
// The guard stops the finite descendant and waits for its completion marker on failures too.
break;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
//! The vocabulary installers and release lookups report in: what a job is doing, how it ended,
//! where new installs go and what a release lookup found.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::release::Release;
/// Parent folders for new installs. Existing installations keep their recorded location.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct InstallLocations {
pub root: Option<String>,
pub per_app: BTreeMap<String, String>,
}
impl InstallLocations {
pub fn folder(&self, app: &str) -> Option<&str> {
self.per_app.get(app).map(String::as_str).or(self.root.as_deref())
}
}
/// The answer to "what's the latest release of this app?".
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LatestRelease {
Found(Release),
/// The repo exists but has no published release.
None,
/// Couldn't find out (offline, rate-limited, unreadable answer).
Failed(String),
}
/// What a running job is doing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobPhase {
Downloading {
done: u64,
total: u64,
},
/// The connection dropped; trying again shortly, from where it stopped.
Reconnecting {
done: u64,
total: u64,
},
Verifying,
/// The system's installer is busy with another app; this one goes next.
Waiting,
/// Waiting on the OS installer (MSI) or copying files.
Installing,
Removing,
}
/// The `JobFinished` error a paused job ends with (not a failure).
pub const PAUSED: &str = "paused";
/// The `JobFinished` error a cancelled job ends with (not a failure).
pub const CANCELLED: &str = "cancelled";
/// The `JobFinished` error of an install whose system prompt (password, Windows Installer) the
/// user dismissed after the download finished (not a failure).
pub const INSTALL_CANCELLED: &str = "install cancelled";
/// Where release information came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Source {
/// ArtCraft's release feed (storyteller-web), which polls GitHub for everyone.
Feed,
/// The GitHub REST API.
GitHubApi,
/// github.com release pages (no API quota; used when the API is rate-limited).
GitHubPages,
/// Saved answers from an earlier check.
Cache,
}
impl Source {
pub fn label(self) -> &'static str {
match self {
Self::Feed => "ArtCraft",
Self::GitHubApi => "GitHub",
Self::GitHubPages => "GitHub (release pages)",
Self::Cache => "saved results",
}
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Which HTTP proxy the launcher uses: the proxy environment variables first, else the proxy set
//! in the OS (Windows Internet Settings, macOS network settings). Automatic configuration (PAC
//! scripts, WPAD) is not supported.
use ureq::{Proxy, ProxyProtocol};
/// A manually configured proxy read from the OS settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemProxy {
pub host: String,
pub port: Option<u16>,
/// Hosts that skip the proxy, in `NO_PROXY` form (`example.com`, `*.example.com`, `10.*`).
pub bypass: Vec<String>,
/// Where it came from, for the log.
pub source: &'static str,
}
/// The proxy every agent uses, or `None` to connect directly. Logs the choice at info level,
/// without any user name or password.
pub fn pick() -> Option<Proxy> {
if let Some(proxy) = Proxy::try_from_env() {
let mut builder = Proxy::builder(proxy.protocol()).host(proxy.host()).port(proxy.port());
if let Some(user) = proxy.username() {
builder = builder.username(user);
}
if let Some(pass) = proxy.password() {
builder = builder.password(pass);
}
if let Ok(no_proxy_env) = std::env::var("NO_PROXY").or_else(|_| std::env::var("no_proxy")) {
for entry in no_proxy_env.split(',') {
let entry = entry.trim();
if !entry.is_empty() {
builder = builder.no_proxy(entry);
}
}
}
for entry in ["localhost", "127.0.0.1", "[::1]"] {
builder = builder.no_proxy(entry);
}
if let Ok(p) = builder.build() {
log::info!("HTTP proxy: {} {}:{} (from environment variables)", p.protocol(), p.host(), p.port());
return Some(p);
}
log::info!("HTTP proxy: {} {}:{} (from environment variables)", proxy.protocol(), proxy.host(), proxy.port());
return Some(proxy);
}
let system = system()?;
match to_proxy(&system) {
Ok(proxy) => {
log::info!("HTTP proxy: {} {}:{} (from {})", proxy.protocol(), proxy.host(), proxy.port(), system.source);
Some(proxy)
}
Err(e) => {
log::warn!("ignoring the proxy in {} ({e}); connecting directly", system.source);
None
}
}
}
/// A ureq proxy for `system`. Loopback addresses always go direct, as they do on Windows and
/// macOS.
pub fn to_proxy(system: &SystemProxy) -> Result<Proxy, ureq::Error> {
let mut builder = Proxy::builder(ProxyProtocol::Http).host(&system.host);
if let Some(port) = system.port {
builder = builder.port(port);
}
for entry in ["localhost", "127.0.0.1", "[::1]"].iter().copied().chain(system.bypass.iter().map(String::as_str)) {
builder = builder.no_proxy(entry);
}
builder.build()
}
#[cfg(windows)]
fn system() -> Option<SystemProxy> {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;
let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings").ok()?;
let enabled: u32 = key.get_value("ProxyEnable").unwrap_or(0);
if enabled == 0 {
return None;
}
let server: String = key.get_value("ProxyServer").ok()?;
let overrides: String = key.get_value("ProxyOverride").unwrap_or_default();
let (host, port) = windows_proxy_server(&server)?;
Some(SystemProxy { host, port, bypass: windows_bypass(&overrides), source: "Windows proxy settings" })
}
#[cfg(target_os = "macos")]
fn system() -> Option<SystemProxy> {
let out = crate::process::capture("/usr/sbin/scutil", &["--proxy"], std::time::Duration::from_secs(3))?;
parse_scutil(&out)
}
#[cfg(not(any(windows, target_os = "macos")))]
fn system() -> Option<SystemProxy> {
// Linux and the BSDs: desktop proxy settings reach programs through the environment.
None
}
/// Parses Windows' `ProxyServer` value: either `host:port` for every protocol, or a list such as
/// `http=h:p;https=h:p;socks=h:p`. Prefers the HTTPS entry (every request is HTTPS), then HTTP.
/// SOCKS-only settings are ignored.
pub fn windows_proxy_server(value: &str) -> Option<(String, Option<u16>)> {
let entries: Vec<&str> = value.split([';', ' ']).map(str::trim).filter(|e| !e.is_empty()).collect();
let chosen = if entries.iter().any(|e| e.contains('=')) {
let find = |scheme: &str| {
entries.iter().find_map(|e| e.split_once('=').filter(|(k, _)| k.trim().eq_ignore_ascii_case(scheme)).map(|(_, v)| v.trim()))
};
find("https").or_else(|| find("http"))?
} else {
entries.first().copied()?
};
host_port(chosen)
}
/// Splits `host:port`, `http://host:port/`, `[::1]:8080` and similar into host and port.
fn host_port(value: &str) -> Option<(String, Option<u16>)> {
let rest = match value.split_once("://") {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("http") => rest,
Some(_) => return None,
None => value,
};
let rest = rest.trim_end_matches('/');
// Credentials never come from these settings; refuse rather than mis-parse.
if rest.is_empty() || rest.contains(['@', '/', '?', '#']) {
return None;
}
if rest.starts_with('[') {
let (host, after) = rest.split_once(']')?;
let host = format!("{host}]");
return match after.strip_prefix(':') {
Some(port) => Some((host, Some(port.parse().ok()?))),
None if after.is_empty() => Some((host, None)),
None => None,
};
}
match rest.split_once(':') {
Some((host, port)) if !host.is_empty() && !port.contains(':') => Some((host.to_string(), Some(port.parse().ok()?))),
Some(_) => None,
None => Some((rest.to_string(), None)),
}
}
/// Parses Windows' `ProxyOverride` bypass list (`*.local;10.*;<local>`). `<local>` (plain host
/// names without a dot) has no `NO_PROXY` equivalent and is dropped.
pub fn windows_bypass(value: &str) -> Vec<String> {
value.split([';', ' ']).map(str::trim).filter(|e| !e.is_empty() && !e.eq_ignore_ascii_case("<local>")).map(str::to_string).collect()
}
/// Parses `scutil --proxy` output: the HTTPS proxy when enabled, else the HTTP proxy, plus the
/// exceptions list.
pub fn parse_scutil(out: &str) -> Option<SystemProxy> {
let mut fields: Vec<(&str, &str)> = Vec::new();
let mut bypass = Vec::new();
let mut in_exceptions = false;
for line in out.lines().map(str::trim) {
if in_exceptions {
if line.starts_with('}') {
in_exceptions = false;
} else if let Some((_, v)) = line.split_once(" : ") {
let v = v.trim();
if !v.is_empty() {
bypass.push(v.to_string());
}
}
continue;
}
let Some((k, v)) = line.split_once(" : ") else { continue };
let (k, v) = (k.trim(), v.trim());
if k == "ExceptionsList" && v.starts_with("<array>") {
in_exceptions = !v.ends_with('}');
} else {
fields.push((k, v));
}
}
let get = |key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| *v);
["HTTPS", "HTTP"].into_iter().find_map(|kind| {
if get(&format!("{kind}Enable")) != Some("1") {
return None;
}
let host = get(&format!("{kind}Proxy")).filter(|h| !h.is_empty() && !h.contains(['@', '/', ' ']))?;
let port = match get(&format!("{kind}Port")) {
Some(p) => Some(p.parse().ok()?),
None => None,
};
Some(SystemProxy { host: host.to_string(), port, bypass: bypass.clone(), source: "macOS proxy settings" })
})
}
#[cfg(test)]
mod tests {
use super::*;
fn hp(host: &str, port: Option<u16>) -> Option<(String, Option<u16>)> {
Some((host.to_string(), port))
}
#[test]
fn windows_single_proxy_for_everything() {
assert_eq!(windows_proxy_server("proxy.corp:8080"), hp("proxy.corp", Some(8080)));
assert_eq!(windows_proxy_server("127.0.0.1:7890"), hp("127.0.0.1", Some(7890)));
assert_eq!(windows_proxy_server("proxy.corp"), hp("proxy.corp", None));
assert_eq!(windows_proxy_server("http://proxy.corp:3128/"), hp("proxy.corp", Some(3128)));
assert_eq!(windows_proxy_server("[::1]:8080"), hp("[::1]", Some(8080)));
}
#[test]
fn windows_per_protocol_list_prefers_https() {
assert_eq!(windows_proxy_server("http=h1:80;https=h2:443;ftp=h3:21;socks=h4:1080"), hp("h2", Some(443)));
assert_eq!(windows_proxy_server("ftp=h3:21;http=h1:8080"), hp("h1", Some(8080)));
assert_eq!(windows_proxy_server("HTTPS=h2:8443 ; "), hp("h2", Some(8443)));
}
#[test]
fn windows_settings_that_cannot_be_used() {
assert_eq!(windows_proxy_server(""), None);
assert_eq!(windows_proxy_server("socks=h4:1080"), None);
assert_eq!(windows_proxy_server("socks5://h4:1080"), None);
assert_eq!(windows_proxy_server("proxy.corp:notaport"), None);
assert_eq!(windows_proxy_server("proxy.corp:99999"), None);
assert_eq!(windows_proxy_server("user:pw@proxy.corp:8080"), None);
}
#[test]
fn windows_bypass_list() {
assert_eq!(windows_bypass("*.local;10.*; localhost;<local>"), ["*.local", "10.*", "localhost"]);
assert!(windows_bypass("").is_empty());
}
const SCUTIL: &str = "<dictionary> {
ExceptionsList : <array> {
0 : *.local
1 : 169.254/16
}
FTPPassive : 1
HTTPEnable : 1
HTTPPort : 8080
HTTPProxy : http.corp
HTTPSEnable : 1
HTTPSPort : 3128
HTTPSProxy : https.corp
SOCKSEnable : 0
}
";
#[test]
fn scutil_https_proxy_with_exceptions() {
let p = parse_scutil(SCUTIL).unwrap();
assert_eq!((p.host.as_str(), p.port), ("https.corp", Some(3128)));
assert_eq!(p.bypass, ["*.local", "169.254/16"]);
}
#[test]
fn scutil_falls_back_to_http_proxy() {
let out = SCUTIL.replace("HTTPSEnable : 1", "HTTPSEnable : 0");
let p = parse_scutil(&out).unwrap();
assert_eq!((p.host.as_str(), p.port), ("http.corp", Some(8080)));
}
#[test]
fn scutil_without_a_proxy() {
let out = "<dictionary> {\n ExceptionsList : <array> {\n 0 : *.local\n }\n FTPPassive : 1\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n";
assert_eq!(parse_scutil(out), None);
assert_eq!(parse_scutil(""), None);
assert_eq!(parse_scutil("HTTPSEnable : 1\nHTTPSProxy : \nHTTPSPort : 1\n"), None);
}
#[test]
fn system_proxy_becomes_a_ureq_proxy_that_skips_loopback() {
let p = to_proxy(&SystemProxy { host: "proxy.corp".into(), port: Some(8080), bypass: vec!["*.local".into()], source: "test" }).unwrap();
assert_eq!((p.protocol(), p.host(), p.port()), (ProxyProtocol::Http, "proxy.corp", 8080));
for (url, direct) in
[("http://127.0.0.1:1/", true), ("http://localhost/", true), ("https://nas.local/", true), ("https://github.com/", false)]
{
assert_eq!(p.is_no_proxy(&url.parse().unwrap()), direct, "{url}");
}
let p = to_proxy(&SystemProxy { host: "proxy.corp".into(), port: None, bypass: vec![], source: "test" }).unwrap();
assert_eq!(p.port(), 80);
}
}
+512
View File
@@ -0,0 +1,512 @@
//! One system installer at a time.
//!
//! apt, dnf, rpm and the Windows Installer each hold a system-wide lock: an install started
//! while another runs fails. Jobs wait their turn here instead. Where the installer takes
//! several packages, the jobs waiting for the same command share one call, so the password is
//! asked for once.
use std::ffi::OsString;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use crate::protocol::CANCELLED;
type Outcome = Result<(), String>;
struct Waiting {
id: u64,
command: Vec<OsString>,
target: OsString,
}
struct State {
busy: bool,
next_id: u64,
waiting: Vec<Waiting>,
/// The jobs of the call in progress.
running: Vec<u64>,
finished: Vec<(u64, Outcome)>,
}
pub struct Queue {
state: Mutex<State>,
changed: Condvar,
}
/// Frees the installer when a call ends (even by unwinding) and hands its outcome to the other
/// jobs of the call.
struct Turn<'a> {
queue: &'a Queue,
others: Vec<u64>,
outcome: Outcome,
}
impl Drop for Turn<'_> {
fn drop(&mut self) {
let mut state = self.queue.lock();
state.busy = false;
state.running.clear();
for id in self.others.drain(..) {
state.finished.push((id, self.outcome.clone()));
}
self.queue.changed.notify_all();
}
}
impl Default for Queue {
fn default() -> Self {
Self::new()
}
}
impl Queue {
pub const fn new() -> Self {
let state = State { busy: false, next_id: 0, waiting: Vec::new(), running: Vec::new(), finished: Vec::new() };
Self { state: Mutex::new(state), changed: Condvar::new() }
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Until something changes, or long enough to ask again whether the job was cancelled.
fn wait<'a>(&self, state: MutexGuard<'a, State>) -> MutexGuard<'a, State> {
self.changed.wait_timeout(state, Duration::from_millis(100)).unwrap_or_else(PoisonError::into_inner).0
}
/// Runs `work` once no other installer is running. `turn(true)` says it has to wait,
/// `turn(false)` that the wait is over. `None` when `cancelled` (asked a few times a second,
/// so it must not block) said so during the wait; `work` then never ran.
pub fn alone<R>(&self, cancelled: &dyn Fn() -> bool, turn: &mut dyn FnMut(bool), work: impl FnOnce() -> R) -> Option<R> {
let mut state = self.lock();
let mut waited = false;
loop {
if waited && cancelled() {
return None;
}
if !state.busy {
break;
}
if waited {
state = self.wait(state);
} else {
waited = true;
drop(state);
turn(true);
state = self.lock();
}
}
state.busy = true;
drop(state);
if waited {
turn(false);
}
let _turn = Turn { queue: self, others: Vec::new(), outcome: Ok(()) };
Some(work())
}
/// Runs `exec(command, targets)` once no other installer is running, with the targets of
/// every job waiting for the same `command`, and gives each of those jobs its outcome.
/// `turn(true)` says the job has to wait, `turn(false)` that its call has started.
///
/// A job that is waiting leaves with [`CANCELLED`] once `cancelled` (asked a few times a
/// second, so it must not block) says so; once its call has started it sees it through.
pub fn batched(
&self,
command: Vec<OsString>,
target: OsString,
cancelled: &dyn Fn() -> bool,
turn: &mut dyn FnMut(bool),
exec: &dyn Fn(&[OsString], &[OsString]) -> Outcome,
) -> Outcome {
let mut state = self.lock();
let id = state.next_id;
state.next_id = state.next_id.wrapping_add(1);
state.waiting.push(Waiting { id, command: command.clone(), target });
let (mut waited, mut started) = (false, false);
loop {
if let Some(at) = state.finished.iter().position(|(done, _)| *done == id) {
return state.finished.swap_remove(at).1;
}
// Another job took this one into its call.
let running = state.running.contains(&id);
if !running && waited && cancelled() {
state.waiting.retain(|w| w.id != id);
return Err(CANCELLED.into());
}
if !running && !state.busy {
break;
}
let tell = if running { waited && !started } else { !waited };
if tell {
(waited, started) = (true, running);
drop(state);
turn(!running);
state = self.lock();
} else {
state = self.wait(state);
}
}
let (batch, rest): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut state.waiting).into_iter().partition(|w| w.command == command);
state.waiting = rest;
state.busy = true;
state.running = batch.iter().map(|w| w.id).collect();
self.changed.notify_all();
drop(state);
if waited {
turn(false);
}
let others = batch.iter().map(|w| w.id).filter(|other| *other != id).collect();
let targets: Vec<OsString> = batch.into_iter().map(|w| w.target).collect();
let mut call = Turn { queue: self, others, outcome: Err("the system installer was interrupted".into()) };
let outcome = exec(&command, &targets);
call.outcome.clone_from(&outcome);
outcome
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use super::*;
fn os(s: &str) -> OsString {
OsString::from(s)
}
fn line(parts: &[&str]) -> Vec<OsString> {
parts.iter().map(|p| os(p)).collect()
}
fn install() -> Vec<OsString> {
line(&["apt-get", "install", "-y"])
}
fn remove() -> Vec<OsString> {
line(&["apt-get", "remove", "-y"])
}
/// Every call made, as `command + targets` with the targets sorted.
#[derive(Default)]
struct Calls(Mutex<Vec<Vec<OsString>>>);
impl Calls {
fn record(&self, command: &[OsString], targets: &[OsString]) {
let mut targets = targets.to_vec();
targets.sort();
self.0.lock().unwrap().push(command.iter().cloned().chain(targets).collect());
}
fn taken(&self) -> Vec<Vec<OsString>> {
let mut calls = std::mem::take(&mut *self.0.lock().unwrap());
calls.sort();
calls
}
}
/// Starts `jobs` (command, target) while an install is running, so they all wait; lets that
/// install finish once every one of them has said it is waiting. Returns their outcomes.
fn behind_a_running_install(
queue: &Queue,
jobs: &[(Vec<OsString>, &str)],
exec: &(dyn Fn(&[OsString], &[OsString]) -> Outcome + Sync),
) -> Vec<Option<Outcome>> {
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| false, &mut turn, exec)
})
})
.collect();
for _ in jobs {
turns.recv().unwrap();
}
release.send(()).unwrap();
handles.into_iter().map(|h| h.join().ok()).collect()
})
}
#[test]
fn jobs_that_wait_together_share_one_call_per_command() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (remove(), "photocraft"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Ok(())
});
assert_eq!(results, vec![Some(Ok(())); 4]);
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb", "b.deb", "c.deb"]), line(&["apt-get", "remove", "-y", "photocraft"])]);
}
#[test]
fn installers_never_run_at_the_same_time() {
let queue = Queue::new();
let (inside, most, handled) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
let enter = || {
most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(3));
inside.fetch_sub(1, Ordering::SeqCst);
};
std::thread::scope(|scope| {
for i in 0..24 {
let (queue, enter, handled) = (&queue, &enter, &handled);
scope.spawn(move || {
std::thread::sleep(Duration::from_millis(i % 5));
let exec = |_: &[OsString], targets: &[OsString]| {
enter();
handled.fetch_add(targets.len(), Ordering::SeqCst);
Ok(())
};
match i % 3 {
0 => queue.batched(install(), os(&format!("{i}.deb")), &|| false, &mut |_| {}, &exec).unwrap(),
1 => queue.batched(remove(), os(&format!("app{i}")), &|| false, &mut |_| {}, &exec).unwrap(),
_ => queue
.alone(&|| false, &mut |_| {}, || {
enter();
handled.fetch_add(1, Ordering::SeqCst);
})
.unwrap(),
}
});
}
});
assert_eq!(most.load(Ordering::SeqCst), 1);
assert_eq!(handled.load(Ordering::SeqCst), 24);
}
#[test]
fn a_job_arriving_mid_install_waits_then_runs() {
let queue = Queue::new();
let calls = Calls::default();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls) = (&queue, &calls);
let first = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| false, &mut |_| panic!("the first job never waits"), &|command, targets| {
calls.record(command, targets);
started.send(()).unwrap();
released.recv().unwrap();
Ok(())
})
});
has_started.recv().unwrap();
let second = scope.spawn(move || {
queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned.send(waiting).unwrap(), &|command, targets| {
calls.record(command, targets);
Ok(())
})
});
assert!(turns.recv().unwrap(), "the second job says it is waiting");
assert_eq!(calls.0.lock().unwrap().len(), 1, "and does not start while the first runs");
release.send(()).unwrap();
assert_eq!(first.join().unwrap(), Ok(()));
assert_eq!(second.join().unwrap(), Ok(()));
assert!(!turns.recv().unwrap(), "then that its turn came");
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb"]), line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn a_job_taken_into_another_jobs_call_hears_it_started() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
let installing = AtomicUsize::new(0);
std::thread::scope(|scope| {
let (queue, installing) = (&queue, &installing);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
for file in ["a.deb", "b.deb"] {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if !waiting {
installing.fetch_add(1, Ordering::SeqCst);
}
turned.send(waiting).unwrap();
};
// The call lasts until both jobs know it is under way.
let exec = |_: &[OsString], targets: &[OsString]| {
assert_eq!(targets.len(), 2);
while installing.load(Ordering::SeqCst) < 2 {
std::thread::sleep(Duration::from_millis(1));
}
Ok(())
};
assert_eq!(queue.batched(install(), os(file), &|| false, &mut turn, &exec), Ok(()));
});
}
assert!(turns.recv().unwrap() && turns.recv().unwrap());
release.send(()).unwrap();
});
assert_eq!(turns.try_iter().collect::<Vec<_>>(), [false, false]);
}
#[test]
fn a_dismissed_prompt_cancels_every_job_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Err("cancelled".into())
});
assert_eq!(calls.taken().len(), 1);
assert_eq!(results, vec![Some(Err("cancelled".to_string())); 3]);
}
#[test]
fn a_waiting_job_can_be_cancelled_and_is_left_out_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let cancel = AtomicBool::new(false);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls, cancel) = (&queue, &calls, &cancel);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let exec = |command: &[OsString], targets: &[OsString]| {
calls.record(command, targets);
Ok(())
};
let turned_too = turned.clone();
let leaving = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| cancel.load(Ordering::SeqCst), &mut |waiting| turned.send(waiting).unwrap(), &exec)
});
let staying =
scope.spawn(move || queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned_too.send(waiting).unwrap(), &exec));
assert!(turns.recv().unwrap() && turns.recv().unwrap());
cancel.store(true, Ordering::SeqCst);
// It leaves while the other install is still running, without waiting for its turn.
assert_eq!(leaving.join().unwrap(), Err("cancelled".to_string()));
assert!(calls.0.lock().unwrap().is_empty());
release.send(()).unwrap();
assert_eq!(staying.join().unwrap(), Ok(()));
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn waiting_alone_for_the_installer_can_be_cancelled() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
std::thread::scope(|scope| {
let queue = &queue;
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let ran = queue.alone(&|| true, &mut |_| {}, || panic!("a cancelled job never runs"));
assert_eq!(ran, None);
release.send(()).unwrap();
});
}
#[test]
fn a_job_whose_call_has_started_sees_it_through() {
let queue = Queue::new();
let cancel = AtomicBool::new(false);
let (in_call, is_in_call) = mpsc::channel();
let (finish, finished) = mpsc::channel::<()>();
let finished = Mutex::new(finished);
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
std::thread::scope(|scope| {
let (queue, cancel, finished) = (&queue, &cancel, &finished);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let (turned, in_call) = (turned.clone(), in_call.clone());
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| cancel.load(Ordering::SeqCst), &mut turn, &|_, targets| {
assert_eq!(targets.len(), 2);
in_call.send(()).unwrap();
finished.lock().unwrap().recv().unwrap();
Ok(())
})
})
})
.collect();
turns.recv().unwrap();
turns.recv().unwrap();
release.send(()).unwrap();
is_in_call.recv().unwrap();
// Cancelled mid-call: the job taken into the other's call has time to notice, and must not leave.
cancel.store(true, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
finish.send(()).unwrap();
for h in handles {
assert_eq!(h.join().unwrap(), Ok(()));
}
});
}
#[test]
fn an_installer_that_dies_frees_the_queue_and_fails_its_jobs() {
let queue = Queue::new();
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
let results = behind_a_running_install(&queue, &jobs, &|_, targets| {
assert_eq!(targets.len(), 2);
panic!("the installer died")
});
// The job that made the call unwound; the other was told.
assert!(results.contains(&None), "{results:?}");
assert!(results.contains(&Some(Err("the system installer was interrupted".to_string()))), "{results:?}");
assert_eq!(queue.batched(install(), os("c.deb"), &|| false, &mut |_| {}, &|_, _| Ok(())), Ok(()));
}
}
+438
View File
@@ -0,0 +1,438 @@
//! GitHub releases: reading the API's answer and `SHA256SUMS.txt`.
//!
//! Everything here is untrusted input. Sizes are bounded by the caller, download links must point
//! at the app's own release downloads, and checksums must look like checksums.
use serde::{Deserialize, Serialize};
use crate::version::{self, Version};
/// One downloadable file of a release.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Asset {
pub name: String,
/// Bytes; 0 when unknown.
pub size: u64,
/// `https://github.com/<repo>/releases/download/<tag>/<name>`.
pub url: String,
/// Lowercase hex SHA-256, when GitHub or `SHA256SUMS.txt` told us.
pub sha256: Option<String>,
}
/// A published release of one app.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Release {
pub tag: String,
pub version: Option<Version>,
/// `PhotoCraft v0.5.0`
pub name: String,
/// Unix seconds; `None` when only the tag is known.
pub published_at: Option<i64>,
pub html_url: String,
/// Release notes (Markdown), capped.
pub notes: String,
pub prerelease: bool,
pub assets: Vec<Asset>,
}
impl Release {
pub fn version_label(&self) -> String {
self.version.as_ref().map_or_else(|| self.tag.clone(), ToString::to_string)
}
/// Fills in checksums from a `SHA256SUMS.txt` for assets that don't have one.
pub fn apply_sums(&mut self, sums: &[(String, String)]) {
for asset in &mut self.assets {
if asset.sha256.is_none() {
asset.sha256 = sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone());
}
}
}
pub fn sums_asset(&self) -> Option<&Asset> {
self.assets.iter().find(|a| a.name == "SHA256SUMS.txt")
}
}
const MAX_NOTES: usize = 64 * 1024;
fn is_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Whether `url` is safe to hand to the system browser from untrusted text (release notes):
/// `https` only, a host, no user-info, no spaces or control characters, and no shell metacharacters.
///
/// The system opener is not always a direct `open`: the pinned `webbrowser` crate's WSL fallback
/// passes the URL to `cmd.exe /c start` while escaping only `^` and `&`, so a character like `|`
/// in an otherwise valid URL becomes a command separator (CWE-78). Because a legitimate
/// `https://` web link never needs these characters unescaped, we refuse any URL that contains a
/// shell/cmd metacharacter rather than rely on the opener to neutralise it.
pub fn safe_web_link(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
url.len() <= 2000
&& !authority.is_empty()
&& !authority.contains(['@', '\\'])
&& !url.chars().any(|c| c.is_control() || c.is_whitespace())
&& !url.chars().any(is_shell_meta)
}
/// Characters a command interpreter (POSIX shells or `cmd.exe`) may treat specially. A real
/// `https` URL never needs any of them unescaped in a browser-opened link. `%` matters because
/// `cmd.exe` expands `%NAME%` references, and an expanded value could itself carry metacharacters.
pub fn is_shell_meta(c: char) -> bool {
matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '$' | '`' | '"' | '\'' | '^' | '!' | '*' | '%' | '{' | '}' | '[' | ']')
}
/// Whether `url` is a valid release page URL strictly for `repo` on GitHub:
/// starts with `https://github.com/<repo>/releases`, contains no dot-segments (`.` or `..`),
/// no percent-encoded path separators (`%2f`, `%5c`), no query/fragment/backslash, and
/// satisfies [`safe_web_link`] (no shell metacharacters or control bytes, CWE-78).
pub fn safe_release_html_url(repo: &str, url: &str) -> Option<String> {
if !safe_web_link(url) {
return None;
}
let releases = format!("https://github.com/{repo}/releases");
if url == releases {
return Some(url.to_string());
}
let rest = url.strip_prefix(&releases)?;
let path = rest.strip_prefix('/')?;
if path.is_empty() || path.contains(['?', '#', '\\']) {
return None;
}
for seg in path.split('/') {
if seg.is_empty() || seg == "." || seg == ".." || seg.contains("%2f") || seg.contains("%2F") || seg.contains("%5c") || seg.contains("%5C") {
return None;
}
}
Some(url.to_string())
}
/// The prefix every download of `repo` must start with.
pub fn download_prefix(repo: &str) -> String {
format!("https://github.com/{repo}/releases/download/")
}
/// Whether `url` is a download that truly belongs to `repo`.
///
/// A raw `starts_with(prefix)` check is not enough: a value such as
/// `https://github.com/storytold/craft-launcher/releases/download/../../../ATTACKER/REPO/releases/download/v1/x`
/// shares the prefix textually, yet its interpreted path escapes the repository. The link is
/// accepted only when the text after the prefix is exactly `<tag>/<name>` — two non-empty path
/// segments with no traversal (`.`/`..`), no additional slashes, no query or fragment, and no
/// backslash or percent-encoded separators that a client could later re-interpret.
pub fn download_url_under(url: &str, prefix: &str) -> bool {
let Some(rest) = url.strip_prefix(prefix) else { return false };
download_suffix_ok(rest)
}
/// Whether `url` sits under a developer mirror `base` (ending in `/`) without escaping it.
pub fn download_url_under_mirror(url: &str, base: &str) -> bool {
let Some(rest) = url.strip_prefix(base) else { return false };
// A mirror serves `<tag>/<name>` just like GitHub, but we only require that it can't climb out
// of its own folder; its layout is otherwise the developer's business.
!rest.is_empty() && !url.contains(['?', '#', '\\']) && !contains_traversal(rest) && !contains_encoded_separator(rest)
}
/// `<tag>/<name>`: exactly two non-empty segments, no traversal, no re-interpretable separators.
fn download_suffix_ok(rest: &str) -> bool {
if rest.contains(['?', '#', '\\']) || contains_encoded_separator(rest) {
return false;
}
let mut parts = rest.split('/');
let (Some(tag), Some(name), None) = (parts.next(), parts.next(), parts.next()) else { return false };
!tag.is_empty() && tag != "." && tag != ".." && tag.len() <= 64 && !tag.contains('\\') && valid_file_name(name)
}
/// Any `.` or `..` path segment that could climb out of the repository.
fn contains_traversal(rest: &str) -> bool {
rest.split('/').any(|seg| seg == "." || seg == "..")
}
/// Percent-encoded slash or backslash (`%2f`, `%5c`): a client could decode these back into
/// separators after the check, so a confined URL must not contain them.
fn contains_encoded_separator(rest: &str) -> bool {
let bytes = rest.as_bytes();
bytes.windows(3).any(|w| w[0] == b'%' && matches!(&w[1..].to_ascii_lowercase()[..], b"2f" | b"5c"))
}
/// Reads `GET /repos/<repo>/releases/latest` (or one element of `/releases`).
pub fn parse_github_release(json: &str, repo: &str) -> Result<Release, String> {
let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;
release_from_value(&v, repo)
}
/// Reads `GET /repos/<repo>/releases` and returns the newest stable (or any, with
/// `allow_prerelease`) published release.
pub fn parse_github_release_list(json: &str, repo: &str, allow_prerelease: bool) -> Result<Option<Release>, String> {
let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?;
let list = v.as_array().ok_or("unexpected answer from GitHub")?;
let mut best: Option<Release> = None;
for item in list {
if item["draft"].as_bool().unwrap_or(false) {
continue;
}
let Ok(r) = release_from_value(item, repo) else { continue };
let Some(ver) = r.version.as_ref() else { continue };
if !allow_prerelease && (r.prerelease || !version::is_stable(ver)) {
continue;
}
let newer = best.as_ref().and_then(|b| b.version.as_ref()).is_none_or(|b| version::is_newer(ver, b));
if newer {
best = Some(r);
}
}
Ok(best)
}
fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, String> {
if let Some(msg) = v["message"].as_str() {
return Err(if msg == "Not Found" { "no releases yet".into() } else { format!("GitHub says: {}", truncate(msg, 200)) });
}
let tag = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
let releases = format!("https://github.com/{repo}/releases");
let html_url = v["html_url"].as_str().and_then(|u| safe_release_html_url(repo, u)).unwrap_or_else(|| releases.clone());
let prefix = download_prefix(repo);
let assets = v["assets"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|a| {
let name = a["name"].as_str().filter(|n| valid_file_name(n))?.to_string();
let url = a["browser_download_url"].as_str().filter(|u| download_url_under(u, &prefix))?.to_string();
let sha256 = a["digest"].as_str().and_then(|d| d.strip_prefix("sha256:")).filter(|h| is_hex64(h)).map(str::to_ascii_lowercase);
Some(Asset { name, size: a["size"].as_u64().unwrap_or(0), url, sha256 })
})
.collect()
})
.unwrap_or_default();
Ok(Release {
version: version::parse(&tag),
name: v["name"].as_str().map(|n| truncate(n, 120)).filter(|n| !n.is_empty()).unwrap_or_else(|| tag.clone()),
tag,
published_at: v["published_at"].as_str().and_then(crate::time::parse_rfc3339),
html_url,
notes: v["body"].as_str().map(|b| truncate(b, MAX_NOTES)).unwrap_or_default(),
prerelease: v["prerelease"].as_bool().unwrap_or(false),
assets,
})
}
/// A release known only from its tag and `SHA256SUMS.txt` (the quota-free route: the
/// `releases/latest` redirect names the tag, and the sums list every file).
pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Release {
let prefix = download_prefix(repo);
let mut assets: Vec<Asset> = sums
.iter()
.map(|(hash, name)| Asset { name: name.clone(), size: 0, url: format!("{prefix}{tag}/{name}"), sha256: Some(hash.clone()) })
.collect();
assets.push(Asset { name: "SHA256SUMS.txt".into(), size: 0, url: format!("{prefix}{tag}/SHA256SUMS.txt"), sha256: None });
Release {
tag: tag.to_string(),
version: version::parse(tag),
name: tag.to_string(),
published_at: None,
html_url: format!("https://github.com/{repo}/releases/tag/{tag}"),
notes: String::new(),
prerelease: false,
assets,
}
}
/// The tag in a `https://github.com/<repo>/releases/tag/<tag>` redirect target.
pub fn tag_from_redirect(location: &str, repo: &str) -> Option<String> {
let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?;
(!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string())
}
/// Reads `sha256sum` output: `<hex> <name>` (or `<hex> *<name>`) per line.
pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let (hash, rest) = line.trim().split_once(char::is_whitespace)?;
let name = rest.trim_start().trim_start_matches('*');
(is_hex64(hash) && valid_file_name(name)).then(|| (hash.to_ascii_lowercase(), name.to_string()))
})
.collect()
}
/// A bare file name we'd be willing to save: no paths, no control characters.
pub fn valid_file_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 200
&& name != "."
&& name != ".."
&& !name.starts_with('.')
&& !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'))
}
/// `s` cut to at most `max` bytes (on a char boundary) with an ellipsis, for text from a
/// release or the feed shown in the UI.
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
const REPO: &str = "storytold/photocraft";
#[test]
fn truncate_keeps_whole_characters() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("abcdef", 3), "abc…");
// `é` is two bytes: cutting at 2 would split it, so the cut backs off to 1.
assert_eq!(truncate("aé", 2), "a…");
}
#[test]
fn only_plain_https_links_pass() {
assert!(safe_web_link("https://github.com/storytold/photocraft/releases/tag/v1"));
for bad in [
"http://example.com/",
"javascript:alert(1)",
"file:///C:/Windows/System32/calc.exe",
"ms-msdt:/id",
"https://",
"https://user@evil.example/",
"https://example.com/a b",
"https://example.com/\nx",
"https:///path",
// Shell/cmd metacharacters: the WSL opener fallback shells out, so these must be
// refused even inside an otherwise valid https URL (CWE-78).
"https://example.com/a|calc",
"https://example.com/p?x=1&y=2",
"https://example.com/`id`",
"https://example.com/$(whoami)",
"https://example.com/a;b",
"https://example.com/a>b",
"https://example.com/%COMSPEC%",
"https://example.com/%PATH%",
] {
assert!(!safe_web_link(bad), "{bad:?}");
}
}
const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";
fn api_json(url: &str) -> String {
format!(
r#"{{"tag_name":"v0.5.0","name":"PhotoCraft v0.5.0","html_url":"https://github.com/storytold/photocraft/releases/tag/v0.5.0",
"published_at":"2026-10-08T14:52:50Z","prerelease":false,"body":"Notes",
"assets":[{{"name":"photocraft-0.5.0-macos-universal.dmg","size":123,"browser_download_url":"{url}","digest":"sha256:{HASH}"}}]}}"#
)
}
#[test]
fn reads_the_latest_release() {
let r = parse_github_release(
&api_json("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg"),
REPO,
)
.unwrap();
assert_eq!(r.version, Some(Version::new(0, 5, 0)));
assert_eq!(r.name, "PhotoCraft v0.5.0");
assert_eq!(r.published_at, Some(1_791_471_170));
assert_eq!(r.assets.len(), 1);
assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH));
assert_eq!(r.assets[0].size, 123);
}
#[test]
fn drops_downloads_that_point_elsewhere() {
for url in ["https://evil.example/photocraft.dmg", "https://github.com/storytold/other/releases/download/v1/x.dmg", "javascript:alert(1)"] {
let r = parse_github_release(&api_json(url), REPO).unwrap();
assert!(r.assets.is_empty(), "{url}");
}
assert_eq!(parse_github_release(r#"{"message":"Not Found"}"#, REPO).unwrap_err(), "no releases yet");
assert!(parse_github_release("<html>", REPO).is_err());
}
#[test]
fn release_html_url_rejects_traversal_and_metacharacters() {
let repo = "storytold/photocraft";
assert_eq!(
safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases/tag/v1.0.0"),
Some("https://github.com/storytold/photocraft/releases/tag/v1.0.0".to_string())
);
assert_eq!(
safe_release_html_url(repo, "https://github.com/storytold/photocraft/releases"),
Some("https://github.com/storytold/photocraft/releases".to_string())
);
for bad in [
"https://github.com/storytold/photocraft/releases/../../attacker/repo/releases/tag/v1",
"https://github.com/storytold/photocraft/releases/tag/v1/../../../attacker/repo",
"https://github.com/storytold/photocraft/releases/tag/v1|calc.exe",
"https://github.com/storytold/photocraft/releases/tag/v1%2f..%2f..",
"https://github.com/storytold/photocraft/releases/tag/v1?x=1",
"https://github.com/storytold/photocraft/releases/tag/v1#hash",
"https://github.com/storytold/other/releases/tag/v1.0.0",
"http://github.com/storytold/photocraft/releases/tag/v1",
] {
assert!(safe_release_html_url(repo, bad).is_none(), "should reject: {bad}");
}
}
#[test]
fn traversal_cannot_escape_the_repository() {
// Raw-prefix matches but the interpreted path climbs into another repository (CWE-180).
for url in [
"https://github.com/storytold/photocraft/releases/download/../../../../attacker/repo/releases/download/v99/photocraft-99.0.0-linux-x86_64.AppImage",
"https://github.com/storytold/photocraft/releases/download/v1/../../../attacker/repo/x.dmg",
"https://github.com/storytold/photocraft/releases/download/./v1/x.dmg",
"https://github.com/storytold/photocraft/releases/download/v1/sub/x.dmg",
"https://github.com/storytold/photocraft/releases/download/v1%2f..%2f..%2fx.dmg",
"https://github.com/storytold/photocraft/releases/download/v1/x%2fy.dmg",
] {
let prefix = download_prefix(REPO);
assert!(!download_url_under(url, &prefix), "{url}");
let r = parse_github_release(&api_json(url), REPO).unwrap();
assert!(r.assets.is_empty(), "{url}");
}
// A plain, confined download still passes.
let prefix = download_prefix(REPO);
assert!(download_url_under("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg", &prefix));
}
#[test]
fn list_picks_the_newest_stable_release() {
let json = r#"[
{"tag_name":"v0.6.0-rc.1","prerelease":false,"draft":false,"assets":[]},
{"tag_name":"v0.5.1","prerelease":true,"draft":false,"assets":[]},
{"tag_name":"v0.7.0","draft":true,"assets":[]},
{"tag_name":"v0.5.0","prerelease":false,"draft":false,"assets":[]},
{"tag_name":"v0.4.0","prerelease":false,"draft":false,"assets":[]}]"#;
assert_eq!(parse_github_release_list(json, REPO, false).unwrap().unwrap().tag, "v0.5.0");
assert_eq!(parse_github_release_list(json, REPO, true).unwrap().unwrap().tag, "v0.6.0-rc.1");
assert_eq!(parse_github_release_list("[]", REPO, false).unwrap(), None);
}
#[test]
fn reads_checksums_and_redirects() {
let sums = parse_sha256sums(&format!("{HASH} photocraft-0.5.0-macos-universal.dmg\n{HASH} *b.zip\nnot a line\n{HASH} ../escape\n"));
assert_eq!(sums, [(HASH.to_string(), "photocraft-0.5.0-macos-universal.dmg".to_string()), (HASH.to_string(), "b.zip".to_string())]);
assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases/tag/v0.5.0", REPO).as_deref(), Some("v0.5.0"));
assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases", REPO), None);
assert_eq!(tag_from_redirect("https://evil/storytold/photocraft/releases/tag/v1", REPO), None);
let r = release_from_sums(REPO, "v0.5.0", &sums);
assert_eq!(r.assets[0].url, "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg");
assert_eq!(r.version, Some(Version::new(0, 5, 0)));
}
#[test]
fn file_names_cannot_escape() {
for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] {
assert!(!valid_file_name(bad), "{bad:?}");
}
assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage"));
}
}
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
//! Where release information comes from.
//!
//! [`GitHubSource`] asks the GitHub REST API (one request per app, revalidated with ETags) and,
//! when the API is out of quota, falls back to github.com's release pages: the
//! `releases/latest` redirect names the tag and `SHA256SUMS.txt` lists every file with its
//! checksum, which costs no API quota at all. A later [`ReleaseSource`] will ask the ArtCraft
//! catalog API for every app in one request.
use std::collections::BTreeMap;
use std::sync::{Mutex, PoisonError};
use crate::protocol::{LatestRelease, Source};
use crate::release::{self, Release};
use serde::{Deserialize, Serialize};
use crate::http::{Fetched, Http};
/// One answer from a source.
#[derive(Clone, Debug)]
pub struct Answer {
pub latest: LatestRelease,
pub source: Source,
}
/// Looks up latest releases. Called from worker threads.
pub trait ReleaseSource: Send + Sync {
fn latest(&self, repo: &str, prereleases: bool) -> Answer;
/// The last answer saved for `repo`, for an instant first paint.
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease>;
/// The tag of `repo`'s newest stable release, by a route that costs no API quota (GitHub's
/// `releases/latest` redirect), or `None` when it can't be told. Quick: a check pressed by the
/// user asks this for every app to see whether the feed has fallen behind.
fn newest_tag(&self, repo: &str) -> Option<String> {
let _ = repo;
None
}
/// API refused until this time (Unix seconds).
fn rate_limited_until(&self) -> Option<i64>;
/// Persist anything worth keeping.
fn save(&self);
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Entry {
etag: Option<String>,
/// `None`: the repo has no releases.
release: Option<Release>,
prereleases: bool,
fetched_at: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct CacheFile {
entries: BTreeMap<String, Entry>,
}
pub struct GitHubSource {
http: Http,
cache: Mutex<CacheFile>,
cache_path: std::path::PathBuf,
limited_until: Mutex<Option<i64>>,
}
impl GitHubSource {
pub fn new(http: Http, cache_path: std::path::PathBuf) -> Self {
let cache = std::fs::read(&cache_path).ok().and_then(|b| serde_json::from_slice::<CacheFile>(&b).ok()).unwrap_or_default();
Self { http, cache: Mutex::new(cache), cache_path, limited_until: Mutex::new(None) }
}
fn entry(&self, repo: &str, prereleases: bool) -> Option<Entry> {
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
cache.entries.get(repo).filter(|e| e.prereleases == prereleases).cloned()
}
fn store(&self, repo: &str, entry: Entry) {
self.cache.lock().unwrap_or_else(PoisonError::into_inner).entries.insert(repo.to_string(), entry);
}
fn limited(&self) -> bool {
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
until.is_some_and(|u| u > crate::now())
}
fn ask_api(&self, repo: &str, prereleases: bool) -> Result<Option<Answer>, String> {
let url = if prereleases {
format!("https://api.github.com/repos/{repo}/releases?per_page=30")
} else {
format!("https://api.github.com/repos/{repo}/releases/latest")
};
let saved = self.entry(repo, prereleases);
let found =
|release: Option<Release>| Answer { latest: release.map_or(LatestRelease::None, LatestRelease::Found), source: Source::GitHubApi };
match self.http.get_api(&url, saved.as_ref().and_then(|e| e.etag.as_deref()))? {
Fetched::Body { text, etag } => {
let release = if prereleases {
release::parse_github_release_list(&text, repo, true)?
} else {
Some(release::parse_github_release(&text, repo)?)
};
self.store(repo, Entry { etag, release: release.clone(), prereleases, fetched_at: crate::now() });
Ok(Some(found(release)))
}
Fetched::NotModified => match saved {
Some(e) => {
self.store(repo, Entry { fetched_at: crate::now(), ..e.clone() });
Ok(Some(found(e.release)))
}
None => Err("GitHub said nothing changed, but there's no saved answer".into()),
},
Fetched::NotFound => {
self.store(repo, Entry { etag: None, release: None, prereleases, fetched_at: crate::now() });
Ok(Some(found(None)))
}
Fetched::RateLimited { reset } => {
*self.limited_until.lock().unwrap_or_else(PoisonError::into_inner) = Some(reset.unwrap_or_else(|| crate::now() + 15 * 60));
Ok(None)
}
}
}
/// The quota-free route through github.com (stable releases only).
fn ask_pages(&self, repo: &str) -> Result<Answer, String> {
let location = self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest"))?;
let Some(tag) = location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)) else {
// No published release: github.com sends us to the release list instead.
return Ok(Answer { latest: LatestRelease::None, source: Source::GitHubPages });
};
if let Some(e) = self.entry(repo, false)
&& let Some(r) = e.release.as_ref().filter(|r| r.tag == tag)
{
return Ok(Answer { latest: LatestRelease::Found(r.clone()), source: Source::GitHubPages });
}
let sums_url = format!("{}{tag}/SHA256SUMS.txt", release::download_prefix(repo));
let sums = self.http.get_text(&sums_url)?.map(|t| release::parse_sha256sums(&t)).unwrap_or_default();
let r = release::release_from_sums(repo, &tag, &sums);
self.store(repo, Entry { etag: None, release: Some(r.clone()), prereleases: false, fetched_at: crate::now() });
Ok(Answer { latest: LatestRelease::Found(r), source: Source::GitHubPages })
}
}
impl ReleaseSource for GitHubSource {
fn latest(&self, repo: &str, prereleases: bool) -> Answer {
let api_error = if self.limited() {
None
} else {
match self.ask_api(repo, prereleases) {
Ok(Some(answer)) => return answer,
Ok(None) => None,
Err(e) => Some(e),
}
};
if !prereleases && api_error.is_none() {
match self.ask_pages(repo) {
Ok(answer) => return answer,
Err(e) => log::warn!("{repo}: release pages: {e}"),
}
}
let why = api_error.unwrap_or_else(|| "GitHub's request limit was reached".into());
match self.cached(repo, prereleases) {
Some(latest) => Answer { latest, source: Source::Cache },
None => Answer { latest: LatestRelease::Failed(why), source: Source::Cache },
}
}
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease> {
self.entry(repo, prereleases).map(|e| e.release.map_or(LatestRelease::None, LatestRelease::Found))
}
fn newest_tag(&self, repo: &str) -> Option<String> {
match self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest")) {
Ok(location) => location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)),
Err(e) => {
log::debug!("{repo}: couldn't read the latest-release redirect: {e}");
None
}
}
}
fn rate_limited_until(&self) -> Option<i64> {
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
until.filter(|u| *u > crate::now())
}
fn save(&self) {
let json = {
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
serde_json::to_vec(&*cache)
};
match json {
Ok(bytes) => {
if let Err(e) = crate::paths::write_atomic(&self.cache_path, &bytes) {
log::warn!("release cache: {e}");
}
}
Err(e) => log::warn!("release cache: {e}"),
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//! How a copy of an app got onto the machine, and where it is.
use serde::{Deserialize, Serialize};
use crate::version::Version;
/// How an installed copy got onto the machine, which decides how to update and remove it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstallSource {
/// Installed by this launcher (it keeps a record and can update or remove it).
Launcher,
/// A macOS `.app` the user put in Applications.
MacApp,
/// A Windows Installer (MSI) install.
Msi,
/// A Debian package.
Deb,
/// An RPM package.
Rpm,
/// An Arch Linux package: the release's own (`ai.storyteller.<app>`), or `<app>`, `<app>-bin`
/// or `<app>-git` from the AUR or a repository.
Pacman,
/// A Flatpak.
Flatpak,
/// An AppImage file found on disk.
AppImage,
/// A binary found on `PATH` or in a system prefix.
Binary,
}
impl InstallSource {
pub fn label(self) -> &'static str {
match self {
Self::Launcher => "ArtCraft Launcher",
Self::MacApp => "Applications folder",
Self::Msi => "Windows Installer",
Self::Deb => "Debian package",
Self::Rpm => "RPM package",
Self::Pacman => "Arch package",
Self::Flatpak => "Flatpak",
Self::AppImage => "AppImage",
Self::Binary => "System binary",
}
}
/// Whether the launcher may replace or remove this copy itself. Packages owned by a system
/// package manager (deb, rpm, Flatpak) are updated through that manager instead.
pub fn launcher_can_update(self) -> bool {
matches!(self, Self::Launcher | Self::MacApp | Self::Msi | Self::AppImage)
}
}
/// An installed copy of an app.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Installed {
/// `None` when the copy is there but its version couldn't be read.
pub version: Option<Version>,
pub source: InstallSource,
/// Where it lives: the `.app`, the exe, the AppImage, or the binary of a package.
pub location: String,
/// The package that installed it, for package-manager installs (`photocraft-bin`).
#[serde(default)]
pub package: Option<String>,
}
+70
View File
@@ -0,0 +1,70 @@
//! The Craft App Suite as releases know it: each app's id, name and repository.
//!
//! An app that updates itself takes its own entry (`suite::PHOTOCRAFT`); the launcher takes all
//! of them. Installers use the list to tell a Craft App's package from anyone else's.
use crate::app::App;
pub const PHOTOCRAFT: App = App::new("photocraft", "PhotoCraft", "Image editing", "storytold/photocraft");
pub const VECTORCRAFT: App = App::new("vectorcraft", "VectorCraft", "Vector illustration", "storytold/vectorcraft");
pub const FILMCRAFT: App = App::new("filmcraft", "FilmCraft", "Video editing, color and sound", "storytold/filmcraft");
pub const LIGHTCRAFT: App = App::new("lightcraft", "LightCraft", "Photo library and raw development", "storytold/lightcraft");
pub const PDFCRAFT: App = App {
former_ids: &["printcraft"],
former_names: &["PrintCraft"],
..App::new("pdfcraft", "PdfCraft", "Reading, organizing and protecting PDFs", "storytold/pdfcraft")
};
pub const EFFECTCRAFT: App = App::new("effectcraft", "EffectCraft", "Motion graphics and visual effects", "storytold/effectcraft");
pub const DESIGNCRAFT: App = App::new("designcraft", "DesignCraft", "Page layout and publishing", "storytold/designcraft");
pub const FIGCRAFT: App = App::new("figcraft", "FigCraft", "Interface design and prototyping", "storytold/figcraft");
pub const WORDCRAFT: App = App::new("wordcraft", "WordCraft", "Writing and document design", "storytold/wordcraft");
pub const SOUNDCRAFT: App = App::new("soundcraft", "SoundCraft", "Recording, editing and mixing audio", "storytold/soundcraft");
pub const DECKCRAFT: App = App::new("deckcraft", "DeckCraft", "Presentations and slide shows", "storytold/deckcraft");
pub const CADCRAFT: App = App::new("cadcraft", "CADCraft", "Computer-aided design and drafting", "storytold/cadcraft");
pub const GRIDCRAFT: App = App::new("gridcraft", "GridCraft", "Spreadsheets and data", "storytold/gridcraft");
/// ArtCraft Launcher, described like a Craft App so it updates itself with the same machinery
/// (asset names `artcraft-launcher-<ver>-…`, bundle id `ai.storyteller.artcraft-launcher`,
/// `ArtCraft Launcher.app`). Not part of [`SUITE`].
pub const LAUNCHER: App = App::new("artcraft-launcher", "ArtCraft Launcher", "Installs and updates the Craft App Suite", "storytold/craft-launcher");
/// The Craft App Suite, in getartcraft.com's display order.
pub const SUITE: [App; 13] = [
PHOTOCRAFT,
VECTORCRAFT,
FILMCRAFT,
LIGHTCRAFT,
PDFCRAFT,
EFFECTCRAFT,
DESIGNCRAFT,
FIGCRAFT,
WORDCRAFT,
SOUNDCRAFT,
DECKCRAFT,
CADCRAFT,
GRIDCRAFT,
];
/// The suite's app with this id.
pub fn by_id(id: &str) -> Option<&'static App> {
SUITE.iter().find(|a| a.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thirteen_apps_with_unique_ids_in_their_own_repos() {
let mut ids: Vec<_> = SUITE.iter().map(|a| a.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), SUITE.len());
for a in &SUITE {
assert_eq!(a.repo, format!("storytold/{}", a.id));
assert_eq!(a.name.to_lowercase(), a.id);
}
assert!(by_id(LAUNCHER.id).is_none());
assert_eq!(by_id("pdfcraft").map(|a| a.former_ids), Some(&["printcraft"][..]));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Time and calendar conversions for release timestamps.
//!
//! Calendar conversion goes through chrono, in accordance with the AGENTS.md shared standard.
use chrono::DateTime;
/// Seconds since the Unix epoch for an RFC 3339 UTC timestamp such as `2026-10-08T14:52:50Z`.
pub fn parse_rfc3339(text: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(text.trim()).ok().map(|dt| dt.timestamp())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_github_timestamps() {
assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_rfc3339("2026-10-08T14:52:50Z"), Some(1_791_471_170));
assert_eq!(parse_rfc3339("2026-10-08T16:52:50+02:00"), Some(1_791_471_170));
assert_eq!(parse_rfc3339("2026-10-08T14:52:50.123Z"), Some(1_791_471_170));
for bad in ["", "2026-10-08", "2026-13-08T00:00:00Z", "2026-10-08T14:52:50Q", "20x6-10-08T14:52:50Z"] {
assert_eq!(parse_rfc3339(bad), None, "{bad}");
}
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Versions: release tags (`v0.5.0`), installed versions from many sources, and ordering.
use std::cmp::Ordering;
pub use semver::Version;
/// Reads a version from a tag or an installed-version string.
///
/// Accepts `v0.5.0`, `0.5.0`, `0.2.0-rc.1`, Debian's `0.2.0~rc.1-1`, and the two-part `0.5`
/// some platforms report. Build metadata is kept but never affects ordering.
pub fn parse(text: &str) -> Option<Version> {
let t = text.trim();
let t = t.strip_prefix(['v', 'V']).unwrap_or(t);
if t.is_empty() || t.len() > 64 {
return None;
}
// Debian/RPM spell pre-releases with `~` and append a package revision after `-`.
let debianish = t.contains('~');
let t = if debianish { t.split_once('-').map_or(t, |(v, _)| v).replace('~', "-") } else { t.to_string() };
if let Ok(v) = Version::parse(&t) {
return Some(v);
}
// `0.5` → `0.5.0`
let (core, rest) = match t.find(['-', '+']) {
Some(i) => t.split_at(i),
None => (t.as_str(), ""),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() == 2 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {
return Version::parse(&format!("{core}.0{rest}")).ok();
}
None
}
/// Reads a version as a system package manager reports it, dropping what the packaging adds:
/// an epoch (`1:`), the package revision (`-1`, `-2.fc40`), Debian's `~` pre-release spelling, and
/// a VCS snapshot suffix (`0.3.0.r47.g47f9306` from an AUR `-git` package, read as `0.3.0`).
pub fn parse_package(text: &str) -> Option<Version> {
let t = text.trim();
// Epoch.
let t = match t.split_once(':') {
Some((epoch, rest)) if !epoch.is_empty() && epoch.bytes().all(|b| b.is_ascii_digit()) => rest,
_ => t,
};
// Package revision: whatever follows the last '-' when the part before it is a version.
let t = match t.rsplit_once('-') {
Some((upstream, rev)) if !rev.is_empty() && rev.starts_with(|c: char| c.is_ascii_digit()) && upstream.contains('.') => upstream,
_ => t,
};
let t = t.replace('~', "-");
if let Some(v) = parse(&t) {
return Some(v);
}
// VCS snapshots and other suffixes: the leading X.Y.Z.
let core: String = t.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
let parts: Vec<&str> = core.split('.').filter(|p| !p.is_empty()).take(3).collect();
match parts.as_slice() {
[a, b, c] => Version::parse(&format!("{a}.{b}.{c}")).ok(),
[a, b] => Version::parse(&format!("{a}.{b}.0")).ok(),
// Text around the version, as in `--version` output (`PhotoCraft 0.5.0 (abc, 2026)`):
// the first `X.Y.Z` run anywhere.
_ => first_xyz(&t),
}
}
/// The first `major.minor.patch` run anywhere in `text`.
fn first_xyz(text: &str) -> Option<Version> {
let bytes = text.as_bytes();
for start in 0..bytes.len() {
if !bytes.get(start).is_some_and(u8::is_ascii_digit) || (start > 0 && bytes.get(start - 1).is_some_and(u8::is_ascii_digit)) {
continue;
}
let Some((major, i)) = digits(text, start) else { continue };
if bytes.get(i) != Some(&b'.') {
continue;
}
let Some((minor, j)) = digits(text, i + 1) else { continue };
if bytes.get(j) != Some(&b'.') {
continue;
}
let Some((patch, _)) = digits(text, j + 1) else { continue };
return Some(Version::new(major, minor, patch));
}
None
}
/// A run of ASCII digits from `from`, as a number and the index just past it.
fn digits(text: &str, from: usize) -> Option<(u64, usize)> {
let bytes = text.as_bytes();
let mut end = from;
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
end += 1;
}
if end == from {
return None;
}
text.get(from..end)?.parse().ok().map(|n| (n, end))
}
/// A version that's a plain `X.Y.Z` with no pre-release suffix.
pub fn is_stable(v: &Version) -> bool {
v.pre.is_empty()
}
/// Orders by precedence, ignoring build metadata.
pub fn compare(a: &Version, b: &Version) -> Ordering {
a.cmp_precedence(b)
}
/// True when `latest` is newer than `installed`.
pub fn is_newer(latest: &Version, installed: &Version) -> bool {
compare(latest, installed) == Ordering::Greater
}
/// Platforms that only record `X.Y.Z` (the MSI ProductVersion, `CFBundleShortVersionString`)
/// drop a pre-release suffix. A numeric-only installed version therefore counts as current when it
/// matches a pre-release's numbers, rather than nagging forever.
pub fn is_update(latest: &Version, installed: &Version) -> bool {
if installed.pre.is_empty()
&& !latest.pre.is_empty()
&& (latest.major, latest.minor, latest.patch) == (installed.major, installed.minor, installed.patch)
{
return false;
}
is_newer(latest, installed)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
parse(s).unwrap()
}
#[test]
fn reads_tags_and_platform_spellings() {
assert_eq!(v("v0.5.0"), Version::new(0, 5, 0));
assert_eq!(v(" 1.2.3\n"), Version::new(1, 2, 3));
assert_eq!(v("0.5"), Version::new(0, 5, 0));
assert_eq!(v("0.2.0~rc.1-1").to_string(), "0.2.0-rc.1");
assert_eq!(v("0.2.0-rc.1").to_string(), "0.2.0-rc.1");
for bad in ["", "v", "latest", "1", "1.x.0", "0.5.", &"9".repeat(80)] {
assert_eq!(parse(bad), None, "{bad}");
}
}
#[test]
fn reads_package_manager_versions() {
let p = |s: &str| parse_package(s).map(|v| v.to_string());
// dpkg and pacman add a package revision; it is not a pre-release.
assert_eq!(p("0.5.0-1").as_deref(), Some("0.5.0"));
assert_eq!(p("0.5.0-12").as_deref(), Some("0.5.0"));
assert_eq!(p("1:0.5.0-1").as_deref(), Some("0.5.0"));
assert_eq!(p("0.2.0~rc.1-1").as_deref(), Some("0.2.0-rc.1"));
assert_eq!(p("0.5.0-2.fc40").as_deref(), Some("0.5.0"));
// AUR -git packages: the release the snapshot follows.
assert_eq!(p("0.3.0.r47.g47f9306-1").as_deref(), Some("0.3.0"));
// Plain versions (rpm %{VERSION}, Flatpak) pass through.
assert_eq!(p("0.4.0").as_deref(), Some("0.4.0"));
assert_eq!(p("0.5").as_deref(), Some("0.5.0"));
// A version inside text, as `--version` prints it.
assert_eq!(p("PhotoCraft 0.5.0 (abc, 2026)").as_deref(), Some("0.5.0"));
assert_eq!(p("junk"), None);
assert_eq!(p("garbage"), None);
// The bug this guards: 0.5.0-1 used to read as a pre-release, so an installed .deb of the
// latest release always looked out of date.
assert!(!is_update(&v("0.5.0"), &parse_package("0.5.0-1").unwrap()));
}
#[test]
fn orders_versions_and_spots_updates() {
assert!(is_newer(&v("0.10.0"), &v("0.9.9")));
assert!(is_newer(&v("0.2.0"), &v("0.2.0-rc.5")));
assert!(!is_newer(&v("0.2.0+abc"), &v("0.2.0")));
assert!(is_update(&v("0.5.0"), &v("0.4.1")));
assert!(!is_update(&v("0.5.0"), &v("0.5.0")));
assert!(!is_update(&v("0.5.0"), &v("0.6.0")));
// An MSI that recorded 0.3.0 for 0.3.0-rc.2 is not out of date against 0.3.0-rc.2.
assert!(!is_update(&v("0.3.0-rc.2"), &v("0.3.0")));
assert!(is_stable(&v("1.0.0")) && !is_stable(&v("1.0.0-beta.1")));
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"tag_name": "v0.9.0",
"name": "PhotoCraft v0.9.0",
"html_url": "https://github.com/storytold/photocraft/releases/tag/v0.9.0",
"published_at": "2026-10-14T16:36:26Z",
"prerelease": false,
"draft": false,
"assets": [
{
"name": "photocraft-0.9.0-macos-universal.dmg",
"size": 90386785,
"browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.9.0/photocraft-0.9.0-macos-universal.dmg",
"digest": "sha256:06503eb1c845990e99f8824831cc73b274e2b1dbe4461eb6460dfc9a6008239d"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"tag_name": "v0.10.0",
"name": "SoundCraft v0.10.0",
"html_url": "https://github.com/storytold/soundcraft/releases/tag/v0.10.0",
"published_at": "2026-10-20T14:34:31Z",
"prerelease": false,
"draft": false,
"assets": [
{
"name": "soundcraft-0.10.0-freebsd-aarch64.tar.gz",
"size": 14786997,
"browser_download_url": "https://github.com/storytold/soundcraft/releases/download/v0.10.0/soundcraft-0.10.0-freebsd-aarch64.tar.gz",
"digest": "sha256:6a791aa9afd693d701bdc80bc19839eabe0ccbc8d8424b5684ef1ac46d6d68e6"
},
{
"name": "soundcraft-0.10.0-freebsd-x86_64.tar.gz",
"size": 16384262,
"browser_download_url": "https://github.com/storytold/soundcraft/releases/download/v0.10.0/soundcraft-0.10.0-freebsd-x86_64.tar.gz",
"digest": "sha256:189d9e997cbab32d1672d824ee337a9447e54dd2be4934ff1537e2f53c52ea28"
}
]
}
+9 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-window"
description = "Decide who draws the window frame on Linux (the compositor or the app) and apply it, so every Craft app gets the same native titlebar and borders per desktop."
description = "Decide who draws the window frame (the desktop or the app) so every Craft app gets the same titlebar and borders per desktop, and make an app-drawn strip act as the title bar."
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -8,8 +8,9 @@ rust-version.workspace = true
repository.workspace = true
[features]
# `apply`/`decorations`: decide decorations and set them on an `egui::ViewportBuilder`.
egui = ["dep:egui"]
# `decorate`: set the decision on an `egui::ViewportBuilder`. `title_bar`: an app-drawn strip as
# the title bar (on macOS with AppKit's `movable` flag).
egui = ["dep:egui", "dep:objc2", "dep:objc2-app-kit"]
[dependencies]
egui = { workspace = true, optional = true }
@@ -18,5 +19,10 @@ egui = { workspace = true, optional = true }
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
wayland-client = "0.31"
# `title_bar::sync_movable` (`NSWindow.movable`); eframe's winit already uses these.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6.4", default-features = false, features = ["std"], optional = true }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow"], optional = true }
[lints]
workspace = true
+34 -3
View File
@@ -2,7 +2,8 @@ craft-window
============
Decide who draws the window frame — the desktop or the app — so every Craft app shows the same
titlebar and borders on a given desktop.
titlebar and borders on a given desktop, and make the app's own top strip act as the title bar
when it draws one.
## Why
@@ -54,8 +55,38 @@ if let Some(layout) = choice.frame().layout() {
An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting,
and can override the probe by setting the frame to `Frame::Desktop`.
The `egui` feature adds [`decorate`]. Without it, the crate is pure decision logic (`Choice`,
`Frame`, `Layout`) with no egui dependency.
The `egui` feature adds [`decorate`] and the [`title_bar`](#the-apps-own-title-bar) module.
Without it, the crate is pure decision logic (`Choice`, `Frame`, `Layout`) with no egui dependency.
## The app's own title bar
An app that draws over the title bar — a hidden macOS title bar with a full-size content view, or
`Frame::App` — makes its top strip act as one with `craft_window::title_bar` (`egui` feature):
| Where the drag or double-click starts | What happens |
| --- | --- |
| The strip's empty part | A drag moves the window (`ViewportCommand::StartDrag`); a double-click follows the system setting (macOS `AppleActionOnDoubleClick`: zoom, minimize or nothing; zoom elsewhere) |
| A control on the strip | The control gets it; the window stays put |
| An in-window panel over the strip (an `Order::Middle` layer: a floating dock, an `egui::Window`) | The panel gets it; the window stays put |
| A modal's backdrop or a tooltip (`Foreground`/`Tooltip`) | The window moves, as under AppKit's own title bar |
| Anything, in full screen | Nothing happens to the window |
```rust,ignore
egui::Panel::top("app_bar").show(ui, |ui| {
// Before the bar's controls, so they sit on top and take their own clicks.
craft_window::title_bar::empty_area(ui, ui.max_rect());
// ... the bar's controls ...
});
// macOS, once per frame after the UI: `applied` is app state, starting at `None`.
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.applied);
```
On macOS, AppKit moves a full-size-content window from a drag anywhere in the title-bar band, the
app's controls and panels included, unless `NSWindow.movable` is false. `sync_movable` sets that
flag from `title_bar::window_movable`, which is false while the pointer is on a control or a panel
over the strip. It leaves windows with hidden window buttons alone, such as floating panels in
their own windows.
## Notes
+23 -1
View File
@@ -2,7 +2,7 @@
#![forbid(unsafe_code)]
//! Decide who draws the window frame, so every Craft app shows the same titlebar and borders on a
//! given desktop.
//! given desktop, and make an app-drawn strip act as the title bar.
//!
//! egui apps either let the desktop draw the window frame (the title bar, borders and window
//! buttons) or turn it off and draw their own. Across the suite this decision had drifted: some
@@ -45,6 +45,28 @@
//!
//! The probe opens a short-lived Wayland connection and reads the global registry once; it never
//! touches the connection winit inherits. Call it once at startup.
//!
//! # The app's own title bar
//!
//! An app that draws over the title bar (a hidden macOS title bar with a full-size content view,
//! or [`Frame::App`]) makes its top strip act as one with [`title_bar`]: the strip's empty part
//! moves the window and follows the system's double-click setting, while the strip's controls and
//! in-window panels lying over it keep their own clicks and drags. On macOS,
//! `title_bar::sync_movable` sets AppKit's `movable` flag each frame so a drag that starts on
//! one of those does not move the window.
//!
//! ```ignore
//! egui::Panel::top("app_bar").show(ui, |ui| {
//! craft_window::title_bar::empty_area(ui, ui.max_rect());
//! // ... the bar's controls ...
//! });
//! // After the UI, once per frame (macOS); `applied` lives in the app state, starting at `None`.
//! #[cfg(target_os = "macos")]
//! craft_window::title_bar::sync_movable(ui.ctx(), &mut self.applied);
//! ```
#[cfg(feature = "egui")]
pub mod title_bar;
/// The environment-variable prefix an app overrides the frame with: `<PREFIX>_TITLEBAR`.
///
+279
View File
@@ -0,0 +1,279 @@
//! A strip of the app's own UI as the window's title bar: its empty part moves the window when
//! dragged and follows the system's title-bar double-click setting.
//!
//! This is for windows whose title bar the app draws over: on macOS a hidden title bar with a
//! full-size content view, elsewhere an undecorated window ([`Frame::App`](crate::Frame::App)).
//! Call [`empty_area`] with the strip's rect before drawing the strip's controls.
//!
//! On macOS the full-size content view makes AppKit hand every click in the title-bar band to the
//! app (that is how the strip's own controls work), and AppKit does not zoom the window. It does
//! move the window when a drag starts anywhere in the band, on the strip's controls and on any
//! in-window panel that lies over the band, unless the window is not movable. [`window_movable`]
//! says when the pointer is on one of those, and [`sync_movable`] sets the window's `movable` flag
//! from it before the press.
//!
//! In full screen the strip is not a title bar: neither a drag nor a double-click acts on the
//! window.
use std::sync::{Once, OnceLock};
/// System Settings › Desktop & Dock › "Double-click a window's title bar to".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DoubleClick {
Zoom,
Minimize,
Nothing,
}
/// The value of the global default `AppleActionOnDoubleClick`, as `defaults read` prints it.
/// Anything other than "Minimize" or "None" zooms, the macOS default ("Maximize"; macOS 15 adds
/// "Fill", which zooming comes closest to).
pub fn parse(defaults_output: &str) -> DoubleClick {
match defaults_output.trim() {
"Minimize" => DoubleClick::Minimize,
"None" => DoubleClick::Nothing,
_ => DoubleClick::Zoom,
}
}
static ACTION: OnceLock<DoubleClick> = OnceLock::new();
static READ: Once = Once::new();
/// Starts reading the system setting on a background thread, once per run (macOS only).
fn prefetch() {
READ.call_once(|| {
#[cfg(all(target_os = "macos", not(test)))]
{
// No thread, no setting: the strip zooms.
let _ = std::thread::Builder::new().name("title-bar-setting".into()).spawn(|| {
let action = match std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleActionOnDoubleClick"]).output() {
Ok(out) if out.status.success() => parse(&String::from_utf8_lossy(&out.stdout)),
// Absent (never changed) or unreadable: the default.
_ => DoubleClick::Zoom,
};
let _ = ACTION.set(action);
});
}
});
}
/// The system setting; zoom until it has been read, and always zoom outside macOS.
pub fn action() -> DoubleClick {
ACTION.get().copied().unwrap_or(DoubleClick::Zoom)
}
/// What a double-click on the empty title bar asks of the window.
pub fn double_click_command(action: DoubleClick, maximized: bool) -> Option<egui::ViewportCommand> {
match action {
DoubleClick::Zoom => Some(egui::ViewportCommand::Maximized(!maximized)),
DoubleClick::Minimize => Some(egui::ViewportCommand::Minimized(true)),
DoubleClick::Nothing => None,
}
}
/// Makes the empty part of `rect` act as a title bar. Call it before drawing the strip's controls:
/// they sit on top and take their own clicks.
///
/// The area senses clicks only. A drag-sensing background would also receive drags that start
/// on a click-only control (egui gives clicks and drags to different widgets), so a drag is
/// detected here as a press on the empty area that has stopped being a click.
///
/// Returns the empty area's response, for an accessible label or a tooltip.
pub fn empty_area(ui: &mut egui::Ui, rect: egui::Rect) -> egui::Response {
prefetch();
let resp = ui.interact(rect, ui.id().with("title_bar_empty"), egui::Sense::click());
let covered = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p)).and_then(|p| ui.ctx().layer_id_at(p));
let covered = covered.is_some_and(|top| covers(top, ui.layer_id(), resp.hovered()));
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(movable_id(), (pass, !covered)));
// egui forgets which widget a press landed on once it can no longer be a click, so the press
// is remembered (by its start time) while it still can.
let armed = resp.id.with("drag_press");
if ui.input(|i| i.viewport().fullscreen).unwrap_or(false) {
ui.data_mut(|d| d.remove::<Option<f64>>(armed));
return resp;
}
if resp.double_clicked() {
let maximized = ui.input(|i| i.viewport().maximized).unwrap_or(false);
if let Some(cmd) = double_click_command(action(), maximized) {
ui.ctx().send_viewport_cmd(cmd);
}
}
let press = ui.input(|i| i.pointer.press_start_time());
if resp.is_pointer_button_down_on() {
ui.data_mut(|d| d.insert_temp(armed, press));
}
if press.is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()) && ui.data(|d| d.get_temp::<Option<f64>>(armed)) == Some(press) {
// Once per press: AppKit's drag loop takes the mouse-up, so egui may still see the button
// down afterwards.
ui.data_mut(|d| d.remove::<Option<f64>>(armed));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
resp
}
/// Whether `top`, the topmost layer under the pointer, keeps a drag there from moving the window.
///
/// In the strip's own layer a control covers the empty area when the empty area is not hovered.
/// A `Middle` layer is an in-window panel lying over the strip (a floating dock, an
/// `egui::Window`). A layer above those (a modal's backdrop, a tooltip) leaves the window movable,
/// as AppKit's own title bar would be.
fn covers(top: egui::LayerId, strip: egui::LayerId, empty_hovered: bool) -> bool {
if top == strip { !empty_hovered } else { top.order == egui::Order::Middle }
}
fn movable_id() -> egui::Id {
egui::Id::new("title_bar_window_movable")
}
/// Whether AppKit may move the window when a drag starts in the title-bar band: false while the
/// pointer is on one of the strip's controls or on an in-window panel over the strip, so pressing
/// and dragging there does not move the window. True when neither this pass nor the one before
/// drew a title bar.
pub fn window_movable(ctx: &egui::Context) -> bool {
match ctx.data(|d| d.get_temp::<(u64, bool)>(movable_id())) {
Some((pass, movable)) if ctx.cumulative_pass_nr().saturating_sub(pass) <= 1 => movable,
_ => true,
}
}
/// Sets the window's `movable` flag from this pass's [`window_movable`]. Call it once per frame,
/// after the UI has run. `applied` holds the value last set, so AppKit is only called when it
/// changes.
///
/// Only windows with a full-size content view and visible window buttons are touched: the app
/// draws over their title bar. Floating panels in their own windows hide the buttons, and keep
/// their own `movable`, which AppKit also needs to resize them from their edges.
#[cfg(target_os = "macos")]
pub fn sync_movable(ctx: &egui::Context, applied: &mut Option<bool>) {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowButton, NSWindowStyleMask};
let movable = window_movable(ctx);
if *applied == Some(movable) {
return;
}
// eframe runs the app on the main thread; anywhere else AppKit is out of reach.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let mut found = false;
for window in NSApplication::sharedApplication(mtm).windows().to_vec() {
let buttons = window.standardWindowButton(NSWindowButton::CloseButton).is_some_and(|close| !close.isHidden());
if window.styleMask().contains(NSWindowStyleMask::FullSizeContentView) && buttons {
window.setMovable(movable);
found = true;
}
}
// Before the window is on screen there is nothing to set yet; try again next frame.
if found {
*applied = Some(movable);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Context, Event, Order, PointerButton, Pos2, RawInput, Rect, ViewportCommand, ViewportId, pos2, vec2};
#[test]
fn parses_the_defaults_output() {
assert_eq!(parse("Maximize\n"), DoubleClick::Zoom);
assert_eq!(parse("Minimize\n"), DoubleClick::Minimize);
assert_eq!(parse("None\n"), DoubleClick::Nothing);
for junk in ["", "Fill", "minimize", "1", "The domain/default pair of (kCFPreferencesAnyApplication, X) does not exist"] {
assert_eq!(parse(junk), DoubleClick::Zoom, "{junk:?}");
}
}
#[test]
fn zoom_toggles_and_minimize_minimizes() {
use egui::ViewportCommand::{Maximized, Minimized};
assert_eq!(double_click_command(DoubleClick::Zoom, false), Some(Maximized(true)));
assert_eq!(double_click_command(DoubleClick::Zoom, true), Some(Maximized(false)));
assert_eq!(double_click_command(DoubleClick::Minimize, false), Some(Minimized(true)));
assert_eq!(double_click_command(DoubleClick::Nothing, true), None);
}
/// A 400×28 strip with a button at x 10..60, a `Middle` area at x 100..180 and a `Foreground`
/// area at x 200..280, all over the strip.
struct Bar {
ctx: Context,
time: f64,
fullscreen: bool,
}
const BUTTON: Pos2 = pos2(30.0, 14.0);
const EMPTY: Pos2 = pos2(350.0, 14.0);
const FLOAT: Pos2 = pos2(140.0, 14.0);
const OVERLAY: Pos2 = pos2(240.0, 14.0);
impl Bar {
fn new() -> Self {
Self { ctx: Context::default(), time: 0.0, fullscreen: false }
}
/// Runs one frame with `events`; returns the viewport commands it sent.
fn frame(&mut self, events: Vec<Event>) -> Vec<ViewportCommand> {
self.time += 1.0 / 60.0;
let mut input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))),
time: Some(self.time),
events,
..RawInput::default()
};
input.viewports.entry(ViewportId::ROOT).or_default().fullscreen = Some(self.fullscreen);
let mut out = self.ctx.run_ui(input, |ui| {
empty_area(ui, Rect::from_min_size(Pos2::ZERO, vec2(400.0, 28.0)));
let _ = ui.put(Rect::from_min_max(pos2(10.0, 4.0), pos2(60.0, 24.0)), egui::Button::new("Menu"));
for (id, order, x) in [("float", Order::Middle, 100.0), ("overlay", Order::Foreground, 200.0)] {
egui::Area::new(egui::Id::new(id)).order(order).fixed_pos(pos2(x, 0.0)).show(ui.ctx(), |ui| {
ui.allocate_exact_size(vec2(80.0, 28.0), egui::Sense::hover());
});
}
});
// No renderer here to take the font atlas.
out.textures_delta.clear();
out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
}
/// Moves the pointer to `p` and lets layers and hover settle.
fn hover(&mut self, p: Pos2) {
for _ in 0..3 {
self.frame(vec![Event::PointerMoved(p)]);
}
}
/// Presses at `p`, drags 30 points right and releases; returns how many `StartDrag`s it sent.
fn drag(&mut self, p: Pos2) -> usize {
self.hover(p);
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let mut commands = self.frame(vec![button(p, true)]);
for dx in [5.0, 10.0, 20.0, 30.0] {
commands.extend(self.frame(vec![Event::PointerMoved(p + vec2(dx, 0.0))]));
}
commands.extend(self.frame(vec![button(p + vec2(30.0, 0.0), false)]));
commands.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count()
}
}
#[test]
fn only_the_empty_strip_moves_the_window() {
let mut bar = Bar::new();
bar.hover(EMPTY);
assert!(window_movable(&bar.ctx), "empty strip");
bar.hover(BUTTON);
assert!(!window_movable(&bar.ctx), "control on the strip");
bar.hover(FLOAT);
assert!(!window_movable(&bar.ctx), "Middle area over the strip");
bar.hover(OVERLAY);
assert!(window_movable(&bar.ctx), "Foreground area over the strip");
assert_eq!(bar.drag(EMPTY), 1, "drag on the empty strip");
assert_eq!(bar.drag(BUTTON), 0, "drag on a control");
assert_eq!(bar.drag(FLOAT), 0, "drag on a Middle area");
bar.fullscreen = true;
assert_eq!(bar.drag(EMPTY), 0, "drag in full screen");
}
}
+64 -4
View File
@@ -13,8 +13,8 @@ document stays the app's `CentralPanel`.
| plain egui panels | Fixed view parts: slide thumbnails, notes, formula bar, sheet tabs, status bar. |
| `egui::Window` | Free tool windows that never dock (Read Aloud, Power Query). |
`panes` needs no docking types; it uses only the docking chrome (`FloatingFrame`, `TitleBar`,
`live_move`).
`panes` needs no docking types; it uses only the docking chrome (`ChromeStyle`,
`FloatingFrame`, `TitleBar`, `live_move`).
## API
@@ -25,7 +25,8 @@ impl PaneContent<Pane> for MyPanes<'_> {
fn title(&self, pane: &Pane) -> String { tr(pane.title()) }
fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { /* the pane's contents */ }
fn default_state(&self, pane: &Pane) -> PaneState { PaneState::docked(Edge::End, 250.0) }
// Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu).
// Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu),
// title_bar (false: a docked pane draws no title bar; floating panes always draw one).
}
// Every frame, after the top and bottom bars and before the CentralPanel:
@@ -57,7 +58,7 @@ egui::CentralPanel::default().show(ui, |ui| {
| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. |
| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. |
| `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). |
| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). |
| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The edge takes presses on both sides of its line, over the document's own widgets too, and stays under floating windows. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). |
| Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. |
| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. While the pointer is there, a preview shows the rect the pane will take: with `Columns`, after the panes on that edge that come before it in `open`. |
| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it, with the same preview as a drag out. Rects are clamped to the host area. |
@@ -73,6 +74,65 @@ State:
Escape sends the start rect back.
- `panes::float_rect` brings a saved rect back inside the window, whatever the file says.
## Rail
`panes::Rail` is a vertical strip of icons, always shown at one edge. Each icon stands for a
task pane, and one pane at a time is open beside the strip. A click on an icon opens its pane in
place of the open one; a click on the open pane's icon closes it. The rail stores nothing: the
app owns which pane is open, as with `PaneHost`.
```rust
use craft_ui::panes::{Edge, PaneHost, Rail, RailItem, RailRequest, SidePolicy};
let items = [
RailItem::new(Pane::Comments, icons.source("comments"), tr("Comments")).marker(has_comments),
RailItem::new(Pane::Bookmarks, icons.source("bookmark"), tr("Bookmarks")).enabled(has_outline),
];
// After the top and bottom bars, before PaneHost: the rail is the outermost panel on its edge.
let rail = Rail::new("rail").edge(Edge::End).rtl(ui_is_rtl).show(ui, &items, app.pane.as_ref(), |ui| {
page_navigation(ui); // the foot: below the items, always visible; may draw nothing
});
match rail.request {
Some(RailRequest::Open(pane)) => app.pane = Some(pane),
Some(RailRequest::Close(_)) => app.pane = None,
_ => {}
}
// The open pane, between the document and the rail. Its PaneContent returns `movable = false`
// and a `default_state` docked at the rail's edge.
// Apps whose panes draw their own headers return false from `title_bar`.
let open: Vec<Pane> = app.pane.into_iter().collect();
let out = PaneHost::new("task-panes").rtl(ui_is_rtl).policy(SidePolicy::OneSlot).show(ui, &open, |_| None, &mut MyPanes { app });
egui::CentralPanel::default().show(ui, |ui| document(ui));
```
| Piece | Behavior |
|---|---|
| `RailItem` | `pane`, `icon` (an `egui::ImageSource`, like docking's `DockContent::icon`), `label` (tooltip and accessible name; translate it in the app), `enabled`, `marker`, `marker_label` (the accessible description while the marker shows, such as "Has comments"). `RailItem::new` makes an enabled item without a marker. |
| `.edge(e)` | `End` (default) or `Start`. `Start` is left, or right with `.rtl(true)`. The rail is vertical, so `Bottom` draws at `End`. |
| `.width(w)`, `.button_size(s)` | Rail width (48) and item size (34; the icon is 16 smaller). Non-finite values fall back to the defaults. |
| `.frame(f)` | Default: the panel fill, 8 pt above and below. The panel's separator line draws the inner edge. Item colors come from `ChromeStyle`, like the pane title bars. |
| Panel | Exact width, not resizable. Shown before `PaneHost::show`, it is outermost, so a docked pane on the same edge sits between it and the document. |
| Items | Top-down in a vertical scroll area. The foot is a bottom sub-panel, so in a short window the items scroll and never run into it. A pane listed twice is drawn once. An empty list draws an empty rail. |
| States | The open pane's item draws selected. `marker` draws a dot while the pane is not open. A disabled item draws disabled, takes no focus and sends nothing. An item disabled while focused hands focus to the nearest enabled item, the one below first. |
| `RailOutput` | `#[non_exhaustive]`. `request`: `Open(p)` or `Close(p)` (`#[non_exhaustive]`), at most one per frame. `focused`: the item holding keyboard focus. `rect`: where the rail was drawn. |
| Accessibility | Each item is a toggle button (accesskit `Button` with a toggled state) named by its `label`. |
| Keyboard | Items take Tab focus. Enter or Space clicks the focused item. Up and Down move focus between enabled items and stop at the ends. Escape is the app's: the rail neither closes the pane nor moves focus on it. |
F6 stays with the app. To include the rail in an F6 cycle, give `PaneHost` `.f6(false)` and,
when the cycle reaches the rail, focus the open pane's item if it is enabled, else the first
enabled one:
```rust
let enabled = || items.iter().filter(|item| item.enabled);
let target = enabled().find(|item| Some(&item.pane) == app.pane.as_ref()).or_else(|| enabled().next());
if let Some(item) = target {
ctx.memory_mut(|m| m.request_focus(Rail::new("rail").item_id(&item.pane)));
}
```
While `rail.focused` is `Some`, focus is in the rail: the next F6 moves on, for example to the
panes with `panes::focus_next`.
## Adoption
### WordCraft (`Columns`)