3 Commits
Author SHA1 Message Date
Mexandskylar taylor-barrick c08e5f5d4e Drain helper output after reaching the capture limit (#315)
* Drain helper output after reaching the capture limit

* fix: enforce helper deadline while draining inherited pipes

Keep both reader completions under the original shared timeout after the direct child exits. Add a finite self-spawned descendant regression that fails before the fix. Fresh isolated module: 6 tests passed, 2 ignored fixture entry points; strict Clippy and formatting passed. Independent review approved. Timeout detaches readers as before; no descendant-tree cancellation or lifetime resource-cleanup claim. AI assistance: OpenAI Codex for @MMexicann.

---------

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-17 22:02:43 -06:00
Mex 0b3097c7d2 Respect managed AppImage desktop entries and refresh icon caches (#355)
* Respect managed AppImage desktop integration and refresh icon caches

* fix: preserve customized AppImage desktop executables

Enhance entry refresh logic to preserve customized fields.

* test: cover custom and ambiguous AppImage executables
2026-10-17 21:14:41 -06:00
Mex 4d7353a429 Read complete single-instance greetings before comparing versions (#331) 2026-10-17 21:13:01 -06:00
4 changed files with 330 additions and 15 deletions

No files matched your search

+119 -3
View File
@@ -75,6 +75,14 @@ pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result
}
}
};
// A descendant may retain a pipe after the direct child exits. Keep reader completion
// under the same deadline; on timeout the readers detach, as in the child timeout path.
while !stdout.is_finished() || !stderr.is_finished() {
if start.elapsed() >= timeout {
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(15));
}
let text = |pipe: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&pipe.join().unwrap_or_default()).into_owned();
let (out, err) = (text(stdout), text(stderr));
if status.success() {
@@ -84,12 +92,15 @@ pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result
Err(if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() })
}
/// Reads a child's pipe to the end (at most 8 MB) on its own thread.
/// Captures at most 8 MB of a child's pipe and drains the rest on its own thread.
fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(pipe) = pipe {
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(pipe, 8 << 20), &mut out);
if let Some(mut pipe) = pipe {
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut pipe, 8 << 20), &mut out);
// Keep reading without retaining more output: closing a full pipe early can make a
// successful helper fail with a broken pipe (SIGPIPE on Unix).
let _ = std::io::copy(&mut pipe, &mut std::io::sink());
}
out
})
@@ -503,6 +514,30 @@ mod run_tests {
assert_eq!(capture(program, &args, Duration::from_secs(30)), None);
}
#[cfg(unix)]
#[test]
fn successful_helpers_can_write_past_the_stdout_capture_limit() {
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero"], Duration::from_secs(10)).unwrap();
assert_eq!(out.len(), 8 << 20);
}
#[cfg(unix)]
#[test]
fn successful_helpers_can_write_past_the_stderr_capture_limit() {
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero >&2"], Duration::from_secs(10));
assert!(out.is_ok(), "the helper should finish without a broken pipe");
assert!(out.unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn a_helper_that_keeps_writing_still_times_out() {
let start = Instant::now();
let err = run("yes", &[], Duration::from_millis(100)).unwrap_err();
assert!(err.contains("didn't finish within"), "{err}");
assert!(start.elapsed() < Duration::from_secs(5));
}
#[test]
fn a_silent_failure_and_a_success() {
let (program, args) = shell("exit 4");
@@ -567,3 +602,84 @@ mod discord_tests {
assert!(url == DISCORD_URL || url == DISCORD_APP_URL);
}
}
#[cfg(test)]
mod inherited_pipe_tests {
use super::*;
use std::io::Write;
fn fixture_args(name: &str, marker: &str) -> Vec<String> {
let (_, module) = module_path!().split_once("::").unwrap();
// libtest accepts arbitrary skip filters; this one carries our private fixture path.
vec!["--ignored".into(), "--exact".into(), format!("{module}::{name}"), "--nocapture".into(), "--skip".into(), marker.into()]
}
fn fixture_dir() -> std::path::PathBuf {
let args: Vec<String> = std::env::args().collect();
std::path::PathBuf::from(&args[args.iter().position(|arg| arg == "--skip").unwrap() + 1])
}
#[test]
#[ignore = "self-spawned subprocess fixture"]
fn pipe_holder() {
let dir = fixture_dir();
let buffer = [b'x'; 64 << 10];
// Both streams exceed the capture cap before the direct child exits.
for _ in 0..144 {
std::io::stdout().write_all(&buffer).unwrap();
std::io::stderr().write_all(&buffer).unwrap();
}
std::fs::write(dir.join("ready"), []).unwrap();
let start = Instant::now();
while !dir.join("stop").exists() && start.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(10));
}
std::fs::write(dir.join("done"), []).unwrap();
}
#[test]
#[ignore = "self-spawned subprocess fixture"]
fn pipe_parent() {
let dir = fixture_dir();
let descendant = command(std::env::current_exe().unwrap()).args(fixture_args("pipe_holder", dir.to_str().unwrap())).spawn().unwrap();
let start = Instant::now();
while !dir.join("ready").exists() && start.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(10));
}
assert!(dir.join("ready").exists(), "descendant did not fill both captured pipes");
std::fs::write(dir.join("parent-returning"), []).unwrap();
// Dropping Child leaves the finite fixture running with inherited stdout/stderr.
drop(descendant);
}
struct StopHolder(tempfile::TempDir);
impl Drop for StopHolder {
fn drop(&mut self) {
let _ = std::fs::write(self.0.path().join("stop"), []);
let start = Instant::now();
while !self.0.path().join("done").exists() && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(10));
}
}
}
#[test]
fn exited_helper_does_not_wait_past_deadline_for_inherited_pipes() {
let holder = StopHolder(tempfile::tempdir().unwrap());
let exe = std::env::current_exe().unwrap();
let args = fixture_args("pipe_parent", holder.0.path().to_str().unwrap());
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let start = Instant::now();
let result = run(exe.to_str().unwrap(), &args, Duration::from_secs(1));
let elapsed = start.elapsed();
assert!(holder.0.path().join("parent-returning").exists(), "direct child did not exit its fixture");
assert!(
matches!(&result, Err(error) if error.contains("didn't finish within")),
"expected a pipe deadline error; helper returned success: {}, elapsed: {elapsed:?}",
result.is_ok()
);
assert!(elapsed < Duration::from_secs(2), "capture exceeded its caller deadline: {elapsed:?}");
// The guard stops the finite descendant and waits for its completion marker on failures too.
}
}
+85 -4
View File
@@ -103,10 +103,8 @@ fn listen(listener: TcpListener, ours: Option<Version>, wake: Wake) -> Receiver<
let (tx, rx) = channel();
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut buf = [0u8; 64];
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
let n = stream.read(&mut buf).unwrap_or(0);
let Some((request, reply)) = buf.get(..n).and_then(|m| answer(m, ours.as_ref())) else { continue };
let message = read_message(&mut stream);
let Some((request, reply)) = answer(&message, ours.as_ref()) else { continue };
if let Some(reply) = reply {
let _ = stream.write_all(reply.as_bytes());
}
@@ -122,6 +120,34 @@ fn listen(listener: TcpListener, ours: Option<Version>, wake: Wake) -> Receiver<
rx
}
/// Reads a greeting through EOF, at most 64 bytes and one second in total. New starts close
/// their write half to delimit the message; older starts keep it open while waiting for a
/// reply, so a read timeout also ends their greeting.
fn read_message(stream: &mut TcpStream) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_secs(1);
let mut message = Vec::new();
let mut buf = [0u8; 64];
while message.len() < buf.len() {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() || stream.set_read_timeout(Some(remaining)).is_err() {
break;
}
let available = buf.len() - message.len();
let Some(room) = buf.get_mut(..available) else { break };
match stream.read(room) {
Ok(0) => break,
Ok(n) => {
if let Some(bytes) = room.get(..n) {
message.extend_from_slice(bytes);
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
message
}
/// The running launcher's side: what `message` asks for, and what to answer.
fn answer(message: &[u8], ours: Option<&Version>) -> Option<(Request, Option<String>)> {
if message == SHOW {
@@ -145,6 +171,7 @@ fn ask(addr: SocketAddr, ours: Option<&Version>) -> std::io::Result<Answer> {
return Ok(Answer::Defer);
};
stream.write_all(format!("{HELLO}{ours}").as_bytes())?;
stream.shutdown(std::net::Shutdown::Write)?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut reply = Vec::new();
match (&mut stream).take(64).read_to_end(&mut reply) {
@@ -198,6 +225,60 @@ mod tests {
assert_eq!(next(&rx), Request::Replace);
}
#[test]
fn a_fragmented_greeting_waits_for_the_complete_version() {
for (running, first, rest, version) in [("0.1.0", "", "0.1.2", "0.1.2"), ("0.1.2", "0.1.1", "0", "0.1.10")] {
let (addr, rx) = primary(running);
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
stream.write_all(format!("{HELLO}{first}").as_bytes()).unwrap();
assert!(rx.recv_timeout(Duration::from_millis(100)).is_err(), "an incomplete greeting must not show the old launcher");
stream.write_all(rest.as_bytes()).unwrap();
stream.shutdown(std::net::Shutdown::Write).unwrap();
let mut reply = Vec::new();
stream.read_to_end(&mut reply).unwrap();
assert_eq!(verdict(&reply, &v(version)), Answer::TakeOver);
assert_eq!(next(&rx), Request::Replace);
}
}
#[test]
fn an_older_greeting_without_a_write_shutdown_still_gets_an_answer() {
let (addr, rx) = primary("0.1.0");
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
stream.write_all(format!("{HELLO}0.1.2").as_bytes()).unwrap();
let mut reply = Vec::new();
stream.read_to_end(&mut reply).unwrap();
assert_eq!(verdict(&reply, &v("0.1.2")), Answer::TakeOver);
assert_eq!(next(&rx), Request::Replace);
}
#[test]
fn a_peer_that_keeps_sending_cannot_extend_the_greeting_deadline() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel();
let reader = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
tx.send(read_message(&mut stream)).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
let writer = std::thread::spawn(move || {
for _ in 0..100 {
if stream.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
let message = rx.recv_timeout(Duration::from_secs(3)).unwrap();
assert!(!message.is_empty());
assert!(message.len() < 64, "the deadline should stop a slow greeting before the size limit");
reader.join().unwrap();
writer.join().unwrap();
}
#[test]
fn the_same_or_an_older_start_shows_the_running_launcher() {
let (addr, rx) = primary("0.1.2");
+40 -8
View File
@@ -7,7 +7,8 @@
# generic "Wayland" icon. Best-effort desktop integration: on first run
# (and whenever the AppImage's path changes) install the .desktop entry and the
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
# the launch untouched. Opt out with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1.
# the launch untouched. AppImage managers can opt out with DESKTOPINTEGRATION=1,
# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1.
set -u
@@ -18,6 +19,9 @@ integrate() {
case "${ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION:-}" in
1 | true | yes) return 0 ;;
esac
case "${DESKTOPINTEGRATION:-}" in
1 | true | yes) return 0 ;;
esac
# The AppImage runtime exports $APPIMAGE; without it (an extracted AppDir) there
# is no path to register and nothing to do.
appimage="${APPIMAGE:-}"
@@ -41,15 +45,19 @@ integrate() {
mkdir -p "$apps" 2>/dev/null || return 0
tmp="$apps/.$DESK_ID.tmp.$$"
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
if sed -e "s|^Exec=.*|Exec=\"$esc\"|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
if sed -e "s|^Exec=.*|Exec=\"$esc\"|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp" &&
printf '%s\n' "X-ArtCraft-Launcher-Generated-Exec=\"$appimage\"" >>"$tmp"; then
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
rm -f "$tmp"
else
elif [ ! -e "$apps/$DESK_ID.desktop" ] && [ ! -L "$apps/$DESK_ID.desktop" ] || own_entry "$apps/$DESK_ID.desktop"; then
if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
else
rm -f "$tmp"
fi
else
# Keep manager metadata and user changes, including a custom Exec environment.
rm -f "$tmp"
fi
else
rm -f "$tmp"
@@ -60,12 +68,36 @@ integrate() {
src_icons="$APPDIR/usr/share/icons/hicolor"
[ -d "$src_icons" ] || return 0
icons="$data/icons/hicolor"
(cd "$src_icons" && find . -type f) | while IFS= read -r rel; do
mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue
if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then
cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true
(cd "$src_icons" && find . -type f) | {
changed=false
while IFS= read -r rel; do
mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue
if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then
if cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null; then
changed=true
fi
fi
done
if [ "$changed" = true ] && command -v gtk-update-icon-cache >/dev/null 2>&1; then
# A per-user hicolor directory need not have index.theme; -t permits that.
gtk-update-icon-cache -f -t "$icons" >/dev/null 2>&1 || true
fi
done
}
}
# Refresh only an unchanged entry whose Exec still matches the executable we wrote.
# Unmarked legacy entries and customized or ambiguous executable fields are preserved.
own_entry() {
[ -f "$1" ] && [ ! -L "$1" ] || return 1
[ "$(grep -c '^Exec=' "$1")" = 1 ] || return 1
[ "$(grep -c '^X-ArtCraft-Launcher-Generated-Exec=' "$1")" = 1 ] || return 1
grep -Eq '^Exec="/[^"`$\\%]+"$' "$1" || return 1
existing_exec="$(sed -n 's/^Exec=//p' "$1")" || return 1
generated_exec="$(sed -n 's/^X-ArtCraft-Launcher-Generated-Exec=//p' "$1")" || return 1
[ "$existing_exec" = "$generated_exec" ] || return 1
expected_body="$(sed -e '/^Exec=/d' -e '/^TryExec=/d' "$src_desktop")" || return 1
existing_body="$(sed -e '/^Exec=/d' -e '/^X-ArtCraft-Launcher-Generated-Exec=/d' "$1")" || return 1
[ "$expected_body" = "$existing_body" ]
}
integrate
+86
View File
@@ -29,6 +29,21 @@ export APPIMAGE
export HOME="$TMP/home"
export XDG_DATA_HOME="$HOME/.local/share"
mkdir -p "$HOME"
unset DESKTOPINTEGRATION ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION
# Record cache refreshes without needing a desktop or GTK installation.
export REAL_CACHE_UPDATER
REAL_CACHE_UPDATER="$(command -v gtk-update-icon-cache || true)"
mkdir -p "$TMP/tools"
cat >"$TMP/tools/gtk-update-icon-cache" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "$CACHE_CALLS"
if [ -n "$REAL_CACHE_UPDATER" ]; then
"$REAL_CACHE_UPDATER" "$@"
fi
EOF
chmod +x "$TMP/tools/gtk-update-icon-cache"
export PATH="$TMP/tools:$PATH" CACHE_CALLS="$TMP/cache-calls"
fail() {
echo "FAIL: $*" >&2
@@ -57,6 +72,12 @@ fi
for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do
[ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f"
done
[ -f "$CACHE_CALLS" ] || fail "installed icons did not refresh the icon cache"
[ "$(wc -l <"$CACHE_CALLS")" -eq 1 ] || fail "icon cache refreshed more than once"
grep -Fxq -- "-f -t $XDG_DATA_HOME/icons/hicolor" "$CACHE_CALLS" || fail "wrong icon cache arguments"
if [ -n "$REAL_CACHE_UPDATER" ]; then
[ -f "$XDG_DATA_HOME/icons/hicolor/icon-theme.cache" ] || fail "GTK did not build the icon cache"
fi
# 4. Idempotent: a second launch must not rewrite anything (inode unchanged).
before="$(stat -c %i "$DESKTOP")"
@@ -64,12 +85,14 @@ out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero"
[ "$out" = "stub: " ] || fail "second launch output: $out"
after="$(stat -c %i "$DESKTOP")"
[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged"
[ "$(wc -l <"$CACHE_CALLS")" -eq 1 ] || fail "unchanged icons refreshed the cache"
# 5. Moving the AppImage refreshes Exec.
mv "$APPIMAGE" "$TMP/moved.AppImage"
export APPIMAGE="$TMP/moved.AppImage"
"$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero"
grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\"" "$DESKTOP" || fail "Exec not refreshed after move"
cp "$DESKTOP" "$TMP/generated-entry"
# 6. Opt-out installs nothing.
rm -rf "$XDG_DATA_HOME"
@@ -95,4 +118,67 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi
[ "$out" = "stub: x" ] || fail "odd path: $out"
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
# 10. Gear Lever's environment flag suppresses integration, including icon writes.
out="$(DESKTOPINTEGRATION=1 "$APPDIR/AppRun" managed)" || fail "manager launch failed"
[ "$out" = "stub: managed" ] || fail "manager launch lost arguments"
[ ! -e "$XDG_DATA_HOME" ] || fail "manager-integrated AppImage integrated again"
# 11. A manager entry under the native id keeps TryExec and its launch environment.
mkdir -p "$(dirname "$DESKTOP")"
cp "$TMP/generated-entry" "$DESKTOP"
cat >>"$DESKTOP" <<EOF
TryExec=$APPIMAGE
X-GearLever-Managed=true
EOF
cp "$DESKTOP" "$TMP/manager-entry"
"$APPDIR/AppRun" >/dev/null || fail "launch with existing manager entry failed"
cmp -s "$DESKTOP" "$TMP/manager-entry" || fail "manager metadata overwritten"
# 12. Preserve a launcher's entry customized by its user, even after a move.
sed 's/^Exec=/Exec=env DESKTOPINTEGRATION=1 /' "$TMP/generated-entry" >"$DESKTOP"
"$APPDIR/AppRun" >/dev/null || fail "launch with custom Exec failed"
grep -Fq 'Exec=env DESKTOPINTEGRATION=1' "$DESKTOP" || fail "custom launch environment overwritten"
# 12a. A plain quoted absolute wrapper is a customization, too.
wrapper="$TMP/custom launcher wrapper"
printf '#!/bin/sh\nexit 0\n' >"$wrapper"
sed "s|^Exec=.*|Exec=\"$wrapper\"|" "$TMP/generated-entry" >"$DESKTOP"
cp "$DESKTOP" "$TMP/custom-wrapper-entry"
"$APPDIR/AppRun" >/dev/null || fail "launch with quoted wrapper failed"
cmp -s "$DESKTOP" "$TMP/custom-wrapper-entry" || fail "quoted absolute wrapper overwritten"
# 12b. Unmarked legacy entries cannot establish ownership, even with a plain Exec.
legacy="$TMP/legacy.AppImage"
: >"$legacy"
sed -e '/^X-ArtCraft-Launcher-Generated-Exec=/d' -e "s|^Exec=.*|Exec=\"$legacy\"|" "$TMP/generated-entry" >"$DESKTOP"
cp "$DESKTOP" "$TMP/legacy-entry"
"$APPDIR/AppRun" >/dev/null || fail "launch with legacy entry failed"
cmp -s "$DESKTOP" "$TMP/legacy-entry" || fail "ambiguous legacy entry overwritten"
# 12c. Duplicate executable or ownership keys are ambiguous and stay untouched.
for key in Exec X-ArtCraft-Launcher-Generated-Exec; do
cp "$TMP/generated-entry" "$DESKTOP"
grep "^$key=" "$TMP/generated-entry" >>"$DESKTOP"
cp "$DESKTOP" "$TMP/duplicate-entry"
"$APPDIR/AppRun" >/dev/null || fail "launch with duplicate $key failed"
cmp -s "$DESKTOP" "$TMP/duplicate-entry" || fail "duplicate $key entry overwritten"
done
# The uncustomized entry refreshed after the move keeps a matching ownership marker.
grep -Fxq "X-ArtCraft-Launcher-Generated-Exec=\"$(readlink -f "$APPIMAGE")\"" "$TMP/generated-entry" || fail "generated executable marker missing or stale"
# 13. Refresh a changed icon, and tolerate a cache updater that fails.
printf '#!/bin/sh\nexit 1\n' >"$TMP/tools/gtk-update-icon-cache"
icon="16x16/apps/$APP_ID.png"
printf 'stale icon' >"$XDG_DATA_HOME/icons/hicolor/$icon"
out="$("$APPDIR/AppRun" cache-failure)" || fail "cache failure blocked launch"
[ "$out" = "stub: cache-failure" ] || fail "cache failure lost arguments"
cmp -s "$APPDIR/usr/share/icons/hicolor/$icon" "$XDG_DATA_HOME/icons/hicolor/$icon" || fail "changed icon not refreshed"
# 14. A symlink to someone else's entry must not be replaced.
rm "$DESKTOP"
ln -s "$TMP/manager-entry" "$DESKTOP"
"$APPDIR/AppRun" >/dev/null || fail "launch with desktop symlink failed"
[ -L "$DESKTOP" ] || fail "desktop symlink replaced"
echo "AppRun desktop integration ok"