Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
- Keep existing update paths available for older managers. Minor platform fixes can be delivered without updating unaffected platforms.
- Fix a Linux accessibility startup error caused by conflicting credential-store runtime settings.

### Linux Rev. 2

- Show What's New once after a revision update, preserving existing dismissal records.
- Give Manager and Builder matching desktop identities so Linux desktops can associate their windows with the correct launcher and icon.
- Fix closing to the system tray on X11 and XWayland. On native Wayland, the unsupported setting is disabled and closing quits normally. Keep the manager able to start when XWayland is unavailable.
- Show AppImage installations as AppImage in installation selectors and package details, including existing installations. Preserve installation selections and portable letters.

## 0.8.2

- Switch between installations directly from Home. The selected copy stays synchronized with the app page.
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ zip = { version = "2.4", default-features = false, features = ["deflate"] }
tray-icon = { version = "0.26.1", default-features = false }

[target.'cfg(target_os = "linux")'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11", "wayland"] }
x11rb = { version = "0.13", default-features = false }
# AccessKit uses the shared zbus 4 dependency from its async-io executor.
# Enabling zbus 4's Tokio backend through keyring breaks accessibility startup.
keyring = { version = "3.6.3", default-features = false, features = ["async-secret-service", "async-io", "crypto-rust"] }
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ Enable **Keep running in system tray when closed** in General settings to hide t

This needs a usable desktop tray. On Linux, some GNOME desktops require a compatible tray extension. If there is no tray host, closing quits normally so the manager cannot become inaccessible.

Linux close-to-tray also needs X11 or XWayland. The manager prefers a reachable XWayland display when available. On native Wayland, the setting is disabled and closing quits normally.

## Custom GitHub apps

Paste a repository link into **Settings → Apps → Custom apps**. The setup dialog lets you review the detected download, package type, and architecture, and choose a download when several are possible.
Expand Down
4 changes: 4 additions & 0 deletions docs/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ App updates, source downloads, backups, logs, launch settings and the source bui

Profile deletion remains disabled on Linux until each app's actual profile directories have been checked. Installer cancellation is available during downloading, but an authorized package transaction must finish to protect the package database.

Installation selectors identify AppImages from their file headers, including older installations whose executable was renamed. Other portable packages keep the Portable label; installation letters and selections remain unchanged.

Close-to-tray requires both a usable tray host and an X11/XWayland window. The manager prefers a reachable X11 display when one is available. Native Wayland windows cannot be hidden by the current window backend, so the setting is disabled there and closing quits normally. The Builder retains its normal backend selection.

## Arch Linux (experimental)

Arch and distributions identifying as Arch-compatible use pacman for native
Expand Down
1 change: 1 addition & 0 deletions packaging/linux/craft-apps-builder.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ Name=Craft Apps Builder
Comment=Build Craft apps from source
Exec=craft-apps-manager --builder
Icon=craft-apps-manager
StartupWMClass=craft-apps-builder
Terminal=false
Categories=Development;
1 change: 1 addition & 0 deletions packaging/linux/craft-apps-manager.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ Name=Craft Apps Manager
Comment=Manage Craft app releases, sources and builds
Exec=craft-apps-manager
Icon=craft-apps-manager
StartupWMClass=craft-apps-manager
Terminal=false
Categories=Utility;
44 changes: 38 additions & 6 deletions src/installation_labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ pub fn version_summary(copy: &crate::model::Installed) -> String {
}

fn display_summary(copy: &crate::model::Installed, include_version: bool) -> String {
let kind = if copy.install_kind == "installer" {
"Installer"
} else {
"Portable"
};
let kind = package_label(copy);
let mut text = format!(
"{kind} · {}",
crate::model::architecture_label(&copy.architecture)
Expand All @@ -31,12 +27,22 @@ fn display_summary(copy: &crate::model::Installed, include_version: bool) -> Str
copy.version.trim_start_matches(['v', 'V'])
));
}
if kind == "Portable" && valid_label(&copy.installation_label) {
if copy.install_kind != "installer" && valid_label(&copy.installation_label) {
text.push_str(&format!(" ({})", copy.installation_label));
}
text
}

pub fn package_label(copy: &crate::model::Installed) -> &'static str {
if copy.install_kind == "installer" {
"Installer"
} else if cfg!(target_os = "linux") && copy.package_type == "appimage" {
"AppImage"
} else {
"Portable"
}
}

