#!/usr/bin/env bash # # Verify the .deb that `cargo tauri build` produced is a correct, installable # native package — BEFORE the one-command installer starts handing it to people. # # WHY: install.sh prefers the .deb on every Debian/Ubuntu machine, but until now # nothing had ever inspected it, let alone installed it. Its Depends line was # entirely whatever tauri inferred, unread. The failure mode we're guarding is a # user running install.sh and getting either an apt resolution error or an app # that installs and then won't launch because a library it needs was never # declared. # # Five checks, cheapest first: # 1. Print the control file + contents — the generated metadata becomes ground # truth in the build log instead of an assumption. # 2. Naming: the binary is /usr/bin/thoughtsync and the generated .desktop # entry's StartupWMClass matches it. The app used to identify itself three # different ways depending on install channel (issue 2075); this is what # keeps the .deb — the only channel whose entry Tauri generates for us — # from drifting away from the two we write by hand. # 3. dpkg-shlibdeps: the canonical Debian answer for "what does this ELF # actually need". Compared against what the package declares. # 4. Every declared dependency resolves to a real package in apt (catches a # typo in the hand-written list, which would break install for everyone). # 5. If a docker CLI is present, install into a clean debian container — the # highest-fidelity check, since the build image already has the -dev # packages installed and so can't prove resolution on its own. # # Check 5 is opportunistic on purpose: the build image is not guaranteed to carry # a docker CLI, and adding one at job time would violate "the image is the # toolchain" (rule 5). Checks 1-4 are self-contained and always run. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" DEB_DIR="$REPO_ROOT/target/release/bundle/deb" shopt -s nullglob DEBS=("$DEB_DIR"/*.deb) [ ${#DEBS[@]} -gt 0 ] || { echo "ERROR: no .deb under $DEB_DIR — did the tauri build run?" >&2; exit 1; } DEB="${DEBS[0]}" echo "==> Verifying $(basename "$DEB")" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT INT TERM fail=0 note_fail() { echo "FAIL: $1" >&2; fail=1; } # --- 1. control metadata + contents ----------------------------------------- echo echo "--- control ---" dpkg-deb -I "$DEB" | sed 's/^/ /' echo "--- contents ---" dpkg-deb -c "$DEB" | awk '{print $1, $6}' | sed 's/^/ /' # Declared runtime deps, normalised: strip version constraints, split # alternatives ("a | b" both count as declared), one package name per line. declared="$(dpkg-deb -f "$DEB" Depends 2>/dev/null | tr ',|' '\n\n' | sed -E 's/\(.*\)//; s/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' | sort -u || true)" echo echo "--- declared Depends ---" printf '%s\n' "$declared" | sed 's/^/ /' dpkg-deb -x "$DEB" "$WORK/root" BIN="$(find "$WORK/root" -type f -path '*/bin/*' -print -quit)" [ -n "$BIN" ] || { echo "ERROR: no binary found under */bin/ in the package" >&2; exit 1; } echo " (binary: ${BIN#"$WORK/root"})" # --- 2. naming is consistent ------------------------------------------------- # CANON is the one name the app answers to everywhere: the binary, the CLI # command, the icon, and the WM_CLASS the window reports. Hardcoded here on # purpose — this literal IS the contract the three install channels are held to. # Deliberately NOT asserted: the control file's `Package:` field, which is # `thought-sync`. tauri-bundler derives it as kebab-case(productName) with no # config override, so "ThoughtSync" splits at the hump. Fixing it would mean # unpacking and rewriting the control archive on every build — a fragile step for # a cosmetic gain on one uninstall command. Left as a known wart (issue 2075). CANON="thoughtsync" installed_bin="${BIN#"$WORK/root"}" [ "$installed_bin" = "/usr/bin/$CANON" ] || note_fail "binary is at $installed_bin, expected /usr/bin/$CANON (mainBinaryName in tauri.conf.json)." # Tauri generates this entry from the binary name, so a mismatch means the config # and the bundler have diverged — exactly the drift that made the app group under # a different taskbar icon depending on how it was installed. entry="$(find "$WORK/root/usr/share/applications" -name '*.desktop' -print -quit 2>/dev/null || true)" if [ -z "$entry" ]; then note_fail "no .desktop entry in the package — the app would not appear in any menu." else echo "--- desktop entry ($(basename "$entry")) ---" sed 's/^/ /' "$entry" wmclass="$(sed -n 's/^StartupWMClass=//p' "$entry" | head -1)" [ "$wmclass" = "$CANON" ] || note_fail "StartupWMClass is '${wmclass:-}', expected '$CANON' — the taskbar icon will not group." # `%u`/`%U` field codes may follow, so match the first word only. exec_cmd="$(sed -n 's/^Exec=//p' "$entry" | head -1 | awk '{print $1}')" [ "$exec_cmd" = "$CANON" ] || note_fail "Exec runs '${exec_cmd:-}', expected '$CANON'." fi [ "$fail" -eq 0 ] && echo "OK: binary, Exec and StartupWMClass all agree on '$CANON'." # --- 3. what the ELF actually needs ----------------------------------------- if command -v dpkg-shlibdeps >/dev/null 2>&1; then # dpkg-shlibdeps insists on a debian/control in the working directory even with # -O (write to stdout); a stub is enough to let it do the ELF analysis. mkdir -p "$WORK/deb-stub/debian" printf 'Source: thoughtsync\n\nPackage: thoughtsync\nArchitecture: amd64\n' \ >"$WORK/deb-stub/debian/control" # `|| true`: pipefail is on and dpkg-shlibdeps still exits non-zero on some # symbol warnings even with --ignore-missing-info. An empty result degrades to # "check skipped" below — it must not abort the whole verification. required="$(cd "$WORK/deb-stub" && dpkg-shlibdeps -O --ignore-missing-info "$BIN" 2>/dev/null | sed -E 's/^shlibs:Depends=//' | tr ',' '\n' | sed -E 's/\(.*\)//; s/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' | sort -u || true)" echo echo "--- required by the binary (dpkg-shlibdeps) ---" printf '%s\n' "$required" | sed 's/^/ /' # A package may be satisfied transitively — we deliberately declare only # webkit2gtk + gtk3 and let glib/cairo/pango/gdk-pixbuf/libsoup3 arrive through # them, so "required but not declared" is only a real problem if it's also # unreachable from the declared set. Ask apt for that closure. closure="$(apt-cache depends --recurse --no-recommends --no-suggests \ --no-conflicts --no-breaks --no-replaces --no-enhances -i $declared 2>/dev/null | grep -E '^[a-zA-Z0-9]' | sed -E 's/[<>]//g' | sort -u || true)" if [ -z "$required" ]; then echo "WARN: dpkg-shlibdeps returned nothing — skipping the coverage check." >&2 elif [ -z "$closure" ]; then echo "WARN: apt has no package index here — skipping the transitive-coverage check." >&2 else for pkg in $required; do # Essential / required-priority packages (libc6, libgcc-s1 …) are present on # every Debian system by definition; declaring them is noise, not safety. prio="$(dpkg-query -W -f='${Priority} ${Essential}' "$pkg" 2>/dev/null || true)" case "$prio" in *required*|*important*|*yes*) continue ;; esac printf '%s\n' "$closure" | grep -qx "$pkg" && continue printf '%s\n' "$declared" | grep -qx "$pkg" && continue note_fail "$pkg is needed by the binary but is neither declared nor reachable from the declared deps." done [ "$fail" -eq 0 ] && echo "OK: every non-essential library the binary needs is covered." fi else echo "WARN: dpkg-shlibdeps unavailable — skipping the ELF dependency check." >&2 fi # --- 4. declared deps are real packages ------------------------------------- if apt-cache policy dpkg >/dev/null 2>&1 && [ -n "$(apt-cache policy dpkg 2>/dev/null)" ]; then for pkg in $declared; do if [ -z "$(apt-cache policy "$pkg" 2>/dev/null)" ]; then note_fail "declared dependency '$pkg' does not exist in apt — install would fail for every user." fi done [ "$fail" -eq 0 ] && echo "OK: every declared dependency exists in apt." fi # --- 5. clean-container install (opportunistic) ----------------------------- # The build image already has libwebkit2gtk-4.1-dev etc. installed, so installing # here would pass no matter what we declared. Only a pristine container proves # apt can actually resolve the package for a real user. if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then echo echo "==> Clean-container install test (debian:bookworm)" if docker run --rm -v "$DEB:/tmp/$(basename "$DEB"):ro" debian:bookworm \ sh -c "apt-get update -qq && apt-get install -y --no-install-recommends '/tmp/$(basename "$DEB")'"; then echo "OK: the .deb installs cleanly on a stock debian:bookworm." else note_fail "the .deb did NOT install on a stock debian:bookworm." fi else echo echo "NOTE: no usable docker CLI — skipping the clean-container install test." echo " Checks 1-3 above still gate the package; a real-machine install is the operator's confirm." fi echo [ "$fail" -eq 0 ] && { echo "==> .deb verification passed."; exit 0; } echo "==> .deb verification FAILED." >&2 exit 1