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")` | | A reply that fails | `http_error(429)`, `url_error()`, `raw_body("not json")` |
| Reading what was sent | `sent_json(request)`, `multipart_fields(request)` | | Reading what was sent | `sent_json(request)`, `multipart_fields(request)` |
| A program on the PATH | `only_these_tools("pactl", "wl-copy")` | | 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()` | | Audio | `silence()`, `tone()`, `speech()`, `stereo()`, `make_wav()` |
| A settings object | `self.config(cleanup_enabled=False)` | | A settings object | `self.config(cleanup_enabled=False)` |
@@ -52,30 +53,35 @@ forgets fails rather than hangs.
## Another platform ## Another platform
Most of what Dikte does is not desktop-specific, and the tests are split along Three systems are supported: Wayland, X11 and macOS. Each one is a named entry
that line. 511 of them pass anywhere: transcription, cleanup, the config file, in a table, and one chooser picks between them, so a fourth adds an entry and a
the history, the agent, the command line, the timeline of a meeting. The line rather than a branch inside every function. The three tables are
remaining 59 cover what Dikte *is* on this desktop, and carry `@linux_only` `paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device
from `tests.support`: PipeWire capture and the pactl device list, wl-clipboard lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform`
and ydotool, KDE's shortcut file and the `/dev/input` listener. 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 The tests are split along the same line, and almost none of them are skipped.
programs. Do not mark one because it happens to be convenient: a test that 892 of the 935 run on any machine, including every line of the Wayland, X11 and
quietly stops running on the platform you are porting to protects nothing. 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
The other half of a port is where the branch goes. Keep `sys.platform` out of rather than avoiding the question:
the middle of a function; make the public name a chooser and give each platform
its own function underneath:
```python ```python
def copy(text): class MacOS(ClipboardContract, DikteTest):
return _copy_macos(text) if sys.platform == "darwin" else _copy_wayland(text) platform = "darwin"
here = paste.MACOS
``` ```
Then each platform's test calls its own function directly and passes everywhere, so the Linux half is checked on a Mac and the macOS half on Linux, and a change
and adding a third one leaves the first two's tests alone. An `if` buried inside to a chooser cannot quietly break the platform nobody is sitting at. What the
`copy()` forces every existing test to patch `sys.platform` instead, and the systems owe in common is written once as a contract class and subclassed by each
next port breaks all of them. 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 ## 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 missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in. whatever window you were typing in.
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages: Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. No
just the Python standard library and PyQt6. dependencies beyond system packages: just the Python standard library, 3.11 or
newer, and PyQt6.
*[Türkçe README](README.tr.md)* *[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 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 On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11
tools instead: tools instead:
@@ -37,6 +55,12 @@ tools instead:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg 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 `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 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 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 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`, 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 `GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in
`~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which `~/.config/dikte/config.json`, mode 600, or in
case the raw transcript is pasted, and a thinking model's effort can be set next `~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in
to it. which case the raw transcript is pasted, and a thinking model's effort can be
set next to it.
## Using it ## Using it
+28 -4
View File
@@ -4,8 +4,9 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. 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: KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem
sadece Python standart kütüphanesi ve PyQt6. paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya
üstü) ve PyQt6.
*[English README](README.md)* *[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 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 Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11
araçlarıyla çalışır: araçlarıyla çalışır:
@@ -36,6 +54,11 @@ araçlarıyla çalışır:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg 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 `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` 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; 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 (`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 ç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` `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında.
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin
yanındaki kutudan düşünme seviyesini de seçebilirsin.
## Kullanım ## Kullanım
+225 -81
View File
@@ -1,19 +1,27 @@
"""Raw PCM capture with a live level meter. """Raw PCM capture with a live level meter.
Dictation records one source through pw-record. A meeting records two of them at Dictation records one source. A meeting records two of them at once, the
once, the microphone and what comes out of the speakers, and for that it goes microphone and what comes out of the speakers, and for that it goes through
through ffmpeg instead: one process reading both devices and merging them into ffmpeg: one process reading both devices and merging them into the two channels
the two channels of a single stream, which is the only way the two stay aligned of a single stream, which is the only way the two stay aligned with each other
with each other over an hour. 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 array
import collections
import json import json
import math import math
import os import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys
import tempfile import tempfile
import threading import threading
import wave import wave
@@ -57,9 +65,7 @@ class Recorder(QObject):
return return
cmd = recording_command(target) cmd = recording_command(target)
if not cmd: if not cmd:
self.failed.emit(t( self.failed.emit(t(sound().missing))
"No audio recorder found. Install pulseaudio-utils or pipewire-audio."
))
return return
try: try:
@@ -175,55 +181,13 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
def recording_command(target=""): def recording_command(target=""):
"""Return a raw-s16 capture command for the sound server on this desktop. """A raw-s16 capture command for the sound system on this machine."""
return sound().record(target)
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
service, and its source names are the same ones shown by list_sources().
Keep pw-record as the fallback for minimal native-PipeWire installations.
"""
if shutil.which("parec"):
cmd = [
"parec", "--record", "--raw", f"--rate={RATE}",
f"--channels={CHANNELS}", "--format=s16le",
# Left alone, parec holds about two seconds before handing anything
# over, and then hands over all of it at once: the level meter sits
# still and jumps, and the tail of a recording can be lost on the
# way out. A chunk of the meter is the unit the rest of this file
# is measured in, so ask for that.
f"--latency-msec={CHUNK_LATENCY_MS}",
]
if target:
cmd.append(f"--device={target}")
return cmd
if shutil.which("pw-record"):
cmd = [
"pw-record", *_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(): def meeting_command(mic_target, system_target):
"""Use --raw only on pw-record releases that provide it. """One ffmpeg reading both devices and merging them into two channels."""
return sound().meeting(mic_target, system_target)
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 []
class MeetingRecorder(QObject): class MeetingRecorder(QObject):
@@ -270,18 +234,7 @@ class MeetingRecorder(QObject):
"Pick one in Settings → Meeting.")) "Pick one in Settings → Meeting."))
return return
merge = ( cmd = meeting_command(mic_target, system_target)
"[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), "-",
]
try: try:
os.makedirs(os.path.dirname(path), exist_ok=True) 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) 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"): if not shutil.which("pactl"):
return [] return []
try: try:
@@ -471,30 +497,23 @@ def _sources():
return [] return []
def list_sources(): def _pulse_inputs():
"""[(name, description)] for every real input source."""
return [ return [
(src.get("name", ""), src.get("description") or src.get("name", "")) (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") if not src.get("name", "").endswith(".monitor")
] ]
def list_monitors(): def _pulse_outputs():
"""[(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.
"""
return [ return [
(src.get("name", ""), src.get("description") or src.get("name", "")) (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") if src.get("name", "").endswith(".monitor")
] ]
def default_monitor(): def _pulse_default_output():
"""The monitor of the output sound is currently going to, or ''."""
if not shutil.which("pactl"): if not shutil.which("pactl"):
return "" return ""
try: try:
@@ -507,5 +526,130 @@ def default_monitor():
if not sink: if not sink:
return "" return ""
monitor = f"{sink}.monitor" 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 "" 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() combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
if not combo: if not combo:
return fail(opts, "no combination given and none stored; pass --combo", 2) 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) return fail(opts, f"cannot parse that combination: {combo}", 2)
clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id) clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes and not opts.force: 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 collections
import hashlib import hashlib
import json import json
import os import os
import pathlib import pathlib
import sys
import api import api
import ggml import ggml
import i18n import i18n
import paste
from i18n import t from i18n import t
@@ -16,9 +18,21 @@ def _xdg(var, default):
return pathlib.Path(os.environ.get(var) or os.path.expanduser(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" CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR = _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte"
HISTORY_FILE = DATA_DIR / "history.jsonl" HISTORY_FILE = DATA_DIR / "history.jsonl"
RECORDINGS_DIR = DATA_DIR / "recordings" RECORDINGS_DIR = DATA_DIR / "recordings"
MEETINGS_DIR = DATA_DIR / "meetings" MEETINGS_DIR = DATA_DIR / "meetings"
@@ -415,7 +429,7 @@ DEFAULTS = {
"local_llm_reasoning": "none", "local_llm_reasoning": "none",
"cleanup_prompt": "", # empty -> language-specific default "cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True, "auto_paste": True,
"paste_shortcut": "ctrl+v", "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
"restore_clipboard": False, "restore_clipboard": False,
"mic_target": "", "mic_target": "",
"max_seconds": 300, "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"): if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "xcb") 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.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
@@ -96,7 +105,7 @@ class Dikte:
self.ask_pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf)
self.meeting_recorder = audio.MeetingRecorder() self.meeting_recorder = audio.MeetingRecorder()
self.meetings = MeetingPipeline(self.conf) 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 # Before anything of ours is started: a server from a Dikte that was
# killed outright is still holding a model in memory. # killed outright is still holding a model in memory.
ggml.sweep() ggml.sweep()
@@ -325,8 +334,11 @@ class Dikte:
# that same press. Its lateness is also the proof we were waiting for # 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 # that the shortcut is live, which leaves the listener with nothing to
# do but double every press. # 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) 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() self._retire_listener()
return return
handler() handler()
@@ -346,8 +358,9 @@ class Dikte:
self.conf.save() self.conf.save()
self.tray.showMessage( self.tray.showMessage(
"Dikte", "Dikte",
t("The KDE shortcut is live now, so the built-in listener has been " t("The {desktop} shortcut is live now, so the built-in listener has "
"turned off. It was doubling every key press."), "been turned off. It was doubling every key press.",
desktop=hotkey.desktop_name()),
QSystemTrayIcon.MessageIcon.Information, 8000, QSystemTrayIcon.MessageIcon.Information, 8000,
) )
@@ -853,7 +866,10 @@ class Dikte:
self._apply_local() self._apply_local()
self._build_tray() self._build_tray()
self._refresh_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] self.evdev.start({name: self.conf[spec.setting]
for name, spec in hotkey.SHORTCUTS.items()}) for name, spec in hotkey.SHORTCUTS.items()})
else: else:
+14 -1
View File
@@ -37,6 +37,7 @@ import shutil
import signal import signal
import socket import socket
import subprocess import subprocess
import sys
import tarfile import tarfile
import threading import threading
import time import time
@@ -220,8 +221,15 @@ def _has_vulkan():
def _wanted_assets(program): 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() 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(): 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-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz")
return (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: if item:
break break
if item is None: 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.", raise LocalError(t("{repo} {tag} has no build for this machine.",
repo=program.repo, tag=tag)) 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 ast
import collections import collections
import ctypes
import ctypes.util
import glob import glob
import os import os
import pathlib import pathlib
@@ -10,6 +20,7 @@ import select
import shutil import shutil
import struct import struct
import subprocess import subprocess
import sys
import threading import threading
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
@@ -197,8 +208,222 @@ class EvdevHotkey(QObject):
return True 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 ------------------------------------------- # --- the desktop's own shortcut -------------------------------------------
def _macos():
return sys.platform == "darwin"
def _gnome(): def _gnome():
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
return "gnome" in desktop and shutil.which("gsettings") is not None 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 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", def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
desktop_id=DESKTOP_ID): 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(): if _gnome():
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
return install_kde_shortcut(shortcut, exec_command, name, desktop_id) return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
def remove_shortcut(desktop_id=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) remove_gnome_shortcut(desktop_id)
else: else:
remove_kde_shortcut(desktop_id) remove_kde_shortcut(desktop_id)
def shortcut_status(desktop_id=DESKTOP_ID): def shortcut_status(desktop_id=DESKTOP_ID):
if _macos():
return _REGISTERED.get(desktop_id)
return (gnome_shortcut_status(desktop_id) if _gnome() return (gnome_shortcut_status(desktop_id) if _gnome()
else kde_shortcut_status(desktop_id)) else kde_shortcut_status(desktop_id))
def desktop_name(): def desktop_name():
if _macos():
return "macOS"
return "GNOME" if _gnome() else "KDE" 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): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination.""" """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: try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8") text = SHORTCUTS_FILE.read_text(encoding="utf-8")
except OSError: except OSError:
+40 -8
View File
@@ -80,9 +80,9 @@ TR = {
"{service} hesapta kredi kalmadığını söylüyor (HTTP 402).", "{service} hesapta kredi kalmadığını söylüyor (HTTP 402).",
"{service} is rate limiting you (HTTP 429). Try again in a moment.": "{service} is rate limiting you (HTTP 429). Try again in a moment.":
"{service} hız sınırı uyguluyor (HTTP 429). Birazdan tekrar dene.", "{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 " "The {desktop} shortcut is live now, so the built-in listener has "
"turned off. It was doubling every key press.": "been turned off. It was doubling every key press.":
"KDE kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. " "{desktop} kısayolu artık çalışıyor, bu yüzden dahili dinleyici kapatıldı. "
"Her tuşa basışı ikiye katlıyordu.", "Her tuşa basışı ikiye katlıyordu.",
"No speech detected": "Ses algılanmadı", "No speech detected": "Ses algılanmadı",
"No speech detected ({level} dB)": "Ses algılanmadı ({level} dB)", "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}", "Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.": "No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", "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}": "Audio recorder stopped before receiving sound: {error}":
"Ses kayıt aracı veri alamadan kapandı: {error}", "Ses kayıt aracı veri alamadan kapandı: {error}",
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
"{tool} not found. Install {packages}.": "{tool} not found. Install {packages}.":
"{tool} bulunamadı. {packages} paketlerini kur.", "{tool} bulunamadı. {packages} paketlerini kur.",
"{tool} not found.": "{tool} bulunamadı.",
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.", "{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
"{tool} not found, cannot paste automatically.": "{tool} not found, cannot paste automatically.":
"{tool} bulunamadı, otomatik yapıştırma yapılamıyor.", "{tool} bulunamadı, otomatik yapıştırma yapılamıyor.",
@@ -117,6 +120,10 @@ TR = {
"{tool} failed: {error}": "{tool} hatası: {error}", "{tool} failed: {error}": "{tool} hatası: {error}",
"Is ydotoold running? (systemctl --user status ydotool)": "Is ydotoold running? (systemctl --user status ydotool)":
"ydotoold çalışıyor mu? (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 ---------------------------------------------------- # --- api errors ----------------------------------------------------
"{service} API key is empty. Add it in Settings.": "{service} API key is empty. Add it in Settings.":
@@ -155,6 +162,8 @@ TR = {
"Paste key": "Yapıştırma tuşu", "Paste key": "Yapıştırma tuşu",
"Terminals usually want ctrl+shift+v. Change this if pasting does nothing.": "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.", "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": "Restore the previous clipboard after pasting":
"Yapıştırdıktan sonra eski pano içeriğini geri koy", "Yapıştırdıktan sonra eski pano içeriğini geri koy",
"Indicator corner": "Gösterge köşesi", "Indicator corner": "Gösterge köşesi",
@@ -167,8 +176,7 @@ TR = {
"Skip silent recordings (don't call the API)": "Skip silent recordings (don't call the API)":
"Sessiz kayıtları atla (API'ye gönderme)", "Sessiz kayıtları atla (API'ye gönderme)",
"Silence threshold": "Sessizlik eşiği", "Silence threshold": "Sessizlik eşiği",
"Keep audio files (~/.local/share/dikte/recordings)": "Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
"Ses kayıtlarını sakla (~/.local/share/dikte/recordings)",
# --- settings: api -------------------------------------------------- # --- settings: api --------------------------------------------------
"Keys": "Anahtarlar", "Keys": "Anahtarlar",
@@ -284,7 +292,7 @@ TR = {
"Saved: {path}": "Kaydedildi: {path}", "Saved: {path}": "Kaydedildi: {path}",
# --- settings: shortcut ------------------------------------------------ # --- 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", "Install as a global shortcut": "Global kısayol olarak kur",
"Remove": "Kaldır", "Remove": "Kaldır",
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
@@ -305,8 +313,10 @@ TR = {
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}", "Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
"Could not register the GNOME shortcut: {error}": "Could not register the GNOME shortcut: {error}":
"GNOME kısayolu kaydedilemedi: {error}", "GNOME kısayolu kaydedilemedi: {error}",
"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 "
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken", "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 " "Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application.": "combination also reaches the focused application.":
"Anında çalışır, oturum yenilemek gerekmez. Tek farkı: tuş kombinasyonu " "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 " "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 " "Sistem Ayarları → Kısayollar altında görünür ama oturumu yeniden açana kadar "
"tetiklenmez. O zamana kadar yerleşik dinleyiciyi kullanabilirsin.", "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 conflict": "Kısayol çakışması",
"{shortcut} is also used by:\n\n{list}\n\nInstall anyway?": "{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?", "{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 the desktop file: {error}": "Desktop dosyası yazılamadı: {error}",
"Could not write kglobalshortcutsrc: {error}": "kglobalshortcutsrc yazılamadı: {error}", "Could not write kglobalshortcutsrc: {error}": "kglobalshortcutsrc yazılamadı: {error}",
"Could not parse the shortcut: {shortcut}": "Kısayol çözümlenemedi: {shortcut}", "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" "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)": " sudo usermod -aG input $USER (then log out and back in)":
"/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n" "/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n"
@@ -556,6 +582,12 @@ TR = {
"Same as dictation": "Diktedekiyle aynı", "Same as dictation": "Diktedekiyle aynı",
"Current output": "Geçerli çıkış", "Current output": "Geçerli çıkış",
"The other participants": "Karşı tarafın sesi", "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 " "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 " "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.": "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[*]}" warn "Missing: ${missing[*]}"
say "Ubuntu X11: sudo apt install pulseaudio-utils xclip xdotool ffmpeg" 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 "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 echo
else else
ok "All dependencies present" ok "All dependencies present"
fi fi
# 2. ydotoold -------------------------------------------------------------- # 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 [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then
if systemctl --user is-active --quiet ydotool 2>/dev/null \ socket="${YDOTOOL_SOCKET:-${XDG_RUNTIME_DIR:-/tmp}/.ydotool_socket}"
|| systemctl --user is-active --quiet ydotoold 2>/dev/null; then alive() { pgrep -x ydotoold >/dev/null 2>&1; }
if [[ -w "$socket" ]] && alive; then
ok "ydotoold is running (auto-paste ready)" 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 else
warn "ydotoold is not running, auto-paste will not work" 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
fi fi
+7 -2
View File
@@ -1,6 +1,7 @@
"""The small recording indicator that appears in a screen corner without taking focus.""" """The small recording indicator that appears in a screen corner without taking focus."""
import math import math
import sys
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
@@ -54,13 +55,17 @@ class Overlay(QWidget):
self._phase = 0.0 self._phase = 0.0
self._concealed = True self._concealed = True
self.setWindowFlags( flags = (
Qt.WindowType.FramelessWindowHint Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.WindowStaysOnTopHint
| Qt.WindowType.Tool | Qt.WindowType.Tool
| Qt.WindowType.WindowDoesNotAcceptFocus | 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_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
# One that can be clicked away has to receive the click, which means it # 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 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 xdotool, and macOS has pbcopy with the key press going straight to
place, and each desktop is a small group of functions below it: another desktop, CoreGraphics. Which of them is here gets decided in one place, and each is a
or another operating system, adds a group and a line to the chooser rather than small group of functions below it: another desktop, or another operating
a branch inside every function here. system, adds a group and a line to the chooser rather than a branch inside
every function here.
""" """
import collections import collections
import ctypes
import functools
import json
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
import tempfile
import time import time
from i18n import t from i18n import t
@@ -27,11 +33,88 @@ KEYCODES = {
KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert", KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert",
"enter": "Return", "return": "Return"} "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): class PasteError(Exception):
pass pass
# --- the key press, one group per system ----------------------------------
def _keys(shortcut): def _keys(shortcut):
"""'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know.""" """'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()] 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)] 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 = collections.namedtuple(
"Desktop", "Desktop",
# The two programs, the packages to install them from, how to build the key # The clipboard program and the two commands it is run with, what to
# press, and what else to say when the key press fails. # install when it is missing, the paste combinations Settings offers, and
"clipboard keyboard packages read_command copy_command key_command key_hint", # 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( WAYLAND = Desktop(
clipboard="wl-copy", clipboard="wl-copy",
keyboard="ydotool",
packages="wl-clipboard and ydotool", packages="wl-clipboard and ydotool",
read_command=["wl-paste", "--no-newline"], read_command=["wl-paste", "--no-newline"],
copy_command=["wl-copy"], copy_command=["wl-copy"],
key_command=_ydotool_command, shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
key_hint="Is ydotoold running? (systemctl --user status ydotool)", **_program_keyboard(
"ydotool", _ydotool_command,
hint="Is ydotoold running? (systemctl --user status ydotool)",
),
) )
X11 = Desktop( X11 = Desktop(
clipboard="xclip", clipboard="xclip",
keyboard="xdotool",
packages="xclip and xdotool", packages="xclip and xdotool",
read_command=["xclip", "-selection", "clipboard", "-out"], read_command=["xclip", "-selection", "clipboard", "-out"],
copy_command=["xclip", "-selection", "clipboard", "-in"], copy_command=["xclip", "-selection", "clipboard", "-in"],
key_command=_xdotool_command, shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
key_hint="", **_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(): 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 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 display server was up would otherwise be stuck with the wrong answer, and a
test would have nowhere to say which one it means. test would have nowhere to say which one it means.
""" """
if sys.platform == "darwin":
return MACOS
if os.environ.get("XDG_SESSION_TYPE") == "x11": if os.environ.get("XDG_SESSION_TYPE") == "x11":
return X11 return X11
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
@@ -98,8 +340,45 @@ def desktop():
# --- the clipboard --------------------------------------------------------- # --- 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(): def read_clipboard():
here = desktop() 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]): if not shutil.which(here.read_command[0]):
return None return None
try: try:
@@ -124,8 +403,11 @@ def _run_copy(payload):
def copy(text): def copy(text):
here = desktop() here = desktop()
if not shutil.which(here.clipboard): if not shutil.which(here.clipboard):
raise PasteError(t("{tool} not found. Install {packages}.", raise PasteError(
tool=here.clipboard, packages=here.packages)) t("{tool} not found. Install {packages}.",
tool=here.clipboard, packages=here.packages) if here.packages
else t("{tool} not found.", tool=here.clipboard)
)
try: try:
res = _run_copy(text.encode("utf-8")) res = _run_copy(text.encode("utf-8"))
except (subprocess.SubprocessError, OSError) as exc: except (subprocess.SubprocessError, OSError) as exc:
@@ -136,6 +418,9 @@ def copy(text):
def copy_bytes(data): def copy_bytes(data):
if isinstance(data, _MAC_SNAPSHOT):
_macos_restore(data)
return
if data is None or not shutil.which(desktop().clipboard): if data is None or not shutil.which(desktop().clipboard):
return return
try: try:
@@ -147,25 +432,11 @@ def copy_bytes(data):
# --- the key press --------------------------------------------------------- # --- the key press ---------------------------------------------------------
def paste_ready(): 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): def press(shortcut="", delay=0.12):
"""Press a key combination, e.g. 'ctrl+v'.""" """Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
here = desktop() here = desktop()
if not paste_ready(): here.press(shortcut or here.shortcuts[0], delay)
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)
+72 -18
View File
@@ -23,6 +23,7 @@ import ggml
import hotkey import hotkey
import ipc import ipc
import meeting import meeting
import paste
from filetranscribe import FileTranscriber from filetranscribe import FileTranscriber
from i18n import t from i18n import t
@@ -106,7 +107,6 @@ REASONING_LEVELS = [
("Low", "low"), ("Medium", "medium"), ("High", "high"), ("Low", "low"), ("Medium", "medium"), ("High", "high"),
("Very high", "xhigh"), ("Maximum", "max"), ("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 # 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 # than four. The boxes stay editable: this is a shortlist of combinations that
# are usually free, not the set of ones that work. # are usually free, not the set of ones that work.
@@ -116,6 +116,13 @@ SHORTCUTS = [
"Meta+A", "Meta+D", "Meta+M", "Meta+A", "Meta+D", "Meta+M",
"Ctrl+Alt+F1", "Ctrl+Alt+F2", "Ctrl+Alt+F3", "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 " AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi") "*.mp4 *.mkv *.webm *.mov *.avi")
@@ -550,10 +557,16 @@ class SettingsWindow(QDialog):
form.addRow("", self.auto_paste) form.addRow("", self.auto_paste)
self.paste_shortcut = QComboBox() self.paste_shortcut = QComboBox()
self.paste_shortcut.addItems(PASTE_SHORTCUTS) # A shortlist of the combinations that usually paste, not the set of
self.paste_shortcut.setToolTip( # them: a stored one this desktop does not offer is kept as it is
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.") # 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) form.addRow(t("Paste key"), self.paste_shortcut)
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting")) self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
@@ -591,7 +604,9 @@ class SettingsWindow(QDialog):
) )
form.addRow("", self.filter_hallucinations) 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) form.addRow("", self.keep_audio)
return page return page
@@ -984,6 +999,15 @@ class SettingsWindow(QDialog):
self.meeting_system.addItem(desc, name) self.meeting_system.addItem(desc, name)
sources_form.addRow(t("The other participants"), self.meeting_system) 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( note = QLabel(t(
"Wear headphones if you can. Through speakers your microphone hears " "Wear headphones if you can. Through speakers your microphone hears "
"the other side as well, and although a line that lands on both " "the other side as well, and although a line that lands on both "
@@ -1224,20 +1248,34 @@ class SettingsWindow(QDialog):
layout.addLayout(form) layout.addLayout(form)
self.evdev_enabled = QCheckBox(t( self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is " "Use the built-in listener (/dev/input), for when the {desktop} "
"not active yet" "shortcut is not active yet", desktop=hotkey.desktop_name()
)) ))
self.evdev_enabled.setToolTip(t( self.evdev_enabled.setToolTip(t(
"Works immediately, no session restart. The only difference: the key " "Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application." "combination also reaches the focused application."
)) ))
layout.addWidget(self.evdev_enabled) 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 " "KWin only reads shortcut settings at startup. After 'Install' the "
"shortcut shows up under System Settings → Shortcuts, but it will not " "shortcut shows up under System Settings → Shortcuts, but it will "
"fire until you log out and back in. Until then, use the built-in listener." "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) note.setWordWrap(True)
layout.addWidget(note) layout.addWidget(note)
layout.addStretch(1) layout.addStretch(1)
@@ -1310,7 +1348,8 @@ class SettingsWindow(QDialog):
"""The field a global shortcut is typed or picked in.""" """The field a global shortcut is typed or picked in."""
box = QComboBox() box = QComboBox()
box.setEditable(True) box.setEditable(True)
box.addItems(SHORTCUTS) box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS"
else SHORTCUTS)
box.setCurrentText("") box.setCurrentText("")
if placeholder: if placeholder:
box.lineEdit().setPlaceholderText(placeholder) box.lineEdit().setPlaceholderText(placeholder)
@@ -1344,17 +1383,32 @@ class SettingsWindow(QDialog):
box = self._shortcut_box(placeholder or t("none")) box = self._shortcut_box(placeholder or t("none"))
if tooltip: if tooltip:
box.setToolTip(tooltip) box.setToolTip(tooltip)
install = QPushButton(t("Install as a KDE shortcut")) form.addRow(label, self._row(box, *self._install_buttons(
install.clicked.connect(lambda: self._install_shortcut(which)) lambda: self._install_shortcut(which),
remove = QPushButton(t("Remove")) lambda: self._remove_shortcut(which),
remove.clicked.connect(lambda: self._remove_shortcut(which)) )))
form.addRow(label, self._row(box, install, remove))
status = QLabel("") status = QLabel("")
status.setWordWrap(True) status.setWordWrap(True)
form.addRow(status) form.addRow(status)
self._shortcut_rows[which] = (box, status, missing) self._shortcut_rows[which] = (box, status, missing)
return box 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 @staticmethod
def _row(*widgets): def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space.""" """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-") _SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config") os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data") 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) atexit.register(shutil.rmtree, _SANDBOX, True)
# A key sitting in the environment would otherwise reach the code that falls # 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 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 here: on Linux a source that is not a monitor is an input, one that is belongs
speakers, and neither list may go missing when pactl is absent. 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 import array
@@ -11,6 +15,7 @@ import io
import json import json
import os import os
import subprocess import subprocess
import sys
import unittest import unittest
import wave import wave
from unittest import mock from unittest import mock
@@ -19,7 +24,6 @@ import audio
from tests.support import ( from tests.support import (
DikteTest, DikteTest,
FakeCompleted, FakeCompleted,
linux_only,
only_these_tools, only_these_tools,
pcm, pcm,
silence, 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): class ChunkLevels(unittest.TestCase):
def test_silence(self): def test_silence(self):
self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0)) self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0))
@@ -115,8 +135,7 @@ SOURCES = [
] ]
@linux_only class Devices(OnLinux, DikteTest):
class Devices(DikteTest):
@contextlib.contextmanager @contextlib.contextmanager
def pactl(self, sources=None, sink=None, tools=("pactl",)): def pactl(self, sources=None, sink=None, tools=("pactl",)):
payloads = { payloads = {
@@ -216,8 +235,7 @@ class FakeProcess:
self._alive = False self._alive = False
@linux_only class RecordingCommand(OnLinux, DikteTest):
class RecordingCommand(DikteTest):
"""Which program captures the microphone, and how it is asked to.""" """Which program captures the microphone, and how it is asked to."""
def test_parec_is_preferred(self): def test_parec_is_preferred(self):
@@ -287,8 +305,7 @@ class RecordingCommand(DikteTest):
if arg.startswith(flag)]) if arg.startswith(flag)])
@linux_only class RecorderChain(OnLinux, DikteTest):
class RecorderChain(DikteTest):
"""Start to WAV, with pw-record faked out.""" """Start to WAV, with pw-record faked out."""
def record(self, data, target="", max_seconds=300): def record(self, data, target="", max_seconds=300):
@@ -423,5 +440,146 @@ class RecorderChain(DikteTest):
self.assertFalse(recorder.active) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+34
View File
@@ -16,6 +16,7 @@ import cleanup
import config as cfg import config as cfg
import ggml import ggml
import i18n import i18n
import paste
from tests.support import DikteTest from tests.support import DikteTest
@@ -453,6 +454,39 @@ class Defaults(unittest.TestCase):
with self.subTest(prompt=f"{name}_{suffix}"): with self.subTest(prompt=f"{name}_{suffix}"):
self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+20 -1
View File
@@ -176,6 +176,9 @@ class Download(Local):
class InstallProgram(Local): class InstallProgram(Local):
def setUp(self): def setUp(self):
super().setUp() 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 # Built once, because the release listing has to publish its checksum
# and a tarball is not the same bytes twice. # and a tarball is not the same bytes twice.
self.archive = tarball({ self.archive = tarball({
@@ -216,6 +219,23 @@ class InstallProgram(Local):
ggml.install_program(ggml.WHISPER) ggml.install_program(ggml.WHISPER)
self.assertIn("this machine", str(caught.exception)) 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): def test_what_was_installed_is_remembered(self):
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
self.assertEqual(ggml.installed_program(ggml.WHISPER), path) 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(512), "512 B")
self.assertEqual(ggml.human_size(574041195), "547.4 MB") self.assertEqual(ggml.human_size(574041195), "547.4 MB")
self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") 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 self.assertEqual(fired({29, 42}), []) # ctrl + shift
@linux_only
class Chooser(DikteTest): class Chooser(DikteTest):
"""Which desktop is asked to register the shortcut.""" """Which desktop is asked to register the shortcut."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux")
@contextlib.contextmanager @contextlib.contextmanager
def under(self, desktop, has_gsettings=True): def under(self, desktop, has_gsettings=True):
"""A session that says it is this desktop, with or without gsettings.""" """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"), []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+219 -22
View File
@@ -1,30 +1,37 @@
"""The clipboard and the key press, which is where a dictation actually lands. """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 Everything here is faked: the programs the two Linux desktops shell out to, and
the command line: a paste that presses the wrong keys, or in the wrong order, the frameworks macOS goes through. What the tests hold onto is what was asked
types nothing and looks like a hang. 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 Every system owes the same promises about the clipboard, so those are written
each of them. A third one added to paste.py inherits the same list rather than once and run against each of them. A fourth one added to paste.py inherits the
needing its own copy of it. 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 os
import pathlib
import subprocess import subprocess
import sys
import tempfile
import unittest import unittest
from typing import ClassVar from typing import ClassVar
from unittest import mock from unittest import mock
import paste 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): class Chooser(DikteTest):
"""Which pair of programs this session's clipboard goes through.""" """Which pair of programs this session's clipboard goes through."""
def under(self, **env): def under(self, platform="linux", **env):
with mock.patch.dict(os.environ, env, clear=True): with mock.patch.object(sys, "platform", platform), \
mock.patch.dict(os.environ, env, clear=True):
return paste.desktop() return paste.desktop()
def test_a_wayland_session(self): def test_a_wayland_session(self):
@@ -46,18 +53,29 @@ class Chooser(DikteTest):
def test_nothing_set_at_all(self): def test_nothing_set_at_all(self):
self.assertIs(self.under(), paste.WAYLAND) self.assertIs(self.under(), paste.WAYLAND)
def test_a_mac(self):
self.assertIs(self.under("darwin"), paste.MACOS)
class DesktopContract: def test_a_mac_running_an_x_server_is_still_a_mac(self):
"""What both desktops owe. Each of them subclasses this once, below.""" """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] = {} env: ClassVar[dict] = {}
platform = "linux"
here = None here = None
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True)) 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): def test_no_reader_installed(self):
with only_these_tools(): with only_these_tools():
@@ -81,13 +99,10 @@ class DesktopContract:
mock.patch.object(subprocess, "run", side_effect=OSError("nope")): mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertIsNone(paste.read_clipboard()) self.assertIsNone(paste.read_clipboard())
# ---- copying ---------------------------------------------------------- def test_no_clipboard_tool_installed_names_it(self):
def test_no_clipboard_tool_installed_says_what_to_install(self):
with only_these_tools(), self.assertRaises(paste.PasteError) as caught: with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
paste.copy("hello") paste.copy("hello")
self.assertIn(self.here.clipboard, str(caught.exception)) 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): def test_the_text_goes_in_as_utf8(self):
with only_these_tools(self.here.clipboard), \ with only_these_tools(self.here.clipboard), \
@@ -138,7 +153,15 @@ class DesktopContract:
paste.copy_bytes(b"\x89PNG\r\n") paste.copy_bytes(b"\x89PNG\r\n")
self.assertEqual(run.call_args.kwargs["input"], 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): def press(self, shortcut, result=None):
with only_these_tools(self.here.keyboard), \ with only_these_tools(self.here.keyboard), \
@@ -148,6 +171,10 @@ class DesktopContract:
paste.press(shortcut) paste.press(shortcut)
return run.call_args.args[0] 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): def test_no_keyboard_tool_installed(self):
with only_these_tools(): with only_these_tools():
self.assertFalse(paste.paste_ready()) self.assertFalse(paste.paste_ready())
@@ -180,9 +207,11 @@ class DesktopContract:
self.assertIn(self.here.keyboard, str(caught.exception)) self.assertIn(self.here.keyboard, str(caught.exception))
self.assertIn("no socket", 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", env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0"} "WAYLAND_DISPLAY": "wayland-0"}
here = paste.WAYLAND here = paste.WAYLAND
@@ -208,8 +237,7 @@ class Wayland(DesktopContract, DikteTest):
self.assertIn("ydotoold", str(caught.exception)) self.assertIn("ydotoold", str(caught.exception))
@linux_only class X11(ClipboardContract, KeyProgramContract, DikteTest):
class X11(DesktopContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"} env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
here = paste.X11 here = paste.X11
@@ -232,5 +260,174 @@ class X11(DesktopContract, DikteTest):
self.assertNotIn("ydotoold", str(caught.exception)) 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__": if __name__ == "__main__":
unittest.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. next time anybody presses Save. That is the failure this catches.
""" """
import sys
import unittest import unittest
from typing import ClassVar
from unittest import mock from unittest import mock
from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtWidgets import QApplication, QMessageBox
@@ -15,6 +17,7 @@ import cleanup
import config as cfg import config as cfg
import hotkey import hotkey
import overlay as overlay_module import overlay as overlay_module
import paste
import settings_ui import settings_ui
from tests.support import DikteTest, only_these_tools from tests.support import DikteTest, only_these_tools
@@ -97,10 +100,16 @@ CHANGED = {
class Settings(DikteTest): 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): def setUp(self):
super().setUp() super().setUp()
# No pactl, no model lists over the network, and no modal dialogue # No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK. # waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(only_these_tools()) self.enterContext(only_these_tools())
self.enterContext(mock.patch.object(QMessageBox, "information")) self.enterContext(mock.patch.object(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow, self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
@@ -133,11 +142,11 @@ class Settings(DikteTest):
self.assertEqual(conf.data, before) self.assertEqual(conf.data, before)
def test_a_setting_of_your_own_survives_the_round_trip(self): def test_a_setting_of_your_own_survives_the_round_trip(self):
self.write_config(CHANGED) self.write_config(self.changed)
conf = cfg.Config() conf = cfg.Config()
self.window(conf)._save() self.window(conf)._save()
stored = self.read_config_file() stored = self.read_config_file()
for key, value in CHANGED.items(): for key, value in self.changed.items():
with self.subTest(key=key): with self.subTest(key=key):
self.assertEqual(stored[key], value) self.assertEqual(stored[key], value)
@@ -304,6 +313,34 @@ class Settings(DikteTest):
self.assertFalse(window.file_stop.isEnabled()) 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): class Overlay(DikteTest):
def overlay(self, **kwargs): def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs) widget = overlay_module.Overlay(**kwargs)
+14 -3
View File
@@ -32,7 +32,7 @@ class Chain(DikteTest):
transcript="uh, book it for Thursday", transcript="uh, book it for Thursday",
cleaned="Book it for Thursday.", cleaned="Book it for Thursday.",
cleanup_error=None, answer=("Booked.", ""), rms=None, 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) pipeline = worker.Pipeline(self.conf)
done, failures, stages, cancels = [], [], [], [] done, failures, stages, cancels = [], [], [], []
pipeline.finished.connect(lambda *args: done.append(args)) 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") as copy, \
mock.patch.object(paste, "copy_bytes") as copy_bytes, \ mock.patch.object(paste, "copy_bytes") as copy_bytes, \
mock.patch.object(paste, "press") as press, \ 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): mock.patch.object(worker.time, "sleep", lambda seconds: None):
press.side_effect = paste_error
calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call, 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, pipeline._work(self.wav, duration,
self.rms if rms is None else rms, ask, paste_override) self.rms if rms is None else rms, ask, paste_override)
return {"done": done, "failures": failures, "stages": stages, return {"done": done, "failures": failures, "stages": stages,
@@ -83,9 +86,11 @@ class Chain(DikteTest):
def test_auto_paste_switched_off_only_copies(self): def test_auto_paste_switched_off_only_copies(self):
self.conf["auto_paste"] = False self.conf["auto_paste"] = False
self.conf["restore_clipboard"] = True
run = self.run_chain() run = self.run_chain()
run["copy"].assert_called_once() run["copy"].assert_called_once()
run["press"].assert_not_called() run["press"].assert_not_called()
run["read_clipboard"].assert_not_called()
def test_a_run_asked_for_from_a_terminal_pastes_nowhere(self): 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.""" """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 = self.run_chain()
run["copy_bytes"].assert_not_called() 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): def test_the_transcription_is_told_the_language_and_the_glossary(self):
self.conf["language"] = "tr" self.conf["language"] = "tr"
self.conf["transcribe_prompt"] = "Paraşüt" self.conf["transcribe_prompt"] = "Paraşüt"
+7 -2
View File
@@ -138,13 +138,18 @@ class Pipeline(QObject):
wants_paste = paste_override wants_paste = paste_override
with _paste_lock: 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) paste.copy(text)
if wants_paste: if wants_paste:
self.stage.emit(t("Pasting…")) self.stage.emit(t("Pasting…"))
paste.press(conf["paste_shortcut"]) paste.press(conf["paste_shortcut"])
finally:
if previous is not None: 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) time.sleep(0.35)
paste.copy_bytes(previous) paste.copy_bytes(previous)