fn index(label: &str) -> Option<u32> {
if label.is_empty() || label.len() > 7 {
return None;
Expand Down Expand Up @@ -185,6 +191,32 @@ mod tests {
use super::*;
use crate::model::Installed;

#[test]
fn package_labels_keep_portable_identifiers_and_respect_platform() {
let mut copy = Installed {
install_kind: "portable".into(),
package_type: "appimage".into(),
architecture: "x64".into(),
version: "0.8.2".into(),
installation_label: "A".into(),
..Default::default()
};
assert_eq!(
package_label(&copy),
if cfg!(target_os = "linux") {
"AppImage"
} else {
"Portable"
}
);
assert!(version_summary(&copy).ends_with("v0.8.2 (A)"));
copy.package_type.clear();
assert_eq!(package_label(&copy), "Portable");
copy.install_kind = "installer".into();
assert_eq!(package_label(&copy), "Installer");
assert!(!version_summary(&copy).contains("(A)"));
}

#[cfg(target_os = "windows")]
#[test]
fn windows_path_aliases_share_one_label() {
Expand Down
1 change: 1 addition & 0 deletions src/installers/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ fn detect_package(app: &str, package: &str) -> Result<Option<Installed>> {
};
Ok(Some(Installed {
installation_label: Default::default(),
package_type: Default::default(),
name: app.into(),
version,
path: executable
Expand Down
1 change: 1 addition & 0 deletions src/installers/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub fn resolve_bundle(folder: &Path, app: &str) -> Option<PathBuf> {
fn installed_bundle(bundle: &Path, app: &str) -> Result<Installed> {
Ok(Installed {
installation_label: String::new(),
package_type: String::new(),
name: app.into(),
version: platform::executable_version(bundle).unwrap_or_else(|| "0.0.0".into()),
path: bundle
Expand Down
1 change: 1 addition & 0 deletions src/installers/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ fn detect_in_roots_for_architecture(
}
let candidate = Installed {
installation_label: Default::default(),
package_type: Default::default(),
name: app.into(),
path: location,
version: key.get_value("DisplayVersion").unwrap_or_default(),
Expand Down
38 changes: 26 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,18 +315,32 @@ fn run() -> Result<()> {
Some([width.parse::<f32>().ok()?, height.parse::<f32>().ok()?])
});
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_icon(icon)
.with_inner_size(screenshot_size.unwrap_or(if builder {
[1050.0, 740.0]
} else {
[1320.0, 820.0]
}))
.with_min_inner_size(if builder {
[780.0, 580.0]
} else {
[1100.0, 480.0]
}),
#[cfg(target_os = "linux")]
event_loop_builder: Some(Box::new(move |event_loop| {
// Wayland cannot hide a winit window. Prefer XWayland when present
// so enabling close-to-tray later also works without a restart.
if platform::prefer_x11_for_manager(builder) {
use winit::platform::x11::EventLoopBuilderExtX11;
event_loop.with_x11();
}
})),
viewport: {
let viewport = eframe::egui::ViewportBuilder::default()
.with_icon(icon)
.with_inner_size(screenshot_size.unwrap_or(if builder {
[1050.0, 740.0]
} else {
[1320.0, 820.0]
}))
.with_min_inner_size(if builder {
[780.0, 580.0]
} else {
[1100.0, 480.0]
});
#[cfg(target_os = "linux")]
let viewport = viewport.with_app_id(platform::desktop_app_id(builder));
viewport
},
..Default::default()
};
if !builder {
Expand Down
142 changes: 142 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ pub struct Installed {
pub product_code: String,
#[serde(default)]
pub installation_label: String,
#[serde(default)]
pub package_type: String,
}
fn default_arch() -> String {
MANAGER_ARCH.into()
Expand Down Expand Up @@ -436,6 +438,7 @@ impl Paths {
let direct = self.at(format!("releases/{name}"));
apps.push(Installed {
installation_label: Default::default(),
package_type: Default::default(),
name: name.into(),
version,
path: if path.is_empty() && installed_executable(&direct, name).is_some() {
Expand Down Expand Up @@ -539,6 +542,7 @@ impl Paths {
if let Some(executable) = installed_executable(&library, &app.name) {
config.installations.push(Installed {
installation_label: Default::default(),
package_type: Default::default(),
name: app.name.clone(),
path: library.display().to_string(),
version: crate::platform::executable_version(&executable)
Expand Down Expand Up @@ -622,6 +626,15 @@ impl Paths {
}
}
}
#[cfg(target_os = "linux")]
for copy in config.apps.iter_mut().chain(&mut config.installations) {
if copy.install_kind != "installer" && !copy.path.is_empty() {
copy.package_type = installed_executable(Path::new(&copy.path), &copy.name)
.filter(|executable| is_appimage(executable))
.map(|_| "appimage".to_owned())
.unwrap_or_default();
}
}
for app in &mut config.apps {
if app.architecture.is_empty() && !app.path.is_empty() {
app.architecture = installed_executable(Path::new(&app.path), &app.name)
Expand Down Expand Up @@ -856,6 +869,19 @@ mod desktop_executable_tests {
assert!(unix.contains(&"example-gui".into()));
}
}
/// AppImages retain their identifying ELF header even when renamed on install.
#[cfg(any(target_os = "linux", test))]
fn is_appimage(path: &Path) -> bool {
use std::io::Read;
let mut header = [0u8; 11];
std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut header))
.is_ok()
&& &header[..4] == b"\x7fELF"
&& &header[8..10] == b"AI"
&& matches!(header[10], 1 | 2)
}

pub fn installed_executable(folder: &Path, app: &str) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
Expand Down Expand Up @@ -1701,6 +1727,7 @@ mod detection_tests {
.contains(&name)
.then(|| Installed {
installation_label: Default::default(),
package_type: Default::default(),
name: name.into(),
path: root.join("system").join(name).display().to_string(),
version: "0.2.1".into(),
Expand Down Expand Up @@ -1762,3 +1789,118 @@ mod detection_tests {
std::fs::remove_dir_all(root).unwrap();
}
}

#[cfg(test)]
mod appimage_header_tests {
#[cfg(target_os = "linux")]
#[test]
fn legacy_copies_recover_appimage_labels_without_changing_selection_or_letters() {
use super::*;
let root =
std::env::temp_dir().join(format!("craft-appimage-migration-{}", uuid::Uuid::new_v4()));
let paths = Paths::new(root.clone(), None);
let mut header = [0u8; 64];
header[..4].copy_from_slice(b"\x7fELF");
header[8..11].copy_from_slice(b"AI\x02");
let mut copies = Vec::new();
for (folder, label, bytes) in [
("filmcraft", "A", header.as_slice()),
("filmcraft-2", "B", b"\x7fELFordinary".as_slice()),
] {
let folder = paths.at(format!("releases/{folder}"));
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join(executable_name("filmcraft")), bytes).unwrap();
copies.push(Installed {
name: "filmcraft".into(),
path: folder.display().to_string(),
version: "0.8.2".into(),
architecture: "x64".into(),
install_kind: "portable".into(),
installation_label: label.into(),
..Default::default()
});
}
let config = Config {
apps_root: root.display().to_string(),
apps: vec![copies[0].clone()],
installations: copies.clone(),
portable_label_counters: Default::default(),
};
let mut legacy = serde_json::to_value(config).unwrap();
for field in ["apps", "installations"] {
for copy in legacy[field].as_array_mut().unwrap() {
assert!(copy
.as_object_mut()
.unwrap()
.remove("package_type")
.is_some());
}
}
crate::files::write_json(&paths.at("settings.json"), &legacy).unwrap();
let refreshed = paths.config_with_detector(|_| Ok(None)).unwrap();
let selected = refreshed
.apps
.iter()
.find(|copy| copy.name == "filmcraft")
.unwrap();
assert_eq!(selected.path, copies[0].path);
assert_eq!(selected.package_type, "appimage");
assert_eq!(selected.installation_label, "A");
assert!(crate::installation_labels::summary(selected).starts_with("AppImage"));
paths.save_config(&refreshed).unwrap();
let reopened = paths.config_with_detector(|_| Ok(None)).unwrap();
for expected in &copies {
let copy = reopened
.installations
.iter()
.find(|copy| copy.path == expected.path)
.unwrap();
assert_eq!(copy.installation_label, expected.installation_label);
assert_eq!(
copy.package_type,
if expected.installation_label == "A" {
"appimage"
} else {
""
}
);
assert_eq!(copy.install_kind, "portable");
}
// A later ordinary portable replacement must not retain a stale AppImage label.
std::fs::write(
Path::new(&copies[0].path).join(executable_name("filmcraft")),
b"\x7fELFordinary",
)
.unwrap();
let replaced = paths.config_with_detector(|_| Ok(None)).unwrap();
let selected = replaced
.apps
.iter()
.find(|copy| copy.name == "filmcraft")
.unwrap();
assert_eq!(selected.package_type, "");
assert_eq!(selected.installation_label, "A");
assert!(crate::installation_labels::summary(selected).starts_with("Portable"));
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn renamed_appimages_are_detected_without_mislabeling_other_elf_files() {
let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
std::fs::create_dir_all(&root).unwrap();
let file = root.join("filmcraft");
for kind in [1u8, 2] {
let mut header = [0u8; 11];
header[..4].copy_from_slice(b"\x7fELF");
header[8..10].copy_from_slice(b"AI");
header[10] = kind;
std::fs::write(&file, header).unwrap();
assert!(super::is_appimage(&file));
}
std::fs::write(&file, b"\x7fELFordinary").unwrap();
assert!(!super::is_appimage(&file));
std::fs::write(&file, b"short").unwrap();
assert!(!super::is_appimage(&file));
std::fs::remove_dir_all(root).unwrap();
}
}
Loading
Loading