Merge pull request #30 from huseyin-emre-tigci/windows-support

Add Windows support
Co-authored-by: Hüseyin Emre Tığcı <[email protected]>
This commit is contained in:
Yusuf İpek
2026-08-18 15:00:07 +03:00
committed by GitHub
32 changed files with 1824 additions and 85 deletions
+38
View File
@@ -77,3 +77,41 @@ jobs:
bash -n scripts/release.sh
bash -n packaging/build-appimage.sh
bash -n packaging/build-dmg.sh
# The same job again for the same reason. The Windows backends are faked at
# the one function that loads user32 and kernel32, so every line of them is
# already read on the Linux above; what only this job can catch is the half
# that reads the real system. %APPDATA% and %LOCALAPPDATA% have to be the
# directories Windows actually hands out, a path spelled with a backslash has
# to be one the tests can still read, and the config-permission test has to
# skip rather than fail on a file system that decides by ACL.
windows:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
python: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
# No apt step: PyQt6's wheel carries the Qt DLLs it needs on Windows.
- name: Install PyQt6
run: python -m pip install --quiet PyQt6
- name: Run the tests
run: python -m unittest discover --verbose
# What the Mac does for its installer, in the language this one is in.
# Parsing only: install.ps1 writes into the Start Menu and the user PATH.
- name: Check the installer parses
shell: pwsh
run: |
$problems = $null
[System.Management.Automation.Language.Parser]::ParseFile(
"$PWD/install.ps1", [ref]$null, [ref]$problems) > $null
if ($problems) { $problems; exit 1 }
+27 -15
View File
@@ -53,19 +53,28 @@ forgets fails rather than hangs.
## Another platform
Three systems are supported: Wayland, X11 and macOS. Each one is a named entry
in a table, and one chooser picks between them, so a fourth adds an entry and a
line rather than a branch inside every function. The three tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device
lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform`
Four systems are supported: Wayland, X11, macOS and Windows. Each one is a named
entry in a table, and one chooser picks between them, so a fifth adds an entry
and a line rather than a branch inside every function. The tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device
lists, and whether the far side of a meeting can be recorded at all),
`paths.directories()` (where the settings and the data live) and `hotkey.backend()`,
which names the one mechanism a session has for holding a key. Keep `sys.platform`
inside the chooser and read it there every time: a constant settled at import is
one no test can stand somewhere else.
Where a platform cannot do something, say so in its table entry rather than in
the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no
capture device for what the speakers are playing, and a caller reading a False
there can tell that apart from an empty device list, which only means the tool
that lists them is not installed.
The tests are split along the same line, and almost none of them are skipped.
892 of the 935 run on any machine, including every line of the Wayland, X11 and
macOS backends: the programs are faked at `shutil.which`, the frameworks at the
one function that loads them. A test class says which system it is standing on
rather than avoiding the question:
1084 of the 1147 run on any machine, including every line of the Wayland, X11,
macOS and Windows backends: the programs are faked at `shutil.which`, the
frameworks and system libraries at the one function that loads them
(`paste._win_api`, `hotkey._win_input`). A test class says which system it is
standing on rather than avoiding the question:
```python
class MacOS(ClipboardContract, DikteTest):
@@ -73,14 +82,17 @@ class MacOS(ClipboardContract, DikteTest):
here = paste.MACOS
```
so the Linux half is checked on a Mac and the macOS half on Linux, and a change
to a chooser cannot quietly break the platform nobody is sitting at. What the
systems owe in common is written once as a contract class and subclassed by each
of them.
so the Linux half is checked on a Mac, the macOS half on Linux and the Windows
half on both, and a change to a chooser cannot quietly break the platform nobody
is sitting at. What the systems owe in common is written once as a contract
class and subclassed by each of them.
The 43 that do carry `@linux_only` are the ones that would need the real thing:
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test
that way only when faking it would leave nothing to test. A test that quietly
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. The 20 with
`@posix_only` are `integrate.py`, the menu entry and the login item a downloaded
build writes for itself: there are two downloads, an AppImage and a disk image,
so that module has no Windows half for a Windows host to check. Mark a test
either 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
+10 -4
View File
@@ -5,10 +5,10 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and any other
Linux desktop that will let it read the keyboard. No
dependencies beyond system packages: just the Python standard library, 3.11 or
newer, and PyQt6.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS,
[Windows](README.windows.md) and any other Linux desktop that will let it read
the keyboard. No dependencies beyond system packages: just the Python standard
library, 3.11 or newer, and PyQt6.
*[Türkçe README](README.tr.md)*
@@ -93,6 +93,12 @@ Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON
transcribe in the cloud. A meeting needs BlackHole or Loopback
(`brew install blackhole-2ch`); dictation does not.
Windows works the same way, holding the keys through the system's own hotkey
service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`,
then `python -m dikte`, with an optional `install.ps1` for the Start Menu entry
and the `dikte` command. Meetings are not supported there yet; the details are
in the [Windows README](README.windows.md).
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
two global shortcuts, whose keys are its two arguments, or the ones already in
your settings when it is given none. `./scripts/update.sh` pulls and puts all of
+10 -4
View File
@@ -4,10 +4,10 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta ve klavyeyi
okumasına izin veren diğer Linux masaüstlerinde de çalışır. Sistem
paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya
üstü) ve PyQt6.
KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta,
[Windows](README.windows.md)'ta ve klavyeyi okumasına izin veren diğer Linux
masaüstlerinde de çalışır. Sistem paketleri dışında bağımlılığı yok: sadece
Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)*
@@ -92,6 +92,12 @@ build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı içi
BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için
gerekmiyor.
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install
PyQt6`, sonra `python -m dikte`; Başlat Menüsü girdisi ve `dikte` komutu için
isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar
[Windows README](README.windows.md)'sinde.
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
+78
View File
@@ -0,0 +1,78 @@
# Dikte on Windows
Press `Ctrl+Space`, talk, press again: what you said is transcribed, cleaned
up and pasted where your cursor is.
## Requirements
- **Windows 10/11**
- **Python 3.11+** with **PyQt6** (`pip install PyQt6`; install.ps1 installs
it when it is missing)
- **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg`
## Installing
From a checkout: the releases page carries an AppImage and a disk image, and no
Windows build yet.
```powershell
powershell -ExecutionPolicy Bypass -File install.ps1
```
This adds a **Dikte** entry to the Start Menu and a **`dikte`** command to the
terminal. Add `-Autostart` to also start it at sign-in; `-Uninstall` removes
all of it and leaves the repository and your settings alone.
To try it without installing anything:
```sh
python -m dikte
```
## First run
1. The tray icon appears and the Settings window opens.
2. Under **API and models**, download a local whisper model (the whisper.cpp
Windows build is fetched automatically) or enter an OpenAI, Groq or
OpenRouter key.
3. The shortcut defaults to `Ctrl+Space` and is changed under Shortcuts.
While Dikte runs, Windows' own hotkey service (RegisterHotKey) listens for
it: nothing to install and no permission to grant.
## What is different from Linux and macOS
- **Meeting recording (microphone + speakers) is not supported yet.** Windows
does not offer what the speakers are playing as a capture device, so there
is nothing to record the far side from. Everything else works, including
transcribing audio and video files.
- **The shortcut is swallowed**: while Dikte holds `Ctrl+Space`, the focused
application does not see it. This is how macOS behaves too, and unlike the
Linux listener, which shares the key.
- No external tools for the clipboard or the key press: both go straight
through the Windows API (the clipboard, SendInput).
- Settings live under `%APPDATA%\Dikte`, models and recordings under
`%LOCALAPPDATA%\Dikte`.
## Performance
- The local install fetches whisper.cpp's **OpenBLAS build**, which
transcribes about twice as fast as the stock one on a plain CPU. There is
no GPU build to fetch for machines without an NVIDIA card, and none for
Windows on ARM either: whisper.cpp publishes x64 only, so a Snapdragon
machine runs it under emulation and the cloud is the faster option there.
- Setting Settings → API and models → **Threads** near your physical core
count helps noticeably; the server's own default is 4.
- If speed matters more than accuracy, `ggml-small` and `ggml-base` are much
faster; `ggml-large-v3-turbo-q5_0` transcribes best.
## Troubleshooting
- **Recording does not start:** does `dikte doctor` find ffmpeg, and does
`dikte devices` list your microphone? `devices` also takes a fresh listing,
which is what to run after plugging one in.
- **Nothing is pasted:** a normal-privilege process cannot type into an
elevated (administrator) window; run Dikte elevated too, or paste by hand.
The text lands on the clipboard either way.
- **The shortcut does nothing:** another application already holds the
combination. Dikte says so in a tray notification when it asks for the key;
pick a different one under Settings → Shortcuts.
+13 -1
View File
@@ -18,6 +18,7 @@ import mimetypes
import os
import secrets
import socket
import sys
import threading
import urllib.error
import urllib.request
@@ -148,6 +149,12 @@ def _stop_using(conn):
if sock is not None:
with contextlib.suppress(OSError):
sock.shutdown(socket.SHUT_RDWR)
if sys.platform == "win32":
# On Windows the shutdown leaves a blocked recv exactly where it
# was; only closing the OS handle ends it, and close() on the
# object would wait for the blocked reader to let go of it first.
with contextlib.suppress(OSError):
socket.close(sock.detach())
with contextlib.suppress(OSError):
conn.close()
@@ -258,7 +265,12 @@ def _multipart(fields, file_field, file_path):
out += str(value).encode("utf-8") + b"\r\n"
filename = os.path.basename(file_path)
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
# The two types a dictation actually sends are pinned: on Windows,
# guess_type answers from the registry and differs machine to machine.
known = {".wav": "audio/x-wav", ".mp3": "audio/mpeg"}
extension = os.path.splitext(filename)[1].lower()
ctype = (known.get(extension) or mimetypes.guess_type(filename)[0]
or "application/octet-stream")
with open(file_path, "rb") as fh:
payload = fh.read()
out += f"--{boundary}\r\n".encode()
+31 -1
View File
@@ -15,6 +15,7 @@ import json
import os
import signal
import socket
import subprocess
import sys
import threading
@@ -97,6 +98,9 @@ class Dikte:
self.meeting_base = ""
self.meeting_message = ""
self.settings_window = None
# The single-instance server, handed over once run_app has opened it, so
# that a restart can stop answering before the replacement starts.
self.server = None
self._quitting = False
# A request that asked to be told how its run ended waits in here until
# the run gets there, keyed by which of the three it was waiting on.
@@ -966,8 +970,29 @@ class Dikte:
if self.settings_window is not None:
self.settings_window.close()
self.shutdown()
# Stop answering before the replacement is started, not just afterwards.
# execv leaves nothing behind to answer, but a Windows restart is two
# processes for a moment, and removeServer does nothing about a name
# another process is holding. The new one then either opens a second
# server on a name the old one is still answering on, so that a command
# arriving in that moment reaches the process that is going away, or
# fails to open one at all and says so to a console nobody is watching.
# Closing first leaves neither.
if self.server is not None:
self.server.close()
QLocalServer.removeServer(SERVER_NAME)
args = ipc.launcher() + ["--gui"]
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and leaves the two
# processes sharing a console; a detached start does neither.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
QApplication.instance().quit()
return
os.execv(args[0], args)
def shutdown(self):
@@ -1038,7 +1063,11 @@ def install_signal_handlers(app):
app.quit() # aboutToQuit runs shutdown()
notifier.activated.connect(woken)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
# SIGHUP does not exist on Windows, and neither does a session to hang up.
signals = [signal.SIGINT, signal.SIGTERM]
if hasattr(signal, "SIGHUP"):
signals.append(signal.SIGHUP)
for sig in signals:
# A handler that does nothing, so that the default action, stopping the
# process where it stands, is replaced by the wakeup above.
signal.signal(sig, lambda *_: None)
@@ -1128,6 +1157,7 @@ def run_app(args):
QLocalServer.removeServer(SERVER_NAME)
if not server.listen(SERVER_NAME):
print(f"dikte: could not open the IPC socket: {server.errorString()}")
dikte.server = server
def on_connection():
conn = server.nextPendingConnection()
+1
View File
@@ -378,6 +378,7 @@ def _stream(cmd, conf, on_event, should_stop):
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", bufsize=1,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
raise AssistantError(t("Could not run {binary}: {error}",
+174 -8
View File
@@ -33,6 +33,10 @@ from PyQt6.QtCore import QObject, pyqtSignal
from .i18n import t
# Console programs started from a windowless process would otherwise each open
# a console window of their own on Windows.
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
RATE = 16000
CHANNELS = 1
SAMPLE_WIDTH = 2 # s16
@@ -57,6 +61,19 @@ QUIET_MIC_SECONDS = 10
QUIET_MIC_SHARE = 0.5
def _interrupt(proc):
"""Ask a recorder process to end.
SIGINT is the polite way everywhere it exists; Windows has no equivalent a
child can be sent, so the process is terminated outright. The captured
audio is not lost either way: it has already been read from the pipe.
"""
if sys.platform == "win32":
proc.terminate()
else:
proc.send_signal(signal.SIGINT)
class Recorder(QObject):
"""Runs the available sound-server recorder and reads raw PCM from stdout."""
@@ -111,7 +128,8 @@ class Recorder(QObject):
try:
self._proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
creationflags=NO_WINDOW,
)
except OSError as exc:
self.failed.emit(t("Could not start recording: {error}", error=exc))
@@ -172,7 +190,7 @@ class Recorder(QObject):
proc = self._proc
if proc and proc.poll() is None:
try:
proc.send_signal(signal.SIGINT)
_interrupt(proc)
proc.wait(timeout=1.5)
except (subprocess.TimeoutExpired, OSError):
try:
@@ -284,6 +302,14 @@ class MeetingRecorder(QObject):
def start(self, path, mic_target="", system_target="", max_seconds=14400):
if self.active:
return
# Before ffmpeg is looked for, because installing it would not help: a
# system with no way to capture what the speakers are playing has none
# whatever else is on the machine.
if not sound().meetings:
self.failed.emit(t("This system offers nothing that records what "
"the speakers are playing, so a meeting cannot "
"be recorded on it."))
return
if not shutil.which("ffmpeg"):
self.failed.emit(t("ffmpeg not found. Install it to record a meeting."))
return
@@ -312,7 +338,8 @@ class MeetingRecorder(QObject):
self._procs = []
for command, log in zip(commands, self._logs):
self._procs.append(subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=log, bufsize=0
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
creationflags=NO_WINDOW,
))
except (OSError, wave.Error) as exc:
# One of two capture processes may already be running, and a Mac
@@ -411,7 +438,7 @@ class MeetingRecorder(QObject):
running = [proc for proc in self._procs if proc.poll() is None]
for proc in running:
try:
proc.send_signal(signal.SIGINT)
_interrupt(proc)
except OSError:
pass
for proc in running:
@@ -838,13 +865,131 @@ def _avfoundation_default_output():
return ""
# Windows records through DirectShow, the one capture API ffmpeg's Windows
# builds all ship with. What the speakers are playing is not offered as a
# device at all, so a meeting has nothing to record the far side from yet.
# A device entry and the line under it, in the two shapes ffmpeg has printed
# this listing in. Newer builds mark each device `(audio)` or `(video)`; older
# ones print no marker and group the devices under a heading instead. Both are
# anchored at each end, so that the error lines the command ends with, which
# quote the device name that was not found, are not read as devices. The
# bracketed prefix is not pinned to a spelling: ffmpeg 8 writes `[in#0 @ ...]`
# where the versions before it wrote `[dshow @ ...]`.
_DSHOW_ENTRY = re.compile(
r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$')
_DSHOW_ALTERNATIVE = re.compile(
r'^(?:\[[^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$')
_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices')
# The last listing taken, so that a dictation does not pay for one of its own.
_DSHOW_SEEN = []
def _parse_dshow_listing(text):
"""[(id, name)] for the audio devices in one ffmpeg device listing.
Two friendly names on one machine are routinely identical: a laptop with a
headset plugged in shows two microphones called the same thing, and
`audio=<name>` would reach only the first of them either way. The
alternative name ffmpeg prints under each device is unique and is what the
recorder is given back, while the friendly name is what a user picks from.
"""
devices = []
heading = ""
for line in text.splitlines():
found = _DSHOW_HEADING.search(line)
if found:
heading = found.group(1)
continue
found = _DSHOW_ALTERNATIVE.match(line.strip())
if found:
if devices:
devices[-1][0] = found.group(1)
continue
found = _DSHOW_ENTRY.match(line.strip())
if found:
kind = (found.group(2) or heading).lower()
devices.append([found.group(1), found.group(1), kind])
return [(identifier, name) for identifier, name, kind in devices
if "audio" in kind]
def _dshow_devices():
"""[(id, name)] for every DirectShow audio capture device, freshly asked.
The list comes out on stderr of a command that then fails, the same
documented trick AVFoundation uses above.
"""
if not shutil.which("ffmpeg"):
return []
try:
result = subprocess.run(
["ffmpeg", "-hide_banner", "-list_devices", "true",
"-f", "dshow", "-i", "dummy"],
capture_output=True, timeout=8, check=False, creationflags=NO_WINDOW,
)
except (subprocess.SubprocessError, OSError):
return []
devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace"))
_DSHOW_SEEN[:] = devices
return devices
def _dshow_first_device():
"""The device an unset target stands for, without a listing per dictation.
dshow has no "default" for an empty target to mean, so it has to be turned
into a name, and asking ffmpeg for one costs a process every time the key
is pressed. The last listing is used when there is one: opening Settings or
running `dikte devices` takes a fresh one, which is what somebody who has
just plugged a microphone in does anyway.
"""
devices = _DSHOW_SEEN or _dshow_devices()
return devices[0][0] if devices else ""
def _dshow_record(target):
if not shutil.which("ffmpeg"):
return []
device = target or _dshow_first_device()
if not device:
return []
return [
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
# dshow holds half a second of audio before handing anything over;
# asked for the chunk the level meter is measured in instead.
"-f", "dshow", "-audio_buffer_size", str(CHUNK_LATENCY_MS),
"-i", f"audio={device}",
"-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-",
]
def _dshow_meeting(mic_target, system_target):
return [] # no monitor devices to record the far side from
def _dshow_no_outputs():
return []
def _dshow_no_default_output():
return ""
Sound = collections.namedtuple(
"Sound",
# How to capture one source and how to capture two at once, that one as the
# list of processes it takes, 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",
# records the far side from, whether this system can record one at all, and
# what to say when the programs for any of it are not installed.
#
# `meetings` is the sound system's own answer, not this machine's: an empty
# output list means the tool that lists them is missing, which is a thing a
# user can go and fix, while False here is a thing they cannot.
"record meeting inputs outputs default_output meetings missing",
)
PULSE = Sound(
@@ -853,6 +998,7 @@ PULSE = Sound(
inputs=_pulse_inputs,
outputs=_pulse_outputs,
default_output=_pulse_default_output,
meetings=True,
missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.",
)
@@ -865,13 +1011,33 @@ COREAUDIO = Sound(
# empty list would leave nothing to pick.
outputs=_avfoundation_named_inputs,
default_output=_avfoundation_default_output,
# With a loopback driver installed, which is what the Settings note is for.
meetings=True,
missing="ffmpeg not found. Install it with: brew install ffmpeg",
)
DSHOW = Sound(
record=_dshow_record,
meeting=_dshow_meeting,
inputs=_dshow_devices,
outputs=_dshow_no_outputs,
default_output=_dshow_no_default_output,
# Windows offers no capture device for what the speakers are playing, and
# there is no driver to install that would add one.
meetings=False,
missing="ffmpeg or a microphone was not found. Install ffmpeg with: "
"winget install Gyan.FFmpeg",
)
def sound():
"""The programs this machine records through."""
return COREAUDIO if sys.platform == "darwin" else PULSE
if sys.platform == "darwin":
return COREAUDIO
if sys.platform == "win32":
return DSHOW
return PULSE
def list_sources():
+1
View File
@@ -199,6 +199,7 @@ def _output(cmd, timeout, service):
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except subprocess.TimeoutExpired:
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
+30 -2
View File
@@ -17,6 +17,7 @@ import json
import os
import shutil
import signal
import subprocess
import sys
import time
@@ -136,6 +137,16 @@ def launch_gui(verb=""):
if verb:
args.append(verb)
args.append("--gui")
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and would leave the
# application tied to this console; start it detached instead.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
sys.exit(0)
os.execv(args[0], args)
@@ -299,6 +310,9 @@ def cmd_transcribe(opts):
return fail(opts, f"no such file: {path}")
conf = cfg.Config()
# This runs here rather than in the instance, so the local servers have to
# be handed their settings here too; the GUI does this at startup.
conf.apply_local()
timestamps = opts.srt or _pick(opts.timestamps, conf["file_timestamps"])
worker = filetranscribe.FileTranscriber(conf)
@@ -643,7 +657,10 @@ def cmd_devices(opts):
"default": name == default}
for name, desc in audio.list_monitors()]
if not mics and not monitors:
return fail(opts, "pactl found nothing; is PipeWire running?")
# Which program was asked, and so which one to go and look at, is not
# the same on all four systems: naming pactl on Windows sends somebody
# after a program that was never going to be there.
return fail(opts, audio.sound().missing)
lines = ["Microphones:"]
lines += [f" {'*' if item['chosen'] else ' '} {item['name']}\n"
@@ -802,7 +819,18 @@ def cmd_status(opts):
def cmd_doctor(opts):
"""What the settings window checks behind its buttons, in one pass."""
conf = cfg.Config()
wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
# The two the clipboard and the key press go through come out of the table
# rather than being spelled here, because they are not the same pair on all
# four systems: X11 pastes with xclip where Wayland pastes with wl-copy, a
# Mac shells out for one half and Windows for neither. A row saying ydotool
# is missing on a machine that would never have run it is not a diagnosis,
# it is a red mark to explain away.
here = paste.desktop()
wanted = [here.clipboard, here.keyboard]
if sys.platform.startswith("linux"):
# Recording, the device list, and KDE's shortcut registry.
wanted += ["pw-record", "pactl", "kwriteconfig6"]
wanted += ["ffmpeg",
assistant.executable(assistant.provider(conf)) or "claude",
cleanup.executable(cleanup.provider(conf))]
programs = {name: shutil.which(name) or "" for name in wanted if name}
+1
View File
@@ -283,6 +283,7 @@ def _ffmpeg(args, out, aborter=None):
["ffmpeg", "-nostdin", "-y", *args],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
# doing nothing unless the abort reaches the process itself.
+72 -11
View File
@@ -43,6 +43,7 @@ import threading
import time
import urllib.error
import urllib.request
import zipfile
from . import hub
from . import paths
@@ -154,11 +155,14 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
try:
with urllib.request.urlopen(request, timeout=60) as response:
total = int(response.headers.get("Content-Length") or item.size or 0)
# Windows refuses to delete a file that is open, so nothing is
# unlinked until the handle is closed again.
stopped = overlong = False
with open(part, "wb") as out:
while True:
if should_stop is not None and should_stop():
part.unlink(missing_ok=True)
return False
stopped = True
break
block = response.read(DOWNLOAD_CHUNK)
if not block:
break
@@ -168,11 +172,17 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
# More than was announced: a body that does not end is the
# one way this loop could run until the disk is full.
if total and done > total:
overlong = True
break
if on_progress is not None:
on_progress(done, total)
if stopped:
part.unlink(missing_ok=True)
return False
if overlong:
part.unlink(missing_ok=True)
raise LocalError(t("{name} is longer than it said it "
"would be.", name=item.name))
if on_progress is not None:
on_progress(done, total)
# A proxy notice or an error page that came back as 200 would otherwise
# be renamed into place and only fail when something tries to read it.
if total and done != total:
@@ -218,9 +228,11 @@ def _has_vulkan():
llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics
card gets here. The build without it is smaller and runs on the CPU, and
fetching the Vulkan one for a machine that cannot load it would only make
the download bigger.
the download bigger. Windows spells the loader vulkan-1.dll.
"""
return bool(ctypes.util.find_library("vulkan"))
return bool(ctypes.util.find_library("vulkan")
or (sys.platform == "win32"
and ctypes.util.find_library("vulkan-1")))
def _wanted_assets(program):
@@ -233,6 +245,20 @@ def _wanted_assets(program):
arch = _arch()
if sys.platform == "darwin":
return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",)
if sys.platform == "win32":
if program is WHISPER:
# The BLAS build first: on a plain CPU it transcribes about twice
# as fast as the stock one, and it carries everything it needs.
# Full names, because "bin-x64.zip" alone would also match the
# CUDA archives, whichever the release happened to list first.
#
# x64 whatever this machine is, because whisper.cpp publishes no
# arm64 build for Windows: a Snapdragon runs this one emulated,
# which is slow but is the only local option there is.
return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")
if _has_vulkan() and arch == "x64":
return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip")
return (f"bin-win-cpu-{arch}.zip",)
if program is LLAMA and _has_vulkan():
return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz")
return (f"bin-ubuntu-{arch}.tar.gz",)
@@ -278,6 +304,11 @@ def system_program(program):
return bool(shutil.which(program.binary))
def _binary_file(program):
"""What the program's file is called on disk here."""
return f"{program.binary}.exe" if sys.platform == "win32" else program.binary
def _find_binary(root, name):
for path in sorted(pathlib.Path(root).rglob(name)):
if path.is_file():
@@ -286,19 +317,24 @@ def _find_binary(root, name):
def _extract(archive, into):
"""Unpack a release tarball, refusing anything that reaches outside `into`.
"""Unpack a release archive, refusing anything that reaches outside `into`.
The archives lay their libraries next to their binaries and are linked with
an $ORIGIN runpath, so a whole directory is what has to survive the trip and
the binary cannot be lifted out of it.
the binary cannot be lifted out of it. Linux and macOS releases come as
tarballs, Windows ones as zips; zipfile never writes outside its target.
"""
try:
if str(archive).endswith(".zip"):
with zipfile.ZipFile(archive) as bundle:
bundle.extractall(into)
return
with tarfile.open(archive, "r:gz") as tar:
try:
tar.extractall(into, filter="data")
except TypeError: # Python without the extraction filters
tar.extractall(into)
except (tarfile.TarError, OSError) as exc:
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
raise LocalError(t("Could not unpack {name}: {error}",
name=os.path.basename(str(archive)), error=exc)) from exc
@@ -344,7 +380,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
if not download(item, archive, on_progress, should_stop):
return ""
_extract(archive, into)
binary = _find_binary(into, program.binary)
binary = _find_binary(into, _binary_file(program))
if binary is None:
raise LocalError(t("{name} was not in the download.",
name=program.binary))
@@ -501,6 +537,26 @@ def _tail(path, lines=3):
return " | ".join(found[-lines:])
def _win_image_name(pid):
"""The lower-cased file name of the process's executable, or ''."""
import ctypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return ""
try:
buffer = ctypes.create_unicode_buffer(260)
size = ctypes.c_uint32(len(buffer))
ok = kernel32.QueryFullProcessImageNameW(
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
return os.path.basename(buffer.value).lower() if ok else ""
finally:
kernel32.CloseHandle(handle)
class Server:
"""One process, started when something needs it and stopped when nothing does.
@@ -602,6 +658,8 @@ class Server:
args + ["--host", HOST, "--port", str(port)],
stdout=sink, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
# No console window of its own on Windows.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
raise LocalError(t("Could not start {name}: {error}",
@@ -699,8 +757,11 @@ class Server:
number could belong to something else entirely, and killing it would be
a good deal worse than the leak being cleaned up. The program name alone
could be somebody else's copy; the name together with Dikte's own data
directory on the command line could not.
directory on the command line could not. Windows offers no command line
to read, so the executable's name is the whole of the answer there.
"""
if sys.platform == "win32":
return _win_image_name(pid) == _binary_file(self.program).lower()
try:
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
except OSError:
+189 -13
View File
@@ -440,15 +440,175 @@ def _carbon():
return carbon
# --- Windows: RegisterHotKey ------------------------------------------------
# Windows virtual-key codes: where a key sits, not what a layout prints on it.
WIN_KEYS = {
"space": 0x20, "tab": 0x09, "enter": 0x0D, "return": 0x0D,
"esc": 0x1B, "escape": 0x1B, "backspace": 0x08, "insert": 0x2D,
"delete": 0x2E, "home": 0x24, "end": 0x23, "pgup": 0x21, "pgdown": 0x22,
"up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
**{str(digit): 0x30 + digit for digit in range(10)},
**{chr(ord("a") + i): 0x41 + i for i in range(26)},
**{f"f{n}": 0x6F + n for n in range(1, 13)},
}
WIN_MODS = {
"alt": 0x0001, "ctrl": 0x0002, "control": 0x0002, "shift": 0x0004,
"meta": 0x0008, "super": 0x0008, "win": 0x0008,
}
WIN_MOD_NOREPEAT = 0x4000 # holding the combination fires it once
WM_HOTKEY = 0x0312
WM_QUIT = 0x0012
def _win_input():
"""user32 and kernel32, which is all the listener talks to.
Loaded on the first start rather than at import: this module is read on
every system, and these two libraries exist on one of them.
"""
return ctypes.windll.user32, ctypes.windll.kernel32
def parse_windows_shortcut(text):
"""'Ctrl+Space' -> (2, 32), 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 WIN_MODS:
modifiers |= WIN_MODS[part]
elif key is None and part in WIN_KEYS:
key = WIN_KEYS[part]
else:
return None, None
if key is None:
return None, None
return modifiers, key
class WinHotkey(QObject):
"""Catches global shortcuts through Windows' own hotkey service.
RegisterHotKey asks for one combination rather than reading the keyboard,
so it needs no permission at all. Like Carbon's and unlike the evdev
listener it swallows the key: while Dikte holds a combination, nothing
else on the machine receives it.
RegisterHotKey only fires on the thread that called it, so registration
and the message loop live together on one worker thread; start() hands the
bindings over and waits for it to report what Windows actually gave us.
"""
triggered = pyqtSignal(str) # the name the binding was registered under
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._user32 = None
self._kernel32 = None
self._thread = None
self._thread_id = None
self._count = 0
@property
def running(self):
return self._count > 0 and self._thread is not None and self._thread.is_alive()
def start(self, bindings):
"""`bindings` is {name: 'Ctrl+Space'}; an empty combination is skipped."""
self.stop()
try:
self._user32, self._kernel32 = _win_input()
except (AttributeError, OSError) as exc:
self.failed.emit(t("Could not reach the Windows shortcut service: "
"{error}", error=exc))
return False
wanted = []
for identifier, (name, shortcut) in enumerate(bindings.items(), 1):
if not shortcut:
continue
modifiers, key = parse_windows_shortcut(shortcut)
if key is None:
self.failed.emit(
t("Could not parse the shortcut: {shortcut}", shortcut=shortcut)
)
continue
wanted.append((identifier, name, shortcut, modifiers, key))
if not wanted:
return False
ready = threading.Event()
outcome = {"count": 0, "thread_id": None}
self._thread = threading.Thread(
target=self._loop, args=(wanted, ready, outcome), daemon=True
)
self._thread.start()
ready.wait(timeout=5)
self._thread_id = outcome["thread_id"]
self._count = outcome["count"]
if not self._count:
self._thread = None
return self._count > 0
def stop(self):
if self._thread and self._thread_id and self._user32:
self._user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0)
self._thread.join(timeout=1.5)
self._thread = None
self._thread_id = None
self._count = 0
_REGISTERED.clear()
def _loop(self, wanted, ready, outcome):
import ctypes.wintypes
user32, kernel32 = self._user32, self._kernel32
outcome["thread_id"] = kernel32.GetCurrentThreadId()
# The message queue a PostThreadMessage needs only exists once the
# thread has asked for messages; peek once before reporting ready.
message = ctypes.wintypes.MSG()
user32.PeekMessageW(ctypes.byref(message), None, WM_QUIT, WM_QUIT, 0)
names = {}
for identifier, name, shortcut, modifiers, key in wanted:
if user32.RegisterHotKey(None, identifier,
modifiers | WIN_MOD_NOREPEAT, key):
names[identifier] = name
spec = SHORTCUTS.get(name)
if spec:
_REGISTERED[spec.desktop_id] = shortcut
else:
# This is the conflict warning on Windows: there is no list to
# read beforehand, the answer comes from asking for the key.
self.failed.emit(t(
"Windows would not give Dikte {shortcut}; another "
"application already holds it.", shortcut=shortcut))
outcome["count"] = len(names)
ready.set()
if not names:
return
try:
while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0:
if message.message == WM_HOTKEY:
name = names.get(int(message.wParam))
if name:
self.triggered.emit(name)
finally:
for identifier in names:
user32.UnregisterHotKey(None, identifier)
# --- the desktop's own shortcut -------------------------------------------
# The four ways a combination can reach Dikte. Everything below asks backend()
# The five ways a combination can reach Dikte. Everything below asks backend()
# rather than looking at the session itself, so the name shown, the status read
# back, what Install writes and what the installer promises cannot disagree
# about which one this session got.
KDE = "kde"
GNOME = "gnome"
MACOS = "macos"
WINDOWS = "windows"
LISTENER = "listener"
@@ -456,6 +616,10 @@ def _macos():
return sys.platform == "darwin"
def _windows():
return sys.platform == "win32"
def backend():
"""Which shortcut mechanism this session has.
@@ -467,6 +631,8 @@ def backend():
"""
if _macos():
return MACOS
if _windows():
return WINDOWS
names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":")
names = [name.strip() for name in names if name.strip()]
if any("gnome" in name for name in names) and shutil.which("gsettings"):
@@ -596,7 +762,11 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID):
def listener(parent=None):
"""The thing that hears the key, for whichever system this is."""
return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent)
if _macos():
return CarbonHotkey(parent)
if _windows():
return WinHotkey(parent)
return EvdevHotkey(parent)
def default_combo(which):
@@ -613,16 +783,20 @@ def default_combo(which):
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
if _macos():
return parse_macos_shortcut(text)[1] is not None
if _windows():
return parse_windows_shortcut(text)[1] is not None
return parse_shortcut(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 and the plain listener do not: there is
nothing to install, nothing to remove, and Settings should not offer either.
survives Dikte being closed. macOS, Windows and the plain listener do not:
there is nothing to install, nothing to remove, and Settings should not
offer either.
"""
return backend() in (KDE, GNOME)
@@ -631,7 +805,7 @@ 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 the other two never had one to write.
up as it is written, and the others never had one to write.
"""
return backend() == KDE
@@ -644,7 +818,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
if which == KDE:
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
_REGISTERED[desktop_id] = shortcut
if which == MACOS:
if which in (MACOS, WINDOWS):
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.",
@@ -686,6 +860,8 @@ def desktop_name():
which = backend()
if which == MACOS:
return "macOS"
if which == WINDOWS:
return "Windows"
if which == GNOME:
return "GNOME"
if which == KDE:
@@ -776,11 +952,11 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination."""
if backend() != KDE:
# Nowhere else has a list to read. macOS answers the question by
# refusing the registration, which CarbonHotkey reports when it asks
# for the key; the other two would only be reading a file their session
# never looks at, and a leftover one from a Plasma install the user has
# since left would refuse perfectly good combinations.
# Nowhere else has a list to read. macOS and Windows answer the question
# by refusing the registration, which their listeners report when they
# ask for the key; the other two would only be reading a file their
# session never looks at, and a leftover one from a Plasma install the
# user has since left would refuse perfectly good combinations.
return []
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
+25
View File
@@ -109,6 +109,10 @@ TR = {
"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",
"ffmpeg or a microphone was not found. Install ffmpeg with: "
"winget install Gyan.FFmpeg":
"ffmpeg ya da bir mikrofon bulunamadı. ffmpeg'i şununla kur: "
"winget install Gyan.FFmpeg",
"Audio recorder stopped before receiving sound: {error}":
"Ses kayıt aracı veri alamadan kapandı: {error}",
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
@@ -353,6 +357,11 @@ TR = {
"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.",
"Dikte asks Windows 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 Windows'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"{desktop} keeps no shortcut registry, so Dikte listens for these "
"combinations itself while it is running. Your user has to be able to read "
"/dev/input for that, and the focused application receives the keys as "
@@ -392,6 +401,12 @@ TR = {
"macOS would not give Dikte {shortcut}; another application already holds it.":
"macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
"onu şimdiden tutuyor.",
"Could not reach the Windows shortcut service: {error}":
"Windows kısayol servisine ulaşılamadı: {error}",
"Windows would not give Dikte {shortcut}; another application already "
"holds it.":
"Windows {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
"onu şimdiden tutuyor.",
"Cannot read /dev/input. Your user needs to be in the 'input' group:\n"
" sudo usermod -aG input $USER (then log out and back in)":
"/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n"
@@ -643,6 +658,16 @@ TR = {
"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ç.",
"This system offers nothing that records what the speakers are playing, "
"so a meeting cannot be recorded on it. Dictation and transcribing a file "
"are unaffected.":
"Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; "
"burada toplantı kaydedilemez. Dikte ve dosya deşifresi bundan "
"etkilenmez.",
"This system offers nothing that records what the speakers are playing, "
"so a meeting cannot be recorded on it.":
"Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; "
"burada toplantı kaydedilemez.",
"Wear headphones if you can. Through speakers your microphone hears the "
"other side as well, and although a line that lands on both channels at "
"once is dropped again, the repair is never as clean as not needing it.":
+3 -1
View File
@@ -15,7 +15,9 @@ import sys
from PyQt6.QtNetwork import QLocalSocket
SERVER_NAME = "dikte-" + str(os.getuid())
SERVER_NAME = "dikte-" + (
str(os.getuid()) if hasattr(os, "getuid")
else os.environ.get("USERNAME", "user"))
# Long enough for a process that is already running to answer, short enough that
# "nothing is running" is not a noticeable pause in front of a key press.
+3 -2
View File
@@ -68,9 +68,10 @@ class Overlay(QWidget):
| Qt.WindowType.Tool
| Qt.WindowType.WindowDoesNotAcceptFocus
)
if sys.platform != "darwin":
if sys.platform not in ("darwin", "win32"):
# It is the window manager that would otherwise move this out of
# the corner. macOS has no such hint, and Qt warns about it.
# the corner. macOS has no such hint, and Qt warns about it;
# Windows places tool windows where they ask to be anyway.
flags |= Qt.WindowType.X11BypassWindowManagerHint
# One that can be clicked away has to receive the click, which means it
# also swallows one aimed at whatever is underneath it. The rest stay
+188 -1
View File
@@ -330,6 +330,166 @@ def _macos_press(shortcut, delay):
core.CFRelease(up)
def _win_keys(shortcut):
"""'Ctrl+V' -> [0x11, 0x56]: Windows virtual-key codes, modifiers first."""
codes = []
for key in _keys(shortcut):
if key not in WIN_KEYCODES:
raise PasteError(t("Unknown key: {key}", key=key))
codes.append(WIN_KEYCODES[key])
return codes
# Windows virtual-key codes (winuser.h). Like Apple's, they say where the key
# sits rather than what a layout prints on it.
WIN_KEYCODES = {
"ctrl": 0x11, "control": 0x11, "shift": 0x10, "alt": 0x12,
"super": 0x5B, "meta": 0x5B,
"v": 0x56, "insert": 0x2D, "enter": 0x0D, "return": 0x0D,
}
_WIN_KEYUP = 0x0002 # KEYEVENTF_KEYUP
_WIN_CF_UNICODETEXT = 13 # what the clipboard calls UTF-16 text
_WIN_GMEM_MOVEABLE = 0x0002
@functools.lru_cache(maxsize=1)
def _win_api():
"""user32 and kernel32 with their prototypes spelled out.
The default return type is a 32-bit int, which silently truncates the
64-bit handles and pointers every one of these calls trades in.
"""
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.OpenClipboard.argtypes = [ctypes.c_void_p]
user32.GetClipboardData.restype = ctypes.c_void_p
user32.GetClipboardData.argtypes = [ctypes.c_uint]
user32.SetClipboardData.restype = ctypes.c_void_p
user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
kernel32.GlobalAlloc.restype = ctypes.c_void_p
kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
return user32, kernel32
def _win_error():
"""GetLastError where it exists, so the failure paths run under any test."""
return getattr(ctypes, "get_last_error", lambda: 0)()
def _win_open_clipboard(user32):
"""The clipboard is a lock another program may hold for a moment."""
for _ in range(10):
if user32.OpenClipboard(None):
return True
time.sleep(0.01)
return False
def _win_read_text():
"""The clipboard's text, '' when it holds none, None when it cannot be read."""
user32, kernel32 = _win_api()
if not _win_open_clipboard(user32):
return None
try:
handle = user32.GetClipboardData(_WIN_CF_UNICODETEXT)
if not handle:
return ""
pointer = kernel32.GlobalLock(handle)
if not pointer:
return None
try:
return ctypes.wstring_at(pointer)
finally:
kernel32.GlobalUnlock(handle)
finally:
user32.CloseClipboard()
def _win_write_text(text):
user32, kernel32 = _win_api()
payload = str(text).encode("utf-16-le") + b"\x00\x00"
# Filled before the clipboard is opened at all. EmptyClipboard is what
# throws away whatever was there, and a failure after it and before the
# SetClipboardData would leave the clipboard holding nothing: the one way
# this function could lose what it was called to put back.
handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload))
pointer = kernel32.GlobalLock(handle) if handle else None
if not pointer:
if handle:
kernel32.GlobalFree(handle)
raise PasteError(t("Could not copy to clipboard: {error}",
error="out of memory"))
ctypes.memmove(pointer, payload, len(payload))
kernel32.GlobalUnlock(handle)
if not _win_open_clipboard(user32):
kernel32.GlobalFree(handle)
raise PasteError(t("Could not copy to clipboard: {error}",
error="the clipboard is held by another program"))
try:
user32.EmptyClipboard()
if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle):
raise PasteError(t("Could not copy to clipboard: {error}",
error=f"error {_win_error()}"))
handle = None # the clipboard owns it now
finally:
if handle:
kernel32.GlobalFree(handle)
user32.CloseClipboard()
class _WinKeybdInput(ctypes.Structure):
_fields_ = [("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort),
("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong),
("dwExtraInfo", ctypes.c_size_t)]
class _WinMouseInput(ctypes.Structure):
_fields_ = [("dx", ctypes.c_long), ("dy", ctypes.c_long),
("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong),
("time", ctypes.c_ulong), ("dwExtraInfo", ctypes.c_size_t)]
class _WinInputUnion(ctypes.Union):
_fields_ = [("mi", _WinMouseInput), ("ki", _WinKeybdInput)]
class _WinInput(ctypes.Structure):
# The union carries the mouse shape too: SendInput sizes its argument by
# the biggest member whether or not it is the one being sent.
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
def _win_press(shortcut, delay):
"""Post the presses and releases straight into the input queue.
No permission stands in front of SendInput the way Accessibility does on
macOS: whatever window has focus receives the combination.
"""
codes = _win_keys(shortcut)
user32, _ = _win_api()
time.sleep(delay) # let the selection settle and focus come back
events = ([(code, 0) for code in codes]
+ [(code, _WIN_KEYUP) for code in reversed(codes)])
inputs = (_WinInput * len(events))()
for entry, (code, flags) in zip(inputs, events):
entry.type = 1 # INPUT_KEYBOARD
entry.union.ki = _WinKeybdInput(code, 0, flags, 0, 0)
sent = user32.SendInput(len(inputs), inputs, ctypes.sizeof(_WinInput))
if sent != len(inputs):
raise PasteError(t("Could not run {tool}: {error}", tool="SendInput",
error=f"error {_win_error()}"))
def _win_ready():
return True
# --- which of them is here -------------------------------------------------
Desktop = collections.namedtuple(
@@ -362,6 +522,17 @@ X11 = Desktop(
**_program_keyboard("xdotool", _xdotool_command),
)
WINDOWS = Desktop(
clipboard="", # no program: both directions are calls into the system
packages="",
read_command=[],
copy_command=[],
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
keyboard="",
ready=_win_ready,
press=_win_press,
)
MACOS = Desktop(
clipboard="pbcopy",
packages="", # both are part of macOS; there is nothing to install
@@ -383,6 +554,8 @@ def desktop():
"""
if sys.platform == "darwin":
return MACOS
if sys.platform == "win32":
return WINDOWS
if os.environ.get("XDG_SESSION_TYPE") == "x11":
return X11
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
@@ -427,6 +600,9 @@ def _macos_restore(snapshot):
def read_clipboard():
here = desktop()
if here is WINDOWS:
text = _win_read_text()
return None if text is None else text.encode("utf-8")
if here is MACOS and shutil.which("osascript"):
snapshot = _macos_snapshot()
if snapshot is not None:
@@ -454,6 +630,9 @@ def _run_copy(payload):
def copy(text):
here = desktop()
if here is WINDOWS:
_win_write_text(text)
return
if not shutil.which(here.clipboard):
raise PasteError(
t("{tool} not found. Install {packages}.",
@@ -473,7 +652,15 @@ 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:
return
if desktop() is WINDOWS:
try:
_win_write_text(data.decode("utf-8", "replace"))
except PasteError:
pass
return
if not shutil.which(desktop().clipboard):
return
try:
_run_copy(data)
+13 -5
View File
@@ -16,7 +16,8 @@ import pathlib
import sys
def _xdg(var, default):
def _env(var, default):
"""The directory a variable names, or the one it stands in for."""
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
@@ -24,13 +25,20 @@ 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.
about. Windows keeps them apart on purpose: settings roam with the account,
and several gigabytes of models are exactly what a roaming profile must not
carry. Everywhere else they are separate and follow the XDG variables.
"""
if (platform or sys.platform) == "darwin":
here = platform or sys.platform
if here == "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")
if here == "win32":
roaming = _env("APPDATA", "~/AppData/Roaming")
local = _env("LOCALAPPDATA", "~/AppData/Local")
return roaming / "Dikte", local / "Dikte"
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
CONFIG_DIR, DATA_DIR = directories()
+18
View File
@@ -1100,6 +1100,18 @@ class SettingsWindow(QDialog):
))
mac_note.setWordWrap(True)
sources_form.addRow(mac_note)
elif not audio.sound().meetings:
# Windows is the system this is written for: it offers nothing that
# captures what the speakers are playing, and there is no driver to
# install that would put an entry in the list above. Left unsaid,
# the box is simply empty and the Record button fails at the press.
nothing_note = QLabel(t(
"This system offers nothing that records what the speakers are "
"playing, so a meeting cannot be recorded on it. Dictation and "
"transcribing a file are unaffected."
))
nothing_note.setWordWrap(True)
sources_form.addRow(nothing_note)
note = QLabel(t(
"Wear headphones if you can. Through speakers your microphone hears "
@@ -1369,6 +1381,12 @@ class SettingsWindow(QDialog):
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
elif hotkey.backend() == hotkey.WINDOWS:
explanation = t(
"Dikte asks Windows for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
else:
# The desktops nobody writes a backend for. Saying "installed" here
# would be the old bug in words: there is no registry, the listener
+82
View File
@@ -0,0 +1,82 @@
# Installs Dikte for this Windows user: a Start Menu entry, an optional
# autostart entry, and a `dikte` command that works from any terminal.
#
# powershell -ExecutionPolicy Bypass -File install.ps1 # install
# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + start at sign-in
# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # remove
param(
[switch]$Autostart,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$repo = $PSScriptRoot
# The one file that starts the application, whoever is asking: the Start Menu
# entry, the autostart entry and the dikte command all name it.
$entry = Join-Path $repo "dikte\__main__.py"
$startMenu = [Environment]::GetFolderPath("Programs")
$startup = [Environment]::GetFolderPath("Startup")
$shortcut = Join-Path $startMenu "Dikte.lnk"
$autostartLink = Join-Path $startup "Dikte.lnk"
# WindowsApps is already on the user PATH, so a dikte.cmd left there runs from
# any terminal without a PATH edit and without an administrator.
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
if ($Uninstall) {
foreach ($path in @($shortcut, $autostartLink, $cmdShim)) {
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
}
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
exit 0
}
# --- what it needs ----------------------------------------------------------
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) {
Write-Error "No python found. Install it with: winget install Python.Python.3.12"
}
$version = & python -c "import sys; print('%d.%d' % sys.version_info[:2])"
if ([version]$version -lt [version]"3.11") {
Write-Error "Python 3.11 or newer is needed, and this one is $version."
}
& python -c "import PyQt6.QtWidgets" 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "Installing PyQt6..."
& python -m pip install PyQt6
if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 would not install." }
}
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Warning "No ffmpeg found. Recording needs it: winget install Gyan.FFmpeg"
}
# pythonw.exe runs the same program without a console window behind it.
$pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe"
if (-not (Test-Path $pythonw)) { $pythonw = $python.Source }
# --- the Start Menu entry ---------------------------------------------------
$shell = New-Object -ComObject WScript.Shell
foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) {
$link = $shell.CreateShortcut($path)
$link.TargetPath = $pythonw
$link.Arguments = "`"$entry`" --gui"
$link.WorkingDirectory = $repo
$link.Description = "Dikte: dictation"
$link.Save()
Write-Host "shortcut: $path"
}
# --- the dikte command ------------------------------------------------------
# The interpreter by its full path rather than by name: the one checked above is
# the one the command line should run, whatever a later PATH change puts first.
$shimDir = Split-Path $cmdShim
if (Test-Path $shimDir) {
"@echo off`r`n`"$($python.Source)`" `"$entry`" %*" |
Out-File $cmdShim -Encoding ascii
Write-Host "command: dikte ($cmdShim)"
} else {
Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$entry`""
}
Write-Host ""
Write-Host "Installed. Start it from the Start Menu as 'Dikte', or type 'dikte' in a terminal."
Write-Host "The Settings window opens on the first run: download a model there and pick the shortcut (Ctrl+Space by default)."
+12
View File
@@ -40,6 +40,18 @@ linux_only = unittest.skipUnless(
"covers the Linux desktop stack (PipeWire, wl-clipboard, ydotool, KDE)",
)
# The launchers a downloaded build writes for itself. There are two downloads,
# an AppImage and a disk image, so `integrate` has a Linux half and a macOS half
# and no third one, and the tests that pin them stand in a home laid out the way
# those two systems lay one out: paths that start at the root, a $HOME the
# library reads, a symlink for the command. None of that is a Windows machine,
# where the same code never runs. A Windows build would add an entry there and
# take the mark off these.
posix_only = unittest.skipIf(
sys.platform == "win32",
"covers what an AppImage and a .app write into the desktop they landed on",
)
def _no_network(*args, **kwargs):
raise AssertionError(
+196
View File
@@ -887,5 +887,201 @@ class MacRecordingCommand(OnMacOS, DikteTest):
self.assertFalse(recorder.active)
class NoFarSideToRecord(DikteTest):
"""Two different answers, and the table is what tells them apart.
A sound system that records the far side has a device this machine could
not pick out, and Settings is where to choose one. A sound system that does
not had nothing to offer there in the first place, and "pick one" would
send somebody to an empty box and an installation that cannot help.
"""
def failure(self, meetings):
recorder = audio.MeetingRecorder()
failures = []
recorder.failed.connect(failures.append)
with only_these_tools("ffmpeg"), \
mock.patch.object(audio, "default_monitor", return_value=""), \
mock.patch.object(audio, "sound",
return_value=audio.PULSE._replace(
meetings=meetings)):
recorder.start(str(self.path("meeting.wav")))
self.assertFalse(recorder.active)
return failures[0]
def test_a_system_that_records_the_far_side_sends_you_to_settings(self):
self.assertIn("Settings", self.failure(True))
def test_a_system_that_does_not_says_that_instead(self):
message = self.failure(False)
self.assertIn("nothing that records what the speakers", message)
self.assertNotIn("Settings", message)
def test_the_three_sound_systems_each_answer_the_question(self):
self.assertTrue(audio.PULSE.meetings)
self.assertTrue(audio.COREAUDIO.meetings)
self.assertFalse(audio.DSHOW.meetings)
class OnWindows:
"""A test that runs as if the machine ran Windows."""
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", "win32"))
class WindowsDevices(OnWindows, DikteTest):
"""The one ffmpeg listing the device questions are answered from.
dshow names devices rather than numbering them, and the names carry
whatever alphabet the machine speaks, so the listing here does too.
"""
MIC = "@device_cm_{33D9A762}\\wave_{B1C2}"
LISTING = (
'[dshow @ 0000020c] "Integrated Camera" (video)\n'
'[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n'
'[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n'
f'[dshow @ 0000020c] Alternative name "{MIC}"\n'
'[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n'
'[dshow @ 0000020c] Could not find audio only device with name '
'"dummy" among source devices of type audio.\n'
"dummy: Immediate exit requested\n"
).encode("utf-8")
def setUp(self):
super().setUp()
# The listing is remembered between calls, so that a dictation does not
# run ffmpeg of its own. It cannot be remembered between tests.
audio._DSHOW_SEEN.clear()
self.addCleanup(audio._DSHOW_SEEN.clear)
@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) as run:
yield run
def test_windows_records_through_dshow(self):
self.assertIs(audio.sound(), audio.DSHOW)
def test_the_audio_devices_are_the_only_ones_read(self):
with self.listing():
self.assertEqual(audio.list_sources(), [
(self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"),
("Kulaklık (Soundcore Life Q30)",
"Kulaklık (Soundcore Life Q30)"),
])
def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self):
"""The command ends by quoting the name it was sent to look for."""
with self.listing():
self.assertNotIn("dummy", [name for _, name in audio.list_sources()])
def test_a_listing_from_ffmpeg_8_which_renamed_the_prefix(self):
"""ffmpeg 8 writes `[in#0 @ ...]` where older builds wrote `[dshow @ ...]`."""
listing = (
'[in#0 @ 00000238c3300ac0] "Integrated Camera" (video)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_pnp_\\..."\n'
'[in#0 @ 00000238c3300ac0] "OBS Virtual Camera" (none)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_sw_{860B}"\n'
'[in#0 @ 00000238c3300ac0] "Mikrofon Dizisi (Intel® Smart Sound)" (audio)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_cm_{33D9}"\n'
"Error opening input file dummy.\n"
).encode("utf-8")
with self.listing(stderr=listing):
self.assertEqual(audio.list_sources(),
[("@device_cm_{33D9}",
"Mikrofon Dizisi (Intel® Smart Sound)")])
def test_a_listing_from_an_ffmpeg_that_marks_nothing(self):
"""Older builds print a heading instead of an (audio) on every line."""
listing = (
'[dshow @ 0] DirectShow video devices\n'
'[dshow @ 0] "Integrated Camera"\n'
'[dshow @ 0] Alternative name "@device_pnp_\\..."\n'
'[dshow @ 0] DirectShow audio devices\n'
'[dshow @ 0] "Microphone (Realtek Audio)"\n'
'[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n'
).encode("utf-8")
with self.listing(stderr=listing):
self.assertEqual(audio.list_sources(),
[("@device_cm_{ABCD}", "Microphone (Realtek Audio)")])
def test_two_devices_called_the_same_thing_stay_apart(self):
"""The normal state of a laptop with a headset plugged into it."""
listing = (
'[dshow @ 0] "Microphone" (audio)\n'
'[dshow @ 0] Alternative name "@device_cm_{ONE}"\n'
'[dshow @ 0] "Microphone" (audio)\n'
'[dshow @ 0] Alternative name "@device_cm_{TWO}"\n'
).encode("utf-8")
with self.listing(stderr=listing):
sources = audio.list_sources()
self.assertEqual([identifier for identifier, _ in sources],
["@device_cm_{ONE}", "@device_cm_{TWO}"])
self.assertEqual({name for _, name in sources}, {"Microphone"})
def test_no_ffmpeg_installed(self):
with only_these_tools():
self.assertEqual(audio.list_sources(), [])
self.assertEqual(audio.recording_command(), [])
def test_the_identifier_is_what_the_recorder_is_given_back(self):
with self.listing():
cmd = audio.recording_command(self.MIC)
self.assertEqual(cmd[cmd.index("-f") + 1], "dshow")
self.assertIn(f"audio={self.MIC}", cmd)
def test_no_microphone_named_means_the_first_one_listed(self):
"""dshow has no default device for an empty target to mean."""
with self.listing():
self.assertIn(f"audio={self.MIC}", audio.recording_command())
def test_a_dictation_does_not_run_a_listing_of_its_own(self):
"""Two hundred milliseconds of ffmpeg in front of every key press."""
with self.listing() as run:
audio.list_sources()
audio.recording_command()
audio.recording_command()
self.assertEqual(run.call_count, 1)
def test_opening_the_device_list_asks_again(self):
"""Which is what somebody who has just plugged one in does."""
with self.listing() as run:
audio.list_sources()
audio.list_sources()
self.assertEqual(run.call_count, 2)
def test_a_machine_with_no_microphone_at_all(self):
with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'):
self.assertEqual(audio.recording_command(), [])
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_nothing_offers_the_far_side_of_a_meeting(self):
"""What the speakers play is not a capture device Windows hands out."""
with self.listing():
self.assertEqual(audio.list_monitors(), [])
self.assertEqual(audio.default_monitor(), "")
self.assertEqual(audio.meeting_commands("mic", "sys"), [])
def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self):
recorder = audio.MeetingRecorder()
failures = []
recorder.failed.connect(failures.append)
with self.listing():
recorder.start(str(self.path("meeting.wav")))
self.assertIn("nothing that records what the speakers", failures[0])
self.assertFalse(recorder.active)
if __name__ == "__main__":
unittest.main()
+69 -1
View File
@@ -12,11 +12,14 @@ import json
import unittest
from unittest import mock
from dikte import audio
from dikte import cli
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import ipc
from tests.support import DikteTest, fake_urlopen
from dikte import paste
from tests.support import DikteTest, fake_urlopen, only_these_tools
class Options:
@@ -434,6 +437,30 @@ class Doctor(DikteTest):
self.assertIn("OpenRouter key, cleaning up on some/model",
self.run_doctor(as_json=False, cleanup_model="some/model"))
def test_it_asks_after_the_programs_this_desktop_actually_uses(self):
"""A missing ydotool on a Mac is a red mark with nothing behind it."""
with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS):
mac = self.run_doctor()["programs"]
with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND):
wayland = self.run_doctor()["programs"]
self.assertIn("pbcopy", mac)
self.assertNotIn("ydotool", mac)
self.assertIn("ydotool", wayland)
self.assertIn("ffmpeg", mac) # the one every system records through
def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self):
# shutil.which is faked as well as the platform: the real one reads
# sys.platform too, and reaches for a Windows API this machine has not
# got the moment it is told it is on Windows.
with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \
only_these_tools("ffmpeg"), \
mock.patch.object(cli.sys, "platform", "win32"):
programs = self.run_doctor()["programs"]
self.assertNotIn("", programs)
self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl",
"pw-record", "kwriteconfig6")
if name in programs], [])
def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
reply = self.run_doctor(cleanup_provider="codex",
cleanup_codex_model="gpt-5.4")
@@ -445,6 +472,25 @@ class Doctor(DikteTest):
cleanup_codex_model="gpt-5.4"))
class Devices(DikteTest):
def test_a_machine_with_nothing_names_its_own_missing_program(self):
"""The Windows README sends people here, and pactl is not on it."""
for here, expected in ((audio.DSHOW, "ffmpeg"),
(audio.PULSE, "pulseaudio-utils")):
with self.subTest(sound=expected):
with mock.patch.object(cli.audio, "sound", return_value=here), \
mock.patch.object(cli.audio, "list_sources",
return_value=[]), \
mock.patch.object(cli.audio, "list_monitors",
return_value=[]), \
mock.patch.object(cli.audio, "default_monitor",
return_value=""), \
captured() as (out, _err):
code = cli.cmd_devices(Options(json=True))
self.assertEqual(code, 1)
self.assertIn(expected, json.loads(out.getvalue())["error"])
class Finding(DikteTest):
def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last"))
@@ -623,5 +669,27 @@ class Replies(DikteTest):
self.assertFalse(launched.called)
class TranscribeRunsHere(DikteTest):
"""`dikte transcribe` runs in this process, not in the instance."""
def test_the_local_servers_are_handed_the_settings_first(self):
# The GUI does this at startup; a CLI run has no GUI to have done it,
# and without it the whisper server holds an empty model name.
wav = self.path("clip.wav")
wav.write_bytes(b"RIFF not really audio")
self.write_config({"local_model": "ggml-base.bin"})
self.addCleanup(ggml.whisper.configure,
model="", threads=0, gpu=True, binary="")
opts = cli.build_parser().parse_args(["transcribe", str(wav)])
with mock.patch.object(cli.filetranscribe, "FileTranscriber"), \
mock.patch.object(cli, "_headless",
return_value={"error": "stopped"}), \
captured():
cli.cmd_transcribe(opts)
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -8,6 +8,7 @@ config and now shadows the default.
import json
import os
import sys
import unittest
from unittest import mock
@@ -89,6 +90,8 @@ class Saving(DikteTest):
cfg.Config().save()
self.assertTrue(cfg.CONFIG_FILE.exists())
@unittest.skipIf(sys.platform == "win32",
"NTFS access is decided by ACLs, not by the mode bits")
def test_the_file_is_readable_by_nobody_else(self):
"""It holds two API keys."""
cfg.Config().save()
@@ -491,6 +494,9 @@ class ReadyToRun(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
# A machine Dikte is actually installed on would otherwise answer for
# the "missing program" below through the real install record.
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
def install(self, name):
path = ggml.whisper_model_path(name)
+94
View File
@@ -15,6 +15,7 @@ import tarfile
import textwrap
import threading
import time
import zipfile
from unittest import mock
from dikte import ggml
@@ -77,6 +78,15 @@ def tarball(entries):
return buf.getvalue()
def zipball(entries):
"""A .zip laid out the way the Windows releases are."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as bundle:
for name, content in entries.items():
bundle.writestr(name, content)
return buf.getvalue()
class Local(DikteTest):
"""A test with its own bin, models and cache directories."""
@@ -691,3 +701,87 @@ class Sizes(DikteTest):
self.assertEqual(ggml.human_size(512), "512 B")
self.assertEqual(ggml.human_size(574041195), "547.4 MB")
self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB")
# --- Windows ----------------------------------------------------------------
class WindowsAssets(Local):
"""Which archive a Windows machine is handed."""
def setUp(self):
super().setUp()
self.patch_attr(sys, "platform", "win32")
self.patch_attr(ggml, "_arch", lambda: "x64")
def test_whisper_prefers_the_blas_build(self):
# On a plain CPU it transcribes about twice as fast as the stock one.
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self):
self.patch_attr(ggml, "_has_vulkan", lambda: True)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-vulkan-x64.zip", "bin-win-cpu-x64.zip"))
def test_llama_falls_back_to_the_cpu_build_without_one(self):
self.patch_attr(ggml, "_has_vulkan", lambda: False)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-cpu-x64.zip",))
def test_an_arm_machine_is_not_handed_the_x64_build(self):
self.patch_attr(ggml, "_arch", lambda: "arm64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-cpu-arm64.zip",))
def test_an_arm_machine_is_handed_the_x64_whisper_anyway(self):
"""whisper.cpp publishes no arm64 build for Windows: the release has
Win32 and x64 and nothing else, so emulated is the only local option
a Snapdragon has. Pinned here so that a release which does start
publishing one is noticed rather than quietly ignored."""
self.patch_attr(ggml, "_arch", lambda: "arm64")
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
class InstallOnWindows(Local):
"""The Windows releases are zips, and the binary carries .exe."""
def setUp(self):
super().setUp()
self.patch_attr(sys, "platform", "win32")
self.patch_attr(ggml, "_arch", lambda: "x64")
self.archive = zipball({
"Release/whisper-server.exe": b"MZ not really a program",
"Release/whisper.dll": b"not really a library",
})
def release(self, *names):
digest = hashlib.sha256(self.archive)
return {"tag_name": "v1.9.1", "assets": [
{"name": name, "browser_download_url": f"https://example.invalid/{name}",
"size": 10, "digest": "sha256:" + digest.hexdigest()}
for name in names]}
def test_the_zip_lands_and_the_exe_inside_it_is_found(self):
with serving(self.release("whisper-blas-bin-x64.zip"), self.archive):
path = ggml.install_program(ggml.WHISPER)
self.assertTrue(path.endswith("whisper-server.exe"))
self.assertTrue(os.path.isfile(path))
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path),
"whisper.dll")))
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
def test_the_blas_build_is_the_one_fetched_when_both_are_offered(self):
listing = self.release("whisper-bin-x64.zip", "whisper-blas-bin-x64.zip")
with serving(listing, self.archive) as calls:
ggml.install_program(ggml.WHISPER)
urls = [call.args[0].full_url for call in calls.call_args_list]
self.assertTrue(urls[1].endswith("whisper-blas-bin-x64.zip"))
def test_a_release_with_nothing_for_windows_says_so(self):
with fake_urlopen(json_body(self.release("whisper-bin-ubuntu-x64.tar.gz"))):
with self.assertRaises(ggml.LocalError) as caught:
ggml.install_program(ggml.WHISPER)
self.assertIn("this machine", str(caught.exception))
+203
View File
@@ -2,10 +2,14 @@
import contextlib
import os
import queue
import subprocess
import time
import unittest
from unittest import mock
from PyQt6.QtCore import Qt
from dikte import config as cfg
from dikte import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only
@@ -753,5 +757,204 @@ class MacChooser(DikteTest):
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
# --- Windows ----------------------------------------------------------------
class ParseWindowsShortcut(unittest.TestCase):
def test_the_default(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Space"),
(hotkey.WIN_MODS["ctrl"], 0x20))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(hotkey.parse_windows_shortcut(" ctrl + SPACE "),
hotkey.parse_windows_shortcut("Ctrl+Space"))
def test_several_modifiers_are_one_number(self):
modifiers, key = hotkey.parse_windows_shortcut("Ctrl+Shift+M")
self.assertEqual(modifiers,
hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"])
self.assertEqual(key, hotkey.WIN_KEYS["m"])
def test_the_synonyms_land_on_one_number(self):
for name in ("meta", "super", "win"):
with self.subTest(name=name):
self.assertEqual(hotkey.parse_windows_shortcut(f"{name}+space"),
(hotkey.WIN_MODS["win"], 0x20))
self.assertEqual(hotkey.parse_windows_shortcut("Control+Space"),
hotkey.parse_windows_shortcut("Ctrl+Space"))
def test_a_key_on_its_own(self):
self.assertEqual(hotkey.parse_windows_shortcut("F9"),
(0, hotkey.WIN_KEYS["f9"]))
def test_modifiers_with_no_key(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Alt"), (None, None))
def test_a_key_nobody_mapped(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+F13"), (None, None))
def test_something_that_is_not_even_a_string(self):
self.assertEqual(hotkey.parse_windows_shortcut(None), (None, None))
class FakeWinHotkeys:
"""user32 and kernel32, as much of both as the listener calls.
The message queue is a real queue: GetMessageW blocks on it the way the
real one blocks on the thread's, so the listener runs its actual loop and
a test presses the key by posting the message a press would.
"""
def __init__(self):
self.registered = {} # identifier -> (modifiers, key)
self.refused = set() # (modifiers, key) another program holds
self.unregistered = []
self.queue = queue.Queue()
# --- user32
def RegisterHotKey(self, hwnd, identifier, modifiers, key):
if (modifiers & ~hotkey.WIN_MOD_NOREPEAT, key) in self.refused:
return 0
self.registered[identifier] = (modifiers, key)
return 1
def UnregisterHotKey(self, hwnd, identifier):
self.unregistered.append(identifier)
self.registered.pop(identifier, None)
return 1
def PeekMessageW(self, reference, hwnd, low, high, remove):
return 0
def GetMessageW(self, reference, hwnd, low, high):
kind, wparam = self.queue.get()
if kind == hotkey.WM_QUIT:
return 0
message = reference._obj
message.message = kind
message.wParam = wparam
return 1
def PostThreadMessageW(self, thread_id, message, wparam, lparam):
self.queue.put((message, wparam))
return 1
# --- kernel32
def GetCurrentThreadId(self):
return 1
# --- the keyboard
def press(self, identifier):
self.queue.put((hotkey.WM_HOTKEY, identifier))
class WinListener(DikteTest):
"""What the listener asks Windows for, without a Windows to ask."""
def setUp(self):
super().setUp()
self.api = FakeWinHotkeys()
self.patch_attr(hotkey, "_win_input", lambda: (self.api, self.api))
self.addCleanup(hotkey._REGISTERED.clear)
self.listener = hotkey.WinHotkey()
self.addCleanup(self.listener.stop)
self.failures = []
# Direct, because the emits come from the listener's own thread and
# there is no event loop here to carry a queued one across.
self.listener.failed.connect(self.failures.append,
Qt.ConnectionType.DirectConnection)
@staticmethod
def settles(seen, count=1):
"""The signals arrive from the listener's own thread, not this one."""
deadline = time.monotonic() + 2
while len(seen) < count and time.monotonic() < deadline:
time.sleep(0.01)
return seen
def test_every_binding_is_registered_with_its_modifiers(self):
self.assertTrue(self.listener.start({"toggle": "Ctrl+Space",
"cancel": "Ctrl+Shift+Space"}))
norepeat = hotkey.WIN_MOD_NOREPEAT
self.assertEqual(self.api.registered, {
1: (hotkey.WIN_MODS["ctrl"] | norepeat, 0x20),
2: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] | norepeat, 0x20),
})
def test_what_landed_is_what_the_status_line_shows(self):
self.listener.start({"toggle": "Ctrl+Space"})
self.assertEqual(hotkey._REGISTERED,
{hotkey.DESKTOP_ID: "Ctrl+Space"})
def test_a_press_arrives_under_the_name_it_was_registered_as(self):
seen = []
self.listener.triggered.connect(seen.append,
Qt.ConnectionType.DirectConnection)
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
self.api.press(2)
self.assertEqual(self.settles(seen), ["cancel"])
def test_a_held_combination_is_reported_and_the_rest_still_land(self):
self.api.refused = {(hotkey.WIN_MODS["ctrl"], 0x20)}
started = self.listener.start({"toggle": "Ctrl+Space",
"cancel": "Ctrl+Shift+Space"})
self.assertTrue(started)
self.assertIn("Ctrl+Space", self.settles(self.failures)[0])
self.assertEqual(list(self.api.registered), [2])
def test_an_unparsable_binding_is_reported(self):
self.assertFalse(self.listener.start({"toggle": "Ctrl+F13"}))
self.assertIn("Ctrl+F13", self.failures[0])
def test_nothing_but_empty_bindings_does_not_start(self):
self.assertFalse(self.listener.start({"toggle": "", "cancel": ""}))
self.assertFalse(self.listener.running)
def test_stop_lets_go_of_everything(self):
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
self.listener.stop()
self.assertEqual(self.api.registered, {})
self.assertEqual(hotkey._REGISTERED, {})
self.assertFalse(self.listener.running)
def test_a_second_start_is_a_clean_slate(self):
self.listener.start({"toggle": "Ctrl+Space"})
self.assertTrue(self.listener.start({"toggle": "Ctrl+Shift+Space"}))
self.assertEqual(self.api.registered,
{1: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"]
| hotkey.WIN_MOD_NOREPEAT, 0x20)})
class WindowsChooser(DikteTest):
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(hotkey.sys, "platform", "win32"))
self.addCleanup(hotkey._REGISTERED.clear)
def test_the_listener_is_the_windows_hotkey_service(self):
self.assertIsInstance(hotkey.listener(), hotkey.WinHotkey)
def test_a_combination_is_checked_against_the_windows_table(self):
self.assertTrue(hotkey.valid_shortcut("Ctrl+Space"))
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
def test_no_registry_to_write_into_and_no_restart_to_wait_for(self):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
self.assertEqual(hotkey.desktop_name(), "Windows")
def test_installing_records_it_rather_than_writing_anything(self):
with mock.patch.object(hotkey.subprocess, "run") as run:
ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
run.assert_not_called()
self.assertTrue(ok)
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
def test_no_list_of_conflicts_to_read(self):
"""Not even KDE's file, which a dual-boot home directory could hold."""
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -15,6 +15,7 @@ import unittest
from unittest import mock
from dikte import integrate
from tests.support import posix_only
class Frozen:
@@ -75,6 +76,7 @@ class WhatToStart(unittest.TestCase):
def test_a_checkout_names_this_interpreter_and_the_entry_point(self):
self.assertFalse(integrate.packaged())
@posix_only
def test_an_appimage_names_the_file_and_not_the_mount(self):
"""The mount is a fresh /tmp path every run; a shortcut written to it
would work until the next login and never again."""
@@ -83,6 +85,7 @@ class WhatToStart(unittest.TestCase):
self.assertEqual(str(integrate.target()),
"/home/someone/Downloads/Dikte.AppImage")
@posix_only
def test_a_mac_names_the_bundle_and_not_the_executable_inside_it(self):
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
platform="darwin"):
@@ -95,6 +98,7 @@ class WhatToStart(unittest.TestCase):
class BundledTools(unittest.TestCase):
"""The ffmpeg the disk image carries, and how anything finds it."""
@posix_only
def test_a_mac_looks_beside_the_bundle_not_beside_the_executable(self):
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
platform="darwin"):
@@ -213,6 +217,7 @@ class Certificates(unittest.TestCase):
self.assertIsNone(integrate.use_system_certificates())
@posix_only
class Linux(Home):
def install(self, appimage, force=False):
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
@@ -348,6 +353,7 @@ class Linux(Home):
self.assertEqual(integrate.ensure(), [])
@posix_only
class MacOS(Home):
def agent(self):
return self.home / "Library/LaunchAgents/io.github.yusufipk.dikte.plist"
+10 -4
View File
@@ -7,6 +7,8 @@ answers by saying nothing at all.
import json
import os
import pathlib
import shlex
import sys
import unittest
from unittest import mock
@@ -57,13 +59,17 @@ class FakeSocket:
class Paths(unittest.TestCase):
def test_script_path_points_at_dikte(self):
self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
# By its parts rather than as a string: the separator is a backslash on
# Windows, and the path is what a shortcut there runs too.
path = pathlib.Path(ipc.script_path())
self.assertEqual(path.parts[-2:], ("dikte", "__main__.py"))
self.assertTrue(os.path.exists(ipc.script_path()))
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
command = ipc.command_for("toggle")
self.assertTrue(command.startswith(sys.executable))
self.assertTrue(command.endswith(" toggle"))
# Read back through the same quoting it went out with: a Windows path
# is spelled with backslashes and comes out of the join quoted.
self.assertEqual(shlex.split(ipc.command_for("toggle")),
[sys.executable, ipc.script_path(), "toggle"])
def test_a_packaged_build_names_itself_and_no_interpreter(self):
"""There is no __main__.py on disk in one, and sys.executable is the
+148
View File
@@ -13,6 +13,7 @@ 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 ctypes
import os
import pathlib
import subprocess
@@ -56,6 +57,9 @@ class Chooser(DikteTest):
def test_a_mac(self):
self.assertIs(self.under("darwin"), paste.MACOS)
def test_windows(self):
self.assertIs(self.under("win32"), paste.WINDOWS)
def test_a_mac_running_an_x_server_is_still_a_mac(self):
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
@@ -465,5 +469,149 @@ class MacClipboardSnapshot(DikteTest):
self.assertFalse(os.path.exists(directory))
class FakeWin32:
"""user32 and kernel32, as much of both as paste.py calls.
The clipboard is a string held here. A read materialises it as this
machine's own wide characters, which is what wstring_at reads wherever the
test runs; a write arrives as the UTF-16 the real clipboard is handed, so
what the code sent is exactly what is checked.
"""
def __init__(self):
self.text = None
self.buffers = {}
self.next_handle = 1
self.pressed = [] # (virtual key, flags), in the order sent
self.send_result = None # None: report every event as delivered
self.held = False # another program has the clipboard open
self.out_of_memory = False
def _keep(self, buffer):
handle = self.next_handle
self.next_handle += 1
self.buffers[handle] = buffer
return handle
# --- user32
def OpenClipboard(self, owner):
return 0 if self.held else 1
def CloseClipboard(self):
return 1
def EmptyClipboard(self):
self.text = None
return 1
def GetClipboardData(self, fmt):
if self.text is None:
return 0
return self._keep(ctypes.create_unicode_buffer(self.text))
def SetClipboardData(self, fmt, handle):
raw = self.buffers[handle].raw
self.text = raw.decode("utf-16-le").split("\x00", 1)[0]
return handle
def SendInput(self, count, inputs, size):
self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags)
for entry in inputs)
return count if self.send_result is None else self.send_result
# --- kernel32
def GlobalAlloc(self, flags, size):
if self.out_of_memory:
return 0
return self._keep(ctypes.create_string_buffer(size))
def GlobalLock(self, handle):
buffer = self.buffers.get(handle)
return ctypes.addressof(buffer) if buffer else 0
def GlobalUnlock(self, handle):
return 1
def GlobalFree(self, handle):
self.buffers.pop(handle, None)
return 1
class Windows(Standing, DikteTest):
"""Windows shells out to nothing: both halves are calls into the system."""
platform = "win32"
here = paste.WINDOWS
def setUp(self):
super().setUp()
self.api = FakeWin32()
self.patch_attr(paste, "_win_api", lambda: (self.api, self.api))
self.patch_attr(paste.time, "sleep", lambda seconds: None)
def test_what_is_copied_is_what_reads_back(self):
paste.copy("ığüşöç İ")
self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8"))
def test_an_empty_clipboard_reads_as_empty_text(self):
self.assertEqual(paste.read_clipboard(), b"")
def test_what_was_saved_goes_back_after_the_paste(self):
paste.copy("mine")
saved = paste.read_clipboard()
paste.copy("the dictation")
paste.copy_bytes(saved)
self.assertEqual(self.api.text, "mine")
def test_a_copy_that_fails_leaves_what_was_there(self):
"""EmptyClipboard is the point of no return, so nothing runs after it."""
paste.copy("mine")
for failure in ("out_of_memory", "held"):
with self.subTest(failure=failure):
setattr(self.api, failure, True)
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.text, "mine")
setattr(self.api, failure, False)
def test_the_handle_is_not_leaked_when_the_copy_fails(self):
self.api.held = True
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.buffers, {})
def test_readiness_asks_for_no_program_and_no_permission(self):
with only_these_tools():
self.assertTrue(paste.paste_ready())
def test_the_keys_go_down_in_order_and_up_in_reverse(self):
paste.press("ctrl+v")
keyup = 0x0002
self.assertEqual(self.api.pressed,
[(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)])
def test_three_keys(self):
paste.press("ctrl+shift+v")
self.assertEqual([code for code, _ in self.api.pressed],
[0x11, 0x10, 0x56, 0x56, 0x10, 0x11])
def test_the_other_spellings_land_on_the_same_keys(self):
paste.press("super+enter")
first, self.api.pressed = self.api.pressed, []
paste.press("meta+return")
self.assertEqual(self.api.pressed, first)
def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self):
with self.assertRaises(paste.PasteError):
paste.press("ctrl+f13")
self.assertEqual(self.api.pressed, [])
def test_a_press_the_system_did_not_take_says_so(self):
self.api.send_result = 0
with self.assertRaises(paste.PasteError) as caught:
paste.press("ctrl+v")
self.assertIn("SendInput", str(caught.exception))
if __name__ == "__main__":
unittest.main()
+27 -6
View File
@@ -15,29 +15,50 @@ from dikte import paths
class Directories(unittest.TestCase):
"""Spelled with forward slashes throughout.
A backslash separates on Windows only, and every one of these runs on all
three systems: `as_posix()` is the one spelling they can all be read in.
"""
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 = paths.directories("linux")
self.assertEqual(str(config_dir), "/c/dikte")
self.assertEqual(str(data_dir), "/d/dikte")
self.assertEqual(config_dir.as_posix(), "/c/dikte")
self.assertEqual(data_dir.as_posix(), "/d/dikte")
def test_linux_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = paths.directories("linux")
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
self.assertTrue(config_dir.as_posix().endswith("/.config/dikte"))
self.assertTrue(data_dir.as_posix().endswith("/.local/share/dikte"))
def test_a_mac_keeps_both_in_application_support(self):
config_dir, data_dir = paths.directories("darwin")
self.assertEqual(config_dir, data_dir)
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
self.assertTrue(config_dir.as_posix()
.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, _ = paths.directories("darwin")
self.assertNotIn("/c", str(config_dir))
self.assertNotIn("/c", config_dir.as_posix())
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
"""Settings roam with the account; several gigabytes must not."""
with mock.patch.dict(os.environ, {"APPDATA": "C:/roam",
"LOCALAPPDATA": "C:/local"}):
config_dir, data_dir = paths.directories("win32")
self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte")
self.assertEqual(data_dir.as_posix(), "C:/local/Dikte")
def test_windows_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = paths.directories("win32")
self.assertTrue(config_dir.as_posix().endswith("/AppData/Roaming/Dikte"))
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
class OnePlace(unittest.TestCase):
+43 -3
View File
@@ -16,9 +16,12 @@ from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
from dikte import audio
from dikte import cleanup
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import ipc
from dikte import overlay as overlay_module
from dikte import paste
from dikte import settings_ui
@@ -281,7 +284,7 @@ class Settings(DikteTest):
text = self.shortcut_tab_text(window)
self.assertIn("i3 keeps no shortcut registry", text)
self.assertNotIn("KWin", text)
self.assertIn("__main__.py toggle", text)
self.assertIn(ipc.command_for("toggle"), text)
# Not a choice to offer where it is the only mechanism there is.
self.assertTrue(window.evdev_enabled.isHidden())
self.assertFalse([button for button in
@@ -458,7 +461,7 @@ class MacSettings(Settings):
text = self.shortcut_tab_text(window)
self.assertIn("Dikte asks macOS for these combinations", text)
self.assertNotIn("KWin", text)
self.assertNotIn("__main__.py toggle", text)
self.assertNotIn(ipc.command_for("toggle"), text)
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
@@ -482,7 +485,7 @@ class KdeSettings(Settings):
self.assertIn("KWin only reads shortcut settings at startup", text)
self.assertIn("Install as a KDE shortcut", text)
self.assertNotIn("keeps no shortcut registry", text)
self.assertNotIn("__main__.py toggle", text)
self.assertNotIn(ipc.command_for("toggle"), text)
# Here it is a choice: the wait for the next login, or the key press
# reaching the focused application as well.
self.assertFalse(window.evdev_enabled.isHidden())
@@ -610,6 +613,36 @@ class Overlay(DikteTest):
self.assertFalse(widget.muted)
class MeetingSources(DikteTest):
"""What the Meeting tab says about the far side, per sound system.
The box that picks it is empty on a system that cannot record it, and an
empty box with nothing next to it reads as a list that has not loaded yet.
"""
def notes(self, meetings):
with mock.patch.object(audio, "sound",
return_value=audio.PULSE._replace(
meetings=meetings)), \
only_these_tools(), \
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
mock.patch.object(settings_ui.SettingsWindow,
"_load_transcribe_models"):
window = settings_ui.SettingsWindow(cfg.Config())
self.addCleanup(window.deleteLater)
self.addCleanup(window.close)
return " ".join(label.text()
for label in window.findChildren(settings_ui.QLabel))
def test_a_system_that_cannot_record_the_far_side_says_so(self):
self.assertIn("nothing that records what the speakers",
self.notes(meetings=False))
def test_a_system_that_can_says_nothing_of_the_sort(self):
self.assertNotIn("nothing that records what the speakers",
self.notes(meetings=True))
if __name__ == "__main__":
unittest.main()
@@ -617,6 +650,13 @@ if __name__ == "__main__":
class LocalModels(DikteTest):
"""The download boxes, without a network and without either program."""
def setUp(self):
super().setUp()
# A machine Dikte is actually installed on would otherwise answer the
# "nothing can transcribe" question from its real binary and model.
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
def window(self, conf):
window = settings_ui.SettingsWindow(conf)
self.addCleanup(window.deleteLater)