diff --git a/crates/gui/src/desktop.rs b/crates/gui/src/desktop.rs new file mode 100644 index 0000000..4525c1d --- /dev/null +++ b/crates/gui/src/desktop.rs @@ -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 { + 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 { + 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 { + 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 { + 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) -> Option { + 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, ""), + ] { + 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); + } +} diff --git a/crates/gui/src/lib.rs b/crates/gui/src/lib.rs index 260a71b..13f0fe6 100644 --- a/crates/gui/src/lib.rs +++ b/crates/gui/src/lib.rs @@ -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) -> 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::(settings, socket)?; Ok(()) } diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index 292b58f..0957767 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -217,6 +217,16 @@ pub fn detect() -> Result, 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 diff --git a/docs/installation.md b/docs/installation.md index 3e9fe27..59fdffe 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,7 +38,9 @@ BlueZ. Installing a package does not start anything. Each user who wants the dae **Any other Linux distribution**: `Midi-Harbor--.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 diff --git a/specs/017-appimage/research.md b/specs/017-appimage/research.md index 14910e0..5956062 100644 --- a/specs/017-appimage/research.md +++ b/specs/017-appimage/research.md @@ -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. diff --git a/specs/017-appimage/spec.md b/specs/017-appimage/spec.md index 6b34dd1..79cee62 100644 --- a/specs/017-appimage/spec.md +++ b/specs/017-appimage/spec.md @@ -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. diff --git a/specs/017-appimage/tasks.md b/specs/017-appimage/tasks.md index d16771c..686d4ff 100644 --- a/specs/017-appimage/tasks.md +++ b/specs/017-appimage/tasks.md @@ -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. diff --git a/specs/README.md b/specs/README.md index 2c07108..f650510 100644 --- a/specs/README.md +++ b/specs/README.md @@ -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