From 2ff5ad7b5843bbb335ebb7d6b872cf534fcf2b6d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 15:44:01 +0000 Subject: [PATCH] desktop install: unpack the AppImage where libfuse2 is missing; hqterm desktop prefers it (0.2.2) fusermount being present does not mean an AppImage can mount: the runtime needs libfuse.so.2, which fuse3-only distros (current Ubuntu/Kubuntu) lack. Found on dev2: "dlopen(): error loading libfuse.so.2". Also chmod 644 the launcher icon regardless of umask. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- install.sh | 39 +++++++++++++++++++++++++++++++++------ package.json | 2 +- src/desktop.ts | 24 ++++++++++++++++++++---- test/desktop.test.ts | 13 ++++++++++++- 7 files changed, 72 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a3b19a4..7cc8c7d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ hqterm desktop ``` The installer puts the AppImage in `~/.local/opt/hqterm-desktop/` and adds a -launcher entry (`~/.local/share/applications/hqterm.desktop`). A plain install +launcher entry (`~/.local/share/applications/hqterm.desktop`). Where libfuse2 +is missing (current Ubuntu/Kubuntu ship only fuse3) it unpacks the app once into +`~/.local/opt/hqterm-desktop/app/` and launches that instead. A plain install does this too on Linux when `$DISPLAY` or `$WAYLAND_DISPLAY` is set; `--no-desktop` skips it. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 1275167..f7103ed 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hqterm-desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hqterm-desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/desktop/package.json b/desktop/package.json index b00ba40..24534d8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "hqterm-desktop", "productName": "hqterm", - "version": "0.2.1", + "version": "0.2.2", "description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).", "license": "MIT", "author": "Profullstack, Inc. ", diff --git a/install.sh b/install.sh index 9351719..30bc3d2 100755 --- a/install.sh +++ b/install.sh @@ -134,6 +134,20 @@ install_one() { return 0 } +# have_libfuse2: whether an AppImage can mount itself here. +have_libfuse2() { + for d in /lib /lib64 /usr/lib /usr/lib64 /lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu /lib/aarch64-linux-gnu /usr/lib/aarch64-linux-gnu; do + [ -e "$d/libfuse.so.2" ] && return 0 + done + for l in ldconfig /sbin/ldconfig /usr/sbin/ldconfig; do + if command -v "$l" >/dev/null 2>&1; then + "$l" -p 2>/dev/null | grep -q 'libfuse\.so\.2' && return 0 + return 1 + fi + done + return 1 +} + # install_desktop: the AppImage, an icon and an app-launcher entry (Linux only). install_desktop() { if [ "$OS" != linux ]; then @@ -163,11 +177,24 @@ install_desktop() { || fetch "https://raw.githubusercontent.com/profullstack/openemoji/main/png/256/1f5a5-fe0f.png" "$TMP/hqterm.png" 2>/dev/null; then mv -f "$TMP/hqterm.png" "$icons/hqterm.png" fi - # AppImages mount through FUSE; without it they can unpack and run instead. - extra="" - if ! command -v fusermount >/dev/null 2>&1 && ! command -v fusermount3 >/dev/null 2>&1; then - extra=" --appimage-extract-and-run" - say "note: no FUSE (fusermount) here, so the launcher uses --appimage-extract-and-run" + chmod 644 "$icons/hqterm.png" 2>/dev/null || true + # An AppImage mounts itself with libfuse2 (libfuse.so.2), which many current + # distros no longer install (they ship fuse3 only). Without it, unpack the + # app once into $dir/app and launch that instead. + rm -rf "$dir/app" + exe="$dir/hqterm-desktop.AppImage" + if ! have_libfuse2; then + say "no libfuse2 here (AppImages need it to mount); unpacking the app into $dir/app" + rm -rf "$dir/squashfs-root" + if (cd "$dir" && ./hqterm-desktop.AppImage --appimage-extract >/dev/null) && [ -x "$dir/squashfs-root/hqterm-desktop" ]; then + mv "$dir/squashfs-root" "$dir/app" + chmod -R go+rX "$dir/app" 2>/dev/null || true + exe="$dir/app/hqterm-desktop" + say "installed $exe" + else + rm -rf "$dir/squashfs-root" + say "warning: could not unpack the AppImage; install libfuse2 (e.g. sudo apt install libfuse2t64) to run it" + fi fi cat > "$apps/hqterm.desktop" < boolean = existsSync): boolean { + return LIBFUSE2_DIRS.some((d) => exists(join(d, "libfuse.so.2"))); +} + export function hasDisplay(env: NodeJS.ProcessEnv = process.env, platform = process.platform): boolean { return platform !== "linux" || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); } @@ -100,8 +117,7 @@ export async function desktop(args: string[]): Promise { console.error("hqterm desktop: no display ($DISPLAY / $WAYLAND_DISPLAY unset). Run it from your desktop session."); return 2; } - const hasFuse = Boolean(Bun.which("fusermount") || Bun.which("fusermount3")); - const [cmd, ...rest] = desktopLaunch(path, parsed.opts, process.platform, hasFuse); + const [cmd, ...rest] = desktopLaunch(path, parsed.opts, process.platform, hasLibfuse2()); const child = spawn(cmd!, rest, { detached: true, stdio: "ignore", env: { ...process.env, ELECTRON_DISABLE_SANDBOX: "1" } }); const failed = await new Promise((resolve) => { child.once("error", resolve); diff --git a/test/desktop.test.ts b/test/desktop.test.ts index 32b8baf..5954f42 100644 --- a/test/desktop.test.ts +++ b/test/desktop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { desktopCandidates, desktopLaunch, findDesktop, hasDisplay, parseDesktopArgs } from "../src/desktop.ts"; +import { desktopCandidates, desktopLaunch, findDesktop, hasDisplay, hasLibfuse2, parseDesktopArgs } from "../src/desktop.ts"; import { globBasename } from "../src/sshconfig.ts"; import { main } from "../src/main.ts"; @@ -17,9 +17,13 @@ describe("hqterm desktop", () => { test("looks in $HQTERM_DESKTOP, then the install dir (Linux) or /Applications (macOS)", () => { expect(desktopCandidates({}, "linux", "/h")).toEqual([ + "/h/.local/opt/hqterm-desktop/app/hqterm-desktop", "/h/.local/opt/hqterm-desktop/hqterm-desktop.AppImage", "/h/.local/opt/hqterm-desktop/hqterm-desktop", ]); + // The unpacked app (no libfuse2) wins over the AppImage beside it. + const both = new Set(["/h/.local/opt/hqterm-desktop/app/hqterm-desktop", "/h/.local/opt/hqterm-desktop/hqterm-desktop.AppImage"]); + expect(findDesktop({}, "linux", "/h", (p) => both.has(p))).toBe("/h/.local/opt/hqterm-desktop/app/hqterm-desktop"); expect(desktopCandidates({ HQTERM_DESKTOP: "/x/app" }, "linux", "/h")[0]).toBe("/x/app"); expect(desktopCandidates({}, "darwin", "/h")).toEqual(["/Applications/hqterm.app", "/h/Applications/hqterm.app"]); const have = new Set(["/h/.local/opt/hqterm-desktop/hqterm-desktop"]); @@ -50,6 +54,13 @@ describe("hqterm desktop", () => { ]); }); + test("libfuse2 check looks for libfuse.so.2, not fusermount", () => { + expect(hasLibfuse2(() => false)).toBe(false); + expect(hasLibfuse2((p) => p === "/usr/lib/x86_64-linux-gnu/libfuse.so.2")).toBe(true); + expect(hasLibfuse2((p) => p === "/usr/lib/libfuse.so.2")).toBe(true); + expect(hasLibfuse2((p) => p.endsWith("libfuse3.so.3"))).toBe(false); + }); + test("display check", () => { expect(hasDisplay({}, "linux")).toBe(false); expect(hasDisplay({ WAYLAND_DISPLAY: "wayland-0" }, "linux")).toBe(true);