From 3bd8c1ad275fa6dde020642633c2a726fae8c521 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 21:28:58 +0700 Subject: [PATCH 1/7] Add a Mac as the third system, beside Wayland and X11 Dikte already chose its clipboard programs once instead of in every function; macOS joins that table rather than adding a branch to each one. A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics, records through AVFoundation, and asks Carbon for its global shortcuts. The three tables are paste.Desktop, audio.Sound, and the pair of predicates in hotkey.py. Each reads sys.platform inside the chooser, so a test can stand somewhere else: 697 of the 737 tests now run on any machine, the Wayland and X11 halves included, and the suite passes whole whichever system it is run on. Two things a Mac does not have needed saying rather than pretending: there is no shortcut registry to install into, so Settings offers no Install button and the listener is the mechanism instead of a fallback; and nothing is offered as the sound the speakers are playing, so a meeting needs BlackHole or Loopback and says so. The KDE-only labels around them were already wrong on GNOME, and now name whichever desktop is there. Co-authored-by: firat --- CONTRIBUTING.md | 44 ++++--- audio.py | 270 +++++++++++++++++++++++++++++++---------- cli.py | 2 +- config.py | 22 +++- dikte.py | 26 +++- hotkey.py | 279 ++++++++++++++++++++++++++++++++++++++++++- i18n.py | 48 ++++++-- overlay.py | 9 +- paste.py | 243 +++++++++++++++++++++++++++++++------ settings_ui.py | 114 +++++++++++++----- tests/__init__.py | 5 + tests/test_audio.py | 178 +++++++++++++++++++++++++-- tests/test_config.py | 34 ++++++ tests/test_hotkey.py | 236 +++++++++++++++++++++++++++++++++++- tests/test_paste.py | 214 +++++++++++++++++++++++++++++---- tests/test_ui.py | 41 ++++++- 16 files changed, 1558 insertions(+), 207 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5894a68..33c98f0 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. +697 of the 737 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 40 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/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 facb667..f45798c 100644 --- a/cli.py +++ b/cli.py @@ -720,7 +720,7 @@ def cmd_shortcut(opts): combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).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, desktop_id) if clashes and not opts.force: diff --git a/config.py b/config.py index 396bd58..58018e7 100644 --- a/config.py +++ b/config.py @@ -1,21 +1,35 @@ -"""Settings storage in ~/.config/dikte/config.json""" +"""Settings storage, in the place this system keeps a program's settings.""" import hashlib import json import os import pathlib +import sys import api import i18n +import paste 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" @@ -375,7 +389,7 @@ DEFAULTS = { "cleanup_reasoning": "", # empty -> whatever the model does by default "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 cc17c6c..f1b6418 100755 --- a/dikte.py +++ b/dikte.py @@ -16,6 +16,15 @@ import sys 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 # noqa: E402 from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 @@ -91,7 +100,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() self.recorder.level.connect(self._on_level) self.recorder.stopped.connect(self._on_recorded) @@ -312,8 +321,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() @@ -332,8 +344,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, ) @@ -807,7 +820,10 @@ class Dikte: self.ask_overlay.corner = self.conf["overlay_corner"] 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({"toggle": self.conf["shortcut"], "ask": self.conf["assistant_shortcut"], "meeting": self.conf["meeting_shortcut"]}) diff --git a/hotkey.py b/hotkey.py index b6b3ed1..5fcc2ac 100644 --- a/hotkey.py +++ b/hotkey.py @@ -1,6 +1,16 @@ -"""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 ctypes +import ctypes.util import glob import os import pathlib @@ -9,6 +19,7 @@ import select import shutil import struct import subprocess +import sys import threading from PyQt6.QtCore import QObject, pyqtSignal @@ -18,6 +29,9 @@ from i18n import t DESKTOP_ID = "dikte-toggle.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop" ASK_DESKTOP_ID = "dikte-ask.desktop" +# The name each binding is started under, and the entry it is installed as. +DESKTOP_IDS = {"toggle": DESKTOP_ID, "meeting": MEETING_DESKTOP_ID, + "ask": ASK_DESKTOP_ID} APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc" @@ -176,8 +190,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 + desktop_id = DESKTOP_IDS.get(name) + if desktop_id: + _REGISTERED[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 @@ -300,26 +528,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" @@ -400,6 +671,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 dd70837..68707cb 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.": @@ -154,6 +161,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", @@ -166,8 +175,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", @@ -270,7 +278,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}", @@ -284,8 +292,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 " @@ -296,6 +306,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?", @@ -308,6 +325,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" @@ -527,6 +553,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..a82aa78 100644 --- a/paste.py +++ b/paste.py @@ -1,16 +1,20 @@ -"""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 os import shutil import subprocess +import sys import time from i18n import t @@ -27,11 +31,30 @@ 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 + 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 +77,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"): @@ -124,8 +306,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: @@ -147,25 +332,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 1690a6b..ae418f8 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -20,6 +20,7 @@ import config as cfg import filetranscribe import hotkey import meeting +import paste from filetranscribe import FileTranscriber from i18n import t @@ -89,7 +90,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 all three global shortcuts, which keeps them one kind of field # rather than three. The boxes stay editable: this is a shortlist of # combinations that are usually free, not the set of ones that work. @@ -99,6 +99,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") @@ -191,10 +198,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")) @@ -232,7 +245,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 @@ -368,12 +383,11 @@ class SettingsWindow(QDialog): how = QGroupBox(t("How it runs")) how_form = QFormLayout(how) self.assistant_shortcut = self._shortcut_box(t("none")) - install = QPushButton(t("Install as a KDE shortcut")) - install.clicked.connect(self._install_ask_shortcut) - remove = QPushButton(t("Remove")) - remove.clicked.connect(self._remove_ask_shortcut) - how_form.addRow(t("Shortcut"), - self._row(self.assistant_shortcut, install, remove)) + how_form.addRow(t("Shortcut"), self._row( + self.assistant_shortcut, + *self._install_buttons(self._install_ask_shortcut, + self._remove_ask_shortcut), + )) self.assistant_shortcut_status = QLabel("") self.assistant_shortcut_status.setWordWrap(True) how_form.addRow(self.assistant_shortcut_status) @@ -555,6 +569,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 " @@ -630,12 +653,11 @@ class SettingsWindow(QDialog): recording_form.addRow("", self.meeting_keep_audio) self.meeting_shortcut = self._shortcut_box(t("none")) - install = QPushButton(t("Install as a KDE shortcut")) - install.clicked.connect(self._install_meeting_shortcut) - remove = QPushButton(t("Remove")) - remove.clicked.connect(self._remove_meeting_shortcut) - recording_form.addRow(t("Shortcut"), - self._row(self.meeting_shortcut, install, remove)) + recording_form.addRow(t("Shortcut"), self._row( + self.meeting_shortcut, + *self._install_buttons(self._install_meeting_shortcut, + self._remove_meeting_shortcut), + )) self.meeting_shortcut_status = QLabel("") self.meeting_shortcut_status.setWordWrap(True) recording_form.addRow(self.meeting_shortcut_status) @@ -784,13 +806,10 @@ class SettingsWindow(QDialog): form.addRow(t("Shortcut"), self.shortcut) layout.addLayout(form) - install = QPushButton(t("Install as a KDE shortcut")) - install.clicked.connect(self._install_shortcut) - remove = QPushButton(t("Remove")) - remove.clicked.connect(self._remove_shortcut) row = QHBoxLayout() - row.addWidget(install) - row.addWidget(remove) + for button in self._install_buttons(self._install_shortcut, + self._remove_shortcut): + row.addWidget(button) row.addStretch(1) layout.addLayout(row) @@ -799,20 +818,34 @@ class SettingsWindow(QDialog): layout.addWidget(self.shortcut_status) 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) @@ -885,12 +918,29 @@ 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) 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 d422cec..bcbdba8 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 7120a0c..f9f16ab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,6 +14,7 @@ from unittest import mock import api import config as cfg import i18n +import paste from tests.support import DikteTest @@ -422,6 +423,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_hotkey.py b/tests/test_hotkey.py index 4297724..455c1ee 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -123,10 +123,13 @@ class Bindings(DikteTest): self.assertEqual(len(listener._bindings[57]), 2) -@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.""" @@ -407,5 +410,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..d501e43 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -1,30 +1,35 @@ """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 subprocess +import sys 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 +51,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 +97,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 +151,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 +169,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 +205,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 +235,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 +258,149 @@ 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()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index a616e94..1bdc4c3 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,13 +6,16 @@ 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 import config as cfg import overlay as overlay_module +import paste import settings_ui from tests.support import DikteTest, only_these_tools @@ -80,10 +83,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, @@ -116,11 +125,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) @@ -175,6 +184,34 @@ class Settings(DikteTest): self.assertEqual(window.windowTitle(), "Dikte Ayarları") +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) From b5ef0040b4e30186d4fd563a5b0f6bf3845cfd5e Mon Sep 17 00:00:00 2001 From: Icarus Murqin Date: Sat, 1 Aug 2026 20:15:25 +0300 Subject: [PATCH 2/7] Write down what installing on Fedora takes The README has Arch and Ubuntu on it, and the two things that differ on Fedora are the two that decide whether anything works at all. The package names are its own, and `systemctl --user enable --now ydotool` has nothing to enable there: Fedora ships ydotool as a system service only, and its ydotoold holds a root-owned socket at mode 600. The unit is green, the daemon is up, and every paste is refused. The drop-in the README now spells out points ydotoold at $XDG_RUNTIME_DIR/.ydotool_socket and hands it over by uid, which is the path the client already looks at, so nothing has to carry YDOTOOL_SOCKET. Red Hat closed the same report as NOTABUG in 2023, so this stays a step the user takes rather than one a later package will take for them. install.sh was asking whether the unit was up, which is the question that answers wrongly here. It now asks whether the socket is one this user may write to, which is what auto-paste actually needs, and it names which of the three ways it failed: a socket handed to nobody, a daemon that put its socket somewhere else, or no daemon at all. On Arch and everywhere else the answer is the same one as before. ffmpeg-free out of Fedora's own repositories is enough, against the usual advice to reach for RPM Fusion. That build disables four video decoders, h264, hevc, vc1 and vvc, and keeps every audio decoder it has, AAC among them, while Dikte only ever asks a video file for its audio track. The models that run on this machine ask for nothing either, which is worth saying because it is now the way Dikte starts. The Ubuntu builds run on Fedora unchanged; whisper.cpp transcribed a clip here without a package being added, and llama.cpp came down in its Vulkan build and found the card. That last part is not luck: kwin-libs requires libvulkan.so.1, so the loader is on every Plasma desktop by way of the compositor, and it recommends the Mesa drivers alongside itself. --- README.md | 35 +++++++++++++++++++++++++++++++++++ README.tr.md | 33 +++++++++++++++++++++++++++++++++ install.sh | 27 ++++++++++++++++++++++----- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 29ce1b4..f354e8a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,41 @@ systemctl --user enable --now ydotool # needed for auto-paste dikte # the settings window opens on first run ``` +On Fedora KDE the packages are named differently, and `ydotool` takes one step +more: + +```sh +sudo dnf install pipewire-utils wl-clipboard ydotool ffmpeg-free python3-pyqt6 +``` + +Fedora ships `ydotool` as a system service rather than a user one, and +`ydotoold` then listens on a root-owned socket your session cannot write to, so +auto-paste fails with the daemon running. Point it instead at the path the +`ydotool` client already looks at, and hand the socket over: + +```sh +sudo mkdir -p /etc/systemd/system/ydotool.service.d +printf '[Service]\nExecStart=\nExecStart=/usr/bin/ydotoold --socket-path=%s/.ydotool_socket --socket-own=%s:%s\n' \ + "$XDG_RUNTIME_DIR" "$(id -u)" "$(id -g)" \ + | sudo tee /etc/systemd/system/ydotool.service.d/override.conf >/dev/null +sudo systemctl daemon-reload +sudo systemctl enable --now ydotool +``` + +That runtime directory belongs to your login session, so after a reboot the +service restarts until you are logged in and only then settles. `ffmpeg-free` +out of Fedora's own repositories is enough, RPM Fusion not needed: what the +free build leaves out is H.264 and HEVC video decoding, and Dikte only ever +takes the audio track of a video file, whose AAC, MP3 and Opus decoders are all +there. + +The models that run on this machine need nothing added either. Those releases +are built on Ubuntu and run here as they are, and KWin links `libvulkan.so.1` +itself, so a Plasma desktop already has the loader that decides whether +llama.cpp arrives in its Vulkan build, with the Mesa drivers alongside it. +whisper.cpp publishes no GPU build for Linux at all and transcribes on the +processor wherever it runs. + On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11 tools instead: diff --git a/README.tr.md b/README.tr.md index 38c4639..30c1eb3 100644 --- a/README.tr.md +++ b/README.tr.md @@ -29,6 +29,39 @@ systemctl --user enable --now ydotool # otomatik yapıştırma için dikte # ilk açılışta ayarlar penceresi gelir ``` +Fedora KDE'de paket adları farklı, `ydotool` da bir adım fazla istiyor: + +```sh +sudo dnf install pipewire-utils wl-clipboard ydotool ffmpeg-free python3-pyqt6 +``` + +Fedora `ydotool`'u kullanıcı servisi değil sistem servisi olarak kuruyor; +`ydotoold` de root'a ait bir sokete bağlanıyor, oturumun oraya yazamadığı için +servis çalışırken bile otomatik yapıştırma tutmuyor. Soketi `ydotool` +istemcisinin zaten baktığı yola al ve sahipliğini devret: + +```sh +sudo mkdir -p /etc/systemd/system/ydotool.service.d +printf '[Service]\nExecStart=\nExecStart=/usr/bin/ydotoold --socket-path=%s/.ydotool_socket --socket-own=%s:%s\n' \ + "$XDG_RUNTIME_DIR" "$(id -u)" "$(id -g)" \ + | sudo tee /etc/systemd/system/ydotool.service.d/override.conf >/dev/null +sudo systemctl daemon-reload +sudo systemctl enable --now ydotool +``` + +O çalışma dizini oturumuna ait olduğu için yeniden başlatmanın ardından servis +sen giriş yapana kadar kendini yeniden deneyip ondan sonra oturuyor. Fedora'nın +kendi depolarındaki `ffmpeg-free` yetiyor, RPM Fusion gerekmiyor: özgür yapının +dışarıda bıraktığı şey H.264 ve HEVC video çözümü, Dikte ise video dosyasının +yalnızca ses izini alıyor; onun AAC, MP3 ve Opus çözücüleri yapının içinde. + +Bu makinede koşan modeller için de eklenecek bir şey yok. O sürümler Ubuntu'da +derleniyor ve burada olduğu gibi çalışıyor; `libvulkan.so.1`'i KWin'in kendisi +bağladığı için Plasma masaüstünde llama.cpp'nin Vulkan yapısıyla gelip +gelmeyeceğini belirleyen yükleyici zaten kurulu, Mesa sürücüleri de onunla +birlikte geliyor. whisper.cpp ise Linux için hiç GPU yapısı yayımlamıyor, +nerede çalışırsa çalışsın işlemcide çeviriyor. + Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11 araçlarıyla çalışır: diff --git a/install.sh b/install.sh index c55e5b8..de99f48 100755 --- a/install.sh +++ b/install.sh @@ -40,21 +40,38 @@ python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("python-pyqt6") if ((${#missing[@]})); then warn "Missing: ${missing[*]}" - say "Ubuntu X11: sudo apt install pulseaudio-utils xclip xdotool ffmpeg" - say "Arch Wayland: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6" + say "Ubuntu X11: sudo apt install pulseaudio-utils xclip xdotool ffmpeg" + say "Arch Wayland: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6" + say "Fedora Wayland: sudo dnf install pipewire-utils wl-clipboard ydotool ffmpeg-free python3-pyqt6" echo else ok "All dependencies present" fi # 2. ydotoold -------------------------------------------------------------- +# What auto-paste needs is a socket it may write to, which is not the same +# question as whether the unit is up: Fedora ships ydotool as a system service +# only, and its socket stays root-owned at mode 600, so there the daemon can be +# running while every paste is refused. if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then - if systemctl --user is-active --quiet ydotool 2>/dev/null \ - || systemctl --user is-active --quiet ydotoold 2>/dev/null; then + socket="${YDOTOOL_SOCKET:-${XDG_RUNTIME_DIR:-/tmp}/.ydotool_socket}" + if [[ -w "$socket" ]]; then ok "ydotoold is running (auto-paste ready)" + elif systemctl is-active --quiet ydotool 2>/dev/null; then + warn "ydotoold runs as a system service, whose socket is not yours to" + say "write to. Hand it over in /etc/systemd/system/ydotool.service.d/" + say "override.conf, which is what the README's Fedora section does:" + say " [Service]" + say " ExecStart=" + say " ExecStart=/usr/bin/ydotoold --socket-path=$socket --socket-own=$(id -u):$(id -g)" + say "then sudo systemctl daemon-reload && sudo systemctl restart ydotool" + elif systemctl --user is-active --quiet ydotool 2>/dev/null \ + || systemctl --user is-active --quiet ydotoold 2>/dev/null; then + warn "ydotoold is running, but it did not put its socket at $socket" + say "Point Dikte at the one it did make: export YDOTOOL_SOCKET=..." else warn "ydotoold is not running, auto-paste will not work" - say "systemctl --user enable --now ydotool" + say "systemctl --user enable --now ydotool (on Fedora: see the README)" fi fi From ca6444c011e4e5328dc73b12335cd3fffc65b55e Mon Sep 17 00:00:00 2001 From: firat Date: Mon, 3 Aug 2026 17:48:28 +0200 Subject: [PATCH 3/7] Preserve native macOS clipboard contents --- paste.py | 100 +++++++++++++++++++++++++++++++++++++++++++ tests/test_paste.py | 27 ++++++++++++ tests/test_worker.py | 17 ++++++-- worker.py | 17 +++++--- 4 files changed, 152 insertions(+), 9 deletions(-) diff --git a/paste.py b/paste.py index a82aa78..030f203 100644 --- a/paste.py +++ b/paste.py @@ -11,10 +11,12 @@ 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 @@ -49,6 +51,64 @@ MAC_ALIASES = {"cmd": "command", "meta": "command", "super": "command", 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 @@ -280,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: @@ -321,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: diff --git a/tests/test_paste.py b/tests/test_paste.py index d501e43..4bfe99f 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -14,8 +14,10 @@ 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 @@ -402,5 +404,30 @@ class MacOS(ClipboardContract, DikteTest): 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_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) From fa14ec90f4ec302a86c605a8466bd64702ec567f Mon Sep 17 00:00:00 2001 From: firat Date: Mon, 3 Aug 2026 17:53:20 +0200 Subject: [PATCH 4/7] Select native macOS model builds --- ggml.py | 15 ++++++++++++++- tests/test_ggml.py | 21 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) 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/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") - From 41aec83a34d2ff75b2fbf622aa63aa18fb62501d Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 5 Aug 2026 15:04:36 +0300 Subject: [PATCH 5/7] Say which Python the suite needs A Mac tester ran the suite on the 3.9 that ships with macOS and got 167 errors, all of them TestCase.enterContext, which arrived in 3.11. The workflow already runs 3.11, 3.12 and 3.13; nothing said so out loud. --- README.md | 2 +- README.tr.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29ce1b4..1632708 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. +just the Python standard library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)* diff --git a/README.tr.md b/README.tr.md index 38c4639..be08d12 100644 --- a/README.tr.md +++ b/README.tr.md @@ -5,7 +5,7 @@ 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. +sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. *[English README](README.md)* From d63f2f4a5aeb612784ea122ea0179a8cab6a434f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 5 Aug 2026 15:09:16 +0300 Subject: [PATCH 6/7] Say in the README that a Mac is one of the three The first line still named KDE and Wayland only, and Install stopped at Ubuntu. A Mac has nothing for install.sh to install into, so what it needs is ffmpeg, PyQt6 and python dikte.py, plus BlackHole for a meeting. The settings also sit somewhere else there. --- README.md | 18 +++++++++++++----- README.tr.md | 15 +++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1632708..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, 3.11 or newer, 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 be08d12..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 (3.11 veya üstü) 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 From 993eb6511b35430856ef20669ff91bafc829cc73 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 5 Aug 2026 16:46:28 +0300 Subject: [PATCH 7/7] Keep the Fedora section to what you have to type The section ran longer than Ubuntu and macOS put together, and most of it answered questions nobody asked while installing: why ffmpeg-free is enough in detail, why the runtime directory settles after login, and why the local models need nothing added. That last one says "do nothing", which is what the reader would have done anyway. The reasoning stays in the pull request, where it belongs; the README keeps the package line and the drop-in. install.sh printed the drop-in the README already carries, six lines where every other warning there is two. It now points at the README. Its readiness check also went from asking whether the unit was up to asking whether the socket is writable, which reads a stopped daemon as ready: the socket file outlives the process. It now wants both, with pgrep for the process so a ydotoold started by hand still counts. All five paths were exercised with systemctl and pgrep shims, and on this Arch box the real user service still reports ready. --- README.md | 30 ++++++------------------------ README.tr.md | 28 ++++++---------------------- install.sh | 18 +++++++----------- 3 files changed, 19 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index f354e8a..d95be16 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,15 @@ systemctl --user enable --now ydotool # needed for auto-paste dikte # the settings window opens on first run ``` -On Fedora KDE the packages are named differently, and `ydotool` takes one step -more: +On Fedora the packages are named differently, `ffmpeg-free` out of Fedora's own +repositories is enough because Dikte only ever takes the audio track of a video +file, and `ydotool` takes one step more: it ships as a system service, whose +socket stays root-owned and out of your session's reach, so auto-paste fails +with the daemon running. Point it at the path the client already looks at and +hand the socket over: ```sh sudo dnf install pipewire-utils wl-clipboard ydotool ffmpeg-free python3-pyqt6 -``` - -Fedora ships `ydotool` as a system service rather than a user one, and -`ydotoold` then listens on a root-owned socket your session cannot write to, so -auto-paste fails with the daemon running. Point it instead at the path the -`ydotool` client already looks at, and hand the socket over: - -```sh sudo mkdir -p /etc/systemd/system/ydotool.service.d printf '[Service]\nExecStart=\nExecStart=/usr/bin/ydotoold --socket-path=%s/.ydotool_socket --socket-own=%s:%s\n' \ "$XDG_RUNTIME_DIR" "$(id -u)" "$(id -g)" \ @@ -51,20 +47,6 @@ sudo systemctl daemon-reload sudo systemctl enable --now ydotool ``` -That runtime directory belongs to your login session, so after a reboot the -service restarts until you are logged in and only then settles. `ffmpeg-free` -out of Fedora's own repositories is enough, RPM Fusion not needed: what the -free build leaves out is H.264 and HEVC video decoding, and Dikte only ever -takes the audio track of a video file, whose AAC, MP3 and Opus decoders are all -there. - -The models that run on this machine need nothing added either. Those releases -are built on Ubuntu and run here as they are, and KWin links `libvulkan.so.1` -itself, so a Plasma desktop already has the loader that decides whether -llama.cpp arrives in its Vulkan build, with the Mesa drivers alongside it. -whisper.cpp publishes no GPU build for Linux at all and transcribes on the -processor wherever it runs. - On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11 tools instead: diff --git a/README.tr.md b/README.tr.md index 30c1eb3..5078f20 100644 --- a/README.tr.md +++ b/README.tr.md @@ -29,18 +29,15 @@ systemctl --user enable --now ydotool # otomatik yapıştırma için dikte # ilk açılışta ayarlar penceresi gelir ``` -Fedora KDE'de paket adları farklı, `ydotool` da bir adım fazla istiyor: +Fedora'da paket adları farklı, Fedora'nın kendi depolarındaki `ffmpeg-free` +yetiyor çünkü Dikte video dosyasının yalnızca ses izini alıyor, `ydotool` da +bir adım fazla istiyor: sistem servisi olarak geliyor, soketi de root'a ait +kalıp oturumun erişemediği yerde durduğu için servis çalışırken bile otomatik +yapıştırma tutmuyor. Soketi istemcinin zaten baktığı yola al ve sahipliğini +devret: ```sh sudo dnf install pipewire-utils wl-clipboard ydotool ffmpeg-free python3-pyqt6 -``` - -Fedora `ydotool`'u kullanıcı servisi değil sistem servisi olarak kuruyor; -`ydotoold` de root'a ait bir sokete bağlanıyor, oturumun oraya yazamadığı için -servis çalışırken bile otomatik yapıştırma tutmuyor. Soketi `ydotool` -istemcisinin zaten baktığı yola al ve sahipliğini devret: - -```sh sudo mkdir -p /etc/systemd/system/ydotool.service.d printf '[Service]\nExecStart=\nExecStart=/usr/bin/ydotoold --socket-path=%s/.ydotool_socket --socket-own=%s:%s\n' \ "$XDG_RUNTIME_DIR" "$(id -u)" "$(id -g)" \ @@ -49,19 +46,6 @@ sudo systemctl daemon-reload sudo systemctl enable --now ydotool ``` -O çalışma dizini oturumuna ait olduğu için yeniden başlatmanın ardından servis -sen giriş yapana kadar kendini yeniden deneyip ondan sonra oturuyor. Fedora'nın -kendi depolarındaki `ffmpeg-free` yetiyor, RPM Fusion gerekmiyor: özgür yapının -dışarıda bıraktığı şey H.264 ve HEVC video çözümü, Dikte ise video dosyasının -yalnızca ses izini alıyor; onun AAC, MP3 ve Opus çözücüleri yapının içinde. - -Bu makinede koşan modeller için de eklenecek bir şey yok. O sürümler Ubuntu'da -derleniyor ve burada olduğu gibi çalışıyor; `libvulkan.so.1`'i KWin'in kendisi -bağladığı için Plasma masaüstünde llama.cpp'nin Vulkan yapısıyla gelip -gelmeyeceğini belirleyen yükleyici zaten kurulu, Mesa sürücüleri de onunla -birlikte geliyor. whisper.cpp ise Linux için hiç GPU yapısı yayımlamıyor, -nerede çalışırsa çalışsın işlemcide çeviriyor. - Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11 araçlarıyla çalışır: diff --git a/install.sh b/install.sh index de99f48..c55b4c0 100755 --- a/install.sh +++ b/install.sh @@ -52,21 +52,17 @@ fi # What auto-paste needs is a socket it may write to, which is not the same # question as whether the unit is up: Fedora ships ydotool as a system service # only, and its socket stays root-owned at mode 600, so there the daemon can be -# running while every paste is refused. +# running while every paste is refused. The socket file outlives the daemon, +# though, so the process has to be there as well for the answer to be yes. if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then socket="${YDOTOOL_SOCKET:-${XDG_RUNTIME_DIR:-/tmp}/.ydotool_socket}" - if [[ -w "$socket" ]]; then + alive() { pgrep -x ydotoold >/dev/null 2>&1; } + if [[ -w "$socket" ]] && alive; then ok "ydotoold is running (auto-paste ready)" elif systemctl is-active --quiet ydotool 2>/dev/null; then - warn "ydotoold runs as a system service, whose socket is not yours to" - say "write to. Hand it over in /etc/systemd/system/ydotool.service.d/" - say "override.conf, which is what the README's Fedora section does:" - say " [Service]" - say " ExecStart=" - say " ExecStart=/usr/bin/ydotoold --socket-path=$socket --socket-own=$(id -u):$(id -g)" - say "then sudo systemctl daemon-reload && sudo systemctl restart ydotool" - elif systemctl --user is-active --quiet ydotool 2>/dev/null \ - || systemctl --user is-active --quiet ydotoold 2>/dev/null; then + warn "ydotoold's socket is not yours to write to, so auto-paste will fail" + say "Hand it over with the drop-in in the README's Fedora section." + elif alive; then warn "ydotoold is running, but it did not put its socket at $socket" say "Point Dikte at the one it did make: export YDOTOOL_SOCKET=..." else