mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-12 03:16:19 +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:
@@ -4,3 +4,10 @@ The package is the application. Nothing is imported here on purpose: `dikte
|
||||
config get` runs through the same package as the tray icon does, and it has no
|
||||
business loading Qt to answer one question.
|
||||
"""
|
||||
|
||||
# The one place the number is written down. scripts/release.sh rewrites this
|
||||
# line and tags the commit, the release workflow reads the tag back out, and
|
||||
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
|
||||
# build off master rather than off a tag appends the commit to it, so that a
|
||||
# bug report from someone running "latest" names a commit.
|
||||
__version__ = "1.0.0"
|
||||
|
||||
+8
-1
@@ -44,6 +44,7 @@ from . import config as cfg # noqa: E402
|
||||
from . import ggml # noqa: E402
|
||||
from . import hotkey # noqa: E402
|
||||
from . import i18n # noqa: E402
|
||||
from . import integrate # noqa: E402
|
||||
from . import ipc # noqa: E402
|
||||
from . import meeting # noqa: E402
|
||||
from . import trayicon # noqa: E402
|
||||
@@ -966,7 +967,8 @@ class Dikte:
|
||||
self.settings_window.close()
|
||||
self.shutdown()
|
||||
QLocalServer.removeServer(SERVER_NAME)
|
||||
os.execv(sys.executable, [sys.executable, ipc.script_path(), "--gui"])
|
||||
args = ipc.launcher() + ["--gui"]
|
||||
os.execv(args[0], args)
|
||||
|
||||
def shutdown(self):
|
||||
self._quitting = True
|
||||
@@ -1102,6 +1104,11 @@ def run_app(args):
|
||||
app.setWindowIcon(trayicon.app_icon())
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
_stay_out_of_the_dock()
|
||||
# A downloaded build ran no installer, so it writes its own menu entry,
|
||||
# login item and icon. Here rather than in main() because drawing that icon
|
||||
# needs the QApplication above, and quiet because there is nothing to say
|
||||
# on every start after the first.
|
||||
integrate.ensure()
|
||||
# Before Dikte is built, because building it is what may start a server, and
|
||||
# a signal arriving in the middle of that would otherwise take the default
|
||||
# action and leave the server behind. A signal this early lands in the
|
||||
|
||||
+34
-2
@@ -30,8 +30,10 @@ from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import hotkey
|
||||
from . import ipc
|
||||
from . import integrate
|
||||
from . import meeting
|
||||
from . import paste
|
||||
from . import __version__
|
||||
|
||||
NOT_RUNNING = 3
|
||||
|
||||
@@ -130,11 +132,11 @@ def _ask_instance(opts, cmd, wait=False, **args):
|
||||
|
||||
def launch_gui(verb=""):
|
||||
"""No instance running, so become the application itself."""
|
||||
args = [sys.executable, ipc.script_path()]
|
||||
args = ipc.launcher()
|
||||
if verb:
|
||||
args.append(verb)
|
||||
args.append("--gui")
|
||||
os.execv(sys.executable, args)
|
||||
os.execv(args[0], args)
|
||||
|
||||
|
||||
def _not_running(opts):
|
||||
@@ -753,6 +755,28 @@ def cmd_shortcut(opts):
|
||||
message)
|
||||
|
||||
|
||||
def cmd_integrate(opts):
|
||||
"""Write, or take away, the launchers a downloaded build installs itself.
|
||||
|
||||
Run for you on every start, so this is for the two cases that start does
|
||||
not cover: undoing it, and repairing it from a terminal after the AppImage
|
||||
was moved while Dikte was not running.
|
||||
"""
|
||||
if not integrate.packaged():
|
||||
return fail(opts, "this is a checkout, not a downloaded build; "
|
||||
"./install.sh writes those files here", 2)
|
||||
try:
|
||||
# force, because typing this is asking for it outright, where the same
|
||||
# call on every start stands aside for an installation already there.
|
||||
paths = integrate.remove() if opts.remove else integrate.install(force=True)
|
||||
except OSError as exc:
|
||||
return fail(opts, exc)
|
||||
verb = "Removed" if opts.remove else "Wrote"
|
||||
listing = "\n".join(f" {path}" for path in paths)
|
||||
return out(opts, {"ok": True, "paths": [str(path) for path in paths]},
|
||||
f"{verb}:\n{listing}" if paths else "Nothing to change.")
|
||||
|
||||
|
||||
def cmd_status(opts):
|
||||
reply = ipc.send("status")
|
||||
if reply is None:
|
||||
@@ -847,6 +871,8 @@ def build_parser():
|
||||
help="print the answer as one JSON object")
|
||||
parser.add_argument("-q", "--quiet", action="store_true",
|
||||
help="keep progress lines off stderr")
|
||||
parser.add_argument("--version", action="version", version=f"dikte {__version__}",
|
||||
help="print the version and exit")
|
||||
parser.set_defaults(verb="", timeout=0, func=cmd_plain)
|
||||
subs = parser.add_subparsers(dest="verb", metavar="COMMAND")
|
||||
|
||||
@@ -1033,6 +1059,12 @@ def build_parser():
|
||||
choices=tuple(hotkey.SHORTCUTS))
|
||||
remove.set_defaults(func=cmd_shortcut)
|
||||
|
||||
integrated = leaf(subs, "integrate",
|
||||
"menu entry, login item and command, for a downloaded build")
|
||||
integrated.add_argument("--remove", action="store_true",
|
||||
help="take them away again")
|
||||
integrated.set_defaults(func=cmd_integrate)
|
||||
|
||||
# --- the application --------------------------------------------------
|
||||
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
||||
for name, help_text in (("settings", "open the settings window"),
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Putting a downloaded build into the desktop it landed on.
|
||||
|
||||
A checkout has install.sh for this: the menu entry, the entry that starts Dikte
|
||||
at login, the icon both of those name, and the `dikte` command. Somebody who
|
||||
downloaded an AppImage or dragged Dikte.app out of a disk image ran no
|
||||
installer at all, so the application writes those files itself, on its first
|
||||
run and again whenever the file it was started from has moved.
|
||||
|
||||
Nothing here runs from a checkout. install.sh has already written the same
|
||||
files there, pointing at the interpreter that checkout was installed against,
|
||||
and overwriting them with a guess would be a downgrade.
|
||||
|
||||
Everything is written from the path Dikte is running as, which is why it is
|
||||
also run again on every start rather than once: an AppImage that was moved out
|
||||
of ~/Downloads leaves behind a menu entry naming a file that is no longer
|
||||
there, and the run after the move is the only moment that can be noticed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import plistlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# The same identifier install-mac.sh uses, because it is the name of the login
|
||||
# item and both must mean the one thing when a Mac has been installed to twice.
|
||||
AGENT_ID = "io.github.yusufipk.dikte"
|
||||
ICON_NAME = "dikte"
|
||||
DESKTOP_FILE = "dikte.desktop"
|
||||
|
||||
|
||||
def packaged():
|
||||
"""Whether this is one of the built downloads rather than a checkout."""
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def target():
|
||||
"""The file a launcher has to name to start this build again.
|
||||
|
||||
The AppImage itself, or Dikte.app, rather than the executable inside
|
||||
either: the mount an AppImage runs from is gone by the next login, and a
|
||||
Mac starts an application through its bundle.
|
||||
"""
|
||||
if os.environ.get("APPIMAGE"):
|
||||
return pathlib.Path(os.environ["APPIMAGE"])
|
||||
executable = pathlib.Path(sys.executable).resolve()
|
||||
if sys.platform == "darwin":
|
||||
for parent in executable.parents:
|
||||
if parent.suffix == ".app":
|
||||
return parent
|
||||
return executable
|
||||
|
||||
|
||||
# The two the dynamic loader reads, and what PyInstaller renames the old value
|
||||
# to when it takes one over. Only the platform's own is ever set, so looking
|
||||
# for both costs nothing and keeps the two builds saying the same thing.
|
||||
LIBRARY_PATHS = ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH")
|
||||
|
||||
|
||||
def restore_library_path():
|
||||
"""Put the loader's environment back to what it was. Whether it had moved.
|
||||
|
||||
A build points this at the libraries it carries, and every process started
|
||||
from it inherits that. Ours are the wrong libraries for anything else on
|
||||
the machine: ffmpeg, ydotool, wl-copy and pactl are the distribution's own
|
||||
binaries built against the distribution's libstdc++, and handed the copy
|
||||
from the machine this was built on they refuse to start. So does
|
||||
AppImageLauncher, which is what running the AppImage again goes through,
|
||||
and running it again is how the command line becomes the application.
|
||||
|
||||
Safe to do here because nothing of ours is looked up this way. The
|
||||
libraries this process runs on are loaded before any of this code does, and
|
||||
the ones Qt opens later, its platform plugins and image formats, are found
|
||||
through the RPATH written into them.
|
||||
"""
|
||||
moved = False
|
||||
for name in LIBRARY_PATHS:
|
||||
if name not in os.environ:
|
||||
continue
|
||||
original = os.environ.pop(name + "_ORIG", None)
|
||||
# No _ORIG means there was nothing there to put back: the variable is
|
||||
# the build's own, and what the machine expects is for it to be unset.
|
||||
if original:
|
||||
os.environ[name] = original
|
||||
else:
|
||||
del os.environ[name]
|
||||
moved = True
|
||||
return moved
|
||||
|
||||
|
||||
def bundled_bin():
|
||||
"""Where a build keeps the helper programs it carries, if it carries any.
|
||||
|
||||
The disk image ships an ffmpeg because macOS records through one and has
|
||||
nothing like it preinstalled, so a Mac that downloaded Dikte and nothing
|
||||
else would otherwise not be able to record at all. The AppImage carries
|
||||
none: Linux records through parec or pw-record, which come with the sound
|
||||
server, and the distributions all package ffmpeg for the rest.
|
||||
"""
|
||||
binary = pathlib.Path(sys.executable).parent
|
||||
if sys.platform == "darwin" and binary.name == "MacOS":
|
||||
return binary.parent / "Resources" / "bin"
|
||||
return binary / "bin"
|
||||
|
||||
|
||||
def add_bundled_tools():
|
||||
"""Put that directory in front of PATH. Whether there was one.
|
||||
|
||||
Everything that reaches for ffmpeg goes through shutil.which, so this is
|
||||
the whole of the arrangement. In front rather than behind on purpose: a Mac
|
||||
with its own ffmpeg from Homebrew still gets ours, which is the build the
|
||||
format strings in audio.py and filetranscribe.py are known to work against.
|
||||
"""
|
||||
directory = bundled_bin() if packaged() else None
|
||||
if directory is None or not directory.is_dir():
|
||||
return False
|
||||
os.environ["PATH"] = f"{directory}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return True
|
||||
|
||||
|
||||
def ensure():
|
||||
"""Write whatever is missing or out of date. The paths that changed.
|
||||
|
||||
Called on every start of a packaged build, and quiet when there is nothing
|
||||
to do, so that the cost of being started from a new location is one run
|
||||
with the wrong shortcuts rather than a reinstall.
|
||||
"""
|
||||
if not packaged():
|
||||
return []
|
||||
try:
|
||||
return install()
|
||||
except OSError:
|
||||
# A read-only home, a full disk, a $HOME that is not ours. None of it
|
||||
# is a reason to refuse to start: Dikte works without a menu entry.
|
||||
return []
|
||||
|
||||
|
||||
def install(force=False):
|
||||
"""Write the launchers for this platform. The paths that changed.
|
||||
|
||||
An installation that is already on the machine and still works is left
|
||||
alone unless `force`, which is what typing `dikte integrate` means. Three
|
||||
other things write these same files: install.sh for a checkout, its macOS
|
||||
half, and AppImageLauncher, which many desktops ship and which writes an
|
||||
entry of its own the first time an AppImage is run. Writing over any of
|
||||
them because somebody tried a download once would move the machine onto
|
||||
that download without saying so, and take the menu entry down with it when
|
||||
the file is deleted again.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_install(target(), force)
|
||||
return _linux_install(target(), force)
|
||||
|
||||
|
||||
def remove():
|
||||
"""Take them away again. The paths that were there to delete."""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_remove()
|
||||
return _linux_remove()
|
||||
|
||||
|
||||
# --- the files ------------------------------------------------------------
|
||||
|
||||
def _xdg(var, default):
|
||||
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
||||
|
||||
|
||||
def _paths():
|
||||
data = _xdg("XDG_DATA_HOME", "~/.local/share")
|
||||
return {
|
||||
"menu": data / "applications" / DESKTOP_FILE,
|
||||
"autostart": _xdg("XDG_CONFIG_HOME", "~/.config") / "autostart" / DESKTOP_FILE,
|
||||
"icons": data / "icons",
|
||||
"command": pathlib.Path.home() / ".local" / "bin" / "dikte",
|
||||
}
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
"""Write it if it says something else. Whether it was written.
|
||||
|
||||
The comparison is the point rather than an optimisation: these are read at
|
||||
login, and rewriting an unchanged autostart entry on every start is a
|
||||
modification time that backup tools and the desktop both notice.
|
||||
"""
|
||||
if path.exists() and path.read_text(encoding="utf-8") == text:
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _exec_field(*args):
|
||||
"""One Exec= line. A path with a space in it is what this is for.
|
||||
|
||||
The desktop entry specification quotes with double quotes and escapes with
|
||||
a backslash, which is close enough to POSIX that shlex gets the hard part
|
||||
right, and the difference only shows up in characters no download path has.
|
||||
"""
|
||||
return " ".join(
|
||||
f'"{arg}"' if any(c in arg for c in ' \t"\\$`') else arg
|
||||
for arg in args
|
||||
)
|
||||
|
||||
|
||||
def _desktop_entry(command, autostart=False):
|
||||
lines = [
|
||||
"[Desktop Entry]",
|
||||
"Type=Application",
|
||||
"Name=Dikte",
|
||||
f"Exec={command}",
|
||||
f"Icon={ICON_NAME}",
|
||||
"StartupNotify=false",
|
||||
]
|
||||
if autostart:
|
||||
lines.insert(4, "X-GNOME-Autostart-enabled=true")
|
||||
else:
|
||||
lines.insert(4, "Comment=Voice dictation: record, transcribe, clean up, paste")
|
||||
lines.insert(6, "Categories=Utility;AudioVideo;")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _exec_targets(entry):
|
||||
"""The files an Exec= line names, out of a desktop entry's text.
|
||||
|
||||
The specification quotes with double quotes and escapes with a backslash,
|
||||
which is close enough to a shell that shlex gets the hard part right and
|
||||
the difference only shows up in characters no install path has.
|
||||
"""
|
||||
for line in entry.splitlines():
|
||||
if line.startswith("Exec="):
|
||||
try:
|
||||
return shlex.split(line[len("Exec="):])
|
||||
except ValueError:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _another_dikte(directory, mine):
|
||||
"""A menu entry for an installation that is not this one and still works.
|
||||
|
||||
Read across the whole directory rather than at our own file name, because
|
||||
AppImageLauncher does not use it: it writes appimagekit_<hash>-dikte.desktop
|
||||
and moves the AppImage under ~/Applications, and ours beside it would be a
|
||||
second Dikte in the menu naming a file that has been moved away.
|
||||
"""
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
for path in sorted(directory.glob("*.desktop")):
|
||||
try:
|
||||
entry = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if "\nName=Dikte" not in "\n" + entry:
|
||||
continue
|
||||
words = _exec_targets(entry)
|
||||
if str(mine) in words:
|
||||
continue
|
||||
if any(os.path.exists(word) for word in words):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _linux_install(appimage, force=False):
|
||||
paths, written = _paths(), []
|
||||
if not force:
|
||||
other = _another_dikte(paths["menu"].parent, appimage)
|
||||
if other is not None:
|
||||
return []
|
||||
|
||||
command = _exec_field(str(appimage))
|
||||
if _write(paths["menu"], _desktop_entry(command)):
|
||||
written.append(paths["menu"])
|
||||
if _write(paths["autostart"], _desktop_entry(command, autostart=True)):
|
||||
written.append(paths["autostart"])
|
||||
if _icon(paths["icons"]):
|
||||
written.append(paths["icons"] / "hicolor")
|
||||
|
||||
# A symlink rather than a copy, so that replacing the AppImage in place
|
||||
# replaces the command too. Anything else already sitting there is left
|
||||
# where it is: install.sh puts a symlink into a checkout here, and that
|
||||
# checkout is a working installation this has no business redirecting.
|
||||
link = paths["command"]
|
||||
ours = link.is_symlink() and os.readlink(link).endswith(".AppImage")
|
||||
if not link.exists() and not link.is_symlink() or ours or force:
|
||||
if not link.is_symlink() or os.readlink(link) != str(appimage):
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
link.unlink(missing_ok=True)
|
||||
link.symlink_to(appimage)
|
||||
written.append(link)
|
||||
return written
|
||||
|
||||
|
||||
def _linux_remove():
|
||||
paths, gone = _paths(), []
|
||||
for key in ("menu", "autostart"):
|
||||
if paths[key].exists():
|
||||
paths[key].unlink()
|
||||
gone.append(paths[key])
|
||||
link = paths["command"]
|
||||
if link.is_symlink() and pathlib.Path(os.readlink(link)).suffix == ".AppImage":
|
||||
link.unlink()
|
||||
gone.append(link)
|
||||
for size in _icon_sizes():
|
||||
icon = paths["icons"] / "hicolor" / f"{size}x{size}" / "apps" / f"{ICON_NAME}.png"
|
||||
if icon.exists():
|
||||
icon.unlink()
|
||||
gone.append(icon)
|
||||
return gone
|
||||
|
||||
|
||||
def _icon_sizes():
|
||||
from . import trayicon
|
||||
return trayicon.HICOLOR_SIZES
|
||||
|
||||
|
||||
def _icon(directory):
|
||||
"""Draw the icon into hicolor, if there is a GUI to draw with.
|
||||
|
||||
A QPixmap needs a QGuiApplication under it, and `dikte integrate` typed at
|
||||
a terminal has only the QCoreApplication the command line builds. Nothing
|
||||
is lost by skipping it there: the next start of the application itself
|
||||
draws it, and until then the entries fall back to a generic icon.
|
||||
"""
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
if not isinstance(QGuiApplication.instance(), QGuiApplication):
|
||||
return False
|
||||
from . import trayicon
|
||||
first = directory / "hicolor" / "256x256" / "apps" / f"{ICON_NAME}.png"
|
||||
if first.exists():
|
||||
return False
|
||||
trayicon.write_hicolor(directory, ICON_NAME)
|
||||
return True
|
||||
|
||||
|
||||
# --- macOS ----------------------------------------------------------------
|
||||
|
||||
def _agent_path():
|
||||
return pathlib.Path.home() / "Library" / "LaunchAgents" / f"{AGENT_ID}.plist"
|
||||
|
||||
|
||||
def _agent_plist(app):
|
||||
"""Through `open` rather than the executable inside the bundle, so that the
|
||||
process is one LaunchServices started: that is what gives it the bundle's
|
||||
identity, and so the microphone and Accessibility permissions that were
|
||||
granted to Dikte rather than to launchd."""
|
||||
return plistlib.dumps({
|
||||
"Label": AGENT_ID,
|
||||
"ProgramArguments": ["/usr/bin/open", "-a", str(app)],
|
||||
"RunAtLoad": True,
|
||||
# Off on purpose: quitting from the menu bar should quit it, not
|
||||
# hand it back to launchd to start again.
|
||||
"KeepAlive": False,
|
||||
"ProcessType": "Interactive",
|
||||
})
|
||||
|
||||
|
||||
def _macos_agent_app(agent):
|
||||
"""The bundle a login item already there starts, if it still exists.
|
||||
|
||||
install-mac.sh writes this same file for a checkout, pointing at the bundle
|
||||
it built under ~/Applications, where a disk image is dragged to
|
||||
/Applications instead. Two bundles both starting at login is one too many.
|
||||
"""
|
||||
try:
|
||||
arguments = plistlib.loads(agent.read_bytes()).get("ProgramArguments", [])
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
for argument in arguments:
|
||||
if argument.endswith(".app") and os.path.exists(argument):
|
||||
return argument
|
||||
return None
|
||||
|
||||
|
||||
def _macos_install(app, force=False):
|
||||
written = []
|
||||
agent = _agent_path()
|
||||
if not force and agent.exists():
|
||||
theirs = _macos_agent_app(agent)
|
||||
if theirs is not None and theirs != str(app):
|
||||
return []
|
||||
|
||||
plist = _agent_plist(app)
|
||||
if not agent.exists() or agent.read_bytes() != plist:
|
||||
agent.parent.mkdir(parents=True, exist_ok=True)
|
||||
agent.write_bytes(plist)
|
||||
written.append(agent)
|
||||
_launchctl_reload(agent)
|
||||
|
||||
# The command, as a wrapper rather than a symlink: the executable has to be
|
||||
# run from inside the bundle for macOS to file its permissions under Dikte,
|
||||
# and a symlink somewhere else is a different process to macOS.
|
||||
command = pathlib.Path.home() / ".local" / "bin" / "dikte"
|
||||
binary = app / "Contents" / "MacOS" / "Dikte"
|
||||
marker = "# Written by Dikte itself. Delete it to be rid of it.\n"
|
||||
script = f'#!/bin/sh\n{marker}exec {shlex.quote(str(binary))} "$@"\n'
|
||||
# install-mac.sh writes its own wrapper here, naming the checkout's Python.
|
||||
# Ours only replaces a wrapper it wrote before, or nothing at all.
|
||||
ours = command.exists() and marker in command.read_text(encoding="utf-8")
|
||||
if (not command.exists() or ours or force) and _write(command, script):
|
||||
command.chmod(0o755)
|
||||
written.append(command)
|
||||
return written
|
||||
|
||||
|
||||
def _macos_remove():
|
||||
gone = []
|
||||
agent = _agent_path()
|
||||
if agent.exists():
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{AGENT_ID}"],
|
||||
capture_output=True, check=False)
|
||||
agent.unlink()
|
||||
gone.append(agent)
|
||||
return gone
|
||||
|
||||
|
||||
def _launchctl_reload(agent):
|
||||
"""Load the login item now, so that it does not first take effect a login
|
||||
from now. bootout first because bootstrapping a label that is already
|
||||
loaded fails, and a reinstall is exactly that case."""
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{AGENT_ID}"],
|
||||
capture_output=True, check=False)
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, check=False)
|
||||
+18
-1
@@ -10,6 +10,7 @@ shortcut may still send.
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from PyQt6.QtNetwork import QLocalSocket
|
||||
@@ -32,13 +33,29 @@ def script_path():
|
||||
)
|
||||
|
||||
|
||||
def launcher():
|
||||
"""The argv that starts Dikte again on this installation.
|
||||
|
||||
An interpreter and a file is only how a checkout starts. A packaged build
|
||||
has no __main__.py on disk to name, and an AppImage is a squashfs mounted
|
||||
under a fresh /tmp path every run, so what a shortcut written today has to
|
||||
say is the .AppImage file the user keeps, not the binary inside this run's
|
||||
mount. APPIMAGE is what the runtime puts that path in.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return [sys.executable, script_path()]
|
||||
return [os.environ.get("APPIMAGE") or sys.executable]
|
||||
|
||||
|
||||
def command_for(verb):
|
||||
"""The command line a desktop's shortcut runs for one of the verbs.
|
||||
|
||||
Also what Settings shows an i3 or XFCE user to paste into their own
|
||||
configuration, since there is no registry there for Dikte to write into.
|
||||
Quoted, because a Mac keeps applications under a path with a space in it
|
||||
and an AppImage lives wherever it was downloaded to.
|
||||
"""
|
||||
return f"{sys.executable} {script_path()} {verb}"
|
||||
return shlex.join(launcher() + ([verb] if verb else []))
|
||||
|
||||
|
||||
def send(cmd, wait=False, timeout=0, **args):
|
||||
|
||||
Reference in New Issue
Block a user