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