diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8f0798..86eaa88 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - test: + linux: runs-on: ubuntu-latest strategy: fail-fast: false @@ -38,3 +38,39 @@ jobs: # tests reach neither the network nor a sound device. - name: Run the tests run: python -m unittest discover --verbose + + # The macOS backends are written to be checked from either side, so most of + # what runs here also runs above. What only this job can catch is the half + # that reads the real platform: the Carbon and CoreGraphics libraries have to + # be there to be opened, the paths under ~/Library have to be the ones macOS + # actually uses, and the Linux tests are skipped rather than failed. + macos: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # No apt step: PyQt6's wheel carries the frameworks it needs on a Mac. + - name: Install PyQt6 + run: python -m pip install --quiet PyQt6 + + - name: Run the tests + run: python -m unittest discover --verbose + + # The installer is most of what is new on a Mac, and a syntax error in it + # would otherwise only turn up on somebody's machine. Running it is not on + # the table here: it wants a microphone, a login session and a keychain. + - name: Check the installer parses + run: | + bash -n install.sh + bash -n install-mac.sh + bash -n update.sh + bash -n uninstall.sh diff --git a/README.md b/README.md index 5517d69..e408c94 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,32 @@ tools instead: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -macOS has no shortcut registry for `install.sh` to install into, so the listener -that catches the keys is the mechanism there and there is nothing to run: -`brew install ffmpeg`, `pip install PyQt6`, then `python dikte.py`. A meeting -needs BlackHole or Loopback, because nothing else offers what the speakers are -playing. +On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which +puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a +LaunchAgent: + +```sh +brew install pyqt ffmpeg # pyqt brings a Python newer than Apple's 3.9 with it + +./install.sh # or: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D" +open -a Dikte +``` + +The bundle is what macOS files the **Microphone** and **Accessibility** +permissions against, and it asks for each the first time it needs one. The +default there is `Ctrl+Option+Space`, since macOS keeps `Ctrl+Space` for the +input-source switch, and nothing needs a logout: Dikte holds the combination +itself while it runs. PyQt6 comes from brew rather than pip because Homebrew's +Python refuses to be installed into, and `DIKTE_PYTHON=…/venv/bin/python +./install.sh` points the installer at a virtualenv instead. + +Local speech to text is the one piece that has to be built by hand there: +whisper.cpp publishes no macOS binary and Homebrew's is configured with +`WHISPER_BUILD_SERVER=OFF`, so it installs `whisper-cli` and not the server +Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON +-DGGML_METAL=ON && cmake --build build -j`) and give Settings → API the path, or +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 @@ -189,8 +210,9 @@ vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps overlay.py the corner indicator settings_ui.py settings window -hotkey.py KDE shortcut installation and the evdev listener -paste.py wl-clipboard and ydotool wrappers +hotkey.py KDE shortcut installation, the evdev listener, Carbon on a Mac +paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics +trayicon.py the tray icons, drawn where there is no icon theme i18n.py the string table ``` diff --git a/README.tr.md b/README.tr.md index edebd17..db1f23c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -54,10 +54,32 @@ araçlarıyla çalışır: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -macOS'ta `install.sh`'ın kuracağı bir kısayol kaydı yok, tuşları yakalayan -dinleyici orada mekanizmanın kendisi, dolayısıyla kurulacak bir şey de yok: -`brew install ffmpeg`, `pip install PyQt6`, sonra `python dikte.py`. Toplantı -için BlackHole ya da Loopback gerekiyor, hoparlörden çıkanı kimse vermiyor. +macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder; +`~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent +kurar: + +```sh +brew install pyqt ffmpeg # pyqt Apple'ın 3.9'undan yeni bir Python'ı da getirir + +./install.sh # ya da: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D" +open -a Dikte +``` + +macOS **Mikrofon** ve **Erişilebilirlik** izinlerini uygulama paketinin kimliğine +yazıyor, ikisini de ilk gerektiğinde soruyor. Varsayılan kısayol orada +`Ctrl+Option+Space`, çünkü `Ctrl+Space` giriş kaynağını değiştirmeye ayrılmış; ve +hiçbir şey için oturumu kapatman gerekmez, kombinasyonu Dikte çalışırken kendisi +tutar. PyQt6 pip'ten değil brew'dan geliyor, Homebrew'un Python'u içine +kurulmayı reddediyor; sanal ortam kullanacaksan +`DIKTE_PYTHON=…/venv/bin/python ./install.sh`. + +Orada elle derlenmesi gereken tek parça yerel transkripsiyon: whisper.cpp'nin +macOS sürümü yok, Homebrew'unki de `WHISPER_BUILD_SERVER=OFF` ile derleniyor, +yani `whisper-cli` kuruluyor, Dikte'nin konuştuğu sunucu değil. Kendin derle +(`cmake -B build -DWHISPER_BUILD_SERVER=ON -DGGML_METAL=ON && cmake --build +build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı için +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` @@ -186,8 +208,9 @@ vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları overlay.py köşedeki gösterge settings_ui.py ayarlar penceresi -hotkey.py KDE kısayol kurulumu ve evdev dinleyici -paste.py wl-clipboard ve ydotool sarmalayıcıları +hotkey.py KDE kısayol kurulumu, evdev dinleyici, Mac'te Carbon +paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics +trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş i18n.py metin tablosu ``` diff --git a/cli.py b/cli.py index 7992bd0..e122905 100644 --- a/cli.py +++ b/cli.py @@ -697,16 +697,27 @@ def cmd_test_key(opts): def cmd_shortcut(opts): conf = cfg.Config() if opts.shortcut == "status": + # The running instance is asked first. On KDE and GNOME this process + # could read the registry itself and get the same answer; on macOS + # there is no registry, the combination is held by that process and by + # nothing else, and asking here would report every shortcut as missing + # while they all work. + live = ipc.send("status") or {} + registered = live.get("shortcuts") or {} rows = {} for name, spec in hotkey.SHORTCUTS.items(): - rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id), - "configured": conf[spec.setting]} + rows[name] = { + "registered": registered.get( + name, hotkey.shortcut_status(spec.desktop_id)), + "configured": conf[spec.setting], + } + listener = live.get("listener", conf["evdev_hotkey"]) lines = [f"{name:8} {row['registered'] or '(not installed)':16} " f"setting: {row['configured'] or '(none)'}" for name, row in rows.items()] - lines.append(f"built-in listener: {'on' if conf['evdev_hotkey'] else 'off'}") + lines.append(f"built-in listener: {'on' if listener else 'off'}") return out(opts, {"ok": True, "shortcuts": rows, - "listener": conf["evdev_hotkey"]}, "\n".join(lines)) + "listener": listener}, "\n".join(lines)) spec = hotkey.SHORTCUTS[opts.which] if opts.shortcut == "remove": @@ -714,7 +725,8 @@ def cmd_shortcut(opts): return out(opts, {"ok": True, "removed": opts.which}, f"Removed the {opts.which} shortcut.") - combo = (opts.combo or conf[spec.setting] or spec.fallback).strip() + combo = (opts.combo or conf[spec.setting] + or hotkey.default_combo(opts.which)).strip() if not combo: return fail(opts, "no combination given and none stored; pass --combo", 2) if not hotkey.valid_shortcut(combo): diff --git a/config.py b/config.py index c80bbfb..4dd4088 100644 --- a/config.py +++ b/config.py @@ -4,34 +4,21 @@ import collections import hashlib import json import os -import pathlib import sys import api import ggml import i18n import paste +import paths from i18n import t -def _xdg(var, default): - return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) +_MACOS = sys.platform == "darwin" - -def _directories(platform=None): - """(settings, data), in the two places this system keeps them. - - macOS keeps both in the one directory a Mac user's backup already knows - about. Everywhere else they are separate and follow the XDG variables. - """ - if (platform or sys.platform) == "darwin": - support = pathlib.Path.home() / "Library/Application Support/Dikte" - return support, support - return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", - _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") - - -CONFIG_DIR, DATA_DIR = _directories() +# In paths.py rather than here, because ggml.py needs the same answer and +# cannot ask this module: the import already runs the other way. +CONFIG_DIR, DATA_DIR = paths.CONFIG_DIR, paths.DATA_DIR CONFIG_FILE = CONFIG_DIR / "config.json" HISTORY_FILE = DATA_DIR / "history.jsonl" RECORDINGS_DIR = DATA_DIR / "recordings" @@ -438,11 +425,16 @@ DEFAULTS = { "speech_margin_db": 10.0, # how far speech must rise above the noise floor "min_voiced_seconds": 0.3, "filter_hallucinations": True, - "shortcut": "Ctrl+Space", + # Ctrl+Space everywhere except a Mac, where macOS itself holds it for the + # input-source switch and Cmd+Space for Spotlight: neither is ours to take, + # so there Dikte starts on a combination a stock system leaves free. + "shortcut": "Ctrl+Option+Space" if _MACOS else "Ctrl+Space", # Ctrl+Alt+Space rather than Escape: the combination the recording started # with, one modifier along. Escape belongs to whatever window has focus, and - # while you are dictating something else usually has it. - "cancel_shortcut": "Ctrl+Alt+Space", + # while you are dictating something else usually has it. On a Mac that same + # trick lands on the toggle, Alt and Option being one key, so discarding + # gets a letter instead. + "cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space", "evdev_hotkey": False, "overlay_corner": "bottom-left", "keep_audio": False, diff --git a/dikte.py b/dikte.py index cda7f54..a0e8718 100755 --- a/dikte.py +++ b/dikte.py @@ -8,6 +8,8 @@ command line says "there is no instance to talk to, so be one". """ import contextlib +import ctypes +import ctypes.util import json import os import signal @@ -43,6 +45,7 @@ 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 @@ -218,7 +221,12 @@ class Dikte: self._toggle() def _set_icon(self, name): + # The theme first, so a Linux desktop keeps its own icons, then the ones + # drawn in trayicon.py. macOS has no theme at all and would otherwise be + # handed a null icon, which in a menu bar is an item you cannot see. icon = QIcon.fromTheme(name) + if icon.isNull(): + icon = trayicon.icon(name) if icon.isNull(): icon = QIcon.fromTheme("audio-input-microphone") self.tray.setIcon(icon) @@ -473,6 +481,12 @@ class Dikte: "agent": assistant.display_name(self.conf), "provider": assistant.provider(self.conf), "listener": self.evdev.running, + # Asked here rather than by the command line, because on macOS + # there is no registry to read: a combination is held by this + # process and by nothing else, so this is the only process that + # can say whether it is. + "shortcuts": {name: hotkey.shortcut_status(spec.desktop_id) + for name, spec in hotkey.SHORTCUTS.items()}, } def reload_settings(self): @@ -958,6 +972,51 @@ def install_signal_handlers(app): return reader, writer, notifier +def _stay_out_of_the_dock(): + """Ask macOS to treat this as a menu bar application, not a windowed one. + + LSUIElement in the bundle says the same thing, but it is read for the + process LaunchServices started, and that is the launcher script rather than + the Python it runs: the interpreter is a child, and the child inherits the + registration without inheriting the policy. Said here it holds however Dikte + was started, including straight from a terminal. + + Accessory rather than Prohibited: a prohibited application cannot put + anything in the menu bar, which is the whole interface. + """ + if sys.platform != "darwin": + return + NS_ACCESSORY = 1 # NSApplicationActivationPolicyAccessory + try: + objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")) + objc.objc_getClass.restype = ctypes.c_void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + objc.sel_registerName.restype = ctypes.c_void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + # objc_msgSend is a trampoline with no signature of its own, and on + # arm64 the arguments have to be in the registers the real method + # expects, so each call gets a prototype of its own. Built from the + # address: handing CFUNCTYPE the imported function object would make a + # callback into it rather than a call through it, and the crash lands + # inside the Objective-C runtime with nothing to read. + send = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value + shared = ctypes.CFUNCTYPE( + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, + )(send) + policy = ctypes.CFUNCTYPE( + ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, + )(send) + + application = shared(objc.objc_getClass(b"NSApplication"), + objc.sel_registerName(b"sharedApplication")) + if application: + policy(application, objc.sel_registerName(b"setActivationPolicy:"), + NS_ACCESSORY) + except (OSError, AttributeError, TypeError): + # A Dock icon is a blemish, not a failure: everything still works. + pass + + def run_app(args): command = args[0] if args else "" @@ -965,6 +1024,7 @@ def run_app(args): app.setApplicationName("Dikte") app.setDesktopFileName("dikte") app.setQuitOnLastWindowClosed(False) + _stay_out_of_the_dock() # Before Dikte is built, because building it is what may start a server, and # a signal arriving in the middle of that would otherwise take the default # action and leave the server behind. A signal this early lands in the diff --git a/ggml.py b/ggml.py index 5ba85a7..3278f46 100644 --- a/ggml.py +++ b/ggml.py @@ -45,14 +45,17 @@ import urllib.error import urllib.request import hub +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. INFERENCE_PATH = "/v1/audio/transcriptions" -DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") - or os.path.expanduser("~/.local/share")) / "dikte") +# Not worked out here: a Mac keeps its data under ~/Library, and a copy of the +# rule that did not know that put several gigabytes of models somewhere no Mac +# user looks and uninstall.sh never deleted from. +DATA_DIR = paths.DATA_DIR BIN_DIR = DATA_DIR / "bin" MODELS_DIR = DATA_DIR / "models" @@ -319,10 +322,17 @@ def install_program(program, tag="", on_progress=None, should_stop=None, if item: break if item is None: + # Nothing to download and nothing to install for you: whisper.cpp + # publishes no macOS binary, and Homebrew's whisper-cpp is configured + # with WHISPER_BUILD_SERVER=OFF, so it is whisper-cli that lands and not + # the server Dikte talks to. Building it is a cmake line, and the + # binary is picked up from the PATH or from the box above, the same way + # a distribution's own build is on Linux. if sys.platform == "darwin" and program is WHISPER: raise LocalError(t( - "whisper.cpp publishes no macOS build. Install it with: " - "brew install whisper-cpp" + "whisper.cpp has no macOS build, and Homebrew's leaves out the " + "server. Build whisper-server yourself and give its path here, " + "or transcribe in the cloud. See the README." )) raise LocalError(t("{repo} {tag} has no build for this machine.", repo=program.repo, tag=tag)) diff --git a/hotkey.py b/hotkey.py index e7e10ea..0cbcfc4 100644 --- a/hotkey.py +++ b/hotkey.py @@ -56,6 +56,11 @@ SHORTCUTS = { "meeting_shortcut", ""), } +# The fallbacks above are Linux's. macOS holds Ctrl+Space for the input-source +# switch, so asking for it there gets a combination that either loses to the +# system or fires while the keyboard layout changes underneath the dictation. +MACOS_FALLBACKS = {"toggle": "Ctrl+Option+Space"} + # --- evdev key codes (linux/input-event-codes.h) -------------------------- EV_KEY = 0x01 @@ -551,6 +556,18 @@ def listener(parent=None): return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent) +def default_combo(which): + """What to register for `which` when the setting has been cleared. + + The one place the platforms disagree about a default, so that the command + line and the settings window cannot drift apart on it. + """ + if _macos(): + return MACOS_FALLBACKS.get(which, "") + spec = SHORTCUTS.get(which) + return spec.fallback if spec else "" + + def valid_shortcut(text): """Whether this machine can bind the combination as it was typed.""" parse = parse_macos_shortcut if _macos() else parse_shortcut diff --git a/i18n.py b/i18n.py index 436f917..fad21fd 100644 --- a/i18n.py +++ b/i18n.py @@ -133,6 +133,13 @@ TR = { "Could not connect: {reason}": "Bağlantı kurulamadı: {reason}", "Could not parse the response: {error}": "Yanıt çözümlenemedi: {error}", + "whisper.cpp has no macOS build, and Homebrew's leaves out the server. " + "Build whisper-server yourself and give its path here, or transcribe in " + "the cloud. See the README.": + "whisper.cpp'nin macOS sürümü yok, Homebrew'unki de sunucuyu dışarıda " + "bırakıyor. whisper-server'ı kendin derleyip yolunu buraya yaz, ya da " + "buluta çevir. README'ye bak.", + # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", diff --git a/install-mac.sh b/install-mac.sh new file mode 100755 index 0000000..34c3f7a --- /dev/null +++ b/install-mac.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# Dikte on macOS: dependency check, the application bundle, the command, and +# the login item. install.sh hands over to this on a Mac; run it directly and +# it does the same thing. +# +# A Mac needs a bundle where Linux needs a .desktop file, and for the same +# reason plus one: it is what puts a name and an icon in the Finder, and it is +# also the identity macOS files the microphone and Accessibility permissions +# under. Run as a bare script instead, every permission is granted to whatever +# copy of python3 happened to run it, and it is granted again the next time +# Homebrew moves that copy. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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}" + +# The two places Homebrew installs to, in front, for the same reason dikte.py +# 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. +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +say() { printf ' %s\n' "$1"; } +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; } + +echo +echo "Installing Dikte" +echo "────────────────" + +# 1. An interpreter new enough --------------------------------------------- +# The system python3 is 3.9 and will stay 3.9: Apple ships it for its own +# scripts, not for anybody's application, and Dikte needs 3.11. So the first +# question is not "is python3 there" but "which python3", and the answer is +# written into the bundle rather than looked up again at launch, because a +# bundle started from the Finder gets none of the shell's PATH. +find_python() { + local candidate + for candidate in \ + "${DIKTE_PYTHON:-}" \ + /opt/homebrew/bin/python3.14 /opt/homebrew/bin/python3.13 \ + /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 \ + /usr/local/bin/python3.14 /usr/local/bin/python3.13 \ + /usr/local/bin/python3.12 /usr/local/bin/python3.11 \ + python3.14 python3.13 python3.12 python3.11 python3 + do + [[ -n "$candidate" ]] || continue + candidate="$(command -v "$candidate" 2>/dev/null)" || continue + # Resolved, so that the bundle does not point at a Homebrew shim that a + # later `brew upgrade` repoints at a version Dikte cannot run on. + candidate="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + # Exit status, not printed output: a candidate that cannot run Python at + # all and one that is too old are the same answer here. + if "$candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 11))' 2>/dev/null; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +PY="$(find_python || true)" +if [[ -z "$PY" ]]; then + warn "No Python 3.11 or newer found (the one Apple ships is 3.9)." + say "brew install python@3.13" + die "Nothing was installed." +fi +ok "Python: $PY ($("$PY" -c 'import platform; print(platform.python_version())'))" + +# 2. The rest of the dependencies ------------------------------------------ +missing=() +"$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("PyQt6") +command -v ffmpeg >/dev/null || missing+=("ffmpeg") + +if ((${#missing[@]})); then + warn "Missing: ${missing[*]}" + for item in "${missing[@]}"; do + case "$item" in + # Not pip: Homebrew's python is marked externally managed, so installing + # into it is refused. `brew install pyqt` puts PyQt6 where its own + # interpreter already looks. A virtualenv is the other answer, and then + # DIKTE_PYTHON is how this script is pointed at it. + PyQt6) say "PyQt6: brew install pyqt" ;; + ffmpeg) say "ffmpeg: brew install ffmpeg" ;; + esac + done + # ffmpeg alone is survivable: it is what records, so nothing works without + # it, but the settings window opens and says so. PyQt6 is not: there is no + # window to say anything in. + if [[ " ${missing[*]} " == *" PyQt6 "* ]]; then + echo + die "PyQt6 is what the whole interface is; install it and run this again." + fi + echo +else + ok "All dependencies present" +fi + +# 3. The application bundle ------------------------------------------------- +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +# The interpreter is copied in rather than run where it lies, and that copy is +# what the whole of macOS's idea of "which application is this" rests on. +# +# A bundle whose launcher runs an interpreter somewhere else is not that +# interpreter's application: the process is /opt/homebrew/…/python3.13, so that +# is the name in the microphone dialog, that is the row in Accessibility, and +# every application on the machine sharing that interpreter shares the +# permission. Copied to Contents/MacOS and exec'd from there, the running +# executable sits inside Dikte.app, macOS reads the Info.plist above it, and +# the permissions are Dikte's: its name, its icon, its row. +# +# The copy runs because the framework it links against is named by an absolute +# path; what it loses is the tree it was found in, which is what PYTHONHOME and +# PYTHONPATH below hand back. Both of those are what a `brew upgrade python` +# moves out from under it, so the launcher checks before it starts: a bundle +# that fails silently is a menu bar with nothing in it and no way to guess why. +PY_REAL="$("$PY" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", sys.executable)))')" +PY_HOME="$("$PY" -c 'import sys; print(sys.base_prefix)')" +PY_SITE="$("$PY" -c 'import site; print(site.getsitepackages()[0])')" +cp -f "$PY_REAL" "$APP/Contents/MacOS/python3" + +# exec, and this time it is right. The earlier version of this script did not +# exec, because exec'ing an interpreter outside the bundle throws away the +# application registration LaunchServices handed to the process it started, and +# what is left answers the socket while drawing no menu bar icon at all. The +# target here is inside the bundle, so the registration survives it. +cat > "$APP/Contents/MacOS/Dikte" </dev/null 2>&1 + exit 1 +fi +exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@" +EOF +chmod +x "$APP/Contents/MacOS/Dikte" + +# LSUIElement is the line that makes this a menu bar application: no Dock icon, +# no menu bar of its own, nothing in the app switcher. The usage strings are +# not decoration either, they are what the permission dialog reads out, and a +# bundle that asks for the microphone without one is killed rather than asked +# about. +version="$(cd "$DIR" && git describe --tags --always 2>/dev/null || echo 0)" +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName Dikte + CFBundleDisplayName Dikte + CFBundleIdentifier $AGENT_ID + CFBundleExecutable Dikte + CFBundleIconFile Dikte + CFBundlePackageType APPL + CFBundleShortVersionString$version + CFBundleVersion $version + LSMinimumSystemVersion 11.0 + NSHighResolutionCapable + LSUIElement + 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. + + +EOF +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# The icon, drawn by trayicon.py so that there is no binary in the repository +# 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 \ + && iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then + ok "Icon drawn" +else + warn "Could not build the icon; the bundle gets the generic one" +fi +rm -rf "$(dirname "$iconset")" + +# Signed, ad-hoc, and this is the load-bearing step. macOS remembers a +# permission against a code signature; with none, it remembers a path and a +# hash of the bundle, and every reinstall is a bundle it has never seen, which +# means granting Accessibility and the microphone again each time. --force +# because a reinstall is signing over the last signature. +if codesign --force --sign - --identifier "$AGENT_ID" "$APP" 2>/dev/null; then + ok "Application bundle: $APP" +else + warn "Could not sign $APP; macOS will ask for its permissions again on every" + say "reinstall. Install the command line tools: xcode-select --install" +fi + +# LaunchServices does not necessarily notice a bundle written under a directory +# it was not watching, and until it does, `open -a Dikte` cannot find it. +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$APP" >/dev/null 2>&1 || true + +# 4. The command ------------------------------------------------------------ +# A wrapper, where Linux gets a symlink to dikte.py. 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 +# scripts cannot disagree about which Python this installation is on. +mkdir -p "$BIN_DIR" +cat > "$BIN_DIR/dikte" < "$AGENT" < + + + + Label $AGENT_ID + ProgramArguments + + /usr/bin/open + -a + $APP + + RunAtLoad + KeepAlive + ProcessType Interactive + + +EOF +# Through `open` rather than running the launcher directly, so that the process +# is one LaunchServices started: that is what gives it the bundle's identity, +# and so the permissions granted to Dikte rather than to launchd. +launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true +if launchctl bootstrap "gui/$(id -u)" "$AGENT" >/dev/null 2>&1; then + ok "Will start automatically on login" +else + warn "Could not register the login item; add $APP under" + say "System Settings → General → Login Items instead." +fi + +# 6. The shortcuts ---------------------------------------------------------- +# Dikte registers them rather than this script writing a file: macOS keeps no +# shortcut registry at all, so "installed" means the running application is +# holding the combination, and the settings file is where it reads it from +# after a restart. Nothing here needs a logout, unlike KDE. +if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then + warn "Both arguments 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 + ok "$3: $2" + else + warn "${out%%$'\n'*}" + fi +} +register toggle "$SHORTCUT" "Start and stop" +if [[ -n "$CANCEL_SHORTCUT" ]]; then + register cancel "$CANCEL_SHORTCUT" "Discard the recording" +fi + +# 7. What is left to do by hand --------------------------------------------- +# Two things this script cannot do, because macOS only takes them from the +# person at the keyboard. +echo +say "Two permissions have to be granted the first time, and macOS will ask:" +say " • Microphone, when the first recording starts" +say " • Accessibility, when the first transcript is pasted" +say "Both are under System Settings → Privacy & Security." +if ! system_profiler SPAudioDataType 2>/dev/null | grep -qi 'blackhole\|loopback\|soundflower'; then + echo + say "Recording a meeting also needs a loopback driver: macOS does not offer" + say "what the speakers are playing as something to record. Install one with:" + say " brew install blackhole-2ch" + say "Dictation does not need it." +fi + +echo +ok "Done. Start it with: open -a Dikte" +say "The settings window opens on first run: download a speech model, or add" +say "an OpenAI or OpenRouter key instead." +echo diff --git a/install.sh b/install.sh index c55b4c0..7d4f94f 100755 --- a/install.sh +++ b/install.sh @@ -3,6 +3,15 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Nothing below this line is true on a Mac: no XDG directories, no .desktop +# files, no shortcut registry, and an application is a bundle rather than a +# 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" "$@" +fi + PY="$(command -v python3)" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" diff --git a/paste.py b/paste.py index 030f203..d39198b 100644 --- a/paste.py +++ b/paste.py @@ -200,6 +200,14 @@ def _macos_api(): tool="CoreGraphics", error=exc)) from exc services.AXIsProcessTrusted.argtypes = [] services.AXIsProcessTrusted.restype = ctypes.c_bool + services.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p] + services.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool + core.CFDictionaryCreate.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), ctypes.c_long, + ctypes.c_void_p, ctypes.c_void_p, + ] + core.CFDictionaryCreate.restype = ctypes.c_void_p services.CGEventCreateKeyboardEvent.argtypes = [ ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool, ] @@ -224,16 +232,60 @@ def _macos_trusted(): _asked_for_permission = False -def _ask_for_permission(): - """Open the one settings pane that grants it, and only the first time. +def _macos_prompt_options(services, core): + """{kAXTrustedCheckOptionPrompt: true}, as a CFDictionary, or 0. - Every dictation would otherwise reopen it until the box is ticked, which is - a window in the user's face on top of the paste that did not happen. + Built by hand because there is no Objective-C bridge here and this is the + only dictionary Dikte ever makes. Its own function so that a test can hand + back something without a framework to read the constants out of. + """ + keys = (ctypes.c_void_p * 1)( + ctypes.c_void_p.in_dll(services, "kAXTrustedCheckOptionPrompt")) + values = (ctypes.c_void_p * 1)( + ctypes.c_void_p.in_dll(core, "kCFBooleanTrue")) + return core.CFDictionaryCreate( + None, keys, values, 1, + ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryKeyCallBacks")), + ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryValueCallBacks")), + ) + + +def _macos_put_us_in_the_list(): + """Ask with the prompt, which is what creates the row to switch on. + + AXIsProcessTrusted only answers the question, and an application that has + only ever asked it is not in Accessibility at all: the pane opens on a list + Dikte is not in, and the only way through is the + button and a trip to the + Applications folder. Asking with kAXTrustedCheckOptionPrompt puts it there, + and macOS shows its own dialog with the button that opens the pane. + """ + services, core = _macos_api() + options = _macos_prompt_options(services, core) + if not options: + return + try: + services.AXIsProcessTrustedWithOptions(options) + finally: + core.CFRelease(options) + + +def _ask_for_permission(): + """Get Dikte into the Accessibility list, and only the first time. + + Every dictation would otherwise reopen the pane until the box is ticked, + which is a window in the user's face on top of the paste that did not + happen. """ global _asked_for_permission if _asked_for_permission: return _asked_for_permission = True + try: + _macos_put_us_in_the_list() + except (PasteError, OSError, ValueError): + # An older macOS, or a framework that would not load: the pane below is + # still worth opening, even if the row has to be added by hand. + pass try: subprocess.Popen( ["open", ("x-apple.systempreferences:com.apple.preference.security" diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..8493746 --- /dev/null +++ b/paths.py @@ -0,0 +1,36 @@ +"""Where Dikte keeps its settings and its data, for the system it is on. + +A module of its own because two others need the answer and one of them cannot +ask the other: config.py imports ggml.py, so ggml.py cannot import config.py +back. Left alone, each worked it out for itself, and only config.py knew about +macOS. The result on a Mac was settings under `~/Library/Application Support` +and several gigabytes of models under `~/.local/share`, which is not a place a +Mac user looks, and not a place `uninstall.sh --purge` would have deleted from. + +Read at import, as the modules that use it already do. `directories()` takes the +platform as an argument so that a test can stand on the other one. +""" + +import os +import pathlib +import sys + + +def _xdg(var, default): + return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) + + +def directories(platform=None): + """(settings, data), in the two places this system keeps them. + + macOS keeps both in the one directory a Mac user's backup already knows + about. Everywhere else they are separate and follow the XDG variables. + """ + if (platform or sys.platform) == "darwin": + support = pathlib.Path.home() / "Library/Application Support/Dikte" + return support, support + return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", + _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") + + +CONFIG_DIR, DATA_DIR = directories() diff --git a/settings_ui.py b/settings_ui.py index 87d2611..b671931 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -671,6 +671,14 @@ class SettingsWindow(QDialog): self.local_threads = QSpinBox() self.local_threads.setRange(0, 64) self.local_threads.setSpecialValueText(t("Automatic")) + # A spin box asks for room for its numbers, and 64 is two characters: + # the word standing in for zero is what actually has to fit, and on + # macOS, where the stepper sits inside the frame, it does not. Widened + # to the word rather than to a number picked by eye, so that it still + # fits once the word is "Otomatik". + self.local_threads.setMinimumWidth( + self.local_threads.fontMetrics() + .horizontalAdvance(t("Automatic")) + 56) self.local_options = QWidget() options_form = QFormLayout(self.local_options) options_form.setContentsMargins(0, 0, 0, 0) @@ -1618,7 +1626,8 @@ class SettingsWindow(QDialog): # turns them off. for which, (box, _status, _missing) in self._shortcut_rows.items(): spec = hotkey.SHORTCUTS[which] - conf[spec.setting] = box.currentText().strip() or spec.fallback + conf[spec.setting] = (box.currentText().strip() + or hotkey.default_combo(which)) conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["history_limit"] = self.history_limit.value() conf.save() @@ -1864,7 +1873,7 @@ class SettingsWindow(QDialog): def _install_shortcut(self, which): spec = hotkey.SHORTCUTS[which] box, _status, _missing = self._shortcut_rows[which] - combo = box.currentText().strip() or spec.fallback + combo = box.currentText().strip() or hotkey.default_combo(which) if not combo: QMessageBox.information(self, t("Shortcut"), t("Type a key combination first.")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9547df2..ebfce3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -333,6 +333,57 @@ class ConfigCommands(DikteTest): {"cleanup", "subtitles", "meeting", "agent"}) +class ShortcutStatus(DikteTest): + """Where the answer comes from, which is not one place on every system. + + macOS keeps no shortcut registry: a combination is held by the running + process and by nothing else, so a command line that reads its own idea of + "installed" reports every shortcut as missing while all of them work. + """ + + def run_cmd(self, func, **values): + with captured() as (out, err): + code = func(Options(**values)) + return code, out.getvalue(), err.getvalue() + + def status(self, reply): + with mock.patch.object(ipc, "send", return_value=reply) as send: + code, out, _ = self.run_cmd(cli.cmd_shortcut, shortcut="status", + json=True) + return code, json.loads(out), send + + def test_what_the_running_instance_holds_is_what_is_reported(self): + code, answer, _ = self.status({ + "shortcuts": {"toggle": "Ctrl+Option+Space", "cancel": None, + "ask": None, "meeting": None}, + "listener": True, + }) + self.assertEqual(code, 0) + self.assertEqual(answer["shortcuts"]["toggle"]["registered"], + "Ctrl+Option+Space") + self.assertIsNone(answer["shortcuts"]["cancel"]["registered"]) + self.assertIs(answer["listener"], True) + + def test_the_instance_is_the_one_asked(self): + _, _, send = self.status({"shortcuts": {}, "listener": False}) + send.assert_called_once_with("status") + + def test_nothing_running_falls_back_to_what_this_process_can_read(self): + """Which on Linux is the registry, and on macOS is nothing, correctly + so, because there the keys really are gone with the process.""" + code, answer, _ = self.status(None) + self.assertEqual(code, 0) + for name, spec in hotkey.SHORTCUTS.items(): + with self.subTest(name=name): + self.assertEqual(answer["shortcuts"][name]["registered"], + hotkey.shortcut_status(spec.desktop_id)) + + def test_the_configured_combination_is_reported_either_way(self): + code, answer, _ = self.status(None) + self.assertEqual(answer["shortcuts"]["toggle"]["configured"], + cfg.Config()["shortcut"]) + + class Providers(DikteTest): """The terminal reaches every provider the settings window does.""" diff --git a/tests/test_config.py b/tests/test_config.py index 31e153c..57482be 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -460,34 +460,6 @@ class Defaults(unittest.TestCase): paste.desktop().shortcuts[0]) -class Directories(unittest.TestCase): - """Where the settings and the recordings are kept, per system.""" - - def test_linux_keeps_them_apart_and_follows_xdg(self): - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", - "XDG_DATA_HOME": "/d"}): - config_dir, data_dir = cfg._directories("linux") - self.assertEqual(str(config_dir), "/c/dikte") - self.assertEqual(str(data_dir), "/d/dikte") - - def test_linux_without_the_variables_set(self): - with mock.patch.dict(os.environ, {}, clear=True): - config_dir, data_dir = cfg._directories("linux") - self.assertTrue(str(config_dir).endswith("/.config/dikte")) - self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) - - def test_a_mac_keeps_both_in_application_support(self): - config_dir, data_dir = cfg._directories("darwin") - self.assertEqual(config_dir, data_dir) - self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) - - def test_a_mac_does_not_read_the_xdg_variables(self): - """A Mac with them set from some other tool still stores in one place.""" - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): - config_dir, _ = cfg._directories("darwin") - self.assertNotIn("/c", str(config_dir)) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 161e688..983041e 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -226,7 +226,7 @@ class InstallProgram(Local): with fake_urlopen(listing): with self.assertRaises(ggml.LocalError) as caught: ggml.install_program(ggml.WHISPER) - self.assertIn("brew install whisper-cpp", str(caught.exception)) + self.assertIn("Build whisper-server yourself", str(caught.exception)) def test_a_mac_uses_the_native_llama_archive_instead_of_ubuntu(self): self.patch_attr(sys, "platform", "darwin") diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index c6ec50f..388c41b 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -78,6 +78,16 @@ class Table(unittest.TestCase): self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items() if spec.fallback], ["toggle"]) + def test_the_fallback_a_mac_gets_is_not_one_macos_already_holds(self): + """Ctrl+Space switches the input source there and Cmd+Space is + Spotlight, so the table's own fallback is Linux's and only Linux's.""" + with mock.patch.object(hotkey.sys, "platform", "darwin"): + self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Option+Space") + self.assertEqual(hotkey.default_combo("cancel"), "") + with mock.patch.object(hotkey.sys, "platform", "linux"): + self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Space") + self.assertEqual(hotkey.default_combo("cancel"), "") + class ModsMatch(unittest.TestCase): """The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space.""" diff --git a/tests/test_paste.py b/tests/test_paste.py index 4bfe99f..85c13cd 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -275,11 +275,16 @@ class FakeCoreGraphics: self.flags = [] # (event, flags) self.posted = [] # (tap, event) self.released = [] + self.prompted = [] # the options dictionaries asked with # --- ApplicationServices def AXIsProcessTrusted(self): return self.trusted + def AXIsProcessTrustedWithOptions(self, options): + self.prompted.append(options) + return self.trusted + def CGEventCreateKeyboardEvent(self, source, keycode, down): self.made.append((keycode, down)) if self.makes is not None and len(self.made) > self.makes: @@ -301,10 +306,16 @@ class MacOS(ClipboardContract, DikteTest): platform = "darwin" here = paste.MACOS + # A stand-in for the CFDictionary: the real one is built out of constants + # read from the frameworks, which a fake has none of. + OPTIONS = 4242 + def setUp(self): super().setUp() self.api = FakeCoreGraphics() self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api)) + self.patch_attr(paste, "_macos_prompt_options", + lambda services, core: self.OPTIONS) self.patch_attr(paste.time, "sleep", lambda seconds: None) # It opens the settings pane once per run; each test gets its own run. self.patch_attr(paste, "_asked_for_permission", False) @@ -385,6 +396,31 @@ class MacOS(ClipboardContract, DikteTest): paste.press("cmd+v") self.assertIn("Accessibility", str(caught.exception)) + def test_asking_is_what_puts_dikte_in_the_accessibility_list(self): + """Opening the pane is not enough on its own. + + AXIsProcessTrusted only answers the question; an application that has + never asked with the prompt is not in the list, so the pane opens on a + list Dikte is not in and the only way through is the + button. + """ + self.api.trusted = False + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.assertEqual(self.api.prompted, [self.OPTIONS]) + # The dictionary is ours to release, and nothing else made an event. + self.assertIn(self.OPTIONS, self.api.released) + + def test_it_asks_once_however_many_dictations_fail(self): + self.api.trusted = False + for _ in range(3): + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.assertEqual(len(self.api.prompted), 1) + + def test_a_trusted_process_is_never_prompted(self): + paste.press("cmd+v") + self.assertEqual(self.api.prompted, []) + def test_readiness_is_the_permission_rather_than_a_program(self): self.assertTrue(paste.paste_ready()) self.api.trusted = False diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..d932e0f --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,62 @@ +"""Where the settings and the data are kept, per system. + +Its own file because the answer has to be one answer: config.py and ggml.py +both need it, and when each worked it out for itself only one of them knew +about macOS. +""" + +import os +import unittest +from unittest import mock + +import config as cfg +import ggml +import paths + + +class Directories(unittest.TestCase): + def test_linux_keeps_them_apart_and_follows_xdg(self): + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", + "XDG_DATA_HOME": "/d"}): + config_dir, data_dir = paths.directories("linux") + self.assertEqual(str(config_dir), "/c/dikte") + self.assertEqual(str(data_dir), "/d/dikte") + + def test_linux_without_the_variables_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + config_dir, data_dir = paths.directories("linux") + self.assertTrue(str(config_dir).endswith("/.config/dikte")) + self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) + + def test_a_mac_keeps_both_in_application_support(self): + config_dir, data_dir = paths.directories("darwin") + self.assertEqual(config_dir, data_dir) + self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) + + def test_a_mac_does_not_read_the_xdg_variables(self): + """A Mac with them set from some other tool still stores in one place.""" + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + config_dir, _ = paths.directories("darwin") + self.assertNotIn("/c", str(config_dir)) + + +class OnePlace(unittest.TestCase): + """The programs and the models go where everything else goes. + + ggml.py used to read XDG_DATA_HOME itself, which is right on Linux and + wrong on a Mac: the settings and the dictations went to ~/Library while + several gigabytes of models went to ~/.local/share, where no Mac user looks + and where `uninstall.sh --purge` would never have found them. + """ + + def test_the_models_live_under_the_data_directory(self): + self.assertEqual(ggml.DATA_DIR, paths.DATA_DIR) + self.assertEqual(ggml.MODELS_DIR.parent, paths.DATA_DIR) + self.assertEqual(ggml.BIN_DIR.parent, paths.DATA_DIR) + + def test_config_and_ggml_cannot_disagree(self): + self.assertEqual(cfg.DATA_DIR, ggml.DATA_DIR) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 518166f..e069ad0 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -180,13 +180,19 @@ class Settings(DikteTest): def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self): """The application is unusable without the toggle, so that one box - falls back. The rest stay empty, which is how they are switched off.""" + falls back. The rest stay empty, which is how they are switched off. + + Which combination it falls back to is the platform's and is pinned in + test_hotkey; MacSettings runs this too, and there the answer is not + Ctrl+Space. + """ conf = cfg.Config() window = self.window(conf) for box, _status, _missing in window._shortcut_rows.values(): box.setCurrentText("") window._save() - self.assertEqual(conf["shortcut"], "Ctrl+Space") + self.assertTrue(conf["shortcut"]) + self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle")) self.assertEqual(conf["cancel_shortcut"], "") self.assertEqual(conf["assistant_shortcut"], "") self.assertEqual(conf["meeting_shortcut"], "") diff --git a/trayicon.py b/trayicon.py new file mode 100644 index 0000000..b5aafc2 --- /dev/null +++ b/trayicon.py @@ -0,0 +1,213 @@ +"""The three tray icons, drawn here for systems that have no icon theme. + +Linux hands out `audio-input-microphone`, `media-record` and `view-refresh` +from whatever icon theme is installed, and Qt finds them through +QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon +there, and a null icon in the menu bar is an item you cannot see, which is the +whole of Dikte's interface gone. So the same three shapes are drawn here, and +used whenever the theme has nothing to offer. + +They are drawn as template images: one colour, transparent everywhere else, +with isMask set. That is what lets macOS invert them for a dark menu bar and +grey them while the menu is open, and it is why the shapes are outlines rather +than the coloured glyphs a Linux theme would give. +""" + +import pathlib +import sys + +from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath, + QPen, QPixmap) + +# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the +# icon rather than one being scaled, because a scaled stroke goes soft. +SIZES = (22, 44) +# Drawn in black; the mask throws the colour away and keeps the coverage, and +# on a system that does not do masks black is still the right ink for a light +# panel and readable on a dark one. +INK = QColor(0, 0, 0) + + +def _canvas(size): + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + return pixmap, painter + + +def _paint_microphone(painter, size, ink): + """A capsule on a stand: idle, and the application's own mark. + + The colour is a parameter because the same glyph is the tray stencil, where + it is black and then masked, and the white one on the application icon. + """ + unit = size / 22.0 + pen = QPen(ink, 1.6 * unit) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(ink) + # The capsule, held away from the edges so the stroke below has room. + painter.drawRoundedRect( + QRectF(8.2 * unit, 3.4 * unit, 5.6 * unit, 10.4 * unit), + 2.8 * unit, 2.8 * unit, + ) + painter.setBrush(Qt.BrushStyle.NoBrush) + # The arc that cradles it, and the post and foot under that. + painter.drawArc( + QRectF(5.4 * unit, 6.6 * unit, 11.2 * unit, 10.4 * unit), + 180 * 16, 180 * 16, + ) + painter.drawLine(QPointF(11 * unit, 16.8 * unit), QPointF(11 * unit, 19 * unit)) + painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit)) + + +def _microphone(painter, size): + _paint_microphone(painter, size, INK) + + +def _record(painter, size): + """A filled dot: recording, and the same red dot the overlay shows.""" + unit = size / 22.0 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(INK) + painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit) + + +def _working(painter, size): + """An arrow chasing its own circle: transcribing, cleaning up, thinking.""" + unit = size / 22.0 + pen = QPen(INK, 2.0 * unit) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + ring = QRectF(4.4 * unit, 4.4 * unit, 13.2 * unit, 13.2 * unit) + # Three quarters of the way round, leaving the gap the head sits in. + painter.drawArc(ring, 90 * 16, -280 * 16) + + # The head, as a filled triangle at the open end rather than two more + # strokes: at 22 points a drawn arrowhead closes up into a blob. + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(INK) + head = QPainterPath() + head.moveTo(QPointF(11.0 * unit, 1.6 * unit)) + head.lineTo(QPointF(11.0 * unit, 7.2 * unit)) + head.lineTo(QPointF(15.8 * unit, 4.4 * unit)) + head.closeSubpath() + painter.drawPath(head) + + +# The names Linux themes use, which are what dikte.py asks for either way. +SHAPES = { + "audio-input-microphone": _microphone, + "media-record": _record, + "view-refresh": _working, +} + +_cache = {} + + +def icon(name): + """The named icon drawn here, or a null QIcon when it is not one of ours. + + Cached because the tray is refreshed on every state change and every one of + those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the + pixmaps inside it are shared, so handing the same object out is safe. + """ + shape = SHAPES.get(name) + if shape is None: + return QIcon() + if name in _cache: + return _cache[name] + + result = QIcon() + for size in SIZES: + pixmap, painter = _canvas(size) + try: + shape(painter, size) + finally: + painter.end() + result.addPixmap(pixmap) + # The line that makes it a template image: macOS then owns the colour, and + # the icon follows the menu bar into dark mode instead of staying black. + result.setIsMask(True) + _cache[name] = result + return result + + +# --- the application icon -------------------------------------------------- +# +# The menu bar wants a flat stencil; the Finder, the Dock and the permission +# dialogs want a picture. Same microphone, on a ground of its own, and drawn +# here as well so that `install-mac.sh` has an .icns to build without a binary +# blob living in the repository. + +# What iconutil expects to find in an .iconset: each of these at 1x and 2x. +APP_ICON_SIZES = (16, 32, 128, 256, 512) + + +def app_pixmap(size): + """The application icon at one size: a white microphone on a blue tile.""" + pixmap, painter = _canvas(size) + try: + unit = size / 22.0 + # macOS rounds and shadows the tile itself for some icon styles but not + # for a plain .icns, so the shape is drawn: the squircle radius Apple + # uses is close enough to 22% of the side. + ground = QLinearGradient(0, 0, 0, size) + ground.setColorAt(0.0, QColor(0x3B, 0x82, 0xF6)) + ground.setColorAt(1.0, QColor(0x1D, 0x4E, 0xD8)) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(ground) + inset = 1.0 * unit + painter.drawRoundedRect( + QRectF(inset, inset, size - 2 * inset, size - 2 * inset), + 4.4 * unit, 4.4 * unit, + ) + # The same glyph as the tray, in white and a little smaller so it sits + # inside the tile rather than against its edges. + painter.save() + painter.translate(size / 2.0, size / 2.0) + painter.scale(0.64, 0.64) + painter.translate(-size / 2.0, -size / 2.0) + _paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF)) + painter.restore() + finally: + painter.end() + return pixmap + + +def write_iconset(directory): + """Write the PNGs `iconutil -c icns` reads. The directory it wrote to.""" + directory = pathlib.Path(directory) + directory.mkdir(parents=True, exist_ok=True) + for size in APP_ICON_SIZES: + for scale in (1, 2): + name = f"icon_{size}x{size}{'@2x' if scale == 2 else ''}.png" + app_pixmap(size * scale).save(str(directory / name), "PNG") + return directory + + +def _main(argv): + """`python3 trayicon.py .iconset`, which install-mac.sh calls. + + A QGuiApplication has to exist before a QPixmap can, and offscreen because + this runs from a shell script with no window to open. + """ + if len(argv) != 2: + print("usage: trayicon.py .iconset", file=sys.stderr) + return 2 + from PyQt6.QtGui import QGuiApplication + QGuiApplication.setAttribute( + Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) + app = QGuiApplication(["dikte-icon", "-platform", "offscreen"]) + try: + print(write_iconset(argv[1])) + finally: + del app + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv)) diff --git a/uninstall.sh b/uninstall.sh index dfbdaa1..b7e740b 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -5,13 +5,34 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PY="$(command -v python3 || true)" USER_NAME="$(id -un)" BIN_DIR="$HOME/.local/bin" -APP_DIR="$HOME/.local/share/applications" -AUTOSTART_DIR="$HOME/.config/autostart" -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" -DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" + +# What an installation is made of differs by platform, but what --purge deletes +# and what it asks before deleting it does not, which is why this is one script +# with two sets of paths rather than two scripts. +if [[ "$(uname -s)" == "Darwin" ]]; then + MACOS=1 + MAC_APP="$HOME/Applications/Dikte.app" + AGENT_ID="io.github.yusufipk.dikte" + AGENT="$HOME/Library/LaunchAgents/$AGENT_ID.plist" + CONFIG_DIR="$HOME/Library/Application Support/Dikte" + DATA_DIR="$CONFIG_DIR" + # The interpreter this installation was put on, which is not necessarily the + # python3 on PATH: Apple's is 3.9 and cannot run Dikte. The wrapper written + # by install-mac.sh names it, so it is read back out of there. + # `|| true` because there may be no wrapper to read: pipefail would otherwise + # make a missing file the end of the script rather than a question answered no. + PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$BIN_DIR/dikte" 2>/dev/null | head -1 || true)" + [[ -x "$PY" ]] || PY="$(command -v python3 || true)" +else + MACOS=0 + APP_DIR="$HOME/.local/share/applications" + AUTOSTART_DIR="$HOME/.config/autostart" + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" + DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" + PY="$(command -v python3 || true)" +fi PURGE=0 ASSUME_YES=0 @@ -63,8 +84,12 @@ echo "──────────────────" # 1. Global shortcuts ------------------------------------------------------ # Handed to Dikte while it can still run, because it is the half that knows -# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. -if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then +# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. macOS +# keeps no registry: the combinations are held by the running process and are +# gone the moment it stops, so there is nothing here to take back. +if ((MACOS)); then + say "Nothing to unregister: macOS shortcuts live only while Dikte runs." +elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then for which in toggle cancel ask meeting; do "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true done @@ -89,25 +114,49 @@ if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then fi # 3. Launchers ------------------------------------------------------------- -# Only our own symlink goes: a file of the same name that somebody else put -# there is not ours to delete. +# Only what this installer wrote goes: a file of the same name that somebody +# else put there is not ours to delete. On Linux ours is a symlink; on macOS it +# is a generated wrapper, which says so in its second line. if [[ -L "$BIN_DIR/dikte" ]]; then remove "$BIN_DIR/dikte" +elif ((MACOS)) && grep -q 'Written by install-mac.sh' "$BIN_DIR/dikte" 2>/dev/null; then + remove "$BIN_DIR/dikte" elif [[ -e "$BIN_DIR/dikte" ]]; then - warn "$BIN_DIR/dikte is not our symlink, leaving it alone" + warn "$BIN_DIR/dikte is not ours, leaving it alone" else gone "Was not there: $BIN_DIR/dikte" fi -remove "$APP_DIR/dikte.desktop" -remove "$AUTOSTART_DIR/dikte.desktop" -# Removing the shortcut takes its desktop file with it, but an install from -# before this script existed may have left one behind on a desktop that never -# used them. -for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do - if [[ -e "$APP_DIR/$id.desktop" ]]; then - remove "$APP_DIR/$id.desktop" + +if ((MACOS)); then + # The login item first: bootout while the plist is still there, because + # launchctl is told which job by the file as much as by the label. + if [[ -e "$AGENT" ]]; then + launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true fi -done + remove "$AGENT" + if [[ -d "$MAC_APP" ]]; then + rm -rf "$MAC_APP" + ok "Removed $MAC_APP" + else + gone "Was not there: $MAC_APP" + fi + # macOS keeps the permissions filed against the bundle that asked for them, + # and deleting the bundle does not withdraw them. + say "Microphone and Accessibility are still granted to Dikte; take them back" + say "under System Settings → Privacy & Security if you want them gone." +fi +if ((!MACOS)); then + remove "$APP_DIR/dikte.desktop" + remove "$AUTOSTART_DIR/dikte.desktop" + # Removing the shortcut takes its desktop file with it, but an install from + # before this script existed may have left one behind on a desktop that never + # used them. + for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do + if [[ -e "$APP_DIR/$id.desktop" ]]; then + remove "$APP_DIR/$id.desktop" + fi + done +fi # 4. Settings and dictations ----------------------------------------------- echo @@ -142,6 +191,11 @@ fi if ((PURGE)); then rm -rf "$CONFIG_DIR" "$DATA_DIR" ok "Settings and dictations deleted" +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" else say "Settings kept: $CONFIG_DIR" say "Dictations kept: $DATA_DIR" diff --git a/update.sh b/update.sh index 9194407..18cc40d 100755 --- a/update.sh +++ b/update.sh @@ -3,9 +3,22 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PY="$(command -v python3 || true)" USER_NAME="$(id -un)" +if [[ "$(uname -s)" == "Darwin" ]]; then + # Apple's python3 is 3.9 and will stay 3.9, so the one on PATH is the wrong + # question here. install-mac.sh names the interpreter it installed against in + # the wrapper it wrote, and that is the one this installation runs on. + # `|| true` because there may be no wrapper to read: pipefail would otherwise + # 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"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } @@ -83,7 +96,7 @@ echo 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:-Ctrl+Space}" "${cancel_shortcut:-}" +"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}" # 5. The running instance --------------------------------------------------- # It is still running the code from before the pull.