mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #39 from yusufipk/downloadable-builds
Build an AppImage and a disk image, and publish them
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
name: release
|
||||
|
||||
# Three ways in, one set of builds behind them.
|
||||
#
|
||||
# a push to master rebuilds the "latest" release, which is the newest
|
||||
# commit, prerelease, and always at the same download URL
|
||||
# a v* tag publishes that version and leaves it there
|
||||
# the Run button raises the version, tags it, and then does the above
|
||||
#
|
||||
# The Run button and scripts/release.sh do the same thing, and this runs that
|
||||
# script rather than repeating it, so the two cannot drift apart.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: which part of the version to raise
|
||||
type: choice
|
||||
options: [patch, minor, major]
|
||||
default: patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Two pushes in a row would otherwise race each other to replace the same
|
||||
# "latest" release, and the one that finishes second is not the newer one. Only
|
||||
# those are cancelled: a run that is publishing a version has already pushed a
|
||||
# tag by the time it gets there, and cancelling it would leave that tag with no
|
||||
# release under it.
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
# tests.yml runs the same suite on the same push, and this runs it again
|
||||
# rather than reaching across to it: what that one guards is the branch,
|
||||
# what this one guards is the download, and a "latest" built from a commit
|
||||
# whose tests fail is worse than no latest at all.
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
- run: python -m pip install --quiet PyQt6
|
||||
- run: python -m unittest discover
|
||||
|
||||
version:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ref: ${{ steps.decide.outputs.ref }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
prerelease: ${{ steps.decide.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Raise the version, when that is what was asked for
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
./scripts/release.sh "${{ inputs.bump }}" --yes
|
||||
|
||||
# A push made with the workflow's own token starts no further workflows,
|
||||
# which is what keeps this from setting itself off again, and is also why
|
||||
# the tag it just made has to be built by this run rather than the next.
|
||||
- name: Work out what is being built
|
||||
id: decide
|
||||
run: |
|
||||
read_version() { sed -n 's/^__version__ = "\(.*\)"$/\1/p' dikte/__init__.py; }
|
||||
case "${{ github.event_name }}" in
|
||||
workflow_dispatch)
|
||||
version="$(read_version)"
|
||||
echo "ref=v$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Not a version anybody released: the number in the tree, said
|
||||
# to be ahead of it, and the commit, so that a bug report from
|
||||
# somebody running "latest" names one.
|
||||
version="$(read_version)-dev.${GITHUB_SHA::7}"
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The oldest Ubuntu still offered, because the glibc a build links
|
||||
# against is the oldest one it will run on, and 22.04's covers every
|
||||
# distribution released since. Move it up only when it goes away.
|
||||
- os: ubuntu-22.04
|
||||
kind: appimage
|
||||
- os: macos-latest
|
||||
kind: dmg
|
||||
# Intel Macs. This runner is the last x86_64 image Actions will
|
||||
# offer, and it goes away in August 2027.
|
||||
- os: macos-15-intel
|
||||
kind: dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.ref }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
|
||||
# the build draws its own icon before it packages anything.
|
||||
- name: Install the Qt runtime libraries
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
|
||||
- name: Install PyQt6 and PyInstaller
|
||||
run: python -m pip install --quiet PyQt6 pyinstaller
|
||||
|
||||
# Only for the builds off master: a tagged build already says the number
|
||||
# it was tagged with, and rewriting it would be rewriting the tag.
|
||||
- name: Write the version being built
|
||||
if: needs.version.outputs.prerelease == 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, pathlib, re
|
||||
path = pathlib.Path("dikte/__init__.py")
|
||||
path.write_text(re.sub(r'^__version__ = ".*"$',
|
||||
f'__version__ = "{os.environ["VERSION"]}"',
|
||||
path.read_text(), flags=re.M))
|
||||
PY
|
||||
|
||||
- name: Build
|
||||
run: ./packaging/build-${{ matrix.kind }}.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dikte-${{ matrix.os }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.ref }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: downloads
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.version.outputs.tag }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
ls -la downloads
|
||||
|
||||
notes=$(cat <<'EOF'
|
||||
Linux: download the AppImage, `chmod +x` it, run it. It writes its own
|
||||
menu entry and starts with you at login the first time it runs, and it
|
||||
leaves an installation that is already on the machine alone.
|
||||
|
||||
macOS: open the .dmg and drag Dikte to Applications. It is not signed
|
||||
with an Apple certificate, so the first launch is refused: open System
|
||||
Settings, Privacy & Security, and press Open Anyway. Or, in a terminal:
|
||||
`xattr -dr com.apple.quarantine /Applications/Dikte.app`. Take the arm64
|
||||
image for an Apple silicon Mac and the x86_64 one for an Intel Mac.
|
||||
Recording, transcribing and pasting need the microphone and
|
||||
Accessibility permissions, which macOS asks for the first time each is
|
||||
used. It asks again after an update, because an application signed with
|
||||
no certificate is one macOS has never seen before.
|
||||
EOF
|
||||
)
|
||||
|
||||
if [ "$TAG" = latest ]; then
|
||||
# Rolling: the release and its tag are replaced rather than added
|
||||
# to, so that the download URL stays the one people wrote down.
|
||||
gh release delete latest --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create latest downloads/* \
|
||||
--title "latest ($VERSION)" \
|
||||
--prerelease \
|
||||
--target "$GITHUB_SHA" \
|
||||
--notes "The newest commit on master, built and not released. For a version somebody meant to publish, take the release below.
|
||||
|
||||
$notes"
|
||||
else
|
||||
gh release create "$TAG" downloads/* \
|
||||
--title "Dikte $VERSION" \
|
||||
--generate-notes \
|
||||
--notes "$notes"
|
||||
fi
|
||||
@@ -74,3 +74,6 @@ jobs:
|
||||
bash -n scripts/install-mac.sh
|
||||
bash -n scripts/update.sh
|
||||
bash -n scripts/uninstall.sh
|
||||
bash -n scripts/release.sh
|
||||
bash -n packaging/build-appimage.sh
|
||||
bash -n packaging/build-dmg.sh
|
||||
|
||||
@@ -2,3 +2,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
# What packaging/ works in and leaves behind.
|
||||
build/
|
||||
dist/
|
||||
|
||||
@@ -24,6 +24,16 @@ newer, and PyQt6.
|
||||
|
||||
## Install
|
||||
|
||||
The [releases page](../../releases) has an AppImage and a disk image per Mac
|
||||
architecture. Both write their own menu entry, login item and `dikte` command
|
||||
the first time they run, and stand aside for an installation already on the
|
||||
machine; `dikte integrate --remove` takes them back. The AppImage still wants
|
||||
the system packages below, for the sound server, the clipboard and the
|
||||
keyboard. The disk image is signed with no Apple certificate, so the first
|
||||
launch is refused until you press **Open Anyway** under System Settings →
|
||||
Privacy & Security, and macOS asks for the microphone and Accessibility again
|
||||
after each update; installing from a checkout is what avoids that.
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
systemctl --user enable --now ydotool # needed for auto-paste
|
||||
@@ -204,8 +214,9 @@ keys.
|
||||
|
||||
Everything below is in the `dikte` package, which is what `python3 -m dikte`
|
||||
runs and what the `__main__.py` in it hands to every launcher and shortcut.
|
||||
`scripts/` holds install-mac.sh, update.sh and uninstall.sh; install.sh stays at
|
||||
the top, and `tests/` has a file per module.
|
||||
`scripts/` holds install-mac.sh, update.sh, uninstall.sh and release.sh;
|
||||
`packaging/` builds the AppImage and the disk image that release.sh's tag
|
||||
publishes; install.sh stays at the top, and `tests/` has a file per module.
|
||||
|
||||
```
|
||||
app.py entry point, tray icon, state machine
|
||||
@@ -226,6 +237,7 @@ settings_ui.py settings window
|
||||
hotkey.py the desktop's shortcut registry, the evdev listener, Carbon on a Mac
|
||||
paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
|
||||
trayicon.py the tray icons, drawn where there is no icon theme
|
||||
integrate.py what a downloaded build writes into the desktop it landed on
|
||||
i18n.py the string table
|
||||
```
|
||||
|
||||
|
||||
+15
-2
@@ -23,6 +23,16 @@ paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi
|
||||
|
||||
## Kurulum
|
||||
|
||||
[Sürümler sayfasında](../../releases) bir AppImage, bir de her Mac mimarisi
|
||||
için birer disk imajı var. İkisi de ilk çalıştıklarında kendi menü girdisini,
|
||||
oturum açılışını ve `dikte` komutunu yazar, makinede zaten duran bir kuruluma
|
||||
dokunmazlar; `dikte integrate --remove` yazdıklarını geri alır. AppImage yine
|
||||
de aşağıdaki sistem paketlerini ister: ses sunucusu, pano ve klavye onlardan
|
||||
gelir. Disk imajı bir Apple sertifikasıyla imzalı değil, bu yüzden ilk açılış
|
||||
reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik altından **Yine de Aç**
|
||||
demek gerekir; macOS her güncellemeden sonra mikrofonu ve Erişilebilirliği
|
||||
yeniden sorar, checkout'tan kurmak bundan kurtarır.
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
systemctl --user enable --now ydotool # otomatik yapıştırma için
|
||||
@@ -200,8 +210,10 @@ Kısayollar sekmesi bağlanacak komutu gösterir.
|
||||
|
||||
Aşağıdakilerin hepsi `dikte` paketinin içinde: `python3 -m dikte` bunu çalıştırır,
|
||||
içindeki `__main__.py` de her başlatıcının ve kısayolun adlandırdığı dosyadır.
|
||||
`scripts/` altında install-mac.sh, update.sh ve uninstall.sh var; install.sh en
|
||||
üstte kalır, `tests/` içinde de her modülün bir dosyası.
|
||||
`scripts/` altında install-mac.sh, update.sh, uninstall.sh ve release.sh var;
|
||||
`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage ile disk
|
||||
imajını derler; install.sh en üstte kalır, `tests/` içinde de her modülün bir
|
||||
dosyası.
|
||||
|
||||
```
|
||||
app.py giriş noktası, tepsi simgesi, durum makinesi
|
||||
@@ -222,6 +234,7 @@ settings_ui.py ayarlar penceresi
|
||||
hotkey.py masaüstünün kısayol kaydı, evdev dinleyici, Mac'te Carbon
|
||||
paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics
|
||||
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
|
||||
integrate.py indirilen bir yapının indiği masaüstüne yazdıkları
|
||||
i18n.py metin tablosu
|
||||
```
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# The Linux download: one file, no dependencies of its own beyond the sound
|
||||
# and clipboard programs that come with the desktop.
|
||||
#
|
||||
# Run from anywhere; it works in build/ at the top of the checkout and leaves
|
||||
# the finished AppImage in dist/. The release workflow runs it on the oldest
|
||||
# Ubuntu still supported, because the glibc a build is linked against is the
|
||||
# oldest one it will run on, and nothing here depends on which Ubuntu that is.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD="$ROOT/build"
|
||||
APPDIR="$BUILD/AppDir"
|
||||
OUT="$ROOT/dist"
|
||||
ARCH="${ARCH:-$(uname -m)}"
|
||||
export ARCH
|
||||
|
||||
VERSION="$(cd "$ROOT" && python3 -c 'import dikte; print(dikte.__version__)')"
|
||||
|
||||
rm -rf "$BUILD" "$OUT"
|
||||
mkdir -p "$APPDIR/usr/bin" "$OUT"
|
||||
|
||||
# 1. The application -------------------------------------------------------
|
||||
python3 -m PyInstaller "$ROOT/packaging/dikte.spec" \
|
||||
--distpath "$BUILD/dist" --workpath "$BUILD/work" --noconfirm --clean
|
||||
cp -a "$BUILD/dist/dikte/." "$APPDIR/usr/bin/"
|
||||
|
||||
# 2. The icon --------------------------------------------------------------
|
||||
# Drawn by the application itself, which is why there is no image file in the
|
||||
# repository and no second place to change what Dikte looks like. Offscreen,
|
||||
# since this runs with no display anywhere near it.
|
||||
icons="$BUILD/icons"
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH="$ROOT" \
|
||||
python3 -m dikte.trayicon --hicolor "$icons"
|
||||
mkdir -p "$APPDIR/usr/share/icons"
|
||||
cp -a "$icons/hicolor" "$APPDIR/usr/share/icons/"
|
||||
# At the top as well, under the name the desktop entry gives: that copy is what
|
||||
# appimagetool reads, and what a desktop shows before the file is ever run.
|
||||
cp "$icons/hicolor/256x256/apps/dikte.png" "$APPDIR/dikte.png"
|
||||
|
||||
# 3. What the runtime reads ------------------------------------------------
|
||||
# AppRun is started from the mount point, which is a different path every run,
|
||||
# so it has to find its own directory rather than be told one.
|
||||
cat > "$APPDIR/AppRun" <<'EOF'
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$HERE/usr/bin/dikte" "$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
# Exec names the file rather than a path: a desktop that integrates the
|
||||
# AppImage rewrites this line with wherever the user keeps it, and Dikte writes
|
||||
# its own copy of this entry on first run, which is the one that matters.
|
||||
cat > "$APPDIR/dikte.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Dikte
|
||||
Comment=Voice dictation: record, transcribe, clean up, paste
|
||||
Exec=dikte
|
||||
Icon=dikte
|
||||
Categories=Utility;AudioVideo;
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
EOF
|
||||
|
||||
# 4. The AppImage ----------------------------------------------------------
|
||||
# appimagetool is itself an AppImage, and a container or a CI runner has no
|
||||
# FUSE for it to mount itself with, so it is asked to unpack instead.
|
||||
tool="$BUILD/appimagetool"
|
||||
if [ ! -x "$tool" ]; then
|
||||
curl -fsSL -o "$tool" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$tool"
|
||||
fi
|
||||
"$tool" --appimage-extract-and-run "$APPDIR" "$OUT/Dikte-$VERSION-$ARCH.AppImage"
|
||||
|
||||
echo "dist/Dikte-$VERSION-$ARCH.AppImage"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# The macOS download: a disk image with Dikte.app in it and the usual arrow at
|
||||
# /Applications to drag it onto.
|
||||
#
|
||||
# Run from anywhere; it works in build/ at the top of the checkout and leaves
|
||||
# the finished .dmg in dist/. One image per architecture, because PyQt6 has no
|
||||
# universal wheel to build a universal binary out of, so the workflow runs this
|
||||
# once on an Apple silicon runner and once on an Intel one.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD="$ROOT/build"
|
||||
OUT="$ROOT/dist"
|
||||
ARCH="$(uname -m)"
|
||||
BUNDLE_ID="io.github.yusufipk.dikte"
|
||||
|
||||
VERSION="$(cd "$ROOT" && python3 -c 'import dikte; print(dikte.__version__)')"
|
||||
APP="$BUILD/dist/Dikte.app"
|
||||
|
||||
# A pinned tag and a checksum rather than "whatever is newest": this binary
|
||||
# goes out inside something people run, so what it is has to be decided here
|
||||
# and not by whoever pushes to that repository next. GPL, which is what Dikte
|
||||
# is licensed under too. 6.1.1 is behind the current release and stays there
|
||||
# until something Dikte asks of it needs a newer one.
|
||||
FFMPEG_TAG="b6.1.1"
|
||||
case "$ARCH" in
|
||||
arm64) FFMPEG_ASSET="ffmpeg-darwin-arm64.gz"
|
||||
FFMPEG_SHA="8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa" ;;
|
||||
x86_64) FFMPEG_ASSET="ffmpeg-darwin-x64.gz"
|
||||
FFMPEG_SHA="929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106" ;;
|
||||
*) echo "no ffmpeg pinned for $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
rm -rf "$BUILD" "$OUT"
|
||||
mkdir -p "$BUILD" "$OUT"
|
||||
|
||||
# 1. The icon --------------------------------------------------------------
|
||||
# Before the application, because the bundle is built with it rather than
|
||||
# having it copied in afterwards. Drawn by Dikte itself, offscreen, which is
|
||||
# why there is no image file in the repository.
|
||||
iconset="$BUILD/Dikte.iconset"
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH="$ROOT" python3 -m dikte.trayicon "$iconset"
|
||||
iconutil -c icns "$iconset" -o "$BUILD/Dikte.icns"
|
||||
export DIKTE_ICNS="$BUILD/Dikte.icns"
|
||||
|
||||
# 2. The application -------------------------------------------------------
|
||||
python3 -m PyInstaller "$ROOT/packaging/dikte.spec" \
|
||||
--distpath "$BUILD/dist" --workpath "$BUILD/work" --noconfirm --clean
|
||||
|
||||
# 3. ffmpeg ----------------------------------------------------------------
|
||||
# Recording on a Mac goes through ffmpeg, and macOS ships nothing like it, so
|
||||
# without this the disk image would be an application that cannot record until
|
||||
# the person who downloaded it installs Homebrew. Resources/bin because
|
||||
# Contents/MacOS is for the executable the bundle names, and integrate.py puts
|
||||
# this directory in front of PATH at startup.
|
||||
bin="$APP/Contents/Resources/bin"
|
||||
mkdir -p "$bin"
|
||||
curl -fsSL -o "$BUILD/$FFMPEG_ASSET" \
|
||||
"https://github.com/eugeneware/ffmpeg-static/releases/download/$FFMPEG_TAG/$FFMPEG_ASSET"
|
||||
echo "$FFMPEG_SHA $BUILD/$FFMPEG_ASSET" | shasum -a 256 -c -
|
||||
gunzip -c "$BUILD/$FFMPEG_ASSET" > "$bin/ffmpeg"
|
||||
chmod +x "$bin/ffmpeg"
|
||||
|
||||
# 4. Signing ---------------------------------------------------------------
|
||||
# Ad-hoc, because there is no Developer ID to sign with. It is not decoration:
|
||||
# macOS files a microphone or Accessibility permission against a code
|
||||
# signature, and an arm64 binary carrying none is refused by the kernel outright
|
||||
# rather than merely warned about. What it does not buy is Gatekeeper, which is
|
||||
# why the README tells people how to get past the first-launch refusal.
|
||||
#
|
||||
# --deep is the wrong tool for a real signature and the right one here: every
|
||||
# dylib PyInstaller collected plus the ffmpeg added above all need one, and
|
||||
# adding ffmpeg invalidated the signature PyInstaller left.
|
||||
codesign --force --deep --sign - --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep "$APP"
|
||||
|
||||
# 5. The disk image --------------------------------------------------------
|
||||
# A staging directory rather than the bundle on its own, so that the window
|
||||
# that opens has the arrow to drag it onto. UDZO is the compressed read-only
|
||||
# format every Mac has understood for twenty years.
|
||||
stage="$BUILD/stage"
|
||||
mkdir -p "$stage"
|
||||
cp -a "$APP" "$stage/"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
hdiutil create -volname "Dikte $VERSION" -srcfolder "$stage" \
|
||||
-ov -format UDZO -quiet "$OUT/Dikte-$VERSION-$ARCH.dmg"
|
||||
|
||||
echo "dist/Dikte-$VERSION-$ARCH.dmg"
|
||||
@@ -0,0 +1,107 @@
|
||||
# PyInstaller's description of the build, shared by the AppImage and the disk
|
||||
# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand:
|
||||
# each of those has a few steps of its own on either side of this.
|
||||
#
|
||||
# A directory rather than a single file, on both platforms. Onefile unpacks
|
||||
# itself into /tmp on every start, which for something a global shortcut is
|
||||
# meant to bring up is a second of nothing happening, and for the AppImage it
|
||||
# would be an unpacking inside an unpacking. The single file people download is
|
||||
# the AppImage and the .dmg; this only has to be tidy inside them.
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(SPECPATH).parent # noqa: F821 (PyInstaller's)
|
||||
|
||||
# Read rather than imported. Putting the checkout on sys.path to import dikte
|
||||
# would put this directory there under the name `packaging`, which is a real
|
||||
# library that PyInstaller itself uses, and a spec file is no place to find out
|
||||
# whether that matters.
|
||||
__version__ = re.search(r'^__version__ = "(.*)"$',
|
||||
(ROOT / "dikte" / "__init__.py").read_text(),
|
||||
re.M).group(1)
|
||||
|
||||
MACOS = sys.platform == "darwin"
|
||||
BUNDLE_ID = "io.github.yusufipk.dikte"
|
||||
|
||||
# PyQt6's wheel is most of the build, and most of the wheel is modules nothing
|
||||
# here imports: Qt ships a browser engine, three declarative UI stacks and a
|
||||
# 3D renderer. Naming them keeps the download to something a person on a slow
|
||||
# connection will actually finish. Only the four in dikte's imports are left.
|
||||
UNUSED_QT = [
|
||||
"PyQt6." + name for name in (
|
||||
"Qt3DAnimation", "Qt3DCore", "Qt3DExtras", "Qt3DInput", "Qt3DLogic",
|
||||
"Qt3DRender", "QtBluetooth", "QtCharts", "QtDataVisualization",
|
||||
"QtDesigner", "QtHelp", "QtLocation", "QtMultimedia",
|
||||
"QtMultimediaWidgets", "QtNfc", "QtPdf", "QtPdfWidgets",
|
||||
"QtPositioning", "QtQml", "QtQuick", "QtQuick3D", "QtQuickWidgets",
|
||||
"QtRemoteObjects", "QtSensors", "QtSerialPort", "QtSpatialAudio",
|
||||
"QtSql", "QtTest", "QtTextToSpeech", "QtWebChannel", "QtWebEngineCore",
|
||||
"QtWebEngineQuick", "QtWebEngineWidgets", "QtWebSockets",
|
||||
)
|
||||
]
|
||||
|
||||
analysis = Analysis( # noqa: F821
|
||||
[str(ROOT / "packaging" / "entry.py")],
|
||||
pathex=[str(ROOT)],
|
||||
hiddenimports=["PyQt6.QtNetwork"],
|
||||
# tkinter is the other GUI toolkit CPython ships and would be dead weight;
|
||||
# dikte's own tests have no business in a build at all.
|
||||
excludes=UNUSED_QT + ["tkinter", "tests"],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
archive = PYZ(analysis.pure) # noqa: F821
|
||||
|
||||
executable = EXE( # noqa: F821
|
||||
archive,
|
||||
analysis.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="Dikte" if MACOS else "dikte",
|
||||
console=False,
|
||||
# Both platforms use whatever the machine is, because neither build is
|
||||
# cross-compiled: the workflow runs one job per architecture.
|
||||
target_arch=None,
|
||||
# Ad-hoc, and only on a Mac, where an arm64 binary that carries no
|
||||
# signature at all is refused by the kernel rather than merely warned
|
||||
# about. build-dmg.sh signs the finished bundle over the top of this.
|
||||
codesign_identity="-" if MACOS else None,
|
||||
)
|
||||
|
||||
collection = COLLECT( # noqa: F821
|
||||
executable,
|
||||
analysis.binaries,
|
||||
analysis.datas,
|
||||
name="dikte",
|
||||
)
|
||||
|
||||
if MACOS:
|
||||
# LSUIElement is the line that makes this a menu bar application: no Dock
|
||||
# icon, no menu of its own, nothing in the app switcher. The usage strings
|
||||
# are not decoration either, they are what the permission dialogs read out,
|
||||
# and a bundle that asks for the microphone without one is killed rather
|
||||
# than asked about.
|
||||
app = BUNDLE( # noqa: F821
|
||||
collection,
|
||||
name="Dikte.app",
|
||||
icon=os.environ.get("DIKTE_ICNS") or None,
|
||||
bundle_identifier=BUNDLE_ID,
|
||||
version=__version__,
|
||||
info_plist={
|
||||
"CFBundleName": "Dikte",
|
||||
"CFBundleDisplayName": "Dikte",
|
||||
"CFBundleShortVersionString": __version__,
|
||||
"CFBundleVersion": __version__,
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
"LSUIElement": True,
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSMicrophoneUsageDescription":
|
||||
"Dikte records what you dictate so that it can be transcribed.",
|
||||
"NSAppleEventsUsageDescription":
|
||||
"Dikte puts the transcript on the clipboard and pastes it into "
|
||||
"the window you were typing in.",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""What the AppImage and the disk image start.
|
||||
|
||||
dikte/__main__.py is written for a checkout: it puts the directory above the
|
||||
package on the import path, which a build has neither the need for nor a
|
||||
directory to point at. What is left over is one thing a checkout never sees.
|
||||
The Finder hands a double-clicked application a -psn_0_… argument naming the
|
||||
process serial number, which argparse reads as a flag it has never heard of and
|
||||
exits over, and no one clicking an icon would ever find out why.
|
||||
|
||||
The two environment lines have to run before anything starts another process,
|
||||
and before is easier to be sure of here than anywhere further in.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from dikte import integrate
|
||||
from dikte.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
integrate.restore_library_path()
|
||||
integrate.add_bundled_tools()
|
||||
sys.argv[1:] = [arg for arg in sys.argv[1:] if not arg.startswith("-psn_")]
|
||||
sys.exit(main())
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Raise the version and tag it. GitHub builds and publishes the rest.
|
||||
#
|
||||
# ./scripts/release.sh patch 1.2.3 -> 1.2.4 a fix
|
||||
# ./scripts/release.sh minor 1.2.3 -> 1.3.0 something new
|
||||
# ./scripts/release.sh major 1.2.3 -> 2.0.0 something that breaks
|
||||
# ./scripts/release.sh 2.5.0 that number, whatever is there now
|
||||
#
|
||||
# The same three words the Release button in the Actions tab offers, because
|
||||
# that button runs this script. Neither is the real one; use whichever you are
|
||||
# in front of.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
INIT="$ROOT/dikte/__init__.py"
|
||||
BUMP="${1:-}"
|
||||
YES=0
|
||||
PUSH=1
|
||||
for arg in "${@:2}"; do
|
||||
case "$arg" in
|
||||
--yes|-y) YES=1 ;;
|
||||
--no-push) PUSH=0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
die() { printf 'release: %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
CURRENT="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$INIT")"
|
||||
[[ -n "$CURRENT" ]] || die "no __version__ in dikte/__init__.py"
|
||||
IFS=. read -r major minor patch <<<"$CURRENT"
|
||||
|
||||
case "$BUMP" in
|
||||
major) NEXT="$((major + 1)).0.0" ;;
|
||||
minor) NEXT="$major.$((minor + 1)).0" ;;
|
||||
patch) NEXT="$major.$minor.$((patch + 1))" ;;
|
||||
[0-9]*.[0-9]*.[0-9]*) NEXT="$BUMP" ;;
|
||||
*) die "say major, minor, patch, or a number like 2.5.0" ;;
|
||||
esac
|
||||
TAG="v$NEXT"
|
||||
|
||||
cd "$ROOT"
|
||||
# A release is built from what is tagged, so anything not committed would not
|
||||
# be in it, and the tag would name a tree that never existed anywhere.
|
||||
[[ -z "$(git status --porcelain)" ]] || die "commit or stash your changes first"
|
||||
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null && die "$TAG already exists"
|
||||
|
||||
if [[ $YES == 0 ]]; then
|
||||
printf ' %s -> %s, tagged %s' "$CURRENT" "$NEXT" "$TAG"
|
||||
[[ $PUSH == 1 ]] && printf ', and pushed to %s' "$(git rev-parse --abbrev-ref HEAD)"
|
||||
printf '\n Enter to go ahead, Ctrl-C to stop. '
|
||||
read -r _
|
||||
fi
|
||||
|
||||
# The one line, rewritten in place. A .bak and then delete it, because the BSD
|
||||
# sed on a Mac and the GNU one on Linux disagree about what -i on its own means.
|
||||
sed -i.bak "s/^__version__ = \".*\"$/__version__ = \"$NEXT\"/" "$INIT"
|
||||
rm -f "$INIT.bak"
|
||||
|
||||
git add "$INIT"
|
||||
git commit -q -m "Dikte $NEXT"
|
||||
git tag -a "$TAG" -m "Dikte $NEXT"
|
||||
|
||||
if [[ $PUSH == 1 ]]; then
|
||||
git push -q origin HEAD "$TAG"
|
||||
echo " Pushed $TAG. Watch it build: gh run watch"
|
||||
else
|
||||
echo " Tagged $TAG. Push it when you are ready: git push origin HEAD $TAG"
|
||||
fi
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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()
|
||||
# Resolved, because target() resolves what it is handed and a Mac's
|
||||
# temporary directory is under /var, which is a symlink to /private/var.
|
||||
# Left alone, every path here would be compared against the other
|
||||
# spelling of itself.
|
||||
self.home = pathlib.Path(self.tmp.name).resolve()
|
||||
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()
|
||||
@@ -65,6 +65,30 @@ class Paths(unittest.TestCase):
|
||||
self.assertTrue(command.startswith(sys.executable))
|
||||
self.assertTrue(command.endswith(" toggle"))
|
||||
|
||||
def test_a_packaged_build_names_itself_and_no_interpreter(self):
|
||||
"""There is no __main__.py on disk in one, and sys.executable is the
|
||||
build's own binary rather than a Python anybody could run it with."""
|
||||
with mock.patch.object(sys, "frozen", True, create=True), \
|
||||
mock.patch.object(sys, "executable", "/Applications/Dikte.app/Contents/MacOS/Dikte"), \
|
||||
mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(ipc.launcher(),
|
||||
["/Applications/Dikte.app/Contents/MacOS/Dikte"])
|
||||
|
||||
def test_an_appimage_names_the_file_rather_than_this_run_s_mount(self):
|
||||
"""A shortcut written to the mount works until the next login."""
|
||||
with mock.patch.object(sys, "frozen", True, create=True), \
|
||||
mock.patch.object(sys, "executable", "/tmp/.mount_ab12/usr/bin/dikte"), \
|
||||
mock.patch.dict(os.environ, {"APPIMAGE": "/home/me/Dikte.AppImage"}):
|
||||
self.assertEqual(ipc.command_for("toggle"),
|
||||
"/home/me/Dikte.AppImage toggle")
|
||||
|
||||
def test_a_path_with_a_space_in_it_is_quoted(self):
|
||||
"""Which is every Mac, and an AppImage kept anywhere with a name."""
|
||||
with mock.patch.object(sys, "frozen", True, create=True), \
|
||||
mock.patch.dict(os.environ, {"APPIMAGE": "/home/me/My Things/Dikte.AppImage"}):
|
||||
self.assertEqual(ipc.command_for("cancel"),
|
||||
"'/home/me/My Things/Dikte.AppImage' cancel")
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "getuid"),
|
||||
"the socket is named after a user id, which Windows "
|
||||
"has no equivalent of")
|
||||
|
||||
Reference in New Issue
Block a user