diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 650b1dc8..bf7c41d3 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -35,6 +35,8 @@ jobs: "$RUNNER_TEMP/actionlint" -color - name: shellcheck run: shellcheck packaging/*.sh packaging/*/*.sh + - name: Linux tarball installer + run: python3 packaging/linux/test_install.py - name: PowerShell shell: pwsh run: | diff --git a/README.md b/README.md index ba8fa9fe..7b543612 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,11 @@ Installers and executables are code-signed. | Flatpak | `filmcraft--linux-x86_64.flatpak` | `filmcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `filmcraft--linux-x86_64.deb` | `filmcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `filmcraft--linux-x86_64.rpm` | `filmcraft--linux-aarch64.rpm` | | -| Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack anywhere | +| Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack and run `./install.sh` | + +The tarball installer places FilmCraft, its command-line tool and desktop integration in +`~/.local`, without administrator permissions. For all users, run +`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation. ### FreeBSD diff --git a/docs/releasing.md b/docs/releasing.md index 10029006..14e699c5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -107,6 +107,15 @@ nor the portable zip needs the Visual C++ redistributable. `.tar.gz` tree. The packages install both programs, the desktop entry, the AppStream metainfo, the icons and the licence files. +The Linux tarball includes `install.sh`. Extract it, open a terminal in the extracted +directory and run `./install.sh` to install both programs and their desktop integration +under `~/.local` (no administrator permissions needed). The application-menu entry uses +the installed binary's absolute path; add `~/.local/bin` to `PATH` for terminal commands. +Run the installer again to update. For a system-wide installation, use +`sudo ./install.sh --prefix /usr/local`. A custom absolute `--prefix` is also supported; +its `share` directory must be in the desktop's data search path to appear in the menu. +Prefixes containing `=`, `%` or control characters are rejected for desktop-launcher compatibility. + The jobs run on `ubuntu-22.04`, the oldest GitHub-hosted image, so the binaries only need glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. The deb (`libc6 (>= 2.35)`) and the rpm (`glibc >= 2.35`) both declare that floor, so older systems refuse the install. Moving diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 00000000..033fe9b7 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Install the extracted Linux tarball. No sudo is needed for the default prefix. +set -euo pipefail + +usage() { + echo 'Usage: ./install.sh [--prefix /absolute/path]' + echo 'Installs FilmCraft and its desktop integration into ~/.local by default.' + echo 'For all users: sudo ./install.sh --prefix /usr/local' +} +fail() { echo "error: $*" >&2; exit 1; } +prefix="${HOME:?HOME is not set}/.local" +while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) + [ "$#" -ge 2 ] || fail '--prefix needs an absolute path' + prefix="$2"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) usage >&2; fail "unknown argument: $1" ;; + esac +done +case "$prefix" in /*) ;; *) fail '--prefix must be an absolute path' ;; esac +prefix="$(realpath -m -- "$prefix")" +# Desktop Exec paths cannot contain '='; launchers also misinterpret '%' in executable names. +[[ "$prefix" != *'='* && "$prefix" != *'%'* && ! "$prefix" =~ [[:cntrl:]] ]] || fail 'prefix cannot contain =, % or control characters' +[ "$prefix" != / ] || fail 'use /usr/local or a user directory, not /' +source_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" +case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; esac +app_id=ai.storyteller.filmcraft +for binary in filmcraft filmcraft-cli; do + [ -f "$source_dir/bin/$binary" ] || fail "missing bin/$binary; extract the complete tarball first" +done +[ -f "$source_dir/share/applications/$app_id.desktop" ] || fail 'missing desktop entry; extract the complete tarball first' + +mkdir -p -- "$prefix/bin" "$prefix/share" +install -m755 -- "$source_dir/bin/filmcraft" "$prefix/bin/filmcraft" +install -m755 -- "$source_dir/bin/filmcraft-cli" "$prefix/bin/filmcraft-cli" +cp -R -- "$source_dir/share/." "$prefix/share/" + +# Use an absolute path so launching from the application menu does not depend on PATH. +# Escape the Exec argument, then the desktop entry's string value (two separate layers). +# https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html +exec_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/[\\"`$]/\\&/g; s/\\/\\\\/g')" +try_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/\\/\\\\/g')" +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + Exec=*) printf 'Exec="%s" %%F\n' "$exec_path" ;; + TryExec=*) printf 'TryExec=%s\n' "$try_path" ;; + *) printf '%s\n' "$line" ;; + esac +done <"$source_dir/share/applications/$app_id.desktop" >"$prefix/share/applications/$app_id.desktop" + +# Cache tools are optional; installing binaries must also work on minimal systems. +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q "$prefix/share/applications" || echo 'warning: desktop cache refresh failed' >&2 +fi +if command -v update-mime-database >/dev/null 2>&1; then + update-mime-database "$prefix/share/mime" || echo 'warning: MIME cache refresh failed' >&2 +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f "$prefix/share/icons/hicolor" || echo 'warning: icon cache refresh failed' >&2 +fi +printf 'Installed FilmCraft in %s\nLaunch it from the application menu or run "%s/bin/filmcraft".\n' "$prefix" "$prefix" +case ":${PATH:-}:" in + *":$prefix/bin:"*) ;; + *) printf 'For terminal commands, add %s/bin to your PATH.\n' "$prefix" ;; +esac diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 1ee72e19..b2d00bc3 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -76,6 +76,7 @@ has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } if has tar; then mkdir -p "$WORK/tar" cp -R "$STAGE/usr" "$WORK/tar/$BASENAME" + install -m755 "$HERE/install.sh" "$WORK/tar/$BASENAME/install.sh" tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" echo "wrote $DIST/$BASENAME.tar.gz" fi diff --git a/packaging/linux/test_install.py b/packaging/linux/test_install.py new file mode 100644 index 00000000..ecaf2252 --- /dev/null +++ b/packaging/linux/test_install.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Exercise the tarball installer with disposable binaries and installation directories.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import time +import unittest + + +HERE = Path(__file__).resolve().parent +APP_ID = "ai.storyteller.filmcraft" + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="filmcraft-install-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bundle = self.root / "extracted bundle" + (self.bundle / "bin").mkdir(parents=True) + self.share = self.bundle / "share" + (self.share / "applications").mkdir(parents=True) + shutil.copyfile(HERE / "install.sh", self.bundle / "install.sh") + shutil.copyfile( + HERE / f"{APP_ID}.desktop", self.share / "applications" / f"{APP_ID}.desktop" + ) + for name in ("filmcraft", "filmcraft-cli"): + (self.bundle / "bin" / name).write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$0" "$@" > "$(dirname "$0")/launched"\n' + ) + for path in ("doc/filmcraft/LICENSE-MIT", "icons/hicolor/test", "mime/packages/test.xml"): + target = self.share / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("fixture\n") + + def install(self, *args): + # Run outside the extracted directory to check that the script finds its own files. + return subprocess.run( + ["bash", str(self.bundle / "install.sh"), *map(str, args)], + cwd=self.root, capture_output=True, text=True, timeout=30, + ) + + def test_install_and_update_preserve_other_applications(self): + prefix = self.root / "user programs" + other = prefix / "share/applications/other.desktop" + other.parent.mkdir(parents=True) + other.write_text("unrelated\n") + for version in ("first", "updated"): + (self.bundle / "share/doc/filmcraft/LICENSE-MIT").write_text(version) + result = self.install("--prefix", prefix) + self.assertEqual(result.returncode, 0, result.stderr) + for name in ("filmcraft", "filmcraft-cli"): + self.assertTrue(os.access(prefix / "bin" / name, os.X_OK)) + self.assertEqual( + (prefix / "bin" / name).read_bytes(), (self.bundle / "bin" / name).read_bytes() + ) + self.assertEqual((prefix / "share/doc/filmcraft/LICENSE-MIT").read_text(), version) + self.assertTrue((prefix / "share/icons/hicolor/test").is_file()) + self.assertTrue((prefix / "share/mime/packages/test.xml").is_file()) + self.assertEqual(other.read_text(), "unrelated\n") + desktop = prefix / "share/applications" / f"{APP_ID}.desktop" + self.assertIn(f'Exec="{prefix}/bin/filmcraft" %F\n', desktop.read_text()) + if shutil.which("desktop-file-validate"): + subprocess.run(["desktop-file-validate", str(desktop)], check=True) + + @unittest.skipUnless(shutil.which("gio"), "gio is needed to exercise the desktop launcher") + def test_desktop_launch_escapes_paths_and_preserves_file_arguments(self): + prefix = self.root / 'programs with "quotes" $dollar `tick` \\slash & more' + result = self.install("--prefix", prefix) + self.assertEqual(result.returncode, 0, result.stderr) + document = self.root / "my film.filmcraft" + document.touch() + desktop = prefix / "share/applications" / f"{APP_ID}.desktop" + subprocess.run(["gio", "launch", str(desktop), str(document)], check=True, timeout=10) + launched = prefix / "bin/launched" + for _ in range(100): + if launched.exists() and launched.read_text().splitlines() == [ + str(prefix / "bin/filmcraft"), str(document) + ]: + break + time.sleep(0.01) + self.assertEqual(launched.read_text().splitlines(), [str(prefix / "bin/filmcraft"), str(document)]) + + def test_invalid_arguments_fail_before_writing(self): + for args in (("--prefix",), ("--prefix", "relative"), ("--prefix", "/"), ("--unknown",), + ("--prefix", "///"), ("--prefix", "/tmp/../"), ("--prefix", str(self.bundle)), + ("--prefix", str(self.bundle / "nested")), + ("--prefix", str(self.root / "bad=path")), + ("--prefix", str(self.root / "bad%path")), + ("--prefix", str(self.root / "bad\npath"))): + with self.subTest(args=args): + self.assertNotEqual(self.install(*args).returncode, 0) + + def test_incomplete_bundle_does_not_install(self): + (self.bundle / "bin/filmcraft-cli").unlink() + prefix = self.root / "destination" + result = self.install("--prefix", prefix) + self.assertNotEqual(result.returncode, 0) + self.assertIn("extract the complete tarball", result.stderr) + self.assertFalse(prefix.exists()) + + def test_help(self): + self.assertEqual(self.install("--help").returncode, 0) + + def test_release_tarball_includes_executable_installer(self): + target = self.root / "target" + binaries = target / "release" + binaries.mkdir(parents=True) + for name in ("filmcraft", "filmcraft-cli"): + binary = binaries / name + binary.write_text("#!/bin/sh\necho 'FilmCraft fixture'\n") + binary.chmod(0o755) + dist = self.root / "dist" + result = subprocess.run( + ["bash", str(HERE / "package.sh"), "--skip-build", "--formats", "tar"], + env={**os.environ, "CARGO_TARGET_DIR": str(target), "DIST": str(dist)}, + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + archives = list(dist.glob("*.tar.gz")) + self.assertEqual(len(archives), 1) + with tarfile.open(archives[0]) as archive: + installer = next(m for m in archive.getmembers() if m.name.endswith("/install.sh")) + self.assertEqual(installer.mode & 0o777, 0o755) + self.assertEqual(archive.extractfile(installer).read(), (HERE / "install.sh").read_bytes()) + + +if __name__ == "__main__": + unittest.main()