Merge pull request #387 from txiki739/fix/linux-tar-installer
#214: install script for the Linux tarball
This commit is contained in:
commit
d16f9adb0a
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue