From 0f20f041fd804af283fca65a04e21766992b1c1a Mon Sep 17 00:00:00 2001 From: LHagfoss Date: Sun, 15 Nov 2026 15:25:17 +0100 Subject: [PATCH] fix(desktop): share the engine in Linux system WebKit installs --- .github/workflows/ci.yml | 4 ++ docs/install.md | 43 +++++++++-- scripts/install.sh | 131 +++++++++++++++++++++++++++++----- scripts/test-install-linux.py | 131 ++++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 scripts/test-install-linux.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ceb9014d..34b8c9870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,6 +264,10 @@ jobs: if: needs.changes.outputs.scripts == 'true' run: bash -n scripts/install.sh + - name: Check Linux installer with isolated release assets + if: needs.changes.outputs.scripts == 'true' + run: python3 scripts/test-install-linux.py + - name: Check CI change matcher if: needs.changes.outputs.scripts == 'true' run: bash scripts/test-ci-relevant-changes.sh diff --git a/docs/install.md b/docs/install.md index 1b1f59846..fd36f2bd1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -48,7 +48,8 @@ against the release's `SHA256SUMS`: | --- | --- | --- | | macOS (Apple Silicon) | `RustCode--macos-aarch64.app.zip` | `/Applications/RustCode.app` (`~/Applications` when `/Applications` is not writable) | | Windows x64 | `RustCode--windows-x64-setup.exe` | per-user install in `%LOCALAPPDATA%\RustCode`, Start menu entry | -| Linux x86_64 | `RustCode--linux-x86_64.AppImage` | `~/Applications/RustCode.AppImage`, menu entry and icon in `~/.local/share` | +| Linux x86_64 (system WebKitGTK 4.1 available) | `RustCode--linux-x86_64.tar.gz` | `~/.local/share/rustcode/desktop`, menu entry and shared CLI engine | +| Linux x86_64 (`--appimage`, or no system WebKitGTK) | `RustCode--linux-x86_64.AppImage` | `~/Applications/RustCode.AppImage`, menu entry and icon in `~/.local/share` | | Linux x86_64 (`--deb`) | `RustCode--linux-x86_64.deb` | system package via `apt-get`/`dpkg` (asks for sudo) | The app carries its own engine (`rustcode --acp`) inside the bundle, so it @@ -69,6 +70,8 @@ Options (`curl … | bash -s -- ` on macOS and Linux): | `--no-app` | `-NoApp` or `$env:RUSTCODE_NO_APP = "1"` | CLI only, as before | | `--no-cli` | `-NoCli` or `$env:RUSTCODE_NO_CLI = "1"` | app only | | `--deb` | | Linux: the `.deb` instead of the AppImage | +| `--system-webkit` | | Linux: require system WebKitGTK 4.1 and install the smaller archive | +| `--appimage` | | Linux: use the portable AppImage even with system WebKitGTK installed | | `--bin-dir DIR` | | CLI to `DIR` instead of `/usr/local/bin` or `~/.local/bin` | | `--version 1.31.4` or `$RUSTCODE_VERSION` | `-Version 1.31.4` or `$env:RUSTCODE_VERSION` | that release instead of the latest (`v` optional, a beta like `1.32.0-beta.2` too); a release that does not exist stops with its URL. `rustcode machines add-ssh` uses it to put the other computer on this one's version | @@ -112,10 +115,13 @@ RUSTCODE_INSTALL_ARTIFACTS=/tmp/rc/art bash ../../scripts/install.sh --bin-dir / it with `pkexec apt-get install -y` (or `pkexec dpkg -i`), which asks for your password through polkit, then reopens. Without `pkexec` or a polkit agent, install the new `.deb` yourself (`sudo apt install ./RustCode--linux-x86_64.deb`). +- Linux system-WebKitGTK archive: **Update to X** verifies the archive, + replaces both binaries and reopens. The CLI symlink follows the updated + engine. No root access is needed. - Windows: **Update to X** downloads the setup, checks it, runs it silently (`/S /UPDATE /R`) and quits; the installer replaces the app in place and reopens it. No administrator prompt. -- Other installs (a tarball, Flatpak, a folder RustCode cannot write, arm +- Other installs (an unrecognised folder layout, Flatpak, a folder RustCode cannot write, arm Linux or Windows): the button links to the release; run the installer again to update. @@ -132,6 +138,9 @@ RUSTCODE_INSTALL_ARTIFACTS=/tmp/rc/art bash ../../scripts/install.sh --bin-dir / `~/.local/share/applications/rustcode.desktop` and `~/.local/share/icons/hicolor/128x128/apps/rustcode.png`. - Linux `.deb`: `sudo apt remove rustcode` (the package name `dpkg -l | grep -i rustcode` shows). +- Linux system-WebKitGTK: remove `~/.local/share/rustcode/desktop`, its + `rustcode` CLI symlink, and the menu entry and icon listed for AppImage above. + A custom `XDG_DATA_HOME` replaces `~/.local/share`. ### Taking over from the SwiftUI app @@ -204,8 +213,34 @@ continue to use `brew upgrade rustcode`. ### Arch Linux: system WebKitGTK -For smaller downloads (under 50 MB), install the system web view with -`sudo pacman -S webkit2gtk-4.1`. Download +For smaller downloads (under 50 MB), install the system web view, then run +the installer: + +```sh +sudo pacman -S webkit2gtk-4.1 +curl -fsSL https://rustcode.lhagfoss.com/install.sh | bash -s -- --system-webkit +``` + +The installer checks the release checksum and the app's shared-library +dependencies before installing. By default it selects this archive when +WebKitGTK 4.1 is available; `--system-webkit` requires it and fails with +installation guidance when it is missing. `--appimage` explicitly keeps the +portable bundle. No system packages are installed automatically. + +The app lives in `${XDG_DATA_HOME:-~/.local/share}/rustcode/desktop` with a +menu entry. When the CLI is requested, the installer links it to the app's +engine instead of downloading another copy. A background server installed +from this app also uses that stable engine without copying it. + +To migrate an AppImage installation, run the command above, close the old app +and open RustCode from the applications menu. Reinstall an existing background +server from the new app so it uses the new engine path. After checking that +the app, CLI and server work, remove `~/Applications/RustCode.AppImage` and +the old service engine at `~/.local/share/rustcode/bin/rustcode`. The installer +leaves these files intact because an existing process or service may use them. +`--no-cli` preserves the existing terminal command. + +For a manual installation, download `RustCode--linux-x86_64.tar.gz` and `SHA256SUMS` from the same release, then verify the archive with `sha256sum --ignore-missing -c SHA256SUMS`. Unpack it into a folder you own: diff --git a/scripts/install.sh b/scripts/install.sh index 6d4d5d384..4e6c383f2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,8 +4,8 @@ # curl -fsSL https://rustcode.lhagfoss.com/install.sh | bash -s -- --no-app # # Installs the `rustcode` CLI and the RustCode desktop app (macOS: -# /Applications/RustCode.app; Linux: an AppImage with a menu entry, or the -# .deb with --deb). Every download is checked against the release SHA256SUMS. +# /Applications/RustCode.app; Linux: a system-WebKitGTK app when available, +# otherwise AppImage, or .deb with --deb). Downloads are checksum-verified. # Run with --help for the options; docs/install.md has the details. set -euo pipefail @@ -15,6 +15,8 @@ APP_BUNDLE_ID="com.lhagfoss.rustcode.desktop" INSTALL_CLI=1 INSTALL_APP=1 USE_DEB=0 +LINUX_APP_FORMAT=auto +CLI_SHARED=0 BIN_DIR="" # A release to install instead of the latest one ("1.31.4" or "v1.31.4"). VERSION="${RUSTCODE_VERSION:-}" @@ -26,12 +28,16 @@ usage() { cat <<'USAGE' Install the RustCode CLI and desktop app from the latest GitHub release, or from the release --version names. +Linux uses the smaller system-WebKitGTK app when WebKitGTK 4.1 is available, +otherwise AppImage. The system app and CLI share one engine. Options: --no-app Install only the CLI. --no-cli Install only the desktop app. --deb Linux: install the desktop app's .deb with dpkg (asks for sudo) instead of the AppImage. + --system-webkit Linux: require the smaller app using system WebKitGTK 4.1. + --appimage Linux: bundle the web view instead of using system libraries. --bin-dir DIR Install the CLI to DIR (default /usr/local/bin when writable, else ~/.local/bin). --version X.Y.Z Install that release instead of the latest one. @@ -49,6 +55,8 @@ while [ $# -gt 0 ]; do --no-app) INSTALL_APP=0 ;; --no-cli) INSTALL_CLI=0 ;; --deb) USE_DEB=1 ;; + --system-webkit) LINUX_APP_FORMAT=system ;; + --appimage) LINUX_APP_FORMAT=appimage ;; --bin-dir) [ $# -ge 2 ] || { echo "--bin-dir needs a directory" >&2; exit 2; } BIN_DIR="$2" @@ -66,6 +74,10 @@ while [ $# -gt 0 ]; do esac shift done +if [ "$USE_DEB" = 1 ] && [ "$LINUX_APP_FORMAT" != auto ]; then + echo "--deb cannot be combined with --system-webkit or --appimage." >&2 + exit 2 +fi if [ "$INSTALL_CLI" = 0 ] && [ "$INSTALL_APP" = 0 ]; then echo "Nothing to install: --no-app and --no-cli together." >&2 exit 2 @@ -363,6 +375,19 @@ manifest_asset() { printf '%s\n' "$found" } +select_cli_directory() { + if [ -n "$BIN_DIR" ]; then + INSTALL_DIR="$BIN_DIR" + elif [ -w /usr/local/bin ]; then + INSTALL_DIR=/usr/local/bin + else + INSTALL_DIR="${HOME}/.local/bin" + fi + mkdir -p "$INSTALL_DIR" + INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + TARGET_EXE="${INSTALL_DIR}/rustcode" +} + install_cli() { local archive extracted if ! fetch_verified "$ASSET_NAME"; then @@ -385,17 +410,7 @@ install_cli() { chmod +x "$extracted" - # Target installation directory - if [ -n "$BIN_DIR" ]; then - INSTALL_DIR="$BIN_DIR" - elif [ -w "/usr/local/bin" ]; then - INSTALL_DIR="/usr/local/bin" - else - INSTALL_DIR="${HOME}/.local/bin" - fi - - mkdir -p "$INSTALL_DIR" - TARGET_EXE="${INSTALL_DIR}/rustcode" + select_cli_directory if ! run_with_spinner "Installing to ${TARGET_EXE}..." cp "$extracted" "$TARGET_EXE"; then error "Could not install RustCode to ${TARGET_EXE}." @@ -555,6 +570,73 @@ install_app_macos() { success "RustCode.app installed to ${dest}. Open it from Launchpad, Spotlight or: open -a RustCode" } +system_webkit_available() { + command -v ldconfig >/dev/null 2>&1 || return 1 + # Read the whole stream: grep -q can SIGPIPE ldconfig under pipefail. + ldconfig -p 2>/dev/null | grep 'libwebkit2gtk-4.1.so.0 ' >/dev/null +} + +install_app_linux_system() { + local name="$1" stage target data missing icon pending link_stage + fetch_verified "$name" || error "Could not download ${name}." + stage="${TMP_DIR}/system-app" + mkdir -p "$stage" + tar -xzf "$VERIFIED_PATH" -C "$stage" || error "Could not extract ${name}." + [ -x "$stage/usr/bin/rustcode" ] && [ -x "$stage/usr/bin/rustcode-desktop" ] || error "${name} is missing the app or engine." + # Check the actual release's dependencies, not just the WebKit SONAME. + missing="$(ldd "$stage/usr/bin/rustcode-desktop" 2>&1)" || error "Could not inspect app dependencies: ${missing}" + if printf '%s\n' "$missing" | grep 'not found' >/dev/null; then + error "System libraries are missing: ${missing}. On Arch: sudo pacman -S webkit2gtk-4.1. Or use --appimage." + fi + data="${XDG_DATA_HOME:-${HOME}/.local/share}" + mkdir -p "$data" + data="$(cd "$data" && pwd)" + target="${data}/rustcode/desktop" + mkdir -p "$target/usr/bin" "$data/applications" "$data/icons/hicolor/128x128/apps" + pending="$(mktemp -d "$target/.install.XXXXXX")" + # Rename each executable rather than overwriting a running file or following + # an old symlink. The updater recognises this usr/bin layout. + for name in rustcode rustcode-desktop; do + install -m 755 "$stage/usr/bin/$name" "$pending/$name" + done + for name in rustcode rustcode-desktop; do + mv -fT "$pending/$name" "$target/usr/bin/$name" + done + rmdir "$pending" + icon="$(find "$stage/usr/share/icons" -path '*128x128/apps/*.png' 2>/dev/null | head -n 1 || true)" + [ -z "$icon" ] || cp "$icon" "$data/icons/hicolor/128x128/apps/rustcode.png" + cat > "$data/applications/rustcode.desktop" </dev/null 2>&1 && update-desktop-database "$data/applications" >/dev/null 2>&1 || true + if [ "$INSTALL_CLI" = 1 ]; then + select_cli_directory + link_stage="$(mktemp -d "$INSTALL_DIR/.rustcode-link.XXXXXX")" + ln -s "$target/usr/bin/rustcode" "$link_stage/rustcode" + mv -fT "$link_stage/rustcode" "$TARGET_EXE" + rmdir "$link_stage" + CLI_SHARED=1 + success "CLI linked to the app's engine at ${TARGET_EXE}; no separate CLI download needed." + case ":$PATH:" in + *":${INSTALL_DIR}:"*) ;; + *) warn "Add ${INSTALL_DIR} to your PATH to run rustcode from the terminal." ;; + esac + fi + success "RustCode installed to ${target}, using system WebKitGTK and a menu entry." + if [ -f "$HOME/Applications/RustCode.AppImage" ]; then + warn "The old AppImage is still at ~/Applications/RustCode.AppImage. Close it and launch RustCode from the menu to use this installation; remove the old AppImage after verifying it works." + warn "If you installed a background server from the AppImage, reinstall that server from the new app before removing its old engine copy." + fi +} + install_app_linux() { local machine name file machine="$TARGET_ARCH" @@ -578,6 +660,20 @@ install_app_linux() { return 0 fi + if [ "$LINUX_APP_FORMAT" != appimage ]; then + if system_webkit_available; then + if name="$(manifest_asset "^RustCode-[0-9][^ ]*-linux-${machine}\\.tar\\.gz\$")"; then + install_app_linux_system "$name" + return 0 + elif [ "$LINUX_APP_FORMAT" = system ]; then + error "Release ${LATEST_TAG} has no system-WebKitGTK app for Linux ${machine}." + fi + elif [ "$LINUX_APP_FORMAT" = system ]; then + error "WebKitGTK 4.1 is required. On Arch: sudo pacman -S webkit2gtk-4.1. Then rerun this installer, or use --appimage." + fi + info "Using AppImage; install system WebKitGTK 4.1 and rerun with --system-webkit for smaller downloads." + fi + if ! name="$(manifest_asset "^RustCode-[0-9][^ ]*-linux-${machine}\\.AppImage\$")"; then warn "Release ${LATEST_TAG} has no desktop AppImage for Linux ${machine}; skipping the app." return 0 @@ -621,15 +717,18 @@ DESKTOP fi } -if [ "$INSTALL_CLI" = 1 ]; then +# Linux's system-WebKitGTK app provides the CLI too. Install it first so we +# avoid downloading another engine. macOS retains its existing install order. +if [ "$INSTALL_APP" = 1 ] && [ "$TARGET_OS" = linux ]; then + install_app_linux +fi +if [ "$INSTALL_CLI" = 1 ] && [ "$CLI_SHARED" = 0 ]; then install_cli fi if [ "$INSTALL_APP" = 1 ]; then if [ "$TARGET_OS" = "macos" ]; then install_app_macos - else - install_app_linux fi fi diff --git a/scripts/test-install-linux.py b/scripts/test-install-linux.py new file mode 100644 index 000000000..9ed1bc362 --- /dev/null +++ b/scripts/test-install-linux.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Exercise Linux installation with verified local assets and no real user data.""" +import hashlib +import io +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest + +INSTALLER = Path(__file__).resolve().with_name("install.sh") + + +class LinuxInstaller(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="rustcode-install-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / "home with spaces" + self.home.mkdir() + self.assets = self.root / "assets" + self.assets.mkdir() + self.commands = self.root / "commands" + self.commands.mkdir() + self.command("uname", 'case "$1" in -s) echo Linux;; -m) echo x86_64;; esac') + self.command("ldconfig", 'echo "libwebkit2gtk-4.1.so.0 (libc6,x86-64) => /usr/lib/libwebkit2gtk-4.1.so.0"') + self.command("ldd", 'echo "libc.so.6 => /usr/lib/libc.so.6"') + self.command("update-desktop-database", "exit 0") + self.archive("RustCode-1.2.3-linux-x86_64.tar.gz", { + "./usr/bin/rustcode": b"#!/bin/sh\necho shared-engine\n", + "./usr/bin/rustcode-desktop": b"#!/bin/sh\nexit 0\n", + }) + self.appimage = self.assets / "RustCode-1.2.3-linux-x86_64.AppImage" + self.appimage.write_text("#!/bin/sh\nexit 1\n") + # Deliberately no standalone CLI archive: shared installs must not fetch it. + self.manifest() + + def command(self, name, body): + path = self.commands / name + path.write_text("#!/bin/sh\n" + body + "\n") + path.chmod(0o755) + + def archive(self, name, files): + with tarfile.open(self.assets / name, "w:gz") as archive: + for path, content in files.items(): + entry = tarfile.TarInfo(path) + entry.mode = 0o755 + entry.size = len(content) + archive.addfile(entry, io.BytesIO(content)) + + def manifest(self): + entries = [] + for path in self.assets.iterdir(): + if path.name != "SHA256SUMS": + entries.append(hashlib.sha256(path.read_bytes()).hexdigest() + " " + path.name) + (self.assets / "SHA256SUMS").write_text("\n".join(entries) + "\n") + + def run_installer(self, *args, success=True): + env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.home / "data"), + RUSTCODE_INSTALL_ARTIFACTS=str(self.assets), + PATH=str(self.commands) + os.pathsep + os.environ["PATH"]) + result = subprocess.run(["bash", str(INSTALLER), "--bin-dir", str(self.home / "bin"), *args], + env=env, text=True, capture_output=True, timeout=20) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + return result.stdout + result.stderr + + @property + def engine(self): + return self.home / "data/rustcode/desktop/usr/bin/rustcode" + + def test_auto_shares_engine_and_preserves_link_on_reinstall(self): + self.run_installer() + cli = self.home / "bin/rustcode" + self.assertTrue(cli.is_symlink()) + self.assertEqual(cli.resolve(), self.engine) + self.assertEqual(subprocess.check_output([str(cli)], text=True).strip(), "shared-engine") + self.run_installer() + self.assertTrue(cli.is_symlink()) + entry = (self.home / "data/applications/rustcode.desktop").read_text() + self.assertIn('Exec="' + str(self.engine.with_name("rustcode-desktop")) + '" %U', entry) + + def test_no_cli_preserves_existing_cli(self): + cli = self.home / "bin/rustcode" + cli.parent.mkdir() + cli.write_text("existing CLI") + self.run_installer("--no-cli", "--system-webkit") + self.assertEqual(cli.read_text(), "existing CLI") + self.assertTrue(self.engine.exists()) + + def test_missing_webkit_is_actionable_and_does_not_install(self): + self.command("ldconfig", "exit 0") + output = self.run_installer("--system-webkit", success=False) + self.assertIn("sudo pacman -S webkit2gtk-4.1", output) + self.assertFalse(self.engine.exists()) + self.assertFalse((self.home / "bin/rustcode").exists()) + + def test_missing_transitive_dependency_stops_before_install(self): + self.command("ldd", 'echo "libexample.so => not found"') + self.run_installer("--system-webkit", success=False) + self.assertFalse(self.engine.exists()) + + def test_checksum_failure_stops_before_install(self): + with (self.assets / "RustCode-1.2.3-linux-x86_64.tar.gz").open("ab") as archive: + archive.write(b"tampered") + self.assertIn("SHA-256 mismatch", self.run_installer("--system-webkit", success=False)) + self.assertFalse(self.engine.exists()) + + def test_incomplete_archive_stops_before_install(self): + self.archive("RustCode-1.2.3-linux-x86_64.tar.gz", {"./usr/bin/rustcode": b"engine"}) + self.manifest() + self.run_installer("--system-webkit", success=False) + self.assertFalse(self.engine.exists()) + + def test_explicit_appimage_and_auto_fallback(self): + self.run_installer("--appimage", "--no-cli") + self.assertEqual((self.home / "Applications/RustCode.AppImage").read_bytes(), self.appimage.read_bytes()) + self.assertFalse(self.engine.exists()) + self.command("ldconfig", "exit 0") + self.run_installer("--no-cli") + self.assertFalse(self.engine.exists()) + + def test_old_release_without_tarball_uses_appimage(self): + (self.assets / "RustCode-1.2.3-linux-x86_64.tar.gz").unlink() + self.manifest() + self.run_installer("--no-cli") + self.assertTrue((self.home / "Applications/RustCode.AppImage").exists()) + + +if __name__ == "__main__": + unittest.main()