Merge master into the pw-record raw check

recording_command became a table of sound systems while this branch was
open, so the pw-record command it patched now lives in _pulse_record. The
check moves there with it, and nothing else about it changes.
This commit is contained in:
yusufipk
2026-08-05 18:40:54 +03:00
23 changed files with 1826 additions and 237 deletions
+25 -19
View File
@@ -31,6 +31,7 @@ directories, resets the language, and puts them back afterwards.
| A reply that fails | `http_error(429)`, `url_error()`, `raw_body("not json")` |
| Reading what was sent | `sent_json(request)`, `multipart_fields(request)` |
| A program on the PATH | `only_these_tools("pactl", "wl-copy")` |
| Standing on another system | `mock.patch.object(sys, "platform", "darwin")` |
| Audio | `silence()`, `tone()`, `speech()`, `stereo()`, `make_wav()` |
| A settings object | `self.config(cleanup_enabled=False)` |
@@ -52,30 +53,35 @@ forgets fails rather than hangs.
## Another platform
Most of what Dikte does is not desktop-specific, and the tests are split along
that line. 511 of them pass anywhere: transcription, cleanup, the config file,
the history, the agent, the command line, the timeline of a meeting. The
remaining 59 cover what Dikte *is* on this desktop, and carry `@linux_only`
from `tests.support`: PipeWire capture and the pactl device list, wl-clipboard
and ydotool, KDE's shortcut file and the `/dev/input` listener.
Three systems are supported: Wayland, X11 and macOS. Each one is a named entry
in a table, and one chooser picks between them, so a fourth adds an entry and a
line rather than a branch inside every function. The three tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device
lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform`
inside the chooser and read it there every time: a constant settled at import is
one no test can stand somewhere else.
Mark a test `@linux_only` when it would fail on a machine that never had those
programs. Do not mark one because it happens to be convenient: a test that
quietly stops running on the platform you are porting to protects nothing.
The other half of a port is where the branch goes. Keep `sys.platform` out of
the middle of a function; make the public name a chooser and give each platform
its own function underneath:
The tests are split along the same line, and almost none of them are skipped.
892 of the 935 run on any machine, including every line of the Wayland, X11 and
macOS backends: the programs are faked at `shutil.which`, the frameworks at the
one function that loads them. A test class says which system it is standing on
rather than avoiding the question:
```python
def copy(text):
return _copy_macos(text) if sys.platform == "darwin" else _copy_wayland(text)
class MacOS(ClipboardContract, DikteTest):
platform = "darwin"
here = paste.MACOS
```
Then each platform's test calls its own function directly and passes everywhere,
and adding a third one leaves the first two's tests alone. An `if` buried inside
`copy()` forces every existing test to patch `sys.platform` instead, and the
next port breaks all of them.
so the Linux half is checked on a Mac and the macOS half on Linux, and a change
to a chooser cannot quietly break the platform nobody is sitting at. What the
systems owe in common is written once as a contract class and subclassed by each
of them.
The 43 that do carry `@linux_only` are the ones that would need the real thing:
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test
that way only when faking it would leave nothing to test. A test that quietly
stops running on the platform you are porting to protects nothing.
## What a pull request should carry
+30 -5
View File
@@ -5,8 +5,9 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in.
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages:
just the Python standard library and PyQt6.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. No
dependencies beyond system packages: just the Python standard library, 3.11 or
newer, and PyQt6.
*[Türkçe README](README.tr.md)*
@@ -30,6 +31,23 @@ systemctl --user enable --now ydotool # needed for auto-paste
dikte # the settings window opens on first run
```
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
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
```
On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11
tools instead:
@@ -37,6 +55,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 +72,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
+28 -4
View File
@@ -4,8 +4,9 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok:
sadece Python standart kütüphanesi ve PyQt6.
KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem
paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya
üstü) ve PyQt6.
*[English README](README.md)*
@@ -29,6 +30,23 @@ systemctl --user enable --now ydotool # otomatik yapıştırma için
dikte # ilk açılışta ayarlar penceresi gelir
```
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
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
```
Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11
araçlarıyla çalışır:
@@ -36,6 +54,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 +72,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
+225 -81
View File
@@ -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,55 +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.
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", *_pw_record_raw_option(), f"--rate={RATE}",
f"--channels={CHANNELS}", "--format=s16",
]
if target:
cmd.append(f"--target={target}")
cmd.append("-")
return cmd
return []
"""A raw-s16 capture command for the sound system on this machine."""
return sound().record(target)
def _pw_record_raw_option():
"""Use --raw only on pw-record releases that provide it.
PipeWire 1.0, including Ubuntu 24.04's build, writes raw PCM to stdout but
rejects the newer --raw option. A rejected option ends the recorder before
it receives sound, so ask the installed binary which form it understands.
"""
try:
result = subprocess.run(
["pw-record", "--help"], capture_output=True, text=True, timeout=2
)
help_text = (result.stdout or "") + (result.stderr or "")
except (subprocess.SubprocessError, OSError):
return ["--raw"] # preserve the existing command when probing itself fails
if not help_text.strip():
return ["--raw"]
return ["--raw"] if "--raw" in help_text else []
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):
@@ -270,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)
@@ -458,7 +411,80 @@ 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", *_pw_record_raw_option(), f"--rate={RATE}",
f"--channels={CHANNELS}", "--format=s16",
]
if target:
cmd.append(f"--target={target}")
cmd.append("-")
return cmd
return []
def _pw_record_raw_option():
"""Use --raw only on pw-record releases that provide it.
PipeWire 1.0, including Ubuntu 24.04's build, writes raw PCM to stdout but
rejects the newer --raw option. A rejected option ends the recorder before
it receives sound, so ask the installed binary which form it understands.
"""
try:
result = subprocess.run(
["pw-record", "--help"], capture_output=True, text=True, timeout=2
)
help_text = (result.stdout or "") + (result.stderr or "")
except (subprocess.SubprocessError, OSError):
return ["--raw"] # preserve the existing command when probing itself fails
if not help_text.strip():
return ["--raw"]
return ["--raw"] if "--raw" in help_text else []
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:
@@ -471,30 +497,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:
@@ -507,5 +526,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()
+1 -1
View File
@@ -717,7 +717,7 @@ def cmd_shortcut(opts):
combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
if not combo:
return fail(opts, "no combination given and none stored; pass --combo", 2)
if hotkey.parse_shortcut(combo) == (None, None):
if not hotkey.valid_shortcut(combo):
return fail(opts, f"cannot parse that combination: {combo}", 2)
clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes and not opts.force:
+18 -4
View File
@@ -1,14 +1,16 @@
"""Settings storage in ~/.config/dikte/config.json"""
"""Settings storage, in the place this system keeps a program's settings."""
import collections
import hashlib
import json
import os
import pathlib
import sys
import api
import ggml
import i18n
import paste
from i18n import t
@@ -16,9 +18,21 @@ def _xdg(var, default):
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", "~/.config") / "dikte"
def _directories(platform=None):
"""(settings, data), in the two places this system keeps them.
macOS keeps both in the one directory a Mac user's backup already knows
about. Everywhere else they are separate and follow the XDG variables.
"""
if (platform or sys.platform) == "darwin":
support = pathlib.Path.home() / "Library/Application Support/Dikte"
return support, support
return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte",
_xdg("XDG_DATA_HOME", "~/.local/share") / "dikte")
CONFIG_DIR, DATA_DIR = _directories()
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR = _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte"
HISTORY_FILE = DATA_DIR / "history.jsonl"
RECORDINGS_DIR = DATA_DIR / "recordings"
MEETINGS_DIR = DATA_DIR / "meetings"
@@ -415,7 +429,7 @@ DEFAULTS = {
"local_llm_reasoning": "none",
"cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True,
"paste_shortcut": "ctrl+v",
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
"restore_clipboard": False,
"mic_target": "",
"max_seconds": 300,
+21 -5
View File
@@ -20,6 +20,15 @@ import threading
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
# An application started from the Finder is given none of the shell's PATH, so
# Homebrew's ffmpeg is invisible to it. Put the two places brew installs to in
# front, before anything goes looking for a program.
if sys.platform == "darwin":
os.environ["PATH"] = os.pathsep.join(
part for part in ("/opt/homebrew/bin", "/usr/local/bin",
os.environ.get("PATH", "")) if part
)
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
@@ -96,7 +105,7 @@ class Dikte:
self.ask_pipeline = Pipeline(self.conf)
self.meeting_recorder = audio.MeetingRecorder()
self.meetings = MeetingPipeline(self.conf)
self.evdev = hotkey.EvdevHotkey()
self.evdev = hotkey.listener()
# Before anything of ours is started: a server from a Dikte that was
# killed outright is still holding a model in memory.
ggml.sweep()
@@ -325,8 +334,11 @@ class Dikte:
# that same press. Its lateness is also the proof we were waiting for
# that the shortcut is live, which leaves the listener with nothing to
# do but double every press.
# Where nothing was installed there is no shortcut to catch up, and
# retiring the listener would leave the keys with nowhere to arrive.
timer = self.last_evdev.get(name)
if self.evdev.running and timer is not None and timer.elapsed() < ECHO_MS:
if (hotkey.installs_shortcuts() and self.evdev.running
and timer is not None and timer.elapsed() < ECHO_MS):
self._retire_listener()
return
handler()
@@ -346,8 +358,9 @@ class Dikte:
self.conf.save()
self.tray.showMessage(
"Dikte",
t("The KDE shortcut is live now, so the built-in listener has been "
"turned off. It was doubling every key press."),
t("The {desktop} shortcut is live now, so the built-in listener has "
"been turned off. It was doubling every key press.",
desktop=hotkey.desktop_name()),
QSystemTrayIcon.MessageIcon.Information, 8000,
)
@@ -853,7 +866,10 @@ class Dikte:
self._apply_local()
self._build_tray()
self._refresh_tray()
if self.conf["evdev_hotkey"]:
# Where the desktop has no shortcut registry of its own, the listener is
# not the fallback the setting offers to turn on: it is the only way the
# keys arrive at all, so it runs whatever the setting says.
if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts():
self.evdev.start({name: self.conf[spec.setting]
for name, spec in hotkey.SHORTCUTS.items()})
else:
+14 -1
View File
@@ -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))
+274 -2
View File
@@ -1,7 +1,17 @@
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
"""Global shortcuts: the desktop's own registry, plus a listener of our own.
Two things have to happen for a key combination to reach Dikte. Somewhere has
to be told about it, and something has to be listening. On Linux that is the
desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of
/dev/input for the wait until the registry is live. macOS has no registry to
write into: the application asks Carbon for the combination while it runs, so
there the listener is not a fallback but the whole mechanism.
"""
import ast
import collections
import ctypes
import ctypes.util
import glob
import os
import pathlib
@@ -10,6 +20,7 @@ import select
import shutil
import struct
import subprocess
import sys
import threading
from PyQt6.QtCore import QObject, pyqtSignal
@@ -197,8 +208,222 @@ class EvdevHotkey(QObject):
return True
# --- macOS: Carbon's hotkey service ---------------------------------------
# Apple virtual key codes: where a key sits, not what is printed on it.
MAC_KEYS = {
"space": 49, "tab": 48, "enter": 36, "return": 36, "esc": 53, "escape": 53,
"backspace": 51, "delete": 117, "home": 115, "end": 119,
"pgup": 116, "pgdown": 121, "up": 126, "down": 125, "left": 123, "right": 124,
"1": 18, "2": 19, "3": 20, "4": 21, "5": 23, "6": 22, "7": 26,
"8": 28, "9": 25, "0": 29,
"a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4,
"i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31,
"p": 35, "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9,
"w": 13, "x": 7, "y": 16, "z": 6,
"f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97,
"f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
}
# Carbon's own modifier bits, which are not the ones CoreGraphics uses in
# paste.py: the same four modifiers, numbered differently by two APIs.
MAC_MODS = {
"cmd": 1 << 8, "command": 1 << 8, "meta": 1 << 8, "super": 1 << 8,
"shift": 1 << 9,
"alt": 1 << 11, "option": 1 << 11,
"ctrl": 1 << 12, "control": 1 << 12,
}
HOTKEY_SIGNATURE = "Dikt" # what our registrations are labelled with
KEYBOARD_EVENT_CLASS = "keyb"
HOTKEY_PRESSED = 5 # kEventHotKeyPressed
PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard
HOTKEY_ID_PARAMETER = "hkid"
# What the running listener holds. This is the whole of "installed" on macOS,
# and it lasts as long as the process does: there is no file, and no other
# program to read one. Written by CarbonHotkey.start(), read by the status
# line, so what Settings shows is what the Mac actually gave us.
_REGISTERED = {}
def parse_macos_shortcut(text):
"""'Cmd+Space' -> (256, 49), or (None, None) when unusable."""
parts = [part.strip().lower() for part in str(text).split("+") if part.strip()]
modifiers, key = 0, None
for part in parts:
if part in MAC_MODS:
modifiers |= MAC_MODS[part]
elif key is None and part in MAC_KEYS:
key = MAC_KEYS[part]
else:
return None, None
if key is None:
return None, None
return modifiers, key
def _fourcc(text):
"""A Carbon four-character code, which is those four bytes as a number."""
return int.from_bytes(text.encode("ascii"), "big")
class _EventTypeSpec(ctypes.Structure):
_fields_ = [("eventClass", ctypes.c_uint32), ("eventKind", ctypes.c_uint32)]
class _EventHotKeyID(ctypes.Structure):
_fields_ = [("signature", ctypes.c_uint32), ("id", ctypes.c_uint32)]
class CarbonHotkey(QObject):
"""Catches global shortcuts through macOS's own hotkey service.
RegisterEventHotKey asks for one combination rather than reading the
keyboard, so it needs no permission at all. Accessibility is a separate
matter, and only for the Cmd+V that puts the text back (see paste.py).
Unlike the evdev listener this one does swallow the key: while Dikte holds
a combination, nothing else on the Mac receives it.
"""
triggered = pyqtSignal(str) # the name the binding was registered under
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._carbon = None
self._callback = None
self._handler = ctypes.c_void_p()
self._registrations = []
self._names = {}
@property
def running(self):
return bool(self._registrations)
def start(self, bindings):
"""`bindings` is {name: 'Cmd+Space'}; an empty combination is skipped."""
self.stop()
try:
self._carbon = _carbon()
except OSError as exc:
self.failed.emit(t("Could not reach the macOS shortcut service: "
"{error}", error=exc))
return False
if not self._install_handler():
return False
for identifier, (name, shortcut) in enumerate(bindings.items(), 1):
if not shortcut:
continue
modifiers, key = parse_macos_shortcut(shortcut)
if key is None:
self.failed.emit(
t("Could not parse the shortcut: {shortcut}", shortcut=shortcut)
)
continue
reference = ctypes.c_void_p()
code = self._carbon.RegisterEventHotKey(
key, modifiers,
_EventHotKeyID(_fourcc(HOTKEY_SIGNATURE), identifier),
self._carbon.GetApplicationEventTarget(), 0, ctypes.byref(reference),
)
if code != 0:
# This is the conflict warning on macOS: there is no list to
# read beforehand, the answer comes from asking for the key.
self.failed.emit(t(
"macOS would not give Dikte {shortcut}; another application "
"already holds it.", shortcut=shortcut))
continue
self._registrations.append(reference)
self._names[identifier] = name
spec = SHORTCUTS.get(name)
if spec:
_REGISTERED[spec.desktop_id] = shortcut
return bool(self._registrations)
def stop(self):
if self._carbon:
for reference in self._registrations:
self._carbon.UnregisterEventHotKey(reference)
if self._handler:
self._carbon.RemoveEventHandler(self._handler)
self._registrations = []
self._names = {}
self._handler = ctypes.c_void_p()
self._callback = None
_REGISTERED.clear()
def _install_handler(self):
carbon = self._carbon
callback_type = ctypes.CFUNCTYPE(
ctypes.c_int32, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p
)
def pressed(_next_handler, event, _user_data):
wanted = _EventHotKeyID()
size = ctypes.c_uint32()
code = carbon.GetEventParameter(
event, _fourcc(PARAMETER_ANY), _fourcc(HOTKEY_ID_PARAMETER), None,
ctypes.sizeof(wanted), ctypes.byref(size), ctypes.byref(wanted),
)
if code == 0:
name = self._names.get(wanted.id)
if name:
self.triggered.emit(name)
return 0
# Kept on self: Carbon holds the address of this function, and nothing
# on the Python side would otherwise stop it being collected.
self._callback = callback_type(pressed)
event_type = _EventTypeSpec(_fourcc(KEYBOARD_EVENT_CLASS), HOTKEY_PRESSED)
code = carbon.InstallEventHandler(
carbon.GetApplicationEventTarget(), self._callback, 1,
ctypes.byref(event_type), None, ctypes.byref(self._handler),
)
if code != 0:
self.failed.emit(t("Could not reach the macOS shortcut service: "
"{error}", error=code))
self._callback = None
return False
return True
def _carbon():
"""Carbon, with its calls typed the way they are used above."""
path = (ctypes.util.find_library("Carbon")
or "/System/Library/Frameworks/Carbon.framework/Carbon")
carbon = ctypes.CDLL(path)
carbon.GetApplicationEventTarget.restype = ctypes.c_void_p
carbon.InstallEventHandler.restype = ctypes.c_int32
carbon.InstallEventHandler.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(_EventTypeSpec), ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
carbon.RegisterEventHotKey.restype = ctypes.c_int32
carbon.RegisterEventHotKey.argtypes = [
ctypes.c_uint32, ctypes.c_uint32, _EventHotKeyID, ctypes.c_void_p,
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
]
carbon.UnregisterEventHotKey.restype = ctypes.c_int32
carbon.UnregisterEventHotKey.argtypes = [ctypes.c_void_p]
carbon.RemoveEventHandler.restype = ctypes.c_int32
carbon.RemoveEventHandler.argtypes = [ctypes.c_void_p]
carbon.GetEventParameter.restype = ctypes.c_int32
carbon.GetEventParameter.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p,
]
return carbon
# --- the desktop's own shortcut -------------------------------------------
def _macos():
return sys.platform == "darwin"
def _gnome():
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
return "gnome" in desktop and shutil.which("gsettings") is not None
@@ -321,26 +546,69 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID):
return None
def listener(parent=None):
"""The thing that hears the key, for whichever system this is."""
return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent)
def valid_shortcut(text):
"""Whether this machine can bind the combination as it was typed."""
parse = parse_macos_shortcut if _macos() else parse_shortcut
return parse(text)[1] is not None
def installs_shortcuts():
"""Whether this system keeps a shortcut registry to write into.
KDE and GNOME do, and something outside Dikte reads it, so the combination
survives Dikte being closed. macOS does not: there is nothing to install,
nothing to remove, and Settings should not offer either.
"""
return not _macos()
def shortcut_needs_restart():
"""Whether an installed shortcut waits for the next login before it works.
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding
up as it is written, and macOS never had one to write.
"""
return not _macos() and not _gnome()
def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
desktop_id=DESKTOP_ID):
if _macos():
_REGISTERED[desktop_id] = shortcut
return True, t(
"Shortcut saved: {shortcut}\nDikte holds this one itself while it "
"is running, so it works as soon as the settings are saved.",
shortcut=shortcut,
)
if _gnome():
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
def remove_shortcut(desktop_id=DESKTOP_ID):
if _gnome():
if _macos():
_REGISTERED.pop(desktop_id, None)
elif _gnome():
remove_gnome_shortcut(desktop_id)
else:
remove_kde_shortcut(desktop_id)
def shortcut_status(desktop_id=DESKTOP_ID):
if _macos():
return _REGISTERED.get(desktop_id)
return (gnome_shortcut_status(desktop_id) if _gnome()
else kde_shortcut_status(desktop_id))
def desktop_name():
if _macos():
return "macOS"
return "GNOME" if _gnome() else "KDE"
@@ -425,6 +693,10 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination."""
if _macos():
# There is no list to read: macOS answers the question by refusing the
# registration, which CarbonHotkey reports when it asks for the key.
return []
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
except OSError:
+40 -8
View File
@@ -80,9 +80,9 @@ TR = {
"{service} hesapta kredi kalmadığını söylüyor (HTTP 402).",
"{service} is rate limiting you (HTTP 429). Try again in a moment.":
"{service} hız sınırı uyguluyor (HTTP 429). Birazdan tekrar dene.",
"The KDE shortcut is live now, so the built-in listener has been "
"turned off. It was doubling every key press.":
"KDE kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. "
"The {desktop} shortcut is live now, so the built-in listener has "
"been turned off. It was doubling every key press.":
"{desktop} kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. "
"Her tuşa basışı ikiye katlıyordu.",
"No speech detected": "Ses algılanmadı",
"No speech detected ({level} dB)": "Ses algılanmadı ({level} dB)",
@@ -104,11 +104,14 @@ TR = {
"Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
"ffmpeg not found. Install it with: brew install ffmpeg":
"ffmpeg bulunamadı. Şununla kur: brew install ffmpeg",
"Audio recorder stopped before receiving sound: {error}":
"Ses kayıt aracı veri alamadan kapandı: {error}",
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
"{tool} not found. Install {packages}.":
"{tool} bulunamadı. {packages} paketlerini kur.",
"{tool} not found.": "{tool} bulunamadı.",
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
"{tool} not found, cannot paste automatically.":
"{tool} bulunamadı, otomatik yapıştırma yapılamıyor.",
@@ -117,6 +120,10 @@ TR = {
"{tool} failed: {error}": "{tool} hatası: {error}",
"Is ydotoold running? (systemctl --user status ydotool)":
"ydotoold çalışıyor mu? (systemctl --user status ydotool)",
"macOS has not been told to let Dikte press keys. Turn Dikte on under "
"System Settings → Privacy & Security → Accessibility.":
"macOS, Dikte'nin tuşlara basmasına henüz izin vermiyor. Sistem Ayarları "
"→ Gizlilik ve Güvenlik → Erişilebilirlik altında Dikte'yi aç.",
# --- api errors ----------------------------------------------------
"{service} API key is empty. Add it in Settings.":
@@ -155,6 +162,8 @@ TR = {
"Paste key": "Yapıştırma tuşu",
"Terminals usually want ctrl+shift+v. Change this if pasting does nothing.":
"Terminaller genelde ctrl+shift+v ister. Yapıştırma çalışmıyorsa bunu değiştir.",
"macOS asks for Accessibility permission the first time this is sent.":
"macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.",
"Restore the previous clipboard after pasting":
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
"Indicator corner": "Gösterge köşesi",
@@ -167,8 +176,7 @@ TR = {
"Skip silent recordings (don't call the API)":
"Sessiz kayıtları atla (API'ye gönderme)",
"Silence threshold": "Sessizlik eşiği",
"Keep audio files (~/.local/share/dikte/recordings)":
"Ses kayıtlarını sakla (~/.local/share/dikte/recordings)",
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
# --- settings: api --------------------------------------------------
"Keys": "Anahtarlar",
@@ -284,7 +292,7 @@ TR = {
"Saved: {path}": "Kaydedildi: {path}",
# --- settings: shortcut ------------------------------------------------
"Install as a KDE shortcut": "KDE kısayolu olarak kur",
"Install as a {desktop} shortcut": "{desktop} kısayolu olarak kur",
"Install as a global shortcut": "Global kısayol olarak kur",
"Remove": "Kaldır",
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
@@ -305,8 +313,10 @@ TR = {
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
"Could not register the GNOME shortcut: {error}":
"GNOME kısayolu kaydedilemedi: {error}",
"Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet":
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken",
"Use the built-in listener (/dev/input), for when the {desktop} shortcut "
"is not active yet":
"Yerleşik dinleyici kullan (/dev/input), {desktop} kısayolu henüz etkin "
"değilken",
"Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application.":
"Anında çalışır, oturum yenilemek gerekmez. Tek farkı: tuş kombinasyonu "
@@ -317,6 +327,13 @@ TR = {
"KWin, kısayol ayarlarını yalnızca açılışta okur. 'Kur' dedikten sonra kısayol "
"Sistem Ayarları → Kısayollar altında görünür ama oturumu yeniden açana kadar "
"tetiklenmez. O zamana kadar yerleşik dinleyiciyi kullanabilirsin.",
"The shortcut starts working as soon as it is installed.":
"Kısayol kurulur kurulmaz çalışmaya başlar.",
"Dikte asks macOS for these combinations itself, while it is running. "
"Nothing is installed, and no other application receives them in the "
"meantime.":
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"Shortcut conflict": "Kısayol çakışması",
"{shortcut} is also used by:\n\n{list}\n\nInstall anyway?":
"{shortcut} şu girdilerde de kullanılıyor:\n\n{list}\n\nYine de kurulsun mu?",
@@ -329,6 +346,15 @@ TR = {
"Could not write the desktop file: {error}": "Desktop dosyası yazılamadı: {error}",
"Could not write kglobalshortcutsrc: {error}": "kglobalshortcutsrc yazılamadı: {error}",
"Could not parse the shortcut: {shortcut}": "Kısayol çözümlenemedi: {shortcut}",
"Shortcut saved: {shortcut}\nDikte holds this one itself while it is "
"running, so it works as soon as the settings are saved.":
"Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi "
"tutar, yani ayarlar kaydedilir kaydedilmez çalışır.",
"Could not reach the macOS shortcut service: {error}":
"macOS kısayol servisine ulaşılamadı: {error}",
"macOS would not give Dikte {shortcut}; another application already holds it.":
"macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
"onu şimdiden tutuyor.",
"Cannot read /dev/input. Your user needs to be in the 'input' group:\n"
" sudo usermod -aG input $USER (then log out and back in)":
"/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n"
@@ -556,6 +582,12 @@ TR = {
"Same as dictation": "Diktedekiyle aynı",
"Current output": "Geçerli çıkış",
"The other participants": "Karşı tarafın sesi",
"macOS does not offer what the speakers are playing as something to "
"record. Install BlackHole or Loopback, send the meeting's sound through "
"it, and pick it above.":
"macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. "
"BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve "
"yukarıdan onu seç.",
"Wear headphones if you can. Through speakers your microphone hears the "
"other side as well, and although a line that lands on both channels at "
"once is dropped again, the repair is never as clean as not needing it.":
+16 -3
View File
@@ -42,19 +42,32 @@ 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 "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. 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
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}"
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'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
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
+7 -2
View File
@@ -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
+307 -36
View File
@@ -1,16 +1,22 @@
"""Clipboard and key injection, through whichever pair of programs is here.
"""Clipboard and key injection, through whatever this machine gives us.
A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and
xdotool, and a session is one or the other. Which it is gets decided in one
place, and each desktop is a small group of functions below it: another desktop,
or another operating system, adds a group and a line to the chooser rather than
a branch inside every function here.
xdotool, and macOS has pbcopy with the key press going straight to
CoreGraphics. Which of them is here gets decided in one place, and each is a
small group of functions below it: another desktop, or another operating
system, adds a group and a line to the chooser rather than a branch inside
every function here.
"""
import collections
import ctypes
import functools
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from i18n import t
@@ -27,11 +33,88 @@ KEYCODES = {
KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert",
"enter": "Return", "return": "Return"}
# Apple virtual key codes, which say where a key sits rather than what is
# printed on it: the same numbers on a Turkish and a US keyboard.
MAC_KEYCODES = {
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
"c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
"y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
"5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
"]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37,
"j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44,
"n": 45, "m": 46, ".": 47, "`": 50, "enter": 36, "return": 36,
}
MAC_FLAGS = {"shift": 1 << 17, "ctrl": 1 << 18, "alt": 1 << 19, "command": 1 << 20}
# What the same modifier is called on a Mac keyboard.
MAC_ALIASES = {"cmd": "command", "meta": "command", "super": "command",
"control": "ctrl", "option": "alt"}
HID_EVENT_TAP = 0 # kCGHIDEventTap: the event goes in where the keyboard does
# pbpaste only reads text, EPS and RTF. In particular, an image on a Mac's
# clipboard comes back as an empty byte string and pbcopy then replaces it with
# empty plain text. Keep every NSPasteboard representation in short-lived
# files instead. The manifest stays small even when the clipboard holds a
# large TIFF, and no additional Python package is needed.
_MAC_SNAPSHOT = collections.namedtuple("MacClipboardSnapshot", "directory manifest")
_MAC_SNAPSHOT_SCRIPT = r'''
ObjC.import("AppKit");
const root = ObjC.unwrap(
$.NSProcessInfo.processInfo.environment.objectForKey("DIKTE_PASTEBOARD_DIR")
);
const pasteboard = $.NSPasteboard.generalPasteboard;
const items = pasteboard.pasteboardItems;
const result = [];
for (let i = 0; i < items.count; i++) {
const item = items.objectAtIndex(i);
const representations = [];
const types = item.types;
for (let j = 0; j < types.count; j++) {
const type = ObjC.unwrap(types.objectAtIndex(j));
const data = item.dataForType(type);
if (!data) continue;
const file = `${i}-${j}.bin`;
if (data.writeToFileAtomically(`${root}/${file}`, true)) {
representations.push({type, file});
}
}
result.push(representations);
}
JSON.stringify(result);
'''
_MAC_RESTORE_SCRIPT = r'''
ObjC.import("AppKit");
const root = ObjC.unwrap(
$.NSProcessInfo.processInfo.environment.objectForKey("DIKTE_PASTEBOARD_DIR")
);
const input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
const source = $.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding);
const rows = JSON.parse(ObjC.unwrap(source));
const items = [];
for (const representations of rows) {
const item = $.NSPasteboardItem.alloc.init;
for (const representation of representations) {
const data = $.NSData.dataWithContentsOfFile(
`${root}/${representation.file}`
);
if (data) item.setDataForType(data, representation.type);
}
items.push(item);
}
const pasteboard = $.NSPasteboard.generalPasteboard;
pasteboard.clearContents;
pasteboard.writeObjects($(items));
'''
class PasteError(Exception):
pass
# --- the key press, one group per system ----------------------------------
def _keys(shortcut):
"""'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know."""
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
@@ -54,41 +137,200 @@ def _xdotool_command(shortcut):
return ["xdotool", "key", "--clearmodifiers", "+".join(keys)]
def _program_keyboard(program, command, hint=""):
"""A desktop that presses keys by running another program.
Returns the three fields an entry below is built from: the program's name,
whether it is here at all, and the press itself.
"""
def ready():
return shutil.which(program) is not None
def press(shortcut, delay):
if not ready():
raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=program))
argv = command(shortcut)
time.sleep(delay) # let the selection settle and focus come back
try:
res = subprocess.run(argv, capture_output=True, text=True, timeout=10)
except (subprocess.SubprocessError, OSError) as exc:
raise PasteError(t("Could not run {tool}: {error}",
tool=program, error=exc)) from exc
if res.returncode != 0:
message = t("{tool} failed: {error}", tool=program,
error=res.stderr.strip() or "unknown error")
raise PasteError(f"{message}\n{t(hint)}" if hint else message)
return {"keyboard": program, "ready": ready, "press": press}
def _macos_keys(shortcut):
"""'Cmd+V' -> (9, 0x100000): where the key sits, and the modifiers on it."""
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
parts = [MAC_ALIASES.get(part, part) for part in parts]
if not parts or parts[-1] not in MAC_KEYCODES:
raise PasteError(t("Unknown key: {key}", key=parts[-1] if parts else shortcut))
flags = 0
for part in parts[:-1]:
if part not in MAC_FLAGS:
raise PasteError(t("Unknown key: {key}", key=part))
flags |= MAC_FLAGS[part]
return MAC_KEYCODES[parts[-1]], flags
@functools.lru_cache(maxsize=1)
def _macos_api():
"""The bit of CoreGraphics and Accessibility a paste goes through.
Loaded on the first paste rather than at import: this module is read on
every system, and these two frameworks exist on one of them.
"""
try:
services = ctypes.CDLL(
"/System/Library/Frameworks/ApplicationServices.framework"
"/ApplicationServices"
)
core = ctypes.CDLL(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
)
except OSError as exc:
raise PasteError(t("Could not run {tool}: {error}",
tool="CoreGraphics", error=exc)) from exc
services.AXIsProcessTrusted.argtypes = []
services.AXIsProcessTrusted.restype = ctypes.c_bool
services.CGEventCreateKeyboardEvent.argtypes = [
ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool,
]
services.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p
services.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
services.CGEventSetFlags.restype = None
services.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
services.CGEventPost.restype = None
core.CFRelease.argtypes = [ctypes.c_void_p]
core.CFRelease.restype = None
return services, core
def _macos_trusted():
"""Whether macOS lets this process type into another application."""
try:
return bool(_macos_api()[0].AXIsProcessTrusted())
except PasteError:
return False
_asked_for_permission = False
def _ask_for_permission():
"""Open the one settings pane that grants it, and only the first time.
Every dictation would otherwise reopen it until the box is ticked, which is
a window in the user's face on top of the paste that did not happen.
"""
global _asked_for_permission
if _asked_for_permission:
return
_asked_for_permission = True
try:
subprocess.Popen(
["open", ("x-apple.systempreferences:com.apple.preference.security"
"?Privacy_Accessibility")],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True,
)
except OSError:
pass
def _macos_press(shortcut, delay):
"""Post the key down and up straight into the window system.
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
only asks once, when the paste it was granted for is first tried.
"""
keycode, flags = _macos_keys(shortcut)
services, core = _macos_api()
if not _macos_trusted():
_ask_for_permission()
raise PasteError(t(
"macOS has not been told to let Dikte press keys. Turn Dikte on "
"under System Settings → Privacy & Security → Accessibility."
))
time.sleep(delay) # let the selection settle and focus come back
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
up = services.CGEventCreateKeyboardEvent(None, keycode, False)
if not down or not up:
for event in (down, up):
if event:
core.CFRelease(event)
raise PasteError(t("Could not run {tool}: {error}", tool="CoreGraphics",
error="it would not make a keyboard event"))
try:
for event in (down, up):
services.CGEventSetFlags(event, flags)
services.CGEventPost(HID_EVENT_TAP, event)
time.sleep(0.01)
finally:
core.CFRelease(down)
core.CFRelease(up)
# --- which of them is here -------------------------------------------------
Desktop = collections.namedtuple(
"Desktop",
# The two programs, the packages to install them from, how to build the key
# press, and what else to say when the key press fails.
"clipboard keyboard packages read_command copy_command key_command key_hint",
# The clipboard program and the two commands it is run with, what to
# install when it is missing, the paste combinations Settings offers, and
# the key press: the program that does it, whether it can happen at all,
# and the pressing itself.
"clipboard packages read_command copy_command shortcuts keyboard ready press",
)
WAYLAND = Desktop(
clipboard="wl-copy",
keyboard="ydotool",
packages="wl-clipboard and ydotool",
read_command=["wl-paste", "--no-newline"],
copy_command=["wl-copy"],
key_command=_ydotool_command,
key_hint="Is ydotoold running? (systemctl --user status ydotool)",
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
**_program_keyboard(
"ydotool", _ydotool_command,
hint="Is ydotoold running? (systemctl --user status ydotool)",
),
)
X11 = Desktop(
clipboard="xclip",
keyboard="xdotool",
packages="xclip and xdotool",
read_command=["xclip", "-selection", "clipboard", "-out"],
copy_command=["xclip", "-selection", "clipboard", "-in"],
key_command=_xdotool_command,
key_hint="",
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
**_program_keyboard("xdotool", _xdotool_command),
)
MACOS = Desktop(
clipboard="pbcopy",
packages="", # both are part of macOS; there is nothing to install
read_command=["pbpaste"],
copy_command=["pbcopy"],
shortcuts=["cmd+v", "cmd+shift+v", "cmd+alt+shift+v"],
keyboard="", # no program: the key press is a call into the system
ready=_macos_trusted,
press=_macos_press,
)
def desktop():
"""The pair of programs this session's clipboard and keyboard go through.
"""The programs this session's clipboard and key press go through.
Read every time rather than settled at import: a session started before the
display server was up would otherwise be stuck with the wrong answer, and a
test would have nowhere to say which one it means.
"""
if sys.platform == "darwin":
return MACOS
if os.environ.get("XDG_SESSION_TYPE") == "x11":
return X11
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
@@ -98,8 +340,45 @@ def desktop():
# --- the clipboard ---------------------------------------------------------
def _macos_snapshot():
"""Copy every native pasteboard type to a temporary, file-backed snapshot."""
directory = tempfile.mkdtemp(prefix="dikte-clipboard-")
environment = dict(os.environ, DIKTE_PASTEBOARD_DIR=directory)
try:
result = subprocess.run(
["osascript", "-l", "JavaScript", "-e", _MAC_SNAPSHOT_SCRIPT],
capture_output=True, text=True, timeout=15, env=environment,
)
manifest = result.stdout.strip()
rows = json.loads(manifest) if result.returncode == 0 else None
if not isinstance(rows, list):
raise ValueError("the pasteboard helper returned no manifest")
return _MAC_SNAPSHOT(directory, manifest)
except (json.JSONDecodeError, OSError, subprocess.SubprocessError, ValueError):
shutil.rmtree(directory, ignore_errors=True)
return None
def _macos_restore(snapshot):
"""Put a native snapshot back, then discard its short-lived files."""
environment = dict(os.environ, DIKTE_PASTEBOARD_DIR=snapshot.directory)
try:
subprocess.run(
["osascript", "-l", "JavaScript", "-e", _MAC_RESTORE_SCRIPT],
input=snapshot.manifest, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, text=True, timeout=15, env=environment,
)
except (OSError, subprocess.SubprocessError):
pass
finally:
shutil.rmtree(snapshot.directory, ignore_errors=True)
def read_clipboard():
here = desktop()
if here is MACOS and shutil.which("osascript"):
snapshot = _macos_snapshot()
if snapshot is not None:
return snapshot
if not shutil.which(here.read_command[0]):
return None
try:
@@ -124,8 +403,11 @@ def _run_copy(payload):
def copy(text):
here = desktop()
if not shutil.which(here.clipboard):
raise PasteError(t("{tool} not found. Install {packages}.",
tool=here.clipboard, packages=here.packages))
raise PasteError(
t("{tool} not found. Install {packages}.",
tool=here.clipboard, packages=here.packages) if here.packages
else t("{tool} not found.", tool=here.clipboard)
)
try:
res = _run_copy(text.encode("utf-8"))
except (subprocess.SubprocessError, OSError) as exc:
@@ -136,6 +418,9 @@ def copy(text):
def copy_bytes(data):
if isinstance(data, _MAC_SNAPSHOT):
_macos_restore(data)
return
if data is None or not shutil.which(desktop().clipboard):
return
try:
@@ -147,25 +432,11 @@ def copy_bytes(data):
# --- the key press ---------------------------------------------------------
def paste_ready():
return shutil.which(desktop().keyboard) is not None
"""Whether a paste can be sent: the program is here, or macOS trusts us."""
return desktop().ready()
def press(shortcut="ctrl+v", delay=0.12):
"""Press a key combination, e.g. 'ctrl+v'."""
def press(shortcut="", delay=0.12):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
here = desktop()
if not paste_ready():
raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=here.keyboard))
command = here.key_command(shortcut)
time.sleep(delay) # let the selection settle and focus come back
try:
res = subprocess.run(command, capture_output=True, text=True, timeout=10)
except (subprocess.SubprocessError, OSError) as exc:
raise PasteError(t("Could not run {tool}: {error}",
tool=here.keyboard, error=exc)) from exc
if res.returncode != 0:
message = t("{tool} failed: {error}", tool=here.keyboard,
error=res.stderr.strip() or "unknown error")
raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint
else message)
here.press(shortcut or here.shortcuts[0], delay)
+72 -18
View File
@@ -23,6 +23,7 @@ import ggml
import hotkey
import ipc
import meeting
import paste
from filetranscribe import FileTranscriber
from i18n import t
@@ -106,7 +107,6 @@ REASONING_LEVELS = [
("Low", "low"), ("Medium", "medium"), ("High", "high"),
("Very high", "xhigh"), ("Maximum", "max"),
]
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
# Offered for every global shortcut, which keeps them one kind of field rather
# than four. The boxes stay editable: this is a shortlist of combinations that
# are usually free, not the set of ones that work.
@@ -116,6 +116,13 @@ SHORTCUTS = [
"Meta+A", "Meta+D", "Meta+M",
"Ctrl+Alt+F1", "Ctrl+Alt+F2", "Ctrl+Alt+F3",
]
# Cmd+Space is Spotlight and Ctrl+Space switches input sources, so a Mac gets
# its own shortlist. Option is what Alt is called on that keyboard.
MAC_SHORTCUTS = [
"Ctrl+Option+Space", "Cmd+Shift+Space", "Ctrl+Shift+Space",
"Ctrl+Option+A", "Ctrl+Option+D", "Ctrl+Option+M",
"Cmd+Option+A", "Cmd+Option+D", "Cmd+Option+M",
]
AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi")
@@ -550,10 +557,16 @@ class SettingsWindow(QDialog):
form.addRow("", self.auto_paste)
self.paste_shortcut = QComboBox()
self.paste_shortcut.addItems(PASTE_SHORTCUTS)
self.paste_shortcut.setToolTip(
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.")
)
# A shortlist of the combinations that usually paste, not the set of
# them: a stored one this desktop does not offer is kept as it is
# rather than quietly replaced by the first item on the list.
self.paste_shortcut.setEditable(True)
self.paste_shortcut.addItems(paste.desktop().shortcuts)
self.paste_shortcut.setToolTip(t(
"macOS asks for Accessibility permission the first time this is sent."
if paste.desktop() is paste.MACOS else
"Terminals usually want ctrl+shift+v. Change this if pasting does nothing."
))
form.addRow(t("Paste key"), self.paste_shortcut)
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
@@ -591,7 +604,9 @@ class SettingsWindow(QDialog):
)
form.addRow("", self.filter_hallucinations)
self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)"))
self.keep_audio = QCheckBox(
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
)
form.addRow("", self.keep_audio)
return page
@@ -984,6 +999,15 @@ class SettingsWindow(QDialog):
self.meeting_system.addItem(desc, name)
sources_form.addRow(t("The other participants"), self.meeting_system)
if audio.sound() is audio.COREAUDIO:
mac_note = QLabel(t(
"macOS does not offer what the speakers are playing as something "
"to record. Install BlackHole or Loopback, send the meeting's "
"sound through it, and pick it above."
))
mac_note.setWordWrap(True)
sources_form.addRow(mac_note)
note = QLabel(t(
"Wear headphones if you can. Through speakers your microphone hears "
"the other side as well, and although a line that lands on both "
@@ -1224,20 +1248,34 @@ class SettingsWindow(QDialog):
layout.addLayout(form)
self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
"not active yet"
"Use the built-in listener (/dev/input), for when the {desktop} "
"shortcut is not active yet", desktop=hotkey.desktop_name()
))
self.evdev_enabled.setToolTip(t(
"Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application."
))
layout.addWidget(self.evdev_enabled)
# Nothing to wait for where nothing is installed: there the listener is
# the mechanism, always on, and not a choice to offer.
self.evdev_enabled.setVisible(hotkey.installs_shortcuts())
note = QLabel(t(
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."
))
"shortcut shows up under System Settings → Shortcuts, but it will "
"not fire until you log out and back in. Until then, use the "
"built-in listener."
)
elif hotkey.installs_shortcuts():
explanation = t("The shortcut starts working as soon as it is installed.")
else:
explanation = t(
"Dikte asks macOS for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
note = QLabel(explanation)
note.setWordWrap(True)
layout.addWidget(note)
layout.addStretch(1)
@@ -1310,7 +1348,8 @@ class SettingsWindow(QDialog):
"""The field a global shortcut is typed or picked in."""
box = QComboBox()
box.setEditable(True)
box.addItems(SHORTCUTS)
box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS"
else SHORTCUTS)
box.setCurrentText("")
if placeholder:
box.lineEdit().setPlaceholderText(placeholder)
@@ -1344,17 +1383,32 @@ class SettingsWindow(QDialog):
box = self._shortcut_box(placeholder or t("none"))
if tooltip:
box.setToolTip(tooltip)
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(lambda: self._install_shortcut(which))
remove = QPushButton(t("Remove"))
remove.clicked.connect(lambda: self._remove_shortcut(which))
form.addRow(label, self._row(box, install, remove))
form.addRow(label, self._row(box, *self._install_buttons(
lambda: self._install_shortcut(which),
lambda: self._remove_shortcut(which),
)))
status = QLabel("")
status.setWordWrap(True)
form.addRow(status)
self._shortcut_rows[which] = (box, status, missing)
return box
@staticmethod
def _install_buttons(install_handler, remove_handler):
"""Install and Remove, where this system has somewhere to install into.
macOS has not: Dikte asks for the combination itself while it runs, so
there is nothing to write down and nothing to take back out.
"""
if not hotkey.installs_shortcuts():
return []
install = QPushButton(t("Install as a {desktop} shortcut",
desktop=hotkey.desktop_name()))
install.clicked.connect(install_handler)
remove = QPushButton(t("Remove"))
remove.clicked.connect(remove_handler)
return [install, remove]
@staticmethod
def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space."""
+5
View File
@@ -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
+168 -10
View File
@@ -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):
@@ -287,8 +305,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):
@@ -423,5 +440,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()
+34
View File
@@ -16,6 +16,7 @@ import cleanup
import config as cfg
import ggml
import i18n
import paste
from tests.support import DikteTest
@@ -453,6 +454,39 @@ class Defaults(unittest.TestCase):
with self.subTest(prompt=f"{name}_{suffix}"):
self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip())
def test_the_paste_key_is_the_one_this_desktop_pastes_with(self):
"""cmd+v on a Mac, and it must be one paste.py can actually press."""
self.assertEqual(cfg.DEFAULTS["paste_shortcut"],
paste.desktop().shortcuts[0])
class Directories(unittest.TestCase):
"""Where the settings and the recordings are kept, per system."""
def test_linux_keeps_them_apart_and_follows_xdg(self):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c",
"XDG_DATA_HOME": "/d"}):
config_dir, data_dir = cfg._directories("linux")
self.assertEqual(str(config_dir), "/c/dikte")
self.assertEqual(str(data_dir), "/d/dikte")
def test_linux_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = cfg._directories("linux")
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
def test_a_mac_keeps_both_in_application_support(self):
config_dir, data_dir = cfg._directories("darwin")
self.assertEqual(config_dir, data_dir)
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
def test_a_mac_does_not_read_the_xdg_variables(self):
"""A Mac with them set from some other tool still stores in one place."""
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
config_dir, _ = cfg._directories("darwin")
self.assertNotIn("/c", str(config_dir))
if __name__ == "__main__":
unittest.main()
+20 -1
View File
@@ -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")
+235 -1
View File
@@ -163,10 +163,13 @@ class Bindings(DikteTest):
self.assertEqual(fired({29, 42}), []) # ctrl + shift
@linux_only
class Chooser(DikteTest):
"""Which desktop is asked to register the shortcut."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux")
@contextlib.contextmanager
def under(self, desktop, has_gsettings=True):
"""A session that says it is this desktop, with or without gsettings."""
@@ -447,5 +450,236 @@ class KdeShortcut(DikteTest):
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
# --- macOS ----------------------------------------------------------------
class ParseMacShortcut(unittest.TestCase):
def test_a_combination_a_mac_would_use(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Space"),
(hotkey.MAC_MODS["cmd"], 49))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(hotkey.parse_macos_shortcut(" ctrl + option + a "),
hotkey.parse_macos_shortcut("Ctrl+Option+A"))
def test_several_modifiers_are_one_number(self):
modifiers, key = hotkey.parse_macos_shortcut("Cmd+Shift+M")
self.assertEqual(modifiers,
hotkey.MAC_MODS["cmd"] | hotkey.MAC_MODS["shift"])
self.assertEqual(key, hotkey.MAC_KEYS["m"])
def test_the_names_a_mac_keyboard_uses(self):
for name in ("cmd", "command", "meta", "super"):
with self.subTest(name=name):
self.assertEqual(hotkey.parse_macos_shortcut(f"{name}+space"),
hotkey.parse_macos_shortcut("cmd+space"))
self.assertEqual(hotkey.parse_macos_shortcut("option+a"),
hotkey.parse_macos_shortcut("alt+a"))
def test_a_key_on_its_own(self):
self.assertEqual(hotkey.parse_macos_shortcut("F5"), (0, 96))
def test_modifiers_with_no_key(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Shift"), (None, None))
def test_a_key_nobody_mapped(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+F13"), (None, None))
def test_two_keys_are_not_a_shortcut(self):
self.assertEqual(hotkey.parse_macos_shortcut("A+B"), (None, None))
def test_nothing(self):
self.assertEqual(hotkey.parse_macos_shortcut(""), (None, None))
self.assertEqual(hotkey.parse_macos_shortcut(None), (None, None))
class FakeCarbon:
"""Enough of Carbon to watch what the listener asks it for.
The references are numbers standing in for pointers, which is all the code
does with them: it collects them and hands them back to be unregistered.
"""
def __init__(self, install=0, register=0):
self.install, self.register = install, register # what they return
self.registered = [] # (key, modifiers, identifier)
self.unregistered = []
self.handlers_removed = 0
self.pressed_id = 0
self.parameter_result = 0
def GetApplicationEventTarget(self):
return 7000
def InstallEventHandler(self, target, callback, count, spec, data, out):
if self.install == 0:
out._obj.value = 8000
return self.install
def RegisterEventHotKey(self, key, modifiers, identifier, target, options, out):
if self.register != 0:
return self.register
self.registered.append((key, modifiers, identifier.id))
out._obj.value = 9000 + len(self.registered)
return 0
def UnregisterEventHotKey(self, reference):
self.unregistered.append(reference.value)
return 0
def RemoveEventHandler(self, handler):
self.handlers_removed += 1
return 0
def GetEventParameter(self, event, kind, name, wanted_type, size, out_size, out):
out._obj.id = self.pressed_id
return self.parameter_result
class CarbonListener(DikteTest):
"""What the listener asks macOS for, without a Mac to ask."""
def setUp(self):
super().setUp()
self.carbon = FakeCarbon()
self.patch_attr(hotkey, "_carbon", lambda: self.carbon)
self.addCleanup(hotkey._REGISTERED.clear)
self.listener = hotkey.CarbonHotkey()
self.addCleanup(self.listener.stop)
self.failures = []
self.listener.failed.connect(self.failures.append)
def test_every_binding_is_asked_for_by_position_and_modifier(self):
self.assertTrue(self.listener.start({"toggle": "Ctrl+Option+Space"}))
self.assertEqual(self.carbon.registered,
[(49, hotkey.MAC_MODS["ctrl"] | hotkey.MAC_MODS["option"], 1)])
self.assertEqual(self.failures, [])
self.assertTrue(self.listener.running)
def test_a_binding_with_no_shortcut_is_skipped(self):
self.assertFalse(self.listener.start({"toggle": "", "ask": ""}))
self.assertEqual(self.carbon.registered, [])
self.assertFalse(self.listener.running)
def test_an_unparsable_shortcut_is_reported_and_the_rest_go_on(self):
self.assertTrue(self.listener.start({"toggle": "Cmd+F13",
"ask": "Cmd+Shift+Space"}))
self.assertEqual(len(self.failures), 1)
self.assertIn("Cmd+F13", self.failures[0])
self.assertEqual(len(self.carbon.registered), 1)
def test_a_combination_another_application_already_holds(self):
"""The conflict warning macOS has: it is the answer to asking."""
self.carbon.register = -9878 # eventHotKeyExistsErr
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertIn("Cmd+Shift+Space", self.failures[0])
self.assertFalse(self.listener.running)
def test_a_handler_that_will_not_install(self):
self.carbon.install = -50
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertEqual(self.carbon.registered, [])
self.assertEqual(len(self.failures), 1)
def test_no_carbon_to_talk_to(self):
self.patch_attr(hotkey, "_carbon",
mock.Mock(side_effect=OSError("no such library")))
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertIn("no such library", self.failures[0])
def test_the_key_press_arrives_under_the_name_it_was_registered_with(self):
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
heard = []
self.listener.triggered.connect(heard.append)
self.carbon.pressed_id = 2 # the second binding, which is "ask"
self.listener._callback(None, 0, None)
self.assertEqual(heard, ["ask"])
def test_a_press_carbon_could_not_identify_is_dropped(self):
self.listener.start({"toggle": "Cmd+Shift+Space"})
heard = []
self.listener.triggered.connect(heard.append)
self.carbon.parameter_result = -50
self.listener._callback(None, 0, None)
self.assertEqual(heard, [])
def test_stopping_hands_every_registration_back(self):
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
self.listener.stop()
self.assertEqual(self.carbon.unregistered, [9001, 9002])
self.assertEqual(self.carbon.handlers_removed, 1)
self.assertFalse(self.listener.running)
def test_starting_twice_does_not_leave_the_first_set_behind(self):
self.listener.start({"toggle": "Cmd+Shift+Space"})
self.listener.start({"toggle": "Cmd+Shift+A"})
self.assertEqual(self.carbon.unregistered, [9001])
self.assertEqual(len(self.listener._registrations), 1)
def test_what_it_registered_is_what_the_status_line_reads_back(self):
with mock.patch.object(hotkey.sys, "platform", "darwin"):
self.listener.start({"toggle": "Ctrl+Option+Space",
"meeting": "Ctrl+Option+M"})
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Option+Space")
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
"Ctrl+Option+M")
self.listener.stop()
self.assertIsNone(hotkey.shortcut_status())
class MacChooser(DikteTest):
"""What the shortcut verbs mean where there is nothing to write them into."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "darwin")
self.addCleanup(hotkey._REGISTERED.clear)
def test_the_listener_is_the_one_macos_has(self):
self.assertIsInstance(hotkey.listener(), hotkey.CarbonHotkey)
def test_everywhere_else_reads_the_keyboard_itself(self):
with mock.patch.object(hotkey.sys, "platform", "linux"):
self.assertIsInstance(hotkey.listener(), hotkey.EvdevHotkey)
def test_there_is_nothing_to_install_into(self):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
self.assertEqual(hotkey.desktop_name(), "macOS")
def test_installing_records_it_rather_than_writing_anything(self):
with mock.patch.object(hotkey.subprocess, "run") as run:
ok, message = hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
run.assert_not_called()
self.assertTrue(ok)
self.assertIn("Cmd+Shift+Space", message)
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
def test_removing_takes_it_back_out(self):
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
def test_each_verb_is_kept_apart(self):
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
hotkey.install_shortcut("Cmd+Shift+M", "dikte meeting",
desktop_id=hotkey.MEETING_DESKTOP_ID)
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
"Cmd+Shift+M")
def test_no_list_of_conflicts_to_read(self):
"""Not even KDE's file, which a Mac could well have a copy of."""
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
def test_a_combination_is_checked_against_the_mac_table(self):
self.assertTrue(hotkey.valid_shortcut("Cmd+Shift+Space"))
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
def test_the_other_table_is_the_one_used_elsewhere(self):
with mock.patch.object(hotkey.sys, "platform", "linux"):
self.assertTrue(hotkey.valid_shortcut("Ctrl+F1"))
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
if __name__ == "__main__":
unittest.main()
+219 -22
View File
@@ -1,30 +1,37 @@
"""The clipboard and the key press, which is where a dictation actually lands.
Everything here shells out, so the tools are faked. What the tests hold onto is
the command line: a paste that presses the wrong keys, or in the wrong order,
types nothing and looks like a hang.
Everything here is faked: the programs the two Linux desktops shell out to, and
the frameworks macOS goes through. What the tests hold onto is what was asked
for. A paste that presses the wrong keys, or in the wrong order, types nothing
and looks like a hang.
Both desktops owe the same promises, so those are written once and run against
each of them. A third one added to paste.py inherits the same list rather than
needing its own copy of it.
Every system owes the same promises about the clipboard, so those are written
once and run against each of them. A fourth one added to paste.py inherits the
same list rather than needing its own copy of it. Each class says which system
it is standing on, which is why none of this is skipped anywhere: the Linux half
is checked on a Mac and the macOS half on Linux, and a change to the chooser
cannot quietly break the platform nobody is sitting at.
"""
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
from typing import ClassVar
from unittest import mock
import paste
from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
from tests.support import DikteTest, FakeCompleted, only_these_tools
@linux_only
class Chooser(DikteTest):
"""Which pair of programs this session's clipboard goes through."""
def under(self, **env):
with mock.patch.dict(os.environ, env, clear=True):
def under(self, platform="linux", **env):
with mock.patch.object(sys, "platform", platform), \
mock.patch.dict(os.environ, env, clear=True):
return paste.desktop()
def test_a_wayland_session(self):
@@ -46,18 +53,29 @@ class Chooser(DikteTest):
def test_nothing_set_at_all(self):
self.assertIs(self.under(), paste.WAYLAND)
def test_a_mac(self):
self.assertIs(self.under("darwin"), paste.MACOS)
class DesktopContract:
"""What both desktops owe. Each of them subclasses this once, below."""
def test_a_mac_running_an_x_server_is_still_a_mac(self):
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
class Standing:
"""A test that runs as if it were sitting at one particular system."""
env: ClassVar[dict] = {}
platform = "linux"
here = None
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True))
# ---- reading the clipboard -------------------------------------------
class ClipboardContract(Standing):
"""What every system owes the text on its way to the clipboard."""
def test_no_reader_installed(self):
with only_these_tools():
@@ -81,13 +99,10 @@ class DesktopContract:
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertIsNone(paste.read_clipboard())
# ---- copying ----------------------------------------------------------
def test_no_clipboard_tool_installed_says_what_to_install(self):
def test_no_clipboard_tool_installed_names_it(self):
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
paste.copy("hello")
self.assertIn(self.here.clipboard, str(caught.exception))
self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception))
def test_the_text_goes_in_as_utf8(self):
with only_these_tools(self.here.clipboard), \
@@ -138,7 +153,15 @@ class DesktopContract:
paste.copy_bytes(b"\x89PNG\r\n")
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
# ---- pressing the key -------------------------------------------------
def test_the_paste_key_it_offers_is_one_it_can_press(self):
"""Whatever Settings lists, pressing it must not come back unknown."""
for shortcut in self.here.shortcuts:
with self.subTest(shortcut=shortcut):
self.assertTrue(self.pressing(shortcut))
class KeyProgramContract(Standing):
"""The half of it that is another program: ydotool, xdotool."""
def press(self, shortcut, result=None):
with only_these_tools(self.here.keyboard), \
@@ -148,6 +171,10 @@ class DesktopContract:
paste.press(shortcut)
return run.call_args.args[0]
def pressing(self, shortcut):
"""The command a shortcut would run, for the contract above."""
return self.press(shortcut)
def test_no_keyboard_tool_installed(self):
with only_these_tools():
self.assertFalse(paste.paste_ready())
@@ -180,9 +207,11 @@ class DesktopContract:
self.assertIn(self.here.keyboard, str(caught.exception))
self.assertIn("no socket", str(caught.exception))
def test_nothing_named_presses_the_one_this_desktop_pastes_with(self):
self.assertEqual(self.press(""), self.press(self.here.shortcuts[0]))
@linux_only
class Wayland(DesktopContract, DikteTest):
class Wayland(ClipboardContract, KeyProgramContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0"}
here = paste.WAYLAND
@@ -208,8 +237,7 @@ class Wayland(DesktopContract, DikteTest):
self.assertIn("ydotoold", str(caught.exception))
@linux_only
class X11(DesktopContract, DikteTest):
class X11(ClipboardContract, KeyProgramContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
here = paste.X11
@@ -232,5 +260,174 @@ class X11(DesktopContract, DikteTest):
self.assertNotIn("ydotoold", str(caught.exception))
class FakeCoreGraphics:
"""Enough of the two frameworks to watch what a paste does to them.
The events are numbers standing in for pointers, which is all the code
treats them as: it makes them, sets flags on them, posts them, and hands
them back.
"""
def __init__(self, trusted=True, makes=None):
self.trusted = trusted
self.makes = makes # how many it will hand out; None for as many as asked
self.made = [] # (keycode, is_down)
self.flags = [] # (event, flags)
self.posted = [] # (tap, event)
self.released = []
# --- ApplicationServices
def AXIsProcessTrusted(self):
return self.trusted
def CGEventCreateKeyboardEvent(self, source, keycode, down):
self.made.append((keycode, down))
if self.makes is not None and len(self.made) > self.makes:
return None
return 1000 + len(self.made)
def CGEventSetFlags(self, event, flags):
self.flags.append((event, flags))
def CGEventPost(self, tap, event):
self.posted.append((tap, event))
# --- CoreFoundation
def CFRelease(self, event):
self.released.append(event)
class MacOS(ClipboardContract, DikteTest):
platform = "darwin"
here = paste.MACOS
def setUp(self):
super().setUp()
self.api = FakeCoreGraphics()
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
self.patch_attr(paste.time, "sleep", lambda seconds: None)
# It opens the settings pane once per run; each test gets its own run.
self.patch_attr(paste, "_asked_for_permission", False)
self.opened = self.patch_attr(paste.subprocess, "Popen", mock.Mock())
def pressing(self, shortcut):
paste.press(shortcut)
return self.api.posted
def test_the_key_goes_in_by_position_with_the_modifiers_on_it(self):
paste.press("cmd+v")
self.assertEqual(self.api.made, [(9, True), (9, False)])
self.assertEqual([flags for _, flags in self.api.flags],
[paste.MAC_FLAGS["command"]] * 2)
self.assertEqual([tap for tap, _ in self.api.posted],
[paste.HID_EVENT_TAP] * 2)
def test_the_down_is_posted_before_the_up(self):
paste.press("cmd+v")
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
def test_both_events_are_handed_back(self):
"""CoreGraphics gives out memory that nothing else will free."""
paste.press("cmd+v")
self.assertEqual(sorted(self.api.released), [1001, 1002])
def test_several_modifiers_are_one_number(self):
paste.press("cmd+shift+v")
self.assertEqual(self.api.flags[0][1],
paste.MAC_FLAGS["command"] | paste.MAC_FLAGS["shift"])
def test_the_names_a_mac_keyboard_uses(self):
for name in ("cmd", "command", "meta", "super"):
with self.subTest(name=name):
self.assertEqual(paste._macos_keys(f"{name}+v"),
(9, paste.MAC_FLAGS["command"]))
self.assertEqual(paste._macos_keys("option+v"), paste._macos_keys("alt+v"))
self.assertEqual(paste._macos_keys("control+v"), paste._macos_keys("ctrl+v"))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(paste._macos_keys(" Cmd + V "), paste._macos_keys("cmd+v"))
def test_a_key_nobody_mapped_is_refused_before_anything_is_posted(self):
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+f13")
self.assertIn("f13", str(caught.exception))
self.assertEqual(self.api.posted, [])
def test_a_modifier_nobody_mapped(self):
with self.assertRaises(paste.PasteError) as caught:
paste.press("hyper+v")
self.assertIn("hyper", str(caught.exception))
def test_nothing_at_all(self):
with self.assertRaises(paste.PasteError):
paste.press("+")
def test_nothing_is_typed_until_macos_says_so(self):
self.api.trusted = False
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+v")
self.assertIn("Accessibility", str(caught.exception))
self.assertEqual(self.api.posted, [])
def test_the_permission_pane_is_opened_once_and_not_again(self):
"""It is a window in the user's face, and one paste is every dictation."""
self.api.trusted = False
for _ in range(3):
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.opened.assert_called_once()
self.assertIn("Privacy_Accessibility", self.opened.call_args.args[0][1])
def test_a_system_that_will_not_open_the_pane_still_says_what_is_wrong(self):
self.api.trusted = False
self.opened.side_effect = OSError("no open(1) here")
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+v")
self.assertIn("Accessibility", str(caught.exception))
def test_readiness_is_the_permission_rather_than_a_program(self):
self.assertTrue(paste.paste_ready())
self.api.trusted = False
self.assertFalse(paste.paste_ready())
def test_an_event_that_could_not_be_made_takes_its_pair_with_it(self):
self.api.makes = 1 # the second one comes back null
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.assertEqual(self.api.released, [1001])
self.assertEqual(self.api.posted, [])
def test_the_frameworks_not_being_there_is_not_a_crash(self):
"""Every other system imports this module too, and must survive it."""
self.patch_attr(paste, "_macos_api",
mock.Mock(side_effect=paste.PasteError("no such library")))
self.assertFalse(paste.paste_ready())
class MacClipboardSnapshot(DikteTest):
def test_every_native_type_is_restored_and_the_files_are_removed(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
manifest = '[[{"type":"public.tiff","file":"0-0.bin"}]]'
pathlib.Path(directory, "0-0.bin").write_bytes(b"a TIFF")
snapshot = paste._MAC_SNAPSHOT(directory, manifest)
with mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run:
paste.copy_bytes(snapshot)
self.assertEqual(run.call_args.kwargs["input"], manifest)
self.assertEqual(run.call_args.kwargs["env"]["DIKTE_PASTEBOARD_DIR"],
directory)
self.assertFalse(os.path.exists(directory))
def test_a_failed_snapshot_leaves_no_temporary_directory(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
with mock.patch.object(paste.tempfile, "mkdtemp", return_value=directory), \
mock.patch.object(subprocess, "run",
return_value=FakeCompleted(stdout=b"not json")):
self.assertIsNone(paste._macos_snapshot())
self.assertFalse(os.path.exists(directory))
if __name__ == "__main__":
unittest.main()
+39 -2
View File
@@ -6,7 +6,9 @@ save, so a setting added to one half and not the other is silently reset the
next time anybody presses Save. That is the failure this catches.
"""
import sys
import unittest
from typing import ClassVar
from unittest import mock
from PyQt6.QtWidgets import QApplication, QMessageBox
@@ -15,6 +17,7 @@ import cleanup
import config as cfg
import hotkey
import overlay as overlay_module
import paste
import settings_ui
from tests.support import DikteTest, only_these_tools
@@ -97,10 +100,16 @@ CHANGED = {
class Settings(DikteTest):
# What a Mac shows instead, where the combination on offer is a different
# one. Everything else about the window is the same on both.
changed = CHANGED
platform = "linux"
def setUp(self):
super().setUp()
# No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(only_these_tools())
self.enterContext(mock.patch.object(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
@@ -133,11 +142,11 @@ class Settings(DikteTest):
self.assertEqual(conf.data, before)
def test_a_setting_of_your_own_survives_the_round_trip(self):
self.write_config(CHANGED)
self.write_config(self.changed)
conf = cfg.Config()
self.window(conf)._save()
stored = self.read_config_file()
for key, value in CHANGED.items():
for key, value in self.changed.items():
with self.subTest(key=key):
self.assertEqual(stored[key], value)
@@ -304,6 +313,34 @@ class Settings(DikteTest):
self.assertFalse(window.file_stop.isEnabled())
class MacSettings(Settings):
"""The same window and the same round trip, standing on a Mac.
Nothing here is about macOS: it is the rest of the window, checked on the
platform where three of its widgets are gone and one offers other keys.
"""
platform = "darwin"
changed: ClassVar[dict] = {**CHANGED, "paste_shortcut": "cmd+shift+v"}
def test_there_is_no_install_button_where_nothing_is_installed(self):
window = self.window(cfg.Config())
labels = [button.text() for button in
window.findChildren(settings_ui.QPushButton)]
self.assertFalse([text for text in labels if "shortcut" in text.lower()])
def test_the_listener_is_not_offered_as_a_choice(self):
"""It is the whole mechanism there; turning it off would leave nothing."""
window = self.window(cfg.Config())
self.assertFalse(window.evdev_enabled.isVisible())
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
offered = [window.paste_shortcut.itemText(index)
for index in range(window.paste_shortcut.count())]
self.assertEqual(offered, paste.MACOS.shortcuts)
class Overlay(DikteTest):
def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs)
+14 -3
View File
@@ -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"
+7 -2
View File
@@ -138,13 +138,18 @@ class Pipeline(QObject):
wants_paste = paste_override
with _paste_lock:
previous = paste.read_clipboard() if conf["restore_clipboard"] else None
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)