From b424c321dc9a77c472ceb5dc964cccaebca07b4c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 00:31:06 +0700 Subject: [PATCH] Fix #43: the AppImage's window shows the EffectCraft icon on Wayland Wayland compositors take a window's icon from the desktop entry named by its app ID; the .deb, .rpm and Flatpak install it, an AppImage installs nothing, so its window got the generic icon. Started from an AppImage ($APPIMAGE), the app now writes a hidden (NoDisplay) desktop entry pointing at it, and its 256 px icon, under $XDG_DATA_HOME (~/.local/share) before the window opens. Nothing is written when an installed package or the user's own entry provides the ID; the entry follows the AppImage when it moves; failures are only logged. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/appimage.rs | 132 +++++++++++++++++++++++++++++++ apps/effectcraft/src/main.rs | 14 +++- 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft/src/appimage.rs diff --git a/apps/effectcraft/src/appimage.rs b/apps/effectcraft/src/appimage.rs new file mode 100644 index 00000000..f353a751 --- /dev/null +++ b/apps/effectcraft/src/appimage.rs @@ -0,0 +1,132 @@ +//! Desktop integration for the AppImage. Wayland compositors (GNOME, KDE…) show a window's icon +//! from the desktop entry named by its app ID (`ai.storyteller.effectcraft.desktop`). The .deb, +//! .rpm and Flatpak packages install one; an AppImage installs nothing, so its window got the +//! generic icon. Started from an AppImage (`$APPIMAGE`), the app adds a hidden entry +//! (`NoDisplay=true`: no menu item) pointing at it and its icon under the user's data folder +//! (`$XDG_DATA_HOME`, else `~/.local/share`). Nothing is written when another entry already +//! provides the ID (an installed package, or the user's own), and an entry it wrote is kept up to +//! date when the AppImage moves. Failures are only logged: the icon is cosmetic. + +use std::path::{Path, PathBuf}; + +pub const APP_ID: &str = "ai.storyteller.effectcraft"; + +/// Marks entries this module wrote (it never touches others). +const MARKER: &str = "X-EffectCraft-AppImage=true"; + +/// A path as one `Exec` argument (desktop entry spec: quoted, with `"`, `` ` ``, `$` and `\` +/// escaped; `%` doubled so it isn't a field code). +pub fn exec_arg(path: &str) -> String { + let mut s = String::with_capacity(path.len() + 2); + s.push('"'); + for c in path.chars() { + // The quoting escape is a backslash, which the key file's string syntax escapes again: + // `"` is written `\\"`, a backslash `\\\\`. + match c { + '"' | '`' | '$' => { + s.push_str("\\\\"); + s.push(c); + } + '\\' => s.push_str("\\\\\\\\"), + '%' => s.push_str("%%"), + '\n' | '\r' => {} + c => s.push(c), + } + } + s.push('"'); + s +} + +/// The hidden desktop entry for the AppImage at `appimage`. +pub fn entry(appimage: &str) -> String { + format!( + "[Desktop Entry]\nType=Application\nName=EffectCraft\nComment=Motion graphics and visual effects compositor\nExec={} %F\nIcon={APP_ID}\nTerminal=false\nStartupWMClass={APP_ID}\nNoDisplay=true\n{MARKER}\n", + exec_arg(appimage) + ) +} + +/// Write the entry and `icon` (a 256 px PNG) under `data_home`, unless one of `data_dirs` (or +/// `data_home` itself, with an entry we didn't write) already has the ID's entry. Returns whether +/// anything was written. +pub fn integrate(appimage: &str, data_home: &Path, data_dirs: &[PathBuf], icon: &[u8]) -> std::io::Result { + let name = format!("{APP_ID}.desktop"); + if data_dirs.iter().any(|d| d.join("applications").join(&name).exists()) { + return Ok(false); + } + let path = data_home.join("applications").join(&name); + let text = entry(appimage); + let icon_path = data_home.join("icons/hicolor/256x256/apps").join(format!("{APP_ID}.png")); + match std::fs::read_to_string(&path) { + Ok(old) if !old.contains(MARKER) => return Ok(false), + Ok(old) if old == text && icon_path.exists() => return Ok(false), + _ => {} + } + write_atomic(&icon_path, icon)?; + write_atomic(&path, text.as_bytes())?; + Ok(true) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("part"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path) +} + +/// [`integrate`] for this process: when started from an AppImage, with the XDG data folders of +/// the environment. +pub fn integrate_from_env(icon: &[u8]) { + let Some(appimage) = std::env::var_os("APPIMAGE").map(|a| a.to_string_lossy().into_owned()) else { return }; + let env_path = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); + let Some(data_home) = env_path("XDG_DATA_HOME").or_else(|| env_path("HOME").map(|h| h.join(".local/share"))) else { return }; + let dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + let data_dirs: Vec = dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect(); + match integrate(&appimage, &data_home, &data_dirs, icon) { + Ok(true) => log::info!("AppImage: desktop entry and icon written to {}", data_home.display()), + Ok(false) => {} + Err(e) => log::warn!("AppImage: could not write the desktop entry: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_arguments_are_quoted_and_escaped() { + assert_eq!(exec_arg("/home/a/EffectCraft.AppImage"), "\"/home/a/EffectCraft.AppImage\""); + assert_eq!(exec_arg("/a b/$x\"y`z\\w%"), "\"/a b/\\\\$x\\\\\"y\\\\`z\\\\\\\\w%%\""); + let e = entry("/opt/EffectCraft.AppImage"); + assert!(e.contains("Exec=\"/opt/EffectCraft.AppImage\" %F\n") && e.contains("NoDisplay=true\n") && e.contains("Icon=ai.storyteller.effectcraft\n")); + } + + #[test] + fn writes_once_follows_moves_and_leaves_other_entries_alone() { + let root = std::env::temp_dir().join(format!("ec-appimage-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (home, system) = (root.join("home"), root.join("system")); + let entry_path = home.join("applications/ai.storyteller.effectcraft.desktop"); + let icon_path = home.join("icons/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + // First start: entry and icon written; again: nothing to do. + assert!(integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert_eq!(std::fs::read(&icon_path).unwrap(), b"png"); + assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/a/EffectCraft.AppImage\"")); + assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + // The AppImage moved: the entry follows it. + assert!(integrate("/b/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/b/EffectCraft.AppImage\"")); + // The user's own entry is never replaced. + std::fs::write(&entry_path, "[Desktop Entry]\nName=Mine\n").unwrap(); + assert!(!integrate("/c/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), "[Desktop Entry]\nName=Mine\n"); + // An installed package provides the entry: nothing is written. + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(system.join("applications")).unwrap(); + std::fs::write(system.join("applications/ai.storyteller.effectcraft.desktop"), "x").unwrap(); + assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert!(!entry_path.exists()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 7a56944e..3f310846 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -7,6 +7,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// Built everywhere so its tests run on every platform; only Linux AppImages use it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod appimage; mod audio_out; mod control_server; #[cfg(target_os = "macos")] @@ -17,11 +20,15 @@ use serde_json::json; /// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by /// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock. +/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too). +#[cfg(not(target_os = "macos"))] +const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + fn app_icon() -> egui::IconData { #[cfg(target_os = "macos")] let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png"); #[cfg(not(target_os = "macos"))] - let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + let png: &[u8] = ICON_PNG; eframe::icon_data::from_png_bytes(png).unwrap_or_default() } @@ -47,6 +54,9 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. + #[cfg(target_os = "linux")] + appimage::integrate_from_env(ICON_PNG); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("EffectCraft") @@ -57,7 +67,7 @@ fn main() -> eframe::Result { .with_active(control_port.is_none()) .with_icon(app_icon()) // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID. - .with_app_id("ai.storyteller.effectcraft"), + .with_app_id(appimage::APP_ID), event_loop_builder: agent_event_loop(control_port.is_some()), ..Default::default() };