fix(appimage): install the desktop entry so the window gets its icon

- A Wayland desktop finds a window's icon by looking up its application ID among the installed desktop entries. An AppImage carries its entry and icon inside its own tree, where no desktop looks, so its window showed the generic Wayland icon and Midi Harbor was missing from the application menu.
- Run from an AppImage, the window now installs com.mrgeckosmedia.MidiHarbor.desktop under $XDG_DATA_HOME/applications and the icon under icons/hicolor/scalable/apps before it opens. Exec is rewritten to the AppImage file, quoted as the Desktop Entry Specification requires, and each file is written only when it differs, so a moved AppImage is followed.
- TryExec names the AppImage file, so desktops stop offering the entry once the file is deleted, which is how an AppImage is removed.
- Nothing is installed when a directory in XDG_DATA_DIRS already holds the entry: one under the user's directory takes precedence and would point a package's menu item at the AppImage.
This commit is contained in:
James Coleman 2026-10-02 12:32:08 -05:00
parent 5dce49ee43
commit 43f7b8916b
8 changed files with 324 additions and 4 deletions

257
crates/gui/src/desktop.rs Normal file
View file

@ -0,0 +1,257 @@
//! Giving an AppImage its place among the user's applications (research R-109).
//!
//! A Wayland desktop finds a window's icon by its application ID, looking for a desktop entry of
//! that name among the installed ones. A package installs the entry. An AppImage carries one
//! inside, where no desktop looks, so its window showed the generic icon and it was missing from
//! the application menu. Run from an AppImage, the window installs the entry and the icon for
//! this user, pointing at the AppImage file.
use std::path::{Path, PathBuf};
/// The desktop entry's name without its extension, which is the window's application ID.
const ID: &str = "com.mrgeckosmedia.MidiHarbor";
/// Installs this AppImage's desktop entry and icon for the user, when the program runs from one.
///
/// A failure costs the icon and nothing else, so it is logged and the window opens regardless.
pub fn integrate() {
let Some((appimage, appdir)) = midi_harbor_service::running_appimage() else {
return;
};
let Some(data_home) = data_home() else {
return;
};
match install(&appimage, &appdir, &data_home, &data_dirs()) {
Ok(true) => tracing::info!(appimage = %appimage.display(), "installed the desktop entry"),
Ok(false) => {}
Err(error) => tracing::debug!(error = %error, "could not install the desktop entry"),
}
}
/// Installs the entry and icon under `data_home`, and reports whether anything was written.
///
/// Nothing is installed where a package already provides the entry in `data_dirs`: one under
/// the user's directory takes precedence, and would point the package's menu item at the
/// AppImage.
fn install(
appimage: &Path,
appdir: &Path,
data_home: &Path,
data_dirs: &[PathBuf],
) -> std::io::Result<bool> {
let entry_name = format!("{ID}.desktop");
let icon_name = format!("{ID}.svg");
if data_dirs
.iter()
.any(|dir| dir.join("applications").join(&entry_name).exists())
{
return Ok(false);
}
let Some(target) = appimage.to_str() else {
return Ok(false);
};
// Write each only when it differs, so an AppImage that has not moved touches nothing.
let carried = appdir.join("usr/share");
let entry = entry_for(
&std::fs::read_to_string(carried.join("applications").join(&entry_name))?,
target,
);
let icon = std::fs::read(carried.join("icons/hicolor/scalable/apps").join(&icon_name))?;
let wrote_entry = write_if_changed(
&data_home.join("applications").join(&entry_name),
entry.as_bytes(),
)?;
let wrote_icon = write_if_changed(
&data_home
.join("icons/hicolor/scalable/apps")
.join(&icon_name),
&icon,
)?;
Ok(wrote_entry || wrote_icon)
}
/// Writes `contents` to `path` unless the file already holds them, and reports whether it wrote.
fn write_if_changed(path: &Path, contents: &[u8]) -> std::io::Result<bool> {
if std::fs::read(path).is_ok_and(|held| held == contents) {
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, contents)?;
Ok(true)
}
/// Rewrites the desktop entry an AppImage carries so that it starts the AppImage file.
///
/// `TryExec` names the file too, so a desktop leaves the entry out of its menu once the AppImage
/// has been deleted, which is the only way an AppImage is ever removed.
fn entry_for(carried: &str, appimage: &str) -> String {
let mut entry = String::with_capacity(carried.len() + appimage.len() * 2);
for line in carried.lines() {
if line.starts_with("TryExec=") {
continue;
}
match line.strip_prefix("Exec=") {
Some(command) => {
// The carried entry runs `midi-harbor`, found on PATH by a package install.
let arguments = command
.split_once(' ')
.map_or("", |(_, arguments)| arguments);
entry.push_str(&format!("Exec={} {arguments}\n", quoted(appimage)));
entry.push_str(&format!("TryExec={}\n", appimage.replace('\\', "\\\\")));
}
None => {
entry.push_str(line);
entry.push('\n');
}
}
}
entry
}
/// Quotes a path as one argument of an `Exec` key.
///
/// The Desktop Entry Specification has an argument holding a space or a reserved character
/// written in double quotes, with `"`, `` ` ``, `$` and `\` escaped by a backslash. The value is
/// then a string, where a backslash is itself written twice, and a literal percent sign is `%%`.
fn quoted(path: &str) -> String {
let mut argument = String::with_capacity(path.len() + 2);
argument.push('"');
for character in path.chars() {
match character {
'"' | '`' | '$' => {
argument.push_str("\\\\");
argument.push(character);
}
'\\' => argument.push_str("\\\\\\\\"),
'%' => argument.push_str("%%"),
other => argument.push(other),
}
}
argument.push('"');
argument
}
/// Returns the directory the user's own data files live under.
fn data_home() -> Option<PathBuf> {
absolute(std::env::var_os("XDG_DATA_HOME"))
.or_else(|| absolute(std::env::var_os("HOME")).map(|home| home.join(".local/share")))
}
/// Returns the directories installed data files are searched in, after the user's own.
fn data_dirs() -> Vec<PathBuf> {
match std::env::var_os("XDG_DATA_DIRS").filter(|dirs| !dirs.is_empty()) {
Some(dirs) => std::env::split_paths(&dirs).collect(),
// The XDG Base Directory Specification's default.
None => vec![
PathBuf::from("/usr/local/share"),
PathBuf::from("/usr/share"),
],
}
}
/// Returns a path from the environment when it is set and absolute, as the specification
/// requires of every one of its directories.
fn absolute(value: Option<std::ffi::OsString>) -> Option<PathBuf> {
value.map(PathBuf::from).filter(|path| path.is_absolute())
}
#[cfg(test)]
mod tests {
use super::*;
/// Proves the entry installed for an AppImage starts the AppImage file, written as the
/// Desktop Entry Specification's `Exec` key reads it: quoted, with a dollar sign escaped and
/// a percent sign doubled, the arguments kept, and `TryExec` naming the file so the entry
/// disappears with it. Every other line is carried over as it was.
#[test]
fn the_installed_entry_starts_the_appimage_file() {
let carried = "[Desktop Entry]\nName=Midi Harbor\nExec=midi-harbor gui\nIcon=x\n";
let cases = [
(
"a plain path",
"/home/user/Applications/Midi-Harbor.AppImage",
"Exec=\"/home/user/Applications/Midi-Harbor.AppImage\" gui\n\
TryExec=/home/user/Applications/Midi-Harbor.AppImage\n",
),
(
"a path with a space, a dollar and a percent sign",
"/home/user/My Apps/$5 100%.AppImage",
"Exec=\"/home/user/My Apps/\\\\$5 100%%.AppImage\" gui\n\
TryExec=/home/user/My Apps/$5 100%.AppImage\n",
),
];
for (name, appimage, want) in cases {
assert_eq!(
entry_for(carried, appimage),
format!("[Desktop Entry]\nName=Midi Harbor\n{want}Icon=x\n"),
"{name}: the entry is not what a desktop reads as this file"
);
}
}
/// Proves an AppImage's entry and icon are installed under the user's data directory, are
/// left untouched on the next run, and are not installed at all where a package already
/// provides the entry, whose menu item the user's copy would otherwise take over.
#[test]
fn an_appimage_installs_its_entry_once_and_never_over_a_package() {
let root = std::env::temp_dir().join(format!("mh-desktop-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let appdir = root.join("AppDir");
let entry = format!("{ID}.desktop");
let icon = format!("{ID}.svg");
for (dir, name, contents) in [
(
"applications",
&entry,
"[Desktop Entry]\nExec=midi-harbor gui\n",
),
("icons/hicolor/scalable/apps", &icon, "<svg/>"),
] {
let dir = appdir.join("usr/share").join(dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), contents).unwrap();
}
let appimage = root.join("Midi-Harbor.AppImage");
let home = root.join("home");
let installed = home.join("applications").join(&entry);
assert!(
install(&appimage, &appdir, &home, &[]).unwrap(),
"the first run must install the entry"
);
assert!(
std::fs::read_to_string(&installed)
.unwrap()
.contains(&format!("Exec=\"{}\" gui", appimage.display())),
"the installed entry must start the AppImage file"
);
assert!(
home.join("icons/hicolor/scalable/apps")
.join(&icon)
.exists(),
"the icon the entry names must be installed with it"
);
assert!(
!install(&appimage, &appdir, &home, &[]).unwrap(),
"a second run of the same AppImage must write nothing"
);
// A package's entry in the system directories is left to stand.
let system = root.join("system");
std::fs::create_dir_all(system.join("applications")).unwrap();
std::fs::write(system.join("applications").join(&entry), "").unwrap();
let other_home = root.join("other-home");
assert!(
!install(&appimage, &appdir, &other_home, &[system]).unwrap(),
"an entry a package provides must not be shadowed"
);
assert!(
!other_home.exists(),
"nothing may be written beside a package's entry"
);
let _ = std::fs::remove_dir_all(&root);
}
}

View file

@ -7,6 +7,8 @@ mod app;
#[cfg(target_os = "macos")]
pub mod app_store;
mod client;
#[cfg(target_os = "linux")]
mod desktop;
mod dialogs;
pub mod format;
mod onboarding;
@ -39,6 +41,9 @@ pub fn run(socket: Option<std::path::PathBuf>) -> Result<(), GuiError> {
let settings = Settings::default().size(Size::new(WINDOW_WIDTH, WINDOW_HEIGHT));
#[cfg(target_os = "macos")]
let settings = macos(settings);
// Before the window opens, so the desktop finds the entry when it looks for the icon.
#[cfg(target_os = "linux")]
desktop::integrate();
cosmic::app::run::<app::App>(settings, socket)?;
Ok(())
}

View file

@ -217,6 +217,16 @@ pub fn detect() -> Result<Box<dyn ServiceManager>, ServiceError> {
}
}
/// Returns the AppImage file this program runs from, and where its runtime mounted it, when it
/// runs from one.
#[cfg(target_os = "linux")]
pub fn running_appimage() -> Option<(PathBuf, PathBuf)> {
let running = std::env::current_exe().ok()?;
let appdir = std::env::var_os("APPDIR");
let file = appimage_file(&running, std::env::var_os("APPIMAGE"), appdir.clone())?;
Some((file, PathBuf::from(appdir?)))
}
/// Returns the AppImage file the running executable was started from, if it was.
///
/// The AppImage runtime sets `APPIMAGE` to the file and `APPDIR` to where it mounted it, and

