fix(appimage): tell running desktops when the icon is installed

- On Plasma under Wayland the AppImage's window went from the generic icon to a blank one. The desktop entry and the icon were both installed, and a new KDE process resolved and drew the icon, but a shell reads the icon theme's directories when it starts, before hicolor/scalable/apps existed under the user's data directory, so it found the entry and no icon.
- After writing the icon the window now sends org.kde.KIconLoader.iconChanged on the session bus through dbus-send, the signal KDE's own programs send to have running programs reload their icons, and sets the theme directory's modification time for loaders that compare times.
- Both happen only when the icon was written, so an AppImage whose icon is already installed sends nothing. A desktop without dbus-send shows the icon from the next login, as before.
This commit is contained in:
James Coleman 2026-10-02 12:59:23 -05:00
parent 43f7b8916b
commit f250678dd6
2 changed files with 82 additions and 19 deletions

View file

@ -11,6 +11,9 @@ use std::path::{Path, PathBuf};
/// The desktop entry's name without its extension, which is the window's application ID. /// The desktop entry's name without its extension, which is the window's application ID.
const ID: &str = "com.mrgeckosmedia.MidiHarbor"; const ID: &str = "com.mrgeckosmedia.MidiHarbor";
/// The icon theme every desktop falls back to, under a data directory.
const THEME: &str = "icons/hicolor";
/// Installs this AppImage's desktop entry and icon for the user, when the program runs from one. /// 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. /// A failure costs the icon and nothing else, so it is logged and the window opens regardless.
@ -22,13 +25,28 @@ pub fn integrate() {
return; return;
}; };
match install(&appimage, &appdir, &data_home, &data_dirs()) { match install(&appimage, &appdir, &data_home, &data_dirs()) {
Ok(true) => tracing::info!(appimage = %appimage.display(), "installed the desktop entry"), Ok(Written { entry, icon }) => {
Ok(false) => {} if icon {
announce_icon(&data_home.join(THEME));
}
if entry || icon {
tracing::info!(appimage = %appimage.display(), "installed the desktop entry");
}
}
Err(error) => tracing::debug!(error = %error, "could not install the desktop entry"), 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. /// Which of the two files an installation wrote.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct Written {
/// The desktop entry.
entry: bool,
/// The icon.
icon: bool,
}
/// Installs the entry and icon under `data_home`, and reports which were written.
/// ///
/// Nothing is installed where a package already provides the entry in `data_dirs`: one under /// 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 /// the user's directory takes precedence, and would point the package's menu item at the
@ -38,17 +56,17 @@ fn install(
appdir: &Path, appdir: &Path,
data_home: &Path, data_home: &Path,
data_dirs: &[PathBuf], data_dirs: &[PathBuf],
) -> std::io::Result<bool> { ) -> std::io::Result<Written> {
let entry_name = format!("{ID}.desktop"); let entry_name = format!("{ID}.desktop");
let icon_name = format!("{ID}.svg"); let icon_name = format!("{ID}.svg");
if data_dirs if data_dirs
.iter() .iter()
.any(|dir| dir.join("applications").join(&entry_name).exists()) .any(|dir| dir.join("applications").join(&entry_name).exists())
{ {
return Ok(false); return Ok(Written::default());
} }
let Some(target) = appimage.to_str() else { let Some(target) = appimage.to_str() else {
return Ok(false); return Ok(Written::default());
}; };
// Write each only when it differs, so an AppImage that has not moved touches nothing. // Write each only when it differs, so an AppImage that has not moved touches nothing.
@ -63,12 +81,39 @@ fn install(
entry.as_bytes(), entry.as_bytes(),
)?; )?;
let wrote_icon = write_if_changed( let wrote_icon = write_if_changed(
&data_home &data_home.join(THEME).join("scalable/apps").join(&icon_name),
.join("icons/hicolor/scalable/apps")
.join(&icon_name),
&icon, &icon,
)?; )?;
Ok(wrote_entry || wrote_icon) Ok(Written {
entry: wrote_entry,
icon: wrote_icon,
})
}
/// Tells desktops already running that the icon theme under `theme` has a new icon.
///
/// A shell reads the theme's directories when it starts and does not look again. Plasma, running
/// since before the icon's directory existed, found the desktop entry and drew a blank where
/// the icon belonged. KDE reloads its icons on the signal sent here, and loaders that compare
/// times reload when the theme's directory is newer than what they read. Neither is needed for
/// the icon to be there at the next login, so a failure is not reported.
fn announce_icon(theme: &Path) {
if let Ok(directory) = std::fs::File::open(theme) {
let _ = directory.set_modified(std::time::SystemTime::now());
}
// The signal KDE's own programs send after changing icons. Group 0 is the desktop's.
let _ = std::process::Command::new("dbus-send")
.args([
"--session",
"--type=signal",
"/KIconLoader",
"org.kde.KIconLoader.iconChanged",
"int32:0",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
} }
/// Writes `contents` to `path` unless the file already holds them, and reports whether it wrote. /// Writes `contents` to `path` unless the file already holds them, and reports whether it wrote.
@ -218,9 +263,13 @@ mod tests {
let home = root.join("home"); let home = root.join("home");
let installed = home.join("applications").join(&entry); let installed = home.join("applications").join(&entry);
assert!( assert_eq!(
install(&appimage, &appdir, &home, &[]).unwrap(), install(&appimage, &appdir, &home, &[]).unwrap(),
"the first run must install the entry" Written {
entry: true,
icon: true
},
"the first run must install the entry and the icon"
); );
assert!( assert!(
std::fs::read_to_string(&installed) std::fs::read_to_string(&installed)
@ -234,8 +283,9 @@ mod tests {
.exists(), .exists(),
"the icon the entry names must be installed with it" "the icon the entry names must be installed with it"
); );
assert!( assert_eq!(
!install(&appimage, &appdir, &home, &[]).unwrap(), install(&appimage, &appdir, &home, &[]).unwrap(),
Written::default(),
"a second run of the same AppImage must write nothing" "a second run of the same AppImage must write nothing"
); );
@ -244,8 +294,9 @@ mod tests {
std::fs::create_dir_all(system.join("applications")).unwrap(); std::fs::create_dir_all(system.join("applications")).unwrap();
std::fs::write(system.join("applications").join(&entry), "").unwrap(); std::fs::write(system.join("applications").join(&entry), "").unwrap();
let other_home = root.join("other-home"); let other_home = root.join("other-home");
assert!( assert_eq!(
!install(&appimage, &appdir, &other_home, &[system]).unwrap(), install(&appimage, &appdir, &other_home, &[system]).unwrap(),
Written::default(),
"an entry a package provides must not be shadowed" "an entry a package provides must not be shadowed"
); );
assert!( assert!(

View file

@ -121,7 +121,8 @@ from its next start.
## R-109: The AppImage's window showed the generic Wayland icon ## 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. **Status**: **FIXED** (2026-10-02), on X11; on Plasma under Wayland the entry is found and the
icon's redraw in a running shell is not yet confirmed. Built as T254.
The owner reported that the window of the AppImage showed the Wayland icon instead of Midi 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 Harbor's. A Wayland desktop does not take an icon from the window. It takes the window's
@ -155,5 +156,16 @@ shown the window with no icon, showed Midi Harbor's. On the first run the icon w
because that machine's `icons/hicolor` directory belonged to root from an earlier test; the entry 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. 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 **On Plasma, a blank icon.** With the released AppImage on Plasma 6 under Wayland the generic
the release. icon was gone and a blank stood in its place. The entry and the icon were both installed, a new
KDE process resolved the icon's name to the file (`kiconfinder6`), and KDE's renderer drew it
correctly (`ksvgtopng`). The shell itself had been running for two weeks and had read the icon
theme's directories when it started, before `hicolor/scalable/apps` existed under the user's data
directory, so it found the entry and no icon. After writing the icon the window now sends
`org.kde.KIconLoader.iconChanged` on the session bus, which is what KDE's own programs send to
have every running program reload its icons, and sets the theme directory's modification time
for loaders that compare times. Both are done only when the icon was written.
**Not covered:** whether the shell redraws the icon on that signal without the window being
opened again, which only the owner's desktop can show, and a real AppImage carrying this last
change.