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