mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
72e60dc6fa
commit
b424c321dc
2 files changed
+144
-2
No files matched your search
@@ -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<bool> {
|
||||
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<PathBuf> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user