Files
txiki739andClaude Opus 5.5 ae09ba904c #214: install script for the Linux tarball
The tarball now ships install.sh. It installs filmcraft, filmcraft-cli,
the desktop entry, icons, MIME type, metainfo and docs under ~/.local
without sudo; --prefix takes any absolute path (sudo ./install.sh
--prefix /usr/local for all users). Running it again updates.

The desktop entry's Exec/TryExec point at the installed binary's
absolute path, escaped per the Desktop Entry spec, so the menu entry
works without ~/.local/bin on PATH. The script checks the bundle is
complete before writing, refuses /, prefixes inside the extracted
bundle and characters launchers cannot express (=, %, control
characters), and treats the cache refresh tools as optional.

packaging/linux/test_install.py covers install and update, launching
through gio, bad arguments, an incomplete bundle, --help and a real
package.sh tarball build; Packaging lint runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:54:20 +02:00

134 lines
6.2 KiB
Python

#!/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()