View file

@ -38,7 +38,9 @@ BlueZ. Installing a package does not start anything. Each user who wants the dae
**Any other Linux distribution**: `Midi-Harbor-<version>-<x86_64|aarch64>.AppImage` runs without
installing. Keep it where it will stay, such as `~/Applications`, make it executable with
`chmod +x`, and open it; it shows the graphical interface, which offers to start the daemon at
login. The service runs the AppImage file itself, so moving a newer one over it updates the
login. Opening it also adds Midi Harbor to your applications, with its icon, by writing a desktop
entry and the icon under `~/.local/share`; the entry goes away by itself once the AppImage file
is deleted. The service runs the AppImage file itself, so moving a newer one over it updates the
daemon at its next start; copying into it fails while the daemon runs. A newer one saved under
another name leaves the registration stale until it is opened and set up again. The AppImage
needs FUSE, which desktop distributions include, and a desktop's own libraries: ALSA, D-Bus and

View file

@ -116,3 +116,44 @@ from its next start.
machine shut down, and started from the AppImage 35 seconds later, when SDDM logged the user in.
**Not checked**: an arm64 machine outside Docker.
---
## R-109: The AppImage's window showed the generic Wayland icon
**Status**: **FIXED** (2026-10-02), on X11. Built as T254. Not yet seen on a Wayland desktop.
The owner reported that the window of the AppImage showed the Wayland icon instead of Midi
Harbor's. A Wayland desktop does not take an icon from the window. It takes the window's
application ID, `com.mrgeckosmedia.MidiHarbor`, and looks for a desktop entry of that name in the
XDG data directories. A package installs one in `/usr/share/applications`. The AppImage carries
the entry and the icon inside its own tree, where no desktop looks unless an integration tool such
as AppImageLauncher copies them out, so the lookup found nothing. R-104 checked the icon only for
an AppImage launched through an entry such a tool had installed.
**Fix.** Run from an AppImage, the window installs the entry and the icon itself before it opens:
`com.mrgeckosmedia.MidiHarbor.desktop` under `$XDG_DATA_HOME/applications` and the SVG under
`icons/hicolor/scalable/apps`, read from the AppImage's own tree. `Exec` is rewritten to the
AppImage file, quoted as the Desktop Entry Specification requires, and `TryExec` names the file,
so a desktop drops the entry once the AppImage is deleted, which is how an AppImage is removed.
Each file is written only when it differs, so an AppImage that has moved is followed and one
that has not touches nothing.
**Not over a package.** An entry in the user's directory takes precedence over the system's. With
a package installed as well, it would point the package's menu item at the AppImage, so nothing is
installed when a directory in `XDG_DATA_DIRS` already holds the entry. The package's entry gives
the window its icon in that case.
**The other way considered.** The `xdg-toplevel-icon-v1` protocol lets a window hand the
compositor an icon. Few compositors implement it and the pinned libcosmic's winit does not, and it
would not put the AppImage in the application menu.
**Checked** on Arch Linux under XFCE on X11, with the program run from a directory laid out as the
AppImage runtime mounts it, in a path holding a space, and `APPIMAGE` and `APPDIR` set as the
runtime sets them. The entry installed passed `desktop-file-validate`, and the taskbar, which had
shown the window with no icon, showed Midi Harbor's. On the first run the icon was not written,
because that machine's `icons/hicolor` directory belonged to root from an earlier test; the entry
was installed without it, and the icon followed once the directory was the user's.
**Not covered:** a Wayland desktop, which is where it was reported, and a real AppImage built by
the release.

