diff --git a/desktop/packaging/arch/README.md b/desktop/packaging/arch/README.md index 52fd071..78ae1f0 100644 --- a/desktop/packaging/arch/README.md +++ b/desktop/packaging/arch/README.md @@ -30,7 +30,7 @@ Or grab the `.pkg.tar.*` from the and install it directly: ```sh -sudo pacman -U thoughtsync-desktop-*-x86_64.pkg.tar.* +sudo pacman -U thoughtsync-*-x86_64.pkg.tar.* ``` The compression suffix depends on what the build image provides — `.zst` when @@ -45,7 +45,11 @@ Either way you get: Launch **ThoughtSync** from your app menu, or run `thoughtsync`. -Uninstall: `sudo pacman -R thoughtsync-desktop`. +Uninstall: `sudo pacman -R thoughtsync`. + +The package was called `thoughtsync-desktop` before; it declares `replaces`/ +`conflicts` on that name, so an upgrade from it is a normal `pacman -U` and +leaves nothing behind. ## How the package is built diff --git a/desktop/packaging/arch/package-prebuilt.sh b/desktop/packaging/arch/package-prebuilt.sh index 4749bed..4ddd298 100755 --- a/desktop/packaging/arch/package-prebuilt.sh +++ b/desktop/packaging/arch/package-prebuilt.sh @@ -29,10 +29,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" SRC_TAURI="$REPO_ROOT/desktop/src-tauri" -BINARY="$SRC_TAURI/target/release/thoughtsync-desktop" +BINARY="$SRC_TAURI/target/release/thoughtsync" OUT_DIR="${1:-$SRC_TAURI/target/release/bundle/arch}" -PKGNAME="thoughtsync-desktop" +PKGNAME="thoughtsync" +# The name this package used to ship under. pacman needs both to retire it: without +# them a `pacman -U` of the renamed package installs ALONGSIDE the old one, and two +# packages both own /usr/bin/thoughtsync (issue 2075). +REPLACES=(thoughtsync-desktop) PKGREL=1 PKGDESC="ThoughtSync desktop — local-first Keep-style thought capture" URL="https://git.fabledsword.com/bvandeusen/thoughtsync" @@ -73,9 +77,10 @@ STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT INT TERM # --- lay out the filesystem tree -------------------------------------------- -# /usr/bin/thoughtsync (not thoughtsync-desktop): matches the CLI name the -# AppImage installer symlinks into ~/.local/bin, so the command is the same -# whichever way the app was installed. +# /usr/bin/thoughtsync — the same command name the .deb installs and the AppImage +# installer symlinks into ~/.local/bin, so it's identical whichever way the app +# arrived. The binary already carries this name (Cargo `[[bin]]`), which is also +# what the .desktop entry's StartupWMClass has to match. install -Dm755 "$BINARY" "$STAGE/usr/bin/thoughtsync" install -Dm644 "$SCRIPT_DIR/thoughtsync.desktop" \ "$STAGE/usr/share/applications/thoughtsync.desktop" @@ -109,6 +114,10 @@ INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)" echo "arch = x86_64" echo "license = $LICENSE" for d in "${DEPENDS[@]}"; do echo "depend = $d"; done + # conflict + replaces together: `conflict` is what makes pacman remove the old + # package rather than refuse the transaction, `replaces` is what makes an upgrade + # pick this one up under its new name. + for r in "${REPLACES[@]}"; do echo "conflict = $r"; echo "replaces = $r"; done } >"$STAGE/.PKGINFO" # --- .MTREE (optional) ------------------------------------------------------ diff --git a/desktop/packaging/arch/thoughtsync.desktop b/desktop/packaging/arch/thoughtsync.desktop index 7328f95..9276a1c 100644 --- a/desktop/packaging/arch/thoughtsync.desktop +++ b/desktop/packaging/arch/thoughtsync.desktop @@ -1,3 +1,6 @@ +# StartupWMClass must equal the BINARY name, not the product name: GTK derives +# WM_CLASS from the executable, so anything else silently breaks taskbar icon +# grouping. Tauri writes the same value into the .deb's generated entry. [Desktop Entry] Type=Application Name=ThoughtSync @@ -6,4 +9,4 @@ Exec=thoughtsync %U Icon=thoughtsync Terminal=false Categories=Utility;Office; -StartupWMClass=ThoughtSync +StartupWMClass=thoughtsync diff --git a/desktop/packaging/deb/verify.sh b/desktop/packaging/deb/verify.sh index 80ba38a..c8650ea 100755 --- a/desktop/packaging/deb/verify.sh +++ b/desktop/packaging/deb/verify.sh @@ -10,20 +10,25 @@ # that installs and then won't launch because a library it needs was never # declared. # -# Four checks, cheapest first: +# 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. dpkg-shlibdeps: the canonical Debian answer for "what does this ELF +# 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. -# 3. Every declared dependency resolves to a real package in apt (catches a +# 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). -# 4. If a docker CLI is present, install into a clean debian container — the +# 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 4 is opportunistic on purpose: the build image is not guaranteed to carry +# 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-3 are self-contained and always run. +# toolchain" (rule 5). Checks 1-4 are self-contained and always run. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -63,7 +68,41 @@ 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. what the ELF actually needs ----------------------------------------- +# --- 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. @@ -110,7 +149,7 @@ else echo "WARN: dpkg-shlibdeps unavailable — skipping the ELF dependency check." >&2 fi -# --- 3. declared deps are real packages ------------------------------------- +# --- 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 @@ -120,7 +159,7 @@ if apt-cache policy dpkg >/dev/null 2>&1 && [ -n "$(apt-cache policy dpkg 2>/dev [ "$fail" -eq 0 ] && echo "OK: every declared dependency exists in apt." fi -# --- 4. clean-container install (opportunistic) ----------------------------- +# --- 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. diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh index a6e06b3..30cce5c 100755 --- a/desktop/packaging/install.sh +++ b/desktop/packaging/install.sh @@ -236,6 +236,10 @@ if ( cd "$tmp" && "$dest" --appimage-extract .DirIcon >/dev/null 2>&1 ) \ fi rm -rf "$tmp/squashfs-root" 2>/dev/null || true +# StartupWMClass is the BINARY name, not the product name and not the AppImage +# filename: the AppImage's AppRun execs usr/bin/thoughtsync, and GTK derives +# WM_CLASS from whatever it ends up running. Anything else here means the window +# never associates with this entry and the taskbar shows a second, generic icon. cat > "$apps_menu/thoughtsync.desktop" </dev/null 2>&1 || true diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9f2c8e4..4df4c63 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" description = "ThoughtSync desktop — local-first Keep-style thought capture" authors = ["bvandeusen"] edition = "2021" +# The executable is named below, not inferred from this package name. Off so the +# inferred `thoughtsync-desktop` target and the explicit one can't both claim +# src/main.rs. +autobins = false # App logic lives in the library (Tauri v2 pattern: a single `run()` entry point # reusable across desktop and any future mobile target); main.rs is a thin shim. @@ -11,6 +15,17 @@ edition = "2021" name = "thoughtsync_desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# The shipped command is `thoughtsync` on every install channel, and the binary's +# own name is what the desktop actually keys on: GTK derives the window's WM_CLASS +# from it, and Tauri's generated .desktop file writes `StartupWMClass={binary}`. +# So the canonical name has to be carried by the build target itself, not just by +# the path it gets installed to (issue 2075). The crate stays `thoughtsync-desktop` +# — only the executable is renamed. `thoughtsync` is also fixed under kebab-casing, +# which the AppImage bundler applies to the binary name on its way in. +[[bin]] +name = "thoughtsync" +path = "src/main.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/desktop/src-tauri/src/integration.rs b/desktop/src-tauri/src/integration.rs index 1d3df8c..1b0824b 100644 --- a/desktop/src-tauri/src/integration.rs +++ b/desktop/src-tauri/src/integration.rs @@ -85,6 +85,11 @@ pub fn integrate_desktop() -> Result { // .desktop launcher -> user applications dir. Icon/Exec are absolute paths, so no // icon-theme lookup is needed. + // + // StartupWMClass is the exception: it must be the BINARY name, because GTK + // derives the window's WM_CLASS from the executable and the desktop matches the + // two to group the taskbar icon. Not the product name, and not this AppImage's + // filename — AppRun execs `usr/bin/thoughtsync` inside it (issue 2075). let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?; if let Some(parent) = entry.parent() { fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?; @@ -98,7 +103,7 @@ pub fn integrate_desktop() -> Result { Icon={icon}\n\ Terminal=false\n\ Categories=Utility;Office;\n\ - StartupWMClass=ThoughtSync\n", + StartupWMClass=thoughtsync\n", exec = installed.to_string_lossy(), icon = icon.to_string_lossy(), ); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index dada888..52db579 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThoughtSync", + "mainBinaryName": "thoughtsync", "version": "0.1.0", "identifier": "com.fabledsword.thoughtsync", "build": {