mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-18 00:06:49 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08e5f5d4e | ||
|
|
0b3097c7d2 | ||
|
|
4d7353a429 |
No files matched your search
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user