diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88c772d..c23508a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ directories, resets the language, and puts them back afterwards. | A reply that fails | `http_error(429)`, `url_error()`, `raw_body("not json")` | | Reading what was sent | `sent_json(request)`, `multipart_fields(request)` | | A program on the PATH | `only_these_tools("pactl", "wl-copy")` | +| Standing on another system | `mock.patch.object(sys, "platform", "darwin")` | | Audio | `silence()`, `tone()`, `speech()`, `stereo()`, `make_wav()` | | A settings object | `self.config(cleanup_enabled=False)` | @@ -52,30 +53,35 @@ forgets fails rather than hangs. ## Another platform -Most of what Dikte does is not desktop-specific, and the tests are split along -that line. 511 of them pass anywhere: transcription, cleanup, the config file, -the history, the agent, the command line, the timeline of a meeting. The -remaining 59 cover what Dikte *is* on this desktop, and carry `@linux_only` -from `tests.support`: PipeWire capture and the pactl device list, wl-clipboard -and ydotool, KDE's shortcut file and the `/dev/input` listener. +Three systems are supported: Wayland, X11 and macOS. Each one is a named entry +in a table, and one chooser picks between them, so a fourth adds an entry and a +line rather than a branch inside every function. The three tables are +`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device +lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform` +inside the chooser and read it there every time: a constant settled at import is +one no test can stand somewhere else. -Mark a test `@linux_only` when it would fail on a machine that never had those -programs. Do not mark one because it happens to be convenient: a test that -quietly stops running on the platform you are porting to protects nothing. - -The other half of a port is where the branch goes. Keep `sys.platform` out of -the middle of a function; make the public name a chooser and give each platform -its own function underneath: +The tests are split along the same line, and almost none of them are skipped. +892 of the 935 run on any machine, including every line of the Wayland, X11 and +macOS backends: the programs are faked at `shutil.which`, the frameworks at the +one function that loads them. A test class says which system it is standing on +rather than avoiding the question: ```python -def copy(text): - return _copy_macos(text) if sys.platform == "darwin" else _copy_wayland(text) +class MacOS(ClipboardContract, DikteTest): + platform = "darwin" + here = paste.MACOS ``` -Then each platform's test calls its own function directly and passes everywhere, -and adding a third one leaves the first two's tests alone. An `if` buried inside -`copy()` forces every existing test to patch `sys.platform` instead, and the -next port breaks all of them. +so the Linux half is checked on a Mac and the macOS half on Linux, and a change +to a chooser cannot quietly break the platform nobody is sitting at. What the +systems owe in common is written once as a contract class and subclassed by each +of them. + +The 43 that do carry `@linux_only` are the ones that would need the real thing: +the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test +that way only when faking it would leave nothing to test. A test that quietly +stops running on the platform you are porting to protects nothing. ## What a pull request should carry diff --git a/README.md b/README.md index 29ce1b4..5acc5e0 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. -Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages: -just the Python standard library and PyQt6. +Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. No +dependencies beyond system packages: just the Python standard library, 3.11 or +newer, and PyQt6. *[Türkçe README](README.tr.md)* @@ -37,6 +38,12 @@ 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. + `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away @@ -48,9 +55,10 @@ speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), cleanup on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in -`~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which -case the raw transcript is pasted, and a thinking model's effort can be set next -to it. +`~/.config/dikte/config.json`, mode 600, or in +`~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in +which case the raw transcript is pasted, and a thinking model's effort can be +set next to it. ## Using it diff --git a/README.tr.md b/README.tr.md index 38c4639..d18f26e 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,8 +4,9 @@ çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. -KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok: -sadece Python standart kütüphanesi ve PyQt6. +KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem +paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya +üstü) ve PyQt6. *[English README](README.md)* @@ -36,6 +37,11 @@ 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. + `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; @@ -49,8 +55,9 @@ seçersen sesi yazıya çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da (`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` -içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript -yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. +içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. +Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin +yanındaki kutudan düşünme seviyesini de seçebilirsin. ## Kullanım diff --git a/audio.py b/audio.py index 0ef5038..ccfd8b0 100644 --- a/audio.py +++ b/audio.py @@ -1,19 +1,27 @@ """Raw PCM capture with a live level meter. -Dictation records one source through pw-record. A meeting records two of them at -once, the microphone and what comes out of the speakers, and for that it goes -through ffmpeg instead: 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. +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. + +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. +They are gathered into one group each near the bottom of this file, and a +chooser picks between them. """ import array +import collections import json import math import os +import re import shutil import signal import subprocess +import sys import tempfile import threading import wave @@ -57,9 +65,7 @@ class Recorder(QObject): return cmd = recording_command(target) if not cmd: - self.failed.emit(t( - "No audio recorder found. Install pulseaudio-utils or pipewire-audio." - )) + self.failed.emit(t(sound().missing)) return try: @@ -175,36 +181,13 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH): def recording_command(target=""): - """Return a raw-s16 capture command for the sound server on this desktop. + """A raw-s16 capture command for the sound system on this machine.""" + return sound().record(target) - parec works with both PulseAudio and PipeWire's PulseAudio compatibility - service, and its source names are the same ones shown by list_sources(). - Keep pw-record as the fallback for minimal native-PipeWire installations. - """ - if shutil.which("parec"): - cmd = [ - "parec", "--record", "--raw", f"--rate={RATE}", - f"--channels={CHANNELS}", "--format=s16le", - # Left alone, parec holds about two seconds before handing anything - # over, and then hands over all of it at once: the level meter sits - # still and jumps, and the tail of a recording can be lost on the - # way out. A chunk of the meter is the unit the rest of this file - # is measured in, so ask for that. - f"--latency-msec={CHUNK_LATENCY_MS}", - ] - if target: - cmd.append(f"--device={target}") - return cmd - if shutil.which("pw-record"): - cmd = [ - "pw-record", "--raw", f"--rate={RATE}", - f"--channels={CHANNELS}", "--format=s16", - ] - if target: - cmd.append(f"--target={target}") - cmd.append("-") - return cmd - return [] + +def meeting_command(mic_target, system_target): + """One ffmpeg reading both devices and merging them into two channels.""" + return sound().meeting(mic_target, system_target) class MeetingRecorder(QObject): @@ -251,18 +234,7 @@ class MeetingRecorder(QObject): "Pick one in Settings → Meeting.")) return - merge = ( - "[0:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];" - "[1:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];" - "[m][s]amerge=inputs=2[out]" - ).format(rate=RATE) - cmd = [ - "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, "-map", "[out]", - "-f", "s16le", "-ar", str(RATE), "-", - ] + cmd = meeting_command(mic_target, system_target) try: os.makedirs(os.path.dirname(path), exist_ok=True) @@ -439,7 +411,61 @@ def _peak(samples): return min(1.0, max(abs(min(samples)), abs(max(samples))) / 32768.0) -def _sources(): +# --- 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. +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];" + "[m][s]amerge=inputs=2[out]" +) + + +def _pulse_record(target): + """parec, or pw-record where PulseAudio's tools were left out. + + parec works with both PulseAudio and PipeWire's PulseAudio compatibility + service, and its source names are the same ones shown by list_sources(). + Keep pw-record as the fallback for minimal native-PipeWire installations. + """ + if shutil.which("parec"): + cmd = [ + "parec", "--record", "--raw", f"--rate={RATE}", + f"--channels={CHANNELS}", "--format=s16le", + # Left alone, parec holds about two seconds before handing anything + # over, and then hands over all of it at once: the level meter sits + # still and jumps, and the tail of a recording can be lost on the + # way out. A chunk of the meter is the unit the rest of this file + # is measured in, so ask for that. + f"--latency-msec={CHUNK_LATENCY_MS}", + ] + if target: + cmd.append(f"--device={target}") + return cmd + if shutil.which("pw-record"): + cmd = [ + "pw-record", "--raw", f"--rate={RATE}", + f"--channels={CHANNELS}", "--format=s16", + ] + if target: + cmd.append(f"--target={target}") + cmd.append("-") + return cmd + return [] + + +def _pulse_meeting(mic_target, system_target): + 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(): if not shutil.which("pactl"): return [] try: @@ -452,30 +478,23 @@ def _sources(): return [] -def list_sources(): - """[(name, description)] for every real input source.""" +def _pulse_inputs(): return [ (src.get("name", ""), src.get("description") or src.get("name", "")) - for src in _sources() + for src in _pactl_sources() if not src.get("name", "").endswith(".monitor") ] -def list_monitors(): - """[(name, description)] for the monitor of every output. - - Recording a monitor is recording whatever is being played, which in a - meeting is the other participants and nothing of your own microphone. - """ +def _pulse_outputs(): return [ (src.get("name", ""), src.get("description") or src.get("name", "")) - for src in _sources() + for src in _pactl_sources() if src.get("name", "").endswith(".monitor") ] -def default_monitor(): - """The monitor of the output sound is currently going to, or ''.""" +def _pulse_default_output(): if not shutil.which("pactl"): return "" try: @@ -488,5 +507,130 @@ def default_monitor(): if not sink: return "" monitor = f"{sink}.monitor" - names = {name for name, _ in list_monitors()} + names = {name for name, _ in _pulse_outputs()} return monitor if not names or monitor in names else "" + + +# macOS hands out no monitor of its own: what the speakers are playing is not +# an input, and the only way to record it is a driver that pretends to be one. +# These are the three people install. +LOOPBACK_DEVICES = ("blackhole", "loopback", "soundflower") + + +def _avfoundation_record(target): + if not shutil.which("ffmpeg"): + return [] + return [ + "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", + # AVFoundation names an input "video:audio", so the empty half in front + # of the colon is what says this recording has no picture in it. + "-f", "avfoundation", "-i", f":{target or 'default'}", + "-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-", + ] + + +def _avfoundation_meeting(mic_target, system_target): + 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), "-", + ] + + +def _avfoundation_inputs(): + """[(index, name)] for every capture device AVFoundation offers. + + The index is what the recorder is given, because that is what ffmpeg takes; + it changes when devices are plugged in, which is why the name is shown. + """ + if not shutil.which("ffmpeg"): + return [] + try: + # Listing devices is not a thing ffmpeg can do without an input, so it + # is asked for one it cannot open: the list comes out on stderr and the + # command then fails, which is the documented way of doing this. + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-f", "avfoundation", + "-list_devices", "true", "-i", ""], + capture_output=True, text=True, timeout=8, check=False, + ) + except (subprocess.SubprocessError, OSError): + return [] + + devices, listing = [], False + for line in result.stderr.splitlines(): + if "AVFoundation audio devices:" in line: + listing = True + continue + if not listing: + continue + match = re.search(r"\[(\d+)\]\s+(.+)$", line) + if match: + devices.append((match.group(1), match.group(2).strip())) + return devices + + +def _avfoundation_default_output(): + for name, description in _avfoundation_inputs(): + if any(word in description.lower() for word in LOOPBACK_DEVICES): + return name + return "" + + +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. + "record meeting inputs outputs default_output missing", +) + +PULSE = Sound( + record=_pulse_record, + meeting=_pulse_meeting, + inputs=_pulse_inputs, + outputs=_pulse_outputs, + default_output=_pulse_default_output, + missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.", +) + +COREAUDIO = Sound( + record=_avfoundation_record, + meeting=_avfoundation_meeting, + inputs=_avfoundation_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, + default_output=_avfoundation_default_output, + missing="ffmpeg not found. Install it with: brew install ffmpeg", +) + + +def sound(): + """The programs this machine records through.""" + return COREAUDIO if sys.platform == "darwin" else PULSE + + +def list_sources(): + """[(name, description)] for every real input source.""" + return sound().inputs() + + +def list_monitors(): + """[(name, description)] for whatever can be recorded as the other side. + + On Linux that is the monitor of an output, and recording it is recording + whatever is being played: in a meeting the other participants, and nothing + of your own microphone. + """ + return sound().outputs() + + +def default_monitor(): + """The device the far side of a meeting comes from, or ''.""" + return sound().default_output() diff --git a/cli.py b/cli.py index 334a957..7992bd0 100644 --- a/cli.py +++ b/cli.py @@ -717,7 +717,7 @@ def cmd_shortcut(opts): combo = (opts.combo or conf[spec.setting] or spec.fallback).strip() if not combo: return fail(opts, "no combination given and none stored; pass --combo", 2) - if hotkey.parse_shortcut(combo) == (None, None): + if not hotkey.valid_shortcut(combo): return fail(opts, f"cannot parse that combination: {combo}", 2) clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id) if clashes and not opts.force: diff --git a/config.py b/config.py index 0f1c48f..c80bbfb 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,16 @@ -"""Settings storage in ~/.config/dikte/config.json""" +"""Settings storage, in the place this system keeps a program's settings.""" import collections import hashlib import json import os import pathlib +import sys import api import ggml import i18n +import paste from i18n import t @@ -16,9 +18,21 @@ def _xdg(var, default): return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) -CONFIG_DIR = _xdg("XDG_CONFIG_HOME", "~/.config") / "dikte" +def _directories(platform=None): + """(settings, data), in the two places this system keeps them. + + macOS keeps both in the one directory a Mac user's backup already knows + about. Everywhere else they are separate and follow the XDG variables. + """ + if (platform or sys.platform) == "darwin": + support = pathlib.Path.home() / "Library/Application Support/Dikte" + return support, support + return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", + _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") + + +CONFIG_DIR, DATA_DIR = _directories() CONFIG_FILE = CONFIG_DIR / "config.json" -DATA_DIR = _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte" HISTORY_FILE = DATA_DIR / "history.jsonl" RECORDINGS_DIR = DATA_DIR / "recordings" MEETINGS_DIR = DATA_DIR / "meetings" @@ -415,7 +429,7 @@ DEFAULTS = { "local_llm_reasoning": "none", "cleanup_prompt": "", # empty -> language-specific default "auto_paste": True, - "paste_shortcut": "ctrl+v", + "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac "restore_clipboard": False, "mic_target": "", "max_seconds": 300, diff --git a/dikte.py b/dikte.py index ef3e042..cda7f54 100755 --- a/dikte.py +++ b/dikte.py @@ -20,6 +20,15 @@ import threading if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"): os.environ.setdefault("QT_QPA_PLATFORM", "xcb") +# An application started from the Finder is given none of the shell's PATH, so +# Homebrew's ffmpeg is invisible to it. Put the two places brew installs to in +# front, before anything goes looking for a program. +if sys.platform == "darwin": + os.environ["PATH"] = os.pathsep.join( + part for part in ("/opt/homebrew/bin", "/usr/local/bin", + os.environ.get("PATH", "")) if part + ) + from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402 from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 @@ -96,7 +105,7 @@ class Dikte: self.ask_pipeline = Pipeline(self.conf) self.meeting_recorder = audio.MeetingRecorder() self.meetings = MeetingPipeline(self.conf) - self.evdev = hotkey.EvdevHotkey() + self.evdev = hotkey.listener() # Before anything of ours is started: a server from a Dikte that was # killed outright is still holding a model in memory. ggml.sweep() @@ -325,8 +334,11 @@ class Dikte: # that same press. Its lateness is also the proof we were waiting for # that the shortcut is live, which leaves the listener with nothing to # do but double every press. + # Where nothing was installed there is no shortcut to catch up, and + # retiring the listener would leave the keys with nowhere to arrive. timer = self.last_evdev.get(name) - if self.evdev.running and timer is not None and timer.elapsed() < ECHO_MS: + if (hotkey.installs_shortcuts() and self.evdev.running + and timer is not None and timer.elapsed() < ECHO_MS): self._retire_listener() return handler() @@ -346,8 +358,9 @@ class Dikte: self.conf.save() self.tray.showMessage( "Dikte", - t("The KDE shortcut is live now, so the built-in listener has been " - "turned off. It was doubling every key press."), + t("The {desktop} shortcut is live now, so the built-in listener has " + "been turned off. It was doubling every key press.", + desktop=hotkey.desktop_name()), QSystemTrayIcon.MessageIcon.Information, 8000, ) @@ -853,7 +866,10 @@ class Dikte: self._apply_local() self._build_tray() self._refresh_tray() - if self.conf["evdev_hotkey"]: + # Where the desktop has no shortcut registry of its own, the listener is + # not the fallback the setting offers to turn on: it is the only way the + # keys arrive at all, so it runs whatever the setting says. + if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts(): self.evdev.start({name: self.conf[spec.setting] for name, spec in hotkey.SHORTCUTS.items()}) else: diff --git a/ggml.py b/ggml.py index d8778b5..5ba85a7 100644 --- a/ggml.py +++ b/ggml.py @@ -37,6 +37,7 @@ import shutil import signal import socket import subprocess +import sys import tarfile import threading import time @@ -220,8 +221,15 @@ def _has_vulkan(): def _wanted_assets(program): - """Asset name endings to accept, best first.""" + """Asset name endings to accept, best first. + + llama.cpp publishes native Metal-enabled macOS archives. whisper.cpp does + not publish a runnable macOS server archive, so an arm64 Mac must not + mistake Ubuntu's arm64 archive for a native build. + """ arch = _arch() + if sys.platform == "darwin": + return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",) if program is LLAMA and _has_vulkan(): return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz") return (f"bin-ubuntu-{arch}.tar.gz",) @@ -311,6 +319,11 @@ def install_program(program, tag="", on_progress=None, should_stop=None, if item: break if item is None: + if sys.platform == "darwin" and program is WHISPER: + raise LocalError(t( + "whisper.cpp publishes no macOS build. Install it with: " + "brew install whisper-cpp" + )) 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 f71de71..e7e10ea 100644 --- a/hotkey.py +++ b/hotkey.py @@ -1,7 +1,17 @@ -"""GNOME/KDE global-shortcut installation plus a built-in evdev listener.""" +"""Global shortcuts: the desktop's own registry, plus a listener of our own. + +Two things have to happen for a key combination to reach Dikte. Somewhere has +to be told about it, and something has to be listening. On Linux that is the +desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of +/dev/input for the wait until the registry is live. macOS has no registry to +write into: the application asks Carbon for the combination while it runs, so +there the listener is not a fallback but the whole mechanism. +""" import ast import collections +import ctypes +import ctypes.util import glob import os import pathlib @@ -10,6 +20,7 @@ import select import shutil import struct import subprocess +import sys import threading from PyQt6.QtCore import QObject, pyqtSignal @@ -197,8 +208,222 @@ class EvdevHotkey(QObject): return True +# --- macOS: Carbon's hotkey service --------------------------------------- + +# Apple virtual key codes: where a key sits, not what is printed on it. +MAC_KEYS = { + "space": 49, "tab": 48, "enter": 36, "return": 36, "esc": 53, "escape": 53, + "backspace": 51, "delete": 117, "home": 115, "end": 119, + "pgup": 116, "pgdown": 121, "up": 126, "down": 125, "left": 123, "right": 124, + "1": 18, "2": 19, "3": 20, "4": 21, "5": 23, "6": 22, "7": 26, + "8": 28, "9": 25, "0": 29, + "a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4, + "i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31, + "p": 35, "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9, + "w": 13, "x": 7, "y": 16, "z": 6, + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, + "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, +} +# Carbon's own modifier bits, which are not the ones CoreGraphics uses in +# paste.py: the same four modifiers, numbered differently by two APIs. +MAC_MODS = { + "cmd": 1 << 8, "command": 1 << 8, "meta": 1 << 8, "super": 1 << 8, + "shift": 1 << 9, + "alt": 1 << 11, "option": 1 << 11, + "ctrl": 1 << 12, "control": 1 << 12, +} +HOTKEY_SIGNATURE = "Dikt" # what our registrations are labelled with +KEYBOARD_EVENT_CLASS = "keyb" +HOTKEY_PRESSED = 5 # kEventHotKeyPressed +PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard +HOTKEY_ID_PARAMETER = "hkid" + +# What the running listener holds. This is the whole of "installed" on macOS, +# and it lasts as long as the process does: there is no file, and no other +# program to read one. Written by CarbonHotkey.start(), read by the status +# line, so what Settings shows is what the Mac actually gave us. +_REGISTERED = {} + + +def parse_macos_shortcut(text): + """'Cmd+Space' -> (256, 49), or (None, None) when unusable.""" + parts = [part.strip().lower() for part in str(text).split("+") if part.strip()] + modifiers, key = 0, None + for part in parts: + if part in MAC_MODS: + modifiers |= MAC_MODS[part] + elif key is None and part in MAC_KEYS: + key = MAC_KEYS[part] + else: + return None, None + if key is None: + return None, None + return modifiers, key + + +def _fourcc(text): + """A Carbon four-character code, which is those four bytes as a number.""" + return int.from_bytes(text.encode("ascii"), "big") + + +class _EventTypeSpec(ctypes.Structure): + _fields_ = [("eventClass", ctypes.c_uint32), ("eventKind", ctypes.c_uint32)] + + +class _EventHotKeyID(ctypes.Structure): + _fields_ = [("signature", ctypes.c_uint32), ("id", ctypes.c_uint32)] + + +class CarbonHotkey(QObject): + """Catches global shortcuts through macOS's own hotkey service. + + RegisterEventHotKey asks for one combination rather than reading the + keyboard, so it needs no permission at all. Accessibility is a separate + matter, and only for the Cmd+V that puts the text back (see paste.py). + + Unlike the evdev listener this one does swallow the key: while Dikte holds + a combination, nothing else on the Mac receives it. + """ + + triggered = pyqtSignal(str) # the name the binding was registered under + failed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._carbon = None + self._callback = None + self._handler = ctypes.c_void_p() + self._registrations = [] + self._names = {} + + @property + def running(self): + return bool(self._registrations) + + def start(self, bindings): + """`bindings` is {name: 'Cmd+Space'}; an empty combination is skipped.""" + self.stop() + try: + self._carbon = _carbon() + except OSError as exc: + self.failed.emit(t("Could not reach the macOS shortcut service: " + "{error}", error=exc)) + return False + if not self._install_handler(): + return False + + for identifier, (name, shortcut) in enumerate(bindings.items(), 1): + if not shortcut: + continue + modifiers, key = parse_macos_shortcut(shortcut) + if key is None: + self.failed.emit( + t("Could not parse the shortcut: {shortcut}", shortcut=shortcut) + ) + continue + reference = ctypes.c_void_p() + code = self._carbon.RegisterEventHotKey( + key, modifiers, + _EventHotKeyID(_fourcc(HOTKEY_SIGNATURE), identifier), + self._carbon.GetApplicationEventTarget(), 0, ctypes.byref(reference), + ) + if code != 0: + # This is the conflict warning on macOS: there is no list to + # read beforehand, the answer comes from asking for the key. + self.failed.emit(t( + "macOS would not give Dikte {shortcut}; another application " + "already holds it.", shortcut=shortcut)) + continue + self._registrations.append(reference) + self._names[identifier] = name + spec = SHORTCUTS.get(name) + if spec: + _REGISTERED[spec.desktop_id] = shortcut + return bool(self._registrations) + + def stop(self): + if self._carbon: + for reference in self._registrations: + self._carbon.UnregisterEventHotKey(reference) + if self._handler: + self._carbon.RemoveEventHandler(self._handler) + self._registrations = [] + self._names = {} + self._handler = ctypes.c_void_p() + self._callback = None + _REGISTERED.clear() + + def _install_handler(self): + carbon = self._carbon + callback_type = ctypes.CFUNCTYPE( + ctypes.c_int32, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p + ) + + def pressed(_next_handler, event, _user_data): + wanted = _EventHotKeyID() + size = ctypes.c_uint32() + code = carbon.GetEventParameter( + event, _fourcc(PARAMETER_ANY), _fourcc(HOTKEY_ID_PARAMETER), None, + ctypes.sizeof(wanted), ctypes.byref(size), ctypes.byref(wanted), + ) + if code == 0: + name = self._names.get(wanted.id) + if name: + self.triggered.emit(name) + return 0 + + # Kept on self: Carbon holds the address of this function, and nothing + # on the Python side would otherwise stop it being collected. + self._callback = callback_type(pressed) + event_type = _EventTypeSpec(_fourcc(KEYBOARD_EVENT_CLASS), HOTKEY_PRESSED) + code = carbon.InstallEventHandler( + carbon.GetApplicationEventTarget(), self._callback, 1, + ctypes.byref(event_type), None, ctypes.byref(self._handler), + ) + if code != 0: + self.failed.emit(t("Could not reach the macOS shortcut service: " + "{error}", error=code)) + self._callback = None + return False + return True + + +def _carbon(): + """Carbon, with its calls typed the way they are used above.""" + path = (ctypes.util.find_library("Carbon") + or "/System/Library/Frameworks/Carbon.framework/Carbon") + carbon = ctypes.CDLL(path) + carbon.GetApplicationEventTarget.restype = ctypes.c_void_p + carbon.InstallEventHandler.restype = ctypes.c_int32 + carbon.InstallEventHandler.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, + ctypes.POINTER(_EventTypeSpec), ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ] + carbon.RegisterEventHotKey.restype = ctypes.c_int32 + carbon.RegisterEventHotKey.argtypes = [ + ctypes.c_uint32, ctypes.c_uint32, _EventHotKeyID, ctypes.c_void_p, + ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p), + ] + carbon.UnregisterEventHotKey.restype = ctypes.c_int32 + carbon.UnregisterEventHotKey.argtypes = [ctypes.c_void_p] + carbon.RemoveEventHandler.restype = ctypes.c_int32 + carbon.RemoveEventHandler.argtypes = [ctypes.c_void_p] + carbon.GetEventParameter.restype = ctypes.c_int32 + carbon.GetEventParameter.argtypes = [ + ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, + ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, + ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p, + ] + return carbon + + # --- the desktop's own shortcut ------------------------------------------- +def _macos(): + return sys.platform == "darwin" + + def _gnome(): desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() return "gnome" in desktop and shutil.which("gsettings") is not None @@ -321,26 +546,69 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID): return None +def listener(parent=None): + """The thing that hears the key, for whichever system this is.""" + return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent) + + +def valid_shortcut(text): + """Whether this machine can bind the combination as it was typed.""" + parse = parse_macos_shortcut if _macos() else parse_shortcut + return parse(text)[1] is not None + + +def installs_shortcuts(): + """Whether this system keeps a shortcut registry to write into. + + KDE and GNOME do, and something outside Dikte reads it, so the combination + survives Dikte being closed. macOS does not: there is nothing to install, + nothing to remove, and Settings should not offer either. + """ + return not _macos() + + +def shortcut_needs_restart(): + """Whether an installed shortcut waits for the next login before it works. + + KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding + up as it is written, and macOS never had one to write. + """ + return not _macos() and not _gnome() + + def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): + if _macos(): + _REGISTERED[desktop_id] = shortcut + return True, t( + "Shortcut saved: {shortcut}\nDikte holds this one itself while it " + "is running, so it works as soon as the settings are saved.", + shortcut=shortcut, + ) if _gnome(): return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) return install_kde_shortcut(shortcut, exec_command, name, desktop_id) def remove_shortcut(desktop_id=DESKTOP_ID): - if _gnome(): + if _macos(): + _REGISTERED.pop(desktop_id, None) + elif _gnome(): remove_gnome_shortcut(desktop_id) else: remove_kde_shortcut(desktop_id) def shortcut_status(desktop_id=DESKTOP_ID): + if _macos(): + return _REGISTERED.get(desktop_id) return (gnome_shortcut_status(desktop_id) if _gnome() else kde_shortcut_status(desktop_id)) def desktop_name(): + if _macos(): + return "macOS" return "GNOME" if _gnome() else "KDE" @@ -425,6 +693,10 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): """Names of other KDE entries bound to the same combination.""" + if _macos(): + # There is no list to read: macOS answers the question by refusing the + # registration, which CarbonHotkey reports when it asks for the key. + return [] try: text = SHORTCUTS_FILE.read_text(encoding="utf-8") except OSError: diff --git a/i18n.py b/i18n.py index c414e34..436f917 100644 --- a/i18n.py +++ b/i18n.py @@ -80,9 +80,9 @@ TR = { "{service} hesapta kredi kalmadığını söylüyor (HTTP 402).", "{service} is rate limiting you (HTTP 429). Try again in a moment.": "{service} hız sınırı uyguluyor (HTTP 429). Birazdan tekrar dene.", - "The KDE shortcut is live now, so the built-in listener has been " - "turned off. It was doubling every key press.": - "KDE kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. " + "The {desktop} shortcut is live now, so the built-in listener has " + "been turned off. It was doubling every key press.": + "{desktop} kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. " "Her tuşa basışı ikiye katlıyordu.", "No speech detected": "Ses algılanmadı", "No speech detected ({level} dB)": "Ses algılanmadı ({level} dB)", @@ -104,11 +104,14 @@ TR = { "Could not start recording: {error}": "Kayıt başlatılamadı: {error}", "No audio recorder found. Install pulseaudio-utils or pipewire-audio.": "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", + "ffmpeg not found. Install it with: brew install ffmpeg": + "ffmpeg bulunamadı. Şununla kur: brew install ffmpeg", "Audio recorder stopped before receiving sound: {error}": "Ses kayıt aracı veri alamadan kapandı: {error}", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", "{tool} not found. Install {packages}.": "{tool} bulunamadı. {packages} paketlerini kur.", + "{tool} not found.": "{tool} bulunamadı.", "{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.", "{tool} not found, cannot paste automatically.": "{tool} bulunamadı, otomatik yapıştırma yapılamıyor.", @@ -117,6 +120,10 @@ TR = { "{tool} failed: {error}": "{tool} hatası: {error}", "Is ydotoold running? (systemctl --user status ydotool)": "ydotoold çalışıyor mu? (systemctl --user status ydotool)", + "macOS has not been told to let Dikte press keys. Turn Dikte on under " + "System Settings → Privacy & Security → Accessibility.": + "macOS, Dikte'nin tuşlara basmasına henüz izin vermiyor. Sistem Ayarları " + "→ Gizlilik ve Güvenlik → Erişilebilirlik altında Dikte'yi aç.", # --- api errors ---------------------------------------------------- "{service} API key is empty. Add it in Settings.": @@ -155,6 +162,8 @@ TR = { "Paste key": "Yapıştırma tuşu", "Terminals usually want ctrl+shift+v. Change this if pasting does nothing.": "Terminaller genelde ctrl+shift+v ister. Yapıştırma çalışmıyorsa bunu değiştir.", + "macOS asks for Accessibility permission the first time this is sent.": + "macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.", "Restore the previous clipboard after pasting": "Yapıştırdıktan sonra eski pano içeriğini geri koy", "Indicator corner": "Gösterge köşesi", @@ -167,8 +176,7 @@ TR = { "Skip silent recordings (don't call the API)": "Sessiz kayıtları atla (API'ye gönderme)", "Silence threshold": "Sessizlik eşiği", - "Keep audio files (~/.local/share/dikte/recordings)": - "Ses kayıtlarını sakla (~/.local/share/dikte/recordings)", + "Keep audio files ({path})": "Ses kayıtlarını sakla ({path})", # --- settings: api -------------------------------------------------- "Keys": "Anahtarlar", @@ -284,7 +292,7 @@ TR = { "Saved: {path}": "Kaydedildi: {path}", # --- settings: shortcut ------------------------------------------------ - "Install as a KDE shortcut": "KDE kısayolu olarak kur", + "Install as a {desktop} shortcut": "{desktop} kısayolu olarak kur", "Install as a global shortcut": "Global kısayol olarak kur", "Remove": "Kaldır", "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", @@ -305,8 +313,10 @@ TR = { "Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}", "Could not register the GNOME shortcut: {error}": "GNOME kısayolu kaydedilemedi: {error}", - "Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet": - "Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken", + "Use the built-in listener (/dev/input), for when the {desktop} shortcut " + "is not active yet": + "Yerleşik dinleyici kullan (/dev/input), {desktop} kısayolu henüz etkin " + "değilken", "Works immediately, no session restart. The only difference: the key " "combination also reaches the focused application.": "Anında çalışır, oturum yenilemek gerekmez. Tek farkı: tuş kombinasyonu " @@ -317,6 +327,13 @@ TR = { "KWin, kısayol ayarlarını yalnızca açılışta okur. 'Kur' dedikten sonra kısayol " "Sistem Ayarları → Kısayollar altında görünür ama oturumu yeniden açana kadar " "tetiklenmez. O zamana kadar yerleşik dinleyiciyi kullanabilirsin.", + "The shortcut starts working as soon as it is installed.": + "Kısayol kurulur kurulmaz çalışmaya başlar.", + "Dikte asks macOS for these combinations itself, while it is running. " + "Nothing is installed, and no other application receives them in the " + "meantime.": + "Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey " + "kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.", "Shortcut conflict": "Kısayol çakışması", "{shortcut} is also used by:\n\n{list}\n\nInstall anyway?": "{shortcut} şu girdilerde de kullanılıyor:\n\n{list}\n\nYine de kurulsun mu?", @@ -329,6 +346,15 @@ TR = { "Could not write the desktop file: {error}": "Desktop dosyası yazılamadı: {error}", "Could not write kglobalshortcutsrc: {error}": "kglobalshortcutsrc yazılamadı: {error}", "Could not parse the shortcut: {shortcut}": "Kısayol çözümlenemedi: {shortcut}", + "Shortcut saved: {shortcut}\nDikte holds this one itself while it is " + "running, so it works as soon as the settings are saved.": + "Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi " + "tutar, yani ayarlar kaydedilir kaydedilmez çalışır.", + "Could not reach the macOS shortcut service: {error}": + "macOS kısayol servisine ulaşılamadı: {error}", + "macOS would not give Dikte {shortcut}; another application already holds it.": + "macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama " + "onu şimdiden tutuyor.", "Cannot read /dev/input. Your user needs to be in the 'input' group:\n" " sudo usermod -aG input $USER (then log out and back in)": "/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n" @@ -556,6 +582,12 @@ TR = { "Same as dictation": "Diktedekiyle aynı", "Current output": "Geçerli çıkış", "The other participants": "Karşı tarafın sesi", + "macOS does not offer what the speakers are playing as something to " + "record. Install BlackHole or Loopback, send the meeting's sound through " + "it, and pick it above.": + "macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. " + "BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve " + "yukarıdan onu seç.", "Wear headphones if you can. Through speakers your microphone hears the " "other side as well, and although a line that lands on both channels at " "once is dropped again, the repair is never as clean as not needing it.": diff --git a/overlay.py b/overlay.py index a35d14e..52ec8b7 100644 --- a/overlay.py +++ b/overlay.py @@ -1,6 +1,7 @@ """The small recording indicator that appears in a screen corner without taking focus.""" import math +import sys from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics @@ -54,13 +55,17 @@ class Overlay(QWidget): self._phase = 0.0 self._concealed = True - self.setWindowFlags( + flags = ( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus - | Qt.WindowType.X11BypassWindowManagerHint ) + if sys.platform != "darwin": + # It is the window manager that would otherwise move this out of + # the corner. macOS has no such hint, and Qt warns about it. + flags |= Qt.WindowType.X11BypassWindowManagerHint + self.setWindowFlags(flags) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) # One that can be clicked away has to receive the click, which means it diff --git a/paste.py b/paste.py index 29b3a5b..030f203 100644 --- a/paste.py +++ b/paste.py @@ -1,16 +1,22 @@ -"""Clipboard and key injection, through whichever pair of programs is here. +"""Clipboard and key injection, through whatever this machine gives us. A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and -xdotool, and a session is one or the other. Which it is gets decided in one -place, and each desktop is a small group of functions below it: another desktop, -or another operating system, adds a group and a line to the chooser rather than -a branch inside every function here. +xdotool, and macOS has pbcopy with the key press going straight to +CoreGraphics. Which of them is here gets decided in one place, and each is a +small group of functions below it: another desktop, or another operating +system, adds a group and a line to the chooser rather than a branch inside +every function here. """ import collections +import ctypes +import functools +import json import os import shutil import subprocess +import sys +import tempfile import time from i18n import t @@ -27,11 +33,88 @@ KEYCODES = { KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert", "enter": "Return", "return": "Return"} +# Apple virtual key codes, which say where a key sits rather than what is +# printed on it: the same numbers on a Turkish and a US keyboard. +MAC_KEYCODES = { + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, + "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, + "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, + "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29, + "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37, + "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44, + "n": 45, "m": 46, ".": 47, "`": 50, "enter": 36, "return": 36, +} +MAC_FLAGS = {"shift": 1 << 17, "ctrl": 1 << 18, "alt": 1 << 19, "command": 1 << 20} +# What the same modifier is called on a Mac keyboard. +MAC_ALIASES = {"cmd": "command", "meta": "command", "super": "command", + "control": "ctrl", "option": "alt"} +HID_EVENT_TAP = 0 # kCGHIDEventTap: the event goes in where the keyboard does + + +# pbpaste only reads text, EPS and RTF. In particular, an image on a Mac's +# clipboard comes back as an empty byte string and pbcopy then replaces it with +# empty plain text. Keep every NSPasteboard representation in short-lived +# files instead. The manifest stays small even when the clipboard holds a +# large TIFF, and no additional Python package is needed. +_MAC_SNAPSHOT = collections.namedtuple("MacClipboardSnapshot", "directory manifest") + +_MAC_SNAPSHOT_SCRIPT = r''' +ObjC.import("AppKit"); +const root = ObjC.unwrap( + $.NSProcessInfo.processInfo.environment.objectForKey("DIKTE_PASTEBOARD_DIR") +); +const pasteboard = $.NSPasteboard.generalPasteboard; +const items = pasteboard.pasteboardItems; +const result = []; +for (let i = 0; i < items.count; i++) { + const item = items.objectAtIndex(i); + const representations = []; + const types = item.types; + for (let j = 0; j < types.count; j++) { + const type = ObjC.unwrap(types.objectAtIndex(j)); + const data = item.dataForType(type); + if (!data) continue; + const file = `${i}-${j}.bin`; + if (data.writeToFileAtomically(`${root}/${file}`, true)) { + representations.push({type, file}); + } + } + result.push(representations); +} +JSON.stringify(result); +''' + +_MAC_RESTORE_SCRIPT = r''' +ObjC.import("AppKit"); +const root = ObjC.unwrap( + $.NSProcessInfo.processInfo.environment.objectForKey("DIKTE_PASTEBOARD_DIR") +); +const input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile; +const source = $.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding); +const rows = JSON.parse(ObjC.unwrap(source)); +const items = []; +for (const representations of rows) { + const item = $.NSPasteboardItem.alloc.init; + for (const representation of representations) { + const data = $.NSData.dataWithContentsOfFile( + `${root}/${representation.file}` + ); + if (data) item.setDataForType(data, representation.type); + } + items.push(item); +} +const pasteboard = $.NSPasteboard.generalPasteboard; +pasteboard.clearContents; +pasteboard.writeObjects($(items)); +''' + class PasteError(Exception): pass +# --- the key press, one group per system ---------------------------------- + def _keys(shortcut): """'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know.""" parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()] @@ -54,41 +137,200 @@ def _xdotool_command(shortcut): return ["xdotool", "key", "--clearmodifiers", "+".join(keys)] +def _program_keyboard(program, command, hint=""): + """A desktop that presses keys by running another program. + + Returns the three fields an entry below is built from: the program's name, + whether it is here at all, and the press itself. + """ + + def ready(): + return shutil.which(program) is not None + + def press(shortcut, delay): + if not ready(): + raise PasteError(t("{tool} not found, cannot paste automatically.", + tool=program)) + argv = command(shortcut) + time.sleep(delay) # let the selection settle and focus come back + try: + res = subprocess.run(argv, capture_output=True, text=True, timeout=10) + except (subprocess.SubprocessError, OSError) as exc: + raise PasteError(t("Could not run {tool}: {error}", + tool=program, error=exc)) from exc + if res.returncode != 0: + message = t("{tool} failed: {error}", tool=program, + error=res.stderr.strip() or "unknown error") + raise PasteError(f"{message}\n{t(hint)}" if hint else message) + + return {"keyboard": program, "ready": ready, "press": press} + + +def _macos_keys(shortcut): + """'Cmd+V' -> (9, 0x100000): where the key sits, and the modifiers on it.""" + parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()] + parts = [MAC_ALIASES.get(part, part) for part in parts] + if not parts or parts[-1] not in MAC_KEYCODES: + raise PasteError(t("Unknown key: {key}", key=parts[-1] if parts else shortcut)) + flags = 0 + for part in parts[:-1]: + if part not in MAC_FLAGS: + raise PasteError(t("Unknown key: {key}", key=part)) + flags |= MAC_FLAGS[part] + return MAC_KEYCODES[parts[-1]], flags + + +@functools.lru_cache(maxsize=1) +def _macos_api(): + """The bit of CoreGraphics and Accessibility a paste goes through. + + Loaded on the first paste rather than at import: this module is read on + every system, and these two frameworks exist on one of them. + """ + try: + services = ctypes.CDLL( + "/System/Library/Frameworks/ApplicationServices.framework" + "/ApplicationServices" + ) + core = ctypes.CDLL( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + except OSError as exc: + raise PasteError(t("Could not run {tool}: {error}", + tool="CoreGraphics", error=exc)) from exc + services.AXIsProcessTrusted.argtypes = [] + services.AXIsProcessTrusted.restype = ctypes.c_bool + services.CGEventCreateKeyboardEvent.argtypes = [ + ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool, + ] + services.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p + services.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64] + services.CGEventSetFlags.restype = None + services.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + services.CGEventPost.restype = None + core.CFRelease.argtypes = [ctypes.c_void_p] + core.CFRelease.restype = None + return services, core + + +def _macos_trusted(): + """Whether macOS lets this process type into another application.""" + try: + return bool(_macos_api()[0].AXIsProcessTrusted()) + except PasteError: + return False + + +_asked_for_permission = False + + +def _ask_for_permission(): + """Open the one settings pane that grants it, and only the first time. + + 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. + """ + global _asked_for_permission + if _asked_for_permission: + return + _asked_for_permission = True + try: + subprocess.Popen( + ["open", ("x-apple.systempreferences:com.apple.preference.security" + "?Privacy_Accessibility")], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, + ) + except OSError: + pass + + +def _macos_press(shortcut, delay): + """Post the key down and up straight into the window system. + + Nothing is typed anywhere until macOS has been told to trust Dikte, and it + only asks once, when the paste it was granted for is first tried. + """ + keycode, flags = _macos_keys(shortcut) + services, core = _macos_api() + if not _macos_trusted(): + _ask_for_permission() + raise PasteError(t( + "macOS has not been told to let Dikte press keys. Turn Dikte on " + "under System Settings → Privacy & Security → Accessibility." + )) + + time.sleep(delay) # let the selection settle and focus come back + down = services.CGEventCreateKeyboardEvent(None, keycode, True) + up = services.CGEventCreateKeyboardEvent(None, keycode, False) + if not down or not up: + for event in (down, up): + if event: + core.CFRelease(event) + raise PasteError(t("Could not run {tool}: {error}", tool="CoreGraphics", + error="it would not make a keyboard event")) + try: + for event in (down, up): + services.CGEventSetFlags(event, flags) + services.CGEventPost(HID_EVENT_TAP, event) + time.sleep(0.01) + finally: + core.CFRelease(down) + core.CFRelease(up) + + +# --- which of them is here ------------------------------------------------- + Desktop = collections.namedtuple( "Desktop", - # The two programs, the packages to install them from, how to build the key - # press, and what else to say when the key press fails. - "clipboard keyboard packages read_command copy_command key_command key_hint", + # The clipboard program and the two commands it is run with, what to + # install when it is missing, the paste combinations Settings offers, and + # the key press: the program that does it, whether it can happen at all, + # and the pressing itself. + "clipboard packages read_command copy_command shortcuts keyboard ready press", ) WAYLAND = Desktop( clipboard="wl-copy", - keyboard="ydotool", packages="wl-clipboard and ydotool", read_command=["wl-paste", "--no-newline"], copy_command=["wl-copy"], - key_command=_ydotool_command, - key_hint="Is ydotoold running? (systemctl --user status ydotool)", + shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"], + **_program_keyboard( + "ydotool", _ydotool_command, + hint="Is ydotoold running? (systemctl --user status ydotool)", + ), ) X11 = Desktop( clipboard="xclip", - keyboard="xdotool", packages="xclip and xdotool", read_command=["xclip", "-selection", "clipboard", "-out"], copy_command=["xclip", "-selection", "clipboard", "-in"], - key_command=_xdotool_command, - key_hint="", + shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"], + **_program_keyboard("xdotool", _xdotool_command), +) + +MACOS = Desktop( + clipboard="pbcopy", + packages="", # both are part of macOS; there is nothing to install + read_command=["pbpaste"], + copy_command=["pbcopy"], + shortcuts=["cmd+v", "cmd+shift+v", "cmd+alt+shift+v"], + keyboard="", # no program: the key press is a call into the system + ready=_macos_trusted, + press=_macos_press, ) def desktop(): - """The pair of programs this session's clipboard and keyboard go through. + """The programs this session's clipboard and key press go through. Read every time rather than settled at import: a session started before the display server was up would otherwise be stuck with the wrong answer, and a test would have nowhere to say which one it means. """ + if sys.platform == "darwin": + return MACOS if os.environ.get("XDG_SESSION_TYPE") == "x11": return X11 if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): @@ -98,8 +340,45 @@ def desktop(): # --- the clipboard --------------------------------------------------------- +def _macos_snapshot(): + """Copy every native pasteboard type to a temporary, file-backed snapshot.""" + directory = tempfile.mkdtemp(prefix="dikte-clipboard-") + environment = dict(os.environ, DIKTE_PASTEBOARD_DIR=directory) + try: + result = subprocess.run( + ["osascript", "-l", "JavaScript", "-e", _MAC_SNAPSHOT_SCRIPT], + capture_output=True, text=True, timeout=15, env=environment, + ) + manifest = result.stdout.strip() + rows = json.loads(manifest) if result.returncode == 0 else None + if not isinstance(rows, list): + raise ValueError("the pasteboard helper returned no manifest") + return _MAC_SNAPSHOT(directory, manifest) + except (json.JSONDecodeError, OSError, subprocess.SubprocessError, ValueError): + shutil.rmtree(directory, ignore_errors=True) + return None + + +def _macos_restore(snapshot): + """Put a native snapshot back, then discard its short-lived files.""" + environment = dict(os.environ, DIKTE_PASTEBOARD_DIR=snapshot.directory) + try: + subprocess.run( + ["osascript", "-l", "JavaScript", "-e", _MAC_RESTORE_SCRIPT], + input=snapshot.manifest, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, text=True, timeout=15, env=environment, + ) + except (OSError, subprocess.SubprocessError): + pass + finally: + shutil.rmtree(snapshot.directory, ignore_errors=True) + def read_clipboard(): here = desktop() + if here is MACOS and shutil.which("osascript"): + snapshot = _macos_snapshot() + if snapshot is not None: + return snapshot if not shutil.which(here.read_command[0]): return None try: @@ -124,8 +403,11 @@ def _run_copy(payload): def copy(text): here = desktop() if not shutil.which(here.clipboard): - raise PasteError(t("{tool} not found. Install {packages}.", - tool=here.clipboard, packages=here.packages)) + raise PasteError( + t("{tool} not found. Install {packages}.", + tool=here.clipboard, packages=here.packages) if here.packages + else t("{tool} not found.", tool=here.clipboard) + ) try: res = _run_copy(text.encode("utf-8")) except (subprocess.SubprocessError, OSError) as exc: @@ -136,6 +418,9 @@ def copy(text): def copy_bytes(data): + if isinstance(data, _MAC_SNAPSHOT): + _macos_restore(data) + return if data is None or not shutil.which(desktop().clipboard): return try: @@ -147,25 +432,11 @@ def copy_bytes(data): # --- the key press --------------------------------------------------------- def paste_ready(): - return shutil.which(desktop().keyboard) is not None + """Whether a paste can be sent: the program is here, or macOS trusts us.""" + return desktop().ready() -def press(shortcut="ctrl+v", delay=0.12): - """Press a key combination, e.g. 'ctrl+v'.""" +def press(shortcut="", delay=0.12): + """Press a paste combination, e.g. 'ctrl+v', or this desktop's own.""" here = desktop() - if not paste_ready(): - raise PasteError(t("{tool} not found, cannot paste automatically.", - tool=here.keyboard)) - - command = here.key_command(shortcut) - time.sleep(delay) # let the selection settle and focus come back - try: - res = subprocess.run(command, capture_output=True, text=True, timeout=10) - except (subprocess.SubprocessError, OSError) as exc: - raise PasteError(t("Could not run {tool}: {error}", - tool=here.keyboard, error=exc)) from exc - if res.returncode != 0: - message = t("{tool} failed: {error}", tool=here.keyboard, - error=res.stderr.strip() or "unknown error") - raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint - else message) + here.press(shortcut or here.shortcuts[0], delay) diff --git a/settings_ui.py b/settings_ui.py index c2ed2e6..87d2611 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -23,6 +23,7 @@ import ggml import hotkey import ipc import meeting +import paste from filetranscribe import FileTranscriber from i18n import t @@ -106,7 +107,6 @@ REASONING_LEVELS = [ ("Low", "low"), ("Medium", "medium"), ("High", "high"), ("Very high", "xhigh"), ("Maximum", "max"), ] -PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"] # Offered for every global shortcut, which keeps them one kind of field rather # than four. The boxes stay editable: this is a shortlist of combinations that # are usually free, not the set of ones that work. @@ -116,6 +116,13 @@ SHORTCUTS = [ "Meta+A", "Meta+D", "Meta+M", "Ctrl+Alt+F1", "Ctrl+Alt+F2", "Ctrl+Alt+F3", ] +# Cmd+Space is Spotlight and Ctrl+Space switches input sources, so a Mac gets +# its own shortlist. Option is what Alt is called on that keyboard. +MAC_SHORTCUTS = [ + "Ctrl+Option+Space", "Cmd+Shift+Space", "Ctrl+Shift+Space", + "Ctrl+Option+A", "Ctrl+Option+D", "Ctrl+Option+M", + "Cmd+Option+A", "Cmd+Option+D", "Cmd+Option+M", +] AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma " "*.mp4 *.mkv *.webm *.mov *.avi") @@ -550,10 +557,16 @@ class SettingsWindow(QDialog): form.addRow("", self.auto_paste) self.paste_shortcut = QComboBox() - self.paste_shortcut.addItems(PASTE_SHORTCUTS) - self.paste_shortcut.setToolTip( - t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.") - ) + # A shortlist of the combinations that usually paste, not the set of + # them: a stored one this desktop does not offer is kept as it is + # rather than quietly replaced by the first item on the list. + self.paste_shortcut.setEditable(True) + self.paste_shortcut.addItems(paste.desktop().shortcuts) + self.paste_shortcut.setToolTip(t( + "macOS asks for Accessibility permission the first time this is sent." + if paste.desktop() is paste.MACOS else + "Terminals usually want ctrl+shift+v. Change this if pasting does nothing." + )) form.addRow(t("Paste key"), self.paste_shortcut) self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting")) @@ -591,7 +604,9 @@ class SettingsWindow(QDialog): ) form.addRow("", self.filter_hallucinations) - self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)")) + self.keep_audio = QCheckBox( + t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR)) + ) form.addRow("", self.keep_audio) return page @@ -984,6 +999,15 @@ class SettingsWindow(QDialog): self.meeting_system.addItem(desc, name) sources_form.addRow(t("The other participants"), self.meeting_system) + if audio.sound() is audio.COREAUDIO: + mac_note = QLabel(t( + "macOS does not offer what the speakers are playing as something " + "to record. Install BlackHole or Loopback, send the meeting's " + "sound through it, and pick it above." + )) + mac_note.setWordWrap(True) + sources_form.addRow(mac_note) + note = QLabel(t( "Wear headphones if you can. Through speakers your microphone hears " "the other side as well, and although a line that lands on both " @@ -1224,20 +1248,34 @@ class SettingsWindow(QDialog): layout.addLayout(form) self.evdev_enabled = QCheckBox(t( - "Use the built-in listener (/dev/input), for when the KDE shortcut is " - "not active yet" + "Use the built-in listener (/dev/input), for when the {desktop} " + "shortcut is not active yet", desktop=hotkey.desktop_name() )) self.evdev_enabled.setToolTip(t( "Works immediately, no session restart. The only difference: the key " "combination also reaches the focused application." )) layout.addWidget(self.evdev_enabled) + # Nothing to wait for where nothing is installed: there the listener is + # the mechanism, always on, and not a choice to offer. + self.evdev_enabled.setVisible(hotkey.installs_shortcuts()) - note = QLabel(t( - "KWin only reads shortcut settings at startup. After 'Install' the " - "shortcut shows up under System Settings → Shortcuts, but it will not " - "fire until you log out and back in. Until then, use the built-in listener." - )) + if hotkey.shortcut_needs_restart(): + explanation = t( + "KWin only reads shortcut settings at startup. After 'Install' the " + "shortcut shows up under System Settings → Shortcuts, but it will " + "not fire until you log out and back in. Until then, use the " + "built-in listener." + ) + elif hotkey.installs_shortcuts(): + explanation = t("The shortcut starts working as soon as it is installed.") + else: + explanation = t( + "Dikte asks macOS for these combinations itself, while it is " + "running. Nothing is installed, and no other application receives " + "them in the meantime." + ) + note = QLabel(explanation) note.setWordWrap(True) layout.addWidget(note) layout.addStretch(1) @@ -1310,7 +1348,8 @@ class SettingsWindow(QDialog): """The field a global shortcut is typed or picked in.""" box = QComboBox() box.setEditable(True) - box.addItems(SHORTCUTS) + box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS" + else SHORTCUTS) box.setCurrentText("") if placeholder: box.lineEdit().setPlaceholderText(placeholder) @@ -1344,17 +1383,32 @@ class SettingsWindow(QDialog): box = self._shortcut_box(placeholder or t("none")) if tooltip: box.setToolTip(tooltip) - install = QPushButton(t("Install as a KDE shortcut")) - install.clicked.connect(lambda: self._install_shortcut(which)) - remove = QPushButton(t("Remove")) - remove.clicked.connect(lambda: self._remove_shortcut(which)) - form.addRow(label, self._row(box, install, remove)) + form.addRow(label, self._row(box, *self._install_buttons( + lambda: self._install_shortcut(which), + lambda: self._remove_shortcut(which), + ))) status = QLabel("") status.setWordWrap(True) form.addRow(status) self._shortcut_rows[which] = (box, status, missing) return box + @staticmethod + def _install_buttons(install_handler, remove_handler): + """Install and Remove, where this system has somewhere to install into. + + macOS has not: Dikte asks for the combination itself while it runs, so + there is nothing to write down and nothing to take back out. + """ + if not hotkey.installs_shortcuts(): + return [] + install = QPushButton(t("Install as a {desktop} shortcut", + desktop=hotkey.desktop_name())) + install.clicked.connect(install_handler) + remove = QPushButton(t("Remove")) + remove.clicked.connect(remove_handler) + return [install, remove] + @staticmethod def _row(*widgets): """Widgets side by side in one form row; the first one takes the space.""" diff --git a/tests/__init__.py b/tests/__init__.py index e83e504..5b00175 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -17,6 +17,11 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") _SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-") os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config") os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data") +# Home goes with them: the shortcut file, the applications directory and every +# macOS path start from it rather than from an XDG variable, and a test run is +# not allowed to touch the real one. +os.environ["HOME"] = os.path.join(_SANDBOX, "home") +os.makedirs(os.environ["HOME"], exist_ok=True) atexit.register(shutil.rmtree, _SANDBOX, True) # A key sitting in the environment would otherwise reach the code that falls diff --git a/tests/test_audio.py b/tests/test_audio.py index 7595d1b..ebeef67 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,8 +1,12 @@ -"""Level metering, the WAV writer, and what pactl is asked for. +"""Level metering, the WAV writer, and what the sound system is asked for. The device list is where a platform port lands first, so the parsing is pinned -here: a source that is not a monitor is an input, one that is belongs to the -speakers, and neither list may go missing when pactl is absent. +here: on Linux a source that is not a monitor is an input, one that is belongs +to the speakers, and neither list may go missing when pactl is absent. macOS +answers the same three questions out of one ffmpeg listing. + +Each class says which machine it is standing on, so both halves run on either +one: nothing here reaches a real sound server. """ import array @@ -11,6 +15,7 @@ import io import json import os import subprocess +import sys import unittest import wave from unittest import mock @@ -19,7 +24,6 @@ import audio from tests.support import ( DikteTest, FakeCompleted, - linux_only, only_these_tools, pcm, silence, @@ -28,6 +32,22 @@ from tests.support import ( ) +class OnLinux: + """A test that runs as if the machine ran PulseAudio or PipeWire.""" + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(sys, "platform", "linux")) + + +class OnMacOS: + """A test that runs as if the machine were a Mac.""" + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(sys, "platform", "darwin")) + + class ChunkLevels(unittest.TestCase): def test_silence(self): self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0)) @@ -115,8 +135,7 @@ SOURCES = [ ] -@linux_only -class Devices(DikteTest): +class Devices(OnLinux, DikteTest): @contextlib.contextmanager def pactl(self, sources=None, sink=None, tools=("pactl",)): payloads = { @@ -216,8 +235,7 @@ class FakeProcess: self._alive = False -@linux_only -class RecordingCommand(DikteTest): +class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" def test_parec_is_preferred(self): @@ -270,8 +288,7 @@ class RecordingCommand(DikteTest): if arg.startswith(flag)]) -@linux_only -class RecorderChain(DikteTest): +class RecorderChain(OnLinux, DikteTest): """Start to WAV, with pw-record faked out.""" def record(self, data, target="", max_seconds=300): @@ -404,5 +421,146 @@ class RecorderChain(DikteTest): self.assertFalse(recorder.active) +class MeetingCommand(unittest.TestCase): + """One process reading both devices, because two would drift apart.""" + + def command(self, platform, mic="", system="them"): + with mock.patch.object(sys, "platform", platform): + return audio.meeting_command(mic, system) + + def test_linux_reads_both_through_pulse(self): + cmd = self.command("linux", mic="mine") + 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_no_microphone_named_means_the_default_one(self): + self.assertIn("default", self.command("linux")) + self.assertIn(":default", self.command("darwin")) + + 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_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)) + + +class MacDevices(OnMacOS, DikteTest): + """The one ffmpeg listing all three device questions are answered from.""" + + LISTING = ( + "[AVFoundation indev @ 0x7fb] AVFoundation video devices:\n" + "[AVFoundation indev @ 0x7fb] [0] FaceTime HD Camera\n" + "[AVFoundation indev @ 0x7fb] [1] Capture screen 0\n" + "[AVFoundation indev @ 0x7fb] AVFoundation audio devices:\n" + "[AVFoundation indev @ 0x7fb] [0] MacBook Pro Microphone\n" + "[AVFoundation indev @ 0x7fb] [1] BlackHole 2ch\n" + ": Input/output error\n" + ) + + @contextlib.contextmanager + def listing(self, stderr=None, tools=("ffmpeg",)): + completed = FakeCompleted( + returncode=1, stderr=self.LISTING if stderr is None else stderr) + with only_these_tools(*tools), \ + mock.patch.object(subprocess, "run", return_value=completed): + yield + + 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")]) + + def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self): + with self.listing(): + name, description = audio.list_sources()[1] + self.assertEqual(name, "1") + self.assertIn("BlackHole", description) + + def test_no_ffmpeg_installed(self): + with only_these_tools(): + self.assertEqual(audio.list_sources(), []) + self.assertEqual(audio.list_monitors(), []) + self.assertEqual(audio.default_monitor(), "") + + def test_an_ffmpeg_that_will_not_run(self): + with only_these_tools("ffmpeg"), \ + mock.patch.object(subprocess, "run", side_effect=OSError("nope")): + self.assertEqual(audio.list_sources(), []) + + def test_a_listing_with_no_audio_section(self): + with self.listing(stderr="[AVFoundation indev @ 0x7fb] [0] FaceTime\n"): + self.assertEqual(audio.list_sources(), []) + + def test_the_far_side_of_a_meeting_is_offered_the_same_devices(self): + """macOS calls none of them an output, so the loopback one is in here.""" + with self.listing(): + self.assertEqual(audio.list_monitors(), audio.list_sources()) + + def test_the_loopback_driver_is_picked_out_by_name(self): + with self.listing(): + self.assertEqual(audio.default_monitor(), "1") + + def test_the_other_two_drivers_people_install(self): + for name in ("Loopback Audio", "Soundflower (2ch)"): + with self.subTest(name=name): + 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") + + 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(), "") + + +class MacRecordingCommand(OnMacOS, DikteTest): + def test_the_microphone_is_read_through_avfoundation(self): + with only_these_tools("ffmpeg"): + cmd = audio.recording_command() + self.assertEqual(cmd[0], "ffmpeg") + self.assertEqual(cmd[cmd.index("-f") + 1], "avfoundation") + + 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")) + + def test_it_captures_the_format_the_rest_of_the_code_expects(self): + with only_these_tools("ffmpeg"): + cmd = audio.recording_command() + self.assertEqual(cmd[cmd.index("-ar") + 1], str(audio.RATE)) + self.assertEqual(cmd[cmd.index("-ac") + 1], str(audio.CHANNELS)) + self.assertEqual(cmd[-2:], ["s16le", "-"]) + + def test_no_ffmpeg_installed(self): + with only_these_tools(): + self.assertEqual(audio.recording_command(), []) + + def test_what_a_mac_is_told_to_install(self): + recorder = audio.Recorder() + failures = [] + recorder.failed.connect(failures.append) + with only_these_tools(): + recorder.start() + self.assertIn("brew install ffmpeg", failures[0]) + self.assertFalse(recorder.active) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index c41c503..31e153c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,7 @@ import cleanup import config as cfg import ggml import i18n +import paste from tests.support import DikteTest @@ -453,6 +454,39 @@ class Defaults(unittest.TestCase): with self.subTest(prompt=f"{name}_{suffix}"): self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip()) + def test_the_paste_key_is_the_one_this_desktop_pastes_with(self): + """cmd+v on a Mac, and it must be one paste.py can actually press.""" + self.assertEqual(cfg.DEFAULTS["paste_shortcut"], + paste.desktop().shortcuts[0]) + + +class Directories(unittest.TestCase): + """Where the settings and the recordings are kept, per system.""" + + def test_linux_keeps_them_apart_and_follows_xdg(self): + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", + "XDG_DATA_HOME": "/d"}): + config_dir, data_dir = cfg._directories("linux") + self.assertEqual(str(config_dir), "/c/dikte") + self.assertEqual(str(data_dir), "/d/dikte") + + def test_linux_without_the_variables_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + config_dir, data_dir = cfg._directories("linux") + self.assertTrue(str(config_dir).endswith("/.config/dikte")) + self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) + + def test_a_mac_keeps_both_in_application_support(self): + config_dir, data_dir = cfg._directories("darwin") + self.assertEqual(config_dir, data_dir) + self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) + + def test_a_mac_does_not_read_the_xdg_variables(self): + """A Mac with them set from some other tool still stores in one place.""" + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + config_dir, _ = cfg._directories("darwin") + self.assertNotIn("/c", str(config_dir)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 548d4c3..161e688 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -176,6 +176,9 @@ class Download(Local): class InstallProgram(Local): def setUp(self): super().setUp() + # These fixtures are Ubuntu release archives. Keep checking that path + # on every host, including the Mac that checks the macOS backend. + self.patch_attr(sys, "platform", "linux") # Built once, because the release listing has to publish its checksum # and a tarball is not the same bytes twice. self.archive = tarball({ @@ -216,6 +219,23 @@ class InstallProgram(Local): ggml.install_program(ggml.WHISPER) self.assertIn("this machine", str(caught.exception)) + def test_a_mac_does_not_install_an_ubuntu_archive_for_the_same_architecture(self): + self.patch_attr(sys, "platform", "darwin") + self.patch_attr(ggml, "_arch", lambda: "arm64") + listing = self.release("whisper-bin-ubuntu-arm64.tar.gz") + 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)) + + def test_a_mac_uses_the_native_llama_archive_instead_of_ubuntu(self): + self.patch_attr(sys, "platform", "darwin") + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.assertEqual( + ggml._wanted_assets(ggml.LLAMA), + ("bin-macos-arm64.tar.gz",), + ) + def test_what_was_installed_is_remembered(self): path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") self.assertEqual(ggml.installed_program(ggml.WHISPER), path) @@ -661,4 +681,3 @@ class Sizes(DikteTest): self.assertEqual(ggml.human_size(512), "512 B") self.assertEqual(ggml.human_size(574041195), "547.4 MB") self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") - diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 01ae92d..c6ec50f 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -163,10 +163,13 @@ class Bindings(DikteTest): self.assertEqual(fired({29, 42}), []) # ctrl + shift -@linux_only class Chooser(DikteTest): """Which desktop is asked to register the shortcut.""" + def setUp(self): + super().setUp() + self.patch_attr(hotkey.sys, "platform", "linux") + @contextlib.contextmanager def under(self, desktop, has_gsettings=True): """A session that says it is this desktop, with or without gsettings.""" @@ -447,5 +450,236 @@ class KdeShortcut(DikteTest): self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), []) +# --- macOS ---------------------------------------------------------------- + +class ParseMacShortcut(unittest.TestCase): + def test_a_combination_a_mac_would_use(self): + self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Space"), + (hotkey.MAC_MODS["cmd"], 49)) + + def test_case_and_spacing_do_not_matter(self): + self.assertEqual(hotkey.parse_macos_shortcut(" ctrl + option + a "), + hotkey.parse_macos_shortcut("Ctrl+Option+A")) + + def test_several_modifiers_are_one_number(self): + modifiers, key = hotkey.parse_macos_shortcut("Cmd+Shift+M") + self.assertEqual(modifiers, + hotkey.MAC_MODS["cmd"] | hotkey.MAC_MODS["shift"]) + self.assertEqual(key, hotkey.MAC_KEYS["m"]) + + def test_the_names_a_mac_keyboard_uses(self): + for name in ("cmd", "command", "meta", "super"): + with self.subTest(name=name): + self.assertEqual(hotkey.parse_macos_shortcut(f"{name}+space"), + hotkey.parse_macos_shortcut("cmd+space")) + self.assertEqual(hotkey.parse_macos_shortcut("option+a"), + hotkey.parse_macos_shortcut("alt+a")) + + def test_a_key_on_its_own(self): + self.assertEqual(hotkey.parse_macos_shortcut("F5"), (0, 96)) + + def test_modifiers_with_no_key(self): + self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Shift"), (None, None)) + + def test_a_key_nobody_mapped(self): + self.assertEqual(hotkey.parse_macos_shortcut("Cmd+F13"), (None, None)) + + def test_two_keys_are_not_a_shortcut(self): + self.assertEqual(hotkey.parse_macos_shortcut("A+B"), (None, None)) + + def test_nothing(self): + self.assertEqual(hotkey.parse_macos_shortcut(""), (None, None)) + self.assertEqual(hotkey.parse_macos_shortcut(None), (None, None)) + + +class FakeCarbon: + """Enough of Carbon to watch what the listener asks it for. + + The references are numbers standing in for pointers, which is all the code + does with them: it collects them and hands them back to be unregistered. + """ + + def __init__(self, install=0, register=0): + self.install, self.register = install, register # what they return + self.registered = [] # (key, modifiers, identifier) + self.unregistered = [] + self.handlers_removed = 0 + self.pressed_id = 0 + self.parameter_result = 0 + + def GetApplicationEventTarget(self): + return 7000 + + def InstallEventHandler(self, target, callback, count, spec, data, out): + if self.install == 0: + out._obj.value = 8000 + return self.install + + def RegisterEventHotKey(self, key, modifiers, identifier, target, options, out): + if self.register != 0: + return self.register + self.registered.append((key, modifiers, identifier.id)) + out._obj.value = 9000 + len(self.registered) + return 0 + + def UnregisterEventHotKey(self, reference): + self.unregistered.append(reference.value) + return 0 + + def RemoveEventHandler(self, handler): + self.handlers_removed += 1 + return 0 + + def GetEventParameter(self, event, kind, name, wanted_type, size, out_size, out): + out._obj.id = self.pressed_id + return self.parameter_result + + +class CarbonListener(DikteTest): + """What the listener asks macOS for, without a Mac to ask.""" + + def setUp(self): + super().setUp() + self.carbon = FakeCarbon() + self.patch_attr(hotkey, "_carbon", lambda: self.carbon) + self.addCleanup(hotkey._REGISTERED.clear) + self.listener = hotkey.CarbonHotkey() + self.addCleanup(self.listener.stop) + self.failures = [] + self.listener.failed.connect(self.failures.append) + + def test_every_binding_is_asked_for_by_position_and_modifier(self): + self.assertTrue(self.listener.start({"toggle": "Ctrl+Option+Space"})) + self.assertEqual(self.carbon.registered, + [(49, hotkey.MAC_MODS["ctrl"] | hotkey.MAC_MODS["option"], 1)]) + self.assertEqual(self.failures, []) + self.assertTrue(self.listener.running) + + def test_a_binding_with_no_shortcut_is_skipped(self): + self.assertFalse(self.listener.start({"toggle": "", "ask": ""})) + self.assertEqual(self.carbon.registered, []) + self.assertFalse(self.listener.running) + + def test_an_unparsable_shortcut_is_reported_and_the_rest_go_on(self): + self.assertTrue(self.listener.start({"toggle": "Cmd+F13", + "ask": "Cmd+Shift+Space"})) + self.assertEqual(len(self.failures), 1) + self.assertIn("Cmd+F13", self.failures[0]) + self.assertEqual(len(self.carbon.registered), 1) + + def test_a_combination_another_application_already_holds(self): + """The conflict warning macOS has: it is the answer to asking.""" + self.carbon.register = -9878 # eventHotKeyExistsErr + self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"})) + self.assertIn("Cmd+Shift+Space", self.failures[0]) + self.assertFalse(self.listener.running) + + def test_a_handler_that_will_not_install(self): + self.carbon.install = -50 + self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"})) + self.assertEqual(self.carbon.registered, []) + self.assertEqual(len(self.failures), 1) + + def test_no_carbon_to_talk_to(self): + self.patch_attr(hotkey, "_carbon", + mock.Mock(side_effect=OSError("no such library"))) + self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"})) + self.assertIn("no such library", self.failures[0]) + + def test_the_key_press_arrives_under_the_name_it_was_registered_with(self): + self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"}) + heard = [] + self.listener.triggered.connect(heard.append) + self.carbon.pressed_id = 2 # the second binding, which is "ask" + self.listener._callback(None, 0, None) + self.assertEqual(heard, ["ask"]) + + def test_a_press_carbon_could_not_identify_is_dropped(self): + self.listener.start({"toggle": "Cmd+Shift+Space"}) + heard = [] + self.listener.triggered.connect(heard.append) + self.carbon.parameter_result = -50 + self.listener._callback(None, 0, None) + self.assertEqual(heard, []) + + def test_stopping_hands_every_registration_back(self): + self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"}) + self.listener.stop() + self.assertEqual(self.carbon.unregistered, [9001, 9002]) + self.assertEqual(self.carbon.handlers_removed, 1) + self.assertFalse(self.listener.running) + + def test_starting_twice_does_not_leave_the_first_set_behind(self): + self.listener.start({"toggle": "Cmd+Shift+Space"}) + self.listener.start({"toggle": "Cmd+Shift+A"}) + self.assertEqual(self.carbon.unregistered, [9001]) + self.assertEqual(len(self.listener._registrations), 1) + + def test_what_it_registered_is_what_the_status_line_reads_back(self): + with mock.patch.object(hotkey.sys, "platform", "darwin"): + self.listener.start({"toggle": "Ctrl+Option+Space", + "meeting": "Ctrl+Option+M"}) + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Option+Space") + self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID), + "Ctrl+Option+M") + self.listener.stop() + self.assertIsNone(hotkey.shortcut_status()) + + +class MacChooser(DikteTest): + """What the shortcut verbs mean where there is nothing to write them into.""" + + def setUp(self): + super().setUp() + self.patch_attr(hotkey.sys, "platform", "darwin") + self.addCleanup(hotkey._REGISTERED.clear) + + def test_the_listener_is_the_one_macos_has(self): + self.assertIsInstance(hotkey.listener(), hotkey.CarbonHotkey) + + def test_everywhere_else_reads_the_keyboard_itself(self): + with mock.patch.object(hotkey.sys, "platform", "linux"): + self.assertIsInstance(hotkey.listener(), hotkey.EvdevHotkey) + + def test_there_is_nothing_to_install_into(self): + self.assertFalse(hotkey.installs_shortcuts()) + self.assertFalse(hotkey.shortcut_needs_restart()) + self.assertEqual(hotkey.desktop_name(), "macOS") + + def test_installing_records_it_rather_than_writing_anything(self): + with mock.patch.object(hotkey.subprocess, "run") as run: + ok, message = hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle") + run.assert_not_called() + self.assertTrue(ok) + self.assertIn("Cmd+Shift+Space", message) + self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space") + + def test_removing_takes_it_back_out(self): + hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle") + hotkey.remove_shortcut() + self.assertIsNone(hotkey.shortcut_status()) + + def test_each_verb_is_kept_apart(self): + hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle") + hotkey.install_shortcut("Cmd+Shift+M", "dikte meeting", + desktop_id=hotkey.MEETING_DESKTOP_ID) + self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space") + self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID), + "Cmd+Shift+M") + + def test_no_list_of_conflicts_to_read(self): + """Not even KDE's file, which a Mac could well have a copy of.""" + self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), []) + + def test_a_combination_is_checked_against_the_mac_table(self): + self.assertTrue(hotkey.valid_shortcut("Cmd+Shift+Space")) + self.assertFalse(hotkey.valid_shortcut("Ctrl+F13")) + + def test_the_other_table_is_the_one_used_elsewhere(self): + with mock.patch.object(hotkey.sys, "platform", "linux"): + self.assertTrue(hotkey.valid_shortcut("Ctrl+F1")) + self.assertFalse(hotkey.valid_shortcut("Cmd+Space")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_paste.py b/tests/test_paste.py index 1d6806b..4bfe99f 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -1,30 +1,37 @@ """The clipboard and the key press, which is where a dictation actually lands. -Everything here shells out, so the tools are faked. What the tests hold onto is -the command line: a paste that presses the wrong keys, or in the wrong order, -types nothing and looks like a hang. +Everything here is faked: the programs the two Linux desktops shell out to, and +the frameworks macOS goes through. What the tests hold onto is what was asked +for. A paste that presses the wrong keys, or in the wrong order, types nothing +and looks like a hang. -Both desktops owe the same promises, so those are written once and run against -each of them. A third one added to paste.py inherits the same list rather than -needing its own copy of it. +Every system owes the same promises about the clipboard, so those are written +once and run against each of them. A fourth one added to paste.py inherits the +same list rather than needing its own copy of it. Each class says which system +it is standing on, which is why none of this is skipped anywhere: the Linux half +is checked on a Mac and the macOS half on Linux, and a change to the chooser +cannot quietly break the platform nobody is sitting at. """ import os +import pathlib import subprocess +import sys +import tempfile import unittest from typing import ClassVar from unittest import mock import paste -from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools +from tests.support import DikteTest, FakeCompleted, only_these_tools -@linux_only class Chooser(DikteTest): """Which pair of programs this session's clipboard goes through.""" - def under(self, **env): - with mock.patch.dict(os.environ, env, clear=True): + def under(self, platform="linux", **env): + with mock.patch.object(sys, "platform", platform), \ + mock.patch.dict(os.environ, env, clear=True): return paste.desktop() def test_a_wayland_session(self): @@ -46,18 +53,29 @@ class Chooser(DikteTest): def test_nothing_set_at_all(self): self.assertIs(self.under(), paste.WAYLAND) + def test_a_mac(self): + self.assertIs(self.under("darwin"), paste.MACOS) -class DesktopContract: - """What both desktops owe. Each of them subclasses this once, below.""" + def test_a_mac_running_an_x_server_is_still_a_mac(self): + """XQuartz sets DISPLAY, and none of X's programs are what pastes here.""" + self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS) + + +class Standing: + """A test that runs as if it were sitting at one particular system.""" env: ClassVar[dict] = {} + platform = "linux" here = None def setUp(self): super().setUp() + self.enterContext(mock.patch.object(sys, "platform", self.platform)) self.enterContext(mock.patch.dict(os.environ, self.env, clear=True)) - # ---- reading the clipboard ------------------------------------------- + +class ClipboardContract(Standing): + """What every system owes the text on its way to the clipboard.""" def test_no_reader_installed(self): with only_these_tools(): @@ -81,13 +99,10 @@ class DesktopContract: mock.patch.object(subprocess, "run", side_effect=OSError("nope")): self.assertIsNone(paste.read_clipboard()) - # ---- copying ---------------------------------------------------------- - - def test_no_clipboard_tool_installed_says_what_to_install(self): + def test_no_clipboard_tool_installed_names_it(self): with only_these_tools(), self.assertRaises(paste.PasteError) as caught: paste.copy("hello") self.assertIn(self.here.clipboard, str(caught.exception)) - self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception)) def test_the_text_goes_in_as_utf8(self): with only_these_tools(self.here.clipboard), \ @@ -138,7 +153,15 @@ class DesktopContract: paste.copy_bytes(b"\x89PNG\r\n") self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n") - # ---- pressing the key ------------------------------------------------- + def test_the_paste_key_it_offers_is_one_it_can_press(self): + """Whatever Settings lists, pressing it must not come back unknown.""" + for shortcut in self.here.shortcuts: + with self.subTest(shortcut=shortcut): + self.assertTrue(self.pressing(shortcut)) + + +class KeyProgramContract(Standing): + """The half of it that is another program: ydotool, xdotool.""" def press(self, shortcut, result=None): with only_these_tools(self.here.keyboard), \ @@ -148,6 +171,10 @@ class DesktopContract: paste.press(shortcut) return run.call_args.args[0] + def pressing(self, shortcut): + """The command a shortcut would run, for the contract above.""" + return self.press(shortcut) + def test_no_keyboard_tool_installed(self): with only_these_tools(): self.assertFalse(paste.paste_ready()) @@ -180,9 +207,11 @@ class DesktopContract: self.assertIn(self.here.keyboard, str(caught.exception)) self.assertIn("no socket", str(caught.exception)) + def test_nothing_named_presses_the_one_this_desktop_pastes_with(self): + self.assertEqual(self.press(""), self.press(self.here.shortcuts[0])) -@linux_only -class Wayland(DesktopContract, DikteTest): + +class Wayland(ClipboardContract, KeyProgramContract, DikteTest): env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "wayland-0"} here = paste.WAYLAND @@ -208,8 +237,7 @@ class Wayland(DesktopContract, DikteTest): self.assertIn("ydotoold", str(caught.exception)) -@linux_only -class X11(DesktopContract, DikteTest): +class X11(ClipboardContract, KeyProgramContract, DikteTest): env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"} here = paste.X11 @@ -232,5 +260,174 @@ class X11(DesktopContract, DikteTest): self.assertNotIn("ydotoold", str(caught.exception)) +class FakeCoreGraphics: + """Enough of the two frameworks to watch what a paste does to them. + + The events are numbers standing in for pointers, which is all the code + treats them as: it makes them, sets flags on them, posts them, and hands + them back. + """ + + def __init__(self, trusted=True, makes=None): + self.trusted = trusted + self.makes = makes # how many it will hand out; None for as many as asked + self.made = [] # (keycode, is_down) + self.flags = [] # (event, flags) + self.posted = [] # (tap, event) + self.released = [] + + # --- ApplicationServices + def AXIsProcessTrusted(self): + 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: + return None + return 1000 + len(self.made) + + def CGEventSetFlags(self, event, flags): + self.flags.append((event, flags)) + + def CGEventPost(self, tap, event): + self.posted.append((tap, event)) + + # --- CoreFoundation + def CFRelease(self, event): + self.released.append(event) + + +class MacOS(ClipboardContract, DikteTest): + platform = "darwin" + here = paste.MACOS + + def setUp(self): + super().setUp() + self.api = FakeCoreGraphics() + self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api)) + 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) + self.opened = self.patch_attr(paste.subprocess, "Popen", mock.Mock()) + + def pressing(self, shortcut): + paste.press(shortcut) + return self.api.posted + + def test_the_key_goes_in_by_position_with_the_modifiers_on_it(self): + paste.press("cmd+v") + self.assertEqual(self.api.made, [(9, True), (9, False)]) + self.assertEqual([flags for _, flags in self.api.flags], + [paste.MAC_FLAGS["command"]] * 2) + self.assertEqual([tap for tap, _ in self.api.posted], + [paste.HID_EVENT_TAP] * 2) + + def test_the_down_is_posted_before_the_up(self): + paste.press("cmd+v") + self.assertEqual([event for _, event in self.api.posted], [1001, 1002]) + + def test_both_events_are_handed_back(self): + """CoreGraphics gives out memory that nothing else will free.""" + paste.press("cmd+v") + self.assertEqual(sorted(self.api.released), [1001, 1002]) + + def test_several_modifiers_are_one_number(self): + paste.press("cmd+shift+v") + self.assertEqual(self.api.flags[0][1], + paste.MAC_FLAGS["command"] | paste.MAC_FLAGS["shift"]) + + def test_the_names_a_mac_keyboard_uses(self): + for name in ("cmd", "command", "meta", "super"): + with self.subTest(name=name): + self.assertEqual(paste._macos_keys(f"{name}+v"), + (9, paste.MAC_FLAGS["command"])) + self.assertEqual(paste._macos_keys("option+v"), paste._macos_keys("alt+v")) + self.assertEqual(paste._macos_keys("control+v"), paste._macos_keys("ctrl+v")) + + def test_case_and_spacing_do_not_matter(self): + self.assertEqual(paste._macos_keys(" Cmd + V "), paste._macos_keys("cmd+v")) + + def test_a_key_nobody_mapped_is_refused_before_anything_is_posted(self): + with self.assertRaises(paste.PasteError) as caught: + paste.press("cmd+f13") + self.assertIn("f13", str(caught.exception)) + self.assertEqual(self.api.posted, []) + + def test_a_modifier_nobody_mapped(self): + with self.assertRaises(paste.PasteError) as caught: + paste.press("hyper+v") + self.assertIn("hyper", str(caught.exception)) + + def test_nothing_at_all(self): + with self.assertRaises(paste.PasteError): + paste.press("+") + + def test_nothing_is_typed_until_macos_says_so(self): + self.api.trusted = False + with self.assertRaises(paste.PasteError) as caught: + paste.press("cmd+v") + self.assertIn("Accessibility", str(caught.exception)) + self.assertEqual(self.api.posted, []) + + def test_the_permission_pane_is_opened_once_and_not_again(self): + """It is a window in the user's face, and one paste is every dictation.""" + self.api.trusted = False + for _ in range(3): + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.opened.assert_called_once() + self.assertIn("Privacy_Accessibility", self.opened.call_args.args[0][1]) + + def test_a_system_that_will_not_open_the_pane_still_says_what_is_wrong(self): + self.api.trusted = False + self.opened.side_effect = OSError("no open(1) here") + with self.assertRaises(paste.PasteError) as caught: + paste.press("cmd+v") + self.assertIn("Accessibility", str(caught.exception)) + + def test_readiness_is_the_permission_rather_than_a_program(self): + self.assertTrue(paste.paste_ready()) + self.api.trusted = False + self.assertFalse(paste.paste_ready()) + + def test_an_event_that_could_not_be_made_takes_its_pair_with_it(self): + self.api.makes = 1 # the second one comes back null + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.assertEqual(self.api.released, [1001]) + self.assertEqual(self.api.posted, []) + + def test_the_frameworks_not_being_there_is_not_a_crash(self): + """Every other system imports this module too, and must survive it.""" + self.patch_attr(paste, "_macos_api", + mock.Mock(side_effect=paste.PasteError("no such library"))) + self.assertFalse(paste.paste_ready()) + + +class MacClipboardSnapshot(DikteTest): + def test_every_native_type_is_restored_and_the_files_are_removed(self): + directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-") + manifest = '[[{"type":"public.tiff","file":"0-0.bin"}]]' + pathlib.Path(directory, "0-0.bin").write_bytes(b"a TIFF") + snapshot = paste._MAC_SNAPSHOT(directory, manifest) + + with mock.patch.object(subprocess, "run", + return_value=FakeCompleted()) as run: + paste.copy_bytes(snapshot) + + self.assertEqual(run.call_args.kwargs["input"], manifest) + self.assertEqual(run.call_args.kwargs["env"]["DIKTE_PASTEBOARD_DIR"], + directory) + self.assertFalse(os.path.exists(directory)) + + def test_a_failed_snapshot_leaves_no_temporary_directory(self): + directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-") + with mock.patch.object(paste.tempfile, "mkdtemp", return_value=directory), \ + mock.patch.object(subprocess, "run", + return_value=FakeCompleted(stdout=b"not json")): + self.assertIsNone(paste._macos_snapshot()) + self.assertFalse(os.path.exists(directory)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 10554f2..e4cf51d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,7 +6,9 @@ save, so a setting added to one half and not the other is silently reset the next time anybody presses Save. That is the failure this catches. """ +import sys import unittest +from typing import ClassVar from unittest import mock from PyQt6.QtWidgets import QApplication, QMessageBox @@ -15,6 +17,7 @@ import cleanup import config as cfg import hotkey import overlay as overlay_module +import paste import settings_ui from tests.support import DikteTest, only_these_tools @@ -97,10 +100,16 @@ CHANGED = { class Settings(DikteTest): + # What a Mac shows instead, where the combination on offer is a different + # one. Everything else about the window is the same on both. + changed = CHANGED + platform = "linux" + def setUp(self): super().setUp() # No pactl, no model lists over the network, and no modal dialogue # waiting for somebody to press OK. + self.enterContext(mock.patch.object(sys, "platform", self.platform)) self.enterContext(only_these_tools()) self.enterContext(mock.patch.object(QMessageBox, "information")) self.enterContext(mock.patch.object(settings_ui.SettingsWindow, @@ -133,11 +142,11 @@ class Settings(DikteTest): self.assertEqual(conf.data, before) def test_a_setting_of_your_own_survives_the_round_trip(self): - self.write_config(CHANGED) + self.write_config(self.changed) conf = cfg.Config() self.window(conf)._save() stored = self.read_config_file() - for key, value in CHANGED.items(): + for key, value in self.changed.items(): with self.subTest(key=key): self.assertEqual(stored[key], value) @@ -304,6 +313,34 @@ class Settings(DikteTest): self.assertFalse(window.file_stop.isEnabled()) +class MacSettings(Settings): + """The same window and the same round trip, standing on a Mac. + + Nothing here is about macOS: it is the rest of the window, checked on the + platform where three of its widgets are gone and one offers other keys. + """ + + platform = "darwin" + changed: ClassVar[dict] = {**CHANGED, "paste_shortcut": "cmd+shift+v"} + + def test_there_is_no_install_button_where_nothing_is_installed(self): + window = self.window(cfg.Config()) + labels = [button.text() for button in + window.findChildren(settings_ui.QPushButton)] + self.assertFalse([text for text in labels if "shortcut" in text.lower()]) + + def test_the_listener_is_not_offered_as_a_choice(self): + """It is the whole mechanism there; turning it off would leave nothing.""" + window = self.window(cfg.Config()) + self.assertFalse(window.evdev_enabled.isVisible()) + + def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): + window = self.window(cfg.Config()) + offered = [window.paste_shortcut.itemText(index) + for index in range(window.paste_shortcut.count())] + self.assertEqual(offered, paste.MACOS.shortcuts) + + class Overlay(DikteTest): def overlay(self, **kwargs): widget = overlay_module.Overlay(**kwargs) diff --git a/tests/test_worker.py b/tests/test_worker.py index 1617efb..7634a51 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -32,7 +32,7 @@ class Chain(DikteTest): transcript="uh, book it for Thursday", cleaned="Book it for Thursday.", cleanup_error=None, answer=("Booked.", ""), rms=None, - clipboard=b"what was there before"): + clipboard=b"what was there before", paste_error=None): pipeline = worker.Pipeline(self.conf) done, failures, stages, cancels = [], [], [], [] pipeline.finished.connect(lambda *args: done.append(args)) @@ -52,10 +52,13 @@ class Chain(DikteTest): mock.patch.object(paste, "copy") as copy, \ mock.patch.object(paste, "copy_bytes") as copy_bytes, \ mock.patch.object(paste, "press") as press, \ - mock.patch.object(paste, "read_clipboard", return_value=clipboard), \ + mock.patch.object(paste, "read_clipboard", + return_value=clipboard) as read_clipboard, \ mock.patch.object(worker.time, "sleep", lambda seconds: None): + press.side_effect = paste_error calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call, - "copy": copy, "copy_bytes": copy_bytes, "press": press} + "copy": copy, "copy_bytes": copy_bytes, "press": press, + "read_clipboard": read_clipboard} pipeline._work(self.wav, duration, self.rms if rms is None else rms, ask, paste_override) return {"done": done, "failures": failures, "stages": stages, @@ -83,9 +86,11 @@ class Chain(DikteTest): def test_auto_paste_switched_off_only_copies(self): self.conf["auto_paste"] = False + self.conf["restore_clipboard"] = True run = self.run_chain() run["copy"].assert_called_once() run["press"].assert_not_called() + run["read_clipboard"].assert_not_called() def test_a_run_asked_for_from_a_terminal_pastes_nowhere(self): """The text comes back down the socket; the focused window is nobody's.""" @@ -103,6 +108,12 @@ class Chain(DikteTest): run = self.run_chain() run["copy_bytes"].assert_not_called() + def test_the_clipboard_is_put_back_when_the_keypress_fails(self): + self.conf["restore_clipboard"] = True + run = self.run_chain(paste_error=paste.PasteError("not trusted")) + self.assertIn("not trusted", run["failures"][0]) + run["copy_bytes"].assert_called_once_with(b"what was there before") + def test_the_transcription_is_told_the_language_and_the_glossary(self): self.conf["language"] = "tr" self.conf["transcribe_prompt"] = "Paraşüt" diff --git a/worker.py b/worker.py index 012ab07..0f0e004 100644 --- a/worker.py +++ b/worker.py @@ -138,13 +138,18 @@ class Pipeline(QObject): wants_paste = paste_override with _paste_lock: - previous = paste.read_clipboard() if conf["restore_clipboard"] else None - paste.copy(text) - - if wants_paste: - self.stage.emit(t("Pasting…")) - paste.press(conf["paste_shortcut"]) + previous = (paste.read_clipboard() + if conf["restore_clipboard"] and wants_paste else None) + try: + paste.copy(text) + if wants_paste: + self.stage.emit(t("Pasting…")) + paste.press(conf["paste_shortcut"]) + finally: if previous is not None: + # Let the focused application consume the temporary + # transcription before putting every old clipboard type + # back. This also runs when key injection fails. time.sleep(0.35) paste.copy_bytes(previous)