View file

@ -62,6 +62,10 @@ artifacts, and the release publishes them.
the unit is stopped.
- **FR-I02**: The release MUST build an AppImage for each Linux architecture it builds, from the
same binary as the packages.
- **FR-I05**: Run from an AppImage, the window MUST install the AppImage's desktop entry and icon
for the user, starting the AppImage file, so the desktop shows its icon and lists it among the
applications. It MUST NOT do so where a package already provides the entry, and the entry MUST
stop being offered once the AppImage file is gone.
- **FR-I03**: The AppImage MUST run on the distributions the packages do, needing from the host only
glibc, FUSE and what a desktop already has.

View file

@ -5,3 +5,4 @@ Tasks by their numbers in the project-wide sequence, which continues across ever
- [x] T246 Register the AppImage file rather than the executable inside its mount, per FR-I01 and FR-I04 — done: `ServiceSpec::for_current_executable` takes `APPIMAGE` when the running executable is under `APPDIR`, so another AppImage's inherited variables are ignored; and the unit's `ExecStop` stops the daemon alone and waits for it, since stopping the whole unit at once unmounted the AppImage under the daemon and killed it with SIGBUS (R-104). Unit tested in `crates/service/src/lib.rs` against the variables the AppImage runtime sets, and in `crates/service/src/systemd.rs` for the `ExecStop`.
- [x] T247 Build and publish an AppImage for each Linux architecture, per FR-I02 and FR-I03 — done: `packaging/linux/appimage.sh`, run by GoReleaser after each full Linux build, bundles the Avahi client libraries and their license from the sysroot, sets the binary's RUNPATH, and packs it with appimagetool and the pinned runtime, which the cross image now carries; the release and checksums include `dist/*.AppImage`. Checked on Arch Linux and Ubuntu 22.04 (R-104); not unit tested, since it is a build script.
- [x] T248 Name the window on Linux so taskbars pair it with its desktop entry — done: the window's title is set to "Midi Harbor" at start, the desktop entry's `StartupWMClass` is `midi-harbor`, and the AppImage's `AppRun` runs the binary as `midi-harbor`, since the pinned libcosmic leaves the X11 class to `argv[0]` (R-104). Checked in XFCE on X11; not unit tested, since it is window-system wiring.
- [x] T254 Install the AppImage's desktop entry and icon for the user when the window runs from one, so a Wayland desktop finds its icon by application ID, per FR-I05 (R-109) — done: `desktop::integrate` runs before the window opens on Linux; `Exec` and `TryExec` name the AppImage file; nothing is written where a package provides the entry, or when the files already match. Unit tests cover the entry as the Desktop Entry Specification reads it, with a path holding a space, a dollar and a percent sign, and installing once and never over a package. Checked on XFCE: the entry passed `desktop-file-validate` and the taskbar gained the icon. Not seen on Wayland.

View file

@ -142,10 +142,10 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
### 017-appimage
- **Requirements**: FR-I01, FR-I02, FR-I03, FR-I04
- **Requirements**: FR-I01, FR-I02, FR-I03, FR-I04, FR-I05
- **Success criteria**: SC-I01
- **Research**: R-104
- **Tasks**: T246–T248
- **Research**: R-104, R-109
- **Tasks**: T246–T248, T254
### 018-remembered-machines