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/install-mac.sh
|
||||||
bash -n scripts/update.sh
|
bash -n scripts/update.sh
|
||||||
bash -n scripts/uninstall.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]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
|
# What packaging/ works in and leaves behind.
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ newer, and PyQt6.
|
|||||||
|
|
||||||
## Install
|
## 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
|
```sh
|
||||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||||
systemctl --user enable --now ydotool # needed for auto-paste
|
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`
|
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.
|
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
|
`scripts/` holds install-mac.sh, update.sh, uninstall.sh and release.sh;
|
||||||
the top, and `tests/` has a file per module.
|
`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
|
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
|
hotkey.py the desktop's shortcut registry, the evdev listener, Carbon on a Mac
|
||||||
paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
|
paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
|
||||||
trayicon.py the tray icons, drawn where there is no icon theme
|
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
|
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
|
## 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
|
```sh
|
||||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||||
systemctl --user enable --now ydotool # otomatik yapıştırma için
|
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,
|
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.
|
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
|
`scripts/` altında install-mac.sh, update.sh, uninstall.sh ve release.sh var;
|
||||||
üstte kalır, `tests/` içinde de her modülün bir dosyası.
|
`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
|
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
|
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
|
paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics
|
||||||
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
|
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
|
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
|
config get` runs through the same package as the tray icon does, and it has no
|
||||||
business loading Qt to answer one question.
|
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 ggml # noqa: E402
|
||||||
from . import hotkey # noqa: E402
|
from . import hotkey # noqa: E402
|
||||||
from . import i18n # noqa: E402
|
from . import i18n # noqa: E402
|
||||||
|
from . import integrate # noqa: E402
|
||||||
from . import ipc # noqa: E402
|
from . import ipc # noqa: E402
|
||||||
from . import meeting # noqa: E402
|
from . import meeting # noqa: E402
|
||||||
from . import trayicon # noqa: E402
|
from . import trayicon # noqa: E402
|
||||||
@@ -966,7 +967,8 @@ class Dikte:
|
|||||||
self.settings_window.close()
|
self.settings_window.close()
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
QLocalServer.removeServer(SERVER_NAME)
|
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):
|
def shutdown(self):
|
||||||
self._quitting = True
|
self._quitting = True
|
||||||
@@ -1102,6 +1104,11 @@ def run_app(args):
|
|||||||
app.setWindowIcon(trayicon.app_icon())
|
app.setWindowIcon(trayicon.app_icon())
|
||||||
app.setQuitOnLastWindowClosed(False)
|
app.setQuitOnLastWindowClosed(False)
|
||||||
_stay_out_of_the_dock()
|
_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
|
# 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
|
# 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
|
# 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 filetranscribe
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
from . import ipc
|
from . import ipc
|
||||||
|
from . import integrate
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
NOT_RUNNING = 3
|
NOT_RUNNING = 3
|
||||||
|
|
||||||
@@ -130,11 +132,11 @@ def _ask_instance(opts, cmd, wait=False, **args):
|
|||||||
|
|
||||||
def launch_gui(verb=""):
|
def launch_gui(verb=""):
|
||||||
"""No instance running, so become the application itself."""
|
"""No instance running, so become the application itself."""
|
||||||
args = [sys.executable, ipc.script_path()]
|
args = ipc.launcher()
|
||||||
if verb:
|
if verb:
|
||||||
args.append(verb)
|
args.append(verb)
|
||||||
args.append("--gui")
|
args.append("--gui")
|
||||||
os.execv(sys.executable, args)
|
os.execv(args[0], args)
|
||||||
|
|
||||||
|
|
||||||
def _not_running(opts):
|
def _not_running(opts):
|
||||||
@@ -753,6 +755,28 @@ def cmd_shortcut(opts):
|
|||||||
message)
|
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):
|
def cmd_status(opts):
|
||||||
reply = ipc.send("status")
|
reply = ipc.send("status")
|
||||||
if reply is None:
|
if reply is None:
|
||||||
@@ -847,6 +871,8 @@ def build_parser():
|
|||||||
help="print the answer as one JSON object")
|
help="print the answer as one JSON object")
|
||||||
parser.add_argument("-q", "--quiet", action="store_true",
|
parser.add_argument("-q", "--quiet", action="store_true",
|
||||||
help="keep progress lines off stderr")
|
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)
|
parser.set_defaults(verb="", timeout=0, func=cmd_plain)
|
||||||
subs = parser.add_subparsers(dest="verb", metavar="COMMAND")
|
subs = parser.add_subparsers(dest="verb", metavar="COMMAND")
|
||||||
|
|
||||||
@@ -1033,6 +1059,12 @@ def build_parser():
|
|||||||
choices=tuple(hotkey.SHORTCUTS))
|
choices=tuple(hotkey.SHORTCUTS))
|
||||||
remove.set_defaults(func=cmd_shortcut)
|
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 --------------------------------------------------
|
# --- the application --------------------------------------------------
|
||||||
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
||||||
for name, help_text in (("settings", "open the settings window"),
|
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 json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PyQt6.QtNetwork import QLocalSocket
|
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):
|
def command_for(verb):
|
||||||
"""The command line a desktop's shortcut runs for one of the verbs.
|
"""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
|
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.
|
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):
|
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.startswith(sys.executable))
|
||||||
self.assertTrue(command.endswith(" toggle"))
|
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"),
|
@unittest.skipUnless(hasattr(os, "getuid"),
|
||||||
"the socket is named after a user id, which Windows "
|
"the socket is named after a user id, which Windows "
|
||||||
"has no equivalent of")
|
"has no equivalent of")
|
||||||
|
|||||||
Reference in New Issue
Block a user