mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Build an AppImage and a disk image, and publish them
Installing meant cloning the repository and running a shell script, which is a fair ask of somebody who already has a terminal open and no ask at all of anybody else. The releases page now carries an AppImage and a disk image per Mac architecture: a push to master rebuilds a rolling "latest", a v* tag publishes a version and leaves it there, and the Run button in the Actions tab raises the number by running scripts/release.sh, which is the same script and not a second copy of what it does. Two things in the application had to give for that. A build has no __main__.py on disk, and an AppImage is mounted somewhere new every run, so the command a shortcut is registered with cannot go on being this interpreter and this file; ipc.launcher() answers with the AppImage or the bundle instead. And a build carries its own libstdc++, which every process it starts inherits through LD_LIBRARY_PATH and none of them can live with: ffmpeg, ydotool and wl-copy are the distribution's binaries built against the distribution's libraries, and AppImageLauncher, which is what starting the AppImage again goes through, refuses outright. integrate.py puts that variable back before anything else runs. Nothing installs itself over an installation that is already there. install.sh's menu entry, install-mac.sh's login item and the desktop file AppImageLauncher writes are each recognised and left alone, so trying a download once does not quietly move the machine onto it. `dikte integrate` is how you ask for it outright, and --remove takes it back. The disk image carries an ffmpeg, pinned and checksummed, because macOS records through one and ships nothing like it. It is signed ad-hoc and not with an Apple certificate, so a first launch is refused until Open Anyway and the permissions are asked for again after each update; both READMEs and the release notes say so.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""What a downloaded build writes into the desktop it landed on.
|
||||
|
||||
The half worth pinning is the restraint rather than the writing. Three things
|
||||
write these same files, and the AppImage is the one a person is most likely to
|
||||
run once out of curiosity: it has to leave a working installation alone, and it
|
||||
has to notice AppImageLauncher's entry, which is not under the name ours is.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import plistlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import integrate
|
||||
|
||||
|
||||
class Frozen:
|
||||
"""A build, standing in for one. The two facts everything here reads.
|
||||
|
||||
sys.frozen is what PyInstaller sets and nothing else does; APPIMAGE is what
|
||||
the AppImage runtime exports, and is the file rather than the mount.
|
||||
"""
|
||||
|
||||
def __init__(self, executable, appimage=None, home=None, platform=None):
|
||||
self.patches = [
|
||||
mock.patch.object(sys, "executable", executable),
|
||||
mock.patch.object(sys, "frozen", True, create=True),
|
||||
]
|
||||
environment = {"APPIMAGE": appimage} if appimage else {}
|
||||
if home:
|
||||
environment["HOME"] = str(home)
|
||||
environment["XDG_DATA_HOME"] = str(pathlib.Path(home) / ".local/share")
|
||||
environment["XDG_CONFIG_HOME"] = str(pathlib.Path(home) / ".config")
|
||||
self.patches.append(mock.patch.dict(os.environ, environment,
|
||||
clear=bool(home)))
|
||||
if platform:
|
||||
self.patches.append(mock.patch.object(sys, "platform", platform))
|
||||
|
||||
def __enter__(self):
|
||||
for patch in self.patches:
|
||||
patch.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
for patch in reversed(self.patches):
|
||||
patch.stop()
|
||||
|
||||
|
||||
class Home(unittest.TestCase):
|
||||
"""A test with a home directory of its own to be written into."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.home = pathlib.Path(self.tmp.name)
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.applications = self.home / ".local/share/applications"
|
||||
self.autostart = self.home / ".config/autostart"
|
||||
|
||||
def entry(self, name, exec_line, application="Dikte"):
|
||||
self.applications.mkdir(parents=True, exist_ok=True)
|
||||
path = self.applications / name
|
||||
path.write_text(f"[Desktop Entry]\nType=Application\nName={application}\n"
|
||||
f"Exec={exec_line}\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class WhatToStart(unittest.TestCase):
|
||||
def test_a_checkout_names_this_interpreter_and_the_entry_point(self):
|
||||
self.assertFalse(integrate.packaged())
|
||||
|
||||
def test_an_appimage_names_the_file_and_not_the_mount(self):
|
||||
"""The mount is a fresh /tmp path every run; a shortcut written to it
|
||||
would work until the next login and never again."""
|
||||
with Frozen("/tmp/.mount_Dikte1a/usr/bin/dikte",
|
||||
appimage="/home/someone/Downloads/Dikte.AppImage"):
|
||||
self.assertEqual(str(integrate.target()),
|
||||
"/home/someone/Downloads/Dikte.AppImage")
|
||||
|
||||
def test_a_mac_names_the_bundle_and_not_the_executable_inside_it(self):
|
||||
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
|
||||
platform="darwin"):
|
||||
self.assertEqual(str(integrate.target()), "/Applications/Dikte.app")
|
||||
|
||||
def test_a_checkout_writes_nothing(self):
|
||||
self.assertEqual(integrate.ensure(), [])
|
||||
|
||||
|
||||
class BundledTools(unittest.TestCase):
|
||||
"""The ffmpeg the disk image carries, and how anything finds it."""
|
||||
|
||||
def test_a_mac_looks_beside_the_bundle_not_beside_the_executable(self):
|
||||
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
|
||||
platform="darwin"):
|
||||
self.assertEqual(str(integrate.bundled_bin()),
|
||||
"/Applications/Dikte.app/Contents/Resources/bin")
|
||||
|
||||
def test_a_checkout_has_none_and_leaves_the_path_alone(self):
|
||||
with mock.patch.dict(os.environ, {"PATH": "/usr/bin"}):
|
||||
self.assertFalse(integrate.add_bundled_tools())
|
||||
self.assertEqual(os.environ["PATH"], "/usr/bin")
|
||||
|
||||
def test_the_directory_goes_in_front(self):
|
||||
"""In front, so that a Mac with its own ffmpeg from Homebrew still gets
|
||||
the build these format strings are known to work against."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tools = pathlib.Path(tmp) / "bin"
|
||||
tools.mkdir()
|
||||
with Frozen(str(pathlib.Path(tmp) / "dikte")), \
|
||||
mock.patch.dict(os.environ, {"PATH": "/usr/bin"}):
|
||||
self.assertTrue(integrate.add_bundled_tools())
|
||||
self.assertEqual(os.environ["PATH"], f"{tools}{os.pathsep}/usr/bin")
|
||||
|
||||
def test_a_build_carrying_nothing_leaves_the_path_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with Frozen(str(pathlib.Path(tmp) / "dikte")), \
|
||||
mock.patch.dict(os.environ, {"PATH": "/usr/bin"}):
|
||||
self.assertFalse(integrate.add_bundled_tools())
|
||||
|
||||
|
||||
class LibraryPath(unittest.TestCase):
|
||||
"""What a build hands to every process it starts.
|
||||
|
||||
The one that catches it first is the AppImage starting itself again, which
|
||||
is what the command line does when no instance is running: that goes back
|
||||
through AppImageLauncher, a system binary, which will not load against the
|
||||
libstdc++ the build was made with. ffmpeg, ydotool and wl-copy are the same
|
||||
problem arriving later and harder to trace.
|
||||
"""
|
||||
|
||||
def test_what_was_there_before_is_put_back(self):
|
||||
with mock.patch.dict(os.environ, {"LD_LIBRARY_PATH": "/tmp/.mount_x/_internal",
|
||||
"LD_LIBRARY_PATH_ORIG": "/opt/cuda/lib"}):
|
||||
self.assertTrue(integrate.restore_library_path())
|
||||
self.assertEqual(os.environ["LD_LIBRARY_PATH"], "/opt/cuda/lib")
|
||||
self.assertNotIn("LD_LIBRARY_PATH_ORIG", os.environ)
|
||||
|
||||
def test_nothing_there_before_means_unset_rather_than_empty(self):
|
||||
"""An empty LD_LIBRARY_PATH is not the same as none: the loader reads it
|
||||
as the current directory."""
|
||||
with mock.patch.dict(os.environ, {"LD_LIBRARY_PATH": "/tmp/.mount_x/_internal"}):
|
||||
self.assertTrue(integrate.restore_library_path())
|
||||
self.assertNotIn("LD_LIBRARY_PATH", os.environ)
|
||||
|
||||
def test_a_mac_has_its_own_name_for_it(self):
|
||||
with mock.patch.dict(os.environ, {"DYLD_LIBRARY_PATH": "/Dikte.app/Contents/Frameworks"}):
|
||||
self.assertTrue(integrate.restore_library_path())
|
||||
self.assertNotIn("DYLD_LIBRARY_PATH", os.environ)
|
||||
|
||||
def test_a_checkout_has_nothing_to_put_back(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertFalse(integrate.restore_library_path())
|
||||
|
||||
|
||||
class Linux(Home):
|
||||
def install(self, appimage, force=False):
|
||||
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
|
||||
home=self.home, platform="linux"):
|
||||
return integrate.install(force=force)
|
||||
|
||||
def test_it_writes_a_menu_entry_an_autostart_entry_and_the_command(self):
|
||||
appimage = self.home / "Downloads" / "Dikte.AppImage"
|
||||
appimage.parent.mkdir(parents=True)
|
||||
appimage.touch()
|
||||
self.install(appimage)
|
||||
|
||||
menu = (self.applications / "dikte.desktop").read_text(encoding="utf-8")
|
||||
self.assertIn(f"Exec={appimage}", menu)
|
||||
self.assertIn("Categories=", menu)
|
||||
autostart = (self.autostart / "dikte.desktop").read_text(encoding="utf-8")
|
||||
self.assertIn(f"Exec={appimage}", autostart)
|
||||
self.assertNotIn("Categories=", autostart)
|
||||
self.assertEqual(os.readlink(self.home / ".local/bin/dikte"), str(appimage))
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
self.install(appimage)
|
||||
self.assertEqual(self.install(appimage), [])
|
||||
|
||||
def test_moving_the_appimage_rewrites_the_entries(self):
|
||||
"""The run after a move is the only moment a stale entry can be
|
||||
noticed, which is why this is done on every start."""
|
||||
first, second = self.home / "a.AppImage", self.home / "b.AppImage"
|
||||
first.touch()
|
||||
self.install(first)
|
||||
first.rename(second)
|
||||
self.install(second)
|
||||
self.assertIn(f"Exec={second}",
|
||||
(self.applications / "dikte.desktop").read_text())
|
||||
self.assertEqual(os.readlink(self.home / ".local/bin/dikte"), str(second))
|
||||
|
||||
def test_it_stands_aside_for_a_checkout_that_install_sh_set_up(self):
|
||||
checkout = self.home / "src" / "dikte" / "__main__.py"
|
||||
checkout.parent.mkdir(parents=True)
|
||||
checkout.touch()
|
||||
self.entry("dikte.desktop", f"/usr/bin/python3 {checkout}")
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
|
||||
self.assertEqual(self.install(appimage), [])
|
||||
self.assertIn(str(checkout),
|
||||
(self.applications / "dikte.desktop").read_text())
|
||||
self.assertFalse((self.autostart / "dikte.desktop").exists())
|
||||
|
||||
def test_it_stands_aside_for_appimagelauncher(self):
|
||||
"""Which writes appimagekit_<hash>-dikte.desktop rather than ours, and
|
||||
moves the file, so ours beside it would be a second Dikte in the menu
|
||||
naming somewhere the AppImage no longer is."""
|
||||
moved = self.home / "Applications" / "Dikte_abc.AppImage"
|
||||
moved.parent.mkdir(parents=True)
|
||||
moved.touch()
|
||||
self.entry("appimagekit_abc-dikte.desktop", str(moved))
|
||||
appimage = self.home / "Downloads" / "Dikte.AppImage"
|
||||
appimage.parent.mkdir(parents=True)
|
||||
appimage.touch()
|
||||
|
||||
self.assertEqual(self.install(appimage), [])
|
||||
|
||||
def test_an_entry_naming_a_file_that_is_gone_is_not_in_the_way(self):
|
||||
self.entry("dikte.desktop", "/removed/last/week/Dikte.AppImage")
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
self.assertTrue(self.install(appimage))
|
||||
|
||||
def test_asking_outright_overrules_all_of_that(self):
|
||||
checkout = self.home / "src" / "__main__.py"
|
||||
checkout.parent.mkdir(parents=True)
|
||||
checkout.touch()
|
||||
self.entry("dikte.desktop", f"/usr/bin/python3 {checkout}")
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
|
||||
self.assertTrue(self.install(appimage, force=True))
|
||||
self.assertIn(str(appimage),
|
||||
(self.applications / "dikte.desktop").read_text())
|
||||
|
||||
def test_a_command_somebody_else_put_there_is_left_alone(self):
|
||||
"""install.sh points it into a checkout, and that checkout is a working
|
||||
installation this has no business redirecting."""
|
||||
command = self.home / ".local/bin/dikte"
|
||||
command.parent.mkdir(parents=True)
|
||||
command.write_text("#!/bin/sh\nexec python3 /somewhere/__main__.py\n")
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
|
||||
self.install(appimage)
|
||||
self.assertIn("/somewhere/__main__.py", command.read_text())
|
||||
|
||||
def test_a_path_with_a_space_in_it_is_quoted(self):
|
||||
appimage = self.home / "My Programs" / "Dikte.AppImage"
|
||||
appimage.parent.mkdir(parents=True)
|
||||
appimage.touch()
|
||||
self.install(appimage)
|
||||
self.assertIn(f'Exec="{appimage}"',
|
||||
(self.applications / "dikte.desktop").read_text())
|
||||
|
||||
def test_removing_takes_back_what_it_wrote(self):
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
self.install(appimage)
|
||||
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
|
||||
home=self.home, platform="linux"):
|
||||
integrate.remove()
|
||||
self.assertFalse((self.applications / "dikte.desktop").exists())
|
||||
self.assertFalse((self.autostart / "dikte.desktop").exists())
|
||||
self.assertFalse((self.home / ".local/bin/dikte").is_symlink())
|
||||
|
||||
def test_removing_leaves_a_command_that_is_not_ours(self):
|
||||
command = self.home / ".local/bin/dikte"
|
||||
command.parent.mkdir(parents=True)
|
||||
command.symlink_to("/somewhere/dikte/__main__.py")
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
|
||||
home=self.home, platform="linux"):
|
||||
integrate.remove()
|
||||
self.assertTrue(command.is_symlink())
|
||||
|
||||
def test_a_home_it_cannot_write_to_is_not_a_reason_to_refuse_to_start(self):
|
||||
appimage = self.home / "Dikte.AppImage"
|
||||
appimage.touch()
|
||||
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
|
||||
home=self.home, platform="linux"), \
|
||||
mock.patch.object(integrate, "_linux_install",
|
||||
side_effect=PermissionError):
|
||||
self.assertEqual(integrate.ensure(), [])
|
||||
|
||||
|
||||
class MacOS(Home):
|
||||
def agent(self):
|
||||
return self.home / "Library/LaunchAgents/io.github.yusufipk.dikte.plist"
|
||||
|
||||
def install(self, app, force=False):
|
||||
with Frozen(str(app / "Contents/MacOS/Dikte"), home=self.home,
|
||||
platform="darwin"), \
|
||||
mock.patch.object(integrate, "_launchctl_reload"):
|
||||
return integrate.install(force=force)
|
||||
|
||||
def test_it_writes_a_login_item_and_the_command(self):
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
self.install(app)
|
||||
|
||||
plist = plistlib.loads(self.agent().read_bytes())
|
||||
self.assertEqual(plist["Label"], integrate.AGENT_ID)
|
||||
# Through `open` rather than the executable, so that the process is one
|
||||
# LaunchServices started and the permissions are the bundle's.
|
||||
self.assertEqual(plist["ProgramArguments"][:2], ["/usr/bin/open", "-a"])
|
||||
self.assertEqual(plist["ProgramArguments"][2], str(app))
|
||||
self.assertFalse(plist["KeepAlive"])
|
||||
|
||||
command = self.home / ".local/bin/dikte"
|
||||
self.assertIn(str(app / "Contents/MacOS/Dikte"), command.read_text())
|
||||
self.assertTrue(os.access(command, os.X_OK))
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
self.install(app)
|
||||
self.assertEqual(self.install(app), [])
|
||||
|
||||
def test_it_stands_aside_for_a_bundle_install_mac_sh_built(self):
|
||||
"""That one goes under ~/Applications, a disk image is dragged to
|
||||
/Applications, and two of them starting at login is one too many."""
|
||||
theirs = self.home / "Applications" / "Dikte.app"
|
||||
theirs.mkdir(parents=True)
|
||||
agent = self.agent()
|
||||
agent.parent.mkdir(parents=True)
|
||||
agent.write_bytes(plistlib.dumps({
|
||||
"Label": integrate.AGENT_ID,
|
||||
"ProgramArguments": ["/usr/bin/open", "-a", str(theirs)],
|
||||
}))
|
||||
|
||||
mine = self.home / "Volumes" / "Dikte.app"
|
||||
(mine / "Contents/MacOS").mkdir(parents=True)
|
||||
self.assertEqual(self.install(mine), [])
|
||||
self.assertIn(str(theirs), agent.read_text(encoding="utf-8"))
|
||||
|
||||
def test_a_login_item_naming_a_bundle_that_is_gone_is_not_in_the_way(self):
|
||||
agent = self.agent()
|
||||
agent.parent.mkdir(parents=True)
|
||||
agent.write_bytes(plistlib.dumps({
|
||||
"Label": integrate.AGENT_ID,
|
||||
"ProgramArguments": ["/usr/bin/open", "-a", "/gone/Dikte.app"],
|
||||
}))
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
self.assertTrue(self.install(app))
|
||||
|
||||
def test_a_command_install_mac_sh_wrote_is_left_alone(self):
|
||||
command = self.home / ".local/bin/dikte"
|
||||
command.parent.mkdir(parents=True)
|
||||
command.write_text("#!/bin/sh\n# Written by install-mac.sh.\n"
|
||||
"exec /opt/homebrew/bin/python3 /src/__main__.py \"$@\"\n")
|
||||
app = self.home / "Applications" / "Dikte.app"
|
||||
(app / "Contents/MacOS").mkdir(parents=True)
|
||||
self.install(app)
|
||||
self.assertIn("install-mac.sh", command.read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user