Field guide · Linux packaging

Chasing down library bugs in the screenpipe AppImage

A repeatable method for figuring out whether a broken feature on Linux traces back to a stale, missing, or badly-pointed shared library bundled inside the AppImage — not just for PipeWire, for anything AppRun links against.

Applies to screenpipe Linux AppImage releases Needs a shell, strings, appimagetool Last verified against a build with the linuxdeploy AppRun chain

Mental model

An AppImage isn't a black box — it's a squashfs filesystem plus a launcher script chain that decides, at runtime, which copy of a library the app actually loads. Most "works on my machine, broken on theirs" Linux bugs live in that decision, not in the app's own code.

screenpipe's AppImage is built with linuxdeploy, which wraps the real binary in three layers. Each layer can override where libraries get loaded from, so the bug hunt is really: find which layer is pointing at the wrong copy.

flowchart TD
    A["screenpipe.appimage
(squashfs, mounted or extracted)"] --> B["AppRun
screenpipe's own wrapper script"] B -->|"sets SPA_PLUGIN_DIR → bundled usr/lib/spa-0.2"| C["AppRun.screenpipe-original
linuxdeploy launcher script"] C -->|"sources apprun-hooks/linuxdeploy-plugin-gtk.sh
forces GDK_BACKEND, GTK env"| D["AppRun.wrapped
linuxdeploy ELF binary"] D -->|"prepends bundled usr/lib/ onto LD_LIBRARY_PATH"| E["usr/bin/screenpipe-app
the actual binary"]

Two env vars matter most: LD_LIBRARY_PATH (general shared-library search path) and any *_PLUGIN_DIR variable a library defines for its own plugin discovery (PipeWire's SPA, GStreamer, GTK modules, etc). Both get force-pointed at the bundle before your system libraries are ever considered — that's normally correct AppImage behavior, but it means a stale or incomplete bundle can't be silently rescued by a good system install sitting right next to it.

01 Extract it

Turn the single-file AppImage into a real, inspectable, editable directory tree.

# from wherever the AppImage lives
./screenpipe.appimage --appimage-extract
# → creates ./squashfs-root/ next to it, untouched original stays intact

Do this in a scratch directory, not on top of the release file — you'll be diffing and possibly patching this copy, and you want the original left alone as a known-good fallback.

02 Trace the launcher chain

Before touching any library, find every place the launcher scripts set a path variable — that tells you which directory is actually authoritative at runtime.

grep -nE "LD_LIBRARY_PATH|PLUGIN_DIR|_PATH=" squashfs-root/AppRun \
  squashfs-root/AppRun.*  squashfs-root/apprun-hooks/*.sh 2>/dev/null

Anything assigned here — SPA_PLUGIN_DIR, GST_PLUGIN_PATH, GDK_PIXBUF_MODULE_FILE, whatever — is a candidate for the same bug class: a bundled directory that's stale, sparse, or simply wrong, silently shadowing a correct system copy.

03 Inventory what's actually bundled

Two different failure shapes to check for, separately.

Shape A — a stale version of a library that is present

# what does the running binary actually need?
ldd squashfs-root/usr/bin/screenpipe-app 2>&1 | grep "not found"

# for any given bundled .so, read its embedded version string
strings squashfs-root/usr/lib/libpipewire-0.3.so.0 \
  | grep -E "^[0-9]+\.[0-9]+\.[0-9]+$" | sort -u

Shape B — a plugin/module directory that's incomplete

Libraries like PipeWire, GStreamer, and GTK don't just link a .so — they load a directory of backend plugins at runtime. A library can be the right version and still be useless if its plugin directory only shipped one file out of thirty.

find squashfs-root -iname "*plugin*" -maxdepth 4 -type d
find squashfs-root/usr/lib/<suspect-dir> -name "*.so" | wc -l
watch for A plugin directory with a handful of files when the equivalent system directory has dozens is a strong signal — not proof, but strong. Compare counts before you start reading code.

04 Diff bundled vs. system

The version/plugin-count comparison only means something next to what your distro actually ships.

Distro familyQuery installed version
Archpacman -Q <pkg>
Debian / Ubuntudpkg -l | grep <pkg>
Fedora / RHELrpm -q <pkg>
Any (file-level)strings /usr/lib/<lib>.so | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'

A version gap of a patch release is rarely the cause of anything. A gap of multiple minor versions on a library with an active plugin ABI (PipeWire, GStreamer) is worth treating as a lead, not a coincidence.

05 Let the runtime logs confirm it

screenpipe logs through tracing-subscriber. A few line shapes reliably mean "the library layer, not the app logic."

Log signatureReads as
Creation failed right after a manager/backend "started" linelibrary layer the handle came up but the backend it needs couldn't init — classic sparse-plugin-dir symptom
A restart / retry loop on the same subsystemlibrary layer app logic is fine, it's being told the resource doesn't exist and keeps trying
error while loading shared libraries: ... cannot open shared object filelibrary layer literal — LD_LIBRARY_PATH doesn't include something the binary needs
Feature silently no-ops with no error at alllibrary layer often a plugin that failed to load logs at debug level only — rerun with more verbosity before assuming it's app logic
A specific, named panic inside screenpipe's own cratesapp logic this guide doesn't help — that's a real code bug

06 Patch and repackage

Once you know which file or directory is the culprit, replacing it in the extracted tree is just cp.

# -L is not optional: system libs are often symlinks,
# and appimagetool needs real regular files, not dangling links
cp -L /usr/lib/<the-stale-lib>.so.0 squashfs-root/usr/lib/<the-stale-lib>.so.0

# for a whole plugin directory, replace it wholesale rather than merging
rm -rf squashfs-root/usr/lib/<plugin-dir>
cp -rL /usr/lib/<plugin-dir> squashfs-root/usr/lib/<plugin-dir>

# repackage into a normal, drop-in-replacement single-file AppImage
ARCH=x86_64 appimagetool squashfs-root screenpipe-fixed.appimage
gotcha If rsync isn't installed, rsync -a src/ dst/ fails with "command not found" but a wrapping script can swallow that and continue — leaving you with a directory that looks copied but is actually still the old, incomplete one. Verify file counts after any bulk copy; don't trust exit codes alone.

07 Verify against a real profile — not a scratch one

This is the step most likely to give a false negative.

A fresh, isolated SCREENPIPE_DATA_DIR — even with onboarding explicitly skipped — often never reaches the state where capture actually starts within a short manual test window. Permission grants, prior setup, and other startup gates beyond just "onboarding complete" seem to matter. If you test the patch against throwaway data and see nothing happen, that's inconclusive, not a failed fix.

HOME="$HOME" \
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" \
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" \
./screenpipe-fixed.appimage 2>&1 | tee ~/screenpipe-verify.log

Running with your real, already-onboarded profile is what actually exercises the code path you're trying to fix. Grep the log for the signal you identified in step 5 — its absence, sustained over the test window, is your actual confirmation.

also useful screenpipe supports SCREENPIPE_SKIP_ONBOARDING (checked in apps/screenpipe-app-tauri/src-tauri/src/main.rs) to bypass the GUI wizard — helpful for cutting steps, but not a substitute for real profile data per above.

Process hygiene gotchas

Small things that waste time if you don't expect them.

  • pkill -f <appimage-path> usually won't match. The AppRun chain execs into a differently-named final binary (screenpipe-app), so pattern-matching on the AppImage's own filename misses the live process. Find the PID with ps aux | grep screenpipe-app and kill it directly.
  • A repackaged, launched AppImage self-mounts via FUSE at something like /tmp/.mount_screen<random>. After killing the process, check mount | grep fuse.screenpipe and run fusermount -u <path> — it'll report "busy" for a second or two right after the kill, that's normal, just retry.
  • Keep the original AppImage untouched. Always extract into a separate scratch copy. It's your known-good fallback and your basis for the diff.

Cheat sheet

The whole loop, back to back.

# 1. extract
./screenpipe.appimage --appimage-extract

# 2. find the path-override points
grep -nE "LD_LIBRARY_PATH|PLUGIN_DIR" squashfs-root/AppRun*

# 3. inventory + diff a suspect lib
strings squashfs-root/usr/lib/<lib>.so.0 | grep -E "^[0-9]+\.[0-9]+\.[0-9]+$"
pacman -Q <pkg>   # or dpkg -l / rpm -q

# 4. inventory + diff a suspect plugin dir
find squashfs-root/usr/lib/<dir> -name "*.so" | wc -l
find /usr/lib/<dir> -name "*.so" | wc -l

# 5. patch
cp -L /usr/lib/<lib>.so.0 squashfs-root/usr/lib/<lib>.so.0
rm -rf squashfs-root/usr/lib/<dir> && cp -rL /usr/lib/<dir> squashfs-root/usr/lib/<dir>

# 6. repackage
ARCH=x86_64 appimagetool squashfs-root screenpipe-fixed.appimage

# 7. verify against REAL data, not a scratch dir
HOME="$HOME" XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" \
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" \
./screenpipe-fixed.appimage 2>&1 | tee ~/screenpipe-verify.log

# cleanup
ps aux | grep screenpipe-app        # find the real PID
kill <pid>
mount | grep fuse.screenpipe && fusermount -u <mount-path>

Automate it: screenpipe-appimage-debug

Every step above, wired into four subcommands. It won't tell you a mismatch is the bug — it flags candidates and leaves the call to you — but extraction, inventory, diffing, patching, repackaging, and a scripted verify pass are each one command away.

What each subcommand covers

SubcommandMaps toDoes
inspectsteps 1–5extracts, prints the AppRun path overrides, diffs bundled libs vs. system (allowlisted libs only — see below), runs ldd for genuinely missing deps, flags sparse plugin dirs. Read-only.
patchstep 6--auto applies everything inspect flagged; --lib NAME / --dir NAME fixes one thing at a time.
packagestep 6repackages the patched tree into a new standalone .appimage via appimagetool.
verifystep 7launches the build and greps for the pass/fail log signatures from step 5. Isolated data by default (fast, inconclusive); --real-data runs it against your actual profile instead — nothing touches real data unless you pass that flag.
allthe whole loopruns inspect always; add --auto to also patch + package, and --real-data to also verify against your real profile. Without those flags it stops after the report.

Install

  • bash
  • coreutils
  • binutils (strings)
  • glibc (ldconfig)
  • appimagetool (package/all)
  • fuse2 (verify/all)
# 1. pick a directory on your PATH
mkdir -p ~/.local/bin

# 2. save the script below as ~/.local/bin/screenpipe-appimage-debug
#    (expand "full source" further down and paste it in)
$EDITOR ~/.local/bin/screenpipe-appimage-debug

# 3. make it executable
chmod +x ~/.local/bin/screenpipe-appimage-debug

# 4. confirm the directory is actually on PATH
echo "$PATH" | tr ':' '\n' | grep -qx "$HOME/.local/bin" \
  || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc

which screenpipe-appimage-debug

Run it

# look, don't touch — just the report
screenpipe-appimage-debug inspect ~/AppImages/screenpipe.appimage

# apply what it flagged, then repackage
screenpipe-appimage-debug patch /tmp/screenpipe-appimage-debug/screenpipe --auto
screenpipe-appimage-debug package /tmp/screenpipe-appimage-debug/screenpipe \
  ~/AppImages/screenpipe-fixed.appimage

# confirm it actually works, against your real profile
screenpipe-appimage-debug verify ~/AppImages/screenpipe-fixed.appimage \
  --real-data --seconds 30

# or the whole loop in one line
screenpipe-appimage-debug all ~/AppImages/screenpipe.appimage --auto --real-data
by design Version comparison only runs for an explicit allowlist (libpipewire-0.3.so.0 by default) — an earlier version of this script diffed every bundled .so blindly and produced a wall of false positives, because most libraries embed unrelated version-shaped numbers (vendored sub-deps, Unicode table versions) rather than their own release version. Extend the list with export SCREENPIPE_DEBUG_VERSION_LIBS="libfoo.so.1" only after checking that library's strings output by hand. The sparse-plugin-dir check has no such caveat — file counting is reliable.
by design verify never touches your real ~/.screenpipe data unless you pass --real-data. Without it, capture may never start within the test window (step 7) — that's an inconclusive result, not a failed one.
full source — screenpipe-appimage-debug (~330 lines)
#!/usr/bin/env bash
# screenpipe-appimage-debug — automates the AppImage library-debugging method
# from the "AppImage Library Debugging" guide: extract, inventory bundled
# libs, diff against the system, flag suspects, optionally patch/repackage,
# optionally verify.
#
# Subcommands:
#   inspect  <appimage> [workdir]         extract (if needed) + report suspects
#   patch    <workdir> [--auto|--lib X|--dir Y]   apply fixes into squashfs-root
#   package  <workdir> <output.appimage>  repackage squashfs-root
#   verify   <appimage> [--real-data] [--seconds N]   run it, grep for signal
#   all      <appimage> [workdir] [--auto] [--real-data]   run the full loop
#
# Nothing here auto-launches the app against your real profile unless you
# pass --real-data explicitly to `verify` (or `all --real-data`).

set -uo pipefail

MIN_MINOR_GAP=2       # flag a version diff at or above this many minor versions
SPARSE_RATIO=3         # flag a plugin dir if system has >= this many times the bundled file count
SPARSE_MIN_SYSTEM=4    # only consider a dir "plugin-shaped" if system side has at least this many files

# Version strings sniffed via `strings` on an arbitrary .so are unreliable —
# most libraries embed unrelated version-shaped numbers (vendored sub-deps,
# Unicode table versions, font-format specs, etc), not their own release
# version. Only compare versions for libraries confirmed (by hand, once) to
# embed a clean, unambiguous self-version string. Extend by exporting
# SCREENPIPE_DEBUG_VERSION_LIBS="libfoo.so.1 libbar.so.2" after you've
# manually verified a library's `strings` output is trustworthy.
VERSION_CHECKED_LIBS="libpipewire-0.3.so.0 ${SCREENPIPE_DEBUG_VERSION_LIBS:-}"

c_bold=$'\033[1m'; c_red=$'\033[31m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_dim=$'\033[2m'; c_off=$'\033[0m'
say()  { printf '%s\n' "$*" >&2; }
info() { say "${c_dim}$*${c_off}"; }
warn() { say "${c_yel}$*${c_off}"; }
good() { say "${c_grn}$*${c_off}"; }
bad()  { say "${c_red}$*${c_off}"; }

usage() {
  sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
  exit 1
}

version_of() {
  # extract the first plausible X.Y.Z version string embedded in a binary
  strings -a "$1" 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -Vu | head -1
}

system_path_for() {
  # find the system copy of a bundled soname, via ldconfig cache first
  local soname="$1" p
  p=$(ldconfig -p 2>/dev/null | awk -v s="$soname" '$1==s {print $NF; exit}')
  if [ -n "${p:-}" ] && [ -e "$p" ]; then printf '%s\n' "$p"; return 0; fi
  for cand in "/usr/lib/$soname" "/usr/lib64/$soname" "/usr/lib/x86_64-linux-gnu/$soname"; do
    [ -e "$cand" ] && { printf '%s\n' "$cand"; return 0; }
  done
  return 1
}

system_dir_for() {
  # given a bundled dir's path relative to usr/lib, find a same-shaped system dir
  local rel="$1"
  for cand in "/usr/lib/$rel" "/usr/lib64/$rel" "/usr/lib/x86_64-linux-gnu/$rel"; do
    [ -d "$cand" ] && { printf '%s\n' "$cand"; return 0; }
  done
  return 1
}

minor_gap() {
  # prints the |minor_a - minor_b| gap, or 99 if majors differ
  local va="$1" vb="$2"
  local maja min_a majb min_b
  maja=${va%%.*}; min_a=$(printf '%s' "$va" | cut -d. -f2)
  majb=${vb%%.*}; min_b=$(printf '%s' "$vb" | cut -d. -f2)
  if [ "$maja" != "$majb" ]; then printf '99\n'; return; fi
  printf '%s\n' "$(( min_a > min_b ? min_a - min_b : min_b - min_a ))"
}

cmd_extract() {
  local appimage="$1" workdir="$2"
  mkdir -p "$workdir"
  if [ -d "$workdir/squashfs-root" ]; then
    info "reusing existing extraction at $workdir/squashfs-root"
    return 0
  fi
  info "extracting $appimage -> $workdir/squashfs-root"
  ( cd "$workdir" && "$appimage" --appimage-extract >/dev/null )
}

cmd_inspect() {
  local appimage="${1:?usage: inspect <appimage> [workdir]}"
  local workdir="${2:-/tmp/screenpipe-appimage-debug/$(basename "$appimage" .appimage)}"
  appimage="$(readlink -f "$appimage")"
  cmd_extract "$appimage" "$workdir"
  local root="$workdir/squashfs-root"

  say ""
  say "${c_bold}== launcher chain: path overrides ==${c_off}"
  grep -nE 'LD_LIBRARY_PATH|PLUGIN_DIR|_PATH=' "$root"/AppRun "$root"/AppRun.* "$root"/apprun-hooks/*.sh 2>/dev/null \
    | sed "s|$root/||" || info "(none found — check AppRun manually, launcher structure may differ)"

  local libs_report="$workdir/inspect-libs.tsv"
  local dirs_report="$workdir/inspect-dirs.tsv"
  : > "$libs_report"; : > "$dirs_report"

  say ""
  say "${c_bold}== bundled libraries: version check (allowlisted libs only) ==${c_off}"
  info "everything else only gets the missing-deps + plugin-dir checks below — see VERSION_CHECKED_LIBS."
  printf '%-32s %-12s %-12s %s\n' "LIBRARY" "BUNDLED" "SYSTEM" "VERDICT"
  while IFS= read -r -d '' f; do
    local soname bver sver gap verdict spath
    soname=$(basename "$f")
    case " $VERSION_CHECKED_LIBS " in *" $soname "*) : ;; *) continue ;; esac
    bver=$(version_of "$f")
    if spath=$(system_path_for "$soname"); then
      sver=$(version_of "$spath")
    else
      spath=""; sver=""
    fi
    if [ -z "$bver" ] || [ -z "$sver" ]; then
      verdict="no-version-string"
    else
      gap=$(minor_gap "$bver" "$sver")
      if [ "$gap" -ge "$MIN_MINOR_GAP" ]; then verdict="MISMATCH"; else verdict="ok"; fi
    fi
    printf '%-32s %-12s %-12s %s\n' "$soname" "${bver:--}" "${sver:--}" "$verdict"
    printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$f" "$spath" "${bver:-}" "${sver:-}" "$verdict" "$soname" >> "$libs_report"
  done < <(find "$root/usr/lib" -maxdepth 1 -name '*.so*' -type f -print0 2>/dev/null)

  say ""
  say "${c_bold}== missing dependencies (ldd, with the bundle's own LD_LIBRARY_PATH) ==${c_off}"
  local main_bin missing
  main_bin=$(find "$root/usr/bin" -maxdepth 1 -type f | head -1)
  if [ -n "${main_bin:-}" ]; then
    missing=$(LD_LIBRARY_PATH="$root/usr/lib" ldd "$main_bin" 2>&1 | grep -i "not found" || true)
    if [ -n "$missing" ]; then
      bad "$missing"
      warn "these are genuinely unresolved at link time — highest-confidence signal in this whole report."
    else
      good "none — everything the binary links against resolves within the bundle."
    fi
  else
    warn "couldn't find a binary under usr/bin to check"
  fi

  say ""
  say "${c_bold}== plugin-shaped directories vs. system ==${c_off}"
  printf '%-40s %-10s %-10s %s\n' "DIR (relative to usr/lib)" "BUNDLED" "SYSTEM" "VERDICT"
  while IFS= read -r -d '' d; do
    local rel bcount scount sdir verdict
    rel=${d#"$root"/usr/lib/}
    [ "$rel" = "$d" ] && continue
    sdir=$(system_dir_for "$rel") || continue
    bcount=$(find "$d" -name '*.so' -type f 2>/dev/null | wc -l)
    scount=$(find "$sdir" -name '*.so' -type f 2>/dev/null | wc -l)
    [ "$scount" -lt "$SPARSE_MIN_SYSTEM" ] && continue
    if [ "$bcount" -gt 0 ] && [ $(( scount / bcount )) -ge "$SPARSE_RATIO" ]; then
      verdict="SPARSE"
    elif [ "$bcount" -eq 0 ]; then
      verdict="SPARSE"
    else
      verdict="ok"
    fi
    printf '%-40s %-10s %-10s %s\n' "$rel" "$bcount" "$scount" "$verdict"
    printf '%s\t%s\t%s\t%s\t%s\n' "$d" "$sdir" "$bcount" "$scount" "$verdict" >> "$dirs_report"
  done < <(find "$root/usr/lib" -mindepth 1 -maxdepth 3 -type d -print0 2>/dev/null)

  say ""
  local n_mismatch n_sparse
  n_mismatch=$(awk -F'\t' '$5=="MISMATCH"' "$libs_report" | wc -l)
  n_sparse=$(awk -F'\t' '$5=="SPARSE"' "$dirs_report" | wc -l)
  if [ "$n_mismatch" -gt 0 ] || [ "$n_sparse" -gt 0 ]; then
    warn "${n_mismatch} library version mismatch(es), ${n_sparse} sparse plugin dir(s) flagged."
    info "review: $libs_report / $dirs_report"
    info "apply fixes: screenpipe-appimage-debug patch $workdir --auto"
  else
    good "nothing flagged — bundled libs look current relative to this system."
  fi
  say "${c_dim}workdir: $workdir${c_off}"
}

cmd_patch() {
  local workdir="${1:?usage: patch <workdir> [--auto|--lib NAME|--dir NAME]}"; shift
  local root="$workdir/squashfs-root"
  local mode="${1:---auto}"
  [ -d "$root" ] || { bad "no extraction at $root — run inspect first"; exit 1; }

  apply_lib() {
    local bpath="$1" spath="$2"
    if [ -z "$spath" ]; then warn "  skip $bpath — no system match recorded"; return; fi
    cp -L "$spath" "$bpath" && good "  patched $(basename "$bpath")"
  }
  apply_dir() {
    local bdir="$1" sdir="$2"
    if [ -z "$sdir" ]; then warn "  skip $bdir — no system match recorded"; return; fi
    rm -rf "$bdir" && cp -rL "$sdir" "$bdir" && good "  patched dir $(basename "$bdir") ($(find "$bdir" -name '*.so' | wc -l) files)"
  }

  case "$mode" in
    --auto)
      say "${c_bold}patching all MISMATCH/SPARSE findings from last inspect${c_off}"
      [ -f "$workdir/inspect-libs.tsv" ] || { bad "no $workdir/inspect-libs.tsv — run inspect first"; exit 1; }
      while IFS=$'\t' read -r bpath spath bver sver verdict soname; do
        [ "$verdict" = "MISMATCH" ] && apply_lib "$bpath" "$spath"
      done < "$workdir/inspect-libs.tsv"
      while IFS=$'\t' read -r bdir sdir bcount scount verdict; do
        [ "$verdict" = "SPARSE" ] && apply_dir "$bdir" "$sdir"
      done < "$workdir/inspect-dirs.tsv"
      ;;
    --lib)
      local name="${2:?--lib needs a soname, e.g. libpipewire-0.3.so.0}"
      local bpath="$root/usr/lib/$name"
      local spath; spath=$(system_path_for "$name") || { bad "no system copy of $name found"; exit 1; }
      apply_lib "$bpath" "$spath"
      ;;
    --dir)
      local rel="${2:?--dir needs a path relative to usr/lib, e.g. spa-0.2}"
      local bdir="$root/usr/lib/$rel"
      local sdir; sdir=$(system_dir_for "$rel") || { bad "no system copy of $rel found"; exit 1; }
      apply_dir "$bdir" "$sdir"
      ;;
    *) usage ;;
  esac
}

cmd_package() {
  local workdir="${1:?usage: package <workdir> <output.appimage>}"
  local out="${2:?usage: package <workdir> <output.appimage>}"
  local root="$workdir/squashfs-root"
  [ -d "$root" ] || { bad "no extraction at $root"; exit 1; }
  command -v appimagetool >/dev/null || { bad "appimagetool not found on PATH"; exit 1; }
  ARCH=x86_64 appimagetool "$root" "$out"
  good "built $out"
}

cmd_verify() {
  local appimage="${1:?usage: verify <appimage> [--real-data] [--seconds N]}"; shift
  local real_data=0 seconds=30
  while [ $# -gt 0 ]; do
    case "$1" in
      --real-data) real_data=1 ;;
      --seconds) seconds="$2"; shift ;;
    esac
    shift
  done
  appimage="$(readlink -f "$appimage")"
  local log; log=$(mktemp /tmp/screenpipe-verify.XXXXXX.log)

  if [ "$real_data" -eq 1 ]; then
    info "launching against your real profile (HOME/XDG_CONFIG_HOME/XDG_DATA_HOME) for up to ${seconds}s..."
    HOME="$HOME" XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" \
      "$appimage" > "$log" 2>&1 &
  else
    warn "launching against an isolated scratch profile — this often never reaches the capture code path;"
    warn "pass --real-data for a conclusive check (see the debugging guide, step 7)."
    local scratch; scratch=$(mktemp -d /tmp/screenpipe-verify-data.XXXXXX)
    SCREENPIPE_DATA_DIR="$scratch" SCREENPIPE_SKIP_ONBOARDING=1 "$appimage" > "$log" 2>&1 &
  fi
  local pid=$!

  local waited=0 result="" pattern_ok='started for|capture started'
  local pattern_bad='Creation failed|cannot open shared object file|panicked at'
  while [ "$waited" -lt "$seconds" ]; do
    if grep -qEi "$pattern_bad" "$log" 2>/dev/null; then result="fail"; break; fi
    if grep -qEi "$pattern_ok" "$log" 2>/dev/null; then result="pass"; break; fi
    sleep 1; waited=$((waited + 1))
  done

  # find the real exec'd binary (AppRun chain), not the launcher pid, and kill it
  sleep 1
  local realpid
  realpid=$(pgrep -f 'screenpipe-app$' | head -1)
  [ -n "${realpid:-}" ] && kill "$realpid" 2>/dev/null
  kill "$pid" 2>/dev/null
  sleep 1
  local mnt
  mnt=$(mount | grep 'fuse.screenpipe' | awk '{print $3}' | head -1)
  if [ -n "${mnt:-}" ]; then
    for _ in 1 2 3 4 5; do
      fusermount -u "$mnt" 2>/dev/null && break
      sleep 1
    done
    mount | grep -q 'fuse.screenpipe' && warn "FUSE mount still busy after retries: $mnt (clean up manually with fusermount -u)"
  fi

  case "$result" in
    pass) good "PASS — capture-started signal seen, no failure signature. log: $log" ;;
    fail) bad  "FAIL — failure signature matched:"; grep -Ei "$pattern_bad" "$log" | head -5; info "log: $log" ;;
    *)    warn "INCONCLUSIVE — neither signal seen in ${seconds}s. log: $log"
          [ "$real_data" -eq 0 ] && warn "  (expected with an isolated profile — retry with --real-data)" ;;
  esac
}

cmd_all() {
  local appimage="${1:?usage: all <appimage> [workdir] [--auto] [--real-data]}"; shift
  local workdir="${1:-/tmp/screenpipe-appimage-debug/$(basename "$appimage" .appimage)}"
  [[ "$workdir" == --* ]] && workdir="/tmp/screenpipe-appimage-debug/$(basename "$appimage" .appimage)" || shift
  local auto=0 real_data=0
  for a in "$@"; do
    [ "$a" = "--auto" ] && auto=1
    [ "$a" = "--real-data" ] && real_data=1
  done

  cmd_inspect "$appimage" "$workdir"
  if [ "$auto" -eq 1 ]; then
    say ""
    cmd_patch "$workdir" --auto
    say ""
    cmd_package "$workdir" "$workdir/screenpipe-fixed.appimage"
    say ""
    cmd_verify "$workdir/screenpipe-fixed.appimage" $( [ "$real_data" -eq 1 ] && echo --real-data )
  else
    info "review the report above, then: patch --auto / patch --lib / patch --dir, then package, then verify."
  fi
}

main() {
  local cmd="${1:-}"; shift || true
  case "$cmd" in
    inspect) cmd_inspect "$@" ;;
    patch)   cmd_patch "$@" ;;
    package) cmd_package "$@" ;;
    verify)  cmd_verify "$@" ;;
    all)     cmd_all "$@" ;;
    *) usage ;;
  esac
}
main "$@"

Worked example

This method, applied end to end — the case that prompted writing it down.

PipeWire / SPA plugins

Wayland screen capture was failing on an Arch system with a fully current PipeWire install. Tracing the AppRun chain showed SPA_PLUGIN_DIR forced onto the bundled directory; inventorying it found only support/libspa-support.so — one file, versus 35 on the system. The bundled libpipewire-0.3.so.0 itself read as v1.0.5 against the system's v1.6.8. Logs showed the exact "started, then Creation failed" pattern from step 5. Swapping both in and repackaging resolved it — confirmed via real-profile verification, no restart loop, capture stayed up.

recurred across at least two releases — the bundled PipeWire hasn't been updated in the build pipeline, so this exact recipe is worth rerunning on every new release until that's fixed upstream.

reproduced by the scriptscreenpipe-appimage-debug all <appimage> --auto --real-data found the same two findings unassisted and reported PASS after patch + repackage + real-profile verify.