Merge pull request #387 from txiki739/fix/linux-tar-installer

#214: install script for the Linux tarball
This commit is contained in:
Brandon Thomas 2026-10-09 09:23:02 -04:00 committed by GitHub
commit d16f9adb0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 216 additions and 1 deletions

View File

@ -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: |

View File

@ -342,7 +342,11 @@ Installers and executables are code-signed.
| Flatpak | `filmcraft-<ver>-linux-x86_64.flatpak` | `filmcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `filmcraft-<ver>-linux-x86_64.deb` | `filmcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-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

View File

@ -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

66
packaging/linux/install.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -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()