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 b2cc0f9..acd91d7 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. Windows works the same way, holding the keys through the system's own hotkey service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`, @@ -195,8 +216,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/audio.py b/audio.py index 5108043..292b533 100644 --- a/audio.py +++ b/audio.py @@ -2,9 +2,11 @@ Dictation records one source. A meeting records two of them at once, the microphone and what comes out of the speakers, and for that it goes through -ffmpeg: one process reading both devices and merging them into the two channels -of a single stream, which is the only way the two stay aligned with each other -over an hour. +ffmpeg. PulseAudio hands both devices to a single process, which merges them +into the two channels of one stream and keeps them aligned itself. AVFoundation +cannot be asked the same: two of its sessions inside one process starve each +other, so a Mac captures each device on its own and the two mono streams are +interleaved here as they arrive. Which programs do the capturing is a property of the machine, not of the code above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS. @@ -17,6 +19,7 @@ import collections import json import math import os +import queue import re import shutil import signal @@ -42,6 +45,21 @@ CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +# A capture process hands over a block every CHUNK_LATENCY_MS. One that has said +# nothing for this long has stopped rather than fallen behind, and the meeting +# ends and says so instead of sitting on a read that will never return. +STALL_SECONDS = 5.0 +# Room for a whole stall of the other stream, so the side still delivering is +# never the one left waiting. +QUEUE_BLOCKS = int(STALL_SECONDS * RATE / CHUNK_FRAMES) + 8 + +# Exact zeroes are not quiet, they are nothing: a microphone that is really in +# the room has a noise floor. This much of a recording that long means it handed +# nothing over, which is worth saying once the meeting is over and nothing can +# be done about it any more. +QUIET_MIC_SECONDS = 10 +QUIET_MIC_SHARE = 0.5 + def _interrupt(proc): """Ask a recorder process to end. @@ -80,7 +98,11 @@ class Recorder(QObject): def start(self, target="", max_seconds=300): if self.active: return - cmd = recording_command(target) + try: + cmd = recording_command(target) + except AudioDeviceError as exc: + self.failed.emit(str(exc)) + return if not cmd: self.failed.emit(t(sound().missing)) return @@ -203,11 +225,21 @@ def recording_command(target=""): return sound().record(target) -def meeting_command(mic_target, system_target): - """One ffmpeg reading both devices and merging them into two channels.""" +def meeting_commands(mic_target, system_target): + """The capture processes that produce one stereo meeting stream. + + One of them on PulseAudio, which merges both inputs itself; one per device + on a Mac, because two AVFoundation sessions in a process starve each other. + Which of the two it is stays in the table with everything else the sound + system decides, and MeetingRecorder reads the count rather than the machine. + """ return sound().meeting(mic_target, system_target) +class AudioDeviceError(RuntimeError): + """A saved capture device can no longer be selected safely.""" + + class MeetingRecorder(QObject): """Microphone and speaker output into one stereo file: left is you, right is everyone else. @@ -221,16 +253,19 @@ class MeetingRecorder(QObject): levels = pyqtSignal(float, float) # mine, theirs stopped = pyqtSignal(str, float) # wav path, duration (s) died = pyqtSignal() # ffmpeg quit on its own + warned = pyqtSignal(str) # recorded, but something was wrong failed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) - self._proc = None + self._procs = [] self._thread = None self._wav = None - self._log = None + self._logs = [] self._path = "" self._frames = 0 + self._mic_zero_frames = 0 + self._split_inputs = False self._cancelled = False self._stopping = False self._lock = threading.Lock() @@ -252,7 +287,11 @@ class MeetingRecorder(QObject): "Pick one in Settings → Meeting.")) return - cmd = meeting_command(mic_target, system_target) + try: + commands = meeting_commands(mic_target, system_target) + except AudioDeviceError as exc: + self.failed.emit(str(exc)) + return try: os.makedirs(os.path.dirname(path), exist_ok=True) @@ -262,12 +301,18 @@ class MeetingRecorder(QObject): self._wav.setframerate(RATE) # ffmpeg keeps talking to stderr for as long as it runs; a pipe # nobody drains would eventually block it, so it writes to a file. - self._log = tempfile.TemporaryFile() - self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0, - creationflags=NO_WINDOW, - ) + self._logs = [tempfile.TemporaryFile() for _ in commands] + self._procs = [] + for command, log in zip(commands, self._logs): + self._procs.append(subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=log, bufsize=0, + creationflags=NO_WINDOW, + )) except (OSError, wave.Error) as exc: + # One of two capture processes may already be running, and a Mac + # left holding an open AVFoundation session records nothing else. + self._terminate_processes() + self._procs = [] self._close_file() self._drop_log() try: @@ -279,6 +324,8 @@ class MeetingRecorder(QObject): self._path = path self._frames = 0 + self._mic_zero_frames = 0 + self._split_inputs = len(self._procs) == 2 self._cancelled = False self._stopping = False self._max_frames = int(max_seconds * RATE) @@ -286,38 +333,83 @@ class MeetingRecorder(QObject): self._thread.start() def _pump(self): - stdout = self._proc.stdout - block = CHUNK_FRAMES * SAMPLE_WIDTH * 2 - try: - while True: - chunk = stdout.read(block) - if not chunk: - break - mine, theirs = stereo_levels(chunk) - with self._lock: - if self._wav is None: - break - self._wav.writeframes(chunk) - self._frames += len(chunk) // (SAMPLE_WIDTH * 2) - too_long = self._frames >= self._max_frames - self.levels.emit(mine, theirs) - if too_long: - self._terminate() - break - except (OSError, ValueError, wave.Error): - pass + if self._split_inputs: + self._pump_split() + else: + self._pump_merged() # Nobody asked it to end: the sound device went away, or ffmpeg fell # over. An hour into a meeting that has to be said out loud rather than # discovered afterwards. if not self._stopping: self.died.emit() + def _pump_merged(self): + stdout = self._procs[0].stdout + block = CHUNK_FRAMES * SAMPLE_WIDTH * 2 + try: + while True: + chunk = stdout.read(block) + if not chunk: + break + if not self._write_chunk(chunk): + break + except (OSError, ValueError, wave.Error): + pass + + def _pump_split(self): + # A reader thread per process. Taking turns on the two pipes from one + # thread would let a starved microphone hold up the far side too: its + # blocks would sit unread until the pipe filled and its ffmpeg stopped + # writing, and an hour of meeting would freeze with nothing said. Each + # stream is read as fast as it arrives, and a side that goes quiet for + # STALL_SECONDS ends the recording rather than hanging it. + block = CHUNK_FRAMES * SAMPLE_WIDTH + streams = [queue.Queue(maxsize=QUEUE_BLOCKS) for _ in self._procs] + for proc, blocks in zip(self._procs, streams): + threading.Thread(target=_read_blocks, daemon=True, + args=(proc.stdout, blocks, block)).start() + try: + while True: + mine = _next_block(streams[0]) + theirs = _next_block(streams[1]) + if not mine or not theirs: + break + frames = min(len(mine), len(theirs)) // SAMPLE_WIDTH + mine = mine[:frames * SAMPLE_WIDTH] + theirs = theirs[:frames * SAMPLE_WIDTH] + self._mic_zero_frames += _zero_samples(mine) + if not self._write_chunk(interleave_mono(mine, theirs)): + break + except (OSError, ValueError, wave.Error): + pass + + def _write_chunk(self, chunk): + mine, theirs = stereo_levels(chunk) + with self._lock: + if self._wav is None: + return False + self._wav.writeframes(chunk) + self._frames += len(chunk) // (SAMPLE_WIDTH * 2) + too_long = self._frames >= self._max_frames + self.levels.emit(mine, theirs) + if too_long: + self._terminate() + return False + return True + def _terminate(self): self._stopping = True - proc = self._proc - if proc and proc.poll() is None: + self._terminate_processes() + + def _terminate_processes(self): + running = [proc for proc in self._procs if proc.poll() is None] + for proc in running: try: _interrupt(proc) + except OSError: + pass + for proc in running: + try: proc.wait(timeout=2) except (subprocess.TimeoutExpired, OSError): try: @@ -335,23 +427,26 @@ class MeetingRecorder(QObject): pass def _error_tail(self): - if self._log is None: - return "" - try: - self._log.seek(0) - text = self._log.read().decode("utf-8", "replace").strip() - except OSError: - return "" - lines = [line for line in text.splitlines() if line.strip()] - return lines[-1] if lines else "" + tails = [] + for log in self._logs: + try: + log.seek(0) + text = log.read().decode("utf-8", "replace").strip() + except OSError: + continue + lines = [line for line in text.splitlines() if line.strip()] + if lines: + tails.append(lines[-1]) + return " | ".join(tails) def _finish_process(self): self._terminate() if self._thread: self._thread.join(timeout=3) self._thread = None - code = self._proc.poll() if self._proc else 0 - self._proc = None + codes = [proc.poll() for proc in self._procs] + code = next((value for value in codes if value), 0) + self._procs = [] self._close_file() return code @@ -365,7 +460,7 @@ class MeetingRecorder(QObject): pass def stop(self): - if not self._proc: + if not self._procs: return # The count is read after the join: the pump thread is still appending # the last blocks up to the moment it ends. @@ -390,15 +485,27 @@ class MeetingRecorder(QObject): ) return self._drop_log() + # A microphone that handed nothing over costs the left channel, and the + # recording is kept anyway: the right one is everyone else, and an hour + # of them is worth more than an empty channel costs. Only the split + # capture can starve a device this way; one ffmpeg reading both cannot. + if (self._split_inputs and frames >= RATE * QUIET_MIC_SECONDS + and self._mic_zero_frames / frames > QUIET_MIC_SHARE): + self.warned.emit(t( + "The microphone handed over almost nothing ({percent}% of the " + "recording was empty), so your own side of the meeting will be " + "mostly missing. Check the device before the next one.", + percent=round(self._mic_zero_frames / frames * 100), + )) self.stopped.emit(self._path, frames / RATE) def _drop_log(self): - if self._log is not None: + for log in self._logs: try: - self._log.close() + log.close() except OSError: pass - self._log = None + self._logs = [] def chunk_levels(chunk): @@ -424,6 +531,62 @@ def stereo_levels(chunk): return _peak(left), _peak(right) +def interleave_mono(left, right): + """Two mono-s16 buffers into one stereo-s16 buffer, the shorter one setting + the length.""" + left_samples, right_samples = _samples(left), _samples(right) + frames = min(len(left_samples), len(right_samples)) + stereo_samples = array.array("h", bytes(frames * 2 * SAMPLE_WIDTH)) + stereo_samples[0::2] = left_samples[:frames] + stereo_samples[1::2] = right_samples[:frames] + return stereo_samples.tobytes() + + +def _read_blocks(stream, blocks, size): + """One stream's blocks onto its queue, ending with the empty one. + + A queue that stays full is the pump having given up on this recording, and + then there is nobody left to hand anything to. + """ + try: + while True: + block = _read_exact(stream, size) + blocks.put(block, timeout=STALL_SECONDS) + if not block: + return + except (OSError, ValueError, queue.Full): + pass + + +def _next_block(blocks): + """The next block of a stream, empty once it ends or falls silent.""" + try: + return blocks.get(timeout=STALL_SECONDS) + except queue.Empty: + return b"" + + +def _read_exact(stream, size): + """Read one meter-sized block, tolerating short unbuffered pipe reads.""" + out = bytearray() + while len(out) < size: + chunk = stream.read(size - len(out)) + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def _zero_samples(chunk): + return _samples(chunk).count(0) + + +def _samples(chunk): + samples = array.array("h") + samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH]) + return samples + + def _peak(samples): if not samples: return 0.0 @@ -432,8 +595,9 @@ def _peak(samples): # --- the sound system, one group per machine ------------------------------- -# Both meeting commands merge the same way: each input down to mono at our own -# rate, then the two of them into the left and right of one stream. +# How the one PulseAudio process merges: each input down to mono at our own +# rate, then the two of them into the left and right of one stream. A Mac does +# the first half per process and the second half itself, in interleave_mono(). MERGE_FILTER = ( f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];" f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];" @@ -497,13 +661,14 @@ def _pw_record_raw_option(): def _pulse_meeting(mic_target, system_target): - return [ + """One process for both devices: PulseAudio keeps them aligned itself.""" + return [[ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default", "-f", "pulse", "-thread_queue_size", "4096", "-i", system_target, "-filter_complex", MERGE_FILTER, "-map", "[out]", "-f", "s16le", "-ar", str(RATE), "-", - ] + ]] def _pactl_sources(): @@ -561,6 +726,7 @@ LOOPBACK_DEVICES = ("blackhole", "loopback", "soundflower") def _avfoundation_record(target): if not shutil.which("ffmpeg"): return [] + target = _resolve_avfoundation_target(target) return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", # AVFoundation names an input "video:audio", so the empty half in front @@ -571,14 +737,26 @@ def _avfoundation_record(target): def _avfoundation_meeting(mic_target, system_target): + """A process per device, both names read off the same device listing. + + Asking ffmpeg what is plugged in costs a process of its own, and a listing + taken twice could renumber in between: the two targets have to be resolved + against the same one to name the same machine the user picked from. + """ + inputs = _avfoundation_inputs() + return [_avfoundation_meeting_capture(mic_target, inputs), + _avfoundation_meeting_capture(system_target, inputs)] + + +def _avfoundation_meeting_capture(target, inputs=None): + target = _resolve_avfoundation_target(target, inputs) return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-thread_queue_size", "4096", - "-f", "avfoundation", "-i", f":{mic_target or 'default'}", - "-thread_queue_size", "4096", - "-f", "avfoundation", "-i", f":{system_target}", - "-filter_complex", MERGE_FILTER, "-map", "[out]", - "-f", "s16le", "-ar", str(RATE), "-", + "-f", "avfoundation", "-i", f":{target or 'default'}", + "-af", (f"aresample={RATE}:async=1:first_pts=0," + "aformat=sample_fmts=s16:channel_layouts=mono"), + "-f", "s16le", "-ar", str(RATE), "-ac", "1", "-", ] @@ -615,10 +793,42 @@ def _avfoundation_inputs(): return devices +def _avfoundation_named_inputs(): + """Stable settings values: the name is saved, never the moving index.""" + return [(description, description) + for _index, description in _avfoundation_inputs()] + + +def _resolve_avfoundation_target(target, inputs=None): + """Resolve a stored device name to its current, positional ffmpeg index.""" + if not target or target == "default": + return "default" + if str(target).isdigit(): + raise AudioDeviceError(t( + "The saved macOS audio device uses an old numeric index. Open " + "Settings and select the device again before recording." + )) + if inputs is None: + inputs = _avfoundation_inputs() + matches = [index for index, description in inputs + if description == target] + if not matches: + raise AudioDeviceError(t( + "The saved macOS audio device is no longer connected: {device}. " + "Open Settings and select another device.", device=target, + )) + if len(matches) > 1: + raise AudioDeviceError(t( + "More than one macOS audio device is named {device}. Disconnect the " + "duplicate or choose a different device.", device=target, + )) + return matches[0] + + def _avfoundation_default_output(): - for name, description in _avfoundation_inputs(): + for _index, description in _avfoundation_inputs(): if any(word in description.lower() for word in LOOPBACK_DEVICES): - return name + return description return "" @@ -689,9 +899,10 @@ def _dshow_no_default_output(): Sound = collections.namedtuple( "Sound", - # How to capture one source and two at once, the two device lists, which - # device a meeting records the far side from, and what to say when the - # programs for any of it are not installed. + # How to capture one source and how to capture two at once, that one as the + # list of processes it takes, the two device lists, which device a meeting + # records the far side from, and what to say when the programs for any of + # it are not installed. "record meeting inputs outputs default_output missing", ) @@ -707,11 +918,11 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, meeting=_avfoundation_meeting, - inputs=_avfoundation_inputs, + inputs=_avfoundation_named_inputs, # Every macOS capture device is offered as the far side of a meeting, the # loopback driver among them: there is no way to tell them apart, and an # empty list would leave nothing to pick. - outputs=_avfoundation_inputs, + outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) diff --git a/cli.py b/cli.py index d282b2b..e6b4450 100644 --- a/cli.py +++ b/cli.py @@ -711,16 +711,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": @@ -728,7 +739,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 529c3c1..4dd4088 100644 --- a/config.py +++ b/config.py @@ -4,40 +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 - if (platform or sys.platform) == "win32": - roaming = pathlib.Path( - os.environ.get("APPDATA") or pathlib.Path.home() / "AppData/Roaming") - local = pathlib.Path( - os.environ.get("LOCALAPPDATA") or pathlib.Path.home() / "AppData/Local") - return roaming / "Dikte", local / "Dikte" - 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" @@ -444,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 d76a82a..7ce82a2 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 @@ -44,6 +46,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 @@ -124,6 +127,7 @@ class Dikte: self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) + self.meeting_recorder.warned.connect(self._on_meeting_warning) self.meeting_recorder.failed.connect(self._on_meeting_error) self.meetings.progress.connect(self._on_meeting_progress) self.meetings.finished.connect(self._on_meeting_finished) @@ -219,7 +223,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) @@ -474,6 +483,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): @@ -722,6 +737,11 @@ class Dikte: self._settle(MEETING, {"ok": False, "error": message}) self._on_error(message) + def _on_meeting_warning(self, message): + """It was recorded and it is being written up, but read this first.""" + self.tray.showMessage("Dikte", message, + QSystemTrayIcon.MessageIcon.Warning, 12000) + def _on_meeting_died(self): if self.meeting_state != M_RECORDING: return @@ -974,6 +994,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 "" @@ -981,6 +1046,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 890b641..39a6e21 100644 --- a/ggml.py +++ b/ggml.py @@ -46,21 +46,17 @@ import urllib.request import zipfile 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" -def _data_dir(): - if sys.platform == "win32": - return pathlib.Path(os.environ.get("LOCALAPPDATA") - or os.path.expanduser("~/AppData/Local")) / "Dikte" - return pathlib.Path(os.environ.get("XDG_DATA_HOME") - or os.path.expanduser("~/.local/share")) / "dikte" - - -DATA_DIR = _data_dir() +# 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" @@ -358,10 +354,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 89a6898..3423b89 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 @@ -718,6 +723,18 @@ def listener(parent=None): return 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.""" if _macos(): diff --git a/i18n.py b/i18n.py index 761416d..15c2133 100644 --- a/i18n.py +++ b/i18n.py @@ -137,6 +137,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", @@ -564,6 +571,24 @@ TR = { "Hangi ses çıkışının kaydedileceği anlaşılamadı. Ayarlar → Toplantı " "sekmesinden seç.", "Nothing was recorded: {error}": "Hiçbir şey kaydedilmedi: {error}", + "The saved macOS audio device uses an old numeric index. Open Settings and " + "select the device again before recording.": + "Kayıtlı macOS ses aygıtı eski bir sayısal indeks kullanıyor. Kayıttan " + "önce Ayarlar'ı açıp aygıtı yeniden seç.", + "The saved macOS audio device is no longer connected: {device}. Open " + "Settings and select another device.": + "Kayıtlı macOS ses aygıtı artık bağlı değil: {device}. Ayarlar'ı açıp " + "başka bir aygıt seç.", + "More than one macOS audio device is named {device}. Disconnect the " + "duplicate or choose a different device.": + "Birden fazla macOS ses aygıtının adı {device}. Aynı adlı aygıtlardan " + "birini çıkar ya da başka bir aygıt seç.", + "The microphone handed over almost nothing ({percent}% of the recording was " + "empty), so your own side of the meeting will be mostly missing. Check the " + "device before the next one.": + "Mikrofon neredeyse hiçbir şey iletmedi (kaydın %{percent} kadarı boştu), " + "toplantının senin tarafın büyük ölçüde eksik olacak. Bir sonrakinden " + "önce aygıtı kontrol et.", "Transcribing {side}: {index}/{count}…": "{side} yazıya çevriliyor: {index}/{count}…", "you": "sen", 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 1ca172b..772e8c4 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..7670c8b --- /dev/null +++ b/paths.py @@ -0,0 +1,44 @@ +"""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 _env(var, default): + """The directory a variable names, or the one it stands in for.""" + 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. Windows keeps them apart on purpose: settings roam with the account, + and several gigabytes of models are exactly what a roaming profile must not + carry. Everywhere else they are separate and follow the XDG variables. + """ + here = platform or sys.platform + if here == "darwin": + support = pathlib.Path.home() / "Library/Application Support/Dikte" + return support, support + if here == "win32": + roaming = _env("APPDATA", "~/AppData/Roaming") + local = _env("LOCALAPPDATA", "~/AppData/Local") + return roaming / "Dikte", local / "Dikte" + return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte", + _env("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_audio.py b/tests/test_audio.py index 0f9582f..6525b0d 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -16,6 +16,7 @@ import json import os import subprocess import sys +import threading import unittest import wave from unittest import mock @@ -100,6 +101,22 @@ class StereoLevels(unittest.TestCase): self.assertAlmostEqual(left, 0.25, places=3) self.assertAlmostEqual(right, 0.5, places=3) + def test_two_mono_streams_are_interleaved_left_then_right(self): + self.assertEqual( + list(array.array("h", audio.interleave_mono( + pcm([100, 200, 300]), pcm([-100, -200, -300]) + ))), + [100, -100, 200, -200, 300, -300], + ) + + def test_interleaving_stops_at_the_shorter_stream(self): + self.assertEqual( + list(array.array("h", audio.interleave_mono( + pcm([100, 200]), pcm([-100]) + ))), + [100, -100], + ) + class WriteWav(DikteTest): def test_the_header_says_what_the_recorder_captured(self): @@ -235,6 +252,34 @@ class FakeProcess: self._alive = False +class StalledProcess(FakeProcess): + """A capture that hands over a buffer and then stops answering at all. + + Not the same thing as one that ends: the device is still there and the pipe + is still open, and a read of it never comes back. + """ + + def __init__(self, data): + super().__init__(data) + self.stdout = _StalledStream(data) + + +class _StalledStream: + def __init__(self, data): + self._data = io.BytesIO(data) + self._released = threading.Event() + + def read(self, size): + chunk = self._data.read(size) + if chunk: + return chunk + self._released.wait() + return b"" + + def release(self): + self._released.set() + + class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" @@ -465,42 +510,178 @@ class RecorderChain(OnLinux, DikteTest): self.assertFalse(recorder.active) -class MeetingCommand(unittest.TestCase): - """One process reading both devices, because two would drift apart.""" +class MeetingCommands(unittest.TestCase): + """Pulse can share a process; AVFoundation sessions cannot.""" - def command(self, platform, mic="", system="them"): - with mock.patch.object(sys, "platform", platform): - return audio.meeting_command(mic, system) + def commands(self, platform, mic="", system="them"): + with mock.patch.object(sys, "platform", platform), \ + mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + mock.patch.object( + audio, "_resolve_avfoundation_target", + side_effect=lambda target, inputs=None: target or "default"): + return audio.meeting_commands(mic, system) def test_linux_reads_both_through_pulse(self): - cmd = self.command("linux", mic="mine") + commands = self.commands("linux", mic="mine") + self.assertEqual(len(commands), 1) + cmd = commands[0] self.assertEqual(cmd.count("pulse"), 2) self.assertEqual(cmd[cmd.index("mine") - 1], "-i") self.assertEqual(cmd[cmd.index("them") - 1], "-i") - def test_a_mac_reads_both_through_avfoundation(self): - cmd = self.command("darwin", mic="1") - self.assertEqual(cmd.count("avfoundation"), 2) - self.assertIn(":1", cmd) - self.assertIn(":them", cmd) + def test_a_mac_gives_each_avfoundation_device_its_own_process(self): + commands = self.commands("darwin", mic="mine") + self.assertEqual(len(commands), 2) + self.assertTrue(all(command.count("avfoundation") == 1 + for command in commands)) + self.assertIn(":mine", commands[0]) + self.assertIn(":them", commands[1]) def test_no_microphone_named_means_the_default_one(self): - self.assertIn("default", self.command("linux")) - self.assertIn(":default", self.command("darwin")) + self.assertIn("default", self.commands("linux")[0]) + self.assertIn(":default", self.commands("darwin")[0]) - def test_both_merge_the_two_into_one_stereo_stream(self): - for platform in ("linux", "darwin"): - with self.subTest(platform=platform): - cmd = self.command(platform) - self.assertIn(audio.MERGE_FILTER, cmd) - self.assertEqual(cmd[cmd.index("-map") + 1], "[out]") - self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le") + def test_pulse_merges_the_two_into_one_stereo_stream(self): + cmd = self.commands("linux")[0] + self.assertIn(audio.MERGE_FILTER, cmd) + self.assertEqual(cmd[cmd.index("-map") + 1], "[out]") + self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le") + + def test_each_mac_process_produces_clock_corrected_mono_pcm(self): + for cmd in self.commands("darwin"): + self.assertIn("first_pts=0", cmd[cmd.index("-af") + 1]) + self.assertEqual(cmd[cmd.index("-ac") + 1], "1") + self.assertEqual(cmd[-2:], ["1", "-"]) def test_neither_lets_ffmpeg_read_the_terminal(self): """It shares stdin with Dikte, and would eat a keypress meant for it.""" for platform in ("linux", "darwin"): with self.subTest(platform=platform): - self.assertIn("-nostdin", self.command(platform)) + for command in self.commands(platform): + self.assertIn("-nostdin", command) + + def test_both_mac_devices_are_read_off_one_listing(self): + """Asking twice costs an ffmpeg run, and the second answer could have + renumbered between the two.""" + with mock.patch.object(sys, "platform", "darwin"), \ + mock.patch.object(audio, "_avfoundation_inputs", + return_value=[("0", "mine"), + ("1", "them")]) as inputs: + audio.meeting_commands("mine", "them") + inputs.assert_called_once_with() + + +class MacMeetingRecorder(OnMacOS, DikteTest): + # Whichever way the machine has them ordered, a name is what is saved and + # the index it happens to hold now is what ffmpeg is given. + DEVICES = [("0", "External Headset"), ("1", "BlackHole 2ch"), + ("2", "MacBook Pro Microphone")] + + def devices(self): + return mock.patch.object(audio, "_avfoundation_inputs", + return_value=self.DEVICES) + + def record(self, mine, theirs): + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped, failed, warnings = [], [], [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + recorder.failed.connect(failed.append) + recorder.warned.connect(warnings.append) + processes = [FakeProcess(mine), FakeProcess(theirs)] + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen", side_effect=processes) as popen: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=5) + recorder.stop() + return path, warnings, stopped, failed, processes, popen + + def test_the_two_capture_processes_become_one_stereo_file(self): + path, _, stopped, failed, _, _ = self.record( + tone(1.0, freq=440), tone(1.0, freq=880) + ) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + with contextlib.closing(wave.open(path, "rb")) as wav: + self.assertEqual(wav.getnchannels(), 2) + self.assertEqual(wav.getframerate(), audio.RATE) + self.assertEqual(wav.getnframes(), audio.RATE) + + def test_each_avfoundation_device_is_opened_by_a_different_process(self): + _, _, _, _, _, popen = self.record(tone(0.5), tone(0.5)) + commands = [call.args[0] for call in popen.call_args_list] + self.assertEqual(len(commands), 2) + self.assertTrue(all(command.count("avfoundation") == 1 + for command in commands)) + self.assertIn(":2", commands[0]) + self.assertIn(":1", commands[1]) + + def test_a_mostly_empty_microphone_is_said_out_loud_and_still_kept(self): + """Half the file is everyone else, and an hour of them is worth more + than the empty channel costs.""" + path, warnings, stopped, failed, _, _ = self.record( + silence(11.0), tone(11.0) + ) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + self.assertTrue(os.path.exists(path)) + self.assertIn("empty", warnings[0]) + + def test_a_microphone_that_was_merely_quiet_is_not_complained_about(self): + _, warnings, stopped, _, _, _ = self.record(tone(11.0), tone(11.0)) + self.assertEqual(warnings, []) + self.assertEqual(len(stopped), 1) + + def test_a_capture_that_falls_silent_ends_the_meeting_rather_than_hanging(self): + """One thread taking turns on both pipes would sit on the dead read + until somebody noticed, an hour later.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped = [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + mine, theirs = StalledProcess(tone(0.512)), FakeProcess(tone(30.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(audio, "STALL_SECONDS", 0.2), \ + mock.patch.object(subprocess, "Popen", side_effect=(mine, theirs)): + try: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=2) + self.assertFalse(recorder.active) + recorder.stop() + finally: + mine.stdout.release() + self.assertAlmostEqual(stopped[0][1], 0.512, places=3) + self.assertTrue(os.path.exists(path)) + + def test_stopping_ends_both_capture_processes(self): + _, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5)) + self.assertTrue(all(process.signals for process in processes)) + + def test_a_legacy_numeric_target_fails_before_recording(self): + recorder = audio.MeetingRecorder() + failed = [] + recorder.failed.connect(failed.append) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen") as popen: + recorder.start(str(self.path("meeting.wav")), "2", "1") + popen.assert_not_called() + self.assertIn("old numeric index", failed[0]) + + def test_a_second_capture_process_that_cannot_start_cleans_up_the_first(self): + """A Mac left holding an open AVFoundation session records nothing + else until it is let go.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + failed = [] + recorder.failed.connect(failed.append) + first = FakeProcess(tone(1.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen", + side_effect=(first, OSError("refused"))): + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + self.assertTrue(first.signals) + self.assertIn("refused", failed[0]) + self.assertFalse(os.path.exists(path)) class MacDevices(OnMacOS, DikteTest): @@ -527,12 +708,13 @@ class MacDevices(OnMacOS, DikteTest): def test_the_audio_half_of_the_listing_is_the_only_half_read(self): with self.listing(): self.assertEqual(audio.list_sources(), - [("0", "MacBook Pro Microphone"), ("1", "BlackHole 2ch")]) + [("MacBook Pro Microphone", "MacBook Pro Microphone"), + ("BlackHole 2ch", "BlackHole 2ch")]) - def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self): + def test_the_name_is_both_saved_and_shown(self): with self.listing(): name, description = audio.list_sources()[1] - self.assertEqual(name, "1") + self.assertEqual(name, "BlackHole 2ch") self.assertIn("BlackHole", description) def test_no_ffmpeg_installed(self): @@ -557,7 +739,7 @@ class MacDevices(OnMacOS, DikteTest): def test_the_loopback_driver_is_picked_out_by_name(self): with self.listing(): - self.assertEqual(audio.default_monitor(), "1") + self.assertEqual(audio.default_monitor(), "BlackHole 2ch") def test_the_other_two_drivers_people_install(self): for name in ("Loopback Audio", "Soundflower (2ch)"): @@ -565,13 +747,43 @@ class MacDevices(OnMacOS, DikteTest): listing = ("AVFoundation audio devices:\n" f"[0] Built-in Microphone\n[1] {name}\n") with self.listing(stderr=listing): - self.assertEqual(audio.default_monitor(), "1") + self.assertEqual(audio.default_monitor(), name) def test_a_mac_with_nothing_to_record_the_far_side_from(self): listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n" with self.listing(stderr=listing): self.assertEqual(audio.default_monitor(), "") + def test_a_saved_name_is_resolved_against_the_current_index(self): + with self.listing(): + self.assertEqual(audio._resolve_avfoundation_target("BlackHole 2ch"), "1") + + def test_a_saved_name_follows_the_device_when_an_earlier_one_disappears(self): + listing = ("AVFoundation audio devices:\n" + "[0] BlackHole 2ch\n[1] MacBook Pro Microphone\n") + with self.listing(stderr=listing): + self.assertEqual( + audio._resolve_avfoundation_target("MacBook Pro Microphone"), "1" + ) + + def test_an_old_numeric_setting_is_not_silently_reused(self): + with self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("1") + self.assertIn("old numeric index", str(caught.exception)) + + def test_a_device_that_went_away_is_said_out_loud(self): + with self.listing(), self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("USB Microphone") + self.assertIn("no longer connected", str(caught.exception)) + + def test_duplicate_names_are_not_guessed_between(self): + listing = ("AVFoundation audio devices:\n" + "[0] USB Microphone\n[1] USB Microphone\n") + with self.listing(stderr=listing), \ + self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("USB Microphone") + self.assertIn("More than one", str(caught.exception)) + class MacRecordingCommand(OnMacOS, DikteTest): def test_the_microphone_is_read_through_avfoundation(self): @@ -583,7 +795,11 @@ class MacRecordingCommand(OnMacOS, DikteTest): def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self): with only_these_tools("ffmpeg"): self.assertIn(":default", audio.recording_command()) - self.assertIn(":2", audio.recording_command("2")) + listing = "AVFoundation audio devices:\n[2] USB Microphone\n" + completed = FakeCompleted(returncode=1, stderr=listing) + with only_these_tools("ffmpeg"), \ + mock.patch.object(subprocess, "run", return_value=completed): + self.assertIn(":2", audio.recording_command("USB Microphone")) def test_it_captures_the_format_the_rest_of_the_code_expects(self): with only_these_tools("ffmpeg"): @@ -681,7 +897,7 @@ class WindowsDevices(OnWindows, DikteTest): with self.listing(): self.assertEqual(audio.list_monitors(), []) self.assertEqual(audio.default_monitor(), "") - self.assertEqual(audio.meeting_command("mic", "sys"), []) + self.assertEqual(audio.meeting_commands("mic", "sys"), []) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index af42b2e..5fe4c36 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -334,6 +334,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 7d0a031..120b0a5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -463,44 +463,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(config_dir.as_posix(), "/c/dikte") - self.assertEqual(data_dir.as_posix(), "/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(config_dir.as_posix().endswith("/.config/dikte")) - self.assertTrue(data_dir.as_posix().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(config_dir.as_posix() - .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", config_dir.as_posix()) - - def test_windows_keeps_settings_and_data_apart(self): - # Forward slashes, because a backslash only separates on Windows and - # this test also runs on the Linux that checks the Windows half. - with mock.patch.dict(os.environ, {"APPDATA": "C:/roam", - "LOCALAPPDATA": "C:/local"}): - config_dir, data_dir = cfg._directories("win32") - self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") - self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 2e3075f..dab95a4 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -236,7 +236,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 d466b89..e063712 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -82,6 +82,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 7e80c0e..db098e7 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -279,11 +279,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: @@ -305,10 +310,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) @@ -389,6 +400,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..5d79c60 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,83 @@ +"""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): + """Spelled with forward slashes throughout. + + A backslash separates on Windows only, and every one of these runs on all + three systems: `as_posix()` is the one spelling they can all be read in. + """ + + 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(config_dir.as_posix(), "/c/dikte") + self.assertEqual(data_dir.as_posix(), "/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(config_dir.as_posix().endswith("/.config/dikte")) + self.assertTrue(data_dir.as_posix().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(config_dir.as_posix() + .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", config_dir.as_posix()) + + def test_windows_keeps_the_models_out_of_the_roaming_profile(self): + """Settings roam with the account; several gigabytes must not.""" + with mock.patch.dict(os.environ, {"APPDATA": "C:/roam", + "LOCALAPPDATA": "C:/local"}): + config_dir, data_dir = paths.directories("win32") + self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") + self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") + + def test_windows_without_the_variables_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + config_dir, data_dir = paths.directories("win32") + self.assertTrue(config_dir.as_posix().endswith("/AppData/Roaming/Dikte")) + self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte")) + + +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 790390c..0cc5969 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -181,13 +181,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.