diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86eaa88..b176668 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,6 @@ jobs: - name: Check the installer parses run: | bash -n install.sh - bash -n install-mac.sh - bash -n update.sh - bash -n uninstall.sh + bash -n scripts/install-mac.sh + bash -n scripts/update.sh + bash -n scripts/uninstall.sh diff --git a/README.md b/README.md index c047b5a..52ab781 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ tools instead: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which +On macOS the same `./install.sh` runs and hands over to `scripts/install-mac.sh`, which puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a LaunchAgent: @@ -84,9 +84,10 @@ transcribe in the cloud. A meeting needs BlackHole or Loopback (`brew install blackhole-2ch`); dictation does not. `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the -two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and -puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away -again and leaves your settings and dictations alone unless you pass `--purge`. +two global shortcuts, whose keys are its two arguments, or the ones already in +your settings when it is given none. `./scripts/update.sh` pulls and puts all of +that back; `./scripts/uninstall.sh` takes it away again and leaves your settings +and dictations alone unless you pass `--purge`. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: @@ -201,8 +202,13 @@ keys. ## Layout +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. + ``` -dikte.py entry point, tray icon, state machine +app.py entry point, tray icon, state machine cli.py the command line: every verb, and what it answers with ipc.py one request and one reply over the local socket audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting @@ -224,7 +230,7 @@ i18n.py the string table ``` The indicator is drawn through XWayland, because a Wayland client cannot place a -window in a screen corner; `dikte.py` sets `QT_QPA_PLATFORM=xcb` for that. +window in a screen corner; `app.py` sets `QT_QPA_PLATFORM=xcb` for that. ## License diff --git a/README.tr.md b/README.tr.md index 38a7537..6db60dd 100644 --- a/README.tr.md +++ b/README.tr.md @@ -55,7 +55,7 @@ araçlarıyla çalışır: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder; +macOS'ta da aynı `./install.sh` çalışır, işi `scripts/install-mac.sh`'a devreder; `~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent kurar: @@ -83,10 +83,10 @@ BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için gerekmiyor. `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik -başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` -son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; -`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve -diktelerine dokunmaz. +başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman +verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve +bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge` +demedikçe ayarlarına ve diktelerine dokunmaz. Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu @@ -198,8 +198,13 @@ Kısayollar sekmesi bağlanacak komutu gösterir. ## Dosyalar +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ı. + ``` -dikte.py giriş noktası, tepsi simgesi, durum makinesi +app.py giriş noktası, tepsi simgesi, durum makinesi cli.py komut satırı: bütün fiiller ve verdikleri cevap ipc.py yerel sokette bir istek, bir cevap audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg @@ -221,7 +226,7 @@ i18n.py metin tablosu ``` Gösterge XWayland üzerinden çizilir; Wayland'da bir pencereyi belirli bir köşeye -yerleştirmenin yolu yok, `dikte.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar. +yerleştirmenin yolu yok, `app.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar. ## Lisans diff --git a/dikte/__init__.py b/dikte/__init__.py new file mode 100644 index 0000000..dbb496d --- /dev/null +++ b/dikte/__init__.py @@ -0,0 +1,6 @@ +"""Dikte: press a key, talk, press again to transcribe, clean up and paste. + +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. +""" diff --git a/dikte/__main__.py b/dikte/__main__.py new file mode 100755 index 0000000..073b0b0 --- /dev/null +++ b/dikte/__main__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""What `python3 -m dikte` and the installed `dikte` command both run. + +The file is also executed by path, because that is what a desktop shortcut and +the launcher in ~/.local/bin do: neither knows a working directory to be in. +Run that way there is no package around it, so the checkout has to be put on +the import path here, before the first line of the application is imported. +""" + +import os +import sys + +if not __package__: + # realpath, because the launcher is a symlink into the checkout: what has to + # end up on the path is the checkout, not ~/.local/bin. The directory this + # file is in comes off the path in exchange, so that a module beside it is + # only ever reachable as part of the package. + here = os.path.dirname(os.path.realpath(__file__)) + sys.path[:] = [p for p in sys.path if os.path.realpath(p or ".") != here] + sys.path.insert(0, os.path.dirname(here)) + +from dikte.app import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api.py b/dikte/api.py similarity index 99% rename from api.py rename to dikte/api.py index 775fe31..fa89af1 100644 --- a/api.py +++ b/dikte/api.py @@ -22,8 +22,8 @@ import threading import urllib.error import urllib.request -import ggml -from i18n import t +from . import ggml +from .i18n import t APP_URL = "https://github.com/yusufipk/dikte" USER_AGENT = f"dikte/1.0 (+{APP_URL})" diff --git a/dikte.py b/dikte/app.py old mode 100755 new mode 100644 similarity index 98% rename from dikte.py rename to dikte/app.py index 12b57c4..a4ce2b1 --- a/dikte.py +++ b/dikte/app.py @@ -1,10 +1,11 @@ -#!/usr/bin/env python3 """Dikte: press Ctrl+Space, talk, press again to transcribe, clean up and paste. This is the application: the tray icon, the state machine, and the socket the terminal talks to. Every verb it answers is in cli.py, which is also what runs -`dikte.py --help`; the only argument handled here is --gui, which is how the +`dikte --help`; the only argument handled here is --gui, which is how the command line says "there is no instance to talk to, so be one". + +Nothing runs this file directly; __main__.py is what the launchers start. """ import contextlib @@ -36,21 +37,21 @@ from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 -import assistant # noqa: E402 -import audio # noqa: E402 -import cli # noqa: E402 -import config as cfg # noqa: E402 -import ggml # noqa: E402 -import hotkey # noqa: E402 -import i18n # noqa: E402 -import ipc # noqa: E402 -import meeting # noqa: E402 -import trayicon # noqa: E402 -from i18n import t # noqa: E402 -from meeting import MeetingPipeline # noqa: E402 -from overlay import Overlay # noqa: E402 -from settings_ui import SettingsWindow # noqa: E402 -from worker import Pipeline # noqa: E402 +from . import assistant # noqa: E402 +from . import audio # noqa: E402 +from . import cli # noqa: E402 +from . import config as cfg # noqa: E402 +from . import ggml # noqa: E402 +from . import hotkey # noqa: E402 +from . import i18n # noqa: E402 +from . import ipc # noqa: E402 +from . import meeting # noqa: E402 +from . import trayicon # noqa: E402 +from .i18n import t # noqa: E402 +from .meeting import MeetingPipeline # noqa: E402 +from .overlay import Overlay # noqa: E402 +from .settings_ui import SettingsWindow # noqa: E402 +from .worker import Pipeline # noqa: E402 SERVER_NAME = ipc.SERVER_NAME IDLE, RECORDING, BUSY = "idle", "recording", "busy" @@ -1168,7 +1169,3 @@ def run_app(args): QTimer.singleShot(0, dikte.toggle_meeting) return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/assistant.py b/dikte/assistant.py similarity index 99% rename from assistant.py rename to dikte/assistant.py index f708ab3..89c2ec1 100644 --- a/assistant.py +++ b/dikte/assistant.py @@ -29,9 +29,9 @@ import subprocess import threading import time -import api -import config as cfg -from i18n import t +from . import api +from . import config as cfg +from .i18n import t SESSION_FILE = cfg.DATA_DIR / "assistant.json" PROVIDERS = ("claude", "codex", "openrouter") diff --git a/audio.py b/dikte/audio.py similarity index 99% rename from audio.py rename to dikte/audio.py index bcd43b0..7221625 100644 --- a/audio.py +++ b/dikte/audio.py @@ -31,7 +31,7 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal -from i18n import t +from .i18n import t RATE = 16000 CHANNELS = 1 diff --git a/cleanup.py b/dikte/cleanup.py similarity index 99% rename from cleanup.py rename to dikte/cleanup.py index b6485b3..22d31ec 100644 --- a/cleanup.py +++ b/dikte/cleanup.py @@ -18,10 +18,10 @@ import shutil import subprocess import tempfile -import api -import assistant -import ggml -from i18n import t +from . import api +from . import assistant +from . import ggml +from .i18n import t PROVIDERS = ("openrouter", "local", "claude", "codex") diff --git a/cli.py b/dikte/cli.py similarity index 99% rename from cli.py rename to dikte/cli.py index 7ab4d99..72f3f5d 100644 --- a/cli.py +++ b/dikte/cli.py @@ -22,16 +22,16 @@ import time from PyQt6.QtCore import QCoreApplication, QTimer -import api -import assistant -import audio -import cleanup -import config as cfg -import filetranscribe -import hotkey -import ipc -import meeting -import paste +from . import api +from . import assistant +from . import audio +from . import cleanup +from . import config as cfg +from . import filetranscribe +from . import hotkey +from . import ipc +from . import meeting +from . import paste NOT_RUNNING = 3 diff --git a/config.py b/dikte/config.py similarity index 99% rename from config.py rename to dikte/config.py index 4861a75..6885fdd 100644 --- a/config.py +++ b/dikte/config.py @@ -6,12 +6,12 @@ import json import os import sys -import api -import ggml -import i18n -import paste -import paths -from i18n import t +from . import api +from . import ggml +from . import i18n +from . import paste +from . import paths +from .i18n import t _MACOS = sys.platform == "darwin" diff --git a/filetranscribe.py b/dikte/filetranscribe.py similarity index 99% rename from filetranscribe.py rename to dikte/filetranscribe.py index 4c8b4b7..5ac97e3 100644 --- a/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -23,10 +23,10 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal -import api -import cleanup -import ggml -from i18n import t +from . import api +from . import cleanup +from . import ggml +from .i18n import t UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less diff --git a/ggml.py b/dikte/ggml.py similarity index 99% rename from ggml.py rename to dikte/ggml.py index 3278f46..6805337 100644 --- a/ggml.py +++ b/dikte/ggml.py @@ -44,9 +44,9 @@ import time import urllib.error import urllib.request -import hub -import paths -from i18n import t +from . import hub +from . import paths +from .i18n import t HOST = "127.0.0.1" # The path api.py asks for, so its URL and the server's line up. diff --git a/hotkey.py b/dikte/hotkey.py similarity index 99% rename from hotkey.py rename to dikte/hotkey.py index 1060c08..16c9a1e 100644 --- a/hotkey.py +++ b/dikte/hotkey.py @@ -30,7 +30,7 @@ import threading from PyQt6.QtCore import QObject, pyqtSignal -from i18n import t +from .i18n import t DESKTOP_ID = "dikte-toggle.desktop" CANCEL_DESKTOP_ID = "dikte-cancel.desktop" diff --git a/hub.py b/dikte/hub.py similarity index 99% rename from hub.py rename to dikte/hub.py index f793d1c..afd8133 100644 --- a/hub.py +++ b/dikte/hub.py @@ -25,7 +25,7 @@ import urllib.error import urllib.parse import urllib.request -from i18n import t +from .i18n import t GITHUB_API = "https://api.github.com" HF_API = "https://huggingface.co/api" diff --git a/i18n.py b/dikte/i18n.py similarity index 100% rename from i18n.py rename to dikte/i18n.py diff --git a/ipc.py b/dikte/ipc.py similarity index 93% rename from ipc.py rename to dikte/ipc.py index fa436e2..4632b31 100644 --- a/ipc.py +++ b/dikte/ipc.py @@ -22,8 +22,13 @@ CONNECT_MS = 800 def script_path(): + """The package entry point, as a path. + + A shortcut and a relaunch both start a second process, and neither has a + working directory to run `-m dikte` from, so the file is named outright. + """ return os.path.realpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "dikte.py") + os.path.join(os.path.dirname(os.path.abspath(__file__)), "__main__.py") ) diff --git a/meeting.py b/dikte/meeting.py similarity index 98% rename from meeting.py rename to dikte/meeting.py index 5da05d3..0d582b4 100644 --- a/meeting.py +++ b/dikte/meeting.py @@ -24,13 +24,13 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal -import api -import cleanup -import config as cfg -import filetranscribe -import vad -from filetranscribe import Cancelled, format_timestamp -from i18n import t +from . import api +from . import cleanup +from . import config as cfg +from . import filetranscribe +from . import vad +from .filetranscribe import Cancelled, format_timestamp +from .i18n import t # Where the document stops being prose and starts being the transcript. It is a # comment, so it never shows up in a rendered document, and it is what a retry diff --git a/overlay.py b/dikte/overlay.py similarity index 100% rename from overlay.py rename to dikte/overlay.py diff --git a/paste.py b/dikte/paste.py similarity index 99% rename from paste.py rename to dikte/paste.py index d39198b..fb1a9e4 100644 --- a/paste.py +++ b/dikte/paste.py @@ -19,7 +19,7 @@ import sys import tempfile import time -from i18n import t +from .i18n import t # Linux input event codes (linux/input-event-codes.h), which is what ydotool # takes. They are also the list of keys a paste shortcut may be built from, so diff --git a/paths.py b/dikte/paths.py similarity index 100% rename from paths.py rename to dikte/paths.py diff --git a/settings_ui.py b/dikte/settings_ui.py similarity index 99% rename from settings_ui.py rename to dikte/settings_ui.py index e7a7f6d..1a73968 100644 --- a/settings_ui.py +++ b/dikte/settings_ui.py @@ -13,19 +13,19 @@ from PyQt6.QtWidgets import ( QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) -import api -import assistant -import audio -import cleanup -import config as cfg -import filetranscribe -import ggml -import hotkey -import ipc -import meeting -import paste -from filetranscribe import FileTranscriber -from i18n import t +from . import api +from . import assistant +from . import audio +from . import cleanup +from . import config as cfg +from . import filetranscribe +from . import ggml +from . import hotkey +from . import ipc +from . import meeting +from . import paste +from .filetranscribe import FileTranscriber +from .i18n import t UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] LANGUAGES = [ diff --git a/trayicon.py b/dikte/trayicon.py similarity index 99% rename from trayicon.py rename to dikte/trayicon.py index 089cb20..cce525b 100644 --- a/trayicon.py +++ b/dikte/trayicon.py @@ -118,7 +118,7 @@ def _working(painter, size, ink): painter.drawPath(head) -# The names Linux themes use, which are what dikte.py asks for either way. +# The names Linux themes use, which are what app.py asks for either way. SHAPES = { "audio-input-microphone": _microphone, "media-record": _record, diff --git a/vad.py b/dikte/vad.py similarity index 100% rename from vad.py rename to dikte/vad.py diff --git a/worker.py b/dikte/worker.py similarity index 97% rename from worker.py rename to dikte/worker.py index 0f0e004..16cdbad 100644 --- a/worker.py +++ b/dikte/worker.py @@ -15,15 +15,15 @@ import traceback from PyQt6.QtCore import QObject, pyqtSignal -import api -import assistant -import audio -import cleanup -import config as cfg -import i18n -import paste -import vad -from i18n import t +from . import api +from . import assistant +from . import audio +from . import cleanup +from . import config as cfg +from . import i18n +from . import paste +from . import vad +from .i18n import t CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE diff --git a/install.sh b/install.sh index c597882..4b8c609 100755 --- a/install.sh +++ b/install.sh @@ -9,18 +9,35 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # path. That is a second script rather than a branch through this one, and # update.sh reaches it through here without having to know which it is on. if [[ "$(uname -s)" == "Darwin" ]]; then - exec "$DIR/install-mac.sh" "$@" + exec "$DIR/scripts/install-mac.sh" "$@" fi PY="$(command -v python3)" +# The one file that starts the application, whoever is asking: the launcher +# below, both .desktop files, and every shortcut Dikte registers. +ENTRY="$DIR/dikte/__main__.py" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" AUTOSTART_DIR="$HOME/.config/autostart" ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons" -SHORTCUT="${1:-Ctrl+Space}" -# Without the colon, so that a second argument given as "" stays empty. That is -# how update.sh says "this one was turned off", as against not saying anything. -CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}" +# Only the one: the discard key's default is the settings' own, read back below. +DEFAULT_SHORTCUT="Ctrl+Space" +# Given as arguments, or asked of the settings further down. An installer run +# again, which is what every update does, must not undo a key you chose in +# Settings, so silence here means "keep whatever is there". +SHORTCUT="${1:-}" +CANCEL_SHORTCUT="${2-}" +# Passed as "" means the discard key is off, which is not the same answer as +# not being passed at all. +CANCEL_GIVEN=$(( $# >= 2 )) +# One caller says both without meaning either: an updater from before Dikte +# became a package looks for the settings at a path that no longer exists, and +# so passes the default key and an empty discard key rather than yours. This +# can go once nobody is updating across that commit any more. +if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then + SHORTCUT="" + CANCEL_GIVEN=0 +fi say() { printf ' %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } @@ -83,8 +100,8 @@ fi # 3. Launchers ------------------------------------------------------------- mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR" -ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte" -chmod +x "$DIR/dikte.py" +ln -sf "$ENTRY" "$BIN_DIR/dikte" +chmod +x "$ENTRY" ok "Command installed: $BIN_DIR/dikte" case ":$PATH:" in *":$BIN_DIR:"*) ;; @@ -97,7 +114,7 @@ esac # bare X11 login Qt is left with hicolor, which has no such name, and the entry # comes out blank. hicolor is also where this goes, since it is the theme every # desktop must fall back to. -if "$PY" "$DIR/trayicon.py" --hicolor "$ICON_DIR" >/dev/null 2>&1; then +if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon --hicolor "$ICON_DIR" >/dev/null 2>&1; then ICON=dikte # Only GTK reads a cache, and only if one is already there; a stale cache # would otherwise hide the file we just wrote. @@ -116,7 +133,7 @@ cat > "$APP_DIR/dikte.desktop" < "$AUTOSTART_DIR/dikte.desktop" </dev/null || true; } +if [[ -z "$SHORTCUT" ]]; then + SHORTCUT="$(stored shortcut)" + SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}" +fi +if [[ $CANCEL_GIVEN == 0 ]]; then + # An empty answer here is a discard key that was turned off, and it stays off. + CANCEL_SHORTCUT="$(stored cancel_shortcut)" +fi + +if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then + warn "Both keys are $SHORTCUT, so the discard key was left out." say "Pass two different combinations, or set it in Settings → Shortcuts." CANCEL_SHORTCUT="" fi register() { # which combination label - if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then + if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then ok "$3: $2" else # One line: the rest of what it has to say about KWin is printed below. @@ -167,7 +196,7 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then # not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every # session that was neither GNOME nor KDE used to be promised a KWin that was # never running. - case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in kde) warn "KWin only reads these at startup, so they go live after your next" say "login. Until then open Settings → Shortcuts and turn on the" diff --git a/install-mac.sh b/scripts/install-mac.sh similarity index 85% rename from install-mac.sh rename to scripts/install-mac.sh index 34c3f7a..f79b1e8 100755 --- a/install-mac.sh +++ b/scripts/install-mac.sh @@ -11,19 +11,38 @@ # Homebrew moves that copy. set -euo pipefail -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The checkout, one level up: this script lives in scripts/, everything it +# touches is at the top of the tree. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# The one file that starts the application, whoever is asking: the wrapper in +# ~/.local/bin, the bundle, and every shortcut Dikte registers. +ENTRY="$DIR/dikte/__main__.py" APP_DIR="$HOME/Applications" APP="$APP_DIR/Dikte.app" BIN_DIR="$HOME/.local/bin" AGENT_DIR="$HOME/Library/LaunchAgents" AGENT_ID="io.github.yusufipk.dikte" AGENT="$AGENT_DIR/$AGENT_ID.plist" -SHORTCUT="${1:-Ctrl+Option+Space}" -# Without the colon, so that a second argument given as "" stays empty. That is -# how update.sh says "this one was turned off", as against not saying anything. -CANCEL_SHORTCUT="${2-Ctrl+Option+D}" +# Only the one: the discard key's default is the settings' own, read back below. +DEFAULT_SHORTCUT="Ctrl+Option+Space" +# Given as arguments, or asked of the settings further down. An installer run +# again, which is what every update does, must not undo a key you chose in +# Settings, so silence here means "keep whatever is there". +SHORTCUT="${1:-}" +CANCEL_SHORTCUT="${2-}" +# Passed as "" means the discard key is off, which is not the same answer as +# not being passed at all. +CANCEL_GIVEN=$(( $# >= 2 )) +# One caller says both without meaning either: an updater from before Dikte +# became a package looks for the settings at a path that no longer exists, and +# so passes the default key and an empty discard key rather than yours. This +# can go once nobody is updating across that commit any more. +if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then + SHORTCUT="" + CANCEL_GIVEN=0 +fi -# The two places Homebrew installs to, in front, for the same reason dikte.py +# The two places Homebrew installs to, in front, for the same reason the app # puts them there: a shell that has not been logged into since Homebrew was # installed does not have them, and this script would then report ffmpeg as # missing while the application finds it perfectly well. @@ -147,7 +166,7 @@ if [ ! -d "\$PYTHONHOME" ]; then osascript -e 'display alert "Dikte" message "The Python this was installed against is gone, most likely after a brew upgrade. Run ./install.sh again."' >/dev/null 2>&1 exit 1 fi -exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@" +exec "\$HERE/python3" "$ENTRY" --gui "\$@" EOF chmod +x "$APP/Contents/MacOS/Dikte" @@ -186,7 +205,7 @@ printf 'APPL????' > "$APP/Contents/PkgInfo" # and no second place to change what Dikte looks like. Failing to draw it is # not worth stopping for: a bundle with no icon gets the generic one. iconset="$(mktemp -d)/Dikte.iconset" -if "$PY" "$DIR/trayicon.py" "$iconset" >/dev/null 2>&1 \ +if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon "$iconset" >/dev/null 2>&1 \ && iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then ok "Icon drawn" else @@ -212,7 +231,7 @@ fi -f "$APP" >/dev/null 2>&1 || true # 4. The command ------------------------------------------------------------ -# A wrapper, where Linux gets a symlink to dikte.py. The shebang there is +# A wrapper, where Linux gets a symlink to the entry point. The shebang there is # `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to # the one interpreter that cannot run this. Naming the interpreter here also # gives update.sh and uninstall.sh somewhere to read it from, so that the three @@ -221,9 +240,9 @@ mkdir -p "$BIN_DIR" cat > "$BIN_DIR/dikte" </dev/null || true; } +if [[ -z "$SHORTCUT" ]]; then + SHORTCUT="$(stored shortcut)" + SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}" +fi +if [[ $CANCEL_GIVEN == 0 ]]; then + # An empty answer here is a discard key that was turned off, and it stays off. + CANCEL_SHORTCUT="$(stored cancel_shortcut)" +fi + +if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then + warn "Both keys are $SHORTCUT, so the discard key was left out." say "Pass two different combinations, or set it in Settings → Shortcuts." CANCEL_SHORTCUT="" fi register() { # which combination label - if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then + if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then ok "$3: $2" else warn "${out%%$'\n'*}" diff --git a/uninstall.sh b/scripts/uninstall.sh similarity index 89% rename from uninstall.sh rename to scripts/uninstall.sh index a89b03b..8031b54 100755 --- a/uninstall.sh +++ b/scripts/uninstall.sh @@ -4,7 +4,10 @@ # is the word that deletes them. set -euo pipefail -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The checkout, one level up: this script lives in scripts/, everything it +# touches is at the top of the tree. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENTRY="$DIR/dikte/__main__.py" USER_NAME="$(id -un)" BIN_DIR="$HOME/.local/bin" @@ -49,7 +52,7 @@ count() { usage() { cat </dev/null; then for which in toggle pause cancel ask meeting; do - "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true + "$PY" "$ENTRY" shortcut remove "$which" >/dev/null 2>&1 || true done - case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in kde) ok "Global shortcuts unregistered" say "KWin reads that file at startup, so the keys are free after your next login." @@ -111,10 +114,12 @@ fi # 2. The running instance -------------------------------------------------- # It holds a tray icon and a socket; asking it to quit is tidier than pulling # its launchers out from under it. -if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then - [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true +# The pattern matches an instance started before Dikte became a package as well, +# which is what an uninstall run straight after an update finds running. +if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then + [[ -n "$PY" ]] && "$PY" "$ENTRY" quit >/dev/null 2>&1 || true sleep 0.5 - if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then + if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then warn "Dikte is still running; close it from the tray icon" else ok "Stopped the running instance" @@ -216,11 +221,11 @@ elif [[ "$CONFIG_DIR" == "$DATA_DIR" ]]; then # macOS keeps both in the one directory a Mac user's backup already knows # about, so naming it twice would only look like two things were kept. say "Settings and dictations kept: $CONFIG_DIR" - say "Delete them too with: ./uninstall.sh --purge" + say "Delete them too with: ./scripts/uninstall.sh --purge" else say "Settings kept: $CONFIG_DIR" say "Dictations kept: $DATA_DIR" - say "Delete them too with: ./uninstall.sh --purge" + say "Delete them too with: ./scripts/uninstall.sh --purge" fi echo diff --git a/update.sh b/scripts/update.sh similarity index 79% rename from update.sh rename to scripts/update.sh index e88db46..88792b5 100755 --- a/update.sh +++ b/scripts/update.sh @@ -2,7 +2,10 @@ # Dikte updater: pull, put the launchers back, restart what was running. set -euo pipefail -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The checkout, one level up: this script lives in scripts/, everything it +# touches is at the top of the tree. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENTRY="$DIR/dikte/__main__.py" USER_NAME="$(id -un)" if [[ "$(uname -s)" == "Darwin" ]]; then @@ -13,10 +16,8 @@ if [[ "$(uname -s)" == "Darwin" ]]; then # make a missing file the end of the script rather than a question answered no. PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)" [[ -x "$PY" ]] || PY="$(command -v python3 || true)" - DEFAULT_SHORTCUT="Ctrl+Option+Space" else PY="$(command -v python3 || true)" - DEFAULT_SHORTCUT="Ctrl+Space" fi say() { printf ' %s\n' "$1"; } @@ -24,13 +25,6 @@ ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; } -# The combination stored in the settings, which is where Dikte itself reads it -# from and the one place that is the same whichever mechanism the session has. -setting() { - [[ -n "$PY" ]] || return 0 - "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true -} - echo echo "Updating Dikte" echo "──────────────" @@ -90,18 +84,15 @@ echo # 4. Launchers -------------------------------------------------------------- # An update can add a dependency or move a file, so the installer runs again. -# It would otherwise register its own defaults over the keys you chose, so it -# is told what those are. Read before the installer runs, since it is the one -# writing them. -shortcut="$(setting shortcut)" -cancel_shortcut="$(setting cancel_shortcut)" -# Positional, so a chosen discard key cannot be passed without the other one. -"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}" +# With no keys named it keeps the ones stored in the settings, which are the +# ones you chose. +"$DIR/install.sh" # 5. The running instance --------------------------------------------------- -# It is still running the code from before the pull. -if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then - if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then +# It is still running the code from before the pull, which on an update across +# the move into a package is a process still named after the old entry point. +if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then + if [[ -n "$PY" ]] && "$PY" "$ENTRY" restart >/dev/null 2>&1; then ok "Restarted, so the new version is the one running" else warn "Could not restart it; use the tray menu → Restart" diff --git a/tests/support.py b/tests/support.py index 26d069b..18c0a06 100644 --- a/tests/support.py +++ b/tests/support.py @@ -22,9 +22,9 @@ import urllib.request import wave from unittest import mock -import assistant -import config as cfg -import i18n +from dikte import assistant +from dikte import config as cfg +from dikte import i18n # What the application is, rather than what it does: PipeWire, wl-clipboard, # ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces diff --git a/tests/test_api.py b/tests/test_api.py index f45ca21..58ab960 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -16,8 +16,8 @@ import threading import time import unittest -import api -import ggml +from dikte import api +from dikte import ggml from tests.support import ( DikteTest, fake_urlopen, diff --git a/tests/test_assistant.py b/tests/test_assistant.py index c57cfbe..1c682c6 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -14,7 +14,7 @@ import time import unittest from unittest import mock -import assistant +from dikte import assistant from tests.support import DikteTest, fake_urlopen, only_these_tools diff --git a/tests/test_audio.py b/tests/test_audio.py index ea2454a..3f7777d 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -21,7 +21,7 @@ import unittest import wave from unittest import mock -import audio +from dikte import audio from tests.support import ( DikteTest, FakeCompleted, diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index efb8417..7753029 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -11,9 +11,9 @@ import subprocess import unittest from unittest import mock -import api -import cleanup -import ggml +from dikte import api +from dikte import cleanup +from dikte import ggml from tests.support import DikteTest, fake_urlopen, sent_json, url_error from tests.test_api import FakeServer, chat_reply diff --git a/tests/test_cli.py b/tests/test_cli.py index 15e62b5..ef8f6f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,10 +12,10 @@ import json import unittest from unittest import mock -import cli -import config as cfg -import hotkey -import ipc +from dikte import cli +from dikte import config as cfg +from dikte import hotkey +from dikte import ipc from tests.support import DikteTest, fake_urlopen diff --git a/tests/test_config.py b/tests/test_config.py index 57482be..aa15c91 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,12 +11,12 @@ import os import unittest from unittest import mock -import api -import cleanup -import config as cfg -import ggml -import i18n -import paste +from dikte import api +from dikte import cleanup +from dikte import config as cfg +from dikte import ggml +from dikte import i18n +from dikte import paste from tests.support import DikteTest diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index ff917aa..2adc5ee 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -13,8 +13,8 @@ import unittest import wave from unittest import mock -import api -import filetranscribe as ft +from dikte import api +from dikte import filetranscribe as ft from tests.support import DikteTest, make_wav, silence, tone diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 9c11a8b..951654b 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -17,8 +17,8 @@ import threading import time from unittest import mock -import ggml -import hub +from dikte import ggml +from dikte import hub from tests.support import (DikteTest, fake_urlopen, http_error, json_body, linux_only, url_error) diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index fa15c34..3ac7b52 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -6,8 +6,8 @@ import subprocess import unittest from unittest import mock -import config as cfg -import hotkey +from dikte import config as cfg +from dikte import hotkey from tests.support import DikteTest, FakeCompleted, linux_only SHORTCUTS_RC = """[services][dikte-toggle.desktop] diff --git a/tests/test_hub.py b/tests/test_hub.py index f3741c2..84cd8d0 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,7 +2,7 @@ import json -import hub +from dikte import hub from tests.support import DikteTest, fake_urlopen, http_error, url_error RELEASE = { diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 0b1f9d5..21af9ab 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -9,7 +9,7 @@ import string import unittest from unittest import mock -import i18n +from dikte import i18n from tests.support import DikteTest diff --git a/tests/test_ipc.py b/tests/test_ipc.py index 0409cad..a95cb8b 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -11,7 +11,7 @@ import sys import unittest from unittest import mock -import ipc +from dikte import ipc class FakeSocket: @@ -57,7 +57,7 @@ class FakeSocket: class Paths(unittest.TestCase): def test_script_path_points_at_dikte(self): - self.assertTrue(ipc.script_path().endswith("dikte.py")) + self.assertTrue(ipc.script_path().endswith("dikte/__main__.py")) self.assertTrue(os.path.exists(ipc.script_path())) def test_the_shortcut_command_runs_it_with_this_interpreter(self): diff --git a/tests/test_meeting.py b/tests/test_meeting.py index 471b77c..26a18e3 100644 --- a/tests/test_meeting.py +++ b/tests/test_meeting.py @@ -11,9 +11,9 @@ import unittest import wave from unittest import mock -import api -import config as cfg -import meeting +from dikte import api +from dikte import config as cfg +from dikte import meeting from tests.support import DikteTest, make_wav, silence, speech, stereo, tone diff --git a/tests/test_paste.py b/tests/test_paste.py index 85c13cd..8da2c81 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -22,7 +22,7 @@ import unittest from typing import ClassVar from unittest import mock -import paste +from dikte import paste from tests.support import DikteTest, FakeCompleted, only_these_tools diff --git a/tests/test_paths.py b/tests/test_paths.py index d932e0f..81517d1 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -9,9 +9,9 @@ import os import unittest from unittest import mock -import config as cfg -import ggml -import paths +from dikte import config as cfg +from dikte import ggml +from dikte import paths class Directories(unittest.TestCase): diff --git a/tests/test_trayicon.py b/tests/test_trayicon.py index 5f53d7d..a257781 100644 --- a/tests/test_trayicon.py +++ b/tests/test_trayicon.py @@ -15,7 +15,7 @@ from unittest import mock from PyQt6.QtGui import QImage from PyQt6.QtWidgets import QApplication -import trayicon +from dikte import trayicon from tests.support import DikteTest # One application for the whole run; Qt allows no second one. @@ -66,7 +66,7 @@ class Tray(DikteTest): self.patch_attr(trayicon, "_cache", {}) def test_a_name_we_do_not_draw_is_a_null_icon(self): - # dikte.py asks the theme first and falls through to here, so anything + # app.py asks the theme first and falls through to here, so anything # answered with a picture would be one the theme should have given. self.assertTrue(trayicon.icon("emblem-important").isNull()) diff --git a/tests/test_ui.py b/tests/test_ui.py index 444d402..192aae7 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -16,12 +16,12 @@ from PyQt6.QtCore import QPoint, QPointF, Qt from PyQt6.QtGui import QWheelEvent from PyQt6.QtWidgets import QApplication, QMessageBox -import cleanup -import config as cfg -import hotkey -import overlay as overlay_module -import paste -import settings_ui +from dikte import cleanup +from dikte import config as cfg +from dikte import hotkey +from dikte import overlay as overlay_module +from dikte import paste +from dikte import settings_ui from tests.support import DikteTest, only_these_tools # One application for the whole run; Qt allows no second one. @@ -281,7 +281,7 @@ class Settings(DikteTest): text = self.shortcut_tab_text(window) self.assertIn("i3 keeps no shortcut registry", text) self.assertNotIn("KWin", text) - self.assertIn("dikte.py toggle", text) + self.assertIn("__main__.py toggle", text) # Not a choice to offer where it is the only mechanism there is. self.assertTrue(window.evdev_enabled.isHidden()) self.assertFalse([button for button in @@ -458,7 +458,7 @@ class MacSettings(Settings): text = self.shortcut_tab_text(window) self.assertIn("Dikte asks macOS for these combinations", text) self.assertNotIn("KWin", text) - self.assertNotIn("dikte.py toggle", text) + self.assertNotIn("__main__.py toggle", text) def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): window = self.window(cfg.Config()) @@ -482,7 +482,7 @@ class KdeSettings(Settings): self.assertIn("KWin only reads shortcut settings at startup", text) self.assertIn("Install as a KDE shortcut", text) self.assertNotIn("keeps no shortcut registry", text) - self.assertNotIn("dikte.py toggle", text) + self.assertNotIn("__main__.py toggle", text) # Here it is a choice: the wait for the next login, or the key press # reaching the focused application as well. self.assertFalse(window.evdev_enabled.isHidden()) diff --git a/tests/test_vad.py b/tests/test_vad.py index 4674ebe..71c6cfd 100644 --- a/tests/test_vad.py +++ b/tests/test_vad.py @@ -2,7 +2,7 @@ import unittest -import vad +from dikte import vad from tests.support import DikteTest CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter diff --git a/tests/test_worker.py b/tests/test_worker.py index 7634a51..1ea30c3 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -11,11 +11,11 @@ import os import unittest from unittest import mock -import api -import assistant -import config as cfg -import paste -import worker +from dikte import api +from dikte import assistant +from dikte import config as cfg +from dikte import paste +from dikte import worker from tests.support import DikteTest, make_wav, speech